← back to AbramsOS

lib/cpsc-fetcher.js

60 lines

// CPSC Recall API client.
// Public, unauthenticated. Docs: https://www.cpsc.gov/Recalls/CPSC-Recalls-Application-Programming-Interface-API
// Endpoint: https://www.saferproducts.gov/RestWebServices/Recall
// Filters: RecallDateStart, RecallDateEnd, RecallNumber, RecallTitle, RecallURL, etc.

const BASE = 'https://www.saferproducts.gov/RestWebServices/Recall';

async function fetchRecalls({ since = null, until = null, format = 'json', timeoutMs = 30_000 } = {}) {
  const params = new URLSearchParams({ format });
  if (since) params.set('RecallDateStart', toCpscDate(since));
  if (until) params.set('RecallDateEnd', toCpscDate(until));
  const url = `${BASE}?${params.toString()}`;
  const ctrl = new AbortController();
  const t = setTimeout(() => ctrl.abort(), timeoutMs);
  try {
    const r = await fetch(url, { signal: ctrl.signal });
    if (!r.ok) throw new Error(`CPSC ${r.status}: ${url}`);
    return r.json();
  } finally {
    clearTimeout(t);
  }
}

function toCpscDate(d) {
  if (typeof d === 'string') return d;
  return d.toISOString().slice(0, 10); // YYYY-MM-DD
}

// Normalize one CPSC recall into our recall_event shape
function normalize(raw) {
  // CPSC API field names use Pascal case
  const products = raw.Products || [];
  const manufacturers = raw.Manufacturers || [];
  const hazards = raw.Hazards || [];
  const remedies = raw.Remedies || [];
  const images = raw.Images || [];

  const product_keys = {
    models: products.flatMap(p => [p.Name, p.Model].filter(Boolean)),
    brand: manufacturers[0]?.Name || null,
    descriptions: products.map(p => p.Description).filter(Boolean),
    upcs: products.map(p => p.UPC).filter(Boolean),
    image_urls: images.map(i => i.URL).filter(Boolean),
  };

  return {
    authority: 'CPSC',
    external_id: String(raw.RecallNumber || raw.RecallID),
    published_at: raw.RecallDate ? new Date(raw.RecallDate) : null,
    title: raw.Title || raw.RecallTitle || null,
    hazard: hazards.map(h => h.Name).filter(Boolean).join('; ') || null,
    remedy: remedies.map(r => r.Name).filter(Boolean).join('; ') || null,
    url: raw.URL || (raw.RecallNumber ? `https://www.cpsc.gov/Recalls/${raw.RecallNumber}` : null),
    product_keys,
    raw,
  };
}

module.exports = { fetchRecalls, normalize };