import { writeFileSync } from "node:fs"; import { resolve } from "node:path"; import type { SensorEndpoint, Tier } from "@websensor/core"; import { closeDispatcher, discoverDomain, getConnector, NormalizeError } from "@websensor/connectors"; import { loadSeedsDetailed, type SourceSeed } from "./seeds"; /** * Registry validator — no database. Loads the seeds (founding file + fragments), then runs every * curated sensor through its connector (fetch + normalize) exactly as the engine would, and prints * one line per sensor: * * OK 200 list items=42 openai_news_feed https://… * WARN 200 text thin acme_pricing https://… (JS shell / interstitial) * FAIL 403 - - foo_blog_feed https://… http_403 * * Usage (from the repo root): * node node_modules/tsx/dist/cli.mjs apps/engine/src/validate.ts [fragment.yaml…] [--all] * [--source id[,id…]] [--connector key] [--concurrency 8] [--json report.json] [--quiet] * node node_modules/tsx/dist/cli.mjs apps/engine/src/validate.ts --probe (discovery dry-run, no DB) * * Without a file argument the founding file is validated; with fragment paths only the sources * declared or extended by those fragments are checked (the whole registry is still loaded so that * `extend: true` resolves). Exit code 1 when any sensor FAILs or any seed is invalid. */ interface Result { source: string; sensor: string; connector: string; type: string; url: string; status: "OK" | "WARN" | "FAIL"; http: number; mode?: string; items?: number; note: string; ms: number; } function arg(name: string, def?: string): string | undefined { const i = process.argv.indexOf(`--${name}`); return i >= 0 ? process.argv[i + 1] : def; } const flag = (name: string): boolean => process.argv.includes(`--${name}`); async function checkSensor(src: SourceSeed, sen: SourceSeed["sensors"][number]): Promise { const t0 = Date.now(); const id = sen.id ?? `${src.id}_${sen.name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "")}`; const base: Omit = { source: src.id, sensor: id, connector: sen.connector, type: sen.type, url: sen.url }; let connector; try { connector = getConnector(sen.connector); } catch (e) { return { ...base, status: "FAIL", http: 0, note: (e as Error).message, ms: 0 }; } const endpoint: SensorEndpoint = { id, sourceId: src.id, name: sen.name, url: sen.url, type: sen.type, tier: (sen.tier ?? src.tier) as Tier, connector: sen.connector, config: sen.config, etag: null, lastModified: null, state: null }; const obs = await connector.fetch(endpoint); if (obs.error) return { ...base, status: "FAIL", http: obs.meta.status, note: `${obs.error.code}: ${obs.error.message.slice(0, 160)}`, ms: Date.now() - t0 }; if (obs.meta.status >= 400) return { ...base, status: "FAIL", http: obs.meta.status, note: `http_${obs.meta.status}${obs.meta.finalUrl !== sen.url ? " → " + obs.meta.finalUrl : ""}`, ms: Date.now() - t0 }; try { const norm = await connector.normalize(endpoint, obs); const items = norm.items?.length; const thin = Boolean((norm.extra as { thin?: boolean } | undefined)?.thin); let status: Result["status"] = "OK"; let note = ""; if (norm.mode === "list" && (items ?? 0) === 0) { status = "WARN"; note = "empty list"; } else if (thin || norm.extractionConfidence < 0.5) { status = "WARN"; note = `thin (confidence ${norm.extractionConfidence})`; } else if (norm.mode === "text" && (norm.text?.length ?? 0) < (sen.type === "FILE" ? 20 : 200)) { status = "WARN"; note = `short text (${norm.text?.length ?? 0} chars)`; } if (["http", "rss", "sitemap", "statuspage", "github", "jsonlist", "statusjson", "openapi", "csv", "pdf", "headers"].includes(sen.connector) && obs.meta.finalUrl && obs.meta.finalUrl.replace(/\/$/, "") !== sen.url.replace(/\/$/, "")) note = `${note ? note + "; " : ""}redirect → ${obs.meta.finalUrl}`; return { ...base, status, http: obs.meta.status, mode: norm.mode, items, note, ms: Date.now() - t0 }; } catch (e) { const msg = e instanceof NormalizeError ? `${e.code}: ${e.message}` : (e as Error).message; return { ...base, status: "FAIL", http: obs.meta.status, note: msg.slice(0, 200), ms: Date.now() - t0 }; } } async function main(): Promise { const probe = arg("probe"); if (probe) { const r = await discoverDomain(probe, { probePages: true }); if (!r.length) console.log(`no validated endpoint found for ${probe}`); for (const e of r) console.log(`${e.type.padEnd(10)} ${e.connector.padEnd(10)} ${e.value.toFixed(2)} ${String(e.itemCount ?? "").padStart(5)} ${e.url} (${e.evidence}${e.title ? " · " + e.title : ""})`); return; } const files = process.argv.slice(2).filter((a) => !a.startsWith("--") && /\.ya?ml$/.test(a) && (process.argv[process.argv.indexOf(a) - 1] ?? "").startsWith("--") === false); const baseFile = process.env.WS_SOURCES_FILE ?? "./config/sources.yaml"; const dir = process.env.WS_SOURCES_DIR ?? "./config/sources.d"; const loaded = loadSeedsDetailed(baseFile, dir); for (const i of loaded.issues) console.error(`SEED ${i.file} ${i.source ?? "-"}: ${i.message}`); const wanted = new Set(files.map((f) => resolve(f))); const onlySources = arg("source")?.split(",").filter(Boolean); const onlyConnector = arg("connector"); let targets = loaded.seeds; if (!flag("all") && wanted.size) targets = targets.filter((s) => (loaded.origins.get(s.id) ?? []).some((f) => wanted.has(resolve(f)))); else if (!flag("all") && !wanted.size) targets = targets.filter((s) => (loaded.origins.get(s.id) ?? []).some((f) => resolve(f) === resolve(baseFile))); if (onlySources) targets = targets.filter((s) => onlySources.includes(s.id)); // For fragment validation, only the sensors that the fragment contributed are relevant. const jobs: { src: SourceSeed; sen: SourceSeed["sensors"][number] }[] = []; for (const src of targets) for (const sen of src.sensors) if (!onlyConnector || sen.connector === onlyConnector) jobs.push({ src, sen }); if (wanted.size && !flag("all")) { const fragmentUrls = new Set(); const YAML = (await import("yaml")).default; const { readFileSync } = await import("node:fs"); for (const f of wanted) { const raw = YAML.parse(readFileSync(f, "utf8")) as { sources?: { sensors?: { url: string }[] }[] }; for (const s of raw.sources ?? []) for (const sen of s.sensors ?? []) fragmentUrls.add(sen.url); } for (let i = jobs.length - 1; i >= 0; i--) if (!fragmentUrls.has(jobs[i]!.sen.url)) jobs.splice(i, 1); } const concurrency = Number(arg("concurrency", "8")); const quiet = flag("quiet"); const results: Result[] = []; // Per-host politeness: never more than 2 in flight for the same host. const inflight = new Map(); const host = (u: string): string => { try { return new URL(u).hostname; } catch { return u; } }; const queue = [...jobs]; await Promise.all( Array.from({ length: concurrency }, async () => { while (queue.length) { const idx = queue.findIndex((j) => (inflight.get(host(j.sen.url)) ?? 0) < 2); if (idx < 0) { await new Promise((r) => setTimeout(r, 150)); continue; } const job = queue.splice(idx, 1)[0]!; const h = host(job.sen.url); inflight.set(h, (inflight.get(h) ?? 0) + 1); try { const r = await checkSensor(job.src, job.sen); results.push(r); if (!quiet || r.status !== "OK") console.log(`${r.status.padEnd(5)} ${String(r.http).padStart(3)} ${(r.mode ?? "-").padEnd(4)} ${(r.items !== undefined ? `items=${r.items}` : "-").padEnd(10)} ${r.sensor.padEnd(52).slice(0, 52)} ${r.url}${r.note ? ` (${r.note})` : ""}`); } finally { inflight.set(h, (inflight.get(h) ?? 1) - 1); } } }), ); const ok = results.filter((r) => r.status === "OK").length; const warn = results.filter((r) => r.status === "WARN").length; const fail = results.filter((r) => r.status === "FAIL").length; const byConnector = new Map(); for (const r of results) { const c = byConnector.get(r.connector) ?? { ok: 0, warn: 0, fail: 0 }; c[r.status.toLowerCase() as "ok" | "warn" | "fail"]++; byConnector.set(r.connector, c); } console.log(`\n${targets.length} sources · ${results.length} sensors → OK ${ok} · WARN ${warn} · FAIL ${fail}`); for (const [c, n] of [...byConnector.entries()].sort()) console.log(` ${c.padEnd(12)} ok ${String(n.ok).padStart(4)} warn ${String(n.warn).padStart(3)} fail ${String(n.fail).padStart(3)}`); const out = arg("json"); if (out) writeFileSync(out, JSON.stringify({ generatedAt: new Date().toISOString(), sources: targets.length, results }, null, 2)); if (fail > 0 || loaded.issues.length > 0) process.exitCode = 1; } main() .catch((e) => { console.error(e); process.exitCode = 1; }) .finally(() => closeDispatcher());