Files
grant-outreach-engine/apps/outreach-worker/src/workflows/ingest-pnd-rss.ts
Croissant Le Doux 1735ff6754 feat(ingestion): Phase 1 data spine — Grants.gov, PND RSS, NHDOJ registry, ProPublica enrichment
Core: icpBandForRevenue (100K-5M primary), serverUpsertOrgFromRegistry
(case-insensitive name/city/state key, new-registrant signal),
serverEnrichOrg (IRS fields + re-band, all-null marks attempted),
serverListOrgsNeedingEnrichment.

Worker: four source verticals, each a thin fail-loud client + pure
tested normalize layer + DBOS scheduled workflow:
- ingestGrants (nightly): Search2 paginated (cap 1000) -> fetchOpportunity
  details (cap 200, logged drops, 250ms politeness) -> batched upsert
- ingestPndRss (nightly): RSS via fast-xml-parser, heuristic funder/
  deadline extraction, link-keyed upsert
- ingestNhdojOrgs (monthly): pdfjs-dist positioned-text extraction, pure
  row reconstruction (multi-line names, inferred columns, fail-loud on
  layout change), registry upsert; no-op warn when PDF URL unset
- enrichOrgs (daily): ProPublica search -> conservative name/city match
  (null beats guess) -> latest-filing revenue/NTEE/FYE, per-org steps for
  checkpointed resume, >20% batch failure rethrows

94 worker + 16 core + 5 ai tests green; docs/features/ingestion.md added.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 13:11:50 -04:00

114 lines
3.6 KiB
TypeScript

/**
* Nightly Philanthropy News Digest RFP-feed ingestion workflow.
*
* Pulls the PND "RFPs" RSS feed, normalizes each item into a grant row, and
* upserts them into the outreach schema. Follows the same
* registration/deps-injection pattern as `ingest-grants.ts` (see that
* module's doc comment for the full rationale): the scheduled function must
* ALSO be registered as a plain workflow (both registrations referencing the
* same function object), and deps are pulled from a module-scope registry
* populated before `DBOS.launch()` — DBOS serializes workflow args, so
* closures/functions can't cross that boundary.
*/
import { DBOS, SchedulerMode } from '@dbos-inc/dbos-sdk';
import type { schema } from '@novelpad/outreach-core';
import {
serverInsertGrants,
type NewGrantInput,
} from '@novelpad/outreach-core/server';
import type { NodePgDatabase } from 'drizzle-orm/node-postgres';
import { fetchPndRfpFeed, PND_RFP_FEED_URL } from '#~/sources/pnd-rss/client.js';
import { parsePndFeed } from '#~/sources/pnd-rss/normalize.js';
export type OutreachDb = NodePgDatabase<typeof schema>;
export interface IngestPndRssDeps {
readonly db: OutreachDb;
}
let registeredDeps: IngestPndRssDeps | null = null;
export function setIngestPndRssDeps(deps: IngestPndRssDeps): void {
registeredDeps = deps;
}
function getIngestPndRssDeps(): IngestPndRssDeps {
if (registeredDeps == null) {
throw new Error(
'IngestPndRssDeps not registered. Call setIngestPndRssDeps() before DBOS.launch().',
);
}
return registeredDeps;
}
/** Fetches the raw PND RFP feed XML, reading the override env var at call time. */
async function fetchPndFeed(): Promise<string> {
const feedUrl = process.env.PND_RFP_FEED_URL ?? PND_RFP_FEED_URL;
return fetchPndRfpFeed(feedUrl);
}
const fetchPndFeedStep = DBOS.registerStep(fetchPndFeed, {
name: 'fetchPndFeed',
retriesAllowed: true,
maxAttempts: 3,
});
// Upsert in bounded batches so a large feed doesn't land as one oversized
// `INSERT ... ON CONFLICT` statement.
const UPSERT_BATCH_SIZE = 100;
async function upsertPndGrants(
db: OutreachDb,
grants: ReadonlyArray<NewGrantInput>,
): Promise<number> {
if (grants.length === 0) return 0;
for (let i = 0; i < grants.length; i += UPSERT_BATCH_SIZE) {
const batch = grants.slice(i, i + UPSERT_BATCH_SIZE);
await serverInsertGrants(db, batch);
}
return grants.length;
}
const upsertPndGrantsStep = DBOS.registerStep(upsertPndGrants, {
name: 'upsertPndGrants',
retriesAllowed: true,
maxAttempts: 3,
});
async function runIngestPndRss(): Promise<void> {
const { db } = getIngestPndRssDeps();
const xml = await fetchPndFeedStep();
const grants = parsePndFeed(xml);
const upserted = await upsertPndGrantsStep(db, grants);
console.log(
`[ingest-pnd-rss] parsed ${grants.length} item(s), upserted ${upserted} grant(s)`,
);
}
const g = globalThis as unknown as {
__outreachIngestPndRssRegistered?: boolean;
};
if (!g.__outreachIngestPndRssRegistered) {
g.__outreachIngestPndRssRegistered = true;
const ingestPndRss = async (_scheduledTime: Date, _startedAt: Date) => {
try {
await runIngestPndRss();
} catch (err) {
console.error('[ingest-pnd-rss] pass failed:', err);
throw err;
}
};
// Must be registered as BOTH a workflow and a scheduled function,
// referencing the same function object — see module doc comment.
DBOS.registerWorkflow(ingestPndRss, { name: 'ingestPndRss' });
DBOS.registerScheduled(ingestPndRss, {
crontab: '30 3 * * *',
name: 'ingestPndRss',
mode: SchedulerMode.ExactlyOncePerInterval,
});
}