← back to Grant

lib/federalRegister.ts

185 lines

/**
 * lib/federalRegister.ts
 *
 * Real-data client for the free, open Federal Register API
 * (https://www.federalregister.gov/api/v1/documents.json — no API key required).
 *
 * Replaces the prior AI-fabricated "news" path that asked an LLM to invent
 * "realistic" headlines, "plausible" URLs, and "plausible" journalist names.
 * Every item this returns is a real, published Federal Register document with a
 * verifiable html_url on federalregister.gov.
 */

const FR_BASE = 'https://www.federalregister.gov/api/v1/documents.json';

/** Shape of a single agency entry inside an FR document. */
interface FRAgency {
  raw_name?: string;
  name?: string;
}

/** Subset of an FR document we request via fields[]. */
interface FRDocument {
  title?: string;
  abstract?: string;
  publication_date?: string;
  html_url?: string;
  agencies?: FRAgency[];
  type?: string;
  document_number?: string;
}

interface FRResponse {
  count?: number;
  results?: FRDocument[];
}

/** The normalized news item shape the rest of the app (DB + frontend) consumes. */
export interface NewsItem {
  headline: string;
  outlet: string;
  url: string | null;
  published_at: string | null;
  summary: string | null;
  tags: string[];
  relevance_score: number;
  author_name: string | null;
  source_type: string;
}

export type FRFetchResult =
  | { ok: true; items: NewsItem[]; count: number; term: string | null }
  | { ok: false; reason: 'network' | 'http' | 'parse'; detail: string };

/** FR document type → the app's source_type taxonomy (kept frontend-compatible). */
function mapSourceType(frType?: string): string {
  switch ((frType || '').toLowerCase()) {
    case 'rule':
    case 'proposed rule':
      return 'policy_brief';
    case 'notice':
      return 'press_release';
    case 'presidential document':
      return 'industry';
    default:
      return 'google_news';
  }
}

/** Best human-readable agency name for the "outlet" slot. */
function primaryAgency(agencies?: FRAgency[]): string {
  if (!agencies || agencies.length === 0) return 'Federal Register';
  const a = agencies[0];
  return a.name || a.raw_name || 'Federal Register';
}

/**
 * Lightweight relevance score in [0,1]. The FR term-search already filters to
 * matching docs, so a real (non-fabricated) heuristic just nudges items whose
 * title/abstract actually contain the org's keywords higher. Defaults to 0.5
 * when no keywords are supplied.
 */
function scoreRelevance(doc: FRDocument, keywords: string[]): number {
  if (keywords.length === 0) return 0.5;
  const hay = `${doc.title || ''} ${doc.abstract || ''}`.toLowerCase();
  let hits = 0;
  for (const kw of keywords) {
    const k = kw.trim().toLowerCase();
    if (k && hay.includes(k)) hits++;
  }
  if (hits === 0) return 0.5;
  // 1 hit → 0.7, scaling up toward 0.95 with more keyword matches.
  return Math.min(0.95, 0.6 + 0.1 * hits + 0.05);
}

/** Build the tag list from the FR doc type + matched org keywords. */
function buildTags(doc: FRDocument, keywords: string[]): string[] {
  const tags = new Set<string>();
  if (doc.type) tags.add(doc.type);
  tags.add('Federal Register');
  const hay = `${doc.title || ''} ${doc.abstract || ''}`.toLowerCase();
  for (const kw of keywords) {
    const k = kw.trim();
    if (k && hay.includes(k.toLowerCase())) tags.add(k);
  }
  return Array.from(tags).slice(0, 5);
}

/**
 * Fetch real, recent Federal Register documents and map them into the app's
 * news-item shape.
 *
 * @param opts.keywords   org keywords; the first few drive an FR term search
 *                        and also feed relevance scoring + tagging.
 * @param opts.perPage    how many docs to request (1-100). Default 20.
 * @param opts.timeoutMs  network timeout. Default 15000.
 */
export async function fetchFederalRegisterNews(opts: {
  keywords?: string[];
  perPage?: number;
  timeoutMs?: number;
} = {}): Promise<FRFetchResult> {
  const keywords = (opts.keywords || []).filter((k) => typeof k === 'string' && k.trim());
  const perPage = Math.min(Math.max(opts.perPage ?? 20, 1), 100);
  const timeoutMs = opts.timeoutMs ?? 15000;

  // Use up to 4 keywords joined as a single OR-ish term search. The FR API
  // treats `conditions[term]` as a relevance full-text query.
  const term = keywords.length > 0 ? keywords.slice(0, 4).join(' ') : null;

  const params = new URLSearchParams();
  params.append('conditions[type][]', 'RULE');
  params.append('conditions[type][]', 'NOTICE');
  params.append('conditions[type][]', 'PRORULE');
  if (term) params.append('conditions[term]', term);
  params.append('per_page', String(perPage));
  params.append('order', term ? 'relevance' : 'newest');
  for (const f of ['title', 'abstract', 'publication_date', 'html_url', 'agencies', 'type', 'document_number']) {
    params.append('fields[]', f);
  }

  const url = `${FR_BASE}?${params.toString()}`;

  let res: Response;
  const controller = new AbortController();
  const timer = setTimeout(() => controller.abort(), timeoutMs);
  try {
    res = await fetch(url, {
      signal: controller.signal,
      headers: { Accept: 'application/json', 'User-Agent': 'Grant-Nonprofit-NewsMonitor/1.0' },
    });
  } catch (err) {
    clearTimeout(timer);
    return { ok: false, reason: 'network', detail: (err as Error).message };
  }
  clearTimeout(timer);

  if (!res.ok) {
    return { ok: false, reason: 'http', detail: `Federal Register API returned ${res.status}` };
  }

  let body: FRResponse;
  try {
    body = (await res.json()) as FRResponse;
  } catch (err) {
    return { ok: false, reason: 'parse', detail: (err as Error).message };
  }

  const results = Array.isArray(body.results) ? body.results : [];
  const items: NewsItem[] = results
    .filter((d) => d.title && d.html_url)
    .map((d) => ({
      headline: d.title!.trim(),
      outlet: primaryAgency(d.agencies),
      url: d.html_url || null,
      published_at: d.publication_date || null,
      summary: d.abstract?.trim() || null,
      tags: buildTags(d, keywords),
      relevance_score: scoreRelevance(d, keywords),
      author_name: 'Federal Register', // source attribution; FR docs have no byline
      source_type: mapSourceType(d.type),
    }));

  return { ok: true, items, count: body.count ?? items.length, term };
}