WebSensor 0.3 — Source Factory & Global Observation Coverage Score
- Deep discovery (robots → sitemaps → feeds incl. official sub-domains → navigation-classified pages → status providers → GitHub org → EDGAR → Hugging Face → OpenAPI → posture), every candidate validated, wildcard DNS / anti-bot detection, content de-duplication, expected change frequency (median gap) - Factory: coverage universes → factory_seeds → scored candidates (explainable) → promotion caps → SHADOW sensors (polled, never published) → evidence-based acceptance/rejection; separate websensor-factory process with Redis heartbeat; CLI expand/factory seed|run|evaluate|stats|export|requeue; YAML export for graduation - Migration 0007 (sources.origin/sector, factory_seeds, candidate lifecycle columns, factory_daily); pipeline keeps SHADOW status and skips events for shadow sensors - Coverage: sector universes schema + validator (--dns), registrableDomain(), GET /api/v1/coverage[/:sector], pages /coverage and /coverage/[sector], homepage rail, stats exclude shadow sensors - Admin API /api/v1/admin/factory* + /ops Source Factory panel (funnel, sectors, candidates, accept/reject) - Docs: COVERAGE.md, CLAUDE.md, deploy README, ARCHITECTURE, README; manifest gains websensor-factory + factory seed hook; version 0.3.0 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
41 changed files +3,084 −173
modified
.env.example
+13 −0
@@ -42,3 +42,16 @@ WS_RETENTION_CHANGED_RAW_DAYS=60 | ||
| 42 | 42 | WS_ADMIN_TOKEN= |
| 43 | 43 | # Custom URL monitors per anonymous owner (spec §45) |
| 44 | 44 | WS_MONITORS_PER_OWNER=5 |
| 45 | + | |
| 46 | +# Source Factory (apps/engine/src/factory-main.ts — separate `websensor-factory` process). Seeds come from | |
| 47 | +# config/coverage/*.yaml (members not yet monitored, or carrying hints) and config/factory/seeds/*.yaml. | |
| 48 | +WS_FACTORY=1 | |
| 49 | +WS_FACTORY_CONCURRENCY=6 # organizations discovered in parallel | |
| 50 | +WS_FACTORY_BUDGET=90 # HTTP requests per organization | |
| 51 | +WS_FACTORY_MIN_SCORE=0.35 # candidate score needed to enter shadow monitoring | |
| 52 | +WS_FACTORY_SHADOW_MIN_CHECKS=5 # checks before a shadow sensor can be accepted | |
| 53 | +WS_FACTORY_SHADOW_MIN_HOURS=24 | |
| 54 | +WS_FACTORY_SHADOW_MAX_HOURS=120 | |
| 55 | +WS_FACTORY_TICK_SECONDS=20 | |
| 56 | +WS_COVERAGE_DIR=./config/coverage | |
| 57 | +# GITHUB_TOKEN= # optional: lets the factory rank an organization's repositories via the GitHub API | |
modified
CLAUDE.md
+36 −6
@@ -5,7 +5,7 @@ Web. It monitors official public sources, detects meaningful changes, classifies | ||
| 5 | 5 | entities, scores importance / confidence / novelty / impact / velocity / anomaly into a WebSensor Signal Score, |
| 6 | 6 | preserves immutable evidence, clusters signals into events with propagation timelines, and publishes them through |
| 7 | 7 | a replayable WebSocket feed. Product brief: `docs/PRODUCT-BRIEF.md`; architecture: `docs/ARCHITECTURE.md`. This file |
| 8 | −is the working guide for the code (v0.2, 2026-09-11). | |
| 8 | +is the working guide for the code (v0.3, 2026-09-13: Source Factory + Global Observation Coverage Score). | |
| 9 | 9 | |
| 10 | 10 | ## Layout (pnpm workspace, TypeScript ESM, Node ≥ 22.15) |
| 11 | 11 | - `packages/core` — taxonomy (event types incl. cyber/finance/government/health/transport classes, event groups, |
@@ -31,6 +31,30 @@ is the working guide for the code (v0.2, 2026-09-11). | ||
| 31 | 31 | (`http rss sitemap statuspage statusjson github jsonlist package edgar openapi csv pdf dns tls headers rdap`) + |
| 32 | 32 | `discovery` + `scrapfly` fallback. `jsonlist.urlTemplate` supports `{key}` (path separators kept) and |
| 33 | 33 | `{field.path}`. |
| 34 | +- **Source Factory** (`apps/engine/src/factory/`, process `apps/engine/src/factory-main.ts` = PM2 `websensor-factory`) — | |
| 35 | + the system grows by itself: coverage universes (`config/coverage/*.yaml`, one sector per file, members = organizations | |
| 36 | + with a verified domain + `hints`) and `config/factory/seeds/*.yaml` become `factory_seeds`; `runFactoryBatch()` claims | |
| 37 | + seeds (systemic first) and runs **deep discovery** (`packages/connectors/src/discovery-deep.ts`: robots → sitemaps → | |
| 38 | + feeds incl. official sub-domains → navigation-classified pages (news/press/IR/changelog/security/pricing/legal/careers/ | |
| 39 | + leadership/docs) → status-page providers → GitHub org repos → EDGAR (cik) → Hugging Face (hf_author) → OpenAPI → | |
| 40 | + posture; every candidate is fetched and parsed, wildcard DNS detected, feeds/pages de-duplicated by content) → | |
| 41 | + `score.ts` (evidence × page-class weight × organization importance × first-party confidence × change frequency − | |
| 42 | + fetch cost, explainable reasons; caps per organization/class) → **shadow sensors** (`sensors.status = 'SHADOW'`, | |
| 43 | + `config.factory/shadow/kind/seedId`, priority 3: polled, snapshots + changes stored, **never published**; the pipeline | |
| 44 | + and `handleMissing` skip event creation) → `shadow.ts` `evaluateShadows()` every 15 min (reject: ≥ 50 % errors / all | |
| 45 | + changes noise / duplicate content of an active sensor; accept after ≥ 5 checks and 24 h → `ACTIVE` with its real | |
| 46 | + priority; defer otherwise, max 120 h). Sources created by the factory carry `origin = 'factory'` + `sector`; attached | |
| 47 | + ones reuse the registry source (match by registrable domain, `coverageKey()`). Candidates and decisions live in | |
| 48 | + `discovery_candidates` (status candidate / shadow / accepted / rejected / duplicate, `reason`, `score`); funnel in | |
| 49 | + `factory_daily`; heartbeat Redis `ws:factory:status`. `cli.ts expand <domain> [--hints=json]` = dry run; | |
| 50 | + `cli.ts factory seed|run|evaluate|stats|export|requeue`; `factory export` writes a reviewable YAML fragment | |
| 51 | + (graduating accepted sensors into `config/sources.d/` hands them to the registry sync). | |
| 52 | +- **Coverage** (`apps/api/src/coverage.ts`, `GET /api/v1/coverage[/:sector]`, pages `/coverage`, `/coverage/[sector]`, | |
| 53 | + homepage rail) — Global Observation Coverage Score: per sector `breadth` (importance-weighted share of universe | |
| 54 | + members with ≥ 1 active non-shadow sensor on their registrable domain), `depth` (min(1, sensors/5)), | |
| 55 | + `score = 100·(0.7·breadth + 0.3·depth)`, global = sector-weight-weighted mean. Universes are the denominator (with | |
| 56 | + `provenance`), never the registry. Validator: `apps/engine/src/coverage-validate.ts [file] [--dns]`. Guide: | |
| 57 | + `docs/registry/COVERAGE.md`. Schema shared in `packages/core/src/coverage-schema.ts` (+ `registrableDomain()`). | |
| 34 | 58 | - `apps/engine` — scheduler (priority-aware `FOR UPDATE SKIP LOCKED` claims, global + per-host concurrency, |
| 35 | 59 | **domain circuit breaker**, heartbeat to Redis `ws:engine:status`), pipeline (fetch → normalize → snapshot → diff → |
| 36 | 60 | heuristics + semantic class → change → event: fingerprint idempotency, entity resolution with ambiguous-alias |
@@ -38,8 +62,8 @@ is the working guide for the code (v0.2, 2026-09-11). | ||
| 38 | 62 | with propagation timeline + lead time + breaking state, entity/source daily counters, alert evaluation + webhook |
| 39 | 63 | delivery, Redis stream + pub/sub), registry sync (`config/sources.yaml` + `config/sources.d/`), discovery, |
| 40 | 64 | connector health rollups, **retention** (`retention.ts`), Prometheus metrics :8262. |
| 41 | − CLI: `src/cli.ts` (`sync`, `discover`, `probe`, `run-once`, `run-due`, `relink-entities [days]`, `prune-blobs`, | |
| 42 | − `refresh-clusters`, `llm-test`); `src/validate.ts` (registry validator). | |
| 65 | + CLI: `src/cli.ts` (`sync`, `discover`, `probe`, `expand`, `run-once`, `run-due`, `relink-entities [days]`, `prune-blobs`, | |
| 66 | + `refresh-clusters`, `llm-test`, `factory …`); `src/validate.ts` (registry validator); `src/coverage-validate.ts`. | |
| 43 | 67 | - `apps/api` — Fastify gateway (:8260): REST `/api/v1/*` (`routes.ts` public, `routes-user.ts` owner-scoped: |
| 44 | 68 | watchlists / alerts (+webhook) / notifications / bookmarks / saved views / custom monitors, `routes-admin.ts` |
| 45 | 69 | behind `WS_ADMIN_TOKEN`: ops, sensor & source actions, connector test, bulk import), intelligence read-models |
@@ -52,8 +76,8 @@ is the working guide for the code (v0.2, 2026-09-11). | ||
| 52 | 76 | (intelligence panel), `command-palette.tsx` (⌘K), `live-strip.tsx`, `live-feed.tsx` v2 (URL-synced filters, |
| 53 | 77 | pause, "↑ N new events", replay), `field-changes.tsx`. Pages: `/` `/live` `/breaking` `/silent` `/pulse` `/radar` |
| 54 | 78 | `/explore` `/entity/[id]` (`/company` redirects) `/cluster/[slug]` `/source/[id]` `/sensor/[id]` `/category/[c]` |
| 55 | − `/country/[slug]` `/event/[slug]` (+ OG image) `/bookmarks` `/watchlists` `/alerts` `/monitors` `/ops` `/health` | |
| 56 | − `/api`. | |
| 79 | + `/country/[slug]` `/event/[slug]` (+ OG image) `/coverage` `/coverage/[sector]` `/bookmarks` `/watchlists` `/alerts` | |
| 80 | + `/monitors` `/ops` (+ Source Factory panel: funnel, sectors, candidates, accept/reject) `/health` `/api`. | |
| 57 | 81 | - `config/sources.yaml` (founding registry) + `config/sources.d/*.yaml` fragments merged in file-name order |
| 58 | 82 | (`extend: true` adds to an earlier source). Fragments 10–35 = site classes; **40–46 (2026-09-11)** = depth: AI |
| 59 | 83 | frontier, cloud infrastructure, cybersecurity, finance & markets, governments, science & health, transport / |
@@ -87,6 +111,11 @@ is the working guide for the code (v0.2, 2026-09-11). | ||
| 87 | 111 | `llm: false` on firehoses (news, arXiv, NVD, package streams). No key → the system still works. |
| 88 | 112 | - Feeds: items are "new" only if never seen and < 14 days old; >50 % list shrink = partial response; 404 becomes |
| 89 | 113 | `page_removed` after `WS_DELETE_CONFIRMATIONS` separated checks. |
| 114 | +- Shadow is not production: `status = 'SHADOW'` sensors never create events, never count in public stats | |
| 115 | + (`stats.sensors`, coverage) and never compete with production sensors (priority 3). Nothing the Factory finds is | |
| 116 | + published before `evaluateShadows()` (or an operator) accepts it. Public source pages show shadow sensors with a SHADOW pill. | |
| 117 | +- Coverage universes are lists of real organizations with verified domains (`--dns`), provenance and retrieval dates — | |
| 118 | + never guessed domains; several files may share a sector key (universes are merged). | |
| 90 | 119 | - Admin API is off unless `WS_ADMIN_TOKEN` is set; custom monitors are limited per owner and never appear in public |
| 91 | 120 | feeds (`sources.kind = 'custom'`). |
| 92 | 121 | - All timestamps UTC. Ids are prefixed (`src_`, `sen_`, `snap_`, `chg_`, `evt_`, `clu_`, `ent_`, `wl_`, `alr_`…). |
@@ -108,6 +137,7 @@ Run CLI/engine from the repo root. A copy of production data for UI work: | ||
| 108 | 137 | `mld stage . websensor && mld deploy websensor --node M4M64b`. Manifest `M1M32:~/dispatch/apps/websensor.json` |
| 109 | 138 | (secrets incl. `WS_ADMIN_TOKEN`; gitignored copy `deploy/websensor.mld.json`). Processes: `websensor-api` (:8260, |
| 110 | 139 | public via MacLustr Tunnel `www.websensor.io` on BHS64), `websensor-web` (:8261 loopback), `websensor-engine` |
| 111 | −(metrics :8262). Postgres 17 `websensor` + Redis local. Blob store `~/websensor-data/blobs`. After a deploy that | |
| 140 | +(metrics :8262), `websensor-factory` (Source Factory, heartbeat `ws:factory:status`; post-sync hook `factory seed --mode=hinted` | |
| 141 | +re-seeds from the coverage universes at every deploy). Postgres 17 `websensor` + Redis local. Blob store `~/websensor-data/blobs`. After a deploy that | |
| 112 | 142 | changes alias or scoring rules: `cli.ts relink-entities 7` and `cli.ts refresh-clusters` on the node. See |
| 113 | 143 | `deploy/README.md`. |
modified
README.md
+31 −0
@@ -94,6 +94,7 @@ threat-intelligence desk*: dense information, one-pixel borders, small badges, p | ||
| 94 | 94 | (OVH, Caddy, (wg1) │ /api/v1/* REST + WebSocket /api/v1/live (Redis pub/sub fan-out) │ |
| 95 | 95 | MacLustr Tunnel) │ /* reverse proxy → websensor-web :8261 (Next.js 16) │ |
| 96 | 96 | │ websensor-engine scheduler + pipeline, Prometheus :8262 │ |
| 97 | + │ websensor-factory Source Factory: seeds → discovery → shadow │ | |
| 97 | 98 | │ PostgreSQL 17 `websensor` · Redis · blob store ~/websensor-data/blobs │ |
| 98 | 99 | └───────────────────────────────────────────────────────────────────────┘ |
| 99 | 100 | ``` |
@@ -105,6 +106,8 @@ threat-intelligence desk*: dense information, one-pixel borders, small badges, p | ||
| 105 | 106 | | Evidence | `packages/store` | content-addressed zstd blob store (`sha256/ab/cd/<hash>.zst`); raw bodies, canonical forms and full diffs never live in Postgres | |
| 106 | 107 | | Acquisition | `packages/connectors` | connector SDK, safe fetcher, 16 connector families, discovery, budgeted Scrapfly fallback | |
| 107 | 108 | | Ingestion | `apps/engine` | scheduler, pipeline, registry sync, discovery, health rollups, retention, alerts, CLI, validator | |
| 109 | +| Expansion | `apps/engine/src/factory` | **Source Factory** (0.3): coverage universes → deep discovery → scored candidates → shadow sensors → evidence-based acceptance; `websensor-factory` process | | |
| 110 | +| Coverage | `apps/api/src/coverage.ts` | **Global Observation Coverage Score** per sector (`/coverage`, `GET /api/v1/coverage`): how much of the world's high-value public web is observed | | |
| 108 | 111 | | Gateway | `apps/api` | REST, WebSocket, intelligence read-models, TTL cache, owner-scoped tools, admin API | |
| 109 | 112 | | Product | `apps/web` | Next.js 16 App Router, design system, live feed, drawer, palette, desks | |
| 110 | 113 | |
@@ -493,6 +496,34 @@ without ever deleting event evidence. Details: `deploy/README.md`, `docs/ARCHITE | ||
| 493 | 496 | - Admin API disabled unless `WS_ADMIN_TOKEN` is set; owner tools use an anonymous browser token; rate limiting |
| 494 | 497 | on every `/api/*` route; strict input validation (zod); snapshots served raw get `nosniff` and a restrictive CSP. |
| 495 | 498 | |
| 499 | +## Source Factory and coverage (0.3, 2026-09-13) | |
| 500 | + | |
| 501 | +WebSensor no longer grows by hand-written lists alone. `config/coverage/*.yaml` declares, sector by sector, the | |
| 502 | +organizations that *should* be observed (listed companies by index, governments of every country by institutional | |
| 503 | +role, AI, cloud, cybersecurity, markets, science, healthcare, energy, transport, telecom, logistics, open source, | |
| 504 | +commerce), each with a verified domain, a country, an importance and optional hints (CIK, GitHub organization, | |
| 505 | +Hugging Face author, status page, explicit URLs). Those universes are the denominator of the **Global Observation | |
| 506 | +Coverage Score** (`breadth` = importance-weighted share of members with an active sensor, `depth` = sensors per | |
| 507 | +organization, `score = 100·(0.7·breadth + 0.3·depth)`) and the input of the **Source Factory**: | |
| 508 | + | |
| 509 | +``` | |
| 510 | +seed (organization + hints) | |
| 511 | + → deep discovery robots · sitemaps · feeds (+ official sub-domains) · navigation-classified pages | |
| 512 | + (news · press · IR · changelog · security · pricing · legal · careers · leadership · docs) | |
| 513 | + · status-page providers · GitHub org repositories · EDGAR · Hugging Face · OpenAPI · posture | |
| 514 | + → validation every candidate fetched and parsed by its connector; wildcard DNS and anti-bot detected; | |
| 515 | + duplicates removed by content (shared feed items, identical page text) | |
| 516 | + → scoring evidence × page class × organization importance × first-party confidence × change | |
| 517 | + frequency − fetch cost, with explainable reasons; caps per organization and per class | |
| 518 | + → shadow sensors polled like any sensor, evidence stored, nothing published | |
| 519 | + → evaluation reject (errors, pure noise, duplicate of an active sensor) · accept (≥ 5 checks, 24 h) · defer | |
| 520 | + → production accepted sensors publish; `cli.ts factory export` graduates them into a registry fragment | |
| 521 | +``` | |
| 522 | + | |
| 523 | +`cli.ts expand <domain>` shows what the Factory would find for one organization; `/ops` has the funnel, per-sector | |
| 524 | +progress and a candidate review table; `/coverage` and `/coverage/<sector>` show the score and every member's state | |
| 525 | +(observed · shadow · queued · not seeded). | |
| 526 | + | |
| 496 | 527 | ## Roadmap |
| 497 | 528 | |
| 498 | 529 | World map for geolocated events; e-mail / Slack / Discord / Telegram / push alert channels (the channel |
modified
apps/api/src/config.ts
+1 −1
@@ -14,5 +14,5 @@ export const config = { | ||
| 14 | 14 | adminToken: env.WS_ADMIN_TOKEN ?? "", |
| 15 | 15 | /** Custom URL monitors per anonymous owner (spec §45). */ |
| 16 | 16 | monitorsPerOwner: Number(env.WS_MONITORS_PER_OWNER ?? 5), |
| 17 | − version: "0.2.0", | |
| 17 | + version: "0.3.0", | |
| 18 | 18 | }; |
added
apps/api/src/coverage.ts
+236 −0
@@ -0,0 +1,236 @@ | ||
| 1 | +import { existsSync, readdirSync, readFileSync, statSync } from "node:fs"; | |
| 2 | +import { join } from "node:path"; | |
| 3 | +import YAML from "yaml"; | |
| 4 | +import { coverageKey, coverageSectorSchema, type CoverageMember, type CoverageSector } from "@websensor/core"; | |
| 5 | +import { db, sql } from "@websensor/db"; | |
| 6 | +import { cached } from "./cache"; | |
| 7 | + | |
| 8 | +/** | |
| 9 | + * Global Observation Coverage Score — "how much of the world's high-value public web do we observe?" | |
| 10 | + * | |
| 11 | + * For every sector the universes in `config/coverage/*.yaml` are the denominator. A member is *covered* when at | |
| 12 | + * least one active (non-shadow) sensor lives on its registrable domain (or on a host declared in `hints.hosts`). | |
| 13 | + * breadth = Σ w·covered / Σ w (w = member importance 1–3) | |
| 14 | + * depth = Σ w·min(1, sensors/5) / Σ w (five sensors per organization = full depth) | |
| 15 | + * score = 100 · (0.7·breadth + 0.3·depth) | |
| 16 | + * The global score is the sector-weight-weighted mean. Everything is explainable: the per-member table shows | |
| 17 | + * which sensors count, what is in shadow and what the Factory is still queuing. | |
| 18 | + */ | |
| 19 | +export interface MemberCoverage { | |
| 20 | + name: string; | |
| 21 | + domain: string; | |
| 22 | + country: string | null; | |
| 23 | + importance: number; | |
| 24 | + universe: string; | |
| 25 | + covered: boolean; | |
| 26 | + sensors: number; | |
| 27 | + shadow: number; | |
| 28 | + source_id: string | null; | |
| 29 | + seed_status: string | null; | |
| 30 | + depth: number; | |
| 31 | +} | |
| 32 | +export interface UniverseCoverage { | |
| 33 | + key: string; | |
| 34 | + label: string; | |
| 35 | + provenance: string | null; | |
| 36 | + members: number; | |
| 37 | + covered: number; | |
| 38 | + sensors: number; | |
| 39 | + breadth: number; | |
| 40 | + depth: number; | |
| 41 | + score: number; | |
| 42 | +} | |
| 43 | +export interface SectorCoverage { | |
| 44 | + sector: string; | |
| 45 | + label: string; | |
| 46 | + description: string | null; | |
| 47 | + weight: number; | |
| 48 | + members: number; | |
| 49 | + covered: number; | |
| 50 | + shadow_members: number; | |
| 51 | + queued: number; | |
| 52 | + sensors: number; | |
| 53 | + breadth: number; | |
| 54 | + depth: number; | |
| 55 | + score: number; | |
| 56 | + universes: UniverseCoverage[]; | |
| 57 | +} | |
| 58 | + | |
| 59 | +const COVERAGE_DIR = process.env.WS_COVERAGE_DIR ?? "./config/coverage"; | |
| 60 | + | |
| 61 | +let fileCache: { at: number; sectors: CoverageSector[]; issues: string[] } | null = null; | |
| 62 | +export function loadSectors(): { sectors: CoverageSector[]; issues: string[] } { | |
| 63 | + if (fileCache && Date.now() - fileCache.at < 60_000) return fileCache; | |
| 64 | + const byKey = new Map<string, CoverageSector>(); | |
| 65 | + const issues: string[] = []; | |
| 66 | + if (existsSync(COVERAGE_DIR) && statSync(COVERAGE_DIR).isDirectory()) { | |
| 67 | + for (const f of readdirSync(COVERAGE_DIR).filter((x) => /\.ya?ml$/.test(x) && !x.startsWith("_")).sort()) { | |
| 68 | + try { | |
| 69 | + const parsed = coverageSectorSchema.safeParse(YAML.parse(readFileSync(join(COVERAGE_DIR, f), "utf8"))); | |
| 70 | + if (!parsed.success) { | |
| 71 | + issues.push(`${f}: ${parsed.error.issues[0]?.path.join(".")}: ${parsed.error.issues[0]?.message}`); | |
| 72 | + continue; | |
| 73 | + } | |
| 74 | + const s = parsed.data; | |
| 75 | + const prev = byKey.get(s.sector); | |
| 76 | + if (prev) prev.universes.push(...s.universes); | |
| 77 | + else byKey.set(s.sector, { ...s, universes: [...s.universes] }); | |
| 78 | + } catch (e) { | |
| 79 | + issues.push(`${f}: ${(e as Error).message}`); | |
| 80 | + } | |
| 81 | + } | |
| 82 | + } | |
| 83 | + fileCache = { at: Date.now(), sectors: [...byKey.values()], issues }; | |
| 84 | + return fileCache; | |
| 85 | +} | |
| 86 | + | |
| 87 | +interface Observed { | |
| 88 | + /** registrable domain → { sensors (active), shadow, sourceId } */ | |
| 89 | + byKey: Map<string, { sensors: number; shadow: number; sourceId: string | null }>; | |
| 90 | + seeds: Map<string, string>; | |
| 91 | +} | |
| 92 | + | |
| 93 | +async function observed(): Promise<Observed> { | |
| 94 | + const [hosts, srcs, seeds] = await Promise.all([ | |
| 95 | + db.execute<{ host: string; status: string; n: string; source_id: string }>(sql` | |
| 96 | + select lower(split_part(split_part(url, '/', 3), ':', 1)) as host, case when status = 'SHADOW' then 'SHADOW' else 'ACTIVE' end as status, count(*)::text as n, min(source_id) as source_id | |
| 97 | + from sensors where enabled and url like 'http%' group by 1, 2`).then((r) => r.rows), | |
| 98 | + db.execute<{ id: string; domain: string }>(sql`select id, domain from sources where enabled and kind = 'registry'`).then((r) => r.rows), | |
| 99 | + db.execute<{ domain: string; status: string }>(sql`select domain, status from factory_seeds`).then((r) => r.rows), | |
| 100 | + ]); | |
| 101 | + const byKey = new Map<string, { sensors: number; shadow: number; sourceId: string | null }>(); | |
| 102 | + for (const h of hosts) { | |
| 103 | + if (!h.host) continue; | |
| 104 | + const k = coverageKey(h.host); | |
| 105 | + const cur = byKey.get(k) ?? { sensors: 0, shadow: 0, sourceId: null }; | |
| 106 | + if (h.status === "SHADOW") cur.shadow += Number(h.n); | |
| 107 | + else cur.sensors += Number(h.n); | |
| 108 | + cur.sourceId = cur.sourceId ?? h.source_id; | |
| 109 | + byKey.set(k, cur); | |
| 110 | + } | |
| 111 | + for (const s of srcs) { | |
| 112 | + const k = coverageKey(s.domain); | |
| 113 | + const cur = byKey.get(k) ?? { sensors: 0, shadow: 0, sourceId: null }; | |
| 114 | + cur.sourceId = cur.sourceId ?? s.id; | |
| 115 | + byKey.set(k, cur); | |
| 116 | + } | |
| 117 | + const seedMap = new Map<string, string>(); | |
| 118 | + for (const s of seeds) seedMap.set(coverageKey(s.domain), s.status); | |
| 119 | + return { byKey, seeds: seedMap }; | |
| 120 | +} | |
| 121 | + | |
| 122 | +function memberCoverage(m: CoverageMember, universe: string, obs: Observed): MemberCoverage { | |
| 123 | + const keys = [coverageKey(m.domain), ...((m.hints?.hosts ?? []).map((h) => coverageKey(h)))]; | |
| 124 | + let sensors = 0; | |
| 125 | + let shadow = 0; | |
| 126 | + let sourceId: string | null = null; | |
| 127 | + for (const k of new Set(keys)) { | |
| 128 | + const o = obs.byKey.get(k); | |
| 129 | + if (!o) continue; | |
| 130 | + sensors += o.sensors; | |
| 131 | + shadow += o.shadow; | |
| 132 | + sourceId = sourceId ?? o.sourceId; | |
| 133 | + } | |
| 134 | + return { name: m.name, domain: m.domain, country: m.country ?? null, importance: m.importance, universe, covered: sensors > 0, sensors, shadow, source_id: sourceId, seed_status: obs.seeds.get(coverageKey(m.domain)) ?? null, depth: Math.min(1, sensors / 5) }; | |
| 135 | +} | |
| 136 | + | |
| 137 | +function aggregate(members: MemberCoverage[]): { breadth: number; depth: number; score: number; covered: number; sensors: number } { | |
| 138 | + let w = 0; | |
| 139 | + let cov = 0; | |
| 140 | + let dep = 0; | |
| 141 | + let covered = 0; | |
| 142 | + let sensors = 0; | |
| 143 | + for (const m of members) { | |
| 144 | + w += m.importance; | |
| 145 | + if (m.covered) { | |
| 146 | + cov += m.importance; | |
| 147 | + covered++; | |
| 148 | + } | |
| 149 | + dep += m.importance * m.depth; | |
| 150 | + sensors += m.sensors; | |
| 151 | + } | |
| 152 | + const breadth = w ? cov / w : 0; | |
| 153 | + const depth = w ? dep / w : 0; | |
| 154 | + return { breadth: round(breadth), depth: round(depth), score: round(100 * (0.7 * breadth + 0.3 * depth), 1), covered, sensors }; | |
| 155 | +} | |
| 156 | +const round = (x: number, d = 3): number => Math.round(x * 10 ** d) / 10 ** d; | |
| 157 | + | |
| 158 | +export async function coverageSummary(): Promise<Record<string, unknown>> { | |
| 159 | + return cached("coverage:summary", 5 * 60_000, async () => { | |
| 160 | + const { sectors, issues } = loadSectors(); | |
| 161 | + const obs = await observed(); | |
| 162 | + const out: SectorCoverage[] = []; | |
| 163 | + let wsum = 0; | |
| 164 | + let ssum = 0; | |
| 165 | + let members = 0; | |
| 166 | + let covered = 0; | |
| 167 | + let sensorsTotal = 0; | |
| 168 | + for (const s of sectors) { | |
| 169 | + const all: MemberCoverage[] = []; | |
| 170 | + const universes: UniverseCoverage[] = []; | |
| 171 | + const seenDomains = new Set<string>(); | |
| 172 | + for (const u of s.universes) { | |
| 173 | + const ms = u.members.map((m) => memberCoverage(m, u.key, obs)); | |
| 174 | + const a = aggregate(ms); | |
| 175 | + universes.push({ key: u.key, label: u.label, provenance: u.provenance ?? null, members: ms.length, covered: a.covered, sensors: a.sensors, breadth: a.breadth, depth: a.depth, score: a.score }); | |
| 176 | + for (const m of ms) { | |
| 177 | + const k = coverageKey(m.domain); | |
| 178 | + if (seenDomains.has(k)) continue; | |
| 179 | + seenDomains.add(k); | |
| 180 | + all.push(m); | |
| 181 | + } | |
| 182 | + } | |
| 183 | + const a = aggregate(all); | |
| 184 | + const sec: SectorCoverage = { sector: s.sector, label: s.label, description: s.description ?? null, weight: s.weight, members: all.length, covered: a.covered, shadow_members: all.filter((m) => !m.covered && m.shadow > 0).length, queued: all.filter((m) => !m.covered && (m.seed_status === "queued" || m.seed_status === "discovering")).length, sensors: a.sensors, breadth: a.breadth, depth: a.depth, score: a.score, universes }; | |
| 185 | + out.push(sec); | |
| 186 | + wsum += s.weight; | |
| 187 | + ssum += s.weight * a.score; | |
| 188 | + members += all.length; | |
| 189 | + covered += a.covered; | |
| 190 | + sensorsTotal += a.sensors; | |
| 191 | + } | |
| 192 | + out.sort((a, b) => b.score - a.score); | |
| 193 | + const [monitored, factory] = await Promise.all([ | |
| 194 | + db.execute<{ sources: string; sensors: string; shadow: string; countries: string }>(sql`select (select count(*) from sources where enabled and kind = 'registry')::text as sources, (select count(*) from sensors where enabled and status <> 'SHADOW')::text as sensors, (select count(*) from sensors where enabled and status = 'SHADOW')::text as shadow, (select count(distinct country) from sources where country is not null and enabled)::text as countries`).then((r) => r.rows[0]), | |
| 195 | + db.execute<{ status: string; n: string }>(sql`select status, count(*)::text as n from factory_seeds group by status`).then((r) => Object.fromEntries(r.rows.map((x) => [x.status, Number(x.n)]))), | |
| 196 | + ]); | |
| 197 | + return { | |
| 198 | + global_score: wsum ? round(ssum / wsum, 1) : 0, | |
| 199 | + members, | |
| 200 | + covered, | |
| 201 | + sensors_matched: sensorsTotal, | |
| 202 | + sectors: out.map(({ universes: _u, ...rest }) => rest), | |
| 203 | + monitored: { sources: Number(monitored?.sources ?? 0), sensors: Number(monitored?.sensors ?? 0), shadow: Number(monitored?.shadow ?? 0), countries: Number(monitored?.countries ?? 0) }, | |
| 204 | + factory: factory, | |
| 205 | + method: "breadth = importance-weighted share of members with ≥ 1 active sensor · depth = importance-weighted min(1, sensors/5) · score = 100·(0.7·breadth + 0.3·depth) · global = sector-weight-weighted mean", | |
| 206 | + issues, | |
| 207 | + generated_at: new Date().toISOString(), | |
| 208 | + }; | |
| 209 | + }); | |
| 210 | +} | |
| 211 | + | |
| 212 | +export async function coverageSector(sector: string): Promise<Record<string, unknown> | null> { | |
| 213 | + return cached(`coverage:sector:${sector}`, 5 * 60_000, async () => { | |
| 214 | + const { sectors } = loadSectors(); | |
| 215 | + const s = sectors.find((x) => x.sector === sector); | |
| 216 | + if (!s) return null; | |
| 217 | + const obs = await observed(); | |
| 218 | + const universes = s.universes.map((u) => { | |
| 219 | + const members = u.members.map((m) => memberCoverage(m, u.key, obs)).sort((a, b) => Number(b.covered) - Number(a.covered) || b.importance - a.importance || b.sensors - a.sensors || a.name.localeCompare(b.name)); | |
| 220 | + const a = aggregate(members); | |
| 221 | + return { key: u.key, label: u.label, description: u.description ?? null, provenance: u.provenance ?? null, members: members.length, covered: a.covered, sensors: a.sensors, breadth: a.breadth, depth: a.depth, score: a.score, items: members }; | |
| 222 | + }); | |
| 223 | + const flat = new Map<string, MemberCoverage>(); | |
| 224 | + for (const u of universes) for (const m of u.items) if (!flat.has(coverageKey(m.domain))) flat.set(coverageKey(m.domain), m); | |
| 225 | + const a = aggregate([...flat.values()]); | |
| 226 | + const byCountry = new Map<string, { members: number; covered: number }>(); | |
| 227 | + for (const m of flat.values()) { | |
| 228 | + const c = m.country ?? "—"; | |
| 229 | + const cur = byCountry.get(c) ?? { members: 0, covered: 0 }; | |
| 230 | + cur.members++; | |
| 231 | + if (m.covered) cur.covered++; | |
| 232 | + byCountry.set(c, cur); | |
| 233 | + } | |
| 234 | + return { sector: s.sector, label: s.label, description: s.description ?? null, weight: s.weight, categories: s.categories, members: flat.size, covered: a.covered, sensors: a.sensors, breadth: a.breadth, depth: a.depth, score: a.score, shadow_members: [...flat.values()].filter((m) => !m.covered && m.shadow > 0).length, queued: [...flat.values()].filter((m) => !m.covered && (m.seed_status === "queued" || m.seed_status === "discovering")).length, universes, by_country: [...byCountry.entries()].map(([country, v]) => ({ country, ...v })).sort((x, y) => y.members - x.members), generated_at: new Date().toISOString() }; | |
| 235 | + }); | |
| 236 | +} | |
modified
apps/api/src/live.ts
+9 −0
@@ -49,6 +49,15 @@ export async function engineStatus(): Promise<Record<string, unknown> | null> { | ||
| 49 | 49 | } |
| 50 | 50 | } |
| 51 | 51 | |
| 52 | +export async function factoryStatus(): Promise<Record<string, unknown> | null> { | |
| 53 | + try { | |
| 54 | + const raw = await getCmd().get("ws:factory:status"); | |
| 55 | + return raw ? (JSON.parse(raw) as Record<string, unknown>) : null; | |
| 56 | + } catch { | |
| 57 | + return null; | |
| 58 | + } | |
| 59 | +} | |
| 60 | + | |
| 52 | 61 | function getCmd(): Redis { |
| 53 | 62 | if (!cmd) cmd = new Redis(config.redisUrl, { maxRetriesPerRequest: 2, lazyConnect: false }); |
| 54 | 63 | return cmd; |
modified
apps/api/src/queries.ts
+5 −3
@@ -127,7 +127,9 @@ export async function stats(): Promise<Record<string, unknown>> { | ||
| 127 | 127 | await db.execute<Record<string, unknown>>(sql` |
| 128 | 128 | select |
| 129 | 129 | (select count(*) from sources where enabled and kind = 'registry') as sources, |
| 130 | − (select count(*) from sensors where enabled) as sensors, | |
| 130 | + (select count(*) from sensors where enabled and status <> 'SHADOW') as sensors, | |
| 131 | + (select count(*) from sensors where enabled and status = 'SHADOW') as sensors_shadow, | |
| 132 | + (select count(*) from sources where enabled and kind = 'registry' and origin = 'factory') as sources_factory, | |
| 131 | 133 | (select count(*) from entities) as entities, |
| 132 | 134 | (select count(*) from snapshots) as snapshots, |
| 133 | 135 | (select count(*) from events) as events_total, |
@@ -144,8 +146,8 @@ export async function stats(): Promise<Record<string, unknown>> { | ||
| 144 | 146 | (select count(*) from sensor_runs where started_at >= now() - interval '1 hour') as checks_last_hour, |
| 145 | 147 | (select count(*) from sensor_runs where started_at >= now() - interval '5 minutes') as checks_last_5m, |
| 146 | 148 | (select count(*) from sensor_runs where started_at >= now() - interval '5 minutes' and outcome = 'not_modified') as not_modified_last_5m, |
| 147 | − (select count(*) from sensors where health = 'UP' and enabled) as sensors_up, | |
| 148 | − (select count(*) from sensors where health in ('DEGRADED','ERROR','RATE_LIMITED') and enabled) as sensors_degraded, | |
| 149 | + (select count(*) from sensors where health = 'UP' and enabled and status <> 'SHADOW') as sensors_up, | |
| 150 | + (select count(*) from sensors where health in ('DEGRADED','ERROR','RATE_LIMITED') and enabled and status <> 'SHADOW') as sensors_degraded, | |
| 149 | 151 | (select count(*) from sources where enabled and first_party and kind = 'registry') as sources_first_party, |
| 150 | 152 | (select count(distinct country) from sources where country is not null) as countries, |
| 151 | 153 | (select max(started_at) from sensor_runs) as last_check_at, |
modified
apps/api/src/routes-admin.ts
+76 −1
@@ -8,7 +8,7 @@ import { getConnector, listConnectors, NormalizeError } from "@websensor/connect | ||
| 8 | 8 | import { db, sql } from "@websensor/db"; |
| 9 | 9 | import { cacheStats, invalidate } from "./cache"; |
| 10 | 10 | import { config } from "./config"; |
| 11 | −import { engineStatus, liveStats } from "./live"; | |
| 11 | +import { engineStatus, factoryStatus, liveStats } from "./live"; | |
| 12 | 12 | |
| 13 | 13 | /** |
| 14 | 14 | * Internal operations (spec §70–72, §85). Enabled only when WS_ADMIN_TOKEN is set; every request |
@@ -193,6 +193,81 @@ export async function registerAdminRoutes(app: FastifyInstance): Promise<void> { | ||
| 193 | 193 | return { dry_run: false, imported_sources: valid.filter((v) => !v.extend).length, extended_sources: valid.filter((v) => v.extend).length, sensors, invalid: report.filter((r) => !r.ok).length, report }; |
| 194 | 194 | }); |
| 195 | 195 | |
| 196 | + // ---- Source Factory --------------------------------------------------------------------------------- | |
| 197 | + app.get("/api/v1/admin/factory", async () => { | |
| 198 | + const [seeds, cands, shadows, daily, sectors, recent, hb, ready] = await Promise.all([ | |
| 199 | + db.execute<Record<string, unknown>>(sql`select status, count(*)::int as n from factory_seeds group by status`).then((r) => Object.fromEntries(r.rows.map((x) => [String(x.status), Number(x.n)]))), | |
| 200 | + db.execute<Record<string, unknown>>(sql`select status, count(*)::int as n from discovery_candidates group by status`).then((r) => Object.fromEntries(r.rows.map((x) => [String(x.status), Number(x.n)]))), | |
| 201 | + db.execute<Record<string, unknown>>(sql`select count(*)::int as total, count(*) filter (where health = 'UP')::int as up, count(*) filter (where health <> 'UP')::int as unhealthy, count(*) filter (where total_runs >= 5)::int as ready, coalesce(sum(raw_changes),0)::int as raw_changes from sensors where status = 'SHADOW' and enabled`).then((r) => r.rows[0]), | |
| 202 | + db.execute<Record<string, unknown>>(sql`select day::text as day, seeds_processed, requests, candidates, shadow_created, accepted, rejected, blocked from factory_daily order by day desc limit 30`).then((r) => r.rows), | |
| 203 | + db.execute<Record<string, unknown>>(sql`select coalesce(sector, '-') as sector, count(*)::int as seeds, count(*) filter (where status = 'queued')::int as queued, count(*) filter (where status = 'discovering')::int as discovering, count(*) filter (where status in ('discovered','done'))::int as discovered, count(*) filter (where status = 'blocked')::int as blocked, count(*) filter (where status = 'error')::int as errors, coalesce(sum(shadow),0)::int as shadow, coalesce(sum(accepted),0)::int as accepted, coalesce(sum(rejected),0)::int as rejected from factory_seeds group by 1 order by seeds desc`).then((r) => r.rows), | |
| 204 | + db.execute<Record<string, unknown>>(sql`select id, name, domain, sector, status, candidates, shadow, accepted, rejected, last_error, discovered_at, source_id from factory_seeds where status <> 'queued' order by updated_at desc limit 40`).then((r) => r.rows), | |
| 205 | + factoryStatus(), | |
| 206 | + db.execute<Record<string, unknown>>(sql`select kind_class, count(*)::int as n from discovery_candidates where status = 'shadow' group by 1 order by n desc`).then((r) => r.rows), | |
| 207 | + ]); | |
| 208 | + return { seeds, candidates: cands, shadow: shadows, shadow_by_kind: ready, daily, sectors, recent_seeds: recent, heartbeat: hb, generated_at: new Date().toISOString() }; | |
| 209 | + }); | |
| 210 | + app.get<{ Querystring: { status?: string; sector?: string; q?: string; limit?: string } }>("/api/v1/admin/factory/seeds", async (req) => { | |
| 211 | + const lim = Math.min(1000, Number(req.query.limit ?? 200)); | |
| 212 | + const rows = await db.execute<Record<string, unknown>>(sql`select * from factory_seeds where 1=1 ${req.query.status ? sql`and status = ${req.query.status}` : sql``} ${req.query.sector ? sql`and sector = ${req.query.sector}` : sql``} ${req.query.q ? sql`and (name ilike ${"%" + req.query.q + "%"} or domain ilike ${"%" + req.query.q + "%"})` : sql``} order by importance desc, updated_at desc limit ${lim}`); | |
| 213 | + return { items: rows.rows }; | |
| 214 | + }); | |
| 215 | + /** Bulk seeds `{ seeds: [{ name, domain, categories?, country?, tier?, importance?, aliases?, sector?, universe?, hints? }] }` — queued for the factory process. */ | |
| 216 | + app.post("/api/v1/admin/factory/seeds", async (req) => { | |
| 217 | + const seedSchema = z.object({ id: z.string().regex(/^[a-z0-9][a-z0-9-]*$/).optional(), name: z.string().min(1), domain: z.string().min(3), categories: z.array(z.string()).default([]), country: z.string().regex(/^[A-Z]{2,3}$/).optional(), language: z.string().optional(), tier: z.enum(["S", "A", "B", "C", "D"]).default("B"), importance: z.number().int().min(1).max(3).default(2), aliases: z.array(z.string()).default([]), first_party: z.boolean().default(true), sector: z.string().optional(), universe: z.string().optional(), hints: z.record(z.string(), z.unknown()).default({}) }); | |
| 218 | + const body = z.object({ seeds: z.array(seedSchema).min(1).max(2000) }).parse(req.body ?? {}); | |
| 219 | + let n = 0; | |
| 220 | + for (const s of body.seeds) { | |
| 221 | + const domain = s.domain.toLowerCase().replace(/^https?:\/\//, "").replace(/^www\./, "").replace(/\/.*$/, ""); | |
| 222 | + const id = s.id ?? domain.replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "").slice(0, 60); | |
| 223 | + await db.execute(sql`insert into factory_seeds (id, name, domain, categories, country, language, tier, importance, aliases, first_party, sector, universe, hints, status) | |
| 224 | + values (${id}, ${s.name}, ${domain}, ${sql.raw("'{" + s.categories.map((c) => '"' + c.replace(/"/g, "") + '"').join(",") + "}'::text[]")}, ${s.country ?? null}, ${s.language ?? null}, ${s.tier}, ${s.importance}, ${sql.raw("'{" + s.aliases.map((c) => '"' + c.replace(/["\\]/g, "") + '"').join(",") + "}'::text[]")}, ${s.first_party}, ${s.sector ?? null}, ${s.universe ?? null}, ${JSON.stringify(s.hints)}::jsonb, 'queued') | |
| 225 | + on conflict (id) do update set name = excluded.name, hints = factory_seeds.hints || excluded.hints, status = 'queued', updated_at = now()`); | |
| 226 | + n++; | |
| 227 | + } | |
| 228 | + invalidate("coverage"); | |
| 229 | + return { ok: true, queued: n }; | |
| 230 | + }); | |
| 231 | + app.post<{ Params: { id: string } }>("/api/v1/admin/factory/seeds/:id/requeue", async (req, reply) => { | |
| 232 | + const r = await db.execute(sql`update factory_seeds set status = 'queued', updated_at = now() where id = ${req.params.id}`); | |
| 233 | + if (!r.rowCount) return reply.status(404).send({ error: "not_found" }); | |
| 234 | + return { ok: true }; | |
| 235 | + }); | |
| 236 | + app.get<{ Querystring: { status?: string; seed?: string; source?: string; kind?: string; limit?: string } }>("/api/v1/admin/factory/candidates", async (req) => { | |
| 237 | + const lim = Math.min(1000, Number(req.query.limit ?? 200)); | |
| 238 | + const rows = await db.execute<Record<string, unknown>>(sql`select c.*, s.health as shadow_health, s.total_runs as shadow_runs, s.raw_changes as shadow_changes from discovery_candidates c left join sensors s on s.id = c.shadow_sensor_id where 1=1 ${req.query.status ? sql`and c.status = ${req.query.status}` : sql``} ${req.query.seed ? sql`and c.seed_id = ${req.query.seed}` : sql``} ${req.query.source ? sql`and c.source_id = ${req.query.source}` : sql``} ${req.query.kind ? sql`and c.kind_class = ${req.query.kind}` : sql``} order by c.updated_at desc limit ${lim}`); | |
| 239 | + return { items: rows.rows }; | |
| 240 | + }); | |
| 241 | + /** Manual decision on a shadow sensor: accept now (→ ACTIVE) or reject (→ DISABLED). */ | |
| 242 | + app.post<{ Params: { id: string; action: string } }>("/api/v1/admin/factory/shadow/:id/:action", async (req, reply) => { | |
| 243 | + const s = (await db.execute<Record<string, unknown>>(sql`select id, config, status from sensors where id = ${req.params.id}`)).rows[0]; | |
| 244 | + if (!s) return reply.status(404).send({ error: "not_found" }); | |
| 245 | + const reason = `manual ${req.params.action} via admin API`; | |
| 246 | + if (req.params.action === "accept") { | |
| 247 | + const tp = Number(((s.config as Record<string, unknown>).targetPriority as number | undefined) ?? 2); | |
| 248 | + await db.execute(sql`update sensors set status = 'ACTIVE', enabled = true, priority = ${tp}, config = (config - 'shadow') || jsonb_build_object('acceptedAt', now()::text, 'shadowReport', ${reason}::text), updated_at = now() where id = ${req.params.id}`); | |
| 249 | + await db.execute(sql`update discovery_candidates set status = 'accepted', reason = ${reason}, decided_at = now(), updated_at = now() where shadow_sensor_id = ${req.params.id}`); | |
| 250 | + } else if (req.params.action === "reject") { | |
| 251 | + await db.execute(sql`update sensors set status = 'DISABLED', enabled = false, config = config || jsonb_build_object('rejectedAt', now()::text, 'shadowReport', ${reason}::text), updated_at = now() where id = ${req.params.id}`); | |
| 252 | + await db.execute(sql`update discovery_candidates set status = 'rejected', reason = ${reason}, decided_at = now(), updated_at = now() where shadow_sensor_id = ${req.params.id}`); | |
| 253 | + } else return reply.status(400).send({ error: "action must be accept or reject" }); | |
| 254 | + invalidate(""); | |
| 255 | + return { ok: true }; | |
| 256 | + }); | |
| 257 | + /** Promote a plain candidate (never shadowed) straight into shadow monitoring. */ | |
| 258 | + app.post<{ Params: { id: string } }>("/api/v1/admin/factory/candidates/:id/shadow", async (req, reply) => { | |
| 259 | + const c = (await db.execute<Record<string, unknown>>(sql`select * from discovery_candidates where id = ${req.params.id}`)).rows[0]; | |
| 260 | + if (!c) return reply.status(404).send({ error: "not_found" }); | |
| 261 | + if (!c.connector || !c.type) return reply.status(400).send({ error: "candidate has no connector/type (legacy discovery row)" }); | |
| 262 | + const src = (await db.execute<Record<string, unknown>>(sql`select categories from sources where id = ${String(c.source_id)}`)).rows[0]; | |
| 263 | + const id = `${String(c.source_id)}_${String(c.name ?? c.kind_class ?? "page").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "")}-${String(c.id).slice(-5)}`.slice(0, 80); | |
| 264 | + await db.execute(sql`insert into sensors (id, source_id, name, url, type, connector, tier, config, status, priority, validated_at) | |
| 265 | + values (${id}, ${String(c.source_id)}, ${String(c.name ?? c.kind_class ?? "page")}, ${String(c.url)}, ${String(c.type)}, ${String(c.connector)}, ${String(c.tier ?? "C")}, ${JSON.stringify({ ...((c.config as Record<string, unknown>) ?? {}), factory: true, shadow: true, seed: false, kind: c.kind_class, candidate: c.id, seedId: c.seed_id, shadowSince: new Date().toISOString(), targetPriority: 2, categories: src?.categories ?? [] })}::jsonb, 'SHADOW', 3, now()) | |
| 266 | + on conflict (id) do nothing`); | |
| 267 | + await db.execute(sql`update discovery_candidates set status = 'shadow', shadow_sensor_id = ${id}, updated_at = now() where id = ${req.params.id}`); | |
| 268 | + return { ok: true, sensor_id: id }; | |
| 269 | + }); | |
| 270 | + | |
| 196 | 271 | app.get("/api/v1/admin/failures", async () => { |
| 197 | 272 | const rows = await db.execute<Record<string, unknown>>(sql`select r.started_at, r.outcome, r.http_status, r.error, r.duration_ms, s.id as sensor_id, s.name, s.source_id, s.url, s.connector from sensor_runs r join sensors s on s.id = r.sensor_id where r.outcome in ('error','parse_error','rate_limited') and r.started_at >= now() - interval '24 hours' order by r.started_at desc limit 200`); |
| 198 | 273 | return { items: rows.rows }; |
modified
apps/api/src/routes.ts
+10 −1
@@ -5,6 +5,7 @@ import { db, sql, textArray } from "@websensor/db"; | ||
| 5 | 5 | import { getBlobStore } from "@websensor/store"; |
| 6 | 6 | import { cached } from "./cache"; |
| 7 | 7 | import { config } from "./config"; |
| 8 | +import { coverageSector, coverageSummary } from "./coverage"; | |
| 8 | 9 | import { breakingDesk, categoryDesk, clusterDetail, countryDesk, countryList, entityInsights, entityRankings, pulse, radar, sourceSensors } from "./intel"; |
| 9 | 10 | import { engineStatus, liveStats } from "./live"; |
| 10 | 11 | import { countEvents, getEvent, listEvents, relatedEvents, sensorHistory, sourceActivity, sourceQuality, stats, trending } from "./queries"; |
@@ -139,7 +140,8 @@ export async function registerRoutes(app: FastifyInstance): Promise<void> { | ||
| 139 | 140 | const q = req.query.q; |
| 140 | 141 | const rows = await db.execute<Record<string, unknown>>(sql` |
| 141 | 142 | select s.id, s.name, s.domain, s.homepage, s.description, s.categories, s.tier, s.importance_weight, s.enabled, s.notes, s.first_party, s.country, s.language, s.robots_checked_at, |
| 142 | − (select count(*) from sensors x where x.source_id = s.id and x.enabled)::int as sensor_count, | |
| 143 | + (select count(*) from sensors x where x.source_id = s.id and x.enabled and x.status <> 'SHADOW')::int as sensor_count, | |
| 144 | + (select count(*) from sensors x where x.source_id = s.id and x.enabled and x.status = 'SHADOW')::int as shadow_count, s.origin, s.sector, | |
| 143 | 145 | (select count(*) from events e where e.source_id = s.id)::int as event_count, |
| 144 | 146 | (select count(*) from events e where e.source_id = s.id and e.detected_at >= now() - interval '24 hours')::int as events_24h, |
| 145 | 147 | (select max(detected_at) from events e where e.source_id = s.id) as last_event_at, |
@@ -247,6 +249,13 @@ export async function registerRoutes(app: FastifyInstance): Promise<void> { | ||
| 247 | 249 | |
| 248 | 250 | // ---- Intelligence desks ---------------------------------------------------------------------- |
| 249 | 251 | app.get("/api/v1/stats", async () => cached("stats", 5_000, stats)); |
| 252 | + // ---- Global Observation Coverage Score ------------------------------------------------------ | |
| 253 | + app.get("/api/v1/coverage", async () => coverageSummary()); | |
| 254 | + app.get<{ Params: { sector: string } }>("/api/v1/coverage/:sector", async (req, reply) => { | |
| 255 | + const r = await coverageSector(req.params.sector.toLowerCase()); | |
| 256 | + if (!r) return reply.status(404).send({ error: "not_found" }); | |
| 257 | + return r; | |
| 258 | + }); | |
| 250 | 259 | app.get<{ Querystring: { hours?: string; limit?: string } }>("/api/v1/trending", async (req) => { |
| 251 | 260 | const hours = Math.min(168, Number(req.query.hours ?? 24)); |
| 252 | 261 | const limit = Math.min(50, Number(req.query.limit ?? 12)); |
modified
apps/engine/src/cli.ts
+49 −1
@@ -80,8 +80,56 @@ async function main(): Promise<void> { | ||
| 80 | 80 | console.log(JSON.stringify(out, null, 2), `\n${Date.now() - t0} ms`); |
| 81 | 81 | break; |
| 82 | 82 | } |
| 83 | + case "expand": { | |
| 84 | + // Deep discovery dry-run (no DB writes): what the Source Factory would find for a domain. | |
| 85 | + const { discoverOrganization } = await import("@websensor/connectors"); | |
| 86 | + const { scoreCandidate } = await import("./factory/score"); | |
| 87 | + const hintsArg = args.find((a) => a.startsWith("--hints=")); | |
| 88 | + const hints = hintsArg ? (JSON.parse(hintsArg.slice(8)) as Record<string, unknown>) : {}; | |
| 89 | + const r = await discoverOrganization(args[0]!, { hints, probePosture: args.includes("--posture") }); | |
| 90 | + console.log(`${r.domain} → origin ${r.origin} · home ${r.homeStatus}${r.blocked ? " BLOCKED" : ""} · ${r.requests} requests · ${r.durationMs} ms`); | |
| 91 | + for (const n of r.notes) console.log(` note: ${n}`); | |
| 92 | + for (const c of r.candidates) { | |
| 93 | + const s = scoreCandidate(c, { importance: Number(args.find((a) => a.startsWith("--importance="))?.slice(13) ?? 2) }); | |
| 94 | + console.log(`${s.score.toFixed(2)} ${c.kind.padEnd(10)} ${c.connector.padEnd(10)} ${c.suggestedTier} ${String(c.itemCount ?? "").padStart(4)} ${c.url} (${c.evidence})`); | |
| 95 | + } | |
| 96 | + if (r.rejected.length) console.log(`rejected: ${r.rejected.map((x) => `${x.url} [${x.reason}]`).join("\n ")}`); | |
| 97 | + break; | |
| 98 | + } | |
| 99 | + case "factory": { | |
| 100 | + const f = await import("./factory"); | |
| 101 | + const sub = args[0]; | |
| 102 | + const flagVal = (n: string): string | undefined => args.find((a) => a.startsWith(`--${n}=`))?.slice(n.length + 3); | |
| 103 | + if (sub === "seed") { | |
| 104 | + const files = await f.seedFromFiles(); | |
| 105 | + const mode = (flagVal("mode") ?? "hinted") as "uncovered" | "hinted" | "all"; | |
| 106 | + const cov = await f.seedFromCoverage({ mode, sectors: flagVal("sector")?.split(","), requeue: args.includes("--requeue") }); | |
| 107 | + console.log(JSON.stringify({ files, coverage: cov }, null, 2)); | |
| 108 | + } else if (sub === "run") { | |
| 109 | + const n = Number(flagVal("n") ?? 6); | |
| 110 | + const ids = args.slice(1).filter((a) => !a.startsWith("--")); | |
| 111 | + const out = await f.runFactoryBatch(n, ids.length ? { seedIds: ids } : {}); | |
| 112 | + for (const o of out) console.log(`${o.status.padEnd(10)} ${o.seedId.padEnd(40)} source=${o.sourceId ?? "-"} candidates=${o.candidates} shadow=${o.shadow} requests=${o.requests} ${o.durationMs} ms${o.notes.length ? " · " + o.notes.join(" · ") : ""}`); | |
| 113 | + } else if (sub === "evaluate") { | |
| 114 | + const r = await f.evaluateShadows({ force: args.includes("--force") }); | |
| 115 | + for (const d of r.decisions.filter((x) => x.decision !== "deferred" || args.includes("--verbose"))) console.log(`${d.decision.padEnd(9)} ${d.sensorId.padEnd(60)} ${d.reason}`); | |
| 116 | + console.log(JSON.stringify({ accepted: r.accepted, rejected: r.rejected, deferred: r.deferred })); | |
| 117 | + } else if (sub === "stats") { | |
| 118 | + console.log(JSON.stringify(await f.factoryStats(), null, 2)); | |
| 119 | + } else if (sub === "export") { | |
| 120 | + process.stdout.write(await f.exportFactoryFragment({ sector: flagVal("sector"), sinceDays: flagVal("since") ? Number(flagVal("since")) : undefined, includeShadow: args.includes("--include-shadow") })); | |
| 121 | + } else if (sub === "requeue") { | |
| 122 | + const ids = args.slice(1).filter((a) => !a.startsWith("--")); | |
| 123 | + const r = await db.execute(sql`update factory_seeds set status = 'queued', updated_at = now() where ${ids.length ? sql`id = any(${sql.raw("array[" + ids.map((i) => "'" + i.replace(/'/g, "''") + "'").join(",") + "]::text[]")})` : sql`status in ('error','blocked')`}`); | |
| 124 | + console.log(`requeued ${r.rowCount ?? 0}`); | |
| 125 | + } else { | |
| 126 | + console.error("usage: cli.ts factory seed [--mode=uncovered|hinted|all] [--sector=a,b] [--requeue] | run [seedId…] [--n=6] | evaluate [--force] [--verbose] | stats | export [--sector=x] [--since=days] [--include-shadow] | requeue [seedId…]"); | |
| 127 | + process.exitCode = 1; | |
| 128 | + } | |
| 129 | + break; | |
| 130 | + } | |
| 83 | 131 | default: |
| 84 | − console.error("usage: cli.ts sync | discover [sourceId…] | probe <domain> | run-once <sensorId> | run-due [n] | relink-entities [days] | prune-blobs [batch] | refresh-clusters"); | |
| 132 | + console.error("usage: cli.ts sync | discover [sourceId…] | probe <domain> | expand <domain> [--hints=json] [--posture] | run-once <sensorId> | run-due [n] | relink-entities [days] | prune-blobs [batch] | refresh-clusters | factory <seed|run|evaluate|stats|export|requeue>"); | |
| 85 | 133 | process.exitCode = 1; |
| 86 | 134 | } |
| 87 | 135 | } |
modified
apps/engine/src/config.ts
+22 −1
@@ -48,7 +48,7 @@ export const config = { | ||
| 48 | 48 | retention: { rawDays: Number(env.WS_RETENTION_RAW_DAYS ?? 21), changedRawDays: Number(env.WS_RETENTION_CHANGED_RAW_DAYS ?? 60), enabled: (env.WS_RETENTION ?? "1") !== "0" }, |
| 49 | 49 | /** silent-change bar: only eligible types with importance ≥ this become "silent" */ |
| 50 | 50 | silentMinImportance: Number(env.WS_SILENT_MIN_IMPORTANCE ?? 45), |
| 51 | − version: "0.2.0", | |
| 51 | + version: "0.3.0", | |
| 52 | 52 | }; |
| 53 | 53 | |
| 54 | 54 | export const log = pino({ |
@@ -56,3 +56,24 @@ export const log = pino({ | ||
| 56 | 56 | ...(config.env !== "production" ? { transport: { target: "pino-pretty", options: { colorize: true, translateTime: "HH:MM:ss" } } } : {}), |
| 57 | 57 | base: { service: "websensor-engine" }, |
| 58 | 58 | }); |
| 59 | + | |
| 60 | +/** Source Factory (apps/engine/src/factory) — organization expansion, shadow monitoring, acceptance. */ | |
| 61 | +export const factoryConfig = { | |
| 62 | + enabled: (env.WS_FACTORY ?? "1") !== "0", | |
| 63 | + /** seeds discovered in parallel */ | |
| 64 | + concurrency: Number(env.WS_FACTORY_CONCURRENCY ?? 6), | |
| 65 | + /** HTTP requests per organization during discovery */ | |
| 66 | + budget: Number(env.WS_FACTORY_BUDGET ?? 90), | |
| 67 | + /** minimum candidate score to enter shadow monitoring */ | |
| 68 | + minScore: Number(env.WS_FACTORY_MIN_SCORE ?? 0.35), | |
| 69 | + /** shadow evaluation: checks needed, minimum and maximum shadow duration */ | |
| 70 | + shadowMinChecks: Number(env.WS_FACTORY_SHADOW_MIN_CHECKS ?? 5), | |
| 71 | + shadowMinHours: Number(env.WS_FACTORY_SHADOW_MIN_HOURS ?? 24), | |
| 72 | + shadowMaxHours: Number(env.WS_FACTORY_SHADOW_MAX_HOURS ?? 120), | |
| 73 | + /** per-organization promotion caps */ | |
| 74 | + caps: { feeds: 4, pages: 6, releases: 4, sitemap: 1, status: 1, models: 1, filings: 1, api: 1, posture: 3, legal: 2, total: 18 }, | |
| 75 | + /** seconds between factory ticks */ | |
| 76 | + tickSeconds: Number(env.WS_FACTORY_TICK_SECONDS ?? 20), | |
| 77 | + coverageDir: env.WS_COVERAGE_DIR ?? "./config/coverage", | |
| 78 | + seedsDir: env.WS_FACTORY_SEEDS_DIR ?? "./config/factory/seeds", | |
| 79 | +}; | |
added
apps/engine/src/coverage-validate.ts
+108 −0
@@ -0,0 +1,108 @@ | ||
| 1 | +import { promises as dns } from "node:dns"; | |
| 2 | +import { existsSync, readdirSync, readFileSync, statSync } from "node:fs"; | |
| 3 | +import { join } from "node:path"; | |
| 4 | +import YAML from "yaml"; | |
| 5 | +import { coverageKey, coverageSectorSchema, type CoverageSector } from "@websensor/core"; | |
| 6 | + | |
| 7 | +/** | |
| 8 | + * Coverage-universe validator — no database. | |
| 9 | + * | |
| 10 | + * node node_modules/tsx/dist/cli.mjs apps/engine/src/coverage-validate.ts [config/coverage/<file>.yaml …] [--dns] [--quiet] | |
| 11 | + * | |
| 12 | + * Without arguments every file in config/coverage is checked. Reports schema issues, duplicate domains inside a | |
| 13 | + * file, duplicate sector keys across files and (with --dns) domains that do not resolve. Exit code 1 on any error. | |
| 14 | + */ | |
| 15 | +const args = process.argv.slice(2); | |
| 16 | +const flag = (n: string): boolean => args.includes(`--${n}`); | |
| 17 | +const files = args.filter((a) => !a.startsWith("--")); | |
| 18 | + | |
| 19 | +export function loadCoverageFiles(dir = process.env.WS_COVERAGE_DIR ?? "./config/coverage"): { file: string; sector: CoverageSector | null; issues: string[] }[] { | |
| 20 | + const list = files.length ? files : existsSync(dir) && statSync(dir).isDirectory() ? readdirSync(dir).filter((f) => /\.ya?ml$/.test(f) && !f.startsWith("_")).sort().map((f) => join(dir, f)) : []; | |
| 21 | + return list.map((file) => { | |
| 22 | + const issues: string[] = []; | |
| 23 | + let sector: CoverageSector | null = null; | |
| 24 | + try { | |
| 25 | + const raw = YAML.parse(readFileSync(file, "utf8")); | |
| 26 | + const parsed = coverageSectorSchema.safeParse(raw); | |
| 27 | + if (!parsed.success) { | |
| 28 | + for (const i of parsed.error.issues.slice(0, 40)) issues.push(`${i.path.join(".")}: ${i.message}`); | |
| 29 | + if (parsed.error.issues.length > 40) issues.push(`… ${parsed.error.issues.length - 40} more issues`); | |
| 30 | + } else sector = parsed.data; | |
| 31 | + } catch (e) { | |
| 32 | + issues.push(`YAML parse error: ${(e as Error).message}`); | |
| 33 | + } | |
| 34 | + if (sector) { | |
| 35 | + const seen = new Map<string, string>(); | |
| 36 | + const keys = new Set<string>(); | |
| 37 | + for (const u of sector.universes) { | |
| 38 | + if (keys.has(u.key)) issues.push(`duplicate universe key ${u.key}`); | |
| 39 | + keys.add(u.key); | |
| 40 | + if (!u.members.length) issues.push(`universe ${u.key} has no members`); | |
| 41 | + for (const m of u.members) { | |
| 42 | + if (!/^[a-z0-9.-]+\.[a-z]{2,}$/.test(m.domain) || m.domain.includes("..")) issues.push(`${u.key}: bad domain "${m.domain}" (${m.name})`); | |
| 43 | + const k = coverageKey(m.domain); | |
| 44 | + const prev = seen.get(k); | |
| 45 | + if (prev && prev !== `${u.key}/${m.name}`) issues.push(`${u.key}: duplicate domain ${m.domain} (${m.name}) — already ${prev}`); | |
| 46 | + seen.set(k, `${u.key}/${m.name}`); | |
| 47 | + if (!m.country && !/^(INT|EU)$/.test(String(m.country))) issues.push(`${u.key}: ${m.name} has no country`); | |
| 48 | + } | |
| 49 | + } | |
| 50 | + } | |
| 51 | + return { file, sector, issues }; | |
| 52 | + }); | |
| 53 | +} | |
| 54 | + | |
| 55 | +async function resolves(domain: string): Promise<boolean> { | |
| 56 | + for (const h of [domain, `www.${domain}`]) { | |
| 57 | + try { | |
| 58 | + const r = await dns.lookup(h, { all: true }); | |
| 59 | + if (r.length) return true; | |
| 60 | + } catch { | |
| 61 | + // try next | |
| 62 | + } | |
| 63 | + } | |
| 64 | + return false; | |
| 65 | +} | |
| 66 | + | |
| 67 | +async function main(): Promise<void> { | |
| 68 | + const loaded = loadCoverageFiles(); | |
| 69 | + if (!loaded.length) { | |
| 70 | + console.error("no coverage files found"); | |
| 71 | + process.exitCode = 1; | |
| 72 | + return; | |
| 73 | + } | |
| 74 | + let errors = 0; | |
| 75 | + const sectors = new Map<string, string>(); | |
| 76 | + for (const l of loaded) { | |
| 77 | + const members = l.sector?.universes.reduce((n, u) => n + u.members.length, 0) ?? 0; | |
| 78 | + const warnIssues = l.issues.filter((i) => / has no country$/.test(i)); | |
| 79 | + const hardIssues = l.issues.filter((i) => !/ has no country$/.test(i)); | |
| 80 | + errors += hardIssues.length; | |
| 81 | + // Several files may extend the same sector (e.g. corporate indices per region); universes are merged by the loader. | |
| 82 | + if (l.sector && !sectors.has(l.sector.sector)) sectors.set(l.sector.sector, l.file); | |
| 83 | + console.log(`${hardIssues.length ? "FAIL" : "OK "} ${l.file} sector=${l.sector?.sector ?? "-"} universes=${l.sector?.universes.length ?? 0} members=${members}${warnIssues.length ? ` warn=${warnIssues.length}` : ""}`); | |
| 84 | + for (const i of hardIssues) console.log(` ${i}`); | |
| 85 | + if (!flag("quiet")) for (const i of warnIssues.slice(0, 15)) console.log(` WARN ${i}`); | |
| 86 | + if (flag("dns") && l.sector) { | |
| 87 | + const domains = [...new Set(l.sector.universes.flatMap((u) => u.members.map((m) => m.domain)))]; | |
| 88 | + let i = 0; | |
| 89 | + const bad: string[] = []; | |
| 90 | + await Promise.all( | |
| 91 | + Array.from({ length: 16 }, async () => { | |
| 92 | + while (i < domains.length) { | |
| 93 | + const d = domains[i++]!; | |
| 94 | + if (!(await resolves(d))) bad.push(d); | |
| 95 | + } | |
| 96 | + }), | |
| 97 | + ); | |
| 98 | + for (const d of bad.sort()) console.log(` DNS ${d} does not resolve`); | |
| 99 | + if (bad.length) console.log(` ${bad.length}/${domains.length} domains do not resolve (fix or remove them)`); | |
| 100 | + errors += bad.length; | |
| 101 | + } | |
| 102 | + } | |
| 103 | + const total = loaded.reduce((n, l) => n + (l.sector?.universes.reduce((m, u) => m + u.members.length, 0) ?? 0), 0); | |
| 104 | + console.log(`\n${loaded.length} files · ${sectors.size} sectors · ${total} members → ${errors ? `${errors} errors` : "all good"}`); | |
| 105 | + if (errors) process.exitCode = 1; | |
| 106 | +} | |
| 107 | + | |
| 108 | +if (process.argv[1] && /coverage-validate\.ts$/.test(process.argv[1])) void main(); | |
added
apps/engine/src/factory-main.ts
+99 −0
@@ -0,0 +1,99 @@ | ||
| 1 | +import { closeDb, db, migrate, sql } from "@websensor/db"; | |
| 2 | +import { closeDispatcher } from "@websensor/connectors"; | |
| 3 | +import { config, factoryConfig, log } from "./config"; | |
| 4 | +import { evaluateShadows, factoryStats, runFactoryBatch, seedFromCoverage, seedFromFiles } from "./factory"; | |
| 5 | +import { closeRedis, getRedis } from "./redis"; | |
| 6 | + | |
| 7 | +/** | |
| 8 | + * Source Factory process (`websensor-factory`): a slow, polite background worker separate from the poller. | |
| 9 | + * every tick — claim queued seeds (systemic organizations first) and run deep discovery + shadow creation | |
| 10 | + * every 15 min — evaluate shadow sensors (accept / reject / defer) | |
| 11 | + * every 24 h — re-seed from the coverage universes (new members become seeds automatically) | |
| 12 | + * Heartbeat: Redis `ws:factory:status`. | |
| 13 | + */ | |
| 14 | +async function heartbeat(extra: Record<string, unknown>): Promise<void> { | |
| 15 | + try { | |
| 16 | + await getRedis().set("ws:factory:status", JSON.stringify({ ...extra, at: new Date().toISOString(), pid: process.pid, version: config.version }), "EX", 120); | |
| 17 | + } catch { | |
| 18 | + // best effort | |
| 19 | + } | |
| 20 | +} | |
| 21 | + | |
| 22 | +async function main(): Promise<void> { | |
| 23 | + log.info({ env: config.env, version: config.version, concurrency: factoryConfig.concurrency, budget: factoryConfig.budget }, "websensor factory starting"); | |
| 24 | + const applied = await migrate(config.databaseUrl); | |
| 25 | + if (applied.length) log.info({ applied }, "migrations applied"); | |
| 26 | + if (!factoryConfig.enabled) { | |
| 27 | + log.warn("WS_FACTORY=0 — factory idle"); | |
| 28 | + setInterval(() => void heartbeat({ idle: true }), 60_000); | |
| 29 | + return; | |
| 30 | + } | |
| 31 | + let stopping = false; | |
| 32 | + let busy = false; | |
| 33 | + let processed = 0; | |
| 34 | + let lastSeed = 0; | |
| 35 | + let lastEval = 0; | |
| 36 | + | |
| 37 | + const seed = async (): Promise<void> => { | |
| 38 | + try { | |
| 39 | + const a = await seedFromFiles(); | |
| 40 | + const b = await seedFromCoverage({ mode: "hinted" }); | |
| 41 | + log.info({ files: a, coverage: { members: b.members, seeds: b.seeds, inserted: b.inserted, issues: b.issues.length } }, "factory: seeds refreshed"); | |
| 42 | + for (const i of b.issues) log.warn({ issue: i }, "coverage file issue"); | |
| 43 | + } catch (e) { | |
| 44 | + log.error({ err: (e as Error).message }, "factory: seeding failed"); | |
| 45 | + } | |
| 46 | + }; | |
| 47 | + | |
| 48 | + const tick = async (): Promise<void> => { | |
| 49 | + if (stopping || busy) return; | |
| 50 | + busy = true; | |
| 51 | + try { | |
| 52 | + if (Date.now() - lastSeed > 24 * 3600e3) { | |
| 53 | + lastSeed = Date.now(); | |
| 54 | + await seed(); | |
| 55 | + } | |
| 56 | + if (Date.now() - lastEval > 15 * 60e3) { | |
| 57 | + lastEval = Date.now(); | |
| 58 | + await evaluateShadows().catch((e) => log.warn({ err: (e as Error).message }, "factory: shadow evaluation failed")); | |
| 59 | + } | |
| 60 | + const out = await runFactoryBatch(factoryConfig.concurrency); | |
| 61 | + processed += out.length; | |
| 62 | + const queued = Number((await db.execute<{ n: string }>(sql`select count(*)::text as n from factory_seeds where status = 'queued'`)).rows[0]?.n ?? 0); | |
| 63 | + await heartbeat({ processed, queued, lastBatch: out.map((o) => ({ seed: o.seedId, status: o.status, candidates: o.candidates, shadow: o.shadow, requests: o.requests, ms: o.durationMs })), concurrency: factoryConfig.concurrency }); | |
| 64 | + if (!out.length && processed % 50 === 0) { | |
| 65 | + const st = await factoryStats(); | |
| 66 | + log.info({ seeds: st.seeds, candidates: st.candidates, shadow: st.shadow }, "factory: idle"); | |
| 67 | + } | |
| 68 | + } catch (e) { | |
| 69 | + log.error({ err: (e as Error).stack ?? (e as Error).message }, "factory tick failed"); | |
| 70 | + } finally { | |
| 71 | + busy = false; | |
| 72 | + } | |
| 73 | + }; | |
| 74 | + | |
| 75 | + await heartbeat({ starting: true }); | |
| 76 | + void tick(); | |
| 77 | + const timer = setInterval(() => void tick(), factoryConfig.tickSeconds * 1000); | |
| 78 | + | |
| 79 | + const shutdown = async (signal: string): Promise<void> => { | |
| 80 | + if (stopping) return; | |
| 81 | + stopping = true; | |
| 82 | + log.info({ signal }, "factory shutting down"); | |
| 83 | + clearInterval(timer); | |
| 84 | + const deadline = Date.now() + 60_000; | |
| 85 | + while (busy && Date.now() < deadline) await new Promise((r) => setTimeout(r, 250)); | |
| 86 | + await closeDispatcher(); | |
| 87 | + await closeRedis(); | |
| 88 | + await closeDb(); | |
| 89 | + process.exit(0); | |
| 90 | + }; | |
| 91 | + process.on("SIGINT", () => void shutdown("SIGINT")); | |
| 92 | + process.on("SIGTERM", () => void shutdown("SIGTERM")); | |
| 93 | + process.on("unhandledRejection", (e) => log.error({ err: e instanceof Error ? e.stack : String(e) }, "unhandled rejection")); | |
| 94 | +} | |
| 95 | + | |
| 96 | +main().catch((e) => { | |
| 97 | + log.fatal({ err: (e as Error).stack ?? String(e) }, "factory failed to start"); | |
| 98 | + process.exit(1); | |
| 99 | +}); | |
added
apps/engine/src/factory/export.ts
+46 −0
@@ -0,0 +1,46 @@ | ||
| 1 | +import YAML from "yaml"; | |
| 2 | +import { db, sql } from "@websensor/db"; | |
| 3 | + | |
| 4 | +/** | |
| 5 | + * Export factory-created sources and accepted sensors as a registry fragment (reviewable YAML). Graduating a | |
| 6 | + * fragment into `config/sources.d/` makes the registry sync own those sensors (`seed: true`) — the Factory never | |
| 7 | + * re-creates them because their URLs already exist. | |
| 8 | + */ | |
| 9 | +export async function exportFactoryFragment(opts: { sector?: string; sinceDays?: number; includeShadow?: boolean } = {}): Promise<string> { | |
| 10 | + const rows = await db.execute<Record<string, unknown>>(sql` | |
| 11 | + select so.id, so.name, so.domain, so.homepage, so.categories, so.tier, so.importance_weight, so.country, so.language, so.first_party, so.origin, so.sector, so.notes, | |
| 12 | + coalesce(json_agg(json_build_object('name', s.name, 'url', s.url, 'type', s.type, 'connector', s.connector, 'tier', s.tier, 'config', s.config - 'factory' - 'shadow' - 'seed' - 'candidate' - 'seedId' - 'shadowSince' - 'targetPriority' - 'acceptedAt' - 'shadowReport' - 'kind') order by s.name) filter (where s.id is not null), '[]'::json) as sensors | |
| 13 | + from sources so | |
| 14 | + left join sensors s on s.source_id = so.id and s.enabled and (s.config->>'factory') = 'true' and (s.status <> 'SHADOW' or ${opts.includeShadow ?? false}) | |
| 15 | + where (so.origin = 'factory' or exists (select 1 from sensors y where y.source_id = so.id and (y.config->>'factory') = 'true')) | |
| 16 | + ${opts.sector ? sql`and so.sector = ${opts.sector}` : sql``} | |
| 17 | + ${opts.sinceDays ? sql`and so.updated_at >= now() - make_interval(days => ${opts.sinceDays})` : sql``} | |
| 18 | + group by so.id order by so.sector, so.id`); | |
| 19 | + const entities = (await db.execute<{ source_id: string; alias: string }>(sql`select se.source_id, ea.alias from source_entities se join entity_aliases ea on ea.entity_id = se.entity_id where se.entity_id like 'org_%'`)).rows; | |
| 20 | + const aliasBySource = new Map<string, string[]>(); | |
| 21 | + for (const e of entities) aliasBySource.set(e.source_id, [...(aliasBySource.get(e.source_id) ?? []), e.alias]); | |
| 22 | + const out: Record<string, unknown>[] = []; | |
| 23 | + for (const r of rows.rows) { | |
| 24 | + const sensors = (r.sensors as Record<string, unknown>[]).map((s) => { | |
| 25 | + const cfg = s.config as Record<string, unknown>; | |
| 26 | + const o: Record<string, unknown> = { name: s.name, url: s.url, type: s.type, connector: s.connector, tier: s.tier }; | |
| 27 | + if (cfg && Object.keys(cfg).length) o.config = cfg; | |
| 28 | + return o; | |
| 29 | + }); | |
| 30 | + if (!sensors.length) continue; | |
| 31 | + const aliases = (aliasBySource.get(String(r.id)) ?? []).filter((a) => a !== String(r.name).toLowerCase() && a !== String(r.domain).toLowerCase() && a !== String(r.domain).replace(/^www\./, "").toLowerCase()); | |
| 32 | + if (r.origin === "factory") { | |
| 33 | + const src: Record<string, unknown> = { id: r.id, name: r.name, domain: r.domain, categories: r.categories, tier: r.tier }; | |
| 34 | + if (Number(r.importance_weight) !== 1) src.weight = Number(r.importance_weight); | |
| 35 | + if (aliases.length) src.aliases = aliases; | |
| 36 | + if (r.country) src.country = r.country; | |
| 37 | + if (r.language) src.language = r.language; | |
| 38 | + if (r.first_party === false) src.first_party = false; | |
| 39 | + if (r.notes) src.notes = r.notes; | |
| 40 | + src.sensors = sensors; | |
| 41 | + out.push(src); | |
| 42 | + } else out.push({ id: r.id, extend: true, sensors }); | |
| 43 | + } | |
| 44 | + const header = `# WebSensor — Source Factory export (${new Date().toISOString().slice(0, 10)}${opts.sector ? `, sector ${opts.sector}` : ""}): ${out.length} organizations, ${out.reduce((n, s) => n + (s.sensors as unknown[]).length, 0)} accepted sensors.\n# Generated by \`cli.ts factory export\`; review, then place in config/sources.d/ to let the registry own these sensors.\n`; | |
| 45 | + return header + YAML.stringify({ sources: out }, { lineWidth: 0, defaultStringType: "PLAIN", defaultKeyType: "PLAIN" }); | |
| 46 | +} | |
added
apps/engine/src/factory/factory.test.ts
+67 −0
@@ -0,0 +1,67 @@ | ||
| 1 | +import { describe, expect, it } from "vitest"; | |
| 2 | +import { coverageKey, registrableDomain } from "@websensor/core"; | |
| 3 | +import type { DeepCandidate } from "@websensor/connectors"; | |
| 4 | +import { itemsPerDayOf } from "@websensor/connectors"; | |
| 5 | +import { KIND_WEIGHT, scoreCandidate, selectForShadow } from "./score"; | |
| 6 | + | |
| 7 | +const cand = (over: Partial<DeepCandidate>): DeepCandidate => ({ url: "https://example.org/feed", type: "RSS", connector: "rss", kind: "news", name: "news feed", config: {}, suggestedTier: "B", evidence: "well-known path", value: 0.8, itemsPerDay: 0.5, firstPartyConfidence: 1, fetchCost: { bytes: 20_000, ms: 400 }, ...over }); | |
| 8 | + | |
| 9 | +describe("registrable domain (coverage key)", () => { | |
| 10 | + it("collapses hosts to the organization's registrable domain", () => { | |
| 11 | + expect(registrableDomain("ir.apple.com")).toBe("apple.com"); | |
| 12 | + expect(registrableDomain("www.ons.gov.uk")).toBe("ons.gov.uk"); | |
| 13 | + expect(registrableDomain("www.bankofengland.co.uk")).toBe("bankofengland.co.uk"); | |
| 14 | + expect(registrableDomain("statcan.gc.ca")).toBe("statcan.gc.ca"); | |
| 15 | + expect(registrableDomain("open.canada.ca")).toBe("canada.ca"); | |
| 16 | + expect(registrableDomain("boj.or.jp")).toBe("boj.or.jp"); | |
| 17 | + expect(coverageKey("https://WWW.Example.COM/path")).toBe("example.com"); | |
| 18 | + }); | |
| 19 | +}); | |
| 20 | + | |
| 21 | +describe("factory scoring", () => { | |
| 22 | + it("weights page classes and organization importance", () => { | |
| 23 | + const status = scoreCandidate(cand({ kind: "status", connector: "statuspage", value: 0.95, itemsPerDay: null }), { importance: 2 }); | |
| 24 | + const careers = scoreCandidate(cand({ kind: "careers", connector: "http", value: 0.35, itemsPerDay: null }), { importance: 2 }); | |
| 25 | + expect(status.score).toBeGreaterThan(careers.score); | |
| 26 | + expect(KIND_WEIGHT.status).toBeGreaterThan(KIND_WEIGHT.careers); | |
| 27 | + const big = scoreCandidate(cand({}), { importance: 3 }); | |
| 28 | + const small = scoreCandidate(cand({}), { importance: 1 }); | |
| 29 | + expect(big.score).toBeGreaterThan(small.score); | |
| 30 | + expect(big.reasons.some((r) => /importance 3/.test(r))).toBe(true); | |
| 31 | + }); | |
| 32 | + it("penalises firehoses, dormant feeds and slow endpoints, explainably", () => { | |
| 33 | + const base = scoreCandidate(cand({}), { importance: 2 }).score; | |
| 34 | + expect(scoreCandidate(cand({ itemsPerDay: 200 }), { importance: 2 }).score).toBeLessThan(base); | |
| 35 | + expect(scoreCandidate(cand({ itemsPerDay: 0.001 }), { importance: 2 }).score).toBeLessThan(base); | |
| 36 | + const slow = scoreCandidate(cand({ fetchCost: { bytes: 100, ms: 12_000 } }), { importance: 2 }); | |
| 37 | + expect(slow.score).toBeLessThan(base); | |
| 38 | + expect(slow.reasons.some((r) => /slow/.test(r))).toBe(true); | |
| 39 | + }); | |
| 40 | + it("applies per-organization caps and the minimum score", () => { | |
| 41 | + const feeds = Array.from({ length: 6 }, (_, i) => scoreCandidate(cand({ url: `https://example.org/f${i}`, value: 0.9 - i * 0.02 }), { importance: 2 })); | |
| 42 | + const low = scoreCandidate(cand({ url: "https://example.org/careers", kind: "careers", connector: "http", value: 0.2, itemsPerDay: null }), { importance: 1 }); | |
| 43 | + const legal = ["terms", "privacy", "cookies"].map((p) => scoreCandidate(cand({ url: `https://example.org/${p}`, kind: "legal", connector: "http", type: "HTML", value: 0.7, itemsPerDay: null }), { importance: 2 })); | |
| 44 | + const { selected, skipped } = selectForShadow([...feeds, low, ...legal], { kinds: new Map(), total: 0 }); | |
| 45 | + expect(selected.filter((s) => s.cand.connector === "rss")).toHaveLength(4); | |
| 46 | + expect(selected.filter((s) => s.cand.kind === "legal")).toHaveLength(2); | |
| 47 | + expect(skipped.some((s) => s.s === low && /score/.test(s.reason))).toBe(true); | |
| 48 | + expect(skipped.some((s) => /feeds cap/.test(s.reason))).toBe(true); | |
| 49 | + }); | |
| 50 | + it("respects sensors the organization already has", () => { | |
| 51 | + const feed = scoreCandidate(cand({}), { importance: 2 }); | |
| 52 | + const { selected, skipped } = selectForShadow([feed], { kinds: new Map([["news", 4]]), total: 4 }); | |
| 53 | + expect(selected).toHaveLength(0); | |
| 54 | + expect(skipped[0]?.reason).toMatch(/feeds cap/); | |
| 55 | + }); | |
| 56 | +}); | |
| 57 | + | |
| 58 | +describe("expected change frequency", () => { | |
| 59 | + it("uses the median gap and survives one bogus date", () => { | |
| 60 | + const day = 86400e3; | |
| 61 | + const now = Date.UTC(2026, 8, 13); | |
| 62 | + const dates = [now, now - day, now - 2 * day, now - 3 * day, now - 4 * day, now - 5000 * day]; | |
| 63 | + expect(itemsPerDayOf(dates)).toBe(1); | |
| 64 | + expect(itemsPerDayOf([now, now - day])).toBeNull(); | |
| 65 | + expect(itemsPerDayOf([now, now - 30 * day, now - 60 * day, now - 90 * day])).toBeCloseTo(0.033, 2); | |
| 66 | + }); | |
| 67 | +}); | |
added
apps/engine/src/factory/index.ts
+5 −0
@@ -0,0 +1,5 @@ | ||
| 1 | +export * from "./seeds"; | |
| 2 | +export * from "./score"; | |
| 3 | +export * from "./run"; | |
| 4 | +export * from "./shadow"; | |
| 5 | +export * from "./export"; | |
added
apps/engine/src/factory/run.ts
+288 −0
@@ -0,0 +1,288 @@ | ||
| 1 | +import { coverageKey, newId, slugify, sourceSchema, type SourceSeed, type Tier } from "@websensor/core"; | |
| 2 | +import { discoverOrganization, type CandidateKind, type DeepDiscoveryHints } from "@websensor/connectors"; | |
| 3 | +import { db, factorySeeds, sensors, sql, type FactorySeed } from "@websensor/db"; | |
| 4 | +import { factoryConfig, log } from "../config"; | |
| 5 | +import { priorityFor, upsertSourceRecord } from "../registry"; | |
| 6 | +import { scoreCandidate, selectForShadow } from "./score"; | |
| 7 | + | |
| 8 | +/** | |
| 9 | + * Source Factory — one organization at a time: | |
| 10 | + * seed → deep discovery → (create or attach the source) → score candidates → promotion policy → | |
| 11 | + * shadow sensors (`status = SHADOW`: observed, evidence stored, nothing published) → later `evaluateShadows()`. | |
| 12 | + */ | |
| 13 | +export interface ProcessOutcome { | |
| 14 | + seedId: string; | |
| 15 | + sourceId: string | null; | |
| 16 | + status: "discovered" | "blocked" | "error"; | |
| 17 | + candidates: number; | |
| 18 | + shadow: number; | |
| 19 | + requests: number; | |
| 20 | + durationMs: number; | |
| 21 | + notes: string[]; | |
| 22 | +} | |
| 23 | + | |
| 24 | +interface SourceRow extends Record<string, unknown> { | |
| 25 | + id: string; | |
| 26 | + domain: string; | |
| 27 | + categories: string[]; | |
| 28 | + tier: string; | |
| 29 | + enabled: boolean; | |
| 30 | +} | |
| 31 | + | |
| 32 | +let sourceIndex: { at: number; byKey: Map<string, SourceRow>; ids: Set<string> } | null = null; | |
| 33 | +async function sourcesByDomain(force = false): Promise<{ byKey: Map<string, SourceRow>; ids: Set<string> }> { | |
| 34 | + if (!force && sourceIndex && Date.now() - sourceIndex.at < 5 * 60_000) return sourceIndex; | |
| 35 | + const rows = await db.execute<SourceRow>(sql`select id, domain, categories, tier, enabled from sources where kind = 'registry'`); | |
| 36 | + const byKey = new Map<string, SourceRow>(); | |
| 37 | + const ids = new Set<string>(); | |
| 38 | + for (const r of rows.rows) { | |
| 39 | + ids.add(r.id); | |
| 40 | + const k = coverageKey(r.domain); | |
| 41 | + if (!byKey.has(k)) byKey.set(k, r); | |
| 42 | + } | |
| 43 | + sourceIndex = { at: Date.now(), byKey, ids }; | |
| 44 | + return sourceIndex; | |
| 45 | +} | |
| 46 | + | |
| 47 | +export async function processSeed(seed: FactorySeed): Promise<ProcessOutcome> { | |
| 48 | + const started = Date.now(); | |
| 49 | + const notes: string[] = []; | |
| 50 | + const hints = (seed.hints ?? {}) as DeepDiscoveryHints & { posture?: boolean }; | |
| 51 | + const idx = await sourcesByDomain(); | |
| 52 | + const key = coverageKey(seed.domain); | |
| 53 | + let source = idx.byKey.get(key) ?? null; | |
| 54 | + | |
| 55 | + const disc = await discoverOrganization(seed.domain, { | |
| 56 | + hints, | |
| 57 | + budget: factoryConfig.budget, | |
| 58 | + concurrency: 4, | |
| 59 | + sensorIdForLogs: `factory_${seed.id}`, | |
| 60 | + probePages: true, | |
| 61 | + probeSubdomains: true, | |
| 62 | + probePosture: seed.importance >= 3 || hints.posture === true, | |
| 63 | + probeOpenApi: true, | |
| 64 | + }); | |
| 65 | + notes.push(...disc.notes); | |
| 66 | + // The homepage may redirect to another registrable domain that we already monitor. | |
| 67 | + if (!source && disc.canonicalDomain !== key) source = idx.byKey.get(disc.canonicalDomain) ?? null; | |
| 68 | + | |
| 69 | + if (!disc.candidates.length) { | |
| 70 | + const status = disc.blocked ? "blocked" : "discovered"; | |
| 71 | + await db.execute(sql`update factory_seeds set status = ${status}, candidates = 0, shadow = 0, source_id = ${source?.id ?? null}, discovered_at = now(), last_error = ${disc.blocked ? `homepage ${disc.homeStatus} (anti-bot)` : null}, updated_at = now() where id = ${seed.id}`); | |
| 72 | + await bumpFactoryDaily({ seeds_processed: 1, requests: disc.requests, blocked: disc.blocked ? 1 : 0 }); | |
| 73 | + return { seedId: seed.id, sourceId: source?.id ?? null, status, candidates: 0, shadow: 0, requests: disc.requests, durationMs: Date.now() - started, notes }; | |
| 74 | + } | |
| 75 | + | |
| 76 | + // ---- source: attach or create -------------------------------------------------------------------------- | |
| 77 | + let sourceId: string; | |
| 78 | + if (source) { | |
| 79 | + sourceId = source.id; | |
| 80 | + if (!source.enabled) notes.push("source disabled in registry — candidates recorded, no shadow sensors"); | |
| 81 | + } else { | |
| 82 | + sourceId = idx.ids.has(seed.id) ? `${seed.id}-${slugify(seed.domain.split(".")[0] ?? "x")}`.slice(0, 70) : seed.id; | |
| 83 | + const seedDoc: SourceSeed = sourceSchema.parse({ | |
| 84 | + id: sourceId, | |
| 85 | + name: seed.name, | |
| 86 | + domain: seed.domain, | |
| 87 | + homepage: seed.homepage ?? `https://${seed.domain}`, | |
| 88 | + categories: seed.categories, | |
| 89 | + tier: (seed.tier as Tier) ?? "B", | |
| 90 | + weight: seed.weight, | |
| 91 | + aliases: seed.aliases, | |
| 92 | + country: seed.country ?? undefined, | |
| 93 | + language: seed.language ?? undefined, | |
| 94 | + first_party: seed.firstParty, | |
| 95 | + notes: `Source Factory · ${seed.sector ?? "-"}/${seed.universe ?? "-"} · discovered ${new Date().toISOString().slice(0, 10)}`, | |
| 96 | + llm: true, | |
| 97 | + }); | |
| 98 | + await upsertSourceRecord(seedDoc, { origin: "factory", sector: seed.sector }); | |
| 99 | + idx.ids.add(sourceId); | |
| 100 | + idx.byKey.set(key, { id: sourceId, domain: seed.domain, categories: seed.categories, tier: seed.tier, enabled: true }); | |
| 101 | + source = idx.byKey.get(key)!; | |
| 102 | + } | |
| 103 | + | |
| 104 | + // ---- existing sensors (this source + global URL uniqueness) ---------------------------------------------- | |
| 105 | + const existing = await db.execute<{ id: string; url: string; type: string; connector: string; config: Record<string, unknown>; status: string }>(sql`select id, url, type, connector, config, status from sensors where source_id = ${sourceId}`); | |
| 106 | + const existingUrls = new Set(existing.rows.map((r) => r.url.replace(/\/$/, ""))); | |
| 107 | + const existingKinds = new Map<CandidateKind, number>(); | |
| 108 | + let existingTotal = 0; | |
| 109 | + for (const r of existing.rows) { | |
| 110 | + if (r.status === "DISABLED") continue; | |
| 111 | + existingTotal++; | |
| 112 | + const k = kindOfExisting(r); | |
| 113 | + existingKinds.set(k, (existingKinds.get(k) ?? 0) + 1); | |
| 114 | + } | |
| 115 | + const urls = disc.candidates.map((c) => c.url); | |
| 116 | + const global = urls.length ? await db.execute<{ url: string; source_id: string }>(sql`select url, source_id from sensors where url = any(${sql.raw("array[" + urls.map((u) => "'" + u.replace(/'/g, "''") + "'").join(",") + "]::text[]")}) and source_id <> ${sourceId}`) : { rows: [] as { url: string; source_id: string }[] }; | |
| 117 | + const ownedElsewhere = new Map(global.rows.map((r) => [r.url.replace(/\/$/, ""), r.source_id])); | |
| 118 | + | |
| 119 | + // ---- score + select --------------------------------------------------------------------------------------- | |
| 120 | + const scored = disc.candidates.map((c) => scoreCandidate(c, { importance: seed.importance, weight: seed.weight })); | |
| 121 | + const fresh = scored.filter((s) => !existingUrls.has(s.cand.url.replace(/\/$/, "")) && !ownedElsewhere.has(s.cand.url.replace(/\/$/, ""))); | |
| 122 | + const { selected, skipped } = source.enabled ? selectForShadow(fresh, { kinds: existingKinds, total: existingTotal }) : { selected: [], skipped: fresh.map((s) => ({ s, reason: "source disabled" })) }; | |
| 123 | + | |
| 124 | + // ---- persist candidates ------------------------------------------------------------------------------------ | |
| 125 | + const day = new Date().toISOString().slice(0, 10); | |
| 126 | + let shadowCreated = 0; | |
| 127 | + for (const s of scored) { | |
| 128 | + const k = s.cand.url.replace(/\/$/, ""); | |
| 129 | + let status = "candidate"; | |
| 130 | + let reason: string | null = null; | |
| 131 | + if (existingUrls.has(k)) { | |
| 132 | + status = "duplicate"; | |
| 133 | + reason = "already a sensor of this source"; | |
| 134 | + } else if (ownedElsewhere.has(k)) { | |
| 135 | + status = "duplicate"; | |
| 136 | + reason = `already a sensor of ${ownedElsewhere.get(k)}`; | |
| 137 | + } else if (selected.includes(s)) status = "shadow"; | |
| 138 | + else { | |
| 139 | + const sk = skipped.find((x) => x.s === s); | |
| 140 | + status = sk && /score/.test(sk.reason) ? "rejected" : "candidate"; | |
| 141 | + reason = sk?.reason ?? null; | |
| 142 | + } | |
| 143 | + const candId = newId("cand"); | |
| 144 | + let shadowSensorId: string | null = null; | |
| 145 | + if (status === "shadow") { | |
| 146 | + shadowSensorId = await createShadowSensor(sourceId, source, s.cand, candId, seed); | |
| 147 | + if (shadowSensorId) shadowCreated++; | |
| 148 | + else { | |
| 149 | + status = "duplicate"; | |
| 150 | + reason = "sensor id/url collision"; | |
| 151 | + } | |
| 152 | + } | |
| 153 | + await db.execute(sql` | |
| 154 | + insert into discovery_candidates (id, source_id, url, kind, evidence, score, status, seed_id, connector, type, name, kind_class, config, tier, score_value, reason, shadow_sensor_id, found_at, updated_at) | |
| 155 | + values (${candId}, ${sourceId}, ${s.cand.url}, ${s.cand.type}, ${s.cand.evidence}, ${JSON.stringify({ value: s.cand.value, score: s.score, reasons: s.reasons, itemCount: s.cand.itemCount ?? null, itemsPerDay: s.cand.itemsPerDay ?? null, firstParty: s.cand.firstPartyConfidence, cost: s.cand.fetchCost, title: s.cand.title ?? null, fingerprint: s.cand.fingerprint ?? null })}::jsonb, ${status}, ${seed.id}, ${s.cand.connector}, ${s.cand.type}, ${s.cand.name}, ${s.cand.kind}, ${JSON.stringify(s.cand.config)}::jsonb, ${s.cand.suggestedTier}, ${s.score}, ${reason}, ${shadowSensorId}, now(), now()) | |
| 156 | + on conflict (source_id, url) do update set evidence = excluded.evidence, score = excluded.score, score_value = excluded.score_value, seed_id = excluded.seed_id, connector = excluded.connector, type = excluded.type, name = excluded.name, kind_class = excluded.kind_class, config = excluded.config, tier = excluded.tier, updated_at = now(), | |
| 157 | + status = case when discovery_candidates.status in ('accepted','rejected','shadow') then discovery_candidates.status else excluded.status end, | |
| 158 | + reason = case when discovery_candidates.status in ('accepted','rejected','shadow') then discovery_candidates.reason else excluded.reason end, | |
| 159 | + shadow_sensor_id = coalesce(discovery_candidates.shadow_sensor_id, excluded.shadow_sensor_id)`); | |
| 160 | + } | |
| 161 | + for (const r of disc.rejected.slice(0, 40)) { | |
| 162 | + await db.execute(sql`insert into discovery_candidates (id, source_id, url, kind, evidence, status, seed_id, reason, kind_class) values (${newId("cand")}, ${sourceId}, ${r.url}, 'HTML', ${r.reason}, 'rejected', ${seed.id}, ${r.reason}, 'other') on conflict (source_id, url) do nothing`); | |
| 163 | + } | |
| 164 | + | |
| 165 | + await db.execute(sql`update factory_seeds set status = 'discovered', source_id = ${sourceId}, candidates = ${scored.length}, shadow = shadow + ${shadowCreated}, discovered_at = now(), last_error = null, updated_at = now() where id = ${seed.id}`); | |
| 166 | + await db.execute(sql`update sources set robots_checked_at = now(), updated_at = now() where id = ${sourceId}`); | |
| 167 | + await bumpFactoryDaily({ seeds_processed: 1, requests: disc.requests, candidates: scored.length, shadow_created: shadowCreated, blocked: disc.blocked ? 1 : 0 }); | |
| 168 | + log.info({ seed: seed.id, source: sourceId, candidates: scored.length, shadow: shadowCreated, requests: disc.requests, ms: Date.now() - started, day }, "factory: organization discovered"); | |
| 169 | + return { seedId: seed.id, sourceId, status: "discovered", candidates: scored.length, shadow: shadowCreated, requests: disc.requests, durationMs: Date.now() - started, notes }; | |
| 170 | +} | |
| 171 | + | |
| 172 | +function kindOfExisting(r: { url: string; type: string; connector: string; config: Record<string, unknown> }): CandidateKind { | |
| 173 | + const k = (r.config as { kind?: string }).kind as CandidateKind | undefined; | |
| 174 | + if (k) return k; | |
| 175 | + if (r.connector === "statuspage" || r.connector === "statusjson") return "status"; | |
| 176 | + if (r.connector === "sitemap") return "sitemap"; | |
| 177 | + if (r.connector === "github") return "releases"; | |
| 178 | + if (r.connector === "edgar") return "filings"; | |
| 179 | + if (r.connector === "dns" || r.connector === "tls" || r.connector === "headers" || r.connector === "rdap") return "posture"; | |
| 180 | + if (r.connector === "openapi") return "api"; | |
| 181 | + if (r.connector === "rss") return /press/i.test(r.url) ? "press" : /blog/i.test(r.url) ? "blog" : /security|advisor/i.test(r.url) ? "security" : /changelog|release/i.test(r.url) ? "changelog" : "news"; | |
| 182 | + if (/pricing|plans/i.test(r.url)) return "pricing"; | |
| 183 | + if (/terms|privacy|legal/i.test(r.url)) return "legal"; | |
| 184 | + if (/career|jobs/i.test(r.url)) return "careers"; | |
| 185 | + if (/changelog|release-notes|whats-new/i.test(r.url)) return "changelog"; | |
| 186 | + if (/security|advisor/i.test(r.url)) return "security"; | |
| 187 | + return "other"; | |
| 188 | +} | |
| 189 | + | |
| 190 | +async function createShadowSensor(sourceId: string, source: SourceRow, c: { url: string; type: string; connector: string; name: string; config: Record<string, unknown>; suggestedTier: Tier; kind: CandidateKind }, candId: string, seed: FactorySeed): Promise<string | null> { | |
| 191 | + let id = `${sourceId}_${slugify(c.name)}`.slice(0, 80); | |
| 192 | + const clash = (await db.execute<{ url: string }>(sql`select url from sensors where id = ${id}`)).rows[0]; | |
| 193 | + if (clash && clash.url.replace(/\/$/, "") !== c.url.replace(/\/$/, "")) id = `${id}-${newId("x").slice(-5)}`; | |
| 194 | + const tier = c.suggestedTier; | |
| 195 | + const priority = 3; // shadows never compete with production sensors | |
| 196 | + const jitter = Math.floor(Math.random() * 30 * 60e3); | |
| 197 | + try { | |
| 198 | + await db | |
| 199 | + .insert(sensors) | |
| 200 | + .values({ id, sourceId, name: c.name, url: c.url, type: c.type, connector: c.connector, tier, importanceWeight: 1, config: { ...c.config, factory: true, shadow: true, seed: false, kind: c.kind, candidate: candId, seedId: seed.id, shadowSince: new Date().toISOString(), targetPriority: priorityFor(tier, source.categories) }, status: "SHADOW", priority, validatedAt: new Date(), nextCheckAt: new Date(Date.now() + jitter) }) | |
| 201 | + .onConflictDoNothing(); | |
| 202 | + } catch (e) { | |
| 203 | + log.warn({ sensor: id, err: (e as Error).message }, "factory: shadow sensor insert failed"); | |
| 204 | + return null; | |
| 205 | + } | |
| 206 | + return id; | |
| 207 | +} | |
| 208 | + | |
| 209 | +export async function bumpFactoryDaily(delta: Partial<Record<"seeds_processed" | "requests" | "candidates" | "shadow_created" | "accepted" | "rejected" | "blocked", number>>): Promise<void> { | |
| 210 | + const day = new Date().toISOString().slice(0, 10); | |
| 211 | + const d = { seeds_processed: 0, requests: 0, candidates: 0, shadow_created: 0, accepted: 0, rejected: 0, blocked: 0, ...delta }; | |
| 212 | + await db.execute(sql`insert into factory_daily (day, seeds_processed, requests, candidates, shadow_created, accepted, rejected, blocked) values (${day}, ${d.seeds_processed}, ${d.requests}, ${d.candidates}, ${d.shadow_created}, ${d.accepted}, ${d.rejected}, ${d.blocked}) | |
| 213 | + on conflict (day) do update set seeds_processed = factory_daily.seeds_processed + ${d.seeds_processed}, requests = factory_daily.requests + ${d.requests}, candidates = factory_daily.candidates + ${d.candidates}, shadow_created = factory_daily.shadow_created + ${d.shadow_created}, accepted = factory_daily.accepted + ${d.accepted}, rejected = factory_daily.rejected + ${d.rejected}, blocked = factory_daily.blocked + ${d.blocked}`).catch(() => undefined); | |
| 214 | +} | |
| 215 | + | |
| 216 | +/** Claim up to `n` queued seeds (systemic organizations first) and process them with bounded concurrency. */ | |
| 217 | +export async function runFactoryBatch(n = factoryConfig.concurrency, opts: { seedIds?: string[] } = {}): Promise<ProcessOutcome[]> { | |
| 218 | + const claimed = await db.execute<Record<string, unknown>>(sql` | |
| 219 | + update factory_seeds set status = 'discovering', attempts = attempts + 1, updated_at = now() | |
| 220 | + where id in ( | |
| 221 | + select id from factory_seeds | |
| 222 | + where ${opts.seedIds ? sql`id = any(${sql.raw("array[" + opts.seedIds.map((i) => "'" + i.replace(/'/g, "''") + "'").join(",") + "]::text[]")})` : sql`(status = 'queued' or (status = 'error' and attempts < 3 and updated_at < now() - interval '6 hours') or (status = 'discovering' and updated_at < now() - interval '2 hours'))`} | |
| 223 | + order by importance desc, created_at asc limit ${n} for update skip locked) | |
| 224 | + returning *`); | |
| 225 | + const seeds = claimed.rows.map(normalizeSeed); | |
| 226 | + const out: ProcessOutcome[] = []; | |
| 227 | + let i = 0; | |
| 228 | + await Promise.all( | |
| 229 | + Array.from({ length: Math.min(factoryConfig.concurrency, seeds.length) }, async () => { | |
| 230 | + while (i < seeds.length) { | |
| 231 | + const seed = seeds[i++]!; | |
| 232 | + try { | |
| 233 | + out.push(await processSeed(seed)); | |
| 234 | + } catch (e) { | |
| 235 | + log.error({ seed: seed.id, err: (e as Error).stack ?? (e as Error).message }, "factory: seed failed"); | |
| 236 | + await db.execute(sql`update factory_seeds set status = 'error', last_error = ${(e as Error).message.slice(0, 500)}, updated_at = now() where id = ${seed.id}`).catch(() => undefined); | |
| 237 | + out.push({ seedId: seed.id, sourceId: null, status: "error", candidates: 0, shadow: 0, requests: 0, durationMs: 0, notes: [(e as Error).message] }); | |
| 238 | + } | |
| 239 | + } | |
| 240 | + }), | |
| 241 | + ); | |
| 242 | + return out; | |
| 243 | +} | |
| 244 | + | |
| 245 | +function normalizeSeed(r: Record<string, unknown>): FactorySeed { | |
| 246 | + const d = (v: unknown): Date | null => (v ? new Date(v as string) : null); | |
| 247 | + return { | |
| 248 | + id: r.id as string, | |
| 249 | + name: r.name as string, | |
| 250 | + domain: r.domain as string, | |
| 251 | + homepage: (r.homepage as string | null) ?? null, | |
| 252 | + categories: (r.categories as string[]) ?? [], | |
| 253 | + country: (r.country as string | null) ?? null, | |
| 254 | + language: (r.language as string | null) ?? null, | |
| 255 | + tier: (r.tier as string) ?? "B", | |
| 256 | + weight: Number(r.weight ?? 1), | |
| 257 | + importance: Number(r.importance ?? 2), | |
| 258 | + aliases: (r.aliases as string[]) ?? [], | |
| 259 | + firstParty: Boolean(r.first_party ?? true), | |
| 260 | + sector: (r.sector as string | null) ?? null, | |
| 261 | + universe: (r.universe as string | null) ?? null, | |
| 262 | + hints: (r.hints as Record<string, unknown>) ?? {}, | |
| 263 | + status: r.status as string, | |
| 264 | + attempts: Number(r.attempts ?? 0), | |
| 265 | + sourceId: (r.source_id as string | null) ?? null, | |
| 266 | + candidates: Number(r.candidates ?? 0), | |
| 267 | + shadow: Number(r.shadow ?? 0), | |
| 268 | + accepted: Number(r.accepted ?? 0), | |
| 269 | + rejected: Number(r.rejected ?? 0), | |
| 270 | + lastError: (r.last_error as string | null) ?? null, | |
| 271 | + discoveredAt: d(r.discovered_at), | |
| 272 | + createdAt: d(r.created_at) ?? new Date(), | |
| 273 | + updatedAt: d(r.updated_at) ?? new Date(), | |
| 274 | + }; | |
| 275 | +} | |
| 276 | + | |
| 277 | +export async function factoryStats(): Promise<Record<string, unknown>> { | |
| 278 | + const [seeds, cands, shadows, daily, sectors] = await Promise.all([ | |
| 279 | + db.execute<Record<string, unknown>>(sql`select status, count(*)::int as n from factory_seeds group by status`).then((r) => r.rows), | |
| 280 | + db.execute<Record<string, unknown>>(sql`select status, count(*)::int as n from discovery_candidates group by status`).then((r) => r.rows), | |
| 281 | + db.execute<Record<string, unknown>>(sql`select count(*)::int as shadow, count(*) filter (where total_runs >= ${factoryConfig.shadowMinChecks})::int as ready, count(*) filter (where health <> 'UP')::int as unhealthy from sensors where status = 'SHADOW'`).then((r) => r.rows[0]), | |
| 282 | + db.execute<Record<string, unknown>>(sql`select day::text as day, seeds_processed, requests, candidates, shadow_created, accepted, rejected, blocked from factory_daily order by day desc limit 30`).then((r) => r.rows), | |
| 283 | + db.execute<Record<string, unknown>>(sql`select coalesce(sector, '-') as sector, count(*)::int as seeds, count(*) filter (where status = 'queued')::int as queued, count(*) filter (where status in ('discovered','done'))::int as discovered, count(*) filter (where status = 'blocked')::int as blocked, sum(shadow)::int as shadow, sum(accepted)::int as accepted, sum(rejected)::int as rejected from factory_seeds group by 1 order by seeds desc`).then((r) => r.rows), | |
| 284 | + ]); | |
| 285 | + return { seeds: Object.fromEntries(seeds.map((r) => [r.status, r.n])), candidates: Object.fromEntries(cands.map((r) => [r.status, r.n])), shadow: shadows, daily, sectors, config: { concurrency: factoryConfig.concurrency, budget: factoryConfig.budget, minScore: factoryConfig.minScore, shadowMinChecks: factoryConfig.shadowMinChecks, shadowMinHours: factoryConfig.shadowMinHours, shadowMaxHours: factoryConfig.shadowMaxHours, caps: factoryConfig.caps } }; | |
| 286 | +} | |
| 287 | + | |
| 288 | +export { factorySeeds }; | |
added
apps/engine/src/factory/score.ts
+114 −0
@@ -0,0 +1,114 @@ | ||
| 1 | +import type { CandidateKind, DeepCandidate } from "@websensor/connectors"; | |
| 2 | +import { factoryConfig } from "../config"; | |
| 3 | + | |
| 4 | +/** | |
| 5 | + * Candidate scoring (Source Factory, stage "signal/noise evaluation before shadow"). Combines the discovery | |
| 6 | + * value (evidence quality) with the organization's importance, the class of page, first-party confidence, | |
| 7 | + * expected change frequency and fetch cost. Explainable: every score carries its reasons. | |
| 8 | + */ | |
| 9 | +export interface ScoredCandidate { | |
| 10 | + cand: DeepCandidate; | |
| 11 | + score: number; | |
| 12 | + reasons: string[]; | |
| 13 | +} | |
| 14 | + | |
| 15 | +/** How much a page class is worth for change intelligence (1 = neutral). */ | |
| 16 | +export const KIND_WEIGHT: Record<CandidateKind, number> = { | |
| 17 | + status: 1.15, | |
| 18 | + security: 1.15, | |
| 19 | + press: 1.1, | |
| 20 | + news: 1.05, | |
| 21 | + filings: 1.1, | |
| 22 | + changelog: 1.05, | |
| 23 | + releases: 1.0, | |
| 24 | + pricing: 1.05, | |
| 25 | + models: 1.05, | |
| 26 | + ir: 0.95, | |
| 27 | + legal: 0.9, | |
| 28 | + blog: 0.9, | |
| 29 | + api: 0.9, | |
| 30 | + leadership: 0.85, | |
| 31 | + data: 0.85, | |
| 32 | + sitemap: 0.8, | |
| 33 | + docs: 0.7, | |
| 34 | + careers: 0.6, | |
| 35 | + posture: 0.6, | |
| 36 | + other: 0.7, | |
| 37 | +}; | |
| 38 | + | |
| 39 | +export function scoreCandidate(c: DeepCandidate, seed: { importance: number; weight?: number }): ScoredCandidate { | |
| 40 | + const reasons: string[] = []; | |
| 41 | + let score = c.value; | |
| 42 | + reasons.push(`evidence ${c.value.toFixed(2)} (${c.evidence})`); | |
| 43 | + const kw = KIND_WEIGHT[c.kind] ?? 0.7; | |
| 44 | + score *= kw; | |
| 45 | + if (kw !== 1) reasons.push(`${c.kind} ×${kw.toFixed(2)}`); | |
| 46 | + const imp = seed.importance === 3 ? 1.1 : seed.importance === 1 ? 0.85 : 1; | |
| 47 | + score *= imp * (seed.weight ?? 1); | |
| 48 | + if (imp !== 1) reasons.push(`organization importance ${seed.importance} ×${imp.toFixed(2)}`); | |
| 49 | + score *= c.firstPartyConfidence; | |
| 50 | + if (c.firstPartyConfidence < 1) reasons.push(`first-party confidence ${c.firstPartyConfidence}`); | |
| 51 | + if (c.itemsPerDay !== null && c.itemsPerDay !== undefined) { | |
| 52 | + if (c.itemsPerDay > 60) { | |
| 53 | + score *= 0.85; | |
| 54 | + reasons.push(`firehose ${c.itemsPerDay}/day ×0.85`); | |
| 55 | + } else if (c.itemsPerDay < 0.02) { | |
| 56 | + score *= 0.8; | |
| 57 | + reasons.push(`nearly dormant ${c.itemsPerDay}/day ×0.80`); | |
| 58 | + } else if (c.itemsPerDay >= 0.2) { | |
| 59 | + score *= 1.05; | |
| 60 | + reasons.push(`active ${c.itemsPerDay}/day ×1.05`); | |
| 61 | + } | |
| 62 | + } | |
| 63 | + if (c.fetchCost.ms > 8000) { | |
| 64 | + score -= 0.08; | |
| 65 | + reasons.push(`slow (${Math.round(c.fetchCost.ms)} ms) −0.08`); | |
| 66 | + } | |
| 67 | + if (c.fetchCost.bytes > 5 * 1024 * 1024) { | |
| 68 | + score -= 0.08; | |
| 69 | + reasons.push(`heavy (${Math.round(c.fetchCost.bytes / 1048576)} MB) −0.08`); | |
| 70 | + } | |
| 71 | + score = Math.round(Math.max(0, Math.min(1.2, score)) * 1000) / 1000; | |
| 72 | + return { cand: c, score, reasons }; | |
| 73 | +} | |
| 74 | + | |
| 75 | +/** Promotion policy: caps per organization and per class, best score first. Returns the selected candidates and the reason for the others. */ | |
| 76 | +export function selectForShadow(scored: ScoredCandidate[], existing: { kinds: Map<CandidateKind, number>; total: number }): { selected: ScoredCandidate[]; skipped: { s: ScoredCandidate; reason: string }[] } { | |
| 77 | + const caps = factoryConfig.caps; | |
| 78 | + const groupOf = (k: CandidateKind): keyof typeof caps => (k === "news" || k === "press" || k === "blog" || k === "ir" || k === "changelog" || k === "security" ? "feeds" : k === "pricing" || k === "legal" || k === "careers" || k === "leadership" || k === "docs" || k === "data" || k === "other" ? "pages" : (k as keyof typeof caps)); | |
| 79 | + const used = new Map<string, number>(); | |
| 80 | + for (const [k, n] of existing.kinds) used.set(groupOf(k), (used.get(groupOf(k)) ?? 0) + n); | |
| 81 | + let total = existing.total; | |
| 82 | + const selected: ScoredCandidate[] = []; | |
| 83 | + const skipped: { s: ScoredCandidate; reason: string }[] = []; | |
| 84 | + const legal = (existing.kinds.get("legal") ?? 0); | |
| 85 | + let legalUsed = legal; | |
| 86 | + for (const s of [...scored].sort((a, b) => b.score - a.score)) { | |
| 87 | + if (s.score < factoryConfig.minScore) { | |
| 88 | + skipped.push({ s, reason: `score ${s.score} < ${factoryConfig.minScore}` }); | |
| 89 | + continue; | |
| 90 | + } | |
| 91 | + if (total >= caps.total) { | |
| 92 | + skipped.push({ s, reason: `organization cap ${caps.total} reached` }); | |
| 93 | + continue; | |
| 94 | + } | |
| 95 | + // Feed-type candidates (rss connector) and HTML pages are capped by group; connectors with their own cap by kind. | |
| 96 | + const g = s.cand.connector === "rss" ? "feeds" : s.cand.connector === "http" && s.cand.kind !== "posture" ? "pages" : groupOf(s.cand.kind); | |
| 97 | + const cap = caps[g as keyof typeof caps] ?? 1; | |
| 98 | + if ((used.get(g) ?? 0) >= cap) { | |
| 99 | + skipped.push({ s, reason: `${g} cap ${cap} reached` }); | |
| 100 | + continue; | |
| 101 | + } | |
| 102 | + if (s.cand.kind === "legal") { | |
| 103 | + if (legalUsed >= caps.legal) { | |
| 104 | + skipped.push({ s, reason: `legal cap ${caps.legal} reached` }); | |
| 105 | + continue; | |
| 106 | + } | |
| 107 | + legalUsed++; | |
| 108 | + } | |
| 109 | + used.set(g, (used.get(g) ?? 0) + 1); | |
| 110 | + total++; | |
| 111 | + selected.push(s); | |
| 112 | + } | |
| 113 | + return { selected, skipped }; | |
| 114 | +} | |
added
apps/engine/src/factory/seeds.ts
+168 −0
@@ -0,0 +1,168 @@ | ||
| 1 | +import { existsSync, readdirSync, readFileSync, statSync } from "node:fs"; | |
| 2 | +import { join } from "node:path"; | |
| 3 | +import YAML from "yaml"; | |
| 4 | +import { coverageKey, coverageSectorSchema, slugify, type CoverageMember, type CoverageSector } from "@websensor/core"; | |
| 5 | +import { db, factorySeeds, sql } from "@websensor/db"; | |
| 6 | +import { factoryConfig, log } from "../config"; | |
| 7 | + | |
| 8 | +/** | |
| 9 | + * Factory seeds come from two places: the coverage universes (`config/coverage/*.yaml`, every member that is not | |
| 10 | + * monitored yet — or that carries hints worth expanding) and ad-hoc seed files (`config/factory/seeds/*.yaml`, | |
| 11 | + * `{ seeds: [{ name, domain, categories, country, … }] }`) or the admin API. | |
| 12 | + */ | |
| 13 | +export interface SeedInput { | |
| 14 | + id?: string; | |
| 15 | + name: string; | |
| 16 | + domain: string; | |
| 17 | + homepage?: string; | |
| 18 | + categories?: string[]; | |
| 19 | + country?: string; | |
| 20 | + language?: string; | |
| 21 | + tier?: string; | |
| 22 | + weight?: number; | |
| 23 | + importance?: number; | |
| 24 | + aliases?: string[]; | |
| 25 | + first_party?: boolean; | |
| 26 | + sector?: string; | |
| 27 | + universe?: string; | |
| 28 | + hints?: Record<string, unknown>; | |
| 29 | +} | |
| 30 | + | |
| 31 | +export function loadCoverageSectors(dir = factoryConfig.coverageDir): { sectors: CoverageSector[]; issues: string[] } { | |
| 32 | + const sectors: CoverageSector[] = []; | |
| 33 | + const issues: string[] = []; | |
| 34 | + if (!existsSync(dir) || !statSync(dir).isDirectory()) return { sectors, issues: [`coverage dir ${dir} not found`] }; | |
| 35 | + for (const f of readdirSync(dir).filter((x) => /\.ya?ml$/.test(x) && !x.startsWith("_")).sort()) { | |
| 36 | + try { | |
| 37 | + const parsed = coverageSectorSchema.safeParse(YAML.parse(readFileSync(join(dir, f), "utf8"))); | |
| 38 | + if (!parsed.success) { | |
| 39 | + issues.push(`${f}: ${parsed.error.issues.slice(0, 3).map((i) => `${i.path.join(".")}: ${i.message}`).join("; ")}`); | |
| 40 | + continue; | |
| 41 | + } | |
| 42 | + sectors.push(parsed.data); | |
| 43 | + } catch (e) { | |
| 44 | + issues.push(`${f}: ${(e as Error).message}`); | |
| 45 | + } | |
| 46 | + } | |
| 47 | + return { sectors, issues }; | |
| 48 | +} | |
| 49 | + | |
| 50 | +/** Merge files that share a sector key. */ | |
| 51 | +export function mergeSectors(sectors: CoverageSector[]): CoverageSector[] { | |
| 52 | + const byKey = new Map<string, CoverageSector>(); | |
| 53 | + for (const s of sectors) { | |
| 54 | + const prev = byKey.get(s.sector); | |
| 55 | + if (!prev) byKey.set(s.sector, { ...s, universes: [...s.universes] }); | |
| 56 | + else prev.universes.push(...s.universes); | |
| 57 | + } | |
| 58 | + return [...byKey.values()]; | |
| 59 | +} | |
| 60 | + | |
| 61 | +export function memberToSeed(sector: CoverageSector, universe: string, m: CoverageMember): SeedInput { | |
| 62 | + return { | |
| 63 | + name: m.name, | |
| 64 | + domain: m.domain, | |
| 65 | + categories: m.categories ?? sector.categories, | |
| 66 | + country: m.country, | |
| 67 | + language: m.language, | |
| 68 | + tier: m.tier ?? sector.tier, | |
| 69 | + importance: m.importance, | |
| 70 | + aliases: m.aliases, | |
| 71 | + first_party: m.first_party, | |
| 72 | + sector: sector.sector, | |
| 73 | + universe, | |
| 74 | + hints: m.hints as Record<string, unknown>, | |
| 75 | + }; | |
| 76 | +} | |
| 77 | + | |
| 78 | +function hasHints(h: Record<string, unknown> | undefined): boolean { | |
| 79 | + if (!h) return false; | |
| 80 | + return Boolean(h.cik || h.github_org || (Array.isArray(h.github_repos) && h.github_repos.length) || h.hf_author || h.status_url || (Array.isArray(h.urls) && h.urls.length) || (Array.isArray(h.hosts) && h.hosts.length)); | |
| 81 | +} | |
| 82 | + | |
| 83 | +/** Registrable domains currently monitored (source domains + hosts of enabled non-shadow sensors). */ | |
| 84 | +export async function monitoredDomainKeys(): Promise<Set<string>> { | |
| 85 | + const rows = await db.execute<{ d: string }>(sql` | |
| 86 | + select domain as d from sources where enabled and kind = 'registry' | |
| 87 | + union | |
| 88 | + select lower(split_part(split_part(url, '/', 3), ':', 1)) as d from sensors where enabled and status <> 'SHADOW' and url like 'http%'`); | |
| 89 | + const out = new Set<string>(); | |
| 90 | + for (const r of rows.rows) if (r.d) out.add(coverageKey(r.d)); | |
| 91 | + return out; | |
| 92 | +} | |
| 93 | + | |
| 94 | +export async function upsertSeeds(inputs: SeedInput[], opts: { requeue?: boolean } = {}): Promise<{ inserted: number; updated: number; skipped: number }> { | |
| 95 | + let inserted = 0; | |
| 96 | + let updated = 0; | |
| 97 | + let skipped = 0; | |
| 98 | + const existingIds = new Set((await db.execute<{ id: string }>(sql`select id from sources`)).rows.map((r) => r.id)); | |
| 99 | + const existingSeedDomains = new Map((await db.execute<{ id: string; domain: string }>(sql`select id, domain from factory_seeds`)).rows.map((r) => [coverageKey(r.domain), r.id])); | |
| 100 | + for (const s of inputs) { | |
| 101 | + const domain = s.domain.toLowerCase().replace(/^https?:\/\//, "").replace(/^www\./, "").replace(/\/.*$/, ""); | |
| 102 | + const key = coverageKey(domain); | |
| 103 | + let id = s.id ?? existingSeedDomains.get(key) ?? slugify(s.name).slice(0, 60); | |
| 104 | + if (!id) id = slugify(domain); | |
| 105 | + // A registry source with the same id but another organization → disambiguate by domain. | |
| 106 | + if (!s.id && !existingSeedDomains.has(key) && existingIds.has(id)) { | |
| 107 | + const src = (await db.execute<{ domain: string }>(sql`select domain from sources where id = ${id}`)).rows[0]; | |
| 108 | + if (src && coverageKey(src.domain) !== key) id = `${id}-${slugify(domain.split(".")[0] ?? domain)}`.slice(0, 70); | |
| 109 | + } | |
| 110 | + const r = await db.execute<{ inserted: boolean }>(sql` | |
| 111 | + insert into factory_seeds (id, name, domain, homepage, categories, country, language, tier, weight, importance, aliases, first_party, sector, universe, hints, status) | |
| 112 | + values (${id}, ${s.name}, ${domain}, ${s.homepage ?? null}, ${sql.raw("'{" + (s.categories ?? []).map((c) => '"' + c.replace(/"/g, "") + '"').join(",") + "}'::text[]")}, ${s.country ?? null}, ${s.language ?? null}, ${s.tier ?? "B"}, ${s.weight ?? 1}, ${s.importance ?? 2}, ${sql.raw("'{" + (s.aliases ?? []).map((c) => '"' + c.replace(/["\\]/g, "") + '"').join(",") + "}'::text[]")}, ${s.first_party ?? true}, ${s.sector ?? null}, ${s.universe ?? null}, ${JSON.stringify(s.hints ?? {})}::jsonb, 'queued') | |
| 113 | + on conflict (id) do update set name = excluded.name, domain = excluded.domain, categories = excluded.categories, country = coalesce(excluded.country, factory_seeds.country), language = coalesce(excluded.language, factory_seeds.language), tier = excluded.tier, importance = excluded.importance, aliases = excluded.aliases, sector = coalesce(excluded.sector, factory_seeds.sector), universe = coalesce(excluded.universe, factory_seeds.universe), hints = factory_seeds.hints || excluded.hints, updated_at = now()${opts.requeue ? sql`, status = 'queued'` : sql``} | |
| 114 | + returning (xmax = 0) as inserted`); | |
| 115 | + if (r.rows[0]?.inserted) inserted++; | |
| 116 | + else if (r.rowCount) updated++; | |
| 117 | + else skipped++; | |
| 118 | + existingSeedDomains.set(key, id); | |
| 119 | + } | |
| 120 | + log.info({ inserted, updated, skipped }, "factory seeds upserted"); | |
| 121 | + return { inserted, updated, skipped }; | |
| 122 | +} | |
| 123 | + | |
| 124 | +/** | |
| 125 | + * Turn coverage universes into seeds. `mode`: `uncovered` = members without any monitored domain; `hinted` = also | |
| 126 | + * covered members that carry expansion hints; `all` = every member (re-discover everything). | |
| 127 | + */ | |
| 128 | +export async function seedFromCoverage(opts: { mode?: "uncovered" | "hinted" | "all"; sectors?: string[]; requeue?: boolean } = {}): Promise<{ members: number; seeds: number; inserted: number; updated: number; issues: string[] }> { | |
| 129 | + const mode = opts.mode ?? "hinted"; | |
| 130 | + const { sectors, issues } = loadCoverageSectors(); | |
| 131 | + const monitored = await monitoredDomainKeys(); | |
| 132 | + const inputs: SeedInput[] = []; | |
| 133 | + const seen = new Set<string>(); | |
| 134 | + let members = 0; | |
| 135 | + for (const sector of mergeSectors(sectors)) { | |
| 136 | + if (opts.sectors && !opts.sectors.includes(sector.sector)) continue; | |
| 137 | + for (const u of sector.universes) { | |
| 138 | + for (const m of u.members) { | |
| 139 | + members++; | |
| 140 | + const key = coverageKey(m.domain); | |
| 141 | + if (seen.has(key)) continue; | |
| 142 | + const covered = monitored.has(key); | |
| 143 | + if (mode === "uncovered" && covered) continue; | |
| 144 | + if (mode === "hinted" && covered && !hasHints(m.hints as Record<string, unknown>)) continue; | |
| 145 | + seen.add(key); | |
| 146 | + inputs.push(memberToSeed(sector, u.key, m)); | |
| 147 | + } | |
| 148 | + } | |
| 149 | + } | |
| 150 | + const r = await upsertSeeds(inputs, { requeue: opts.requeue }); | |
| 151 | + return { members, seeds: inputs.length, inserted: r.inserted, updated: r.updated, issues }; | |
| 152 | +} | |
| 153 | + | |
| 154 | +/** `config/factory/seeds/*.yaml` → `{ seeds: [...] }` */ | |
| 155 | +export async function seedFromFiles(dir = factoryConfig.seedsDir): Promise<{ files: number; seeds: number }> { | |
| 156 | + if (!existsSync(dir)) return { files: 0, seeds: 0 }; | |
| 157 | + const inputs: SeedInput[] = []; | |
| 158 | + let files = 0; | |
| 159 | + for (const f of readdirSync(dir).filter((x) => /\.ya?ml$/.test(x)).sort()) { | |
| 160 | + files++; | |
| 161 | + const raw = YAML.parse(readFileSync(join(dir, f), "utf8")) as { seeds?: SeedInput[] }; | |
| 162 | + for (const s of raw.seeds ?? []) if (s?.name && s?.domain) inputs.push(s); | |
| 163 | + } | |
| 164 | + await upsertSeeds(inputs); | |
| 165 | + return { files, seeds: inputs.length }; | |
| 166 | +} | |
| 167 | + | |
| 168 | +export { factorySeeds }; | |
added
apps/engine/src/factory/shadow.ts
+109 −0
@@ -0,0 +1,109 @@ | ||
| 1 | +import { NOISE_CLASSES } from "@websensor/core"; | |
| 2 | +import { db, sql } from "@websensor/db"; | |
| 3 | +import { factoryConfig, log } from "../config"; | |
| 4 | +import { bumpFactoryDaily } from "./run"; | |
| 5 | + | |
| 6 | +/** | |
| 7 | + * Shadow evaluation — the Factory's "signal/noise evaluation" stage. A shadow sensor is polled like any other | |
| 8 | + * sensor (evidence is stored) but never publishes. Once it has enough checks (or enough time has passed) it is: | |
| 9 | + * rejected — errors (≥ 50 % failed checks, or 3+ consecutive errors), pure noise (changes on most checks, | |
| 10 | + * none of them meaningful), or a duplicate (same latest canonical content as an active sensor of | |
| 11 | + * the same source); | |
| 12 | + * accepted — otherwise, becoming a production sensor with its real priority; | |
| 13 | + * deferred — not enough evidence yet (until `shadowMaxHours`, then accepted if it never failed). | |
| 14 | + */ | |
| 15 | +export interface ShadowDecision { | |
| 16 | + sensorId: string; | |
| 17 | + decision: "accepted" | "rejected" | "deferred"; | |
| 18 | + reason: string; | |
| 19 | +} | |
| 20 | + | |
| 21 | +interface ShadowRow extends Record<string, unknown> { | |
| 22 | + id: string; | |
| 23 | + source_id: string; | |
| 24 | + url: string; | |
| 25 | + tier: string; | |
| 26 | + config: Record<string, unknown>; | |
| 27 | + total_runs: number; | |
| 28 | + consecutive_errors: number; | |
| 29 | + raw_changes: number; | |
| 30 | + health: string; | |
| 31 | + last_snapshot_id: string | null; | |
| 32 | + shadow_since: string | null; | |
| 33 | + categories: string[]; | |
| 34 | + runs: number; | |
| 35 | + failed: number; | |
| 36 | + changes: number; | |
| 37 | + noise_changes: number; | |
| 38 | + last_canonical: string | null; | |
| 39 | + dup_sensor: string | null; | |
| 40 | +} | |
| 41 | + | |
| 42 | +export async function evaluateShadows(opts: { limit?: number; force?: boolean } = {}): Promise<{ accepted: number; rejected: number; deferred: number; decisions: ShadowDecision[] }> { | |
| 43 | + const noise = [...NOISE_CLASSES]; | |
| 44 | + const rows = await db.execute<ShadowRow>(sql` | |
| 45 | + select s.id, s.source_id, s.url, s.tier, s.config, s.total_runs, s.consecutive_errors, s.raw_changes, s.health, s.last_snapshot_id, | |
| 46 | + s.config->>'shadowSince' as shadow_since, so.categories, | |
| 47 | + (select count(*)::int from sensor_runs r where r.sensor_id = s.id) as runs, | |
| 48 | + (select count(*)::int from sensor_runs r where r.sensor_id = s.id and r.outcome in ('error','parse_error','rate_limited','missing')) as failed, | |
| 49 | + (select count(*)::int from changes c where c.sensor_id = s.id) as changes, | |
| 50 | + (select count(*)::int from changes c where c.sensor_id = s.id and c.change_class = any(${sql.raw("array[" + noise.map((n) => "'" + n + "'").join(",") + "]::text[]")})) as noise_changes, | |
| 51 | + (select sn.canonical_hash from snapshots sn where sn.id = s.last_snapshot_id) as last_canonical, | |
| 52 | + (select x.id from sensors x join snapshots sx on sx.id = x.last_snapshot_id where x.source_id = s.source_id and x.id <> s.id and x.status <> 'SHADOW' and x.enabled and sx.canonical_hash = (select sn2.canonical_hash from snapshots sn2 where sn2.id = s.last_snapshot_id) limit 1) as dup_sensor | |
| 53 | + from sensors s join sources so on so.id = s.source_id | |
| 54 | + where s.status = 'SHADOW' and s.enabled | |
| 55 | + order by s.total_runs desc | |
| 56 | + limit ${opts.limit ?? 2000}`); | |
| 57 | + const decisions: ShadowDecision[] = []; | |
| 58 | + let accepted = 0; | |
| 59 | + let rejected = 0; | |
| 60 | + let deferred = 0; | |
| 61 | + const now = Date.now(); | |
| 62 | + for (const r of rows.rows) { | |
| 63 | + const since = r.shadow_since ? new Date(r.shadow_since).getTime() : now; | |
| 64 | + const hours = (now - since) / 3600e3; | |
| 65 | + const runs = Math.max(r.runs, r.total_runs); | |
| 66 | + let decision: ShadowDecision["decision"] = "deferred"; | |
| 67 | + let reason = `${runs} checks · ${hours.toFixed(0)} h`; | |
| 68 | + if (r.dup_sensor && runs >= 2) { | |
| 69 | + decision = "rejected"; | |
| 70 | + reason = `duplicate content of ${r.dup_sensor}`; | |
| 71 | + } else if (r.consecutive_errors >= 3 || (runs >= 4 && r.failed / runs >= 0.5)) { | |
| 72 | + decision = "rejected"; | |
| 73 | + reason = `errors ${r.failed}/${runs} (consecutive ${r.consecutive_errors})`; | |
| 74 | + } else if (r.changes >= 4 && r.noise_changes === r.changes && r.changes >= runs * 0.6) { | |
| 75 | + decision = "rejected"; | |
| 76 | + reason = `noise: ${r.changes} changes on ${runs} checks, all cosmetic/timestamp/ads`; | |
| 77 | + } else if (opts.force || (runs >= factoryConfig.shadowMinChecks && hours >= factoryConfig.shadowMinHours) || (hours >= factoryConfig.shadowMaxHours && runs >= 2 && r.failed === 0)) { | |
| 78 | + if (r.health !== "UP" && !opts.force) { | |
| 79 | + decision = "deferred"; | |
| 80 | + reason = `health ${r.health} — waiting`; | |
| 81 | + } else { | |
| 82 | + decision = "accepted"; | |
| 83 | + reason = `${runs} checks · ${r.changes} changes (${r.changes - r.noise_changes} meaningful) · ${r.failed} errors · ${hours.toFixed(0)} h`; | |
| 84 | + } | |
| 85 | + } else if (hours >= factoryConfig.shadowMaxHours && runs < 2) { | |
| 86 | + decision = "rejected"; | |
| 87 | + reason = `never observed successfully in ${hours.toFixed(0)} h`; | |
| 88 | + } | |
| 89 | + | |
| 90 | + if (decision === "accepted") { | |
| 91 | + const targetPriority = Number((r.config as { targetPriority?: number }).targetPriority ?? 2); | |
| 92 | + await db.execute(sql`update sensors set status = 'ACTIVE', priority = ${targetPriority}, config = (config - 'shadow') || jsonb_build_object('acceptedAt', ${new Date().toISOString()}::text, 'shadowReport', ${reason}::text), updated_at = now() where id = ${r.id}`); | |
| 93 | + await db.execute(sql`update discovery_candidates set status = 'accepted', reason = ${reason}, decided_at = now(), updated_at = now() where shadow_sensor_id = ${r.id}`); | |
| 94 | + await db.execute(sql`update factory_seeds set accepted = accepted + 1, updated_at = now() where id = ${(r.config as { seedId?: string }).seedId ?? ""}`); | |
| 95 | + accepted++; | |
| 96 | + } else if (decision === "rejected") { | |
| 97 | + await db.execute(sql`update sensors set status = 'DISABLED', enabled = false, config = config || jsonb_build_object('rejectedAt', ${new Date().toISOString()}::text, 'shadowReport', ${reason}::text), updated_at = now() where id = ${r.id}`); | |
| 98 | + await db.execute(sql`update discovery_candidates set status = 'rejected', reason = ${reason}, decided_at = now(), updated_at = now() where shadow_sensor_id = ${r.id}`); | |
| 99 | + await db.execute(sql`update factory_seeds set rejected = rejected + 1, updated_at = now() where id = ${(r.config as { seedId?: string }).seedId ?? ""}`); | |
| 100 | + rejected++; | |
| 101 | + } else deferred++; | |
| 102 | + decisions.push({ sensorId: r.id, decision, reason }); | |
| 103 | + } | |
| 104 | + if (accepted || rejected) { | |
| 105 | + await bumpFactoryDaily({ accepted, rejected }); | |
| 106 | + log.info({ accepted, rejected, deferred }, "factory: shadow evaluation"); | |
| 107 | + } | |
| 108 | + return { accepted, rejected, deferred, decisions }; | |
| 109 | +} | |
modified
apps/engine/src/pipeline.ts
+9 −2
@@ -54,7 +54,8 @@ export async function runSensor(sensor: Sensor, source: Source): Promise<RunOutc | ||
| 54 | 54 | const isErr = outcome === "error" || outcome === "parse_error" || outcome === "rate_limited"; |
| 55 | 55 | const consecutive = isErr ? sensor.consecutiveErrors + 1 : 0; |
| 56 | 56 | const health = outcome === "rate_limited" ? "RATE_LIMITED" : isErr ? (consecutive >= 5 ? "ERROR" : "DEGRADED") : outcome === "missing" ? "DEGRADED" : "UP"; |
| 57 | − const status = !sensor.enabled ? "DISABLED" : health === "ERROR" || health === "RATE_LIMITED" ? "DEGRADED" : "ACTIVE"; | |
| 57 | + // Shadow sensors (Source Factory) keep their lifecycle status until evaluateShadows() decides. | |
| 58 | + const status = !sensor.enabled ? "DISABLED" : sensor.status === "SHADOW" ? "SHADOW" : health === "ERROR" || health === "RATE_LIMITED" ? "DEGRADED" : "ACTIVE"; | |
| 58 | 59 | const changes7d = await countChanges7d(sensor.id); |
| 59 | 60 | const events7d = await countEvents7d(sensor.id); |
| 60 | 61 | let interval = nextIntervalSeconds({ tier: sensor.tier as Tier, baseIntervalSeconds: sensor.baseIntervalSeconds, lastChangeAt: extra.changed ? new Date() : sensor.lastChangeAt, changes7d, events7d, consecutiveErrors: consecutive, lastWas304: outcome === "not_modified" }); |
@@ -224,6 +225,12 @@ export async function runSensor(sensor: Sensor, source: Source): Promise<RunOutc | ||
| 224 | 225 | if (isNoise) m.suppressed.inc({ reason: `noise_${semantic.class}` }); |
| 225 | 226 | return finish("changed", { snapshotId: snapId, changed: true }); |
| 226 | 227 | } |
| 228 | + // Shadow monitoring (Source Factory): evidence and changes are recorded, nothing is published until the sensor is accepted. | |
| 229 | + if (sensor.status === "SHADOW") { | |
| 230 | + m.suppressed.inc({ reason: "shadow" }); | |
| 231 | + await db.update(changes).set({ meaningful: true }).where(sql`id = ${changeId}`); | |
| 232 | + return finish("changed", { snapshotId: snapId, changed: true }); | |
| 233 | + } | |
| 227 | 234 | |
| 228 | 235 | // ---- Event -------------------------------------------------------------------------- |
| 229 | 236 | const ev = await createEvent({ sensor, source, obs, norm, prev, snapId, changeId, diff, heuristic, semantic }); |
@@ -444,7 +451,7 @@ async function handleMissing(sensor: Sensor, source: Source, obs: Observation, f | ||
| 444 | 451 | const sepOk = !missing.lastAt || obs.fetchedAt.getTime() - new Date(missing.lastAt).getTime() >= config.deletion.minSeparationMin * 60e3; |
| 445 | 452 | if (sepOk) missing.count += 1; |
| 446 | 453 | missing.lastAt = obs.fetchedAt.toISOString(); |
| 447 | − if (sensor.lastSnapshotId && missing.count >= config.deletion.confirmations && !missing.removedEventAt) { | |
| 454 | + if (sensor.lastSnapshotId && missing.count >= config.deletion.confirmations && !missing.removedEventAt && sensor.status !== "SHADOW") { | |
| 448 | 455 | // Confirmed deletion: independent checks separated in time. |
| 449 | 456 | missing.removedEventAt = obs.fetchedAt.toISOString(); |
| 450 | 457 | const id = newId("evt"); |
modified
apps/engine/src/registry.ts
+39 −29
@@ -18,38 +18,48 @@ export function loadSeeds(file = config.sourcesFile, dir = config.sourcesDir): S | ||
| 18 | 18 | return seeds; |
| 19 | 19 | } |
| 20 | 20 | |
| 21 | −export async function syncRegistry(seeds = loadSeeds()): Promise<{ sources: number; sensors: number }> { | |
| 22 | − let nSensors = 0; | |
| 23 | − for (const s of seeds) { | |
| 24 | − // Provenance: media/aggregators are third-party; everything else is the organization's own channel. | |
| 25 | − const firstParty = s.first_party ?? !s.categories.some((c) => c === "news" || c === "media"); | |
| 26 | − const country = s.country ?? inferCountry(s.domain, s.categories); | |
| 27 | − const language = s.language ?? inferLanguage(s.domain); | |
| 28 | − await db | |
| 29 | − .insert(sources) | |
| 30 | − .values({ id: s.id, name: s.name, domain: s.domain, homepage: s.homepage ?? `https://${s.domain}`, description: s.description, categories: s.categories, tier: s.tier, importanceWeight: s.weight, discover: s.discover, fallback: s.fallback, notes: s.notes, enabled: s.enabled, llmEnabled: s.llm, firstParty, country, language }) | |
| 31 | − .onConflictDoUpdate({ target: sources.id, set: { name: s.name, domain: s.domain, homepage: s.homepage ?? `https://${s.domain}`, description: s.description, categories: s.categories, tier: s.tier, importanceWeight: s.weight, discover: s.discover, fallback: s.fallback, notes: s.notes, enabled: s.enabled, llmEnabled: s.llm, firstParty, country, language, updatedAt: new Date() } }); | |
| 21 | +/** | |
| 22 | + * Upsert one organization (source row + organization entity + aliases + product entities). Shared by the YAML | |
| 23 | + * registry sync (`origin = seed`) and the Source Factory (`origin = factory`). Sensors are handled by the caller. | |
| 24 | + */ | |
| 25 | +export async function upsertSourceRecord(s: SourceSeed, opts: { origin?: "seed" | "import" | "factory"; sector?: string | null } = {}): Promise<void> { | |
| 26 | + // Provenance: media/aggregators are third-party; everything else is the organization's own channel. | |
| 27 | + const firstParty = s.first_party ?? !s.categories.some((c) => c === "news" || c === "media"); | |
| 28 | + const country = s.country ?? inferCountry(s.domain, s.categories); | |
| 29 | + const language = s.language ?? inferLanguage(s.domain); | |
| 30 | + const origin = opts.origin ?? "seed"; | |
| 31 | + const base = { name: s.name, domain: s.domain, homepage: s.homepage ?? `https://${s.domain}`, description: s.description, categories: s.categories, tier: s.tier, importanceWeight: s.weight, discover: s.discover, fallback: s.fallback, notes: s.notes, enabled: s.enabled, llmEnabled: s.llm, firstParty, country, language }; | |
| 32 | + await db | |
| 33 | + .insert(sources) | |
| 34 | + .values({ id: s.id, ...base, origin, sector: opts.sector ?? null }) | |
| 35 | + .onConflictDoUpdate({ target: sources.id, set: origin === "seed" ? { ...base, origin, updatedAt: new Date() } : { ...base, updatedAt: new Date(), ...(opts.sector ? { sector: opts.sector } : {}) } }); | |
| 32 | 36 | |
| 33 | − // Organization entity + aliases | |
| 34 | − const entId = `org_${s.id}`; | |
| 37 | + // Organization entity + aliases | |
| 38 | + const entId = `org_${s.id}`; | |
| 39 | + await db | |
| 40 | + .insert(entities) | |
| 41 | + .values({ id: entId, name: s.name, type: s.entity_type, domain: s.domain, homepage: s.homepage ?? `https://${s.domain}`, description: s.description, importance: sourceImportanceFromTier(s.tier, s.weight), categories: s.categories }) | |
| 42 | + .onConflictDoUpdate({ target: entities.id, set: { name: s.name, type: s.entity_type, domain: s.domain, description: s.description, importance: sourceImportanceFromTier(s.tier, s.weight), categories: s.categories } }); | |
| 43 | + await db.insert(sourceEntities).values({ sourceId: s.id, entityId: entId }).onConflictDoNothing(); | |
| 44 | + for (const alias of new Set([s.name, s.domain, s.domain.replace(/^www\./, ""), ...s.aliases])) { | |
| 45 | + await db.insert(entityAliases).values({ alias: alias.toLowerCase(), entityId: entId }).onConflictDoNothing(); | |
| 46 | + } | |
| 47 | + for (const p of s.products) { | |
| 48 | + const pid = p.id ?? `prd_${s.id}_${slugify(p.name)}`; | |
| 35 | 49 | await db |
| 36 | 50 | .insert(entities) |
| 37 | − .values({ id: entId, name: s.name, type: s.entity_type, domain: s.domain, homepage: s.homepage ?? `https://${s.domain}`, description: s.description, importance: sourceImportanceFromTier(s.tier, s.weight), categories: s.categories }) | |
| 38 | − .onConflictDoUpdate({ target: entities.id, set: { name: s.name, type: s.entity_type, domain: s.domain, description: s.description, importance: sourceImportanceFromTier(s.tier, s.weight), categories: s.categories } }); | |
| 39 | − await db.insert(sourceEntities).values({ sourceId: s.id, entityId: entId }).onConflictDoNothing(); | |
| 40 | − for (const alias of new Set([s.name, s.domain, s.domain.replace(/^www\./, ""), ...s.aliases])) { | |
| 41 | − await db.insert(entityAliases).values({ alias: alias.toLowerCase(), entityId: entId }).onConflictDoNothing(); | |
| 42 | − } | |
| 43 | − for (const p of s.products) { | |
| 44 | − const pid = p.id ?? `prd_${s.id}_${slugify(p.name)}`; | |
| 45 | − await db | |
| 46 | − .insert(entities) | |
| 47 | − .values({ id: pid, name: p.name, type: p.type, parentId: entId, importance: Math.max(30, sourceImportanceFromTier(s.tier, s.weight) - 10), categories: s.categories, domain: s.domain }) | |
| 48 | − .onConflictDoUpdate({ target: entities.id, set: { name: p.name, type: p.type, parentId: entId } }); | |
| 49 | − await db.insert(sourceEntities).values({ sourceId: s.id, entityId: pid }).onConflictDoNothing(); | |
| 50 | − for (const alias of new Set([p.name, ...p.aliases])) await db.insert(entityAliases).values({ alias: alias.toLowerCase(), entityId: pid }).onConflictDoNothing(); | |
| 51 | − await db.execute(sql`insert into entity_relations (from_id, relation, to_id) values (${entId}, 'owns', ${pid}) on conflict do nothing`); | |
| 52 | − } | |
| 51 | + .values({ id: pid, name: p.name, type: p.type, parentId: entId, importance: Math.max(30, sourceImportanceFromTier(s.tier, s.weight) - 10), categories: s.categories, domain: s.domain }) | |
| 52 | + .onConflictDoUpdate({ target: entities.id, set: { name: p.name, type: p.type, parentId: entId } }); | |
| 53 | + await db.insert(sourceEntities).values({ sourceId: s.id, entityId: pid }).onConflictDoNothing(); | |
| 54 | + for (const alias of new Set([p.name, ...p.aliases])) await db.insert(entityAliases).values({ alias: alias.toLowerCase(), entityId: pid }).onConflictDoNothing(); | |
| 55 | + await db.execute(sql`insert into entity_relations (from_id, relation, to_id) values (${entId}, 'owns', ${pid}) on conflict do nothing`); | |
| 56 | + } | |
| 57 | +} | |
| 58 | + | |
| 59 | +export async function syncRegistry(seeds = loadSeeds()): Promise<{ sources: number; sensors: number }> { | |
| 60 | + let nSensors = 0; | |
| 61 | + for (const s of seeds) { | |
| 62 | + await upsertSourceRecord(s, { origin: "seed" }); | |
| 53 | 63 | |
| 54 | 64 | const seedIds: string[] = []; |
| 55 | 65 | for (const sen of s.sensors) { |
added
apps/web/src/app/ops/factory-panel.tsx
+205 −0
@@ -0,0 +1,205 @@ | ||
| 1 | +"use client"; | |
| 2 | + | |
| 3 | +import Link from "next/link"; | |
| 4 | +import { useEffect, useState } from "react"; | |
| 5 | +import { Chip, Empty, HealthPill, Panel, Skeleton, Sparkline, Stat, Table, Td } from "@/components/ui"; | |
| 6 | +import { fmtInt, relTime, utcDateTime } from "@/lib/format"; | |
| 7 | + | |
| 8 | +/** Shape of GET /api/v1/admin/factory (internal). */ | |
| 9 | +interface FactoryReport { | |
| 10 | + seeds: Record<string, number>; | |
| 11 | + candidates: Record<string, number>; | |
| 12 | + shadow: { total: number; up: number; unhealthy: number; ready: number; raw_changes: number }; | |
| 13 | + shadow_by_kind: { kind_class: string; n: number }[]; | |
| 14 | + daily: { day: string; seeds_processed: number; requests: number; candidates: number; shadow_created: number; accepted: number; rejected: number; blocked: number }[]; | |
| 15 | + sectors: { sector: string; seeds: number; queued: number; discovering: number; discovered: number; blocked: number; errors: number; shadow: number; accepted: number; rejected: number }[]; | |
| 16 | + recent_seeds: { id: string; name: string; domain: string; sector: string | null; status: string; candidates: number; shadow: number; accepted: number; rejected: number; last_error: string | null; discovered_at: string | null; source_id: string | null }[]; | |
| 17 | + heartbeat: { at?: string; processed?: number; queued?: number; concurrency?: number; idle?: boolean; lastBatch?: { seed: string; status: string; candidates: number; shadow: number; requests: number; ms: number }[] } | null; | |
| 18 | + generated_at: string; | |
| 19 | +} | |
| 20 | +interface Candidate { | |
| 21 | + id: string; | |
| 22 | + source_id: string; | |
| 23 | + url: string; | |
| 24 | + name: string | null; | |
| 25 | + kind_class: string | null; | |
| 26 | + connector: string | null; | |
| 27 | + tier: string | null; | |
| 28 | + status: string; | |
| 29 | + score_value: number | null; | |
| 30 | + reason: string | null; | |
| 31 | + shadow_sensor_id: string | null; | |
| 32 | + shadow_health: string | null; | |
| 33 | + shadow_runs: number | null; | |
| 34 | + shadow_changes: number | null; | |
| 35 | + updated_at: string; | |
| 36 | +} | |
| 37 | + | |
| 38 | +export function FactoryPanel({ fetcher, tick }: { fetcher: <T>(path: string, init?: RequestInit) => Promise<T>; tick: number }) { | |
| 39 | + const [d, setD] = useState<FactoryReport | null>(null); | |
| 40 | + const [err, setErr] = useState<string | null>(null); | |
| 41 | + const [cands, setCands] = useState<Candidate[] | null>(null); | |
| 42 | + const [candStatus, setCandStatus] = useState("shadow"); | |
| 43 | + const [busy, setBusy] = useState<string | null>(null); | |
| 44 | + | |
| 45 | + useEffect(() => { | |
| 46 | + let cancelled = false; | |
| 47 | + fetcher<FactoryReport>("/api/v1/admin/factory") | |
| 48 | + .then((r) => !cancelled && (setD(r), setErr(null))) | |
| 49 | + .catch((e: Error) => !cancelled && setErr(e.message)); | |
| 50 | + return () => { | |
| 51 | + cancelled = true; | |
| 52 | + }; | |
| 53 | + }, [fetcher, tick]); | |
| 54 | + useEffect(() => { | |
| 55 | + let cancelled = false; | |
| 56 | + fetcher<{ items: Candidate[] }>(`/api/v1/admin/factory/candidates?status=${encodeURIComponent(candStatus)}&limit=60`) | |
| 57 | + .then((r) => !cancelled && setCands(r.items)) | |
| 58 | + .catch(() => !cancelled && setCands([])); | |
| 59 | + return () => { | |
| 60 | + cancelled = true; | |
| 61 | + }; | |
| 62 | + }, [fetcher, tick, candStatus]); | |
| 63 | + | |
| 64 | + const act = async (path: string): Promise<void> => { | |
| 65 | + setBusy(path); | |
| 66 | + try { | |
| 67 | + await fetcher(path, { method: "POST", body: "{}" }); | |
| 68 | + const r = await fetcher<{ items: Candidate[] }>(`/api/v1/admin/factory/candidates?status=${encodeURIComponent(candStatus)}&limit=60`); | |
| 69 | + setCands(r.items); | |
| 70 | + } catch (e) { | |
| 71 | + setErr((e as Error).message); | |
| 72 | + } finally { | |
| 73 | + setBusy(null); | |
| 74 | + } | |
| 75 | + }; | |
| 76 | + | |
| 77 | + if (err && !d) return <Panel title="Source Factory"><Empty>{err}</Empty></Panel>; | |
| 78 | + if (!d) return <Skeleton className="h-40 w-full" />; | |
| 79 | + const hb = d.heartbeat; | |
| 80 | + const stale = hb?.at ? Date.now() - new Date(hb.at).getTime() > 5 * 60_000 : true; | |
| 81 | + const daily = [...d.daily].reverse(); | |
| 82 | + const seedsTotal = Object.values(d.seeds).reduce((a, b) => a + b, 0); | |
| 83 | + return ( | |
| 84 | + <div className="flex flex-col gap-4"> | |
| 85 | + <div className="panel grid grid-cols-2 divide-x divide-y divide-line sm:grid-cols-4 xl:grid-cols-8 xl:divide-y-0"> | |
| 86 | + <Stat label="Factory" value={hb ? (stale ? "STALE" : hb.idle ? "IDLE" : "LIVE") : "NO HEARTBEAT"} tone={hb && !stale ? "signal" : "warn"} hint={hb?.at ? `${relTime(hb.at)} · ${hb.processed ?? 0} processed` : "Redis ws:factory:status empty"} /> | |
| 87 | + <Stat label="Seeds" value={fmtInt(seedsTotal)} hint={`${fmtInt(d.seeds.queued ?? 0)} queued · ${fmtInt(d.seeds.discovering ?? 0)} discovering`} /> | |
| 88 | + <Stat label="Discovered" value={fmtInt((d.seeds.discovered ?? 0) + (d.seeds.done ?? 0))} hint={`${fmtInt(d.seeds.blocked ?? 0)} blocked · ${fmtInt(d.seeds.error ?? 0)} errors`} /> | |
| 89 | + <Stat label="Candidates" value={fmtInt(Object.values(d.candidates).reduce((a, b) => a + b, 0))} hint={`${fmtInt(d.candidates.candidate ?? 0)} waiting · ${fmtInt(d.candidates.duplicate ?? 0)} dup`} /> | |
| 90 | + <Stat label="Shadow" value={fmtInt(d.shadow.total)} tone="info" hint={`${fmtInt(d.shadow.ready)} ready · ${fmtInt(d.shadow.unhealthy)} unhealthy`} /> | |
| 91 | + <Stat label="Accepted" value={fmtInt(d.candidates.accepted ?? 0)} tone="signal" hint="production sensors" /> | |
| 92 | + <Stat label="Rejected" value={fmtInt(d.candidates.rejected ?? 0)} hint="errors · noise · duplicates" /> | |
| 93 | + <Stat label="Requests today" value={fmtInt(daily.at(-1)?.requests ?? 0)} hint={`${fmtInt(daily.at(-1)?.seeds_processed ?? 0)} organizations`} /> | |
| 94 | + </div> | |
| 95 | + | |
| 96 | + <div className="grid grid-cols-1 gap-4 xl:grid-cols-3"> | |
| 97 | + <Panel title="Funnel · 30 days" dense> | |
| 98 | + {daily.length ? ( | |
| 99 | + <div className="grid grid-cols-1 gap-3 p-3 sm:grid-cols-2"> | |
| 100 | + {(["seeds_processed", "candidates", "shadow_created", "accepted"] as const).map((k) => ( | |
| 101 | + <div key={k}> | |
| 102 | + <div className="mb-1 flex items-baseline justify-between"><span className="label">{k.replace(/_/g, " ")}</span><span className="font-mono text-[12px] tabular">{fmtInt(daily.reduce((a, r) => a + Number(r[k] ?? 0), 0))}</span></div> | |
| 103 | + <Sparkline values={daily.map((r) => Number(r[k] ?? 0))} tone={k === "accepted" ? "signal" : k === "shadow_created" ? "info" : "muted"} responsive /> | |
| 104 | + </div> | |
| 105 | + ))} | |
| 106 | + </div> | |
| 107 | + ) : ( | |
| 108 | + <Empty>No factory activity yet.</Empty> | |
| 109 | + )} | |
| 110 | + </Panel> | |
| 111 | + <Panel title="By sector" dense className="xl:col-span-2"> | |
| 112 | + {d.sectors.length ? ( | |
| 113 | + <div className="max-h-72 overflow-auto"> | |
| 114 | + <Table head={["Sector", "Seeds", "Queued", "Discovered", "Blocked", "Errors", "Shadow", "Accepted", "Rejected"]}> | |
| 115 | + {d.sectors.map((s) => ( | |
| 116 | + <tr key={s.sector}> | |
| 117 | + <Td><Link href={`/coverage/${s.sector}`} className="hover:underline">{s.sector}</Link></Td> | |
| 118 | + <Td mono>{fmtInt(s.seeds)}</Td> | |
| 119 | + <Td mono className="text-fg-subtle">{fmtInt(s.queued + s.discovering)}</Td> | |
| 120 | + <Td mono>{fmtInt(s.discovered)}</Td> | |
| 121 | + <Td mono className={s.blocked ? "text-warn" : "text-fg-subtle"}>{fmtInt(s.blocked)}</Td> | |
| 122 | + <Td mono className={s.errors ? "text-danger" : "text-fg-subtle"}>{fmtInt(s.errors)}</Td> | |
| 123 | + <Td mono className="text-info">{fmtInt(s.shadow)}</Td> | |
| 124 | + <Td mono className="text-signal">{fmtInt(s.accepted)}</Td> | |
| 125 | + <Td mono className="text-fg-subtle">{fmtInt(s.rejected)}</Td> | |
| 126 | + </tr> | |
| 127 | + ))} | |
| 128 | + </Table> | |
| 129 | + </div> | |
| 130 | + ) : ( | |
| 131 | + <Empty>No seeds yet — run <code>cli.ts factory seed</code> or POST /api/v1/admin/factory/seeds.</Empty> | |
| 132 | + )} | |
| 133 | + </Panel> | |
| 134 | + </div> | |
| 135 | + | |
| 136 | + <div className="grid grid-cols-1 gap-4 xl:grid-cols-2"> | |
| 137 | + <Panel title="Recently processed organizations" dense> | |
| 138 | + {d.recent_seeds.length ? ( | |
| 139 | + <div className="max-h-80 overflow-auto"> | |
| 140 | + <Table head={["Organization", "Sector", "Status", "Cand.", "Shadow", "Acc.", "Rej.", "When"]}> | |
| 141 | + {d.recent_seeds.map((s) => ( | |
| 142 | + <tr key={s.id}> | |
| 143 | + <Td>{s.source_id ? <Link href={`/source/${s.source_id}`} className="hover:underline">{s.name}</Link> : s.name}<div className="font-mono text-[10.5px] text-fg-subtle">{s.domain}{s.last_error ? ` · ${s.last_error}` : ""}</div></Td> | |
| 144 | + <Td mono className="text-fg-subtle">{s.sector ?? "—"}</Td> | |
| 145 | + <Td><Chip tone={s.status === "discovered" ? "ok" : s.status === "blocked" ? "warn" : s.status === "error" ? "danger" : "default"}>{s.status}</Chip></Td> | |
| 146 | + <Td mono>{s.candidates}</Td> | |
| 147 | + <Td mono className="text-info">{s.shadow}</Td> | |
| 148 | + <Td mono className="text-signal">{s.accepted}</Td> | |
| 149 | + <Td mono className="text-fg-subtle">{s.rejected}</Td> | |
| 150 | + <Td mono className="text-fg-subtle"><span title={s.discovered_at ? utcDateTime(s.discovered_at) : ""}>{s.discovered_at ? relTime(s.discovered_at) : "—"}</span></Td> | |
| 151 | + </tr> | |
| 152 | + ))} | |
| 153 | + </Table> | |
| 154 | + </div> | |
| 155 | + ) : ( | |
| 156 | + <Empty>Nothing processed yet.</Empty> | |
| 157 | + )} | |
| 158 | + </Panel> | |
| 159 | + <Panel | |
| 160 | + title="Candidates" | |
| 161 | + dense | |
| 162 | + action={ | |
| 163 | + <span className="flex gap-1"> | |
| 164 | + {["shadow", "candidate", "accepted", "rejected", "duplicate"].map((s) => ( | |
| 165 | + <button key={s} type="button" onClick={() => setCandStatus(s)} className={`rounded px-1.5 py-0.5 font-mono text-[10.5px] ${candStatus === s ? "bg-panel-2 text-fg" : "text-fg-subtle hover:text-fg"}`}>{s}</button> | |
| 166 | + ))} | |
| 167 | + </span> | |
| 168 | + } | |
| 169 | + > | |
| 170 | + {cands === null ? ( | |
| 171 | + <Skeleton className="h-40 w-full" /> | |
| 172 | + ) : cands.length === 0 ? ( | |
| 173 | + <Empty>No candidate with status {candStatus}.</Empty> | |
| 174 | + ) : ( | |
| 175 | + <div className="max-h-80 overflow-auto"> | |
| 176 | + <Table head={["Sensor", "Kind", "Score", "Shadow", "Reason", ""]}> | |
| 177 | + {cands.map((c) => ( | |
| 178 | + <tr key={c.id}> | |
| 179 | + <Td><Link href={`/source/${c.source_id}`} className="hover:underline">{c.source_id}</Link><div className="max-w-[28ch] truncate font-mono text-[10.5px] text-fg-subtle" title={c.url}>{c.name ?? ""} · {c.url}</div></Td> | |
| 180 | + <Td mono className="text-fg-subtle">{c.kind_class ?? "—"}<div>{c.connector} {c.tier}</div></Td> | |
| 181 | + <Td mono>{c.score_value?.toFixed(2) ?? "—"}</Td> | |
| 182 | + <Td>{c.shadow_sensor_id ? <span className="flex flex-col gap-0.5"><HealthPill health={c.shadow_health ?? "UP"} /><span className="font-mono text-[10.5px] text-fg-subtle">{c.shadow_runs ?? 0} checks · {c.shadow_changes ?? 0} changes</span></span> : <span className="text-fg-subtle">—</span>}</Td> | |
| 183 | + <Td className="text-[11px] text-fg-subtle"><span className="block max-w-[24ch] truncate" title={c.reason ?? ""}>{c.reason ?? "—"}</span></Td> | |
| 184 | + <Td> | |
| 185 | + {c.status === "shadow" && c.shadow_sensor_id && ( | |
| 186 | + <span className="flex gap-1"> | |
| 187 | + <button type="button" disabled={busy !== null} onClick={() => void act(`/api/v1/admin/factory/shadow/${c.shadow_sensor_id}/accept`)} className="rounded border border-line px-1.5 py-0.5 text-[10.5px] text-signal hover:border-line-strong disabled:opacity-50">accept</button> | |
| 188 | + <button type="button" disabled={busy !== null} onClick={() => void act(`/api/v1/admin/factory/shadow/${c.shadow_sensor_id}/reject`)} className="rounded border border-line px-1.5 py-0.5 text-[10.5px] text-danger hover:border-line-strong disabled:opacity-50">reject</button> | |
| 189 | + </span> | |
| 190 | + )} | |
| 191 | + {c.status === "candidate" && c.connector && ( | |
| 192 | + <button type="button" disabled={busy !== null} onClick={() => void act(`/api/v1/admin/factory/candidates/${c.id}/shadow`)} className="rounded border border-line px-1.5 py-0.5 text-[10.5px] text-info hover:border-line-strong disabled:opacity-50">shadow</button> | |
| 193 | + )} | |
| 194 | + </Td> | |
| 195 | + </tr> | |
| 196 | + ))} | |
| 197 | + </Table> | |
| 198 | + </div> | |
| 199 | + )} | |
| 200 | + </Panel> | |
| 201 | + </div> | |
| 202 | + {hb?.lastBatch?.length ? <p className="text-[11px] text-fg-subtle">last batch: {hb.lastBatch.map((b) => `${b.seed} ${b.status} (${b.candidates} cand · ${b.shadow} shadow · ${b.requests} req · ${Math.round(b.ms / 1000)} s)`).join(" · ")}</p> : null} | |
| 203 | + </div> | |
| 204 | + ); | |
| 205 | +} | |
modified
apps/web/src/app/ops/ops.tsx
+6 −1
@@ -2,11 +2,12 @@ | ||
| 2 | 2 | |
| 3 | 3 | import { KeyRound, LogOut, Play, RefreshCw, ShieldAlert } from "lucide-react"; |
| 4 | 4 | import Link from "next/link"; |
| 5 | −import { useEffect, useMemo, useState, type ReactNode } from "react"; | |
| 5 | +import { useCallback, useEffect, useMemo, useState, type ReactNode } from "react"; | |
| 6 | 6 | import { SENSOR_TYPES, TIERS } from "@websensor/core/client"; |
| 7 | 7 | import { useMounted } from "@/components/theme"; |
| 8 | 8 | import { Chip, Empty, HealthPill, Panel, Skeleton, Sparkline, Stat, Table, Td } from "@/components/ui"; |
| 9 | 9 | import { clientApiBase } from "@/lib/api"; |
| 10 | +import { FactoryPanel } from "./factory-panel"; | |
| 10 | 11 | import { fmtBytes, fmtInt, fmtMs, fmtPct, relTime, untilTime, utcDateTime, utcTime } from "@/lib/format"; |
| 11 | 12 | |
| 12 | 13 | const KEY = "ws_admin"; |
@@ -122,6 +123,7 @@ export function Ops() { | ||
| 122 | 123 | setRefreshing(true); |
| 123 | 124 | setTick((x) => x + 1); |
| 124 | 125 | }; |
| 126 | + const factoryFetcher = useCallback(<T,>(path: string, init: RequestInit = {}): Promise<T> => adminFetch<T>(path, init, token ?? ""), [token]); | |
| 125 | 127 | const submitToken = async (): Promise<void> => { |
| 126 | 128 | const t = input.trim(); |
| 127 | 129 | if (!t) return; |
@@ -187,6 +189,9 @@ export function Ops() { | ||
| 187 | 189 | |
| 188 | 190 | {data ? <Dashboard d={data} /> : <Skeleton className="h-64 w-full" />} |
| 189 | 191 | |
| 192 | + <h2 className="mt-2 text-[13px] font-semibold tracking-tight">Source Factory <span className="font-normal text-fg-subtle">· discovery → shadow → acceptance</span></h2> | |
| 193 | + <FactoryPanel fetcher={factoryFetcher} tick={tick} /> | |
| 194 | + | |
| 190 | 195 | <Tools connectors={data?.connectors ?? []} onDone={refresh} /> |
| 191 | 196 | </div> |
| 192 | 197 | ); |
modified
apps/web/src/app/page.tsx
+2 −0
@@ -1,6 +1,7 @@ | ||
| 1 | 1 | import Link from "next/link"; |
| 2 | 2 | import { LiveFeed } from "@/components/live-feed"; |
| 3 | 3 | import { LiveStrip } from "@/components/live-strip"; |
| 4 | +import { CoverageRail } from "@/components/coverage-rail"; | |
| 4 | 5 | import { AnomalyRail, BreakingRail, ClustersPanel, SilentRail, TrendingPanel } from "@/components/rail"; |
| 5 | 6 | import { EventRow } from "@/components/event-row"; |
| 6 | 7 | import { Empty, Panel } from "@/components/ui"; |
@@ -39,6 +40,7 @@ export default async function HomePage() { | ||
| 39 | 40 | <BreakingRail items={breaking?.breaking_now ?? []} developing={breaking?.developing ?? []} /> |
| 40 | 41 | <TrendingPanel items={trending.items} /> |
| 41 | 42 | <AnomalyRail items={explore?.unusual_activity ?? []} /> |
| 43 | + <CoverageRail /> | |
| 42 | 44 | </aside> |
| 43 | 45 | </div> |
| 44 | 46 | <div className="mt-4 grid grid-cols-1 gap-4 lg:grid-cols-3 [&>*]:min-w-0"> |
modified
apps/web/src/components/command-palette.tsx
+1 −0
@@ -33,6 +33,7 @@ const NAV: { label: string; href: string; hint: string; icon: ReactNode }[] = [ | ||
| 33 | 33 | { label: "Entities", href: "/entities", hint: "organizations, products, models", icon: <Building2 className="size-3.5" /> }, |
| 34 | 34 | { label: "Countries", href: "/country", hint: "national desks", icon: <Globe2 className="size-3.5" /> }, |
| 35 | 35 | { label: "Sources", href: "/sources", hint: "monitored organizations", icon: <Building2 className="size-3.5" /> }, |
| 36 | + { label: "Coverage", href: "/coverage", hint: "global observation coverage score by sector", icon: <Globe2 className="size-3.5" /> }, | |
| 36 | 37 | { label: "Watchlists", href: "/watchlists", hint: "your entities, sources, keywords", icon: <Layers className="size-3.5" /> }, |
| 37 | 38 | { label: "Alerts", href: "/alerts", hint: "rules · webhooks", icon: <Siren className="size-3.5" /> }, |
| 38 | 39 | { label: "Bookmarks", href: "/bookmarks", hint: "saved events", icon: <Layers className="size-3.5" /> }, |
added
apps/web/src/components/coverage-rail.tsx
+27 −0
@@ -0,0 +1,27 @@ | ||
| 1 | +import Link from "next/link"; | |
| 2 | +import { api } from "@/lib/api"; | |
| 3 | +import { fmtInt } from "@/lib/format"; | |
| 4 | +import { Bar, Empty, Panel } from "./ui"; | |
| 5 | + | |
| 6 | +/** Homepage rail: Global Observation Coverage Score by sector (server component). */ | |
| 7 | +export async function CoverageRail() { | |
| 8 | + const c = await api.coverage(); | |
| 9 | + return ( | |
| 10 | + <Panel title={<span>Observation coverage {c ? <span className={`font-mono ${c.global_score >= 60 ? "text-signal" : "text-high"}`}>{c.global_score.toFixed(1)}</span> : null}</span>} dense action={<Link href="/coverage" className="text-[11px] text-fg-subtle hover:text-fg">coverage →</Link>}> | |
| 11 | + {!c || !c.sectors.length ? ( | |
| 12 | + <Empty>Coverage universes are loading.</Empty> | |
| 13 | + ) : ( | |
| 14 | + <ul className="px-3 py-1.5 text-[12px]"> | |
| 15 | + {c.sectors.slice(0, 10).map((s) => ( | |
| 16 | + <li key={s.sector} className="grid grid-cols-[7.5rem_1fr_2.5rem] items-center gap-2 py-0.5"> | |
| 17 | + <Link href={`/coverage/${s.sector}`} className="truncate text-fg-muted hover:text-fg" title={`${fmtInt(s.covered)} / ${fmtInt(s.members)} observed`}>{s.label}</Link> | |
| 18 | + <Bar value={s.score} tone={s.score >= 80 ? "signal" : s.score >= 60 ? "high" : s.score >= 40 ? "mid" : "info"} /> | |
| 19 | + <span className="text-right font-mono tabular text-fg-muted">{Math.round(s.score)}%</span> | |
| 20 | + </li> | |
| 21 | + ))} | |
| 22 | + <li className="pt-1 text-[10.5px] text-fg-subtle">{fmtInt(c.covered)} / {fmtInt(c.members)} organizations · {fmtInt(c.monitored.shadow)} sensors in shadow</li> | |
| 23 | + </ul> | |
| 24 | + )} | |
| 25 | + </Panel> | |
| 26 | + ); | |
| 27 | +} | |
modified
apps/web/src/components/nav.tsx
+2 −0
@@ -18,6 +18,7 @@ const NAV = [ | ||
| 18 | 18 | ["/explore", "Explore"], |
| 19 | 19 | ["/entities", "Entities"], |
| 20 | 20 | ["/sources", "Sources"], |
| 21 | + ["/coverage", "Coverage"], | |
| 21 | 22 | ["/watchlists", "Watchlists"], |
| 22 | 23 | ["/alerts", "Alerts"], |
| 23 | 24 | ] as const; |
@@ -82,6 +83,7 @@ export function MobileNav() { | ||
| 82 | 83 | ["/silent", "Silent changes", LayoutGrid], |
| 83 | 84 | ["/entities", "Entities", Globe2], |
| 84 | 85 | ["/sources", "Sources", Globe2], |
| 86 | + ["/coverage", "Coverage", Globe2], | |
| 85 | 87 | ["/country", "Countries", Globe2], |
| 86 | 88 | ["/alerts", "Alerts", Bell], |
| 87 | 89 | ["/bookmarks", "Bookmarks", Bookmark], |
modified
apps/web/src/components/ui.tsx
+2 −2
@@ -80,10 +80,10 @@ export function EvidenceTag({ label }: { label: string | null | undefined }) { | ||
| 80 | 80 | |
| 81 | 81 | export function HealthPill({ health }: { health: string | null | undefined }) { |
| 82 | 82 | const h = (health ?? "UP").toUpperCase(); |
| 83 | − const tone: Tone = h === "UP" || h === "ACTIVE" || h === "VALIDATED" ? "ok" : h === "DEGRADED" || h === "RATE_LIMITED" || h === "PENDING" ? "warn" : h === "ERROR" ? "danger" : "default"; | |
| 83 | + const tone: Tone = h === "UP" || h === "ACTIVE" || h === "VALIDATED" ? "ok" : h === "DEGRADED" || h === "RATE_LIMITED" || h === "PENDING" ? "warn" : h === "ERROR" ? "danger" : h === "SHADOW" ? "info" : "default"; | |
| 84 | 84 | return ( |
| 85 | 85 | <Chip tone={tone} className="font-mono"> |
| 86 | − <span className={`inline-block size-1.5 rounded-full ${tone === "ok" ? "bg-ok" : tone === "danger" ? "bg-danger" : tone === "default" ? "bg-low" : "bg-warn"}`} /> {h} | |
| 86 | + <span className={`inline-block size-1.5 rounded-full ${tone === "ok" ? "bg-ok" : tone === "danger" ? "bg-danger" : tone === "default" ? "bg-low" : tone === "info" ? "bg-info" : "bg-warn"}`} /> {h} | |
| 87 | 87 | </Chip> |
| 88 | 88 | ); |
| 89 | 89 | } |
modified
apps/web/src/lib/api.ts
+63 −0
@@ -196,7 +196,68 @@ export interface EventsPage { | ||
| 196 | 196 | nextCursor: string | null; |
| 197 | 197 | } |
| 198 | 198 | |
| 199 | +export interface CoverageSectorRow { | |
| 200 | + sector: string; | |
| 201 | + label: string; | |
| 202 | + description: string | null; | |
| 203 | + weight: number; | |
| 204 | + members: number; | |
| 205 | + covered: number; | |
| 206 | + shadow_members: number; | |
| 207 | + queued: number; | |
| 208 | + sensors: number; | |
| 209 | + breadth: number; | |
| 210 | + depth: number; | |
| 211 | + score: number; | |
| 212 | +} | |
| 213 | +export interface CoverageSummary { | |
| 214 | + global_score: number; | |
| 215 | + members: number; | |
| 216 | + covered: number; | |
| 217 | + sensors_matched: number; | |
| 218 | + sectors: CoverageSectorRow[]; | |
| 219 | + monitored: { sources: number; sensors: number; shadow: number; countries: number }; | |
| 220 | + factory: Record<string, number>; | |
| 221 | + method: string; | |
| 222 | + issues: string[]; | |
| 223 | + generated_at: string; | |
| 224 | +} | |
| 225 | +export interface CoverageMemberRow { | |
| 226 | + name: string; | |
| 227 | + domain: string; | |
| 228 | + country: string | null; | |
| 229 | + importance: number; | |
| 230 | + universe: string; | |
| 231 | + covered: boolean; | |
| 232 | + sensors: number; | |
| 233 | + shadow: number; | |
| 234 | + source_id: string | null; | |
| 235 | + seed_status: string | null; | |
| 236 | + depth: number; | |
| 237 | +} | |
| 238 | +export interface CoverageUniverseRow { | |
| 239 | + key: string; | |
| 240 | + label: string; | |
| 241 | + description: string | null; | |
| 242 | + provenance: string | null; | |
| 243 | + members: number; | |
| 244 | + covered: number; | |
| 245 | + sensors: number; | |
| 246 | + breadth: number; | |
| 247 | + depth: number; | |
| 248 | + score: number; | |
| 249 | + items: CoverageMemberRow[]; | |
| 250 | +} | |
| 251 | +export interface CoverageSectorDetail extends CoverageSectorRow { | |
| 252 | + categories: string[]; | |
| 253 | + universes: CoverageUniverseRow[]; | |
| 254 | + by_country: { country: string; members: number; covered: number }[]; | |
| 255 | + generated_at: string; | |
| 256 | +} | |
| 257 | + | |
| 199 | 258 | export interface Stats { |
| 259 | + sensors_shadow?: number; | |
| 260 | + sources_factory?: number; | |
| 200 | 261 | checks_per_min?: number; |
| 201 | 262 | events_per_min?: number; |
| 202 | 263 | events_1h?: number; |
@@ -808,5 +869,7 @@ export const api = { | ||
| 808 | 869 | compare: (a: string, b: string) => getJson<CompareResult | null>(`/api/v1/snapshots/compare?a=${encodeURIComponent(a)}&b=${encodeURIComponent(b)}`, null), |
| 809 | 870 | change: (id: string) => getJson<{ change: ChangeRow & { diff: DiffSummary; sensor_url?: string; sensor_name?: string; source_id?: string }; unified: string | null } | null>(`/api/v1/changes/${encodeURIComponent(id)}`, null), |
| 810 | 871 | health: () => getJson<HealthReport | null>("/api/v1/health/connectors", null), |
| 872 | + coverage: () => getJson<CoverageSummary | null>("/api/v1/coverage", null, { revalidate: 60 }), | |
| 873 | + coverageSector: (sector: string) => getJson<CoverageSectorDetail | null>(`/api/v1/coverage/${encodeURIComponent(sector)}`, null, { revalidate: 60 }), | |
| 811 | 874 | search: (q: string) => getJson<SearchResult>(`/api/v1/search?q=${encodeURIComponent(q)}`, { query: q, events: [], entities: [], sources: [], urls: [] }), |
| 812 | 875 | }; |
modified
deploy/README.md
+17 −1
@@ -1,6 +1,6 @@ | ||
| 1 | 1 | # Deploying WebSensor on MacLustr |
| 2 | 2 | |
| 3 | −WebSensor runs as three PM2 processes on one node (M4M64b), published through the MacLustr Tunnel (WireGuard + | |
| 3 | +WebSensor runs as four PM2 processes on one node (M4M64b), published through the MacLustr Tunnel (WireGuard + | |
| 4 | 4 | Caddy on the OVH gateway BHS64, `www.websensor.io`; ngrok was retired on 2026-09-10), orchestrated by `mld` from the |
| 5 | 5 | M1M32 gateway. |
| 6 | 6 | |
@@ -9,6 +9,7 @@ Internet → www.websensor.io → BHS64 (Caddy TLS) → wg1 → websensor-api (: | ||
| 9 | 9 | ├── /api/v1/* REST + WebSocket /api/v1/live |
| 10 | 10 | └── /* reverse proxy → websensor-web (Next.js :8261, loopback) |
| 11 | 11 | websensor-engine (scheduler + pipeline, Prometheus :8262) → PostgreSQL 17 `websensor` + Redis (loopback) |
| 12 | +websensor-factory (Source Factory: coverage seeds → deep discovery → shadow sensors → acceptance; heartbeat Redis ws:factory:status) | |
| 12 | 13 | Blob store: ~/websensor-data/blobs (content-addressed, zstd) — never synced by mld (excluded `data/`). |
| 13 | 14 | ``` |
| 14 | 15 | |
@@ -30,6 +31,21 @@ Blob store: ~/websensor-data/blobs (content-addressed, zstd) — never synced by | ||
| 30 | 31 | - WebSocket upgrades traverse Caddy without configuration; the API sends a heartbeat every 25 s and clients |
| 31 | 32 | replay missed events with `{"since": lastSid}` after a reconnection. |
| 32 | 33 | |
| 34 | +## Source Factory (0.3) | |
| 35 | +- Seeds: `config/coverage/*.yaml` (universes) and `config/factory/seeds/*.yaml`; the post-sync hook runs | |
| 36 | + `cli.ts factory seed --mode=hinted` (members without a monitored domain + members with hints). `--mode=all --requeue` | |
| 37 | + re-discovers everything; `factory requeue` re-queues blocked/errored seeds. | |
| 38 | +- Process: `pm2 logs websensor-factory`; `cli.ts factory stats`; `/ops` → Source Factory panel (funnel, sectors, | |
| 39 | + candidates, manual accept/reject); `GET /api/v1/admin/factory`, `/admin/factory/seeds`, `/admin/factory/candidates`, | |
| 40 | + `POST /admin/factory/seeds` (bulk), `POST /admin/factory/shadow/:sensorId/accept|reject`. | |
| 41 | +- Tuning (env on the factory process): `WS_FACTORY_CONCURRENCY` (6), `WS_FACTORY_BUDGET` (90 requests/org), | |
| 42 | + `WS_FACTORY_MIN_SCORE` (0.35), `WS_FACTORY_SHADOW_MIN_CHECKS` (5), `WS_FACTORY_SHADOW_MIN_HOURS` (24), | |
| 43 | + `WS_FACTORY_SHADOW_MAX_HOURS` (120). `GITHUB_TOKEN` (optional) improves repository ranking. | |
| 44 | +- Shadow sensors are polled by the engine at priority 3; raise `WS_FETCH_CONCURRENCY` on the engine when the shadow | |
| 45 | + population grows (32 at 0.3). `cli.ts factory evaluate [--force]` runs the decision pass by hand; | |
| 46 | + `cli.ts factory export --sector=x > config/sources.d/6x-factory-x.yaml` graduates accepted sensors into the registry. | |
| 47 | +- Coverage: `GET /api/v1/coverage`, `/coverage` page; universes validated with `apps/engine/src/coverage-validate.ts --dns`. | |
| 48 | + | |
| 33 | 49 | ## Operations |
| 34 | 50 | - Logs: `mld logs websensor` or `ssh M4M64b pm2 logs websensor-engine`. |
| 35 | 51 | - Registry edits: change `config/sources.yaml`, redeploy (sync runs in post-sync) or |
modified
deploy/websensor.mld.json.example
+194 −123
@@ -1,128 +1,199 @@ | ||
| 1 | 1 | { |
| 2 | − "app": "websensor", | |
| 3 | − "label": "WebSensor — Detect What Changed. Know Why It Matters.", | |
| 4 | − "domain": "www.websensor.io", | |
| 5 | − "port": 8260, | |
| 6 | − "health_path": "/api/ready", | |
| 7 | − "dir": "~/apps/websensor", | |
| 8 | − "extra_paths": [], | |
| 9 | − "sync_excludes": [ | |
| 10 | − "node_modules/", | |
| 11 | − ".next/", | |
| 12 | − ".git/", | |
| 13 | − ".env", | |
| 14 | − ".env.*", | |
| 15 | − "!.env.example", | |
| 16 | − "*.tsbuildinfo", | |
| 17 | − ".turbo/", | |
| 18 | − "logs/", | |
| 19 | − "tmp/", | |
| 20 | − "data/", | |
| 21 | − "coverage/", | |
| 22 | − ".DS_Store", | |
| 23 | − ".claude/", | |
| 24 | − "qa/", | |
| 25 | − "apps/web/.next/", | |
| 26 | − "apps/web/next-env.d.ts", | |
| 27 | − "deploy/*.mld.json" | |
| 2 | + "app": "websensor", | |
| 3 | + "label": "WebSensor — Detect What Changed. Know Why It Matters.", | |
| 4 | + "domain": "www.websensor.io", | |
| 5 | + "port": 8260, | |
| 6 | + "health_path": "/api/ready", | |
| 7 | + "dir": "~/apps/websensor", | |
| 8 | + "extra_paths": [], | |
| 9 | + "sync_excludes": [ | |
| 10 | + "node_modules/", | |
| 11 | + ".next/", | |
| 12 | + ".git/", | |
| 13 | + ".env", | |
| 14 | + ".env.*", | |
| 15 | + "!.env.example", | |
| 16 | + "*.tsbuildinfo", | |
| 17 | + ".turbo/", | |
| 18 | + "logs/", | |
| 19 | + "tmp/", | |
| 20 | + "data/", | |
| 21 | + "coverage/", | |
| 22 | + ".DS_Store", | |
| 23 | + ".claude/", | |
| 24 | + "qa/", | |
| 25 | + "apps/web/.next/", | |
| 26 | + "apps/web/next-env.d.ts", | |
| 27 | + "deploy/*.mld.json" | |
| 28 | + ], | |
| 29 | + "requires": { | |
| 30 | + "runtimes": [ | |
| 31 | + "pm2", | |
| 32 | + "node", | |
| 33 | + "pnpm", | |
| 34 | + "postgresql@17", | |
| 35 | + "redis" | |
| 36 | + ], | |
| 37 | + "ram_gb": 8, | |
| 38 | + "ports": [ | |
| 39 | + 8260, | |
| 40 | + 8261, | |
| 41 | + 8262 | |
| 42 | + ] | |
| 43 | + }, | |
| 44 | + "ram_mb_observed": 1500, | |
| 45 | + "size_mb": 40, | |
| 46 | + "placement": { | |
| 47 | + "pin": "M4M64b", | |
| 48 | + "prefer": null, | |
| 49 | + "avoid": [ | |
| 50 | + "M3U96b", | |
| 51 | + "M1M32" | |
| 28 | 52 | ], |
| 29 | − "requires": { | |
| 30 | − "runtimes": ["pm2", "ngrok", "node", "pnpm", "postgresql@17", "redis"], | |
| 31 | − "ram_gb": 6, | |
| 32 | − "ports": [8260, 8261, 8262] | |
| 53 | + "reason": "PostgreSQL 17 + Redis + pnpm présents ; 64 Go RAM ; M3U96a déjà chargé (25 processus PM2)" | |
| 54 | + }, | |
| 55 | + "processes": [ | |
| 56 | + { | |
| 57 | + "name": "websensor-engine", | |
| 58 | + "manager": "pm2", | |
| 59 | + "script": "/opt/homebrew/bin/node", | |
| 60 | + "args": [ | |
| 61 | + "node_modules/tsx/dist/cli.mjs", | |
| 62 | + "apps/engine/src/index.ts" | |
| 63 | + ], | |
| 64 | + "interpreter": null, | |
| 65 | + "cwd": "{{HOME}}/apps/websensor", | |
| 66 | + "env": { | |
| 67 | + "NODE_ENV": "production", | |
| 68 | + "DATABASE_URL": "postgres://localhost:5432/websensor", | |
| 69 | + "REDIS_URL": "redis://127.0.0.1:6379", | |
| 70 | + "BLOB_STORE_DIR": "{{HOME}}/websensor-data/blobs", | |
| 71 | + "WS_SOURCES_FILE": "./config/sources.yaml", | |
| 72 | + "WS_FETCH_CONCURRENCY": "32", | |
| 73 | + "WS_PER_HOST_CONCURRENCY": "2", | |
| 74 | + "ENGINE_METRICS_PORT": "8262", | |
| 75 | + "WS_USER_AGENT": "WebSensorBot/0.3 (+https://www.websensor.io/bot; contact@websensor.io)", | |
| 76 | + "ANTHROPIC_API_KEY": "<set me>", | |
| 77 | + "WS_LLM_MODEL_FAST": "claude-haiku-4-5", | |
| 78 | + "WS_LLM_MODEL_DEEP": "claude-opus-5", | |
| 79 | + "WS_LLM_DAILY_CALL_BUDGET": "600", | |
| 80 | + "WS_LLM_MIN_IMPORTANCE": "35", | |
| 81 | + "WS_LLM_DEEP_MIN_IMPORTANCE": "78", | |
| 82 | + "SCRAPFLY_API_KEY": "<set me>", | |
| 83 | + "WS_SCRAPFLY_DAILY_BUDGET": "300", | |
| 84 | + "LOG_LEVEL": "info", | |
| 85 | + "WS_RETENTION_RAW_DAYS": "21", | |
| 86 | + "WS_RETENTION_CHANGED_RAW_DAYS": "60", | |
| 87 | + "WS_SILENT_MIN_IMPORTANCE": "45" | |
| 88 | + }, | |
| 89 | + "cron_restart": null, | |
| 90 | + "autorestart": true, | |
| 91 | + "max_memory_restart": "2G" | |
| 33 | 92 | }, |
| 34 | − "ram_mb_observed": 1500, | |
| 35 | − "size_mb": 40, | |
| 36 | − "placement": { | |
| 37 | − "pin": "M4M64b", | |
| 38 | − "prefer": null, | |
| 39 | − "avoid": ["M3U96b", "M1M32"], | |
| 40 | − "reason": "PostgreSQL 17 + Redis + pnpm présents ; 64 Go RAM ; M3U96a déjà chargé (25 processus PM2)" | |
| 93 | + { | |
| 94 | + "name": "websensor-factory", | |
| 95 | + "manager": "pm2", | |
| 96 | + "script": "/opt/homebrew/bin/node", | |
| 97 | + "args": [ | |
| 98 | + "node_modules/tsx/dist/cli.mjs", | |
| 99 | + "apps/engine/src/factory-main.ts" | |
| 100 | + ], | |
| 101 | + "interpreter": null, | |
| 102 | + "cwd": "{{HOME}}/apps/websensor", | |
| 103 | + "env": { | |
| 104 | + "NODE_ENV": "production", | |
| 105 | + "DATABASE_URL": "postgres://localhost:5432/websensor", | |
| 106 | + "REDIS_URL": "redis://127.0.0.1:6379", | |
| 107 | + "BLOB_STORE_DIR": "{{HOME}}/websensor-data/blobs", | |
| 108 | + "WS_SOURCES_FILE": "./config/sources.yaml", | |
| 109 | + "WS_USER_AGENT": "WebSensorBot/0.3 (+https://www.websensor.io/bot; contact@websensor.io)", | |
| 110 | + "LOG_LEVEL": "info", | |
| 111 | + "WS_FACTORY": "1", | |
| 112 | + "WS_FACTORY_CONCURRENCY": "6", | |
| 113 | + "WS_FACTORY_BUDGET": "90", | |
| 114 | + "WS_FACTORY_MIN_SCORE": "0.35", | |
| 115 | + "WS_FACTORY_SHADOW_MIN_CHECKS": "5", | |
| 116 | + "WS_FACTORY_SHADOW_MIN_HOURS": "24", | |
| 117 | + "WS_FACTORY_SHADOW_MAX_HOURS": "120", | |
| 118 | + "WS_FACTORY_TICK_SECONDS": "20", | |
| 119 | + "WS_COVERAGE_DIR": "./config/coverage", | |
| 120 | + "WS_EDGAR_USER_AGENT": "WebSensor (contact@websensor.io)", | |
| 121 | + "WS_APP_NAME": "websensor-factory" | |
| 122 | + }, | |
| 123 | + "cron_restart": null, | |
| 124 | + "autorestart": true, | |
| 125 | + "max_memory_restart": "1500M" | |
| 41 | 126 | }, |
| 42 | − "processes": [ | |
| 43 | − { | |
| 44 | − "name": "websensor-engine", | |
| 45 | − "manager": "pm2", | |
| 46 | − "script": "/opt/homebrew/bin/node", | |
| 47 | − "args": ["node_modules/tsx/dist/cli.mjs", "apps/engine/src/index.ts"], | |
| 48 | − "interpreter": null, | |
| 49 | − "cwd": "{{HOME}}/apps/websensor", | |
| 50 | − "env": { | |
| 51 | − "NODE_ENV": "production", | |
| 52 | − "DATABASE_URL": "postgres://localhost:5432/websensor", | |
| 53 | − "REDIS_URL": "redis://127.0.0.1:6379", | |
| 54 | − "BLOB_STORE_DIR": "{{HOME}}/websensor-data/blobs", | |
| 55 | − "WS_SOURCES_FILE": "./config/sources.yaml", | |
| 56 | − "WS_FETCH_CONCURRENCY": "16", | |
| 57 | − "WS_PER_HOST_CONCURRENCY": "2", | |
| 58 | − "ENGINE_METRICS_PORT": "8262", | |
| 59 | − "WS_USER_AGENT": "WebSensorBot/0.1 (+https://www.websensor.io/bot; contact@websensor.io)", | |
| 60 | − "ANTHROPIC_API_KEY": "<secret>", | |
| 61 | − "WS_LLM_MODEL_FAST": "claude-haiku-4-5", | |
| 62 | − "WS_LLM_MODEL_DEEP": "claude-opus-5", | |
| 63 | − "WS_LLM_DAILY_CALL_BUDGET": "600", | |
| 64 | − "WS_LLM_MIN_IMPORTANCE": "35", | |
| 65 | − "WS_LLM_DEEP_MIN_IMPORTANCE": "78", | |
| 66 | − "SCRAPFLY_API_KEY": "<secret>", | |
| 67 | − "WS_SCRAPFLY_DAILY_BUDGET": "300", | |
| 68 | − "LOG_LEVEL": "info" | |
| 69 | − }, | |
| 70 | − "cron_restart": null, | |
| 71 | − "autorestart": true, | |
| 72 | − "max_memory_restart": "2G" | |
| 73 | − }, | |
| 74 | − { | |
| 75 | − "name": "websensor-api", | |
| 76 | − "manager": "pm2", | |
| 77 | − "script": "/opt/homebrew/bin/node", | |
| 78 | − "args": ["node_modules/tsx/dist/cli.mjs", "apps/api/src/server.ts"], | |
| 79 | − "interpreter": null, | |
| 80 | − "cwd": "{{HOME}}/apps/websensor", | |
| 81 | − "env": { | |
| 82 | − "NODE_ENV": "production", | |
| 83 | − "DATABASE_URL": "postgres://localhost:5432/websensor", | |
| 84 | − "REDIS_URL": "redis://127.0.0.1:6379", | |
| 85 | − "BLOB_STORE_DIR": "{{HOME}}/websensor-data/blobs", | |
| 86 | − "API_PORT": "8260", | |
| 87 | − "API_HOST": "0.0.0.0", | |
| 88 | − "WEB_URL": "http://127.0.0.1:8261", | |
| 89 | − "PUBLIC_BASE_URL": "https://www.websensor.io", | |
| 90 | − "CANONICAL_HOST": "www.websensor.io", | |
| 91 | − "LOG_LEVEL": "info" | |
| 92 | − }, | |
| 93 | − "cron_restart": null, | |
| 94 | − "autorestart": true, | |
| 95 | − "max_memory_restart": "1G" | |
| 96 | − }, | |
| 97 | − { | |
| 98 | − "name": "websensor-web", | |
| 99 | − "manager": "pm2", | |
| 100 | − "script": "/opt/homebrew/bin/node", | |
| 101 | − "args": ["node_modules/next/dist/bin/next", "start", "-p", "8261", "-H", "127.0.0.1"], | |
| 102 | − "interpreter": null, | |
| 103 | − "cwd": "{{HOME}}/apps/websensor/apps/web", | |
| 104 | − "env": { | |
| 105 | − "NODE_ENV": "production", | |
| 106 | − "API_URL": "http://127.0.0.1:8260", | |
| 107 | − "NEXT_PUBLIC_SITE_URL": "https://www.websensor.io", | |
| 108 | − "NEXT_TELEMETRY_DISABLED": "1" | |
| 109 | − }, | |
| 110 | − "cron_restart": null, | |
| 111 | − "autorestart": true, | |
| 112 | − "max_memory_restart": "1G" | |
| 113 | − } | |
| 114 | − ], | |
| 115 | − "ngrok": { "name": "websensor-ngrok", "url": "www.websensor.io", "port": 8260 }, | |
| 116 | − "launchd": [], | |
| 117 | − "env_overrides": {}, | |
| 118 | − "hooks": { | |
| 119 | − "post_sync": [ | |
| 120 | − "export PATH=\"/opt/homebrew/bin:$PATH\"; pnpm install --frozen-lockfile --silent && echo ' deps ok'", | |
| 121 | − "export PATH=\"/opt/homebrew/bin:$PATH\"; psql -d postgres -Atc \"select 1 from pg_database where datname='websensor'\" | grep -q 1 || createdb websensor; DATABASE_URL=postgres://localhost:5432/websensor pnpm db:migrate 2>&1 | tail -1 && echo ' db ok'", | |
| 122 | − "export PATH=\"/opt/homebrew/bin:$PATH\"; mkdir -p $HOME/websensor-data/blobs && DATABASE_URL=postgres://localhost:5432/websensor node node_modules/tsx/dist/cli.mjs apps/engine/src/cli.ts sync 2>&1 | tail -1 && echo ' registry ok'", | |
| 123 | − "export PATH=\"/opt/homebrew/bin:$PATH\"; export API_URL=http://127.0.0.1:8260 NEXT_PUBLIC_SITE_URL=https://www.websensor.io NEXT_TELEMETRY_DISABLED=1; pnpm --filter @websensor/web build 2>&1 | tail -3 && echo ' web build ok'" | |
| 124 | − ], | |
| 125 | − "post_start": [] | |
| 127 | + { | |
| 128 | + "name": "websensor-api", | |
| 129 | + "manager": "pm2", | |
| 130 | + "script": "/opt/homebrew/bin/node", | |
| 131 | + "args": [ | |
| 132 | + "node_modules/tsx/dist/cli.mjs", | |
| 133 | + "apps/api/src/server.ts" | |
| 134 | + ], | |
| 135 | + "interpreter": null, | |
| 136 | + "cwd": "{{HOME}}/apps/websensor", | |
| 137 | + "env": { | |
| 138 | + "NODE_ENV": "production", | |
| 139 | + "DATABASE_URL": "postgres://localhost:5432/websensor", | |
| 140 | + "REDIS_URL": "redis://127.0.0.1:6379", | |
| 141 | + "BLOB_STORE_DIR": "{{HOME}}/websensor-data/blobs", | |
| 142 | + "API_PORT": "8260", | |
| 143 | + "API_HOST": "0.0.0.0", | |
| 144 | + "WEB_URL": "http://127.0.0.1:8261", | |
| 145 | + "PUBLIC_BASE_URL": "https://www.websensor.io", | |
| 146 | + "CANONICAL_HOST": "www.websensor.io", | |
| 147 | + "LOG_LEVEL": "info", | |
| 148 | + "WS_ADMIN_TOKEN": "<set me>", | |
| 149 | + "WS_MONITORS_PER_OWNER": "5" | |
| 150 | + }, | |
| 151 | + "cron_restart": null, | |
| 152 | + "autorestart": true, | |
| 153 | + "max_memory_restart": "1G" | |
| 126 | 154 | }, |
| 127 | − "notes": "v0.1.0 (2026-09-08) : gateway Fastify :8260 (REST + WS /api/v1/live + proxy → Next :8261), engine (scheduler/pipeline, métriques :8262), 200 sources / ~290 capteurs (config/sources.yaml), Postgres 17 + Redis locaux, blobs zstd dans ~/websensor-data/blobs. Secrets uniquement dans ce manifeste (M1M32)." | |
| 128 | −} | |
| 155 | + { | |
| 156 | + "name": "websensor-web", | |
| 157 | + "manager": "pm2", | |
| 158 | + "script": "/opt/homebrew/bin/node", | |
| 159 | + "args": [ | |
| 160 | + "node_modules/next/dist/bin/next", | |
| 161 | + "start", | |
| 162 | + "-p", | |
| 163 | + "8261", | |
| 164 | + "-H", | |
| 165 | + "127.0.0.1" | |
| 166 | + ], | |
| 167 | + "interpreter": null, | |
| 168 | + "cwd": "{{HOME}}/apps/websensor/apps/web", | |
| 169 | + "env": { | |
| 170 | + "NODE_ENV": "production", | |
| 171 | + "API_URL": "http://127.0.0.1:8260", | |
| 172 | + "NEXT_PUBLIC_SITE_URL": "https://www.websensor.io", | |
| 173 | + "NEXT_TELEMETRY_DISABLED": "1" | |
| 174 | + }, | |
| 175 | + "cron_restart": null, | |
| 176 | + "autorestart": true, | |
| 177 | + "max_memory_restart": "1G" | |
| 178 | + } | |
| 179 | + ], | |
| 180 | + "ngrok": null, | |
| 181 | + "launchd": [], | |
| 182 | + "env_overrides": {}, | |
| 183 | + "hooks": { | |
| 184 | + "post_sync": [ | |
| 185 | + "export PATH=\"/opt/homebrew/bin:$PATH\"; pnpm install --frozen-lockfile --silent && echo ' deps ok'", | |
| 186 | + "set -o pipefail; export PATH=\"/opt/homebrew/bin:$PATH\"; psql -d postgres -Atc \"select 1 from pg_database where datname='websensor'\" | grep -q 1 || createdb websensor; DATABASE_URL=postgres://localhost:5432/websensor pnpm db:migrate 2>&1 | tail -1 && echo ' db ok'", | |
| 187 | + "export PATH=\"/opt/homebrew/bin:$PATH\"; mkdir -p $HOME/websensor-data/blobs && DATABASE_URL=postgres://localhost:5432/websensor node node_modules/tsx/dist/cli.mjs apps/engine/src/cli.ts sync 2>&1 | tail -1 && echo ' registry ok'", | |
| 188 | + "export PATH=\"/opt/homebrew/bin:$PATH\"; DATABASE_URL=postgres://localhost:5432/websensor node node_modules/tsx/dist/cli.mjs apps/engine/src/cli.ts factory seed --mode=hinted 2>&1 | tail -1 && echo ' factory seeds ok'", | |
| 189 | + "set -o pipefail; export PATH=\"/opt/homebrew/bin:$PATH\"; export API_URL=http://127.0.0.1:8260 NEXT_PUBLIC_SITE_URL=https://www.websensor.io NEXT_TELEMETRY_DISABLED=1; pnpm --filter @websensor/web build 2>&1 | tail -3 && echo ' web build ok'" | |
| 190 | + ], | |
| 191 | + "post_start": [] | |
| 192 | + }, | |
| 193 | + "notes": "v0.1.0 (2026-09-08) : gateway Fastify :8260 (REST + WS /api/v1/live + proxy → Next :8261), engine (scheduler/pipeline, métriques :8262), 200 sources / ~290 capteurs (config/sources.yaml), Postgres 17 + Redis locaux, blobs zstd dans ~/websensor-data/blobs. Secrets uniquement dans ce manifeste (M1M32).", | |
| 194 | + "tunnel": { | |
| 195 | + "domain": "www.websensor.io", | |
| 196 | + "gateway": "BHS64", | |
| 197 | + "note": "MacLustr Tunnel depuis 2026-09-10 ; ngrok retiré" | |
| 198 | + } | |
| 199 | +} | |
| \ No newline at end of file | ||
modified
docs/ARCHITECTURE.md
+10 −1
@@ -64,7 +64,16 @@ developing clusters, new coverage), entity insights (35-day heatmap, baseline, d | ||
| 64 | 64 | rank), entity rankings (importance × activity × velocity × quality × confirmation), cluster propagation, country and |
| 65 | 65 | category desks. Aggregates are cached 5–120 s in process (`cache.ts`); the live feed is never cached. |
| 66 | 66 | |
| 67 | −## Not in v0.2 (explicitly) | |
| 67 | +## Source Factory (v0.3) | |
| 68 | +Separate process (`factory-main.ts`). Tables: `factory_seeds` (organization + hints + funnel counters), | |
| 69 | +`discovery_candidates` (extended: connector/type/name/kind_class/config/tier/score_value/reason/shadow_sensor_id), | |
| 70 | +`factory_daily`. Sensors gain `status = 'SHADOW'` (pipeline stores snapshots and changes, never events; priority 3); | |
| 71 | +sources gain `origin` (seed | import | factory) and `sector`. Discovery (`packages/connectors/src/discovery-deep.ts`) | |
| 72 | +is budgeted per organization (90 requests) and polite (4 in flight per organization, 6 organizations in parallel). | |
| 73 | +Acceptance is evidence-based (`evaluateShadows`). Coverage (`apps/api/src/coverage.ts`) matches universe members to | |
| 74 | +sensor hosts by registrable domain (`coverageKey`) and is cached 5 min. | |
| 75 | + | |
| 76 | +## Not in v0.3 (explicitly) | |
| 68 | 77 | Browser rendering (Playwright), Firecrawl, OpenSearch (Postgres FTS + search syntax is used), MinIO (fs blob store |
| 69 | 78 | with the same interface), accounts and email/Slack/Discord/Telegram/push delivery (watchlists, alerts, bookmarks, |
| 70 | 79 | saved views and custom monitors are keyed by an anonymous owner token; alert channels are web + signed webhooks), |
modified
docs/registry/AUTHORING.md
+5 −0
@@ -6,6 +6,11 @@ entity; every `sensors:` entry becomes a real, polled endpoint. **Nothing goes i | ||
| 6 | 6 | fetched and parsed successfully** — a sensor that returns 403/404, HTML instead of a feed, or an empty list is a |
| 7 | 7 | liability (noise, wasted budget, false "page removed" events), not coverage. |
| 8 | 8 | |
| 9 | +> **Coverage universes & the Source Factory (2026-09-13).** Organizations that *should* be observed are listed per sector | |
| 10 | +> in `config/coverage/*.yaml` (guide: `COVERAGE.md`). The Factory discovers, validates, shadow-monitors and accepts their | |
| 11 | +> sensors automatically (`sources.origin = 'factory'`, `sensors.config.factory = true`). `cli.ts factory export` turns | |
| 12 | +> accepted sensors into a fragment like the ones below for review and graduation into `config/sources.d/`. | |
| 13 | + | |
| 9 | 14 | ## Fragment format |
| 10 | 15 | |
| 11 | 16 | ```yaml |
added
docs/registry/COVERAGE.md
+67 −0
@@ -0,0 +1,67 @@ | ||
| 1 | +# Coverage universes — authoring guide | |
| 2 | + | |
| 3 | +`config/coverage/*.yaml` describes **what WebSensor should observe**, sector by sector: the denominator of the | |
| 4 | +Global Observation Coverage Score (`/coverage`, `GET /api/v1/coverage`). The same files feed the **Source | |
| 5 | +Factory**: every member whose registrable domain is not yet monitored becomes an expansion seed, is | |
| 6 | +discovered automatically (robots, sitemaps, feeds, status pages, GitHub, IR/newsroom/pricing/legal pages, | |
| 7 | +EDGAR, Hugging Face…), shadow-monitored, then accepted or rejected on evidence. | |
| 8 | + | |
| 9 | +A universe is a list of **organizations with a correct official domain**. It is *not* a registry fragment: | |
| 10 | +no sensors, no URLs are required (they are welcome as `hints`). The registry stays the source of truth for | |
| 11 | +what is actually polled. | |
| 12 | + | |
| 13 | +## File format — one sector per file (`config/coverage/NN-<sector>.yaml`; several files may share a sector key, their universes are merged — universe keys must then be unique across those files) | |
| 14 | + | |
| 15 | +```yaml | |
| 16 | +sector: energy # kebab-case key, unique across files | |
| 17 | +label: Energy & commodities | |
| 18 | +description: Agencies, grid operators, producers and exchanges whose changes move energy markets. | |
| 19 | +weight: 1.0 # weight of the sector in the global score (1.0 default; 1.2–1.5 systemic) | |
| 20 | +categories: [energy] # default categories for factory-created sources (registry vocabulary) | |
| 21 | +tier: B # default tier for factory-created sources (S A B C D) | |
| 22 | +universes: | |
| 23 | + - key: agencies | |
| 24 | + label: Energy agencies & regulators | |
| 25 | + provenance: "https://en.wikipedia.org/wiki/… (retrieved 2026-09-13)" | |
| 26 | + members: | |
| 27 | + - { name: International Energy Agency, domain: iea.org, country: INT, importance: 3, aliases: [iea] } | |
| 28 | + - { name: ERCOT, domain: ercot.com, country: US, importance: 3, hints: { urls: [{ url: "https://www.ercot.com/news/rss", kind: news }] } } | |
| 29 | + - key: oil-gas-majors | |
| 30 | + label: Oil & gas majors | |
| 31 | + members: | |
| 32 | + - { name: Shell, domain: shell.com, country: GB, importance: 3, hints: { cik: "1306965", ticker: SHEL } } | |
| 33 | +``` | |
| 34 | + | |
| 35 | +### Member fields | |
| 36 | +| field | required | meaning | | |
| 37 | +|---|---|---| | |
| 38 | +| `name` | yes | official English name (ASCII preferred; native name in `aliases`) | | |
| 39 | +| `domain` | yes | bare registrable domain, lower-case, no scheme, no `www.`, no path (`gov.uk` sites: use the registrable domain, e.g. `bankofengland.co.uk`, `ons.gov.uk`) | | |
| 40 | +| `country` | recommended | ISO 3166-1 alpha-2 upper-case; `EU` for EU bodies; `INT` for global bodies. **Quote `"NO"`** (Norway) — YAML reads it as false | | |
| 41 | +| `language` | optional | ISO 639-1 of the site's main language when not English (`"no"` must be quoted) | | |
| 42 | +| `importance` | optional | 1 long tail · 2 notable (default) · 3 systemically important | | |
| 43 | +| `aliases` | optional | lower-case alternative names, tickers, acronyms (entity linking) | | |
| 44 | +| `categories` | optional | override of the sector default | | |
| 45 | +| `tier` | optional | override of the sector default | | |
| 46 | +| `first_party` | optional | `false` only for media/aggregators | | |
| 47 | +| `hints` | optional | `cik`, `ticker`, `exchange`, `github_org`, `github_repos: [owner/name]`, `hf_author`, `status_url`, `hosts: [ir.example.com]`, `urls: [{url, kind, connector?, type?, tier?, config?, name?}]`, `posture: true`, `wikidata: Q…` | | |
| 48 | +| `note` | optional | anything a reviewer should know (blocked by Cloudflare, JS shell…) | | |
| 49 | + | |
| 50 | +`hints.urls[].kind` vocabulary: `news press blog ir filings changelog releases security pricing legal careers docs api status models data other`. | |
| 51 | + | |
| 52 | +## Rules | |
| 53 | +1. **Real organizations, correct domains.** Every domain must resolve (`dig +short <domain>` or `<www.domain>`) | |
| 54 | + and belong to the organization. Wikidata P856 / the index constituents page / EDGAR `website` are good | |
| 55 | + sources; never guess a domain from a name. | |
| 56 | +2. **Provenance on every universe** (`provenance:` URL or query + retrieval date). Index constituents change: | |
| 57 | + record the date. | |
| 58 | +3. **No duplicates inside a file** (same domain twice). The same organization may appear in several sectors | |
| 59 | + (a bank is both `corporate` and `markets`). | |
| 60 | +4. **Countries everywhere** for governments and companies; `INT` for international bodies. | |
| 61 | +5. **Validate**: `node node_modules/tsx/dist/cli.mjs apps/engine/src/coverage-validate.ts config/coverage/<file>.yaml [--dns]` | |
| 62 | + (schema, duplicates, domain syntax; `--dns` resolves every domain). Only files that pass are committed. | |
| 63 | +6. Keep entries compact (flow style `{ … }`, one member per line). | |
| 64 | + | |
| 65 | +## Sector keys in use (2026-09-13) | |
| 66 | +`ai` · `cloud` · `cyber` · `markets` · `corporate` · `government` · `science` · `healthcare` · `energy` · | |
| 67 | +`transport` · `telecom` · `logistics` · `open-source` · `commerce` | |
added
packages/connectors/src/discovery-deep.ts
+672 −0
@@ -0,0 +1,672 @@ | ||
| 1 | +import { promises as dns } from "node:dns"; | |
| 2 | +import { canonicalizeHtml, jaccard, registrableDomain, sha256, shingles, type SensorType, type Tier } from "@websensor/core"; | |
| 3 | +import { httpFetch } from "./fetcher"; | |
| 4 | +import { parseFeed } from "./rss"; | |
| 5 | +import { parseSitemap } from "./sitemap"; | |
| 6 | +import { detectFlavor } from "./statusjson"; | |
| 7 | + | |
| 8 | +/** | |
| 9 | + * Deep discovery — the acquisition stage of the Source Factory. Starting from an organization (domain + | |
| 10 | + * optional hints) it walks robots.txt → sitemaps → feeds (`<link rel=alternate>`, well-known paths, official | |
| 11 | + * sub-domains) → the homepage's own navigation (newsroom, press, investors, changelog, security, pricing, | |
| 12 | + * legal, careers, docs, status) → known status-page providers → GitHub organization → EDGAR → Hugging Face → | |
| 13 | + * OpenAPI documents → web posture, and validates EVERY candidate by fetching and parsing it exactly as the | |
| 14 | + * connector would. Nothing is assumed from a URL pattern. | |
| 15 | + * | |
| 16 | + * The result is a ranked list of candidate sensors with the evidence the Factory needs to score them: | |
| 17 | + * page class, first-party confidence, expected change frequency, fetch cost and a content fingerprint used | |
| 18 | + * to drop duplicates (two feeds exposing the same items, two pages with the same text). | |
| 19 | + */ | |
| 20 | +export type CandidateKind = "news" | "press" | "blog" | "ir" | "filings" | "changelog" | "releases" | "security" | "pricing" | "legal" | "careers" | "leadership" | "docs" | "api" | "status" | "sitemap" | "models" | "data" | "posture" | "other"; | |
| 21 | + | |
| 22 | +export interface DeepCandidate { | |
| 23 | + url: string; | |
| 24 | + type: SensorType; | |
| 25 | + connector: string; | |
| 26 | + kind: CandidateKind; | |
| 27 | + name: string; | |
| 28 | + config: Record<string, unknown>; | |
| 29 | + suggestedTier: Tier; | |
| 30 | + evidence: string; | |
| 31 | + /** rough information value 0–1 (before the Factory's importance-aware scoring) */ | |
| 32 | + value: number; | |
| 33 | + itemCount?: number; | |
| 34 | + /** items per day observed in the feed/list window (null when unknown) */ | |
| 35 | + itemsPerDay?: number | null; | |
| 36 | + /** 1 = the organization's own registrable domain; 0.9 = official channel on a platform (GitHub, HF, status provider) */ | |
| 37 | + firstPartyConfidence: number; | |
| 38 | + fetchCost: { bytes: number; ms: number }; | |
| 39 | + /** content fingerprint for de-duplication (sorted item keys, or canonical text hash) */ | |
| 40 | + fingerprint?: string; | |
| 41 | + itemKeys?: string[]; | |
| 42 | + textShingles?: Set<string>; | |
| 43 | + title?: string; | |
| 44 | +} | |
| 45 | + | |
| 46 | +export interface DeepDiscoveryHints { | |
| 47 | + cik?: string | number; | |
| 48 | + github_org?: string; | |
| 49 | + github_repos?: string[]; | |
| 50 | + hf_author?: string; | |
| 51 | + status_url?: string; | |
| 52 | + hosts?: string[]; | |
| 53 | + urls?: { url: string; kind?: string; connector?: string; type?: string; tier?: Tier; config?: Record<string, unknown>; name?: string }[]; | |
| 54 | + posture?: boolean; | |
| 55 | +} | |
| 56 | + | |
| 57 | +export interface DeepDiscoveryOptions { | |
| 58 | + hints?: DeepDiscoveryHints; | |
| 59 | + /** maximum number of HTTP requests for this organization (default 90) */ | |
| 60 | + budget?: number; | |
| 61 | + concurrency?: number; | |
| 62 | + sensorIdForLogs?: string; | |
| 63 | + probePages?: boolean; | |
| 64 | + probeSubdomains?: boolean; | |
| 65 | + probePosture?: boolean; | |
| 66 | + probeOpenApi?: boolean; | |
| 67 | +} | |
| 68 | + | |
| 69 | +export interface DeepDiscoveryResult { | |
| 70 | + domain: string; | |
| 71 | + /** origin actually used after following the homepage redirect (may be another registrable domain) */ | |
| 72 | + origin: string; | |
| 73 | + canonicalDomain: string; | |
| 74 | + homeStatus: number; | |
| 75 | + /** anti-bot / challenge detected on the homepage */ | |
| 76 | + blocked: boolean; | |
| 77 | + requests: number; | |
| 78 | + candidates: DeepCandidate[]; | |
| 79 | + rejected: { url: string; reason: string }[]; | |
| 80 | + notes: string[]; | |
| 81 | + durationMs: number; | |
| 82 | +} | |
| 83 | + | |
| 84 | +// ------------------------------------------------------------------------------------------------ | |
| 85 | +// Vocabularies | |
| 86 | +// ------------------------------------------------------------------------------------------------ | |
| 87 | + | |
| 88 | +const FEED_PATHS = ["/feed", "/rss", "/rss.xml", "/atom.xml", "/feed.xml", "/index.xml", "/feed/", "/feeds/all.atom.xml", "/blog/feed", "/blog/rss", "/blog/rss.xml", "/blog/feed.xml", "/blog/atom.xml", "/blog/index.xml", "/news/feed", "/news/rss", "/news/rss.xml", "/news/feed.xml", "/news.rss", "/news.xml", "/newsroom/rss", "/newsroom/feed", "/newsroom/rss.xml", "/press/rss", "/press/feed", "/press-releases/rss", "/press-releases/feed", "/rss/news", "/rss/press-releases.xml", "/rss/news-releases.xml", "/rss/pressreleases.xml", "/investors/rss", "/investors/feed", "/en/feed", "/en/rss", "/en/rss.xml", "/en/news/rss", "/changelog/feed", "/changelog.rss", "/changelog/rss.xml", "/changelog.xml", "/releases.atom", "/releases/feed", "/security/feed", "/security/rss", "/advisories/rss", "/advisories.rss", "/updates/feed", "/updates/rss", "/whats-new/feed", "/?feed=rss2", "/feed/atom", "/feeds/posts/default", "/feed.json"]; | |
| 89 | +const SITEMAP_PATHS = ["/sitemap.xml", "/sitemap_index.xml", "/sitemap-index.xml", "/sitemaps/sitemap.xml", "/news-sitemap.xml", "/sitemap/news.xml", "/sitemap-news.xml", "/sitemap.txt"]; | |
| 90 | +const OPENAPI_PATHS = ["/openapi.json", "/swagger.json", "/api/openapi.json", "/api/swagger.json", "/v1/openapi.json", "/api/v1/openapi.json", "/.well-known/openapi.json", "/swagger/v1/swagger.json", "/api-docs", "/openapi.yaml", "/api/openapi.yaml"]; | |
| 91 | +const SUBDOMAIN_KINDS: [string, CandidateKind][] = [["blog", "blog"], ["news", "news"], ["newsroom", "news"], ["press", "press"], ["media", "press"], ["investors", "ir"], ["investor", "ir"], ["ir", "ir"], ["status", "status"], ["docs", "docs"], ["developer", "docs"], ["developers", "docs"], ["security", "security"], ["trust", "security"], ["careers", "careers"], ["jobs", "careers"], ["changelog", "changelog"], ["updates", "changelog"], ["engineering", "blog"], ["research", "blog"], ["about", "other"], ["corporate", "news"], ["api", "api"]]; | |
| 92 | + | |
| 93 | +/** Page classes probed from navigation links and well-known paths. `paths` are fallbacks when the navigation has no link. */ | |
| 94 | +const PAGE_KINDS: { kind: CandidateKind; label: string; hrefRe: RegExp; textRe: RegExp; paths: string[]; tier: Tier; value: number; max: number }[] = [ | |
| 95 | + { kind: "pricing", label: "pricing", hrefRe: /\/(pricing|plans|plans-and-pricing|tarifs|preise|precios)(\/|$|\?)/i, textRe: /^(pricing|plans|plans & pricing|tarifs|preise|precios)$/i, paths: ["/pricing", "/plans", "/pricing/"], tier: "C", value: 0.75, max: 1 }, | |
| 96 | + { kind: "changelog", label: "changelog", hrefRe: /\/(changelog|change-log|release-notes|releasenotes|whats-new|what-s-new|updates|product-updates)(\/|$|\?)/i, textRe: /^(changelog|change log|release notes|what'?s new|product updates|updates)$/i, paths: ["/changelog", "/release-notes", "/whats-new", "/updates"], tier: "B", value: 0.75, max: 1 }, | |
| 97 | + { kind: "security", label: "security advisories", hrefRe: /\/(security|security-advisories|advisories|psirt|trust|security-bulletins|bulletins|vulnerabilit)/i, textRe: /^(security|security advisories|advisories|trust center|trust|psirt|security bulletins)$/i, paths: ["/security", "/security/advisories", "/trust", "/.well-known/security.txt"], tier: "B", value: 0.7, max: 2 }, | |
| 98 | + { kind: "news", label: "newsroom", hrefRe: /\/(news|newsroom|news-room|media|media-center|media-centre|actualites|actualit%C3%A9s|nouvelles|aktuelles|noticias|notizie)(\/|$|\?)/i, textRe: /^(news|newsroom|news ?room|media|media cent(er|re)|latest news|in the news|actualit[ée]s|nouvelles|aktuelles|noticias)$/i, paths: ["/news", "/newsroom", "/news/", "/en/news", "/media"], tier: "B", value: 0.65, max: 1 }, | |
| 99 | + { kind: "press", label: "press releases", hrefRe: /\/(press|press-releases|pressreleases|press-room|pressroom|press-center|communiques|communiqu%C3%A9s|presse|prensa)(\/|$|\?)/i, textRe: /^(press|press releases|press ?room|press cent(er|re)|communiqu[ée]s( de presse)?|presse|prensa)$/i, paths: ["/press", "/press-releases", "/press/", "/newsroom/press-releases"], tier: "B", value: 0.65, max: 1 }, | |
| 100 | + { kind: "ir", label: "investor relations", hrefRe: /\/(investors|investor-relations|investor|ir|relations-investisseurs|investor-center)(\/|$|\?)/i, textRe: /^(investors|investor relations|investor cent(er|re)|ir|relations investisseurs|shareholders)$/i, paths: ["/investors", "/investor-relations", "/investors/", "/investor"], tier: "C", value: 0.6, max: 1 }, | |
| 101 | + { kind: "legal", label: "terms of service", hrefRe: /\/(terms|terms-of-service|terms-of-use|tos|legal\/terms|conditions|cgu|cgv|legal)(\/|$|\?|\.)/i, textRe: /^(terms|terms of (service|use)|terms & conditions|terms and conditions|legal|conditions g[ée]n[ée]rales|cgu|cgv)$/i, paths: ["/terms", "/legal/terms", "/terms-of-service", "/legal"], tier: "C", value: 0.6, max: 1 }, | |
| 102 | + { kind: "legal", label: "privacy policy", hrefRe: /\/(privacy|privacy-policy|privacypolicy|legal\/privacy|politique-de-confidentialite|datenschutz|privacidad)(\/|$|\?|\.)/i, textRe: /^(privacy|privacy policy|privacy notice|politique de confidentialit[ée]|datenschutz|privacidad)$/i, paths: ["/privacy", "/legal/privacy", "/privacy-policy"], tier: "C", value: 0.55, max: 1 }, | |
| 103 | + { kind: "careers", label: "careers", hrefRe: /\/(careers|jobs|join-us|work-with-us|carrieres|carri%C3%A8res|karriere|empleo)(\/|$|\?)/i, textRe: /^(careers|jobs|join us|work with us|carri[èe]res|karriere|empleo|we'?re hiring)$/i, paths: ["/careers", "/jobs", "/careers/"], tier: "D", value: 0.35, max: 1 }, | |
| 104 | + { kind: "leadership", label: "leadership", hrefRe: /\/(leadership|management|executive-team|our-team|team|board-of-directors|board|about\/leadership|company\/leadership|direction|governance)(\/|$|\?)/i, textRe: /^(leadership|leadership team|management|management team|executive team|our team|board of directors|governance|direction)$/i, paths: ["/leadership", "/about/leadership", "/company/leadership", "/about/team"], tier: "D", value: 0.45, max: 1 }, | |
| 105 | + { kind: "docs", label: "documentation", hrefRe: /\/(docs|documentation|developers|developer|api-docs|reference)(\/|$|\?)/i, textRe: /^(docs|documentation|developers|developer|api|api reference|api docs)$/i, paths: ["/docs", "/documentation", "/developers"], tier: "D", value: 0.4, max: 1 }, | |
| 106 | + { kind: "data", label: "open data", hrefRe: /\/(open-data|opendata|data|statistics|statistiques|datasets|publications)(\/|$|\?)/i, textRe: /^(open data|data|statistics|statistiques|datasets|publications)$/i, paths: [], tier: "C", value: 0.4, max: 1 }, | |
| 107 | +]; | |
| 108 | + | |
| 109 | +const STATUS_HOST_RE = /^(https?:\/\/)?((status|statuspage|uptime|health|trust|system-status|servicestatus)\.[^/\s"']+|[^/\s"']*\.(statuspage\.io|instatus\.com|betteruptime\.com|betterstack\.com|incident\.io|status\.io|hund\.io|statuspal\.io|freshstatus\.io|status\.page|statusgator\.com))/i; | |
| 110 | +const CHALLENGE_RE = /cf-browser-verification|challenge-platform|__cf_chl|Attention Required!|Just a moment\.\.\.|Access Denied|Request unsuccessful\. Incapsula|akamai.*bot|_Incapsula_Resource|Please enable JavaScript to view|captcha/i; | |
| 111 | + | |
| 112 | +// ------------------------------------------------------------------------------------------------ | |
| 113 | + | |
| 114 | +export async function discoverOrganization(domain: string, opts: DeepDiscoveryOptions = {}): Promise<DeepDiscoveryResult> { | |
| 115 | + const started = Date.now(); | |
| 116 | + const id = opts.sensorIdForLogs ?? `factory_${domain}`; | |
| 117 | + const budget = opts.budget ?? 90; | |
| 118 | + const hints = opts.hints ?? {}; | |
| 119 | + const notes: string[] = []; | |
| 120 | + const rejected: { url: string; reason: string }[] = []; | |
| 121 | + const found = new Map<string, DeepCandidate>(); | |
| 122 | + let requests = 0; | |
| 123 | + | |
| 124 | + const add = (c: DeepCandidate): void => { | |
| 125 | + const k = c.url.replace(/\/$/, ""); | |
| 126 | + const prev = found.get(k); | |
| 127 | + if (!prev || prev.value < c.value) found.set(k, c); | |
| 128 | + }; | |
| 129 | + const budgetLeft = (): boolean => requests < budget; | |
| 130 | + const get = async (url: string, o: { timeoutMs?: number; maxBytes?: number; accept?: string; headers?: Record<string, string>; userAgent?: string } = {}) => { | |
| 131 | + requests++; | |
| 132 | + return httpFetch(id, url, { timeoutMs: o.timeoutMs ?? 15_000, maxBytes: o.maxBytes ?? 3 * 1024 * 1024, accept: o.accept, headers: o.headers, userAgent: o.userAgent }); | |
| 133 | + }; | |
| 134 | + | |
| 135 | + // ---- 1. robots.txt -------------------------------------------------------------------------------------- | |
| 136 | + let base = `https://${domain}`; | |
| 137 | + const sitemapsFromRobots: string[] = []; | |
| 138 | + let robotsOk = false; | |
| 139 | + let robotsMentionsAi = false; | |
| 140 | + const robots = await get(`${base}/robots.txt`, { timeoutMs: 12_000, maxBytes: 512 * 1024 }).catch(() => null); | |
| 141 | + if (robots?.body && robots.meta.status === 200 && !/^\s*<!doctype html|<html/i.test(robots.body.toString("utf8").slice(0, 300))) { | |
| 142 | + robotsOk = true; | |
| 143 | + const text = robots.body.toString("utf8"); | |
| 144 | + for (const line of text.split(/\r?\n/)) { | |
| 145 | + const m = line.match(/^\s*sitemap:\s*(\S+)/i); | |
| 146 | + if (m) sitemapsFromRobots.push(m[1]!); | |
| 147 | + } | |
| 148 | + robotsMentionsAi = /GPTBot|ClaudeBot|Google-Extended|CCBot|anthropic-ai|PerplexityBot|Bytespider|Applebot-Extended|meta-externalagent/i.test(text); | |
| 149 | + } | |
| 150 | + | |
| 151 | + // ---- 2. homepage ------------------------------------------------------------------------------------------ | |
| 152 | + const home = await get(`${base}/`, { timeoutMs: 20_000, maxBytes: 4 * 1024 * 1024 }); | |
| 153 | + const homeStatus = home.meta.status; | |
| 154 | + let blocked = false; | |
| 155 | + const homeHtml = home.body && home.meta.status < 400 ? home.body.toString("utf8") : ""; | |
| 156 | + if ([403, 429, 503].includes(homeStatus) || (homeHtml && CHALLENGE_RE.test(homeHtml.slice(0, 20_000)) && homeHtml.length < 60_000)) { | |
| 157 | + blocked = true; | |
| 158 | + notes.push(`homepage ${homeStatus}${homeHtml ? " (anti-bot challenge)" : ""}`); | |
| 159 | + } | |
| 160 | + let origin = base; | |
| 161 | + try { | |
| 162 | + const fu = new URL(home.meta.finalUrl || base); | |
| 163 | + origin = fu.origin; | |
| 164 | + if (registrableDomain(fu.hostname) !== registrableDomain(domain)) notes.push(`homepage redirects to ${fu.hostname}`); | |
| 165 | + } catch { | |
| 166 | + // keep base | |
| 167 | + } | |
| 168 | + base = origin; | |
| 169 | + const canonicalDomain = registrableDomain(new URL(base).hostname); | |
| 170 | + const ownHosts = new Set<string>([registrableDomain(domain), canonicalDomain, ...(hints.hosts ?? []).map((h) => registrableDomain(h))]); | |
| 171 | + const isOwn = (u: string): boolean => { | |
| 172 | + try { | |
| 173 | + return ownHosts.has(registrableDomain(new URL(u).hostname)); | |
| 174 | + } catch { | |
| 175 | + return false; | |
| 176 | + } | |
| 177 | + }; | |
| 178 | + | |
| 179 | + const homeCanon = homeHtml ? canonicalizeHtml(homeHtml, home.meta.finalUrl || base) : null; | |
| 180 | + const homeShingles = homeCanon ? shingles(homeCanon.text) : null; | |
| 181 | + const alternates: string[] = []; | |
| 182 | + const statusLinks = new Set<string>(); | |
| 183 | + const githubOrgs = new Set<string>(); | |
| 184 | + const hfAuthors = new Set<string>(); | |
| 185 | + const pageLinks = new Map<CandidateKind, string[]>(); | |
| 186 | + if (homeHtml) { | |
| 187 | + for (const m of homeHtml.matchAll(/<link[^>]+rel=["']alternate["'][^>]*>/gi)) { | |
| 188 | + const tag = m[0]; | |
| 189 | + if (!/application\/(rss|atom)\+xml|application\/feed\+json/i.test(tag)) continue; | |
| 190 | + const href = tag.match(/href=["']([^"']+)["']/i)?.[1]; | |
| 191 | + const abs = href ? safeAbs(href, home.meta.finalUrl || base) : null; | |
| 192 | + if (abs) alternates.push(abs); | |
| 193 | + } | |
| 194 | + // Navigation links: classify by href pattern first, then by anchor text. | |
| 195 | + const anchors = [...homeHtml.matchAll(/<a\b[^>]*href=["']([^"'#]+)["'][^>]*>([\s\S]{0,300}?)<\/a>/gi)].map((m) => ({ href: safeAbs(m[1]!, home.meta.finalUrl || base), text: m[2]!.replace(/<[^>]+>/g, " ").replace(/\s+/g, " ").trim() })); | |
| 196 | + for (const a of anchors) { | |
| 197 | + if (!a.href) continue; | |
| 198 | + const gh = a.href.match(/^https?:\/\/(?:www\.)?github\.com\/([A-Za-z0-9_.-]+)\/?(?:$|\?)/); | |
| 199 | + if (gh && !/^(features|about|pricing|topics|login|signup|sponsors|marketplace|explore|apps|orgs|enterprise|solutions|security|team|customer-stories|readme|collections|events|trending|search|settings|notifications|new|organizations|site|contact)$/i.test(gh[1]!)) githubOrgs.add(gh[1]!); | |
| 200 | + const hf = a.href.match(/^https?:\/\/huggingface\.co\/([A-Za-z0-9_.-]+)\/?$/); | |
| 201 | + if (hf && !/^(models|datasets|spaces|docs|pricing|papers|blog|join|login|enterprise|posts|tasks|learn|chat)$/i.test(hf[1]!)) hfAuthors.add(hf[1]!); | |
| 202 | + if (STATUS_HOST_RE.test(a.href)) statusLinks.add(a.href); | |
| 203 | + if (!isOwn(a.href)) continue; | |
| 204 | + let path = ""; | |
| 205 | + try { | |
| 206 | + path = new URL(a.href).pathname; | |
| 207 | + } catch { | |
| 208 | + continue; | |
| 209 | + } | |
| 210 | + if (path.split("/").filter(Boolean).length > 3) continue; // deep article links are not sections | |
| 211 | + for (const pk of PAGE_KINDS) { | |
| 212 | + if (pk.hrefRe.test(path) || pk.textRe.test(a.text)) { | |
| 213 | + const arr = pageLinks.get(pk.kind) ?? []; | |
| 214 | + if (!arr.includes(a.href) && arr.length < 4) arr.push(a.href); | |
| 215 | + pageLinks.set(pk.kind, arr); | |
| 216 | + } | |
| 217 | + } | |
| 218 | + } | |
| 219 | + } | |
| 220 | + if (hints.github_org) githubOrgs.add(hints.github_org); | |
| 221 | + if (hints.hf_author) hfAuthors.add(hints.hf_author); | |
| 222 | + if (hints.status_url) statusLinks.add(hints.status_url); | |
| 223 | + | |
| 224 | + // ---- 3. official sub-domains (cheap DNS first) ------------------------------------------------------------ | |
| 225 | + const subHosts: { host: string; kind: CandidateKind }[] = []; | |
| 226 | + if (opts.probeSubdomains !== false) { | |
| 227 | + const rd = registrableDomain(domain); | |
| 228 | + // Wildcard DNS (everything resolves) makes existence checks meaningless: only probe the core labels then. | |
| 229 | + const wildcard = await resolves(`ws-probe-${Date.now().toString(36)}.${rd}`); | |
| 230 | + if (wildcard) notes.push("wildcard DNS"); | |
| 231 | + const labels = wildcard ? ["blog", "news", "status", "investors", "docs", "security"] : [...new Set([...SUBDOMAIN_KINDS.map(([l]) => l)])]; | |
| 232 | + await parallel(labels, 8, async (label) => { | |
| 233 | + const host = `${label}.${rd}`; | |
| 234 | + if (host === new URL(base).hostname) return; | |
| 235 | + if (wildcard || (await resolves(host))) subHosts.push({ host, kind: SUBDOMAIN_KINDS.find(([l]) => l === label)![1] }); | |
| 236 | + }); | |
| 237 | + if (wildcard) { | |
| 238 | + // keep only sub-domains that actually answer with a distinct page | |
| 239 | + const alive: typeof subHosts = []; | |
| 240 | + await parallel(subHosts, 4, async (s) => { | |
| 241 | + if (!budgetLeft()) return; | |
| 242 | + const o = await get(`https://${s.host}/`, { timeoutMs: 10_000, maxBytes: 512 * 1024 }); | |
| 243 | + const fh = o.meta.finalUrl ? new URL(o.meta.finalUrl).hostname : ""; | |
| 244 | + if (o.meta.status === 200 && fh === s.host) alive.push(s); | |
| 245 | + }); | |
| 246 | + subHosts.splice(0, subHosts.length, ...alive); | |
| 247 | + } | |
| 248 | + for (const h of hints.hosts ?? []) if (!subHosts.some((s) => s.host === h)) subHosts.push({ host: h, kind: "other" }); | |
| 249 | + if (subHosts.length) notes.push(`sub-domains: ${subHosts.map((s) => s.host).join(", ")}`); | |
| 250 | + } | |
| 251 | + | |
| 252 | + // ---- 4. feeds --------------------------------------------------------------------------------------------- | |
| 253 | + const feedUrls = new Set<string>(alternates); | |
| 254 | + for (const p of FEED_PATHS) feedUrls.add(base + p); | |
| 255 | + for (const s of subHosts) { | |
| 256 | + if (s.kind === "status" || s.kind === "api") continue; | |
| 257 | + for (const p of ["/feed", "/rss", "/rss.xml", "/atom.xml", "/feed.xml", "/index.xml", "/feed/", "/?feed=rss2"]) feedUrls.add(`https://${s.host}${p}`); | |
| 258 | + } | |
| 259 | + // Feeds are cheap and high-value: spend up to 55 % of the budget here. | |
| 260 | + const feedList = [...feedUrls].slice(0, Math.max(10, Math.floor(budget * 0.55))); | |
| 261 | + const feeds: DeepCandidate[] = []; | |
| 262 | + await parallel(feedList, opts.concurrency ?? 4, async (url) => { | |
| 263 | + if (!budgetLeft()) return; | |
| 264 | + const o = await get(url, { timeoutMs: 12_000, maxBytes: 4 * 1024 * 1024, accept: "application/rss+xml, application/atom+xml, application/feed+json, application/xml;q=0.9, */*;q=0.5" }); | |
| 265 | + if (!o.body || o.meta.status !== 200) return; | |
| 266 | + const text = o.body.toString("utf8"); | |
| 267 | + if (/^\s*<!doctype html|<html/i.test(text.slice(0, 400))) return; | |
| 268 | + try { | |
| 269 | + const f = parseFeed(text, o.meta.finalUrl); | |
| 270 | + if (!f.items.length) return rejected.push({ url, reason: "empty feed" }); | |
| 271 | + const dates = f.items.map((i) => (i.publishedAt ? new Date(i.publishedAt).getTime() : NaN)).filter((t) => !Number.isNaN(t)); | |
| 272 | + const dated = dates.length / f.items.length; | |
| 273 | + const itemsPerDay = itemsPerDayOf(dates); | |
| 274 | + const finalUrl = o.meta.finalUrl || url; | |
| 275 | + const kind = feedKind(finalUrl, f.title); | |
| 276 | + const keys = f.items.map((i) => i.key).sort(); | |
| 277 | + const stale = dates.length && Math.max(...dates) < Date.now() - 3 * 365 * 86400e3; | |
| 278 | + if (stale) return rejected.push({ url, reason: "feed abandoned (> 3 years)" }); | |
| 279 | + feeds.push({ | |
| 280 | + url: finalUrl, | |
| 281 | + type: f.kind === "atom" ? "ATOM" : "RSS", | |
| 282 | + connector: "rss", | |
| 283 | + kind, | |
| 284 | + name: feedName(kind, finalUrl), | |
| 285 | + config: {}, | |
| 286 | + suggestedTier: itemsPerDay !== null && itemsPerDay < 0.05 ? "C" : "B", | |
| 287 | + evidence: `${alternates.includes(url) ? "link[rel=alternate]" : "well-known path"} · ${f.items.length} items${itemsPerDay !== null ? ` · ${itemsPerDay}/day` : ""}`, | |
| 288 | + value: 0.75 + 0.15 * dated + (kind === "news" || kind === "press" ? 0.1 : kind === "security" || kind === "changelog" ? 0.08 : 0), | |
| 289 | + itemCount: f.items.length, | |
| 290 | + itemsPerDay, | |
| 291 | + firstPartyConfidence: isOwn(finalUrl) ? 1 : 0.7, | |
| 292 | + fetchCost: { bytes: o.meta.contentLength, ms: o.meta.durationMs }, | |
| 293 | + fingerprint: sha256(keys.join("\n")), | |
| 294 | + itemKeys: keys, | |
| 295 | + title: f.title, | |
| 296 | + }); | |
| 297 | + } catch { | |
| 298 | + // not a feed | |
| 299 | + } | |
| 300 | + }); | |
| 301 | + // De-duplicate feeds sharing ≥ 70 % of their items (rss + atom of the same channel, /feed vs /rss.xml). | |
| 302 | + feeds.sort((a, b) => b.value - a.value || (b.itemCount ?? 0) - (a.itemCount ?? 0)); | |
| 303 | + const keptFeeds: DeepCandidate[] = []; | |
| 304 | + for (const f of feeds) { | |
| 305 | + const dup = keptFeeds.find((k) => k.itemKeys && f.itemKeys && jaccard(new Set(k.itemKeys), new Set(f.itemKeys)) >= 0.7); | |
| 306 | + if (dup) rejected.push({ url: f.url, reason: `duplicate of ${dup.url}` }); | |
| 307 | + else keptFeeds.push(f); | |
| 308 | + } | |
| 309 | + for (const f of keptFeeds) add(f); | |
| 310 | + | |
| 311 | + // ---- 5. sitemaps ------------------------------------------------------------------------------------------- | |
| 312 | + const sitemapCandidates = [...new Set([...sitemapsFromRobots.filter((u) => !/image|video/i.test(u)).sort((a, b) => Number(/news/i.test(b)) - Number(/news/i.test(a))).slice(0, 6), ...SITEMAP_PATHS.map((p) => base + p)])]; | |
| 313 | + const sitemaps: DeepCandidate[] = []; | |
| 314 | + await parallel(sitemapCandidates, 3, async (url) => { | |
| 315 | + if (!budgetLeft() || sitemaps.length >= 3) return; | |
| 316 | + const o = await get(url, { timeoutMs: 15_000, maxBytes: 20 * 1024 * 1024, accept: "application/xml, text/xml, text/plain;q=0.8, */*;q=0.5" }); | |
| 317 | + if (!o.body || o.meta.status !== 200) return; | |
| 318 | + const text = o.body.toString("utf8"); | |
| 319 | + if (/^\s*<!doctype html|<html/i.test(text.slice(0, 400))) return; | |
| 320 | + try { | |
| 321 | + const s = parseSitemap(text); | |
| 322 | + const n = s.kind === "sitemapindex" ? s.children.length : s.entries.length; | |
| 323 | + if (!n) return; | |
| 324 | + const lastmods = s.entries.filter((e) => e.lastmod).length; | |
| 325 | + const isNews = /news/i.test(url) || s.entries.some((e) => e.publishedAt); | |
| 326 | + sitemaps.push({ url: o.meta.finalUrl || url, type: "SITEMAP", connector: "sitemap", kind: "sitemap", name: isNews ? "news sitemap" : "sitemap", config: { maxChildren: 4, maxUrls: 3000 }, suggestedTier: "B", evidence: `${sitemapsFromRobots.includes(url) ? "robots.txt" : "well-known path"} · ${n} ${s.kind === "sitemapindex" ? "children" : "urls"}`, value: 0.45 + (s.entries.length ? 0.3 * (lastmods / s.entries.length) : 0.15) + (isNews ? 0.2 : 0), itemCount: n, itemsPerDay: null, firstPartyConfidence: 1, fetchCost: { bytes: o.meta.contentLength, ms: o.meta.durationMs } }); | |
| 327 | + } catch { | |
| 328 | + // not a sitemap | |
| 329 | + } | |
| 330 | + }); | |
| 331 | + sitemaps.sort((a, b) => b.value - a.value); | |
| 332 | + if (sitemaps[0]) add(sitemaps[0]); | |
| 333 | + | |
| 334 | + // ---- 6. status pages ---------------------------------------------------------------------------------------- | |
| 335 | + const statusHosts = new Set<string>(); | |
| 336 | + if (hints.status_url) { | |
| 337 | + try { | |
| 338 | + statusHosts.add(new URL(hints.status_url).origin); | |
| 339 | + } catch { | |
| 340 | + // ignore | |
| 341 | + } | |
| 342 | + } | |
| 343 | + for (const s of statusLinks) { | |
| 344 | + try { | |
| 345 | + statusHosts.add(new URL(s).origin); | |
| 346 | + } catch { | |
| 347 | + // ignore | |
| 348 | + } | |
| 349 | + } | |
| 350 | + for (const s of subHosts) if (s.kind === "status") statusHosts.add(`https://${s.host}`); | |
| 351 | + let statusFound = false; | |
| 352 | + for (const root of statusHosts) { | |
| 353 | + if (statusFound || !budgetLeft()) break; | |
| 354 | + for (const [path, connector] of [["/api/v2/summary.json", "statuspage"], ["/summary.json", "statusjson"], ["/api/v1/summary", "statusjson"]] as const) { | |
| 355 | + if (statusFound || !budgetLeft()) break; | |
| 356 | + const o = await get(root + path, { timeoutMs: 12_000, accept: "application/json" }); | |
| 357 | + if (!o.body || o.meta.status !== 200) continue; | |
| 358 | + const text = o.body.toString("utf8"); | |
| 359 | + try { | |
| 360 | + const j = JSON.parse(text) as Record<string, unknown>; | |
| 361 | + if (connector === "statuspage" && Array.isArray(j.incidents) && (j.components || j.status)) { | |
| 362 | + add({ url: root + path, type: "STATUSPAGE", connector: "statuspage", kind: "status", name: "status", config: {}, suggestedTier: "S", evidence: `Atlassian Statuspage (${new URL(root).host})`, value: 0.95, itemsPerDay: null, firstPartyConfidence: 0.9, fetchCost: { bytes: o.meta.contentLength, ms: o.meta.durationMs } }); | |
| 363 | + statusFound = true; | |
| 364 | + } else if (connector === "statusjson") { | |
| 365 | + const flavor = detectFlavor(j); | |
| 366 | + if (flavor) { | |
| 367 | + add({ url: root + path, type: "STATUSPAGE", connector: "statusjson", kind: "status", name: "status", config: { flavor }, suggestedTier: "S", evidence: `${flavor} status JSON (${new URL(root).host})`, value: 0.93, itemsPerDay: null, firstPartyConfidence: 0.9, fetchCost: { bytes: o.meta.contentLength, ms: o.meta.durationMs } }); | |
| 368 | + statusFound = true; | |
| 369 | + } | |
| 370 | + } | |
| 371 | + } catch { | |
| 372 | + // not JSON | |
| 373 | + } | |
| 374 | + } | |
| 375 | + } | |
| 376 | + | |
| 377 | + // ---- 7. pages (server-rendered sections) -------------------------------------------------------------------- | |
| 378 | + if (opts.probePages !== false && !blocked) { | |
| 379 | + const pageJobs: { kind: CandidateKind; label: string; tier: Tier; value: number; urls: string[]; max: number }[] = []; | |
| 380 | + for (const pk of PAGE_KINDS) { | |
| 381 | + const linked = pageLinks.get(pk.kind) ?? []; | |
| 382 | + // feeds already cover news/press/blog for this org → the HTML index page adds little | |
| 383 | + if ((pk.kind === "news" || pk.kind === "press") && keptFeeds.some((f) => f.kind === pk.kind || f.kind === "news")) continue; | |
| 384 | + const shallowFirst = (a: string, b: string): number => safePath(a).split("/").length - safePath(b).split("/").length || a.length - b.length; | |
| 385 | + const urls = [...new Set([...linked.filter((u) => pk.hrefRe.test(safePath(u)) || linked.length <= 2).sort(shallowFirst), ...pk.paths.map((p) => base + p)])].slice(0, 4); | |
| 386 | + if (!urls.length) continue; | |
| 387 | + pageJobs.push({ kind: pk.kind, label: pk.label, tier: pk.tier, value: pk.value, urls, max: pk.max }); | |
| 388 | + } | |
| 389 | + const pageCanon: { url: string; sh: Set<string>; hash: string }[] = []; | |
| 390 | + const probed = new Set<string>(); | |
| 391 | + await parallel(pageJobs, 3, async (job) => { | |
| 392 | + let kept = 0; | |
| 393 | + for (const url of job.urls) { | |
| 394 | + if (kept >= job.max || !budgetLeft()) break; | |
| 395 | + const pk = url.replace(/\/$/, ""); | |
| 396 | + if (probed.has(pk) || found.has(pk)) continue; | |
| 397 | + probed.add(pk); | |
| 398 | + const o = await get(url, { timeoutMs: 15_000, maxBytes: 3 * 1024 * 1024, accept: "text/html, application/xhtml+xml;q=0.9, */*;q=0.5" }); | |
| 399 | + if (o.meta.status !== 200 || !o.body) continue; | |
| 400 | + const finalPath = safePath(o.meta.finalUrl); | |
| 401 | + if (finalPath === "/" || finalPath === "") continue; // redirected home | |
| 402 | + if (!isOwn(o.meta.finalUrl || url)) continue; | |
| 403 | + if (!/text\/html|xhtml/i.test(o.meta.contentType ?? "") && /^\s*[{[]/.test(o.body.toString("utf8").slice(0, 50))) continue; | |
| 404 | + const c = canonicalizeHtml(o.body.toString("utf8"), o.meta.finalUrl || url); | |
| 405 | + if (c.text.length < 300) { | |
| 406 | + rejected.push({ url, reason: `thin (${c.text.length} chars)` }); | |
| 407 | + continue; | |
| 408 | + } | |
| 409 | + const sh = shingles(c.text); | |
| 410 | + if (homeCanon && (c.canonicalHash === homeCanon.canonicalHash || (homeShingles && jaccard(sh, homeShingles) > 0.8))) { | |
| 411 | + rejected.push({ url, reason: "same as homepage" }); | |
| 412 | + continue; | |
| 413 | + } | |
| 414 | + const dup = pageCanon.find((p) => p.hash === c.canonicalHash || jaccard(p.sh, sh) > 0.85); | |
| 415 | + if (dup) { | |
| 416 | + rejected.push({ url, reason: `duplicate of ${dup.url}` }); | |
| 417 | + continue; | |
| 418 | + } | |
| 419 | + pageCanon.push({ url: o.meta.finalUrl || url, sh, hash: c.canonicalHash }); | |
| 420 | + const isSecurityTxt = /security\.txt$/.test(url); | |
| 421 | + add({ url: o.meta.finalUrl || url, type: "HTML", connector: "http", kind: job.kind, name: isSecurityTxt ? "security.txt" : job.label, config: {}, suggestedTier: isSecurityTxt ? "D" : job.tier, evidence: `${pageLinks.get(job.kind)?.includes(url) ? "linked from homepage" : "well-known path"} · GET 200 · ${c.text.length} chars · distinct from homepage`, value: isSecurityTxt ? 0.3 : job.value, itemsPerDay: null, firstPartyConfidence: 1, fetchCost: { bytes: o.meta.contentLength, ms: o.meta.durationMs }, fingerprint: c.canonicalHash, textShingles: sh, title: c.title ?? undefined }); | |
| 422 | + kept++; | |
| 423 | + } | |
| 424 | + }); | |
| 425 | + } | |
| 426 | + | |
| 427 | + // ---- 8. GitHub organization → repositories' releases ---------------------------------------------------------- | |
| 428 | + const repos = new Set<string>(hints.github_repos ?? []); | |
| 429 | + for (const org of [...githubOrgs].slice(0, 1)) { | |
| 430 | + if (repos.size >= 4 || !budgetLeft()) break; | |
| 431 | + const token = process.env.GITHUB_TOKEN; | |
| 432 | + if (token) { | |
| 433 | + const o = await get(`https://api.github.com/search/repositories?q=org:${encodeURIComponent(org)}+fork:false&sort=stars&order=desc&per_page=5`, { accept: "application/vnd.github+json", headers: { authorization: `Bearer ${token}`, "x-github-api-version": "2022-11-28" } }); | |
| 434 | + if (o.body && o.meta.status === 200) { | |
| 435 | + try { | |
| 436 | + const j = JSON.parse(o.body.toString("utf8")) as { items?: { full_name: string; archived?: boolean; stargazers_count?: number }[] }; | |
| 437 | + for (const r of j.items ?? []) if (!r.archived && (r.stargazers_count ?? 0) >= 50) repos.add(r.full_name); | |
| 438 | + } catch { | |
| 439 | + // ignore | |
| 440 | + } | |
| 441 | + } | |
| 442 | + } else { | |
| 443 | + // Unauthenticated: the organization page lists pinned / popular repositories server-side. | |
| 444 | + const o = await get(`https://github.com/${org}`, { timeoutMs: 15_000, maxBytes: 3 * 1024 * 1024 }); | |
| 445 | + if (o.body && o.meta.status === 200) { | |
| 446 | + const html = o.body.toString("utf8"); | |
| 447 | + const counts = new Map<string, number>(); | |
| 448 | + for (const m of html.matchAll(new RegExp(`href="/${org.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}/([A-Za-z0-9_.-]+)"`, "g"))) { | |
| 449 | + const r = m[1]!; | |
| 450 | + if (/^(followers|following|repositories|projects|packages|people|teams|sponsoring|discussions|orgs|\.github)$/i.test(r)) continue; | |
| 451 | + counts.set(r, (counts.get(r) ?? 0) + 1); | |
| 452 | + } | |
| 453 | + const pinned = [...html.matchAll(/class="[^"]*pinned-item-list-item[^"]*"[\s\S]{0,1500}?href="\/([^/"]+)\/([^/"]+)"/g)].map((m) => `${m[1]}/${m[2]}`).filter((f) => f.toLowerCase().startsWith(org.toLowerCase() + "/")); | |
| 454 | + for (const f of pinned) repos.add(f); | |
| 455 | + for (const [r] of [...counts.entries()].sort((a, b) => b[1] - a[1]).slice(0, 6)) if (repos.size < 6) repos.add(`${org}/${r}`); | |
| 456 | + } | |
| 457 | + } | |
| 458 | + } | |
| 459 | + const repoList = [...repos].slice(0, 6); | |
| 460 | + let releasesKept = 0; | |
| 461 | + await parallel(repoList, 3, async (full) => { | |
| 462 | + if (releasesKept >= 4 || !budgetLeft()) return; | |
| 463 | + for (const kind of ["releases", "tags"] as const) { | |
| 464 | + const url = `https://github.com/${full}/${kind}.atom`; | |
| 465 | + const o = await get(url, { timeoutMs: 12_000, accept: "application/atom+xml, application/xml;q=0.9" }); | |
| 466 | + if (!o.body || o.meta.status !== 200) { | |
| 467 | + if (o.meta.status === 404) return; | |
| 468 | + continue; | |
| 469 | + } | |
| 470 | + try { | |
| 471 | + const f = parseFeed(o.body.toString("utf8"), url); | |
| 472 | + if (!f.items.length) continue; | |
| 473 | + const dates = f.items.map((i) => (i.publishedAt ? new Date(i.publishedAt).getTime() : NaN)).filter((t) => !Number.isNaN(t)); | |
| 474 | + if (dates.length && Math.max(...dates) < Date.now() - 2 * 365 * 86400e3) return rejected.push({ url, reason: "repository inactive (> 2 years)" }); | |
| 475 | + add({ url, type: "GITHUB_RELEASE", connector: "github", kind: "releases", name: `${full.split("/")[1]} ${kind}`, config: { repo: full, kind }, suggestedTier: "B", evidence: `GitHub ${kind}.atom · ${f.items.length} items`, value: kind === "releases" ? 0.8 : 0.65, itemCount: f.items.length, itemsPerDay: null, firstPartyConfidence: 0.9, fetchCost: { bytes: o.meta.contentLength, ms: o.meta.durationMs }, fingerprint: sha256(f.items.map((i) => i.key).sort().join("\n")) }); | |
| 476 | + releasesKept++; | |
| 477 | + return; | |
| 478 | + } catch { | |
| 479 | + // not a feed | |
| 480 | + } | |
| 481 | + } | |
| 482 | + }); | |
| 483 | + | |
| 484 | + // ---- 9. Hugging Face models ------------------------------------------------------------------------------------ | |
| 485 | + for (const author of [...hfAuthors].slice(0, 2)) { | |
| 486 | + if (!budgetLeft()) break; | |
| 487 | + const url = `https://huggingface.co/api/models?author=${encodeURIComponent(author)}&sort=lastModified&direction=-1&limit=50`; | |
| 488 | + const o = await get(url, { accept: "application/json" }); | |
| 489 | + if (!o.body || o.meta.status !== 200) continue; | |
| 490 | + try { | |
| 491 | + const j = JSON.parse(o.body.toString("utf8")) as unknown[]; | |
| 492 | + if (Array.isArray(j) && j.length) add({ url, type: "REST_API", connector: "jsonlist", kind: "models", name: "hugging face models", config: { keyField: "id", titleField: "id", dateField: "lastModified", compareFields: ["pipeline_tag", "library_name"], maxItems: 50 }, suggestedTier: "B", evidence: `Hugging Face author ${author} · ${j.length} models`, value: 0.85, itemCount: j.length, itemsPerDay: null, firstPartyConfidence: 0.9, fetchCost: { bytes: o.meta.contentLength, ms: o.meta.durationMs } }); | |
| 493 | + } catch { | |
| 494 | + // ignore | |
| 495 | + } | |
| 496 | + } | |
| 497 | + | |
| 498 | + // ---- 10. EDGAR filings ------------------------------------------------------------------------------------------- | |
| 499 | + if (hints.cik !== undefined && budgetLeft()) { | |
| 500 | + const cik = String(hints.cik).replace(/\D/g, "").padStart(10, "0"); | |
| 501 | + const url = `https://data.sec.gov/submissions/CIK${cik}.json`; | |
| 502 | + const ua = process.env.WS_EDGAR_USER_AGENT ?? "WebSensor (contact@websensor.io)"; | |
| 503 | + const o = await get(url, { accept: "application/json", userAgent: ua, headers: { "user-agent": ua }, timeoutMs: 30_000, maxBytes: 30 * 1024 * 1024 }); | |
| 504 | + if (o.body && o.meta.status === 200 && /"filings"/.test(o.body.toString("utf8").slice(0, 200_000))) { | |
| 505 | + add({ url, type: "REST_API", connector: "edgar", kind: "filings", name: "edgar filings", config: { forms: ["8-K", "10-K", "10-Q", "6-K", "20-F", "40-F", "S-1", "SC 13D", "DEF 14A"] }, suggestedTier: "B", evidence: `SEC EDGAR submissions CIK ${cik}`, value: 0.9, itemsPerDay: null, firstPartyConfidence: 1, fetchCost: { bytes: o.meta.contentLength, ms: o.meta.durationMs } }); | |
| 506 | + } else rejected.push({ url, reason: `edgar ${o.meta.status}` }); | |
| 507 | + } | |
| 508 | + | |
| 509 | + // ---- 11. OpenAPI documents --------------------------------------------------------------------------------------- | |
| 510 | + if (opts.probeOpenApi !== false && (subHosts.some((s) => s.kind === "docs" || s.kind === "api") || pageLinks.has("docs")) && budgetLeft()) { | |
| 511 | + const roots = [base, ...subHosts.filter((s) => s.kind === "docs" || s.kind === "api").map((s) => `https://${s.host}`)].slice(0, 3); | |
| 512 | + let openapiFound = false; | |
| 513 | + for (const root of roots) { | |
| 514 | + if (openapiFound) break; | |
| 515 | + for (const p of OPENAPI_PATHS.slice(0, 7)) { | |
| 516 | + if (openapiFound || !budgetLeft()) break; | |
| 517 | + const o = await get(root + p, { timeoutMs: 15_000, maxBytes: 8 * 1024 * 1024, accept: "application/json, application/yaml, */*;q=0.5" }); | |
| 518 | + if (!o.body || o.meta.status !== 200) continue; | |
| 519 | + const text = o.body.toString("utf8").slice(0, 4000); | |
| 520 | + if (/^\s*[{[]/.test(text) && /"(openapi|swagger)"\s*:/.test(text)) { | |
| 521 | + add({ url: root + p, type: "JSON", connector: "openapi", kind: "api", name: "openapi", config: {}, suggestedTier: "C", evidence: `OpenAPI document (${new URL(root).host})`, value: 0.7, itemsPerDay: null, firstPartyConfidence: 1, fetchCost: { bytes: o.meta.contentLength, ms: o.meta.durationMs } }); | |
| 522 | + openapiFound = true; | |
| 523 | + } | |
| 524 | + } | |
| 525 | + } | |
| 526 | + } | |
| 527 | + | |
| 528 | + // ---- 12. explicit hint URLs (validated like everything else) -------------------------------------------------------- | |
| 529 | + for (const h of hints.urls ?? []) { | |
| 530 | + if (!budgetLeft()) break; | |
| 531 | + const known = h.url.replace(/\/$/, ""); | |
| 532 | + if (found.has(known)) continue; | |
| 533 | + const o = await get(h.url, { timeoutMs: 15_000, maxBytes: 6 * 1024 * 1024 }); | |
| 534 | + if (!o.body || o.meta.status !== 200) { | |
| 535 | + rejected.push({ url: h.url, reason: `hint ${o.meta.status || o.error?.code}` }); | |
| 536 | + continue; | |
| 537 | + } | |
| 538 | + const text = o.body.toString("utf8"); | |
| 539 | + const kind = (h.kind ?? "other") as CandidateKind; | |
| 540 | + const tier = h.tier ?? (kind === "status" ? "S" : kind === "pricing" || kind === "legal" || kind === "ir" ? "C" : "B"); | |
| 541 | + const cost = { bytes: o.meta.contentLength, ms: o.meta.durationMs }; | |
| 542 | + if (h.connector) { | |
| 543 | + add({ url: h.url, type: (h.type ?? (h.connector === "rss" ? "RSS" : h.connector === "jsonlist" || h.connector === "edgar" ? "REST_API" : h.connector === "statuspage" || h.connector === "statusjson" ? "STATUSPAGE" : h.connector === "sitemap" ? "SITEMAP" : "HTML")) as SensorType, connector: h.connector, kind, name: h.name ?? kind, config: h.config ?? {}, suggestedTier: tier, evidence: "explicit hint · GET 200", value: 0.8, itemsPerDay: null, firstPartyConfidence: isOwn(h.url) ? 1 : 0.8, fetchCost: cost }); | |
| 544 | + continue; | |
| 545 | + } | |
| 546 | + // Sniff: feed → rss; JSON → http/json; HTML → http | |
| 547 | + if (!/^\s*<!doctype html|<html/i.test(text.slice(0, 400)) && /<(rss|feed|rdf:RDF)\b/i.test(text.slice(0, 2000))) { | |
| 548 | + try { | |
| 549 | + const f = parseFeed(text, o.meta.finalUrl); | |
| 550 | + if (f.items.length) { | |
| 551 | + add({ url: o.meta.finalUrl || h.url, type: f.kind === "atom" ? "ATOM" : "RSS", connector: "rss", kind, name: h.name ?? feedName(kind, h.url), config: {}, suggestedTier: tier, evidence: `explicit hint · feed · ${f.items.length} items`, value: 0.85, itemCount: f.items.length, itemsPerDay: null, firstPartyConfidence: isOwn(h.url) ? 1 : 0.8, fetchCost: cost, fingerprint: sha256(f.items.map((i) => i.key).sort().join("\n")), itemKeys: f.items.map((i) => i.key).sort() }); | |
| 552 | + continue; | |
| 553 | + } | |
| 554 | + } catch { | |
| 555 | + // fallthrough | |
| 556 | + } | |
| 557 | + } | |
| 558 | + if (/^\s*[{[]/.test(text.slice(0, 50))) { | |
| 559 | + add({ url: h.url, type: "JSON", connector: "http", kind, name: h.name ?? kind, config: h.config ?? {}, suggestedTier: tier, evidence: "explicit hint · JSON 200", value: 0.65, itemsPerDay: null, firstPartyConfidence: isOwn(h.url) ? 1 : 0.8, fetchCost: cost }); | |
| 560 | + continue; | |
| 561 | + } | |
| 562 | + const c = canonicalizeHtml(text, o.meta.finalUrl || h.url); | |
| 563 | + if (c.text.length < 300) { | |
| 564 | + rejected.push({ url: h.url, reason: `hint thin (${c.text.length} chars)` }); | |
| 565 | + continue; | |
| 566 | + } | |
| 567 | + add({ url: o.meta.finalUrl || h.url, type: "HTML", connector: "http", kind, name: h.name ?? PAGE_KINDS.find((p) => p.kind === kind)?.label ?? kind, config: h.config ?? {}, suggestedTier: tier, evidence: `explicit hint · GET 200 · ${c.text.length} chars`, value: 0.7, itemsPerDay: null, firstPartyConfidence: isOwn(h.url) ? 1 : 0.8, fetchCost: cost, fingerprint: c.canonicalHash, title: c.title ?? undefined }); | |
| 568 | + } | |
| 569 | + | |
| 570 | + // ---- 13. web posture (no network here: validated in shadow by the dns/tls connectors) ------------------------------ | |
| 571 | + if (opts.probePosture || hints.posture) { | |
| 572 | + const rd = registrableDomain(domain); | |
| 573 | + add({ url: `dns://${rd}`, type: "DNS", connector: "dns", kind: "posture", name: "dns", config: {}, suggestedTier: "D", evidence: "posture", value: 0.3, itemsPerDay: null, firstPartyConfidence: 1, fetchCost: { bytes: 0, ms: 0 } }); | |
| 574 | + add({ url: `tls://${new URL(base).hostname}`, type: "TLS", connector: "tls", kind: "posture", name: "tls certificate", config: {}, suggestedTier: "D", evidence: "posture", value: 0.3, itemsPerDay: null, firstPartyConfidence: 1, fetchCost: { bytes: 0, ms: 0 } }); | |
| 575 | + if (robotsOk) add({ url: `https://${domain}/robots.txt`, type: "HTML", connector: "http", kind: "posture", name: "robots.txt", config: {}, suggestedTier: "D", evidence: robotsMentionsAi ? "robots.txt names AI crawlers" : "robots.txt", value: robotsMentionsAi ? 0.45 : 0.3, itemsPerDay: null, firstPartyConfidence: 1, fetchCost: { bytes: robots?.meta.contentLength ?? 0, ms: robots?.meta.durationMs ?? 0 } }); | |
| 576 | + } | |
| 577 | + | |
| 578 | + const candidates = [...found.values()].sort((a, b) => b.value - a.value); | |
| 579 | + // Strip the heavy de-dup helpers before returning (they are not serializable and no longer needed). | |
| 580 | + for (const c of candidates) { | |
| 581 | + delete c.textShingles; | |
| 582 | + if (c.itemKeys && c.itemKeys.length > 60) c.itemKeys = c.itemKeys.slice(0, 60); | |
| 583 | + } | |
| 584 | + return { domain, origin: base, canonicalDomain, homeStatus, blocked, requests, candidates, rejected, notes, durationMs: Date.now() - started }; | |
| 585 | +} | |
| 586 | + | |
| 587 | +// ------------------------------------------------------------------------------------------------ | |
| 588 | + | |
| 589 | +/** Expected publication rate from item dates: the median gap between consecutive items (robust to one bogus date). */ | |
| 590 | +export function itemsPerDayOf(dates: number[]): number | null { | |
| 591 | + const d = [...dates].filter((t) => Number.isFinite(t)).sort((a, b) => b - a).slice(0, 30); | |
| 592 | + if (d.length < 3) return null; | |
| 593 | + const gaps = d.slice(1).map((t, i) => (d[i]! - t) / 86400e3).filter((g) => g >= 0).sort((a, b) => a - b); | |
| 594 | + if (!gaps.length) return null; | |
| 595 | + const median = gaps[Math.floor(gaps.length / 2)]!; | |
| 596 | + if (median <= 0) return 50; | |
| 597 | + return Math.round((1 / median) * 1000) / 1000; | |
| 598 | +} | |
| 599 | + | |
| 600 | +function feedKind(url: string, title?: string): CandidateKind { | |
| 601 | + const s = `${url} ${title ?? ""}`.toLowerCase(); | |
| 602 | + if (/press|communiqu|pressemitteilung|prensa|news-release|newsrelease/.test(s)) return "press"; | |
| 603 | + if (/investor|\/ir\/|ir\.|sec-filing|filings/.test(s)) return "ir"; | |
| 604 | + if (/security|advisor|psirt|bulletin|vulnerab|cve/.test(s)) return "security"; | |
| 605 | + if (/changelog|release-notes|releasenotes|releases|whats-new|updates/.test(s)) return "changelog"; | |
| 606 | + if (/blog|engineering|research|insights|stories/.test(s)) return "blog"; | |
| 607 | + if (/news|actualit|nouvelles|aktuell|noticias|announcement/.test(s)) return "news"; | |
| 608 | + if (/jobs|careers/.test(s)) return "careers"; | |
| 609 | + return "news"; | |
| 610 | +} | |
| 611 | + | |
| 612 | +function feedName(kind: CandidateKind, url: string): string { | |
| 613 | + switch (kind) { | |
| 614 | + case "press": | |
| 615 | + return "press releases feed"; | |
| 616 | + case "ir": | |
| 617 | + return "investor news feed"; | |
| 618 | + case "security": | |
| 619 | + return "security feed"; | |
| 620 | + case "changelog": | |
| 621 | + return "changelog feed"; | |
| 622 | + case "blog": | |
| 623 | + return /engineering/i.test(url) ? "engineering blog feed" : /research/i.test(url) ? "research feed" : "blog feed"; | |
| 624 | + case "careers": | |
| 625 | + return "jobs feed"; | |
| 626 | + default: | |
| 627 | + return "news feed"; | |
| 628 | + } | |
| 629 | +} | |
| 630 | + | |
| 631 | +function safePath(u: string): string { | |
| 632 | + try { | |
| 633 | + return new URL(u).pathname.replace(/\/$/, ""); | |
| 634 | + } catch { | |
| 635 | + return ""; | |
| 636 | + } | |
| 637 | +} | |
| 638 | + | |
| 639 | +function safeAbs(h: string, base: string): string | null { | |
| 640 | + try { | |
| 641 | + const u = new URL(h, base); | |
| 642 | + if (!u.protocol.startsWith("http")) return null; | |
| 643 | + u.hash = ""; | |
| 644 | + return u.toString(); | |
| 645 | + } catch { | |
| 646 | + return null; | |
| 647 | + } | |
| 648 | +} | |
| 649 | + | |
| 650 | +async function resolves(host: string): Promise<boolean> { | |
| 651 | + try { | |
| 652 | + const r = await dns.lookup(host, { all: true }); | |
| 653 | + return r.length > 0; | |
| 654 | + } catch { | |
| 655 | + return false; | |
| 656 | + } | |
| 657 | +} | |
| 658 | + | |
| 659 | +async function parallel<T>(items: T[], limit: number, fn: (item: T) => Promise<unknown>): Promise<void> { | |
| 660 | + let i = 0; | |
| 661 | + const workers = Array.from({ length: Math.min(limit, items.length) }, async () => { | |
| 662 | + while (i < items.length) { | |
| 663 | + const item = items[i++]!; | |
| 664 | + try { | |
| 665 | + await fn(item); | |
| 666 | + } catch { | |
| 667 | + // best effort | |
| 668 | + } | |
| 669 | + } | |
| 670 | + }); | |
| 671 | + await Promise.all(workers); | |
| 672 | +} | |
modified
packages/connectors/src/index.ts
+1 −0
@@ -66,3 +66,4 @@ export function getConnector(key: string): WebSensorConnector { | ||
| 66 | 66 | export function listConnectors(): WebSensorConnector[] { |
| 67 | 67 | return Object.values(REGISTRY); |
| 68 | 68 | } |
| 69 | +export * from "./discovery-deep"; | |
added
packages/core/src/coverage-schema.ts
+150 −0
@@ -0,0 +1,150 @@ | ||
| 1 | +import { z } from "zod"; | |
| 2 | +import { TIERS } from "./taxonomy"; | |
| 3 | + | |
| 4 | +/** | |
| 5 | + * Coverage universes (`config/coverage/*.yaml`). A universe is the list of organizations WebSensor *should* | |
| 6 | + * observe for a sector — the denominator of the Global Observation Coverage Score. The same files feed the | |
| 7 | + * Source Factory: every member without a monitored domain becomes an expansion seed. | |
| 8 | + * | |
| 9 | + * One file per sector: | |
| 10 | + * | |
| 11 | + * sector: ai | |
| 12 | + * label: Artificial intelligence | |
| 13 | + * weight: 1.2 # weight of the sector in the global score (default 1) | |
| 14 | + * categories: [ai, technology] # default categories applied to members (registry vocabulary) | |
| 15 | + * tier: B # default tier for factory-created sources | |
| 16 | + * universes: | |
| 17 | + * - key: frontier-labs | |
| 18 | + * label: Frontier labs & model providers | |
| 19 | + * members: | |
| 20 | + * - { name: OpenAI, domain: openai.com, country: US, importance: 3, hints: { github_org: openai, hf_author: openai } } | |
| 21 | + */ | |
| 22 | +export const memberHintUrlSchema = z.object({ | |
| 23 | + url: z.string().url(), | |
| 24 | + /** what the page is: news | press | blog | ir | filings | changelog | releases | security | pricing | legal | careers | docs | api | status | models | data | other */ | |
| 25 | + kind: z.string().default("other"), | |
| 26 | + connector: z.string().optional(), | |
| 27 | + type: z.string().optional(), | |
| 28 | + tier: z.enum(TIERS).optional(), | |
| 29 | + config: z.record(z.string(), z.unknown()).optional(), | |
| 30 | + name: z.string().optional(), | |
| 31 | +}); | |
| 32 | + | |
| 33 | +export const memberHintsSchema = z.object({ | |
| 34 | + /** SEC CIK (digits, any length) → EDGAR submissions sensor */ | |
| 35 | + cik: z.union([z.string(), z.number()]).optional(), | |
| 36 | + ticker: z.string().optional(), | |
| 37 | + exchange: z.string().optional(), | |
| 38 | + /** GitHub organization → most active repositories' releases */ | |
| 39 | + github_org: z.string().optional(), | |
| 40 | + /** explicit repositories `owner/name` */ | |
| 41 | + github_repos: z.array(z.string()).optional(), | |
| 42 | + /** Hugging Face author/organization → model list sensor */ | |
| 43 | + hf_author: z.string().optional(), | |
| 44 | + /** status page root (Atlassian Statuspage, Instatus, incident.io, Status.io) */ | |
| 45 | + status_url: z.string().url().optional(), | |
| 46 | + /** additional hosts that belong to the organization (blog.example.org, investors.example.org…) */ | |
| 47 | + hosts: z.array(z.string()).optional(), | |
| 48 | + /** explicit candidate URLs (validated by the factory before becoming sensors) */ | |
| 49 | + urls: z.array(memberHintUrlSchema).optional(), | |
| 50 | + /** probe robots/TLS/DNS/headers posture sensors (default: only for importance 3) */ | |
| 51 | + posture: z.boolean().optional(), | |
| 52 | + /** Wikidata QID, for provenance */ | |
| 53 | + wikidata: z.string().optional(), | |
| 54 | +}); | |
| 55 | + | |
| 56 | +export const coverageMemberSchema = z.object({ | |
| 57 | + name: z.string().min(1), | |
| 58 | + /** bare registrable domain, lower-case, no scheme, no `www.` */ | |
| 59 | + domain: z | |
| 60 | + .string() | |
| 61 | + .min(3) | |
| 62 | + .transform((d) => d.trim().toLowerCase().replace(/^https?:\/\//, "").replace(/^www\./, "").replace(/\/.*$/, "")), | |
| 63 | + country: z.string().regex(/^[A-Z]{2,3}$/).optional(), | |
| 64 | + language: z.string().regex(/^[a-z]{2}$/).optional(), | |
| 65 | + /** 1 = long tail · 2 = notable (default) · 3 = systemically important */ | |
| 66 | + importance: z.union([z.literal(1), z.literal(2), z.literal(3)]).default(2), | |
| 67 | + aliases: z.array(z.string()).default([]), | |
| 68 | + categories: z.array(z.string()).optional(), | |
| 69 | + tier: z.enum(TIERS).optional(), | |
| 70 | + first_party: z.boolean().optional(), | |
| 71 | + hints: memberHintsSchema.default({}), | |
| 72 | + note: z.string().optional(), | |
| 73 | +}); | |
| 74 | +export type CoverageMember = z.infer<typeof coverageMemberSchema>; | |
| 75 | + | |
| 76 | +export const coverageUniverseSchema = z.object({ | |
| 77 | + key: z.string().regex(/^[a-z0-9][a-z0-9-]*$/), | |
| 78 | + label: z.string(), | |
| 79 | + description: z.string().optional(), | |
| 80 | + /** provenance of the list (URL of the index constituents page, Wikidata query…) */ | |
| 81 | + provenance: z.string().optional(), | |
| 82 | + members: z.array(coverageMemberSchema), | |
| 83 | +}); | |
| 84 | +export type CoverageUniverse = z.infer<typeof coverageUniverseSchema>; | |
| 85 | + | |
| 86 | +export const coverageSectorSchema = z.object({ | |
| 87 | + sector: z.string().regex(/^[a-z0-9][a-z0-9-]*$/), | |
| 88 | + label: z.string(), | |
| 89 | + description: z.string().optional(), | |
| 90 | + weight: z.number().positive().default(1), | |
| 91 | + categories: z.array(z.string()).default([]), | |
| 92 | + tier: z.enum(TIERS).default("B"), | |
| 93 | + universes: z.array(coverageUniverseSchema), | |
| 94 | +}); | |
| 95 | +export type CoverageSector = z.infer<typeof coverageSectorSchema>; | |
| 96 | + | |
| 97 | +/** | |
| 98 | + * Registrable domain (eTLD+1) with a compact public-suffix list covering the multi-label suffixes that | |
| 99 | + * matter for official sources (governments, universities, companies). Good enough to match a sensor host | |
| 100 | + * (`ir.example.com`, `www.gov.uk`) to a universe member; not a full PSL. | |
| 101 | + */ | |
| 102 | +const MULTI_SUFFIX = new Set([ | |
| 103 | + "co.uk", "org.uk", "gov.uk", "ac.uk", "nhs.uk", "police.uk", "ltd.uk", "plc.uk", "me.uk", "net.uk", "sch.uk", | |
| 104 | + "com.au", "net.au", "org.au", "gov.au", "edu.au", "id.au", "asn.au", | |
| 105 | + "gc.ca", "qc.ca", "on.ca", "bc.ca", "ab.ca", "mb.ca", "sk.ca", "ns.ca", "nb.ca", "nl.ca", "pe.ca", "nt.ca", "nu.ca", "yk.ca", "gouv.qc.ca", | |
| 106 | + "co.jp", "ne.jp", "or.jp", "go.jp", "ac.jp", "ad.jp", "lg.jp", "ed.jp", | |
| 107 | + "co.kr", "go.kr", "or.kr", "ac.kr", "re.kr", "ne.kr", | |
| 108 | + "com.cn", "gov.cn", "org.cn", "edu.cn", "net.cn", "ac.cn", | |
| 109 | + "com.hk", "gov.hk", "org.hk", "edu.hk", | |
| 110 | + "com.tw", "gov.tw", "org.tw", "edu.tw", | |
| 111 | + "com.sg", "gov.sg", "org.sg", "edu.sg", | |
| 112 | + "co.in", "gov.in", "nic.in", "org.in", "ac.in", "net.in", "res.in", "edu.in", | |
| 113 | + "com.br", "gov.br", "org.br", "edu.br", "net.br", "leg.br", "jus.br", "mil.br", | |
| 114 | + "com.mx", "gob.mx", "org.mx", "edu.mx", | |
| 115 | + "com.ar", "gob.ar", "gov.ar", "org.ar", "edu.ar", | |
| 116 | + "gob.cl", "gov.cl", "cl", "gob.pe", "gob.es", "gov.co", "gob.ec", "gob.bo", "gub.uy", "gob.pa", "gob.do", "gob.gt", "gob.hn", "gob.sv", "gob.ni", "gob.cu", "gob.ve", "gob.py", | |
| 117 | + "co.za", "gov.za", "org.za", "ac.za", "net.za", | |
| 118 | + "co.nz", "govt.nz", "org.nz", "ac.nz", "net.nz", | |
| 119 | + "com.tr", "gov.tr", "org.tr", "edu.tr", | |
| 120 | + "co.il", "gov.il", "org.il", "ac.il", "muni.il", | |
| 121 | + "gov.ae", "ac.ae", "co.ae", | |
| 122 | + "gov.sa", "com.sa", "edu.sa", "org.sa", | |
| 123 | + "gov.qa", "com.qa", "gov.kw", "gov.bh", "gov.om", "gov.jo", "gov.lb", "gov.eg", "com.eg", "gov.ma", "gov.dz", "gov.tn", | |
| 124 | + "gov.ng", "com.ng", "org.ng", "go.ke", "co.ke", "or.ke", "ac.ke", "gov.gh", "com.gh", "gov.et", "gov.rw", "go.tz", "co.tz", "go.ug", "co.ug", "gov.zm", "gov.zw", "gov.bw", "gov.na", "gov.mz", "gov.sn", "gouv.sn", "gouv.ci", "gouv.bf", "gouv.ml", "gouv.ne", "gouv.tg", "gouv.bj", "gouv.cm", "gov.mu", | |
| 125 | + "com.my", "gov.my", "org.my", "edu.my", "go.th", "co.th", "or.th", "ac.th", "gov.vn", "com.vn", "edu.vn", "gov.ph", "com.ph", "org.ph", "edu.ph", "go.id", "co.id", "or.id", "ac.id", "gov.bd", "com.bd", "gov.pk", "com.pk", "edu.pk", "gov.lk", "gov.np", "gov.kh", "gov.la", "gov.mm", "gov.bn", "gov.mn", | |
| 126 | + "gov.kz", "gov.uz", "gov.kg", "gov.tj", "gov.tm", "gov.az", "gov.ge", "gov.am", "gov.by", "gov.ua", "com.ua", "org.ua", "edu.ua", "gov.md", "gov.ru", "com.ru", "org.ru", "edu.ru", "ac.ru", | |
| 127 | + "gov.pl", "com.pl", "org.pl", "edu.pl", "gov.cz", "gov.sk", "gov.hu", "gov.ro", "gov.bg", "gov.hr", "gov.si", "gov.rs", "gov.ba", "gov.mk", "gov.al", "gov.me", "gov.lv", "gov.lt", "gov.ee", "gov.gr", "gov.cy", "gov.mt", "gov.ie", "gov.pt", "gov.it", "gouv.fr", "gov.lu", "gov.be", "gov.nl", "gov.is", "gov.li", | |
| 128 | + "com.es", "org.es", "edu.es", "gob.es", "com.pt", "edu.pt", "co.it", "edu.it", "com.gr", "edu.gr", "com.pl", "com.ro", "com.tr", | |
| 129 | + "europa.eu", | |
| 130 | + "gov.uk", "parliament.uk", "judiciary.uk", "mod.uk", "nhs.uk", "royal.uk", | |
| 131 | + "state.gov", "mil", "fed.us", "nsn.us", "k12.us", "lib.us", "dni.us", "isa.us", "kids.us", | |
| 132 | + "ac.at", "co.at", "gv.at", "or.at", "ac.be", "co.nl", "ac.nz", "ac.uk", "ac.jp", "edu.au", "edu.sg", "edu.hk", | |
| 133 | + "org.au", "com.au", "co.ke", | |
| 134 | +]); | |
| 135 | + | |
| 136 | +export function registrableDomain(host: string): string { | |
| 137 | + const h = host.toLowerCase().replace(/\.$/, "").replace(/^www\./, ""); | |
| 138 | + const parts = h.split("."); | |
| 139 | + if (parts.length <= 2) return h; | |
| 140 | + const last2 = parts.slice(-2).join("."); | |
| 141 | + const last3 = parts.slice(-3).join("."); | |
| 142 | + if (MULTI_SUFFIX.has(last3) && parts.length >= 4) return parts.slice(-4).join("."); | |
| 143 | + if (MULTI_SUFFIX.has(last2)) return last3; | |
| 144 | + return last2; | |
| 145 | +} | |
| 146 | + | |
| 147 | +/** Normalize a member/source domain to its comparison key. */ | |
| 148 | +export function coverageKey(domain: string): string { | |
| 149 | + return registrableDomain(domain.trim().toLowerCase().replace(/^https?:\/\//, "").replace(/\/.*$/, "")); | |
| 150 | +} | |
modified
packages/core/src/index.ts
+1 −0
@@ -11,3 +11,4 @@ export * from "./types"; | ||
| 11 | 11 | export * from "./semantic"; |
| 12 | 12 | export * from "./search"; |
| 13 | 13 | export * from "./registry-schema"; |
| 14 | +export * from "./coverage-schema"; | |
added
packages/db/migrations/0007_factory.sql
+73 −0
@@ -0,0 +1,73 @@ | ||
| 1 | +-- WebSensor 0.3 — Source Factory & coverage (2026-09-13). Additive only. | |
| 2 | + | |
| 3 | +-- ---- Sources: origin (seed = YAML registry · import = admin import · factory = Source Factory) ---- | |
| 4 | +alter table sources add column if not exists origin text not null default 'seed'; | |
| 5 | +alter table sources add column if not exists sector text; -- coverage sector that produced the source | |
| 6 | +create index if not exists sources_origin_idx on sources (origin); | |
| 7 | +create index if not exists sources_domain_idx on sources (domain); | |
| 8 | + | |
| 9 | +-- ---- Sensors: SHADOW status (observed, evidence stored, never published) ---------------------- | |
| 10 | +create index if not exists sensors_status_idx on sensors (status); | |
| 11 | +create index if not exists sensors_url_host_idx on sensors ((lower(split_part(split_part(url, '/', 3), ':', 1)))); | |
| 12 | + | |
| 13 | +-- ---- Factory seeds: organizations to expand ------------------------------------------------- | |
| 14 | +create table if not exists factory_seeds ( | |
| 15 | + id text primary key, -- kebab id; becomes the source id when a new source is created | |
| 16 | + name text not null, | |
| 17 | + domain text not null, | |
| 18 | + homepage text, | |
| 19 | + categories text[] not null default '{}', | |
| 20 | + country text, | |
| 21 | + language text, | |
| 22 | + tier text not null default 'B', | |
| 23 | + weight real not null default 1, | |
| 24 | + importance smallint not null default 2, -- 1 long tail · 2 notable · 3 systemic | |
| 25 | + aliases text[] not null default '{}', | |
| 26 | + first_party boolean not null default true, | |
| 27 | + sector text, | |
| 28 | + universe text, | |
| 29 | + hints jsonb not null default '{}', | |
| 30 | + status text not null default 'queued', -- queued | discovering | discovered | blocked | error | done | |
| 31 | + attempts integer not null default 0, | |
| 32 | + source_id text, -- existing or created source | |
| 33 | + candidates integer not null default 0, | |
| 34 | + shadow integer not null default 0, | |
| 35 | + accepted integer not null default 0, | |
| 36 | + rejected integer not null default 0, | |
| 37 | + last_error text, | |
| 38 | + discovered_at timestamptz, | |
| 39 | + created_at timestamptz not null default now(), | |
| 40 | + updated_at timestamptz not null default now() | |
| 41 | +); | |
| 42 | +create index if not exists factory_seeds_status_idx on factory_seeds (status, importance desc, created_at); | |
| 43 | +create index if not exists factory_seeds_domain_idx on factory_seeds (domain); | |
| 44 | +create index if not exists factory_seeds_sector_idx on factory_seeds (sector); | |
| 45 | + | |
| 46 | +-- ---- Discovery candidates: full lifecycle (candidate → shadow → accepted | rejected | duplicate | blocked) ---- | |
| 47 | +alter table discovery_candidates add column if not exists seed_id text; | |
| 48 | +alter table discovery_candidates add column if not exists connector text; | |
| 49 | +alter table discovery_candidates add column if not exists type text; | |
| 50 | +alter table discovery_candidates add column if not exists name text; | |
| 51 | +alter table discovery_candidates add column if not exists kind_class text; -- news press blog ir filings changelog releases security pricing legal careers docs api status sitemap models data posture other | |
| 52 | +alter table discovery_candidates add column if not exists config jsonb not null default '{}'; | |
| 53 | +alter table discovery_candidates add column if not exists tier text; | |
| 54 | +alter table discovery_candidates add column if not exists score_value real; | |
| 55 | +alter table discovery_candidates add column if not exists reason text; | |
| 56 | +alter table discovery_candidates add column if not exists shadow_sensor_id text; | |
| 57 | +alter table discovery_candidates add column if not exists decided_at timestamptz; | |
| 58 | +alter table discovery_candidates add column if not exists updated_at timestamptz not null default now(); | |
| 59 | +create index if not exists discovery_candidates_status_idx on discovery_candidates (status, score_value desc); | |
| 60 | +create index if not exists discovery_candidates_seed_idx on discovery_candidates (seed_id); | |
| 61 | +create index if not exists discovery_candidates_shadow_idx on discovery_candidates (shadow_sensor_id) where shadow_sensor_id is not null; | |
| 62 | + | |
| 63 | +-- ---- Factory daily counters (funnel) ---------------------------------------------------------- | |
| 64 | +create table if not exists factory_daily ( | |
| 65 | + day date primary key, | |
| 66 | + seeds_processed integer not null default 0, | |
| 67 | + requests integer not null default 0, | |
| 68 | + candidates integer not null default 0, | |
| 69 | + shadow_created integer not null default 0, | |
| 70 | + accepted integer not null default 0, | |
| 71 | + rejected integer not null default 0, | |
| 72 | + blocked integer not null default 0 | |
| 73 | +); | |
modified
packages/db/src/schema.ts
+46 −0
@@ -25,6 +25,9 @@ export const sources = pgTable("sources", { | ||
| 25 | 25 | language: text("language"), |
| 26 | 26 | kind: text("kind").notNull().default("registry"), |
| 27 | 27 | ownerToken: text("owner_token"), |
| 28 | + /** seed (YAML registry) · import (admin) · factory (Source Factory) */ | |
| 29 | + origin: text("origin").notNull().default("seed"), | |
| 30 | + sector: text("sector"), | |
| 28 | 31 | createdAt: ts("created_at").notNull().defaultNow(), |
| 29 | 32 | updatedAt: ts("updated_at").notNull().defaultNow(), |
| 30 | 33 | }); |
@@ -419,7 +422,50 @@ export const discoveryCandidates = pgTable("discovery_candidates", { | ||
| 419 | 422 | score: jsonb("score").$type<Record<string, unknown>>().notNull().default({}), |
| 420 | 423 | status: text("status").notNull().default("candidate"), |
| 421 | 424 | foundAt: ts("found_at").notNull().defaultNow(), |
| 425 | + seedId: text("seed_id"), | |
| 426 | + connector: text("connector"), | |
| 427 | + type: text("type"), | |
| 428 | + name: text("name"), | |
| 429 | + kindClass: text("kind_class"), | |
| 430 | + config: jsonb("config").$type<Record<string, unknown>>().notNull().default({}), | |
| 431 | + tier: text("tier"), | |
| 432 | + scoreValue: real("score_value"), | |
| 433 | + reason: text("reason"), | |
| 434 | + shadowSensorId: text("shadow_sensor_id"), | |
| 435 | + decidedAt: ts("decided_at"), | |
| 436 | + updatedAt: ts("updated_at").notNull().defaultNow(), | |
| 437 | +}); | |
| 438 | + | |
| 439 | +export const factorySeeds = pgTable("factory_seeds", { | |
| 440 | + id: text("id").primaryKey(), | |
| 441 | + name: text("name").notNull(), | |
| 442 | + domain: text("domain").notNull(), | |
| 443 | + homepage: text("homepage"), | |
| 444 | + categories: text("categories").array().notNull().default([]), | |
| 445 | + country: text("country"), | |
| 446 | + language: text("language"), | |
| 447 | + tier: text("tier").notNull().default("B"), | |
| 448 | + weight: real("weight").notNull().default(1), | |
| 449 | + importance: smallint("importance").notNull().default(2), | |
| 450 | + aliases: text("aliases").array().notNull().default([]), | |
| 451 | + firstParty: boolean("first_party").notNull().default(true), | |
| 452 | + sector: text("sector"), | |
| 453 | + universe: text("universe"), | |
| 454 | + hints: jsonb("hints").$type<Record<string, unknown>>().notNull().default({}), | |
| 455 | + status: text("status").notNull().default("queued"), | |
| 456 | + attempts: integer("attempts").notNull().default(0), | |
| 457 | + sourceId: text("source_id"), | |
| 458 | + candidates: integer("candidates").notNull().default(0), | |
| 459 | + shadow: integer("shadow").notNull().default(0), | |
| 460 | + accepted: integer("accepted").notNull().default(0), | |
| 461 | + rejected: integer("rejected").notNull().default(0), | |
| 462 | + lastError: text("last_error"), | |
| 463 | + discoveredAt: ts("discovered_at"), | |
| 464 | + createdAt: ts("created_at").notNull().defaultNow(), | |
| 465 | + updatedAt: ts("updated_at").notNull().defaultNow(), | |
| 422 | 466 | }); |
| 467 | +export type FactorySeed = typeof factorySeeds.$inferSelect; | |
| 468 | +export type DiscoveryCandidate = typeof discoveryCandidates.$inferSelect; | |
| 423 | 469 | |
| 424 | 470 | export const metricsDaily = pgTable("metrics_daily", { |
| 425 | 471 | day: date("day", { mode: "string" }).primaryKey(), |
| 426 | 472 | |