import { sha256, simhash, type ConnectorMetadata, type NormalizedContent, type Observation, type SensorEndpoint } from "@websensor/core"; import { httpFetchWithRetry } from "./fetcher"; import { NormalizeError, type WebSensorConnector } from "./types"; /** * SEC EDGAR connector — company submissions API (`https://data.sec.gov/submissions/CIK##########.json`). * The API is columnar (`filings.recent.form[]`, `filings.recent.filingDate[]`…), which the generic * `jsonlist` connector cannot read; this connector rebuilds one item per filing keyed by accession * number, so a new 8-K / 10-Q / S-1 / 13D becomes a `filing` list event with a direct document link. * * Rules of the road (sec.gov/developer): a descriptive User-Agent with contact e-mail is mandatory, * ≤ 10 requests/second overall. The engine's per-host concurrency (2) plus tier B/C intervals keep us * far below that. * * Config: { forms?: ["8-K", "10-K", "10-Q", "S-1", "6-K", "SC 13D", "DEF 14A"], excludeForms?: ["4", "144", "3", "5"], maxItems?: 60 } * Default: all forms except insider forms 3/4/5 and 144 notices (noise). */ const DEFAULT_EXCLUDE = new Set(["3", "4", "5", "3/A", "4/A", "5/A", "144", "144/A"]); const UA = process.env.WS_EDGAR_USER_AGENT ?? "WebSensor (contact@websensor.io)"; const FORM_LABEL: Record = { "8-K": "Current report", "10-K": "Annual report", "10-Q": "Quarterly report", "20-F": "Annual report (foreign issuer)", "40-F": "Annual report (Canadian issuer)", "6-K": "Report of foreign issuer", "S-1": "Registration statement (IPO)", "S-3": "Shelf registration", "S-4": "Merger registration", "S-8": "Employee plan registration", "424B4": "Prospectus (final)", "424B5": "Prospectus supplement", "DEF 14A": "Proxy statement", "DEFA14A": "Additional proxy materials", "SC 13D": "Beneficial ownership >5% (active)", "SC 13G": "Beneficial ownership >5% (passive)", "13F-HR": "Institutional holdings", "SD": "Specialized disclosure", "11-K": "Employee plan annual report", "NT 10-K": "Late filing notice (10-K)", "NT 10-Q": "Late filing notice (10-Q)", "25-NSE": "Delisting notice", "8-K/A": "Current report (amended)", "10-K/A": "Annual report (amended)", "10-Q/A": "Quarterly report (amended)", }; /** 8-K item numbers → human labels (Regulation S-K). */ const ITEM_LABEL: Record = { "1.01": "Entry into a material agreement", "1.02": "Termination of a material agreement", "1.03": "Bankruptcy or receivership", "1.05": "Material cybersecurity incident", "2.01": "Completion of acquisition or disposition", "2.02": "Results of operations and financial condition", "2.03": "Creation of a direct financial obligation", "2.04": "Triggering events (acceleration of obligation)", "2.05": "Costs associated with exit or disposal activities", "2.06": "Material impairments", "3.01": "Delisting notice or failure to satisfy listing rule", "3.02": "Unregistered sales of equity securities", "3.03": "Material modification to rights of security holders", "4.01": "Changes in registrant's certifying accountant", "4.02": "Non-reliance on previously issued financial statements (restatement)", "5.01": "Changes in control of registrant", "5.02": "Departure/appointment of directors or officers; compensation", "5.03": "Amendments to articles/bylaws; change in fiscal year", "5.07": "Submission of matters to a vote of security holders", "7.01": "Regulation FD disclosure", "8.01": "Other events", "9.01": "Financial statements and exhibits", }; export class EdgarConnector implements WebSensorConnector { mode = "list" as const; metadata(): ConnectorMetadata { return { key: "edgar", name: "SEC EDGAR filings", sensorTypes: ["REST_API", "JSON"], description: "Company submissions (data.sec.gov) → filings list keyed by accession number, 8-K items decoded", version: "1.0.0" }; } async fetch(endpoint: SensorEndpoint): Promise { const obs = await httpFetchWithRetry(endpoint.id, endpoint.url, { etag: endpoint.etag, lastModified: endpoint.lastModified, accept: "application/json", headers: { "user-agent": UA, host: "data.sec.gov" }, userAgent: UA, timeoutMs: 40_000, maxBytes: 30 * 1024 * 1024 }); return obs; } async normalize(endpoint: SensorEndpoint, obs: Observation): Promise { if (!obs.body) throw new NormalizeError("no_body", "Observation has no body"); const text = obs.body.toString("utf8"); let j: Record; try { j = JSON.parse(text) as Record; } catch { throw new NormalizeError("bad_json", "EDGAR response is not JSON"); } const recent = ((j.filings as Record | undefined)?.recent ?? {}) as Record; const forms = (recent.form ?? []) as string[]; if (!Array.isArray(forms)) throw new NormalizeError("bad_shape", "filings.recent.form missing"); const cfg = endpoint.config as { forms?: string[]; excludeForms?: string[]; maxItems?: number }; const include = cfg.forms ? new Set(cfg.forms.map((f) => f.toUpperCase())) : null; const exclude = new Set((cfg.excludeForms ?? [...DEFAULT_EXCLUDE]).map((f) => f.toUpperCase())); const cik = String(j.cik ?? "").padStart(10, "0"); const cikNum = String(Number(cik)); const company = String(j.name ?? ""); const tickers = (j.tickers ?? []) as string[]; const col = (k: string, i: number): string => String((recent[k] as unknown[] | undefined)?.[i] ?? ""); const items: { key: string; [k: string]: unknown }[] = []; for (let i = 0; i < forms.length && items.length < (cfg.maxItems ?? 60); i++) { const form = forms[i]!.toUpperCase(); if (exclude.has(form)) continue; if (include && !include.has(form)) continue; const acc = col("accessionNumber", i); const accPath = acc.replace(/-/g, ""); const doc = col("primaryDocument", i); const itemsRaw = col("items", i); const itemLabels = itemsRaw .split(",") .map((s) => s.trim()) .filter(Boolean) .map((n) => `${n} ${ITEM_LABEL[n] ?? ""}`.trim()); const filingDate = col("filingDate", i); const accepted = col("acceptanceDateTime", i); const desc = col("primaryDocDescription", i); const label = FORM_LABEL[form] ?? form; const title = `${company}: ${form} — ${label}${itemLabels.length ? ` (${itemLabels.map((l) => l.split(" ")[0]).join(", ")})` : ""}`; const summary = [desc && desc !== form ? desc : "", itemLabels.length ? `Items: ${itemLabels.join("; ")}` : "", col("reportDate", i) ? `Period: ${col("reportDate", i)}` : ""].filter(Boolean).join(". "); items.push({ key: acc, form, formLabel: label, items: itemsRaw, title, summary: summary.slice(0, 800), url: doc ? `https://www.sec.gov/Archives/edgar/data/${cikNum}/${accPath}/${doc}` : `https://www.sec.gov/Archives/edgar/data/${cikNum}/${accPath}/`, indexUrl: `https://www.sec.gov/Archives/edgar/data/${cikNum}/${accPath}/${acc}-index.htm`, filingDate, publishedAt: accepted && !Number.isNaN(new Date(accepted).getTime()) ? new Date(accepted).toISOString() : filingDate ? new Date(filingDate + "T12:00:00Z").toISOString() : null, size: Number(col("size", i)) || 0, xbrl: col("isXBRL", i) === "1", }); } const canonical = items.map((i) => `${i.key}\t${String(i.form)}\t${String(i.items)}`).join("\n"); const newest = items.map((i) => i.publishedAt as string | null).filter((x): x is string => Boolean(x)).sort().at(-1); return { mode: "list", items, compareFields: ["form", "items"], title: `${company} (${tickers.join(", ") || "CIK " + cikNum}) — EDGAR filings`, rawHash: sha256(text), canonicalHash: sha256(canonical), semanticHash: simhash(items.map((i) => String(i.title)).join("\n")), publishedAt: newest ? new Date(newest) : null, extractionConfidence: 1, extra: { cik: cikNum, company, tickers, exchanges: j.exchanges, sic: j.sic, sicDescription: j.sicDescription, fiscalYearEnd: j.fiscalYearEnd, stateOfIncorporation: j.stateOfIncorporation, totalRecent: forms.length, filings: items.length }, }; } }