import { canonicalizeText, sha256, simhash, type ConnectorMetadata, type NormalizedContent, type Observation, type SensorEndpoint } from "@websensor/core"; import { httpFetchWithRetry } from "./fetcher"; import { NormalizeError, type WebSensorConnector } from "./types"; /** * PDF connector — official documents published as PDF (central-bank statements, regulatory notices, * tariff schedules, price lists, terms). Text is extracted with pdf.js (via `unpdf`, no native * dependency), then canonicalized like any text page so diffs and heuristics are shared. * * Config: { maxPages?: 40, keepLayout?: false } * A PDF that yields < 40 chars of text (scanned image) gets extraction confidence 0.2 — the pipeline * then relies on the raw hash only (changed / unchanged), never on text diffs. */ export class PdfConnector implements WebSensorConnector { mode = "text" as const; metadata(): ConnectorMetadata { return { key: "pdf", name: "PDF document", sensorTypes: ["FILE", "PDF_INDEX"], description: "Text extraction (pdf.js) + canonical text diff for official PDF documents", version: "1.0.0" }; } async fetch(endpoint: SensorEndpoint): Promise { const cfg = endpoint.config as { headers?: Record; timeoutMs?: number }; return httpFetchWithRetry(endpoint.id, endpoint.url, { etag: endpoint.etag, lastModified: endpoint.lastModified, accept: "application/pdf, */*;q=0.5", headers: cfg.headers, timeoutMs: cfg.timeoutMs ?? 60_000, maxBytes: 40 * 1024 * 1024 }); } async normalize(endpoint: SensorEndpoint, obs: Observation): Promise { if (!obs.body) throw new NormalizeError("no_body", "Observation has no body"); if (obs.body.subarray(0, 5).toString("latin1") !== "%PDF-") { if (/^\s*<(!doctype|html)/i.test(obs.body.subarray(0, 300).toString("utf8"))) throw new NormalizeError("html_not_pdf", "Endpoint returned HTML instead of a PDF"); throw new NormalizeError("bad_pdf", "Body does not start with %PDF-"); } const cfg = endpoint.config as { maxPages?: number }; const { text, pages, meta } = await extractPdfText(obs.body, cfg.maxPages ?? 40); const t = canonicalizeText(text); const thin = t.text.length < 40; return { mode: "text", text: t.text, title: meta.title ?? endpoint.name, rawHash: sha256(obs.body), canonicalHash: t.canonicalHash, semanticHash: t.semanticHash, publishedAt: meta.modified ?? meta.created ?? null, extractionConfidence: thin ? 0.2 : 0.85, extra: { pages, chars: t.text.length, thin, producer: meta.producer, author: meta.author }, }; } } export async function extractPdfText(buf: Buffer, maxPages: number): Promise<{ text: string; pages: number; meta: { title?: string; author?: string; producer?: string; created?: Date | null; modified?: Date | null } }> { const { getDocumentProxy, extractText, getMeta } = await import("unpdf"); const data = new Uint8Array(buf.buffer, buf.byteOffset, buf.byteLength); const pdf = await getDocumentProxy(data); const total = pdf.numPages; const parts: string[] = []; const limit = Math.min(total, maxPages); if (limit === total) { const r = await extractText(pdf, { mergePages: false }); parts.push(...(r.text as string[])); } else { for (let p = 1; p <= limit; p++) { const page = await pdf.getPage(p); const content = await page.getTextContent(); parts.push((content.items as { str?: string; hasEOL?: boolean }[]).map((it) => (it.str ?? "") + (it.hasEOL ? "\n" : "")).join(" ")); } } let meta: { title?: string; author?: string; producer?: string; created?: Date | null; modified?: Date | null } = {}; try { const m = await getMeta(pdf); const info = (m.info ?? {}) as Record; meta = { title: str(info.Title), author: str(info.Author), producer: str(info.Producer), created: pdfDate(info.CreationDate), modified: pdfDate(info.ModDate) }; } catch { /* metadata optional */ } const text = parts .map((p) => p.replace(/[ \t]+\n/g, "\n").replace(/\n{3,}/g, "\n\n")) .join("\n\n") .trim(); return { text, pages: total, meta }; } function str(v: unknown): string | undefined { return typeof v === "string" && v.trim() ? v.trim() : undefined; } /** PDF date `D:YYYYMMDDHHmmSS+HH'mm'` → Date */ function pdfDate(v: unknown): Date | null { if (typeof v !== "string") return null; const m = v.match(/D:(\d{4})(\d{2})?(\d{2})?(\d{2})?(\d{2})?(\d{2})?([Z+-])?(\d{2})?'?(\d{2})?/); if (!m) return null; const [, y, mo = "01", d = "01", h = "00", mi = "00", s = "00", tz, tzh = "00", tzm = "00"] = m; const iso = `${y}-${mo}-${d}T${h}:${mi}:${s}${tz === "Z" || !tz ? "Z" : `${tz}${tzh}:${tzm}`}`; const dt = new Date(iso); return Number.isNaN(dt.getTime()) ? null : dt; }