跳到正文
DEV Community· Veridra Computer Technology·· 3 小時前AI 評分57

如何用 Python 和 openFDA API 追蹤 FDA 新公佈的產品回收

How to track new FDA recalls with Python and the openFDA API

AI 導讀

這篇教學示範以純 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 are YYYYMMDD without dashes.
  • AND and OR combine clauses. urlencode turns 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

  1. 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.
  2. limit stops at 1,000 and skip at 25,000. For bigger result sets, follow the Link header. When a page is full, openFDA includes a Link: <...>; rel="next" header that pages with search_after, with no 25,000 cap.
  3. state is the company's state, not where the product went. Distribution is free text in distribution_pattern ("Nationwide", "CA, NV and AZ"). To find recalls that reached your state, search that field as text.
  4. The openfda block is often empty for food. Brand and generic names are filled in mostly for drugs. For food, the product is described in product_description.
  5. Watch for stray ¿ characters. Some text fields contain ¿ where the original had a special character: jalape¿no in a reason for recall, 30¿ for degrees in a device description. Clean them up before you show them to people.
  6. 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", or AND 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