Monitor FDA recall (enforcement) reports across drug, device and food. One normalized record per recall (number, Class I/II/III, status, firm, product, reason, dates), with type/class filters, a new-since-last-run mode, and change-detection flagging classification escalations. openFDA, CC0.
docs/LISTING.md — Apify Store listing draft (title, short description, tags, SEO keywords, full
Markdown description, example input, publish-asset checklist), mirroring the USGS / Federal Register
listings. Headline value = watch drug+device+food recalls in one normalized feed + first-seen /
change-detection (Class III→II→I escalations, terminations). API-churn caveats kept out of
customer-facing copy.
docs/PRICING.md — PPE pricing proposal for the single recall-returned event (verified emitted
in src/main.js; _noData/_error uncharged). Documents the model, rejected alternatives
(per-run, tiered class/escalation events, per-CU), and the price-band framing (recalls are
higher-value + rarer than a sensor reading → intended ≈ 2× a USGS reading-returned, per
BUILD_QUEUE.md §2). Price VALUES remain owner-deferred — no on-platform pricing set.
Launch-ready: tests green (node --test = 45 pass / 0 fail, no network) + COMPLIANCE ✓ + LISTING +
PRICING. Publish stays owner-gated (no apify push, no Store publish, no monetization).
0.1 — 2026-07-10 — slice 5: COMPLIANCE.md
COMPLIANCE.md — mirrors the USGS structure (drops the sunset/migration section; openFDA has no
announced decommission). Cites the CC0 1.0 Universal public-domain dedication verbatim from
open.fda.gov/license/ and the api.fda.gov/robots.txt = HTTP 404 result, both copied from
BUILD_QUEUE.md §2 (offline — no re-fetch). Rate-limit terms tabled (240 req/min · 1000/day per IP;
optional free api_key → 120,000/day). Verdict: PERMITTED (CC0 1.0 = no attribution required,
commercial use explicit).
test/ — Node built-in node --test suite over src/openfda.js + src/filters.js (never imports
main.js), zero dependencies, zero network. fetchJson driven by injected
fetchImpl/sleep/onRetry; .status/.body asserted via then/catch capture; URL assertions
parse new URL(...).searchParams. Covers URL building, backoff/retry, 404-no-match detection,
normalize folding of all three schemas + date parsing, dedupe, classification/status filters,
new-only watermark, change-detection (escalation / status / escalation-wins / de-escalation
not flagged), field projection, and the total cap. 45 tests, all pass.
filterByClassification / filterByStatus — case-insensitive client-side narrowing (empty = all;
null value dropped while a filter is active).
filterNewOnly(records, seenSet) — return only unseen recall_numbers, tag isNew, advance the set.
detectChanges(records, priorState) — compare classification (via CLASSIFICATION_RANK) + status
vs the caller-owned prior-state map; attach a single change object on escalation (Class III→II→I,
which wins over a co-occurring status change) or status change; always advance the stored state.
applyFieldProjection(records, fields) — optional client-side field narrowing (empty = passthrough)
that always preserves category/isNew/change; only projects fields the normalizer emits.
applyMaxResults(records, maxResults) — total cap (0 = no cap).
Wired src/main.js runtime (replaces the slice-1 stub): read input → select categories (empty = all
three) → per-category buildEnforcementUrl (trailing 7-day window when reportedSince unset; window
ignored under monitorNewOnly) → fetchJson (backoff; per-category isNoMatchesError 404 → skip, not
fail) → normalizeRecords → dedupeByRecallNumber → classification/status filters → filterNewOnly
(if monitorNewOnly) → detectChanges (default on) → applyFieldProjection → applyMaxResults →
charge one recall-returned PPE event per pushed record. _noData/_error control records carry
the filters summary and are not charged. KV store persists SEEN (new-only watermark) +
PRIOR_STATE (change-detection baseline) only after a fully successful, non-charge-capped pass.
Verified offline (no network): filter/dedupe/change-detection assertions (escalation, status change,
both→escalation-wins, de-escalation not flagged, null-number handling, field projection, cap) and the
URL-builder integration shape (windowed vs new-only) all pass; all three src/*.jsnode --check clean.
Formal node --test mocked-HTTP suite lands in slice 4.
Implemented src/openfda.js (replaces the slice-1 stub):
buildEnforcementUrl(category, { reportedSince, today, limit, skip, apiKey }) — per-endpoint URL
builder with search=report_date:[YYYYMMDD TO YYYYMMDD] (both bounds required — openFDA has no
half-open range), sort=report_date:desc, limit clamp 1–1000, optional skip and api_key.
parseOpenFdaDate('YYYYMMDD') → 'YYYY-MM-DD' | null and toCompactDate('YYYY-MM-DD'|'YYYYMMDD').
fetchJson(url, { fetchImpl, sleep, onRetry, dispatcher, retries }) — injectable fetch with
deterministic exponential backoff (backoffDelayMs, cap 8000 ms) on 5xx/429/transport errors;
4xx (incl. openFDA's 404 "no matches") not retried, carries .status/.body.
isNoMatchesError(err) — detects openFDA's zero-result 404 (NOT_FOUND) so a quiet window can be
treated as _noData (green) downstream rather than _error.
normalizeRecords(json, { category }) — folds results[] from all three endpoints into the one
documented record shape, defensively (missing → null), parsing the four YYYYMMDD date fields.
Verified offline (no network) via an ad-hoc smoke harness: URL construction, bad-category throw,
limit clamp/skip, date parse edge cases, normalize skips non-object rows, backoff cap, retry-then-
success, and 404-no-match detection all pass. Formal node --test suite lands in slice 4.
0.1 — 2026-07-10 — slice 1: scaffold
Scaffolded from the usgs-water-monitor / federal-register-monitor reference implementations
(structure, patterns, PPE model).
Fetch-based Apify Actor (no browser): apify@^3.2.6 only, apify/actor-node:20 base, 256–512 MB.