import { canonicalizeHtml, canonicalizeText, canonicalJson, sha256, simhash, type ConnectorMetadata, type NormalizedContent, type Observation, type SensorEndpoint } from "@websensor/core"; import { httpFetchWithRetry } from "./fetcher"; import { NormalizeError, type WebSensorConnector } from "./types"; /** * Generic HTTP connector: HTML pages (canonical text), plain text and JSON documents. * Config: { keepChrome?: boolean, selector?: string, accept?: string, headers?: {} } */ export class HttpConnector implements WebSensorConnector { metadata(): ConnectorMetadata { return { key: "http", name: "Generic HTTP", sensorTypes: ["HTML", "JSON", "XML", "HTTP_HEADERS", "FILE"], description: "Conditional GET + canonical extraction for HTML/JSON/text", version: "1.0.0" }; } async fetch(endpoint: SensorEndpoint): Promise { const cfg = endpoint.config as { accept?: string; headers?: Record; method?: "GET" | "HEAD"; timeoutMs?: number }; return httpFetchWithRetry(endpoint.id, endpoint.url, { method: cfg.method ?? "GET", etag: endpoint.etag, lastModified: endpoint.lastModified, accept: cfg.accept ?? (endpoint.type === "JSON" ? "application/json, */*;q=0.5" : undefined), headers: cfg.headers, timeoutMs: cfg.timeoutMs, }); } async normalize(endpoint: SensorEndpoint, obs: Observation): Promise { if (!obs.body) throw new NormalizeError("no_body", "Observation has no body"); const ct = (obs.meta.contentType ?? "").toLowerCase(); const text = obs.body.toString("utf8"); const cfg = endpoint.config as { keepChrome?: boolean; jsonPath?: string; ignoreKeys?: string[] }; if (endpoint.type === "HTTP_HEADERS" || obs.meta.method === "HEAD") { const h = { ...obs.meta.headers }; delete h.date; delete h.age; delete h["cf-ray"]; const j = canonicalJson(h); return { mode: "json", json: h, rawHash: sha256(j), canonicalHash: sha256(j), semanticHash: simhash(j), extractionConfidence: 1 }; } if (endpoint.type === "JSON" || ct.includes("json") || /^\s*[[{]/.test(text.slice(0, 50))) { let parsed: unknown; try { parsed = JSON.parse(text); } catch { if (endpoint.type === "JSON") throw new NormalizeError("bad_json", "Response is not valid JSON"); parsed = undefined; } if (parsed !== undefined) { let node: unknown = parsed; if (cfg.jsonPath) node = getPath(node, cfg.jsonPath); if (cfg.ignoreKeys?.length) node = stripKeys(node, new Set(cfg.ignoreKeys)); const j = canonicalJson(node); return { mode: "json", json: node as object | null, rawHash: sha256(text), canonicalHash: sha256(j), semanticHash: simhash(j), extractionConfidence: 1 }; } } if (ct.includes("html") || /<\s*(!doctype|html|body|div|p)\b/i.test(text.slice(0, 4000))) { const c = canonicalizeHtml(text, obs.meta.finalUrl, { keepChrome: cfg.keepChrome }); if (c.text.length < 40 && c.headings.length === 0) { // Probably a JS shell, a challenge page, or an interstitial: low extraction confidence. return { mode: "text", text: c.text, title: c.title, headings: c.headings, links: c.links, rawHash: c.rawHash, canonicalHash: c.canonicalHash, semanticHash: c.semanticHash, extractionConfidence: 0.3, publishedAt: parseMetaDate(c.meta), extra: { structure: c.structure, meta: c.meta, thin: true } }; } return { mode: "text", text: c.text, title: c.title, headings: c.headings, links: c.links, rawHash: c.rawHash, canonicalHash: c.canonicalHash, semanticHash: c.semanticHash, extractionConfidence: 0.75, publishedAt: parseMetaDate(c.meta), extra: { structure: c.structure, meta: c.meta } }; } const t = canonicalizeText(text); return { mode: "text", text: t.text, rawHash: t.rawHash, canonicalHash: t.canonicalHash, semanticHash: t.semanticHash, extractionConfidence: 0.9 }; } } function parseMetaDate(meta: Record): Date | null { const s = meta["article:modified_time"] ?? meta["article:published_time"] ?? meta["last-modified"]; if (!s) return null; const d = new Date(s); return Number.isNaN(d.getTime()) ? null : d; } export function getPath(obj: unknown, path: string): unknown { let cur = obj; for (const part of path.split(".").filter(Boolean)) { if (cur === null || cur === undefined) return undefined; const m = part.match(/^(\w+)?\[(\d+)\]$/); if (m) { if (m[1]) cur = (cur as Record)[m[1]]; cur = Array.isArray(cur) ? cur[Number(m[2])] : undefined; } else cur = (cur as Record)[part]; } return cur; } function stripKeys(v: unknown, keys: Set): unknown { if (Array.isArray(v)) return v.map((x) => stripKeys(x, keys)); if (v && typeof v === "object") { const out: Record = {}; for (const [k, val] of Object.entries(v as Record)) if (!keys.has(k)) out[k] = stripKeys(val, keys); return out; } return v; }