SPB Git

spb/websensor

Public

WebSensor v0.1.0 — real-time web intelligence platform

Core (taxonomy, SSRF policy, canonical extraction, diff engines, heuristics, scoring, adaptive schedule),
db (SQL migrations + Drizzle), content-addressed zstd blob store, connectors (http, rss/atom, sitemap,
statuspage, github, jsonlist, discovery, scrapfly fallback), engine (scheduler, pipeline, Claude
interpretation, entities, novelty/clustering, silent changes, metrics), Fastify gateway (REST + WebSocket +
proxy), Next.js 16 frontend, 200-source registry, tests, docs, mld manifest.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Simon-Pierre Boucher committed (Sep 8, 2026) Browse files

142 changed files +18,939 −0

142 changed files
added .env.example +30 −0
@@ -0,0 +1,30 @@
1 +# WebSensor — environment (never commit real values)
2 +NODE_ENV=development
3 +DATABASE_URL=postgres://localhost:5432/websensor
4 +REDIS_URL=redis://127.0.0.1:6379
5 +
6 +# Content-addressed blob store (fs driver). S3/MinIO driver can be plugged in later.
7 +BLOB_STORE_DIR=./data/blobs
8 +
9 +PUBLIC_BASE_URL=http://localhost:8260
10 +API_PORT=8260
11 +API_HOST=127.0.0.1
12 +WEB_PORT=8261
13 +WEB_URL=http://127.0.0.1:8261
14 +ENGINE_METRICS_PORT=8262
15 +
16 +# Optional acquisition fallbacks
17 +FIRECRAWL_API_KEY=
18 +SCRAPFLY_API_KEY=
19 +
20 +# AI interpretation (heuristics run without any key)
21 +ANTHROPIC_API_KEY=
22 +WS_LLM_MODEL_FAST=claude-haiku-4-5
23 +WS_LLM_MODEL_DEEP=claude-opus-5
24 +WS_LLM_DAILY_CALL_BUDGET=600
25 +WS_LLM_MIN_IMPORTANCE=35
26 +
27 +# Engine tuning
28 +WS_FETCH_CONCURRENCY=16
29 +WS_USER_AGENT=WebSensorBot/0.1 (+https://www.websensor.io/bot)
30 +WS_SOURCES_FILE=./config/sources.yaml
added .gitignore +17 −0
@@ -0,0 +1,17 @@
1 +node_modules/
2 +.next/
3 +dist/
4 +*.tsbuildinfo
5 +.env
6 +.env.*
7 +!.env.example
8 +logs/
9 +tmp/
10 +coverage/
11 +.DS_Store
12 +.claude/
13 +deploy/*.mld.json
14 +data/
15 +qa/test-results/
16 +qa/.e2e-*
17 +apps/web/next-env.d.ts
added CLAUDE.md +76 −0
@@ -0,0 +1,76 @@
1 +# WebSensor — repository guide
2 +
3 +WebSensor (www.websensor.io) is a real-time web intelligence platform: a global sensor network for the changing
4 +Web. It monitors official public sources, detects meaningful changes, classifies them, links entities, scores
5 +importance, preserves evidence and publishes events through a WebSocket feed. The product brief that drove the
6 +design is `docs/PRODUCT-BRIEF.md`; this file is the working guide for the code.
7 +
8 +## Layout (pnpm workspace, TypeScript ESM, Node ≥ 22.15)
9 +- `packages/core` — taxonomy (event types + intrinsic severity, tiers, categories), ids, SSRF policy
10 + (`assertUrlAllowed`, `safeLookup`), hashing (sha256, simhash, shingles/Jaccard), canonical extraction
11 + (`canonicalizeHtml`: strips scripts/nav/footer/cookie chrome, timestamps, tokens, counters), diff engines
12 + (text / json / keyed list), stage-1 heuristics (`evaluateChange`, `describeChange`), scoring
13 + (importance components, confidence, novelty helpers, trending, activity anomaly), adaptive schedule.
14 + `@websensor/core/client` is the browser-safe subset (no `node:` imports) — client components must import it.
15 +- `packages/db` — plain SQL migrations (`migrations/*.sql`, applied by `migrate()` with an advisory lock) +
16 + Drizzle schema for typed access. **`textArray()`** must be used for `= any(...)` / `&&` with JS arrays
17 + (Drizzle spreads arrays into parameter lists).
18 +- `packages/store` — content-addressed blob store (`sha256/ab/cd/<hash>.zst`, zstd via `node:zlib`). Raw
19 + bodies, canonical representations and full diffs live there, never in Postgres. Interface ready for S3/MinIO.
20 +- `packages/connectors` — connector SDK (`WebSensorConnector.fetch/normalize`), safe fetcher (conditional GET,
21 + manual redirects validated per hop, HTTP/2→1.1 pin on NGHTTP2 errors, browser-UA second attempt on
22 + 403/resets, size + time limits), connectors: `http` (HTML/JSON/text/HEAD), `rss` (RSS/Atom/RDF/JSON Feed),
23 + `sitemap` (index + news), `statuspage` (Atlassian v2 summary), `github` (releases/tags/commits Atom,
24 + advisories REST), `jsonlist` (keyed records from any JSON API; `{now-2h}` placeholders), `discovery`
25 + (robots sitemaps, `<link rel=alternate>`, well-known feed paths, linked status pages — every candidate is
26 + fetched and parsed), `scrapfly` (fallback, budgeted, only when `fallback.scrapfly` is set on the source).
27 +- `apps/engine` — scheduler (`FOR UPDATE SKIP LOCKED` claims, global + per-host concurrency), pipeline
28 + (fetch → normalize → snapshot → diff → heuristics → change → event: entities, novelty, LLM interpretation,
29 + clustering, importance/confidence, silent-change detection, publish to Redis), registry sync from
30 + `config/sources.yaml`, discovery, connector health rollups, Prometheus metrics on :8262. CLI: `src/cli.ts`
31 + (`sync`, `discover`, `probe <domain>`, `run-once <sensor>`, `run-due [n]`).
32 +- `apps/api` — Fastify gateway (:8260): REST `/api/v1/*`, WebSocket `/api/v1/live` (Redis pub/sub fan-out,
33 + channels `events:*`, `entity:*`, `source:*`, `watchlist:*`), `/api/v1/feed.rss`, `/api/health` `/api/ready` `/api/metrics`, apex→www
34 + redirect, and a reverse proxy to the Next.js app for everything else.
35 +- `apps/web` — Next.js 16 frontend (:8261, loopback). Pages: live, breaking, explore, sources, entities,
36 + timelines, silent changes, watchlists, alerts, event detail with diff viewer, health, API docs.
37 +- `config/sources.yaml` — the 200-organization registry (sources, curated sensors, product entities, discovery
38 + flags, fallbacks). `docs/connectors/*.md` document each connector family.
39 +
40 +## Rules
41 +- Every URL the engine touches — seeds, discovered candidates, redirects, Scrapfly targets — goes through
42 + `assertUrlAllowed()`. Private ranges, metadata endpoints, `.maclustr.io`/`.ts.net` and single-label hosts are
43 + blocked; the dispatcher's DNS lookup only returns approved addresses.
44 +- Raw evidence is immutable: snapshots and diffs are never rewritten. Re-interpretation creates a new row in
45 + `interpretations` (versioned); `events.interpretation` holds the latest.
46 +- LLM cost control: heuristics first; Claude is called only for candidates above `WS_LLM_MIN_IMPORTANCE`, with a
47 + daily call budget; the deep model only above `WS_LLM_DEEP_MIN_IMPORTANCE`. Output is strict JSON
48 + (`output_config.format`), never free text. No key → heuristics only, the system still works.
49 +- Feeds: items are "new" only if their key was never seen (sensor `state.seenKeys`) and they are not older than
50 + 14 days; items scrolling out of the window are never "removed". Sitemaps/statuspages: a >50 % shrink is
51 + treated as a partial response, not mass deletion. A 404 becomes `page_removed` only after
52 + `WS_DELETE_CONFIRMATIONS` checks separated by `WS_DELETE_SEPARATION_MIN`.
53 +- Never label inference as fact: events carry `evidence_label` (OBSERVED / INFERRED / CONFIRMED / UNCONFIRMED)
54 + and the interpretation keeps `observed` and `inferred` apart. Silent changes are flagged, never asserted as
55 + "unannounced" without checking recent announcement-type events of the same source.
56 +- Do not add sensors that are blocked with 403 by design (Akamai/Cloudflare bot management) unless the source
57 + has `fallback.scrapfly: true` and a tier ≥ C; Scrapfly is a budgeted fallback, not the foundation.
58 +- All timestamps UTC. Ids are prefixed (`src_`, `sen_`, `snap_`, `chg_`, `evt_`, `clu_`, `ent_`…).
59 +
60 +## Dev
61 +```
62 +createdb websensor && cp .env.example .env
63 +pnpm install
64 +pnpm db:migrate # or let the engine migrate on start
65 +npx tsx apps/engine/src/cli.ts sync # registry → DB
66 +npx tsx apps/engine/src/cli.ts run-due 50
67 +pnpm dev:api · pnpm dev:engine · pnpm dev:web
68 +pnpm test · pnpm typecheck
69 +```
70 +Run CLI/engine from the repo root (config paths are relative to cwd).
71 +
72 +## Deploy (MacLustr)
73 +`mld stage . websensor && mld deploy websensor --node M4M64b`. Manifest `deploy/websensor.mld.json` (secrets only
74 +on M1M32). Processes: `websensor-api` (:8260, ngrok www.websensor.io), `websensor-web` (:8261 loopback),
75 +`websensor-engine` (metrics :8262). Postgres 17 `websensor` + Redis local. Blob store `~/websensor-data/blobs`.
76 +See `deploy/README.md`.
added README.md +33 −0
@@ -0,0 +1,33 @@
1 +# WebSensor
2 +
3 +**Detect What Changed. Know Why It Matters.** — https://www.websensor.io
4 +
5 +WebSensor is a global sensor network for the changing Web. It continuously monitors official public sources
6 +(newsrooms, status pages, changelogs, regulators, registries, APIs), preserves every observation, detects raw
7 +changes, filters noise, interprets meaningful changes into events, links them to entities, scores their
8 +importance and confidence, clusters related events and publishes them live over WebSocket.
9 +
10 +```
11 +Sources → Sensors/Connectors → Fetch (conditional) → Snapshot (immutable) → Diff → Heuristics
12 + → Interpretation (Claude, budgeted) → Entities → Novelty/Clustering → Importance → Event
13 + → Redis stream → WebSocket gateway → www.websensor.io
14 +```
15 +
16 +- 200 organizations, ~290 curated sensors at launch (`config/sources.yaml`), plus validated discovery.
17 +- Connector families: HTTP (HTML/JSON/HEAD), RSS/Atom/JSON Feed, sitemaps, Statuspage, GitHub, JSON APIs
18 + (CISA KEV, NVD, Federal Register, USGS, HIBP, Google Cloud incidents…), Scrapfly fallback.
19 +- Every event is auditable: event → change → snapshot A/B → fetch run → sensor → source, with raw evidence.
20 +- Observed vs inferred is always labelled; silent (unannounced) changes are flagged.
21 +
22 +## Quick start
23 +```
24 +createdb websensor && cp .env.example .env && pnpm install
25 +npx tsx apps/engine/src/cli.ts sync # load the registry
26 +pnpm dev:api & pnpm dev:web & pnpm dev:engine
27 +open http://localhost:8260
28 +```
29 +
30 +Docs: `CLAUDE.md` (repo guide), `docs/ARCHITECTURE.md`, `docs/connectors/*.md`, `deploy/README.md`,
31 +API docs at `/api` on the site. Public API: `GET /api/v1/events`, `/api/v1/entities/{id}`,
32 +`/api/v1/sources/{id}`, `/api/v1/domains/{domain}/timeline`, `/api/v1/changes/{id}`, `wss://…/api/v1/live`,
33 +`/api/v1/feed.rss`.
added apps/api/package.json +35 −0
@@ -0,0 +1,35 @@
1 +{
2 + "name": "@websensor/api",
3 + "version": "0.1.0",
4 + "private": true,
5 + "type": "module",
6 + "scripts": {
7 + "dev": "tsx watch src/server.ts",
8 + "start": "tsx src/server.ts",
9 + "typecheck": "tsc -p tsconfig.json --noEmit",
10 + "test": "vitest run --passWithNoTests"
11 + },
12 + "dependencies": {
13 + "@fastify/cors": "^11.0.0",
14 + "@fastify/rate-limit": "^10.3.0",
15 + "@fastify/reply-from": "^12.6.5",
16 + "@fastify/websocket": "^11.2.0",
17 + "@websensor/core": "workspace:*",
18 + "@websensor/db": "workspace:*",
19 + "@websensor/store": "workspace:*",
20 + "fastify": "^5.4.0",
21 + "ioredis": "^5.6.0",
22 + "pino": "^9.7.0",
23 + "pino-pretty": "^13.0.0",
24 + "prom-client": "^15.1.0",
25 + "tsx": "^4.20.0",
26 + "ws": "^8.21.3",
27 + "zod": "^4.0.0"
28 + },
29 + "devDependencies": {
30 + "@types/node": "^24.0.0",
31 + "@types/ws": "^8.18.1",
32 + "typescript": "^5.9.3",
33 + "vitest": "^3.2.0"
34 + }
35 +}
added apps/api/src/config.ts +14 −0
@@ -0,0 +1,14 @@
1 +const env = process.env;
2 +
3 +export const config = {
4 + env: env.NODE_ENV ?? "development",
5 + logLevel: env.LOG_LEVEL ?? "info",
6 + port: Number(env.API_PORT ?? 8260),
7 + host: env.API_HOST ?? "127.0.0.1",
8 + redisUrl: env.REDIS_URL ?? "redis://127.0.0.1:6379",
9 + webUrl: env.WEB_URL ?? "http://127.0.0.1:8261",
10 + publicBaseUrl: env.PUBLIC_BASE_URL ?? "http://localhost:8260",
11 + canonicalHost: env.CANONICAL_HOST ?? "www.websensor.io",
12 + redirectApexToWww: (env.WS_REDIRECT_APEX ?? "1") !== "0",
13 + version: "0.1.0",
14 +};
added apps/api/src/live.ts +129 −0
@@ -0,0 +1,129 @@
1 +import type { FastifyInstance } from "fastify";
2 +import type { WebSocket } from "ws";
3 +import Redis from "ioredis";
4 +import { FEED_CHANNELS } from "@websensor/core";
5 +import { db, sql } from "@websensor/db";
6 +import { config } from "./config";
7 +
8 +/**
9 + * WebSocket gateway `/api/v1/live`. One Redis subscriber fans out to every client; clients
10 + * pick channels: events:global · events:breaking · events:<ai|cyber|finance|health|government|
11 + * science|products|infrastructure> · entity:<id> · source:<id> · watchlist:<id>.
12 + * Protocol (JSON): client → {"subscribe":[…]} | {"unsubscribe":[…]} | {"ping":1}
13 + * server → {"type":"hello"} | {"type":"event", "channels":[…], "event":{…}} | {"type":"pong"}
14 + */
15 +interface Client {
16 + ws: WebSocket;
17 + channels: Set<string>;
18 + watchlists: Map<string, { entities: Set<string>; sources: Set<string>; keywords: string[]; categories: Set<string> }>;
19 +}
20 +
21 +const clients = new Set<Client>();
22 +let sub: Redis | null = null;
23 +let published = 0;
24 +
25 +export function liveStats(): { clients: number; published: number } {
26 + return { clients: clients.size, published };
27 +}
28 +
29 +export async function registerLive(app: FastifyInstance): Promise<void> {
30 + sub = new Redis(config.redisUrl, { maxRetriesPerRequest: 3 });
31 + sub.on("error", (e) => app.log.warn({ err: e.message }, "redis sub error"));
32 + await sub.subscribe("ws:live");
33 + sub.on("message", (_ch, msg) => {
34 + let ev: Record<string, unknown>;
35 + try {
36 + ev = JSON.parse(msg) as Record<string, unknown>;
37 + } catch {
38 + return;
39 + }
40 + published++;
41 + const chans = channelsFor(ev);
42 + for (const c of clients) {
43 + const hit = [...chans].filter((ch) => c.channels.has(ch));
44 + for (const [wid, w] of c.watchlists) if (matchesWatchlist(ev, w)) hit.push(`watchlist:${wid}`);
45 + if (!hit.length) continue;
46 + if (c.ws.readyState === c.ws.OPEN) c.ws.send(JSON.stringify({ type: "event", channels: hit, event: ev }));
47 + }
48 + });
49 +
50 + app.get("/api/v1/live", { websocket: true }, (socket) => {
51 + const client: Client = { ws: socket, channels: new Set(["events:global"]), watchlists: new Map() };
52 + clients.add(client);
53 + socket.send(JSON.stringify({ type: "hello", channels: [...client.channels], serverTime: new Date().toISOString() }));
54 + socket.on("message", async (raw: Buffer | string) => {
55 + let msg: { subscribe?: string[]; unsubscribe?: string[]; ping?: number };
56 + try {
57 + msg = JSON.parse(raw.toString()) as typeof msg;
58 + } catch {
59 + return;
60 + }
61 + if (msg.ping) socket.send(JSON.stringify({ type: "pong", t: Date.now() }));
62 + for (const ch of msg.subscribe ?? []) {
63 + if (typeof ch !== "string" || ch.length > 120 || client.channels.size > 64) continue;
64 + if (ch.startsWith("watchlist:")) await loadWatchlist(client, ch.slice(10));
65 + else client.channels.add(ch);
66 + }
67 + for (const ch of msg.unsubscribe ?? []) {
68 + client.channels.delete(ch);
69 + if (ch.startsWith("watchlist:")) client.watchlists.delete(ch.slice(10));
70 + }
71 + socket.send(JSON.stringify({ type: "subscribed", channels: [...client.channels, ...[...client.watchlists.keys()].map((w) => `watchlist:${w}`)] }));
72 + });
73 + const hb = setInterval(() => {
74 + if (socket.readyState === socket.OPEN) socket.send(JSON.stringify({ type: "heartbeat", t: Date.now() }));
75 + }, 25_000);
76 + socket.on("close", () => {
77 + clearInterval(hb);
78 + clients.delete(client);
79 + });
80 + socket.on("error", () => {
81 + clearInterval(hb);
82 + clients.delete(client);
83 + });
84 + });
85 +}
86 +
87 +export function channelsFor(ev: Record<string, unknown>): Set<string> {
88 + const out = new Set<string>(["events:global"]);
89 + const importance = Number(ev.importance ?? 0);
90 + if (importance >= 80) out.add("events:breaking");
91 + if (ev.silent) out.add("events:silent");
92 + const cats = (ev.categories as string[] | undefined) ?? [];
93 + for (const [ch, list] of Object.entries(FEED_CHANNELS)) if (list.some((c) => cats.includes(c)) || cats.includes(ch)) out.add(`events:${ch}`);
94 + const src = ev.source as { id?: string } | undefined;
95 + if (src?.id) out.add(`source:${src.id}`);
96 + for (const e of (ev.entities as { id: string }[] | undefined) ?? []) out.add(`entity:${e.id}`);
97 + out.add(`type:${String(ev.type)}`);
98 + return out;
99 +}
100 +
101 +async function loadWatchlist(client: Client, id: string): Promise<void> {
102 + const rows = await db.execute<{ kind: string; value: string }>(sql`select kind, value from watchlist_items where watchlist_id = ${id}`);
103 + const w = { entities: new Set<string>(), sources: new Set<string>(), keywords: [] as string[], categories: new Set<string>() };
104 + for (const r of rows.rows) {
105 + if (r.kind === "entity") w.entities.add(r.value);
106 + else if (r.kind === "source") w.sources.add(r.value);
107 + else if (r.kind === "keyword") w.keywords.push(r.value.toLowerCase());
108 + else if (r.kind === "category") w.categories.add(r.value);
109 + }
110 + client.watchlists.set(id, w);
111 +}
112 +
113 +function matchesWatchlist(ev: Record<string, unknown>, w: { entities: Set<string>; sources: Set<string>; keywords: string[]; categories: Set<string> }): boolean {
114 + const src = ev.source as { id?: string } | undefined;
115 + if (src?.id && w.sources.has(src.id)) return true;
116 + for (const e of (ev.entities as { id: string }[] | undefined) ?? []) if (w.entities.has(e.id)) return true;
117 + for (const c of (ev.categories as string[] | undefined) ?? []) if (w.categories.has(c)) return true;
118 + if (w.keywords.length) {
119 + const hay = `${String(ev.title)} ${String(ev.summary)}`.toLowerCase();
120 + if (w.keywords.some((k) => hay.includes(k))) return true;
121 + }
122 + return false;
123 +}
124 +
125 +export async function closeLive(): Promise<void> {
126 + for (const c of clients) c.ws.close(1001, "server shutdown");
127 + clients.clear();
128 + if (sub) await sub.quit().catch(() => undefined);
129 +}
added apps/api/src/queries.ts +146 −0
@@ -0,0 +1,146 @@
1 +import { db, sql, textArray } from "@websensor/db";
2 +
3 +/** Read-model helpers shared by REST routes. All return plain JSON-ready objects. */
4 +
5 +export interface EventFilters {
6 + after?: string;
7 + before?: string;
8 + category?: string;
9 + entity?: string;
10 + source?: string;
11 + domain?: string;
12 + sensor?: string;
13 + cluster?: string;
14 + importance_min?: number;
15 + confidence_min?: number;
16 + event_type?: string;
17 + silent_change?: boolean;
18 + q?: string;
19 + limit: number;
20 + cursor?: string;
21 + order?: "recent" | "importance";
22 +}
23 +
24 +export const EVENT_SELECT = sql`
25 + e.id, e.slug, e.event_type, e.title, e.summary, e.why_it_matters, e.importance, e.confidence, e.novelty, e.categories, e.keywords,
26 + e.silent_change, e.evidence_label, e.url, e.detected_at, e.published_at, e.observed_from, e.processed_at, e.published_to_feed_at,
27 + e.detection_latency_ms, e.processing_latency_ms, e.cluster_id, e.sensor_id, e.source_id, e.change_id, e.old_snapshot_id, e.new_snapshot_id,
28 + e.importance_components, e.processing_version,
29 + json_build_object('id', s.id, 'name', s.name, 'domain', s.domain, 'tier', s.tier, 'categories', s.categories) as source,
30 + json_build_object('id', sen.id, 'name', sen.name, 'type', sen.type, 'connector', sen.connector, 'tier', sen.tier) as sensor,
31 + coalesce((select json_agg(json_build_object('id', en.id, 'name', en.name, 'type', en.type, 'role', ee.role) order by ee.role, en.name)
32 + from event_entities ee join entities en on en.id = ee.entity_id where ee.event_id = e.id), '[]'::json) as entities,
33 + (select event_count from event_clusters c where c.id = e.cluster_id) as cluster_size`;
34 +
35 +export async function listEvents(f: EventFilters): Promise<{ items: Record<string, unknown>[]; nextCursor: string | null }> {
36 + const conds = [sql`true`];
37 + if (f.after) conds.push(sql`e.detected_at > ${new Date(f.after)}`);
38 + if (f.before) conds.push(sql`e.detected_at < ${new Date(f.before)}`);
39 + if (f.category) conds.push(sql`${f.category} = any(e.categories)`);
40 + if (f.source) conds.push(sql`e.source_id = ${f.source}`);
41 + if (f.sensor) conds.push(sql`e.sensor_id = ${f.sensor}`);
42 + if (f.cluster) conds.push(sql`e.cluster_id = ${f.cluster}`);
43 + if (f.domain) conds.push(sql`s.domain = ${f.domain}`);
44 + if (f.entity) conds.push(sql`exists (select 1 from event_entities x where x.event_id = e.id and x.entity_id = ${f.entity})`);
45 + if (f.importance_min !== undefined) conds.push(sql`e.importance >= ${f.importance_min}`);
46 + if (f.confidence_min !== undefined) conds.push(sql`e.confidence >= ${f.confidence_min}`);
47 + if (f.event_type) conds.push(sql`e.event_type = any(${textArray(f.event_type.split(","))})`);
48 + if (f.silent_change !== undefined) conds.push(sql`e.silent_change = ${f.silent_change}`);
49 + if (f.q) conds.push(sql`e.search @@ websearch_to_tsquery('english', ${f.q})`);
50 + if (f.cursor) {
51 + const [ts, id] = decodeCursor(f.cursor);
52 + if (f.order === "importance") conds.push(sql`(e.importance, e.id) < (${Number(ts)}, ${id})`);
53 + else conds.push(sql`(e.detected_at, e.id) < (${new Date(Number(ts))}, ${id})`);
54 + }
55 + const order = f.order === "importance" ? sql`e.importance desc, e.id desc` : sql`e.detected_at desc, e.id desc`;
56 + const rows = await db.execute<Record<string, unknown>>(sql`select ${EVENT_SELECT} from events e join sources s on s.id = e.source_id join sensors sen on sen.id = e.sensor_id where ${sql.join(conds, sql` and `)} order by ${order} limit ${f.limit + 1}`);
57 + const items = rows.rows.slice(0, f.limit);
58 + const last = items[items.length - 1];
59 + const nextCursor = rows.rows.length > f.limit && last ? encodeCursor(f.order === "importance" ? String(last.importance) : String(new Date(last.detected_at as string).getTime()), String(last.id)) : null;
60 + return { items, nextCursor };
61 +}
62 +
63 +export async function getEvent(idOrSlug: string): Promise<Record<string, unknown> | null> {
64 + const rows = await db.execute<Record<string, unknown>>(sql`select ${EVENT_SELECT}, e.interpretation from events e join sources s on s.id = e.source_id join sensors sen on sen.id = e.sensor_id where e.id = ${idOrSlug} or e.slug = ${idOrSlug} limit 1`);
65 + return rows.rows[0] ?? null;
66 +}
67 +
68 +export async function relatedEvents(ev: Record<string, unknown>, limit = 8): Promise<Record<string, unknown>[]> {
69 + const rows = await db.execute<Record<string, unknown>>(sql`
70 + select ${EVENT_SELECT} from events e join sources s on s.id = e.source_id join sensors sen on sen.id = e.sensor_id
71 + where e.id <> ${String(ev.id)} and (e.cluster_id = ${String(ev.cluster_id ?? "")} or e.source_id = ${String(ev.source_id)} or exists (select 1 from event_entities a join event_entities b on a.entity_id = b.entity_id where a.event_id = e.id and b.event_id = ${String(ev.id)}))
72 + order by (e.cluster_id = ${String(ev.cluster_id ?? "")}) desc, e.detected_at desc limit ${limit}`);
73 + return rows.rows;
74 +}
75 +
76 +export async function stats(): Promise<Record<string, unknown>> {
77 + const [r] = (
78 + await db.execute<Record<string, unknown>>(sql`
79 + select
80 + (select count(*) from sources where enabled) as sources,
81 + (select count(*) from sensors where enabled) as sensors,
82 + (select count(*) from entities) as entities,
83 + (select count(*) from snapshots) as snapshots,
84 + (select count(*) from events) as events_total,
85 + (select count(*) from events where detected_at >= now() - interval '24 hours') as events_24h,
86 + (select count(*) from events where detected_at >= now() - interval '24 hours' and silent_change) as silent_24h,
87 + (select count(*) from events where detected_at >= now() - interval '24 hours' and importance >= 80) as breaking_24h,
88 + (select count(*) from changes where detected_at >= now() - interval '24 hours') as changes_24h,
89 + (select coalesce(sum(checks),0) from metrics_daily where day = current_date) as checks_today,
90 + (select coalesce(sum(not_modified),0) from metrics_daily where day = current_date) as not_modified_today,
91 + (select coalesce(sum(bytes),0) from metrics_daily where day = current_date) as bytes_today,
92 + (select count(*) from sensor_runs where started_at >= now() - interval '1 hour') as checks_last_hour,
93 + (select count(*) from sensors where health = 'UP' and enabled) as sensors_up,
94 + (select count(*) from sensors where health in ('DEGRADED','ERROR','RATE_LIMITED') and enabled) as sensors_degraded,
95 + (select max(started_at) from sensor_runs) as last_check_at,
96 + (select max(detected_at) from events) as last_event_at,
97 + (select percentile_cont(0.5) within group (order by processing_latency_ms) from events where detected_at >= now() - interval '24 hours') as p50_processing_ms,
98 + (select percentile_cont(0.5) within group (order by detection_latency_ms) from events where detected_at >= now() - interval '24 hours' and detection_latency_ms is not null and detection_latency_ms < 86400000) as p50_detection_ms`)
99 + ).rows;
100 + return Object.fromEntries(Object.entries(r ?? {}).map(([k, v]) => [k, typeof v === "string" && /^\d+$/.test(v) ? Number(v) : v]));
101 +}
102 +
103 +export async function trending(hours = 24, limit = 12): Promise<Record<string, unknown>[]> {
104 + const rows = await db.execute<Record<string, unknown>>(sql`
105 + with cur as (
106 + select ee.entity_id, count(*) as n, sum(e.importance) as imp, count(distinct e.source_id) as sources, sum(case when e.silent_change then 1 else 0 end) as silent, max(e.importance) as max_imp
107 + from events e join event_entities ee on ee.event_id = e.id where e.detected_at >= now() - make_interval(hours => ${hours}) group by ee.entity_id),
108 + prev as (
109 + select ee.entity_id, count(*) as n from events e join event_entities ee on ee.event_id = e.id
110 + where e.detected_at >= now() - make_interval(hours => ${hours * 2}) and e.detected_at < now() - make_interval(hours => ${hours}) group by ee.entity_id)
111 + select en.id, en.name, en.type, en.domain, cur.n::int as events, cur.imp::float as importance_sum, cur.sources::int as sources, cur.silent::int as silent, cur.max_imp::float as max_importance, coalesce(prev.n,0)::int as prev_events,
112 + round((18*(ln(1+cur.n)/ln(2)) + 0.35*(cur.imp/greatest(1,cur.n)) + 10*(ln(1+cur.sources)/ln(2)) + 12*least(2, case when coalesce(prev.n,0)=0 then 2 else cur.n::float/prev.n end) + 5*least(3,cur.silent))::numeric, 1)::float as score
113 + from cur join entities en on en.id = cur.entity_id left join prev on prev.entity_id = cur.entity_id
114 + order by score desc limit ${limit}`);
115 + return rows.rows;
116 +}
117 +
118 +export async function sourceActivity(sourceId: string): Promise<Record<string, unknown>> {
119 + const [r] = (
120 + await db.execute<Record<string, unknown>>(sql`
121 + select
122 + (select count(*) from changes c join sensors s on s.id = c.sensor_id where s.source_id = ${sourceId} and c.detected_at >= now() - interval '2 hours')::int as changes_2h,
123 + (select count(*) from changes c join sensors s on s.id = c.sensor_id where s.source_id = ${sourceId} and c.detected_at >= now() - interval '14 days')::int as changes_14d,
124 + (select count(*) from events where source_id = ${sourceId} and detected_at >= now() - interval '24 hours')::int as events_24h,
125 + (select count(*) from events where source_id = ${sourceId} and detected_at >= now() - interval '14 days')::int as events_14d`)
126 + ).rows;
127 + const c2h = Number(r?.changes_2h ?? 0);
128 + const baselinePerHour = Number(r?.changes_14d ?? 0) / (14 * 24);
129 + const currentPerHour = c2h / 2;
130 + let anomaly = 0;
131 + if (baselinePerHour <= 0) anomaly = currentPerHour > 2 ? 70 : currentPerHour > 0 ? 40 : 0;
132 + else {
133 + const ratio = currentPerHour / baselinePerHour;
134 + anomaly = ratio <= 1 ? ratio * 30 : Math.min(100, 30 + 25 * Math.log2(ratio));
135 + }
136 + return { ...r, baseline_changes_per_day: Math.round(baselinePerHour * 24 * 10) / 10, activity_score: Math.round(anomaly * 10) / 10 };
137 +}
138 +
139 +export function encodeCursor(a: string, b: string): string {
140 + return Buffer.from(`${a}|${b}`).toString("base64url");
141 +}
142 +export function decodeCursor(c: string): [string, string] {
143 + const s = Buffer.from(c, "base64url").toString("utf8");
144 + const i = s.indexOf("|");
145 + return [s.slice(0, i), s.slice(i + 1)];
146 +}
added apps/api/src/routes.ts +349 −0
@@ -0,0 +1,349 @@
1 +import type { FastifyInstance } from "fastify";
2 +import { z } from "zod";
3 +import { diffText, EVENT_TYPES, FEED_CHANNELS, newId } from "@websensor/core";
4 +import { db, sql, textArray } from "@websensor/db";
5 +import { getBlobStore } from "@websensor/store";
6 +import { config } from "./config";
7 +import { liveStats } from "./live";
8 +import { getEvent, listEvents, relatedEvents, sourceActivity, stats, trending, EVENT_SELECT } from "./queries";
9 +
10 +const eventsQuery = z.object({
11 + after: z.string().optional(),
12 + before: z.string().optional(),
13 + category: z.string().optional(),
14 + entity: z.string().optional(),
15 + source: z.string().optional(),
16 + domain: z.string().optional(),
17 + sensor: z.string().optional(),
18 + cluster: z.string().optional(),
19 + importance_min: z.coerce.number().min(0).max(100).optional(),
20 + confidence_min: z.coerce.number().min(0).max(100).optional(),
21 + event_type: z.string().optional(),
22 + silent_change: z
23 + .enum(["true", "false"])
24 + .transform((v) => v === "true")
25 + .optional(),
26 + q: z.string().max(200).optional(),
27 + limit: z.coerce.number().int().min(1).max(200).default(50),
28 + cursor: z.string().optional(),
29 + order: z.enum(["recent", "importance"]).default("recent"),
30 +});
31 +
32 +function ownerToken(headers: Record<string, unknown>): string | null {
33 + const t = headers["x-websensor-owner"];
34 + return typeof t === "string" && /^[A-Za-z0-9_-]{16,80}$/.test(t) ? t : null;
35 +}
36 +
37 +export async function registerRoutes(app: FastifyInstance): Promise<void> {
38 + // ---- Health ---------------------------------------------------------------------------
39 + app.get("/api/health", async () => ({ status: "ok", service: "api", version: config.version, time: new Date().toISOString() }));
40 + app.get("/api/ready", async (_req, reply) => {
41 + const checks: Record<string, boolean | number> = {};
42 + try {
43 + await db.execute(sql`select 1`);
44 + checks.database = true;
45 + } catch {
46 + checks.database = false;
47 + }
48 + const engine = await db.execute<{ last: Date | null }>(sql`select max(started_at) as last from sensor_runs where started_at >= now() - interval '15 minutes'`);
49 + checks.engine_recent = Boolean(engine.rows[0]?.last);
50 + checks.ws_clients = liveStats().clients;
51 + return reply.status(checks.database ? 200 : 503).send({ status: checks.database ? "ready" : "degraded", checks });
52 + });
53 +
54 + // ---- Events ---------------------------------------------------------------------------
55 + app.get("/api/v1/events", async (req) => {
56 + const q = eventsQuery.parse(req.query);
57 + return listEvents(q);
58 + });
59 + app.get<{ Params: { id: string } }>("/api/v1/events/:id", async (req, reply) => {
60 + const ev = await getEvent(req.params.id);
61 + if (!ev) return reply.status(404).send({ error: "not_found" });
62 + const [related, cluster, change, interp] = await Promise.all([
63 + relatedEvents(ev),
64 + ev.cluster_id ? db.execute<Record<string, unknown>>(sql`select * from event_clusters where id = ${String(ev.cluster_id)}`).then((r) => r.rows[0] ?? null) : null,
65 + ev.change_id ? db.execute<Record<string, unknown>>(sql`select id, kind, diff, signal, noise_ratio, magnitude, heuristic, detected_at, old_snapshot_id, new_snapshot_id from changes where id = ${String(ev.change_id)}`).then((r) => r.rows[0] ?? null) : null,
66 + db.execute<Record<string, unknown>>(sql`select version, model, created_at from interpretations where event_id = ${String(ev.id)} order by version`).then((r) => r.rows),
67 + ]);
68 + const snaps = await db.execute<Record<string, unknown>>(sql`select id, url, captured_at, http_status, content_type, content_length, content_hash, canonical_hash, etag, last_modified, title, mode, fetch_duration_ms, extraction_confidence from snapshots where id in (${String(ev.old_snapshot_id ?? "")}, ${String(ev.new_snapshot_id ?? "")})`);
69 + const sourceRel = await db.execute<Record<string, unknown>>(sql`select health, success_rate, avg_latency_ms, total_runs, raw_changes, meaningful_changes, last_check_at from (select s.health, ch.success_rate, s.avg_latency_ms, s.total_runs, s.raw_changes, s.meaningful_changes, s.last_check_at from sensors s left join connector_health ch on ch.connector = s.connector where s.id = ${String(ev.sensor_id)}) x`);
70 + return { event: ev, related, cluster, change, interpretations: interp, snapshots: snaps.rows, sensor_reliability: sourceRel.rows[0] ?? null };
71 + });
72 +
73 + // ---- Changes / snapshots / diffs -------------------------------------------------------
74 + app.get<{ Params: { id: string } }>("/api/v1/changes/:id", async (req, reply) => {
75 + const r = await db.execute<Record<string, unknown>>(sql`select c.*, s.url as sensor_url, s.name as sensor_name, s.source_id from changes c join sensors s on s.id = c.sensor_id where c.id = ${req.params.id}`);
76 + const c = r.rows[0];
77 + if (!c) return reply.status(404).send({ error: "not_found" });
78 + let unified: string | null = null;
79 + if (c.diff_storage_key) unified = await getBlobStore().getText(String(c.diff_storage_key)).catch(() => null);
80 + return { change: c, unified };
81 + });
82 + app.get<{ Params: { id: string }; Querystring: { raw?: string } }>("/api/v1/snapshots/:id", async (req, reply) => {
83 + const r = await db.execute<Record<string, unknown>>(sql`select * from snapshots where id = ${req.params.id}`);
84 + const s = r.rows[0];
85 + if (!s) return reply.status(404).send({ error: "not_found" });
86 + const store = getBlobStore();
87 + if (req.query.raw === "1" && s.storage_key) {
88 + const buf = await store.get(String(s.storage_key));
89 + reply.header("content-type", String(s.content_type ?? "text/plain") + (String(s.content_type ?? "").includes("charset") ? "" : "; charset=utf-8"));
90 + reply.header("x-content-type-options", "nosniff");
91 + reply.header("content-security-policy", "default-src 'none'; style-src 'unsafe-inline'; img-src data:");
92 + return reply.send(buf);
93 + }
94 + const canonical = s.canonical_storage_key ? await store.getText(String(s.canonical_storage_key)).catch(() => null) : null;
95 + return { snapshot: s, canonical: canonical ? JSON.parse(canonical) : null };
96 + });
97 + app.get<{ Querystring: { a: string; b: string } }>("/api/v1/snapshots/compare", async (req, reply) => {
98 + const { a, b } = req.query;
99 + if (!a || !b) return reply.status(400).send({ error: "a and b required" });
100 + const rows = await db.execute<Record<string, unknown>>(sql`select id, captured_at, canonical_storage_key, mode, url, sensor_id from snapshots where id in (${a}, ${b})`);
101 + const sa = rows.rows.find((r) => r.id === a);
102 + const sb = rows.rows.find((r) => r.id === b);
103 + if (!sa || !sb) return reply.status(404).send({ error: "not_found" });
104 + const store = getBlobStore();
105 + const [ca, cb] = await Promise.all([store.getText(String(sa.canonical_storage_key)), store.getText(String(sb.canonical_storage_key))]);
106 + const ta = renderCanonical(JSON.parse(ca));
107 + const tb = renderCanonical(JSON.parse(cb));
108 + const d = diffText(ta, tb, `${a}@${String(sa.captured_at)}`, `${b}@${String(sb.captured_at)}`);
109 + return { a: sa, b: sb, before: ta, after: tb, diff: { unified: d.unified, stats: d.stats, added: d.added, removed: d.removed, modified: d.modified } };
110 + });
111 +
112 + // ---- Sources & sensors ------------------------------------------------------------------
113 + app.get<{ Querystring: { category?: string; q?: string; limit?: string } }>("/api/v1/sources", async (req) => {
114 + const cat = req.query.category;
115 + const q = req.query.q;
116 + const rows = await db.execute<Record<string, unknown>>(sql`
117 + select s.*, (select count(*) from sensors x where x.source_id = s.id and x.enabled)::int as sensor_count,
118 + (select count(*) from events e where e.source_id = s.id)::int as event_count,
119 + (select count(*) from events e where e.source_id = s.id and e.detected_at >= now() - interval '24 hours')::int as events_24h,
120 + (select max(detected_at) from events e where e.source_id = s.id) as last_event_at,
121 + (select max(last_check_at) from sensors x where x.source_id = s.id) as last_check_at,
122 + (select count(*) from sensors x where x.source_id = s.id and x.enabled and x.health <> 'UP')::int as sensors_degraded
123 + from sources s where s.enabled ${cat ? sql`and ${cat} = any(s.categories)` : sql``} ${q ? sql`and (s.name ilike ${"%" + q + "%"} or s.domain ilike ${"%" + q + "%"})` : sql``}
124 + order by s.tier asc, events_24h desc, s.name asc limit ${Math.min(500, Number(req.query.limit ?? 300))}`);
125 + return { items: rows.rows };
126 + });
127 + app.get<{ Params: { id: string } }>("/api/v1/sources/:id", async (req, reply) => {
128 + const s = (await db.execute<Record<string, unknown>>(sql`select * from sources where id = ${req.params.id} or domain = ${req.params.id} limit 1`)).rows[0];
129 + if (!s) return reply.status(404).send({ error: "not_found" });
130 + const [sensors, ents, activity, candidates] = await Promise.all([
131 + db.execute<Record<string, unknown>>(sql`select id, name, url, type, connector, tier, health, enabled, next_check_at, last_check_at, last_change_at, last_event_at, last_status, last_error, consecutive_errors, total_runs, total_not_modified, raw_changes, meaningful_changes, avg_latency_ms, base_interval_seconds, etag is not null as has_etag, last_modified is not null as has_last_modified from sensors where source_id = ${String(s.id)} order by tier, name`).then((r) => r.rows),
132 + db.execute<Record<string, unknown>>(sql`select en.id, en.name, en.type, en.importance, en.event_count from source_entities se join entities en on en.id = se.entity_id where se.source_id = ${String(s.id)} order by en.type, en.name`).then((r) => r.rows),
133 + sourceActivity(String(s.id)),
134 + db.execute<Record<string, unknown>>(sql`select url, kind, evidence, score, status, found_at from discovery_candidates where source_id = ${String(s.id)} order by (score->>'value')::float desc nulls last limit 50`).then((r) => r.rows),
135 + ]);
136 + return { source: s, sensors, entities: ents, activity, discovery: candidates };
137 + });
138 + app.get<{ Params: { id: string } }>("/api/v1/sensors/:id", async (req, reply) => {
139 + const s = (await db.execute<Record<string, unknown>>(sql`select s.*, so.name as source_name, so.domain from sensors s join sources so on so.id = s.source_id where s.id = ${req.params.id}`)).rows[0];
140 + if (!s) return reply.status(404).send({ error: "not_found" });
141 + const [runs, snaps, changes] = await Promise.all([
142 + db.execute<Record<string, unknown>>(sql`select id, started_at, finished_at, http_status, outcome, error, duration_ms, bytes, fetch_method, snapshot_id from sensor_runs where sensor_id = ${String(s.id)} order by started_at desc limit 50`).then((r) => r.rows),
143 + db.execute<Record<string, unknown>>(sql`select id, captured_at, http_status, content_type, content_length, canonical_hash, title, mode, extraction_confidence from snapshots where sensor_id = ${String(s.id)} order by captured_at desc limit 50`).then((r) => r.rows),
144 + db.execute<Record<string, unknown>>(sql`select id, detected_at, kind, signal, noise_ratio, magnitude, meaningful, event_id, old_snapshot_id, new_snapshot_id, heuristic->>'eventType' as heuristic_type from changes where sensor_id = ${String(s.id)} order by detected_at desc limit 50`).then((r) => r.rows),
145 + ]);
146 + const { etag: _e, last_modified: _lm, state: _st, ...pub } = s as Record<string, unknown>;
147 + return { sensor: pub, runs, snapshots: snaps, changes };
148 + });
149 +
150 + // ---- Entities ----------------------------------------------------------------------------
151 + app.get<{ Querystring: { type?: string; q?: string; limit?: string } }>("/api/v1/entities", async (req) => {
152 + const rows = await db.execute<Record<string, unknown>>(sql`
153 + select en.*, (select count(*) from events e join event_entities ee on ee.event_id = e.id where ee.entity_id = en.id and e.detected_at >= now() - interval '24 hours')::int as events_24h
154 + from entities en where true ${req.query.type ? sql`and en.type = ${req.query.type}` : sql``} ${req.query.q ? sql`and (en.search @@ plainto_tsquery('simple', ${req.query.q}) or en.name ilike ${"%" + req.query.q + "%"})` : sql``}
155 + order by en.event_count desc, en.importance desc, en.name limit ${Math.min(500, Number(req.query.limit ?? 200))}`);
156 + return { items: rows.rows };
157 + });
158 + app.get<{ Params: { id: string } }>("/api/v1/entities/:id", async (req, reply) => {
159 + const e = (await db.execute<Record<string, unknown>>(sql`select * from entities where id = ${req.params.id} or id = ${"org_" + req.params.id} limit 1`)).rows[0];
160 + if (!e) return reply.status(404).send({ error: "not_found" });
161 + const id = String(e.id);
162 + const [children, relations, sources, aliases, recent, byType] = await Promise.all([
163 + db.execute<Record<string, unknown>>(sql`select id, name, type, importance, event_count, last_event_at from entities where parent_id = ${id} order by event_count desc, name`).then((r) => r.rows),
164 + db.execute<Record<string, unknown>>(sql`select r.relation, r.from_id, r.to_id, f.name as from_name, t.name as to_name, t.type as to_type from entity_relations r join entities f on f.id = r.from_id join entities t on t.id = r.to_id where r.from_id = ${id} or r.to_id = ${id} limit 100`).then((r) => r.rows),
165 + db.execute<Record<string, unknown>>(sql`select s.id, s.name, s.domain, s.tier from source_entities se join sources s on s.id = se.source_id where se.entity_id = ${id}`).then((r) => r.rows),
166 + db.execute<{ alias: string }>(sql`select alias from entity_aliases where entity_id = ${id} order by alias`).then((r) => r.rows.map((x) => x.alias)),
167 + listEvents({ entity: id, limit: 30 }),
168 + db.execute<Record<string, unknown>>(sql`select e.event_type, count(*)::int as n from events e join event_entities ee on ee.event_id = e.id where ee.entity_id = ${id} group by 1 order by 2 desc`).then((r) => r.rows),
169 + ]);
170 + return { entity: e, children, relations, sources, aliases, recent: recent.items, by_type: byType };
171 + });
172 + app.get<{ Params: { id: string }; Querystring: { limit?: string; cursor?: string } }>("/api/v1/entities/:id/timeline", async (req) => {
173 + const id = req.params.id.startsWith("org_") || req.params.id.startsWith("prd_") ? req.params.id : `org_${req.params.id}`;
174 + return listEvents({ entity: id, limit: Math.min(200, Number(req.query.limit ?? 100)), cursor: req.query.cursor });
175 + });
176 +
177 + // ---- Domains / URLs ----------------------------------------------------------------------
178 + app.get<{ Params: { domain: string }; Querystring: { limit?: string; cursor?: string } }>("/api/v1/domains/:domain/timeline", async (req) => {
179 + const d = req.params.domain.toLowerCase();
180 + const src = (await db.execute<{ id: string }>(sql`select id from sources where domain = ${d} or domain = ${"www." + d} or ${d} = 'www.' || domain limit 1`)).rows[0];
181 + const urls = await db.execute<Record<string, unknown>>(sql`select url, status, first_seen_at, last_seen_at, snapshot_count, change_count, sensor_id from urls where domain = ${d} or domain like ${"%." + d} order by change_count desc, last_seen_at desc limit 200`);
182 + const ev = src ? await listEvents({ source: src.id, limit: Math.min(200, Number(req.query.limit ?? 100)), cursor: req.query.cursor }) : { items: [], nextCursor: null };
183 + return { domain: d, source_id: src?.id ?? null, urls: urls.rows, events: ev.items, nextCursor: ev.nextCursor };
184 + });
185 + app.get<{ Querystring: { url: string } }>("/api/v1/urls/history", async (req, reply) => {
186 + if (!req.query.url) return reply.status(400).send({ error: "url required" });
187 + const u = (await db.execute<Record<string, unknown>>(sql`select * from urls where url = ${req.query.url}`)).rows[0];
188 + const history = await db.execute<Record<string, unknown>>(sql`
189 + select h.id, h.at, h.kind, h.snapshot_id, h.change_id, h.event_id, h.note, e.title as event_title, e.importance, e.event_type, e.slug as event_slug, c.kind as change_kind, c.signal
190 + from url_history h left join events e on e.id = h.event_id left join changes c on c.id = h.change_id where h.url = ${req.query.url} order by h.at desc limit 300`);
191 + const snaps = await db.execute<Record<string, unknown>>(sql`select id, captured_at, http_status, content_length, canonical_hash, title from snapshots where url = ${req.query.url} or sensor_id = (select sensor_id from urls where url = ${req.query.url}) order by captured_at desc limit 200`);
192 + return { url: u ?? { url: req.query.url }, history: history.rows, snapshots: snaps.rows };
193 + });
194 +
195 + // ---- Explore / trending / stats ------------------------------------------------------------
196 + app.get("/api/v1/stats", async () => stats());
197 + app.get<{ Querystring: { hours?: string; limit?: string } }>("/api/v1/trending", async (req) => ({ items: await trending(Math.min(168, Number(req.query.hours ?? 24)), Math.min(50, Number(req.query.limit ?? 12))) }));
198 + app.get("/api/v1/explore", async () => {
199 + const [mostActive, biggest, silent, clusters, unusual, byType, byCategory] = await Promise.all([
200 + db.execute<Record<string, unknown>>(sql`select s.id, s.name, s.domain, count(*)::int as events_24h, max(e.importance)::float as max_importance from events e join sources s on s.id = e.source_id where e.detected_at >= now() - interval '24 hours' group by s.id, s.name, s.domain order by events_24h desc limit 10`).then((r) => r.rows),
201 + listEvents({ limit: 10, order: "importance", after: new Date(Date.now() - 48 * 3600e3).toISOString() }).then((r) => r.items),
202 + listEvents({ limit: 10, silent_change: true }).then((r) => r.items),
203 + db.execute<Record<string, unknown>>(sql`select c.*, (select json_build_object('id', s.id, 'name', s.name, 'domain', s.domain) from events e join sources s on s.id = e.source_id where e.id = c.primary_event_id) as source from event_clusters c where c.event_count >= 2 and c.last_at >= now() - interval '48 hours' order by c.max_importance desc, c.event_count desc limit 10`).then((r) => r.rows),
204 + db.execute<Record<string, unknown>>(sql`
205 + with cur as (select s.source_id, count(*) as n from changes c join sensors s on s.id = c.sensor_id where c.detected_at >= now() - interval '2 hours' group by s.source_id),
206 + base as (select s.source_id, count(*)::float / (14*24) as per_hour from changes c join sensors s on s.id = c.sensor_id where c.detected_at >= now() - interval '14 days' group by s.source_id)
207 + select so.id, so.name, so.domain, cur.n::int as changes_2h, round(coalesce(base.per_hour,0)::numeric*24,1)::float as baseline_per_day,
208 + round((case when coalesce(base.per_hour,0) = 0 then (case when cur.n/2.0 > 2 then 70 else 40 end) else least(100, case when cur.n/2.0/base.per_hour <= 1 then cur.n/2.0/base.per_hour*30 else 30 + 25*(ln(cur.n/2.0/base.per_hour)/ln(2)) end) end)::numeric, 1)::float as activity_score
209 + from cur join sources so on so.id = cur.source_id left join base on base.source_id = cur.source_id order by activity_score desc limit 10`).then((r) => r.rows),
210 + db.execute<Record<string, unknown>>(sql`select event_type, count(*)::int as n from events where detected_at >= now() - interval '7 days' group by 1 order by 2 desc`).then((r) => r.rows),
211 + db.execute<Record<string, unknown>>(sql`select c as category, count(*)::int as n from events e, unnest(e.categories) c where e.detected_at >= now() - interval '7 days' group by 1 order by 2 desc`).then((r) => r.rows),
212 + ]);
213 + return { most_active_sources: mostActive, biggest_changes: biggest, silent_changes: silent, clusters, unusual_activity: unusual, by_type: byType, by_category: byCategory, channels: FEED_CHANNELS, event_types: Object.fromEntries(Object.entries(EVENT_TYPES).map(([k, v]) => [k, v.label])) };
214 + });
215 + app.get<{ Querystring: { limit?: string; since?: string } }>("/api/v1/clusters", async (req) => {
216 + const rows = await db.execute<Record<string, unknown>>(sql`
217 + select c.*, (select json_agg(json_build_object('id', e.id, 'slug', e.slug, 'title', e.title, 'importance', e.importance, 'event_type', e.event_type, 'detected_at', e.detected_at, 'source_id', e.source_id, 'url', e.url) order by e.importance desc) from events e where e.cluster_id = c.id) as events
218 + from event_clusters c where c.last_at >= now() - make_interval(hours => ${Math.min(720, Number(req.query.since ?? 72))}) order by c.last_at desc limit ${Math.min(200, Number(req.query.limit ?? 50))}`);
219 + return { items: rows.rows };
220 + });
221 +
222 + // ---- Search ----------------------------------------------------------------------------
223 + app.get<{ Querystring: { q?: string; limit?: string } }>("/api/v1/search", async (req) => {
224 + const q = (req.query.q ?? "").trim();
225 + if (q.length < 2) return { query: q, events: [], entities: [], sources: [], urls: [] };
226 + const lim = Math.min(50, Number(req.query.limit ?? 20));
227 + const [ev, ents, srcs, urls] = await Promise.all([
228 + listEvents({ q, limit: lim }).then((r) => r.items),
229 + db.execute<Record<string, unknown>>(sql`select id, name, type, domain, importance, event_count from entities where search @@ plainto_tsquery('simple', ${q}) or name ilike ${"%" + q + "%"} order by event_count desc, importance desc limit ${lim}`).then((r) => r.rows),
230 + db.execute<Record<string, unknown>>(sql`select id, name, domain, tier, categories from sources where name ilike ${"%" + q + "%"} or domain ilike ${"%" + q + "%"} limit ${lim}`).then((r) => r.rows),
231 + db.execute<Record<string, unknown>>(sql`select url, domain, status, change_count, last_seen_at from urls where url ilike ${"%" + q + "%"} order by change_count desc limit ${lim}`).then((r) => r.rows),
232 + ]);
233 + return { query: q, events: ev, entities: ents, sources: srcs, urls };
234 + });
235 +
236 + // ---- Connector health ----------------------------------------------------------------------
237 + app.get("/api/v1/health/connectors", async () => {
238 + const [connectors, sensorsByHealth, worst, noisy, daily] = await Promise.all([
239 + db.execute<Record<string, unknown>>(sql`select * from connector_health order by connector`).then((r) => r.rows),
240 + db.execute<Record<string, unknown>>(sql`select health, count(*)::int as n from sensors where enabled group by health`).then((r) => r.rows),
241 + db.execute<Record<string, unknown>>(sql`select s.id, s.name, s.url, s.source_id, s.connector, s.health, s.consecutive_errors, s.last_error, s.last_status, s.last_check_at from sensors s where s.enabled and s.health <> 'UP' order by s.consecutive_errors desc, s.last_check_at desc limit 50`).then((r) => r.rows),
242 + db.execute<Record<string, unknown>>(sql`select s.id, s.name, s.source_id, s.url, s.raw_changes, s.meaningful_changes, case when s.raw_changes > 0 then round(1 - s.meaningful_changes::numeric / s.raw_changes, 3) else null end as noise_ratio from sensors s where s.raw_changes >= 5 order by noise_ratio desc nulls last, raw_changes desc limit 25`).then((r) => r.rows),
243 + db.execute<Record<string, unknown>>(sql`select * from metrics_daily order by day desc limit 30`).then((r) => r.rows),
244 + ]);
245 + return { connectors, sensors_by_health: sensorsByHealth, degraded_sensors: worst, noisy_sensors: noisy, daily, live: liveStats() };
246 + });
247 +
248 + // ---- Watchlists & alerts (anonymous owner token, phase 1) ----------------------------------
249 + app.get("/api/v1/watchlists", async (req, reply) => {
250 + const owner = ownerToken(req.headers as Record<string, unknown>);
251 + if (!owner) return reply.status(401).send({ error: "owner token required (X-WebSensor-Owner)" });
252 + const rows = await db.execute<Record<string, unknown>>(sql`select w.*, coalesce((select json_agg(json_build_object('kind', i.kind, 'value', i.value, 'added_at', i.added_at)) from watchlist_items i where i.watchlist_id = w.id), '[]'::json) as items from watchlists w where owner_token = ${owner} order by created_at`);
253 + return { items: rows.rows };
254 + });
255 + app.post<{ Body: { name?: string; items?: { kind: string; value: string }[] } }>("/api/v1/watchlists", async (req, reply) => {
256 + const owner = ownerToken(req.headers as Record<string, unknown>);
257 + if (!owner) return reply.status(401).send({ error: "owner token required" });
258 + const body = z.object({ name: z.string().min(1).max(80).default("My watchlist"), items: z.array(z.object({ kind: z.enum(["entity", "source", "keyword", "category", "url"]), value: z.string().min(1).max(200) })).max(200).default([]) }).parse(req.body ?? {});
259 + const count = (await db.execute<{ n: string }>(sql`select count(*)::text as n from watchlists where owner_token = ${owner}`)).rows[0]?.n;
260 + if (Number(count) >= 20) return reply.status(429).send({ error: "too many watchlists" });
261 + const id = newId("wl");
262 + await db.execute(sql`insert into watchlists (id, owner_token, name) values (${id}, ${owner}, ${body.name})`);
263 + for (const it of body.items) await db.execute(sql`insert into watchlist_items (watchlist_id, kind, value) values (${id}, ${it.kind}, ${it.value}) on conflict do nothing`);
264 + return { id, name: body.name, items: body.items };
265 + });
266 + app.put<{ Params: { id: string }; Body: { name?: string; items?: { kind: string; value: string }[] } }>("/api/v1/watchlists/:id", async (req, reply) => {
267 + const owner = ownerToken(req.headers as Record<string, unknown>);
268 + if (!owner) return reply.status(401).send({ error: "owner token required" });
269 + const w = (await db.execute<{ id: string }>(sql`select id from watchlists where id = ${req.params.id} and owner_token = ${owner}`)).rows[0];
270 + if (!w) return reply.status(404).send({ error: "not_found" });
271 + const body = z.object({ name: z.string().min(1).max(80).optional(), items: z.array(z.object({ kind: z.enum(["entity", "source", "keyword", "category", "url"]), value: z.string().min(1).max(200) })).max(200).optional() }).parse(req.body ?? {});
272 + if (body.name) await db.execute(sql`update watchlists set name = ${body.name} where id = ${w.id}`);
273 + if (body.items) {
274 + await db.execute(sql`delete from watchlist_items where watchlist_id = ${w.id}`);
275 + for (const it of body.items) await db.execute(sql`insert into watchlist_items (watchlist_id, kind, value) values (${w.id}, ${it.kind}, ${it.value}) on conflict do nothing`);
276 + }
277 + return { ok: true };
278 + });
279 + app.delete<{ Params: { id: string } }>("/api/v1/watchlists/:id", async (req, reply) => {
280 + const owner = ownerToken(req.headers as Record<string, unknown>);
281 + if (!owner) return reply.status(401).send({ error: "owner token required" });
282 + await db.execute(sql`delete from watchlists where id = ${req.params.id} and owner_token = ${owner}`);
283 + return { ok: true };
284 + });
285 + app.get<{ Params: { id: string }; Querystring: { limit?: string } }>("/api/v1/watchlists/:id/events", async (req, reply) => {
286 + const owner = ownerToken(req.headers as Record<string, unknown>);
287 + if (!owner) return reply.status(401).send({ error: "owner token required" });
288 + const items = (await db.execute<{ kind: string; value: string }>(sql`select i.kind, i.value from watchlist_items i join watchlists w on w.id = i.watchlist_id where w.id = ${req.params.id} and w.owner_token = ${owner}`)).rows;
289 + if (!items.length) return reply.send({ items: [] });
290 + const ents = items.filter((i) => i.kind === "entity").map((i) => i.value);
291 + const srcs = items.filter((i) => i.kind === "source").map((i) => i.value);
292 + const cats = items.filter((i) => i.kind === "category").map((i) => i.value);
293 + const kws = items.filter((i) => i.kind === "keyword").map((i) => i.value);
294 + const conds = [] as ReturnType<typeof sql>[];
295 + if (ents.length) conds.push(sql`exists (select 1 from event_entities x where x.event_id = e.id and x.entity_id = any(${textArray(ents)}))`);
296 + if (srcs.length) conds.push(sql`e.source_id = any(${textArray(srcs)})`);
297 + if (cats.length) conds.push(sql`e.categories && ${textArray(cats)}`);
298 + for (const k of kws) conds.push(sql`(e.title ilike ${"%" + k + "%"} or e.summary ilike ${"%" + k + "%"})`);
299 + if (!conds.length) return { items: [] };
300 + const rows = await db.execute<Record<string, unknown>>(sql`select ${EVENT_SELECT} from events e join sources s on s.id = e.source_id join sensors sen on sen.id = e.sensor_id where ${sql.join(conds, sql` or `)} order by e.detected_at desc limit ${Math.min(200, Number(req.query.limit ?? 50))}`);
301 + return { items: rows.rows };
302 + });
303 +
304 + app.get("/api/v1/alerts", async (req, reply) => {
305 + const owner = ownerToken(req.headers as Record<string, unknown>);
306 + if (!owner) return reply.status(401).send({ error: "owner token required" });
307 + return { items: (await db.execute<Record<string, unknown>>(sql`select * from alerts where owner_token = ${owner} order by created_at`)).rows };
308 + });
309 + app.post<{ Body: Record<string, unknown> }>("/api/v1/alerts", async (req, reply) => {
310 + const owner = ownerToken(req.headers as Record<string, unknown>);
311 + if (!owner) return reply.status(401).send({ error: "owner token required" });
312 + const body = z.object({ name: z.string().min(1).max(80), rule: z.object({ importance_min: z.number().min(0).max(100).optional(), event_types: z.array(z.string()).optional(), entities: z.array(z.string()).optional(), sources: z.array(z.string()).optional(), keywords: z.array(z.string()).optional(), silent_only: z.boolean().optional(), categories: z.array(z.string()).optional() }), channel: z.enum(["web"]).default("web") }).parse(req.body ?? {});
313 + const id = newId("alr");
314 + await db.execute(sql`insert into alerts (id, owner_token, name, rule, channel) values (${id}, ${owner}, ${body.name}, ${JSON.stringify(body.rule)}::jsonb, ${body.channel})`);
315 + return { id, ...body };
316 + });
317 + app.delete<{ Params: { id: string } }>("/api/v1/alerts/:id", async (req, reply) => {
318 + const owner = ownerToken(req.headers as Record<string, unknown>);
319 + if (!owner) return reply.status(401).send({ error: "owner token required" });
320 + await db.execute(sql`delete from alerts where id = ${req.params.id} and owner_token = ${owner}`);
321 + return { ok: true };
322 + });
323 +
324 + // ---- Machine-readable feeds ------------------------------------------------------------------
325 + app.get<{ Querystring: { category?: string; importance_min?: string; silent_change?: string } }>("/api/v1/feed.rss", async (req, reply) => {
326 + const q = eventsQuery.parse({ ...req.query, limit: 50 });
327 + const { items } = await listEvents(q);
328 + const esc = (s: unknown): string => String(s ?? "").replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
329 + const base = config.publicBaseUrl;
330 + const xml = `<?xml version="1.0" encoding="UTF-8"?>\n<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom"><channel><title>WebSensor — ${esc(q.category ? q.category + " events" : "live events")}</title><link>${base}</link><description>Meaningful changes detected on the public Web by WebSensor.</description><atom:link href="${base}/api/v1/feed.rss" rel="self" type="application/rss+xml"/>${items
331 + .map((e) => `<item><title>${esc(e.title)}</title><link>${base}/event/${esc(e.slug)}</link><guid isPermaLink="false">${esc(e.id)}</guid><pubDate>${new Date(String(e.detected_at)).toUTCString()}</pubDate><category>${esc(e.event_type)}</category><description>${esc(e.summary)} (importance ${esc(e.importance)}, confidence ${esc(e.confidence)}${e.silent_change ? ", silent change" : ""}) — source: ${esc(e.url)}</description></item>`)
332 + .join("")}</channel></rss>`;
333 + reply.header("content-type", "application/rss+xml; charset=utf-8");
334 + return reply.send(xml);
335 + });
336 +
337 + app.get("/api/v1", async () => ({
338 + name: "WebSensor API",
339 + version: "v1",
340 + docs: `${config.publicBaseUrl}/api`,
341 + endpoints: ["/api/v1/events", "/api/v1/events/{id|slug}", "/api/v1/changes/{id}", "/api/v1/snapshots/{id}", "/api/v1/snapshots/compare?a=&b=", "/api/v1/sources", "/api/v1/sources/{id}", "/api/v1/sensors/{id}", "/api/v1/entities", "/api/v1/entities/{id}", "/api/v1/entities/{id}/timeline", "/api/v1/domains/{domain}/timeline", "/api/v1/urls/history?url=", "/api/v1/search?q=", "/api/v1/stats", "/api/v1/trending", "/api/v1/explore", "/api/v1/clusters", "/api/v1/health/connectors", "/api/v1/watchlists", "/api/v1/alerts", "/api/v1/feed.rss", "wss://…/api/v1/live"],
342 + }));
343 +}
344 +
345 +function renderCanonical(c: { mode: string; text?: string; items?: Record<string, unknown>[]; json?: unknown }): string {
346 + if (c.mode === "text") return c.text ?? "";
347 + if (c.mode === "list") return (c.items ?? []).map((i) => [i.title ?? i.url ?? i.key, i.url && i.title ? i.url : null, i.summary ? String(i.summary).slice(0, 300) : null].filter(Boolean).join(" — ")).join("\n");
348 + return JSON.stringify(c.json ?? null, null, 2);
349 +}
added apps/api/src/server.ts +107 −0
@@ -0,0 +1,107 @@
1 +import Fastify, { type FastifyReply, type FastifyRequest } from "fastify";
2 +import cors from "@fastify/cors";
3 +import rateLimit from "@fastify/rate-limit";
4 +import websocket from "@fastify/websocket";
5 +import replyFrom from "@fastify/reply-from";
6 +import client from "prom-client";
7 +import { newId } from "@websensor/core";
8 +import { closeDb } from "@websensor/db";
9 +import { config } from "./config";
10 +import { closeLive, registerLive } from "./live";
11 +import { registerRoutes } from "./routes";
12 +
13 +/**
14 + * WebSensor gateway: public entry point behind ngrok.
15 + * /api/v1/* REST + WebSocket (/api/v1/live)
16 + * /api/health /api/ready /api/metrics
17 + * everything else → Next.js frontend (loopback), with forwarded headers.
18 + * Also enforces the canonical host (websensor.io → www.websensor.io).
19 + */
20 +const registry = new client.Registry();
21 +client.collectDefaultMetrics({ register: registry, prefix: "websensor_api_" });
22 +const httpRequests = new client.Counter({ name: "websensor_api_requests_total", help: "API requests", labelNames: ["route", "status"], registers: [registry] });
23 +
24 +export async function buildServer() {
25 + const app = Fastify({
26 + logger: {
27 + level: config.logLevel,
28 + ...(config.env !== "production" ? { transport: { target: "pino-pretty", options: { colorize: true, translateTime: "HH:MM:ss" } } } : {}),
29 + redact: ["req.headers.authorization", "req.headers.cookie", "req.headers['x-websensor-owner']"],
30 + },
31 + trustProxy: true,
32 + bodyLimit: 512 * 1024,
33 + genReqId: () => newId("req"),
34 + disableRequestLogging: config.env === "production",
35 + });
36 +
37 + // Canonical host redirect (apex → www) for every path.
38 + app.addHook("onRequest", async (req, reply) => {
39 + const host = (req.headers.host ?? "").split(":")[0];
40 + if (config.redirectApexToWww && host && host !== config.canonicalHost && host === config.canonicalHost.replace(/^www\./, "")) {
41 + return reply.redirect(`https://${config.canonicalHost}${req.url}`, 301);
42 + }
43 + });
44 +
45 + await app.register(cors, { origin: true, methods: ["GET", "POST", "PUT", "DELETE", "OPTIONS"], allowedHeaders: ["content-type", "x-websensor-owner", "authorization"], exposedHeaders: ["x-request-id"] });
46 + await app.register(rateLimit, { max: 600, timeWindow: "1 minute", allowList: (req) => !req.url.startsWith("/api/") });
47 + await app.register(websocket, { options: { maxPayload: 64 * 1024 } });
48 +
49 + app.addHook("onSend", async (req, reply) => {
50 + reply.header("x-request-id", req.id);
51 + if (req.url.startsWith("/api/")) {
52 + reply.header("cache-control", req.method === "GET" ? "public, max-age=5, stale-while-revalidate=30" : "no-store");
53 + httpRequests.inc({ route: req.routeOptions?.url ?? "unknown", status: String(reply.statusCode) });
54 + }
55 + });
56 +
57 + app.setErrorHandler((err: Error & { statusCode?: number; validation?: unknown; issues?: unknown }, req, reply) => {
58 + if (err.name === "ZodError" || err.issues) return reply.status(400).send({ error: "invalid_request", details: err.issues });
59 + if (err.statusCode && err.statusCode < 500) return reply.status(err.statusCode).send({ error: err.message });
60 + req.log.error({ err }, "unhandled error");
61 + return reply.status(500).send({ error: "internal_error", request_id: req.id });
62 + });
63 +
64 + app.get("/api/metrics", async (_req, reply) => {
65 + reply.header("content-type", registry.contentType);
66 + return registry.metrics();
67 + });
68 +
69 + await registerRoutes(app);
70 + await registerLive(app);
71 +
72 + // Frontend proxy — everything that is not /api/* goes to Next.js (loopback)
73 + // with forwarded headers so the app knows the public host/proto and the real client IP.
74 + await app.register(replyFrom, { base: config.webUrl, http: { requestOptions: { timeout: 60_000 } }, undici: { connections: 64, pipelining: 1 } });
75 + const forward = (req: FastifyRequest, reply: FastifyReply): FastifyReply => {
76 + if (req.url.startsWith("/api/")) return reply.status(404).send({ error: "not_found" });
77 + return reply.from(req.raw.url ?? "/", {
78 + rewriteRequestHeaders: (r, headers) => ({ ...headers, "x-forwarded-host": String(r.headers["x-forwarded-host"] ?? r.headers.host ?? ""), "x-forwarded-proto": String(r.headers["x-forwarded-proto"] ?? "https"), "x-real-ip": r.ip, "x-forwarded-for": r.ip }),
79 + });
80 + };
81 + const methods = ["GET", "HEAD", "POST", "PUT", "DELETE", "PATCH"] as const;
82 + app.route({ method: [...methods], url: "/", handler: forward });
83 + app.route({ method: [...methods], url: "/*", handler: forward });
84 +
85 + return app;
86 +}
87 +
88 +async function main(): Promise<void> {
89 + const app = await buildServer();
90 + await app.listen({ port: config.port, host: config.host });
91 + app.log.info({ port: config.port, web: config.webUrl, canonical: config.canonicalHost }, "websensor gateway listening");
92 + const shutdown = async (): Promise<void> => {
93 + await closeLive();
94 + await app.close();
95 + await closeDb();
96 + process.exit(0);
97 + };
98 + process.on("SIGINT", () => void shutdown());
99 + process.on("SIGTERM", () => void shutdown());
100 +}
101 +
102 +if (process.argv[1] && import.meta.url.endsWith(process.argv[1].split("/").pop() ?? "")) {
103 + main().catch((e) => {
104 + console.error(e);
105 + process.exit(1);
106 + });
107 +}
added apps/api/tsconfig.json +5 −0
@@ -0,0 +1,5 @@
1 +{
2 + "extends": "../../tsconfig.base.json",
3 + "compilerOptions": { "types": ["node"] },
4 + "include": ["src/**/*.ts"]
5 +}
added apps/engine/package.json +35 −0
@@ -0,0 +1,35 @@
1 +{
2 + "name": "@websensor/engine",
3 + "version": "0.1.0",
4 + "private": true,
5 + "type": "module",
6 + "scripts": {
7 + "dev": "tsx watch src/index.ts",
8 + "start": "tsx src/index.ts",
9 + "registry:sync": "tsx src/cli.ts sync",
10 + "discover": "tsx src/cli.ts discover",
11 + "run-once": "tsx src/cli.ts run-once",
12 + "typecheck": "tsc -p tsconfig.json --noEmit",
13 + "test": "vitest run --passWithNoTests"
14 + },
15 + "dependencies": {
16 + "@anthropic-ai/sdk": "^0.124.0",
17 + "@websensor/connectors": "workspace:*",
18 + "@websensor/core": "workspace:*",
19 + "@websensor/db": "workspace:*",
20 + "@websensor/store": "workspace:*",
21 + "fastify": "^5.4.0",
22 + "ioredis": "^5.6.0",
23 + "pino": "^9.7.0",
24 + "pino-pretty": "^13.0.0",
25 + "prom-client": "^15.1.0",
26 + "tsx": "^4.20.0",
27 + "yaml": "^2.8.0",
28 + "zod": "^4.0.0"
29 + },
30 + "devDependencies": {
31 + "@types/node": "^24.0.0",
32 + "typescript": "^5.9.3",
33 + "vitest": "^3.2.0"
34 + }
35 +}
added apps/engine/src/cli.ts +81 −0
@@ -0,0 +1,81 @@
1 +import { closeDb, db, migrate, sensors, sources, sql } from "@websensor/db";
2 +import { closeDispatcher, discoverDomain } from "@websensor/connectors";
3 +import { loadRecent } from "./cluster";
4 +import { config, log } from "./config";
5 +import { closeRedis } from "./redis";
6 +import { runDiscovery, syncRegistry } from "./registry";
7 +import { runSensor } from "./pipeline";
8 +import { diffText, evaluateChange } from "@websensor/core";
9 +import { interpretChange, llmAvailable } from "./interpret";
10 +
11 +/**
12 + * Operator CLI:
13 + * tsx src/cli.ts sync — upsert sources/sensors/entities from config/sources.yaml
14 + * tsx src/cli.ts discover [sourceId…] — run discovery (+ promote validated endpoints)
15 + * tsx src/cli.ts probe <domain> — discovery dry-run for a domain (no DB writes)
16 + * tsx src/cli.ts run-once <sensorId> — run the full pipeline for one sensor now
17 + * tsx src/cli.ts run-due [n] — run up to n due sensors sequentially
18 + */
19 +async function main(): Promise<void> {
20 + const [cmd, ...args] = process.argv.slice(2);
21 + await migrate(config.databaseUrl);
22 + switch (cmd) {
23 + case "sync":
24 + await syncRegistry();
25 + break;
26 + case "discover":
27 + await syncRegistry();
28 + await runDiscovery({ sourceIds: args.length ? args : undefined });
29 + break;
30 + case "probe": {
31 + const r = await discoverDomain(args[0]!, { probePages: true });
32 + for (const e of r) console.log(`${e.type.padEnd(10)} ${e.value.toFixed(2)} ${String(e.itemCount ?? "").padStart(5)} ${e.url} (${e.evidence})`);
33 + break;
34 + }
35 + case "run-once": {
36 + await loadRecent();
37 + const s = (await db.select().from(sensors).where(sql`id = ${args[0]}`))[0];
38 + if (!s) throw new Error(`sensor ${args[0]} not found`);
39 + const src = (await db.select().from(sources).where(sql`id = ${s.sourceId}`))[0]!;
40 + const out = await runSensor(s, src);
41 + console.log(`${s.id}: ${out}`);
42 + break;
43 + }
44 + case "run-due": {
45 + await loadRecent();
46 + const n = Number(args[0] ?? 20);
47 + const rows = await db.select().from(sensors).where(sql`enabled and next_check_at <= now()`).orderBy(sql`next_check_at asc`).limit(n);
48 + const srcs = new Map((await db.select().from(sources)).map((s) => [s.id, s]));
49 + for (const s of rows) {
50 + const out = await runSensor(s, srcs.get(s.sourceId)!);
51 + console.log(`${s.id.padEnd(50)} ${out}`);
52 + }
53 + break;
54 + }
55 + case "llm-test": {
56 + if (!llmAvailable()) throw new Error("ANTHROPIC_API_KEY not set");
57 + const before = "API Pricing\nInput: $10 / million tokens\nOutput: $30 / million tokens\nBatch API: 50% discount";
58 + const after = "API Pricing\nInput: $8 / million tokens\nOutput: $24 / million tokens\nBatch API: 50% discount\nPrompt caching: 90% discount on cached input";
59 + const diff = diffText(before, after);
60 + const heuristic = evaluateChange(diff, { sensorType: "HTML", url: "https://example-ai.com/pricing", sourceCategories: ["ai"], title: "API Pricing" });
61 + const t0 = Date.now();
62 + const out = await interpretChange({ sourceName: "Example AI", sourceCategories: ["ai"], url: "https://example-ai.com/pricing", sensorName: "pricing", sensorType: "HTML", heuristic, diff, prelimImportance: Number(args[0] ?? 60), title: "API Pricing" });
63 + console.log(JSON.stringify(out, null, 2), `\n${Date.now() - t0} ms`);
64 + break;
65 + }
66 + default:
67 + console.error("usage: cli.ts sync | discover [sourceId…] | probe <domain> | run-once <sensorId> | run-due [n]");
68 + process.exitCode = 1;
69 + }
70 +}
71 +
72 +main()
73 + .catch((e) => {
74 + log.error({ err: (e as Error).stack ?? String(e) }, "cli failed");
75 + process.exitCode = 1;
76 + })
77 + .finally(async () => {
78 + await closeDispatcher();
79 + await closeRedis();
80 + await closeDb();
81 + });
added apps/engine/src/cluster.ts +135 −0
@@ -0,0 +1,135 @@
1 +import { jaccard, newId, shingles } from "@websensor/core";
2 +import { db, eventClusters, events, gte, sql, textArray } from "@websensor/db";
3 +
4 +/**
5 + * Novelty + clustering over a rolling in-memory window of recent events (loaded from
6 + * Postgres at startup). Similarity = Jaccard over word 3-shingles of title+summary.
7 + */
8 +interface RecentEvent {
9 + id: string;
10 + clusterId: string | null;
11 + sourceId: string;
12 + sensorId: string;
13 + eventType: string;
14 + entityIds: string[];
15 + detectedAt: number;
16 + sh: Set<string>;
17 + importance: number;
18 +}
19 +
20 +const WINDOW_MS = 72 * 3600e3;
21 +const CLUSTER_WINDOW_MS = 6 * 3600e3;
22 +let recent: RecentEvent[] = [];
23 +let loaded = false;
24 +
25 +export async function loadRecent(): Promise<void> {
26 + const since = new Date(Date.now() - WINDOW_MS);
27 + const rows = await db.execute<{ id: string; cluster_id: string | null; source_id: string; sensor_id: string; event_type: string; title: string; summary: string; detected_at: Date; importance: number; entity_ids: string[] | null }>(sql`
28 + select e.id, e.cluster_id, e.source_id, e.sensor_id, e.event_type, e.title, e.summary, e.detected_at, e.importance,
29 + (select array_agg(entity_id) from event_entities ee where ee.event_id = e.id) as entity_ids
30 + from events e where e.detected_at >= ${since} order by e.detected_at desc limit 3000`);
31 + recent = rows.rows.map((r) => ({ id: r.id, clusterId: r.cluster_id, sourceId: r.source_id, sensorId: r.sensor_id, eventType: r.event_type, entityIds: r.entity_ids ?? [], detectedAt: new Date(r.detected_at).getTime(), sh: shingles(`${r.title}\n${r.summary}`), importance: r.importance }));
32 + loaded = true;
33 +}
34 +
35 +function prune(): void {
36 + const cutoff = Date.now() - WINDOW_MS;
37 + recent = recent.filter((r) => r.detectedAt >= cutoff);
38 +}
39 +
40 +export interface NoveltyResult {
41 + novelty: number;
42 + nearest: { id: string; similarity: number } | null;
43 + /** number of distinct sources reporting near-identical content */
44 + confirmations: number;
45 +}
46 +
47 +export async function assessNovelty(text: string, sourceId: string): Promise<NoveltyResult> {
48 + if (!loaded) await loadRecent();
49 + prune();
50 + const sh = shingles(text);
51 + let best = 0;
52 + let nearest: RecentEvent | null = null;
53 + const confirmingSources = new Set<string>();
54 + for (const r of recent) {
55 + const s = jaccard(sh, r.sh);
56 + if (s > best) {
57 + best = s;
58 + nearest = r;
59 + }
60 + if (s >= 0.45 && r.sourceId !== sourceId) confirmingSources.add(r.sourceId);
61 + }
62 + return { novelty: Math.round((1 - best) * 100), nearest: nearest ? { id: nearest.id, similarity: Math.round(best * 100) / 100 } : null, confirmations: confirmingSources.size };
63 +}
64 +
65 +export interface ClusterDecision {
66 + clusterId: string;
67 + created: boolean;
68 +}
69 +
70 +/**
71 + * Attach to an existing open cluster when the event shares an entity (or the same source)
72 + * with a recent event and is textually related, or when it is the same event type on the
73 + * same source within 30 minutes (e.g. one launch touching six pages). Otherwise open one.
74 + */
75 +export async function clusterEvent(ev: { id: string; sourceId: string; sensorId: string; eventType: string; entityIds: string[]; detectedAt: Date; title: string; summary: string; importance: number; categories: string[] }): Promise<ClusterDecision> {
76 + if (!loaded) await loadRecent();
77 + const sh = shingles(`${ev.title}\n${ev.summary}`);
78 + const now = ev.detectedAt.getTime();
79 + let bestCluster: string | null = null;
80 + let bestScore = 0;
81 + for (const r of recent) {
82 + if (!r.clusterId || now - r.detectedAt > CLUSTER_WINDOW_MS) continue;
83 + const sharedEntity = r.entityIds.some((e) => ev.entityIds.includes(e));
84 + const sameSource = r.sourceId === ev.sourceId;
85 + if (!sharedEntity && !sameSource) continue;
86 + const sim = jaccard(sh, r.sh);
87 + const closeInTime = now - r.detectedAt < 30 * 60e3;
88 + let score = 0;
89 + if (sim >= 0.22) score = sim + (sharedEntity ? 0.2 : 0);
90 + else if (sameSource && r.eventType === ev.eventType && closeInTime && r.sensorId !== ev.sensorId) score = 0.3;
91 + else if (sameSource && closeInTime && sim >= 0.12) score = 0.25;
92 + if (score > bestScore) {
93 + bestScore = score;
94 + bestCluster = r.clusterId;
95 + }
96 + }
97 + let clusterId: string;
98 + let created = false;
99 + if (bestCluster) {
100 + clusterId = bestCluster;
101 + await db.execute(sql`update event_clusters set event_count = event_count + 1, last_at = greatest(last_at, ${ev.detectedAt}), max_importance = greatest(max_importance, ${ev.importance}),
102 + entity_ids = (select array(select distinct unnest(entity_ids || ${textArray(ev.entityIds)}))),
103 + categories = (select array(select distinct unnest(categories || ${textArray(ev.categories)}))),
104 + title = case when ${ev.importance} > max_importance then ${ev.title} else title end,
105 + primary_event_id = case when ${ev.importance} > max_importance then ${ev.id} else primary_event_id end
106 + where id = ${clusterId}`);
107 + } else {
108 + clusterId = newId("clu");
109 + created = true;
110 + await db.insert(eventClusters).values({ id: clusterId, title: ev.title, summary: ev.summary, primaryEventId: ev.id, entityIds: ev.entityIds, categories: ev.categories, eventCount: 1, maxImportance: ev.importance, firstAt: ev.detectedAt, lastAt: ev.detectedAt });
111 + }
112 + recent.unshift({ id: ev.id, clusterId, sourceId: ev.sourceId, sensorId: ev.sensorId, eventType: ev.eventType, entityIds: ev.entityIds, detectedAt: now, sh, importance: ev.importance });
113 + if (recent.length > 5000) recent.length = 5000;
114 + return { clusterId, created };
115 +}
116 +
117 +/** Recent events of the same source published through announcement-type sensors (for silent-change detection). */
118 +export function recentAnnouncementSimilarity(sourceId: string, text: string, sinceMs: number): number {
119 + const sh = shingles(text);
120 + let best = 0;
121 + const cutoff = Date.now() - sinceMs;
122 + for (const r of recent) {
123 + if (r.sourceId !== sourceId || r.detectedAt < cutoff) continue;
124 + if (!/announcement|product_launch|model_release|software_release|repository_release|incident|outage|maintenance|security_advisory/.test(r.eventType)) continue;
125 + best = Math.max(best, jaccard(sh, r.sh));
126 + }
127 + return best;
128 +}
129 +
130 +export async function recentClusterCount(sinceMs: number): Promise<number> {
131 + const rows = await db.select({ n: sql<number>`count(*)` }).from(eventClusters).where(gte(eventClusters.lastAt, new Date(Date.now() - sinceMs)));
132 + return Number(rows[0]?.n ?? 0);
133 +}
134 +
135 +export { events };
added apps/engine/src/config.ts +40 −0
@@ -0,0 +1,40 @@
1 +import pino from "pino";
2 +
3 +const env = process.env;
4 +
5 +export const config = {
6 + env: env.NODE_ENV ?? "development",
7 + logLevel: env.LOG_LEVEL ?? "info",
8 + databaseUrl: env.DATABASE_URL ?? "postgres://localhost:5432/websensor",
9 + redisUrl: env.REDIS_URL ?? "redis://127.0.0.1:6379",
10 + sourcesFile: env.WS_SOURCES_FILE ?? "./config/sources.yaml",
11 + entitiesFile: env.WS_ENTITIES_FILE ?? "./config/entities.yaml",
12 + fetchConcurrency: Number(env.WS_FETCH_CONCURRENCY ?? 16),
13 + perHostConcurrency: Number(env.WS_PER_HOST_CONCURRENCY ?? 2),
14 + metricsPort: Number(env.ENGINE_METRICS_PORT ?? 8262),
15 + metricsHost: env.ENGINE_METRICS_HOST ?? "127.0.0.1",
16 + processingVersion: "event-pipeline-v1",
17 + /** heuristic signal threshold to promote a raw change to an event */
18 + meaningfulSignal: Number(env.WS_MEANINGFUL_SIGNAL ?? 0.32),
19 + llm: {
20 + apiKey: env.ANTHROPIC_API_KEY ?? "",
21 + modelFast: env.WS_LLM_MODEL_FAST ?? "claude-haiku-4-5",
22 + modelDeep: env.WS_LLM_MODEL_DEEP ?? "claude-opus-5",
23 + dailyCallBudget: Number(env.WS_LLM_DAILY_CALL_BUDGET ?? 600),
24 + minImportance: Number(env.WS_LLM_MIN_IMPORTANCE ?? 35),
25 + deepMinImportance: Number(env.WS_LLM_DEEP_MIN_IMPORTANCE ?? 78),
26 + },
27 + discovery: {
28 + enabled: (env.WS_DISCOVERY ?? "1") !== "0",
29 + /** re-run discovery for a source after this many days */
30 + intervalDays: Number(env.WS_DISCOVERY_INTERVAL_DAYS ?? 7),
31 + },
32 + deletion: { confirmations: Number(env.WS_DELETE_CONFIRMATIONS ?? 3), minSeparationMin: Number(env.WS_DELETE_SEPARATION_MIN ?? 60) },
33 + version: "0.1.0",
34 +};
35 +
36 +export const log = pino({
37 + level: config.logLevel,
38 + ...(config.env !== "production" ? { transport: { target: "pino-pretty", options: { colorize: true, translateTime: "HH:MM:ss" } } } : {}),
39 + base: { service: "websensor-engine" },
40 +});
added apps/engine/src/entities.ts +62 −0
@@ -0,0 +1,62 @@
1 +import { db, entityAliases, entities, eq, sourceEntities, sql, textArray } from "@websensor/db";
2 +import { log } from "./config";
3 +
4 +/**
5 + * Entity resolution. The source's own entities are always attached (subject). Then aliases
6 + * of all known entities are matched against the event text (word-boundary, case-insensitive,
7 + * longest alias first) so products/models/regulators named in the change are linked too.
8 + */
9 +interface AliasIndex {
10 + loadedAt: number;
11 + byLen: { alias: string; re: RegExp; entityId: string }[];
12 + importance: Map<string, number>;
13 +}
14 +
15 +let index: AliasIndex | null = null;
16 +
17 +async function loadIndex(): Promise<AliasIndex> {
18 + if (index && Date.now() - index.loadedAt < 5 * 60_000) return index;
19 + const rows = await db.select({ alias: entityAliases.alias, entityId: entityAliases.entityId }).from(entityAliases);
20 + const ents = await db.select({ id: entities.id, importance: entities.importance }).from(entities);
21 + const byLen = rows
22 + .filter((r) => r.alias.length >= 3 && !/^\d+$/.test(r.alias))
23 + .map((r) => ({ alias: r.alias, entityId: r.entityId, re: new RegExp(`(^|[^\\p{L}\\p{N}])${escapeRe(r.alias)}(?=$|[^\\p{L}\\p{N}])`, "iu") }))
24 + .sort((a, b) => b.alias.length - a.alias.length);
25 + index = { loadedAt: Date.now(), byLen, importance: new Map(ents.map((e) => [e.id, e.importance])) };
26 + log.debug({ aliases: byLen.length }, "alias index loaded");
27 + return index;
28 +}
29 +
30 +function escapeRe(s: string): string {
31 + return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
32 +}
33 +
34 +export function invalidateEntityIndex(): void {
35 + index = null;
36 +}
37 +
38 +export async function resolveEntities(input: { sourceId: string; text: string; hints: string[] }): Promise<{ subject: string[]; mentioned: string[]; importance: number }> {
39 + const idx = await loadIndex();
40 + const subject = (await db.select({ entityId: sourceEntities.entityId }).from(sourceEntities).where(eq(sourceEntities.sourceId, input.sourceId))).map((r) => r.entityId);
41 + // Only the organization (not every product) is a default subject; products are attached when named.
42 + const orgSubjects = subject.filter((s) => s.startsWith("org_"));
43 + const mentioned = new Set<string>();
44 + const hay = `${input.text}\n${input.hints.join("\n")}`;
45 + const hayLower = hay.toLowerCase();
46 + for (const a of idx.byLen) {
47 + if (mentioned.size >= 12) break;
48 + if (orgSubjects.includes(a.entityId)) continue;
49 + if (!hayLower.includes(a.alias)) continue;
50 + if (a.re.test(hay)) mentioned.add(a.entityId);
51 + }
52 + // Products of the source named in the text are subjects too.
53 + for (const s of subject) if (!s.startsWith("org_") && mentioned.has(s)) mentioned.delete(s), orgSubjects.push(s);
54 + const all = [...orgSubjects, ...mentioned];
55 + const importance = all.length ? Math.max(...all.map((e) => idx.importance.get(e) ?? 40)) : 40;
56 + return { subject: orgSubjects, mentioned: [...mentioned], importance };
57 +}
58 +
59 +export async function bumpEntityCounters(entityIds: string[], at: Date): Promise<void> {
60 + if (!entityIds.length) return;
61 + await db.execute(sql`update entities set event_count = event_count + 1, last_event_at = ${at} where id = any(${textArray(entityIds)})`);
62 +}
added apps/engine/src/index.ts +52 −0
@@ -0,0 +1,52 @@
1 +import { closeDb, migrate } from "@websensor/db";
2 +import { closeDispatcher } from "@websensor/connectors";
3 +import { loadRecent } from "./cluster";
4 +import { config, log } from "./config";
5 +import { startMetricsServer } from "./metrics";
6 +import { closeRedis } from "./redis";
7 +import { runDiscovery, syncRegistry } from "./registry";
8 +import { pruneOldRuns, rollupConnectorHealth, Scheduler } from "./scheduler";
9 +
10 +async function main(): Promise<void> {
11 + log.info({ env: config.env, version: config.version, llm: Boolean(config.llm.apiKey) }, "websensor engine starting");
12 + const applied = await migrate(config.databaseUrl);
13 + if (applied.length) log.info({ applied }, "migrations applied");
14 + await syncRegistry();
15 + await loadRecent();
16 + await startMetricsServer();
17 +
18 + const scheduler = new Scheduler();
19 + await scheduler.start();
20 +
21 + const timers: NodeJS.Timeout[] = [];
22 + timers.push(setInterval(() => rollupConnectorHealth().catch((e) => log.warn({ err: (e as Error).message }, "health rollup failed")), 60_000));
23 + timers.push(setInterval(() => pruneOldRuns().catch(() => undefined), 6 * 3600e3));
24 + await rollupConnectorHealth().catch(() => undefined);
25 +
26 + if (config.discovery.enabled) {
27 + // Background: validate feeds/sitemaps/status pages for sources not discovered recently, then weekly.
28 + setTimeout(() => runDiscovery({ onlyMissing: true }).catch((e) => log.warn({ err: (e as Error).message }, "discovery failed")), 5_000);
29 + timers.push(setInterval(() => runDiscovery({ onlyMissing: true }).catch(() => undefined), 24 * 3600e3));
30 + }
31 +
32 + let shuttingDown = false;
33 + const shutdown = async (signal: string): Promise<void> => {
34 + if (shuttingDown) return;
35 + shuttingDown = true;
36 + log.info({ signal }, "shutting down");
37 + for (const t of timers) clearInterval(t);
38 + await scheduler.stop();
39 + await closeDispatcher();
40 + await closeRedis();
41 + await closeDb();
42 + process.exit(0);
43 + };
44 + process.on("SIGINT", () => void shutdown("SIGINT"));
45 + process.on("SIGTERM", () => void shutdown("SIGTERM"));
46 + process.on("unhandledRejection", (e) => log.error({ err: e instanceof Error ? e.stack : String(e) }, "unhandled rejection"));
47 +}
48 +
49 +main().catch((e) => {
50 + log.fatal({ err: (e as Error).stack ?? String(e) }, "engine failed to start");
51 + process.exit(1);
52 +});
added apps/engine/src/interpret.ts +153 −0
@@ -0,0 +1,153 @@
1 +import Anthropic from "@anthropic-ai/sdk";
2 +import { EVENT_TYPES, type DiffResult, type HeuristicResult } from "@websensor/core";
3 +import { db, llmUsage, sql } from "@websensor/db";
4 +import { config, log } from "./config";
5 +import { bumpDaily, m } from "./metrics";
6 +
7 +/**
8 + * Stage-2 interpretation with Claude. Only called for candidates that already cleared the
9 + * heuristic bar and a preliminary importance threshold; a daily call budget caps spend.
10 + * Routine changes go to the fast model; high-importance candidates get the deep model.
11 + * Output is strictly structured (JSON schema) — never free text.
12 + */
13 +export interface Interpretation {
14 + event_type: string;
15 + title: string;
16 + summary: string;
17 + why_it_matters: string;
18 + who_it_affects: string;
19 + observed: string;
20 + inferred: string;
21 + meaningful: boolean;
22 + severity: number;
23 + confidence: number;
24 + entities: string[];
25 + keywords: string[];
26 + announced: boolean | null;
27 + model: string;
28 + input_tokens: number;
29 + output_tokens: number;
30 +}
31 +
32 +const client = config.llm.apiKey ? new Anthropic({ apiKey: config.llm.apiKey, timeout: 60_000, maxRetries: 1 }) : null;
33 +
34 +const SYSTEM = `You are the interpretation stage of WebSensor, a platform that monitors official public web sources and turns detected changes into precise, sober intelligence events.
35 +You receive a detected change on one monitored endpoint: the source, the URL, the sensor kind, the heuristic pre-classification, extracted facts and the diff (before → after, or new/removed items).
36 +Your job: decide whether the change is meaningful to a professional audience, classify it, and write a factual title and summary.
37 +Rules:
38 +- Never sensationalize. No marketing tone. State what is observed; keep inference clearly separate.
39 +- "observed" must only contain facts visible in the diff. "inferred" may contain a cautious interpretation or be empty.
40 +- Titles: ≤ 110 characters, start with the organization name, no trailing period, no emoji.
41 +- Summary: 1–3 sentences, concrete (numbers, names, versions when present).
42 +- meaningful=false for cosmetic edits, typos, navigation/footer churn, copyright years, tracking or timestamp noise, and generic marketing rewording with no new information.
43 +- severity: 0–100 intrinsic importance of this kind of change for people who follow this organization (pricing/security/outage/model launch high; doc typo low).
44 +- confidence: 0–100 how sure you are of the classification given the evidence.
45 +- entities: organizations, products, models, APIs, drugs, standards explicitly named in the change (canonical names).
46 +- announced: true if the diff itself is an announcement (news/blog/release item), false if it is a silent modification of an existing page, null if unclear.
47 +Allowed event_type values: ${Object.keys(EVENT_TYPES).join(", ")}.`;
48 +
49 +const schema = {
50 + type: "object",
51 + additionalProperties: false,
52 + properties: {
53 + event_type: { type: "string", enum: Object.keys(EVENT_TYPES) },
54 + title: { type: "string" },
55 + summary: { type: "string" },
56 + why_it_matters: { type: "string" },
57 + who_it_affects: { type: "string" },
58 + observed: { type: "string" },
59 + inferred: { type: "string" },
60 + meaningful: { type: "boolean" },
61 + severity: { type: "integer", description: "0–100" },
62 + confidence: { type: "integer", description: "0–100" },
63 + entities: { type: "array", items: { type: "string" } },
64 + keywords: { type: "array", items: { type: "string" } },
65 + announced: { type: ["boolean", "null"] },
66 + },
67 + required: ["event_type", "title", "summary", "why_it_matters", "who_it_affects", "observed", "inferred", "meaningful", "severity", "confidence", "entities", "keywords", "announced"],
68 +} as const;
69 +
70 +let budgetDay = "";
71 +let budgetUsed = 0;
72 +
73 +async function loadBudget(): Promise<void> {
74 + const today = new Date().toISOString().slice(0, 10);
75 + if (budgetDay === today) return;
76 + budgetDay = today;
77 + const r = await db.execute<{ n: string }>(sql`select count(*)::text as n from llm_usage where at >= current_date`);
78 + budgetUsed = Number(r.rows[0]?.n ?? 0);
79 +}
80 +
81 +export function llmAvailable(): boolean {
82 + return client !== null;
83 +}
84 +
85 +export async function interpretChange(input: { sourceName: string; sourceCategories: string[]; url: string; sensorName: string; sensorType: string; heuristic: HeuristicResult; diff: DiffResult; prelimImportance: number; title?: string | null }): Promise<Interpretation | null> {
86 + if (!client) return null;
87 + await loadBudget();
88 + if (budgetUsed >= config.llm.dailyCallBudget) {
89 + log.warn({ used: budgetUsed }, "LLM daily budget exhausted — heuristics only");
90 + return null;
91 + }
92 + const deep = input.prelimImportance >= config.llm.deepMinImportance;
93 + const model = deep ? config.llm.modelDeep : config.llm.modelFast;
94 + const diffText = renderDiff(input.diff).slice(0, 14_000);
95 + const user = `SOURCE: ${input.sourceName} (${input.sourceCategories.join(", ") || "n/a"})
96 +URL: ${input.url}
97 +SENSOR: ${input.sensorName} [${input.sensorType}]${input.title ? `\nPAGE TITLE: ${input.title}` : ""}
98 +HEURISTIC: type=${input.heuristic.eventType} signal=${input.heuristic.signal.toFixed(2)} magnitude=${input.heuristic.magnitude} noise=${input.heuristic.noiseRatio.toFixed(2)} reasons=${input.heuristic.reasons.join("; ") || "none"}
99 +FACTS: ${input.heuristic.facts.map((f) => `${f.kind}: ${f.before ?? "∅"} → ${f.after ?? "∅"}`).join(" | ") || "none"}
100 +
101 +DIFF:
102 +${diffText}`;
103 +
104 + budgetUsed++;
105 + const started = Date.now();
106 + try {
107 + // Haiku 4.5 does not accept `effort`; the deep model (Opus 5) runs adaptive thinking at medium effort.
108 + const params: Anthropic.MessageCreateParamsNonStreaming = {
109 + model,
110 + max_tokens: 1500,
111 + system: [{ type: "text", text: SYSTEM, cache_control: { type: "ephemeral" } }],
112 + messages: [{ role: "user", content: user }],
113 + output_config: { format: { type: "json_schema", schema: schema as unknown as Record<string, unknown> }, ...(deep ? { effort: "medium" as const } : {}) },
114 + };
115 + const res = await client.messages.create(params);
116 + const text = res.content.find((b) => b.type === "text")?.text ?? "";
117 + if (res.stop_reason === "refusal" || !text) throw new Error(`no text (stop_reason=${res.stop_reason})`);
118 + const parsed = JSON.parse(text) as Omit<Interpretation, "model" | "input_tokens" | "output_tokens">;
119 + const usage = { input: res.usage.input_tokens + (res.usage.cache_read_input_tokens ?? 0) + (res.usage.cache_creation_input_tokens ?? 0), output: res.usage.output_tokens };
120 + m.llmCalls.inc({ model, ok: "true" });
121 + m.llmTokens.inc({ model, direction: "input" }, usage.input);
122 + m.llmTokens.inc({ model, direction: "output" }, usage.output);
123 + await db.insert(llmUsage).values({ model, purpose: "interpret", inputTokens: usage.input, outputTokens: usage.output, ok: true });
124 + await bumpDaily({ llm_calls: 1, llm_input_tokens: usage.input, llm_output_tokens: usage.output });
125 + log.debug({ model, ms: Date.now() - started, type: parsed.event_type, meaningful: parsed.meaningful }, "llm interpretation");
126 + if (!(parsed.event_type in EVENT_TYPES)) parsed.event_type = input.heuristic.eventType;
127 + parsed.severity = Math.max(0, Math.min(100, Math.round(Number(parsed.severity) || 0)));
128 + parsed.confidence = Math.max(0, Math.min(100, Math.round(Number(parsed.confidence) || 0)));
129 + return { ...parsed, model, input_tokens: usage.input, output_tokens: usage.output };
130 + } catch (e) {
131 + m.llmCalls.inc({ model, ok: "false" });
132 + await db.insert(llmUsage).values({ model, purpose: "interpret", inputTokens: 0, outputTokens: 0, ok: false }).catch(() => undefined);
133 + const err = e as Error & { status?: number };
134 + log.warn({ model, status: err.status, err: err.message }, "llm interpretation failed — falling back to heuristics");
135 + return null;
136 + }
137 +}
138 +
139 +export function renderDiff(d: DiffResult): string {
140 + if (d.kind === "text") {
141 + const lines: string[] = [];
142 + for (const mo of d.modified.slice(0, 60)) lines.push(`- ${mo.before}\n+ ${mo.after}`);
143 + for (const r of d.removed.slice(0, 60)) lines.push(`- ${r}`);
144 + for (const a of d.added.slice(0, 80)) lines.push(`+ ${a}`);
145 + return lines.join("\n");
146 + }
147 + if (d.kind === "json") return d.unified;
148 + const lines: string[] = [];
149 + for (const a of d.added.slice(0, 30)) lines.push(`+ NEW: ${String(a.title ?? a.url ?? a.key)}${a.summary ? `\n ${String(a.summary).slice(0, 500)}` : ""}${a.url ? `\n ${String(a.url)}` : ""}${a.publishedAt ? `\n published ${String(a.publishedAt)}` : ""}`);
150 + for (const r of d.removed.slice(0, 30)) lines.push(`- REMOVED: ${String(r.title ?? r.url ?? r.key)}`);
151 + for (const mo of d.modified.slice(0, 30)) lines.push(`~ UPDATED: ${String(mo.after.title ?? mo.key)} (${mo.fields.join(", ")})\n before: ${mo.fields.map((f) => `${f}=${JSON.stringify(mo.before[f])}`).join(" ")}\n after: ${mo.fields.map((f) => `${f}=${JSON.stringify(mo.after[f])}`).join(" ")}`);
152 + return lines.join("\n");
153 +}
added apps/engine/src/metrics.ts +46 −0
@@ -0,0 +1,46 @@
1 +import Fastify from "fastify";
2 +import client from "prom-client";
3 +import { db, sql } from "@websensor/db";
4 +import { config, log } from "./config";
5 +
6 +export const registry = new client.Registry();
7 +client.collectDefaultMetrics({ register: registry, prefix: "websensor_engine_" });
8 +
9 +export const m = {
10 + checks: new client.Counter({ name: "websensor_checks_total", help: "Sensor checks", labelNames: ["connector", "outcome"], registers: [registry] }),
11 + bytes: new client.Counter({ name: "websensor_fetched_bytes_total", help: "Bytes fetched", labelNames: ["connector"], registers: [registry] }),
12 + changes: new client.Counter({ name: "websensor_changes_total", help: "Raw changes", labelNames: ["connector", "meaningful"], registers: [registry] }),
13 + events: new client.Counter({ name: "websensor_events_total", help: "Events published", labelNames: ["event_type", "silent"], registers: [registry] }),
14 + llmCalls: new client.Counter({ name: "websensor_llm_calls_total", help: "LLM calls", labelNames: ["model", "ok"], registers: [registry] }),
15 + llmTokens: new client.Counter({ name: "websensor_llm_tokens_total", help: "LLM tokens", labelNames: ["model", "direction"], registers: [registry] }),
16 + fetchDuration: new client.Histogram({ name: "websensor_fetch_duration_seconds", help: "Fetch latency", labelNames: ["connector"], buckets: [0.1, 0.25, 0.5, 1, 2, 5, 10, 25], registers: [registry] }),
17 + processingLatency: new client.Histogram({ name: "websensor_processing_latency_seconds", help: "Detected → published", buckets: [0.05, 0.1, 0.25, 0.5, 1, 2, 5, 15], registers: [registry] }),
18 + queueDue: new client.Gauge({ name: "websensor_sensors_due", help: "Sensors due for a check", registers: [registry] }),
19 + inflight: new client.Gauge({ name: "websensor_inflight_checks", help: "Checks in flight", registers: [registry] }),
20 + httpStatus: new client.Counter({ name: "websensor_http_status_total", help: "HTTP status codes", labelNames: ["status"], registers: [registry] }),
21 +};
22 +
23 +/** Daily counters in Postgres (feed the public homepage stats). */
24 +export async function bumpDaily(fields: Partial<Record<"checks" | "not_modified" | "bytes" | "raw_changes" | "events" | "silent_events" | "errors" | "llm_calls" | "llm_input_tokens" | "llm_output_tokens" | "scrapfly_calls", number>>): Promise<void> {
25 + const keys = Object.keys(fields) as (keyof typeof fields)[];
26 + if (!keys.length) return;
27 + const sets = keys.map((k) => sql.raw(`${k} = metrics_daily.${k} + ${Number(fields[k] ?? 0)}`));
28 + const cols = keys.map((k) => sql.raw(k));
29 + const vals = keys.map((k) => sql`${Number(fields[k] ?? 0)}`);
30 + try {
31 + await db.execute(sql`insert into metrics_daily (day, ${sql.join(cols, sql`, `)}) values (current_date, ${sql.join(vals, sql`, `)}) on conflict (day) do update set ${sql.join(sets, sql`, `)}`);
32 + } catch (e) {
33 + log.warn({ err: (e as Error).message }, "metrics_daily update failed");
34 + }
35 +}
36 +
37 +export async function startMetricsServer(): Promise<void> {
38 + const app = Fastify({ logger: false });
39 + app.get("/metrics", async (_req, reply) => {
40 + reply.header("content-type", registry.contentType);
41 + return registry.metrics();
42 + });
43 + app.get("/health", async () => ({ status: "ok", service: "engine", version: config.version, time: new Date().toISOString() }));
44 + await app.listen({ port: config.metricsPort, host: config.metricsHost });
45 + log.info({ port: config.metricsPort }, "engine metrics listening");
46 +}
added apps/engine/src/pipeline.ts +404 −0
Large diff (404 lines) is not loaded by default to keep the page fast.Load diff
added apps/engine/src/redis.ts +41 −0
Large diff (41 lines) is not loaded by default to keep the page fast.Load diff
added apps/engine/src/registry.ts +179 −0
Large diff (179 lines) is not loaded by default to keep the page fast.Load diff
added apps/engine/src/scheduler.ts +162 −0
Large diff (162 lines) is not loaded by default to keep the page fast.Load diff
added apps/engine/tsconfig.json +5 −0
Large diff (5 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/README.md +18 −0
Large diff (18 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/eslint.config.mjs +6 −0
Large diff (6 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/next.config.ts +23 −0
Large diff (23 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/package.json +32 −0
Large diff (32 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/postcss.config.mjs +2 −0
Large diff (2 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/app/alerts/alerts.tsx +186 −0
Large diff (186 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/app/alerts/page.tsx +14 −0
Large diff (14 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/app/api/page.tsx +131 −0
Large diff (131 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/app/bot/page.tsx +36 −0
Large diff (36 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/app/breaking/page.tsx +17 −0
Large diff (17 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/app/category/[channel]/page.tsx +29 −0
Large diff (29 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/app/company/[id]/page.tsx +111 −0
Large diff (111 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/app/compare/page.tsx +24 −0
Large diff (24 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/app/domain/[domain]/page.tsx +51 −0
Large diff (51 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/app/entities/page.tsx +50 −0
Large diff (50 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/app/error.tsx +14 −0
Large diff (14 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/app/event/[slug]/page.tsx +254 −0
Large diff (254 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/app/explore/page.tsx +112 −0
Large diff (112 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/app/globals.css +161 −0
Large diff (161 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/app/health/page.tsx +107 −0
Large diff (107 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/app/icon.svg +1 −0
Large diff (1 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/app/layout.tsx +39 −0
Large diff (39 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/app/manifest.ts +14 −0
Large diff (14 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/app/not-found.tsx +15 −0
Large diff (15 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/app/opengraph-image.tsx +33 −0
Large diff (33 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/app/page.tsx +28 −0
Large diff (28 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/app/robots.ts +10 −0
Large diff (10 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/app/search/page.tsx +74 −0
Large diff (74 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/app/sensor/[id]/page.tsx +105 −0
Large diff (105 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/app/silent/page.tsx +17 −0
Large diff (17 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/app/sitemap.ts +19 −0
Large diff (19 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/app/source/[id]/page.tsx +134 −0
Large diff (134 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/app/sources/page.tsx +53 −0
Large diff (53 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/app/timeline/[id]/page.tsx +33 −0
Large diff (33 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/app/url/compare-form.tsx +35 −0
Large diff (35 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/app/url/page.tsx +72 −0
Large diff (72 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/app/watchlists/page.tsx +14 −0
Large diff (14 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/app/watchlists/watchlists.tsx +154 −0
Large diff (154 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/components/code.tsx +27 −0
Large diff (27 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/components/diff-viewer.tsx +165 −0
Large diff (165 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/components/event-row.tsx +48 −0
Large diff (48 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/components/footer.tsx +21 −0
Large diff (21 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/components/live-feed.tsx +135 −0
Large diff (135 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/components/nav.tsx +105 −0
Large diff (105 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/components/rail.tsx +54 −0
Large diff (54 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/components/stats-strip.tsx +21 −0
Large diff (21 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/components/theme.tsx +30 −0
Large diff (30 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/components/timeline.tsx +38 −0
Large diff (38 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/components/ui.tsx +167 −0
Large diff (167 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/components/watch-button.tsx +54 −0
Large diff (54 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/lib/api.ts +491 −0
Large diff (491 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/lib/format.ts +143 −0
Large diff (143 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/lib/owner.ts +37 −0
Large diff (37 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/lib/use-live.ts +80 −0
Large diff (80 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/tsconfig.json +21 −0
Large diff (21 lines) is not loaded by default to keep the page fast.Load diff
added config/sources.yaml +2088 −0
Large diff (2,088 lines) is not loaded by default to keep the page fast.Load diff
added deploy/README.md +44 −0
Large diff (44 lines) is not loaded by default to keep the page fast.Load diff
added deploy/websensor.mld.json.example +128 −0
Large diff (128 lines) is not loaded by default to keep the page fast.Load diff
added docs/ARCHITECTURE.md +49 −0
Large diff (49 lines) is not loaded by default to keep the page fast.Load diff
added docs/connectors/discovery.md +8 −0
Large diff (8 lines) is not loaded by default to keep the page fast.Load diff
added docs/connectors/github.md +10 −0
Large diff (10 lines) is not loaded by default to keep the page fast.Load diff
added docs/connectors/http.md +16 −0
Large diff (16 lines) is not loaded by default to keep the page fast.Load diff
added docs/connectors/jsonlist.md +12 −0
Large diff (12 lines) is not loaded by default to keep the page fast.Load diff
added docs/connectors/rss.md +15 −0
Large diff (15 lines) is not loaded by default to keep the page fast.Load diff
added docs/connectors/scrapfly.md +8 −0
Large diff (8 lines) is not loaded by default to keep the page fast.Load diff
added docs/connectors/sitemap.md +12 −0
Large diff (12 lines) is not loaded by default to keep the page fast.Load diff
added docs/connectors/statuspage.md +12 −0
Large diff (12 lines) is not loaded by default to keep the page fast.Load diff
added package.json +31 −0
Large diff (31 lines) is not loaded by default to keep the page fast.Load diff
added packages/connectors/package.json +23 −0
Large diff (23 lines) is not loaded by default to keep the page fast.Load diff
added packages/connectors/src/connectors.test.ts +89 −0
Large diff (89 lines) is not loaded by default to keep the page fast.Load diff
added packages/connectors/src/discovery.ts +150 −0
Large diff (150 lines) is not loaded by default to keep the page fast.Load diff
added packages/connectors/src/fetcher.ts +238 −0
Large diff (238 lines) is not loaded by default to keep the page fast.Load diff
added packages/connectors/src/github.ts +64 −0
Large diff (64 lines) is not loaded by default to keep the page fast.Load diff
added packages/connectors/src/http.ts +100 −0
Large diff (100 lines) is not loaded by default to keep the page fast.Load diff
added packages/connectors/src/index.ts +38 −0
Large diff (38 lines) is not loaded by default to keep the page fast.Load diff
added packages/connectors/src/jsonlist.ts +89 −0
Large diff (89 lines) is not loaded by default to keep the page fast.Load diff
added packages/connectors/src/rss.ts +161 −0
Large diff (161 lines) is not loaded by default to keep the page fast.Load diff
added packages/connectors/src/scrapfly.ts +68 −0
Large diff (68 lines) is not loaded by default to keep the page fast.Load diff
added packages/connectors/src/sitemap.ts +106 −0
Large diff (106 lines) is not loaded by default to keep the page fast.Load diff
added packages/connectors/src/statuspage.ts +69 −0
Large diff (69 lines) is not loaded by default to keep the page fast.Load diff
added packages/connectors/src/types.ts +28 −0
Large diff (28 lines) is not loaded by default to keep the page fast.Load diff
added packages/connectors/src/xml.ts +65 −0
Large diff (65 lines) is not loaded by default to keep the page fast.Load diff
added packages/connectors/tsconfig.json +5 −0
Large diff (5 lines) is not loaded by default to keep the page fast.Load diff
added packages/core/package.json +26 −0
Large diff (26 lines) is not loaded by default to keep the page fast.Load diff
added packages/core/src/canonical.ts +196 −0
Large diff (196 lines) is not loaded by default to keep the page fast.Load diff
added packages/core/src/client.ts +8 −0
Large diff (8 lines) is not loaded by default to keep the page fast.Load diff
added packages/core/src/core.test.ts +131 −0
Large diff (131 lines) is not loaded by default to keep the page fast.Load diff
added packages/core/src/diff.ts +176 −0
Large diff (176 lines) is not loaded by default to keep the page fast.Load diff
added packages/core/src/hash.ts +65 −0
Large diff (65 lines) is not loaded by default to keep the page fast.Load diff
added packages/core/src/heuristics.ts +242 −0
Large diff (242 lines) is not loaded by default to keep the page fast.Load diff
added packages/core/src/ids.ts +36 −0
Large diff (36 lines) is not loaded by default to keep the page fast.Load diff
added packages/core/src/index.ts +10 −0
Large diff (10 lines) is not loaded by default to keep the page fast.Load diff
added packages/core/src/schedule.ts +53 −0
Large diff (53 lines) is not loaded by default to keep the page fast.Load diff
added packages/core/src/scoring.ts +116 −0
Large diff (116 lines) is not loaded by default to keep the page fast.Load diff
added packages/core/src/ssrf.ts +189 −0
Large diff (189 lines) is not loaded by default to keep the page fast.Load diff
added packages/core/src/taxonomy.ts +185 −0
Large diff (185 lines) is not loaded by default to keep the page fast.Load diff
added packages/core/src/types.ts +102 −0
Large diff (102 lines) is not loaded by default to keep the page fast.Load diff
added packages/core/tsconfig.json +5 −0
Large diff (5 lines) is not loaded by default to keep the page fast.Load diff
added packages/db/migrations/0001_init.sql +361 −0
Large diff (361 lines) is not loaded by default to keep the page fast.Load diff
added packages/db/migrations/0002_scrapfly.sql +1 −0
Large diff (1 lines) is not loaded by default to keep the page fast.Load diff
added packages/db/migrations/0003_latency_bigint.sql +2 −0
Large diff (2 lines) is not loaded by default to keep the page fast.Load diff
added packages/db/package.json +31 −0
Large diff (31 lines) is not loaded by default to keep the page fast.Load diff
added packages/db/src/index.ts +50 −0
Large diff (50 lines) is not loaded by default to keep the page fast.Load diff
added packages/db/src/migrate.ts +50 −0
Large diff (50 lines) is not loaded by default to keep the page fast.Load diff
added packages/db/src/schema.ts +342 −0
Large diff (342 lines) is not loaded by default to keep the page fast.Load diff
added packages/db/tsconfig.json +5 −0
Large diff (5 lines) is not loaded by default to keep the page fast.Load diff
added packages/store/package.json +21 −0
Large diff (21 lines) is not loaded by default to keep the page fast.Load diff
added packages/store/src/index.ts +69 −0
Large diff (69 lines) is not loaded by default to keep the page fast.Load diff
added packages/store/tsconfig.json +5 −0
Large diff (5 lines) is not loaded by default to keep the page fast.Load diff
added pnpm-lock.yaml +6482 −0
Large diff (6,482 lines) is not loaded by default to keep the page fast.Load diff
added pnpm-workspace.yaml +8 −0
Large diff (8 lines) is not loaded by default to keep the page fast.Load diff
added tests/fixtures/atom.xml +4 −0
Large diff (4 lines) is not loaded by default to keep the page fast.Load diff
added tests/fixtures/kev.json +3 −0
Large diff (3 lines) is not loaded by default to keep the page fast.Load diff
added tests/fixtures/rss-after.xml +5 −0
Large diff (5 lines) is not loaded by default to keep the page fast.Load diff
added tests/fixtures/rss-before.xml +5 −0
Large diff (5 lines) is not loaded by default to keep the page fast.Load diff
added tests/fixtures/sitemap.xml +6 −0
Large diff (6 lines) is not loaded by default to keep the page fast.Load diff
added tests/fixtures/statuspage.json +5 −0
Large diff (5 lines) is not loaded by default to keep the page fast.Load diff
added tsconfig.base.json +17 −0
Large diff (18 lines) is not loaded by default to keep the page fast.Load diff
Menu

Theme