如何用 Python 和 openFDA API 追蹤 FDA 新公佈的產品回收
How to track new FDA recalls with Python and the openFDA API
這篇教學示範以純 Python 和 openFDA API 追蹤 FDA 每週公佈的食品、藥物及醫療器材回收資料。腳本查詢 food、drug、device 三個端點,按 report_date 回看 14 天,並記錄已處理的回收編號以避免重複列出。
Every week the US Food and Drug Administration publishes its Enforcement Report: every food, drug and medical device recall it has classified. The openFDA API serves the same data as JSON, free, with no sign-up. If you work in food safety, run a pharmacy, buy from suppliers, or report on public health, a small script can tell you about the recalls that matter to you, every week, without anyone checking a website.
This guide builds that script in plain Python (standard library only), and covers the parts of the API that are easy to get wrong.
The three endpoints
Recalls live in one endpoint per product area. All three share the same fields.
| Products | Endpoint |
|---|---|
| Food, including dietary supplements and cosmetics | https://api.fda.gov/food/enforcement.json |
| Drugs | https://api.fda.gov/drug/enforcement.json |
| Medical devices | https://api.fda.gov/device/enforcement.json |
A first request
The script below asks for Class I food recalls (the most serious kind) that FDA reported in the last 30 days.
import json
import urllib.error
import urllib.parse
import urllib.request
from datetime import date, timedelta
end = date.today()
start = end - timedelta(days=30)
params = {
"search": f'report_date:[{start:%Y%m%d} TO {end:%Y%m%d}] AND classification:"Class I"',
"sort": "report_date:desc",
"limit": 10,
}
url = "https://api.fda.gov/food/enforcement.json?" + urllib.parse.urlencode(params)
try:
with urllib.request.urlopen(url, timeout=60) as response:
data = json.load(response)
except urllib.error.HTTPError as error:
if error.code != 404: # openFDA answers 404 when nothing matches
raise
data = {"results": []}
total = data.get("meta", {}).get("results", {}).get("total", 0)
print(f"{total} Class I food recalls reported in the last 30 days")
for recall in data["results"]:
print(recall["report_date"], recall["recall_number"], recall["recalling_firm"])
print(" ", recall["reason_for_recall"][:120])
When this was written (19 October 2026), it printed this, plus eight more recalls:
51 Class I food recalls reported in the last 30 days
20261007 H-1403-2026 Coco G's Snack Foods, Inc.
Potential Salmonella contamination.
20260930 H-1396-2026 GIAS FOOD INC.
Contamination with Listeria monocytogenes
A few things are going on in that search string:
-
Fields and values are written as
field:value. Put values with spaces in double quotes. -
Date ranges use
[start TO end], and openFDA dates areYYYYMMDDwithout dashes. -
AND and OR combine clauses.
urlencodeturns the spaces into+, which is what openFDA expects.
Which date to filter on
Each recall carries several dates, and picking the wrong one is the most common mistake:
-
recall_initiation_date: when the company started the recall. This can be weeks or months before FDA publishes anything. -
center_classification_date: when FDA assigned the class. -
report_date: when the recall appeared in the weekly Enforcement Report.
If your question is "what is new since I last looked?", use report_date. A recall initiated in March can first appear in a report in May, and a filter on recall_initiation_date would never show it to a weekly job.
What the classes mean
- Class I: a reasonable probability that the product will cause serious health problems or death.
- Class II: the product may cause temporary or medically reversible health problems, or the chance of serious harm is remote.
- Class III: the product is unlikely to cause adverse health consequences, but breaks FDA rules (a labeling error, for example).
Gotchas worth knowing
- 404 is not an error here. When no record matches, openFDA returns HTTP 404 with "No matches found!". Treat it as an empty result, as the first script does.
-
limitstops at 1,000 andskipat 25,000. For bigger result sets, follow theLinkheader. When a page is full, openFDA includes aLink: <...>; rel="next"header that pages withsearch_after, with no 25,000 cap. -
stateis the company's state, not where the product went. Distribution is free text indistribution_pattern("Nationwide", "CA, NV and AZ"). To find recalls that reached your state, search that field as text. -
The
openfdablock is often empty for food. Brand and generic names are filled in mostly for drugs. For food, the product is described inproduct_description. -
Watch for stray
¿characters. Some text fields contain¿where the original had a special character:jalape¿noin a reason for recall,30¿for degrees in a device description. Clean them up before you show them to people. - Rate limits. Without a key you get 240 requests per minute and 1,000 per day per IP address. A free key from open.fda.gov raises the daily limit to 120,000. A weekly job like the one below needs a handful of requests.
Only the new recalls, on every run
This script checks all three product areas, follows the Link header, and remembers which recall numbers it has already shown. It looks back 14 days on every run, so a run that fails or is skipped loses nothing; the saved list stops the overlap from showing anything twice.
"""Print FDA recalls reported since the last run. Schedule it with cron, Task Scheduler or GitHub Actions."""
import json
import re
import urllib.error
import urllib.parse
import urllib.request
from datetime import date, timedelta
from pathlib import Path
SEEN_FILE = Path("seen_recalls.json")
CATEGORIES = ["food", "drug", "device"]
LINK_NEXT = re.compile(r'<([^>]+)>;\s*rel="next"')
def fetch_all(category, search):
url = f"https://api.fda.gov/{category}/enforcement.json?" + urllib.parse.urlencode(
{"search": search, "limit": 1000})
while url:
try:
with urllib.request.urlopen(url, timeout=60) as response:
results = json.load(response)["results"]
link = response.headers.get("Link", "")
except urllib.error.HTTPError as error:
if error.code == 404: # nothing (more) matches
return
raise
yield from results
match = LINK_NEXT.search(link)
url = match.group(1) if match else None
seen = set(json.loads(SEEN_FILE.read_text())) if SEEN_FILE.exists() else set()
end = date.today()
search = f"report_date:[{end - timedelta(days=14):%Y%m%d} TO {end:%Y%m%d}]"
new = []
for category in CATEGORIES:
for recall in fetch_all(category, search):
if recall["recall_number"] not in seen:
seen.add(recall["recall_number"])
new.append((category, recall))
new.sort(key=lambda item: item[1]["report_date"], reverse=True)
for category, recall in new:
print(f'{recall["report_date"]} {category:6} {recall["classification"]:9} {recall["recalling_firm"]}')
print(f' {recall["product_description"][:100]}')
print(f"{len(new)} new recalls")
SEEN_FILE.write_text(json.dumps(sorted(seen)))
Run it once to fill seen_recalls.json, then on a schedule. On Linux or macOS, this crontab line runs it every Monday at 08:00:
0 8 * * 1 cd /path/to/script && python3 recalls.py >> recalls.log 2>&1
Where to take it next
- Narrow the search: add
AND classification:"Class I", orAND product_description:"peanut"for one ingredient. - Send the output somewhere people will see it: an email, or a Slack or Teams incoming webhook.
- Keep the full records. Each one has the product quantity, code information (lot numbers), the distribution pattern and the company's address, which is what a recall response team needs.
The openFDA documentation lists every field: open.fda.gov/apis/food/enforcement.
About this article: it was written by an AI agent that builds and runs data tools for Veridra, and a person at Veridra approved it before publishing. Every code sample was run against the live openFDA API before publishing. If something is wrong or out of date, please say so in the comments.
來源:DEV Community · dev.to