Classifier: development-verb evidence covers investment commitments, infrastructure nouns, plural campuses/facilities, MW-noun phrases
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
14 changed files +175 −10
modified
apps/api/src/app.ts
+11 −1
@@ -17,6 +17,11 @@ import { facilityRoutes } from "./routes/public/facilities.js"; | ||
| 17 | 17 | import { graphRoutes } from "./routes/public/graph.js"; |
| 18 | 18 | import { activityRoutes } from "./routes/public/activity.js"; |
| 19 | 19 | import { discoveryRoutes } from "./routes/public/discovery.js"; |
| 20 | +import { marketRoutes } from "./routes/public/markets.js"; | |
| 21 | +import { intelligenceRoutes } from "./routes/public/intelligence.js"; | |
| 22 | +import { downloadRoutes } from "./routes/public/download.js"; | |
| 23 | +import { watchlistRoutes } from "./routes/public/watchlist.js"; | |
| 24 | +import { docsMetaRoutes } from "./routes/public/docs-meta.js"; | |
| 20 | 25 | import { adminRoutes } from "./routes/admin/index.js"; |
| 21 | 26 | |
| 22 | 27 | export interface BuildOptions { logger?: boolean; underPressure?: boolean } |
@@ -36,7 +41,7 @@ export async function buildApp(opts: BuildOptions = {}): Promise<FastifyInstance | ||
| 36 | 41 | app.setValidatorCompiler(() => () => true); |
| 37 | 42 | app.setSerializerCompiler(() => (data) => JSON.stringify(data)); |
| 38 | 43 | |
| 39 | − await app.register(cors, { origin: true, methods: ["GET", "HEAD", "OPTIONS", "POST", "PATCH"], allowedHeaders: ["content-type", "x-dci-admin-token", "if-none-match", "authorization"], exposedHeaders: ["etag", "x-cache", "x-route", "x-ratelimit-limit", "x-ratelimit-remaining"], maxAge: 86400 }); | |
| 44 | + await app.register(cors, { origin: true, credentials: true, methods: ["GET", "HEAD", "OPTIONS", "POST", "PATCH", "DELETE"], allowedHeaders: ["content-type", "x-dci-admin-token", "if-none-match", "authorization"], exposedHeaders: ["etag", "x-cache", "x-route", "x-ratelimit-limit", "x-ratelimit-remaining", "content-disposition"], maxAge: 86400 }); | |
| 40 | 45 | |
| 41 | 46 | await app.register(rateLimit, { |
| 42 | 47 | global: true, |
@@ -96,6 +101,11 @@ export async function buildApp(opts: BuildOptions = {}): Promise<FastifyInstance | ||
| 96 | 101 | await v1.register(graphRoutes); |
| 97 | 102 | await v1.register(activityRoutes); |
| 98 | 103 | await v1.register(discoveryRoutes); |
| 104 | + await v1.register(marketRoutes); | |
| 105 | + await v1.register(intelligenceRoutes); | |
| 106 | + await v1.register(downloadRoutes); | |
| 107 | + await v1.register(watchlistRoutes); | |
| 108 | + await v1.register(docsMetaRoutes); | |
| 99 | 109 | }, |
| 100 | 110 | { prefix: "/api/v1" }, |
| 101 | 111 | ); |
modified
apps/api/src/lib/route.ts
+73 −1
@@ -1,6 +1,7 @@ | ||
| 1 | 1 | /** |
| 2 | 2 | * Route helper for public GET endpoints: zod query parsing → cache (memory + Redis) → envelope → ETag. |
| 3 | − * The zod schema is also converted to JSON Schema for the OpenAPI document (validation itself is zod-only). | |
| 3 | + * The zod schema is also converted to JSON Schema for the OpenAPI document (validation itself is zod-only), and every | |
| 4 | + * public route is recorded in `routeRegistry` so `/docs-meta` can describe the API from the live route table. | |
| 4 | 5 | */ |
| 5 | 6 | import type { FastifyInstance, FastifyRequest } from "fastify"; |
| 6 | 7 | import { z, type ZodType } from "zod"; |
@@ -10,6 +11,14 @@ import { cached } from "../cache.js"; | ||
| 10 | 11 | |
| 11 | 12 | export interface RouteResult<T> { data: T; meta?: Record<string, unknown>; sources?: SourceRef[] } |
| 12 | 13 | |
| 14 | +export interface ResponseDoc { | |
| 15 | + /** name of the api-types.ts type carried in `data` (e.g. "FacilitySummary[]") */ | |
| 16 | + type: string; | |
| 17 | + description?: string; | |
| 18 | + /** small illustrative example of `data` (published in OpenAPI as x-example) */ | |
| 19 | + example?: unknown; | |
| 20 | +} | |
| 21 | + | |
| 13 | 22 | export interface PublicRouteOptions<Q> { |
| 14 | 23 | url: string; |
| 15 | 24 | ttl: number; |
@@ -18,6 +27,28 @@ export interface PublicRouteOptions<Q> { | ||
| 18 | 27 | description?: string; |
| 19 | 28 | tags?: string[]; |
| 20 | 29 | params?: Record<string, string>; |
| 30 | + response?: ResponseDoc; | |
| 31 | + /** override the prefix recorded in the route registry (default /api/v1) */ | |
| 32 | + prefix?: string; | |
| 33 | +} | |
| 34 | + | |
| 35 | +export interface RegisteredRoute { | |
| 36 | + method: "GET" | "POST" | "DELETE" | "PATCH"; | |
| 37 | + path: string; | |
| 38 | + summary: string; | |
| 39 | + description?: string; | |
| 40 | + group: string; | |
| 41 | + params: Array<{ name: string; in: "query" | "path"; type: string; description: string; example?: string }>; | |
| 42 | + responseType: string; | |
| 43 | + responseDescription?: string; | |
| 44 | + example?: unknown; | |
| 45 | +} | |
| 46 | + | |
| 47 | +/** Every public route registered through publicGet (or registerRoute), in registration order. */ | |
| 48 | +export const routeRegistry: RegisteredRoute[] = []; | |
| 49 | + | |
| 50 | +export function registerRoute(r: RegisteredRoute): void { | |
| 51 | + if (!routeRegistry.some((x) => x.method === r.method && x.path === r.path)) routeRegistry.push(r); | |
| 21 | 52 | } |
| 22 | 53 | |
| 23 | 54 | export function jsonSchemaOf(schema: ZodType | undefined): Record<string, unknown> | undefined { |
@@ -36,6 +67,35 @@ function paramsSchema(params?: Record<string, string>): Record<string, unknown> | ||
| 36 | 67 | return { type: "object", properties: Object.fromEntries(Object.entries(params).map(([k, d]) => [k, { type: "string", description: d }])), required: Object.keys(params) }; |
| 37 | 68 | } |
| 38 | 69 | |
| 70 | +/** OpenAPI response schema for the `{ data, meta, sources }` envelope. */ | |
| 71 | +export function envelopeSchema(doc?: ResponseDoc): Record<string, unknown> { | |
| 72 | + const schema: Record<string, unknown> = { | |
| 73 | + description: doc?.description ?? `Envelope with \`data\` = ${doc?.type ?? "object"} (see packages/core/src/api-types.ts).`, | |
| 74 | + type: "object", | |
| 75 | + properties: { | |
| 76 | + data: { description: doc?.type ?? "payload", oneOf: [{ type: "object", additionalProperties: true }, { type: "array", items: {} }] }, | |
| 77 | + meta: { type: "object", additionalProperties: true, properties: { total: { type: "integer" }, page: { type: "integer" }, perPage: { type: "integer" }, generatedAt: { type: "string", format: "date-time" }, methodology: { type: "string" } } }, | |
| 78 | + sources: { type: "array", items: { type: "object", additionalProperties: true, properties: { id: { type: "string" }, name: { type: "string" }, kind: { type: "string" }, license: { type: ["string", "null"] }, redistribution: { type: ["string", "null"] } } } }, | |
| 79 | + }, | |
| 80 | + required: ["data"], | |
| 81 | + }; | |
| 82 | + if (doc?.example !== undefined) schema["x-example"] = { data: doc.example, meta: { generatedAt: "2026-09-12T00:00:00.000Z" }, sources: [] }; | |
| 83 | + return schema; | |
| 84 | +} | |
| 85 | + | |
| 86 | +const ERROR_SCHEMA = { description: "Error", type: "object", properties: { error: { type: "string" }, statusCode: { type: "integer" }, details: {} }, required: ["error", "statusCode"] }; | |
| 87 | + | |
| 88 | +/** Describe the zod query schema as docs params. */ | |
| 89 | +export function queryParamsDoc(schema: ZodType | undefined): RegisteredRoute["params"] { | |
| 90 | + const js = jsonSchemaOf(schema); | |
| 91 | + const props = (js?.properties as Record<string, Record<string, unknown>> | undefined) ?? {}; | |
| 92 | + return Object.entries(props).map(([name, p]) => { | |
| 93 | + const t = Array.isArray(p.type) ? (p.type as string[]).filter((x) => x !== "null").join("|") : typeof p.type === "string" ? p.type : p.enum ? "enum" : p.anyOf ? "string" : "string"; | |
| 94 | + const desc = typeof p.description === "string" ? p.description : p.enum ? `one of ${(p.enum as unknown[]).join(", ")}` : ""; | |
| 95 | + return { name, in: "query" as const, type: t || "string", description: desc }; | |
| 96 | + }); | |
| 97 | +} | |
| 98 | + | |
| 39 | 99 | /** Register a cached public GET route. `handler` receives the parsed query and route params. */ |
| 40 | 100 | export function publicGet<Q = Record<string, never>>(app: FastifyInstance, opts: PublicRouteOptions<Q>, handler: (q: Q, params: Record<string, string>, req: FastifyRequest) => Promise<RouteResult<unknown>>): void { |
| 41 | 101 | const schema: Record<string, unknown> = { summary: opts.summary, tags: opts.tags ?? ["public"] }; |
@@ -44,6 +104,18 @@ export function publicGet<Q = Record<string, never>>(app: FastifyInstance, opts: | ||
| 44 | 104 | if (qs) schema.querystring = qs; |
| 45 | 105 | const ps = paramsSchema(opts.params); |
| 46 | 106 | if (ps) schema.params = ps; |
| 107 | + schema.response = { 200: envelopeSchema(opts.response), 400: ERROR_SCHEMA, 404: ERROR_SCHEMA }; | |
| 108 | + registerRoute({ | |
| 109 | + method: "GET", | |
| 110 | + path: `${opts.prefix ?? "/api/v1"}${opts.url}`, | |
| 111 | + summary: opts.summary, | |
| 112 | + description: opts.description, | |
| 113 | + group: opts.tags?.[0] ?? "public", | |
| 114 | + params: [...Object.entries(opts.params ?? {}).map(([name, d]) => ({ name, in: "path" as const, type: "string", description: d })), ...queryParamsDoc(opts.query as ZodType | undefined)], | |
| 115 | + responseType: opts.response?.type ?? "object", | |
| 116 | + responseDescription: opts.response?.description, | |
| 117 | + example: opts.response?.example, | |
| 118 | + }); | |
| 47 | 119 | app.get(opts.url, { schema }, async (req, reply) => { |
| 48 | 120 | const q = (opts.query ? parseQuery(opts.query, req.query) : {}) as Q; |
| 49 | 121 | const params = (req.params ?? {}) as Record<string, string>; |
added
apps/api/src/routes/public/docs-meta.ts
+6 −0
@@ -0,0 +1,6 @@ | ||
| 1 | +/** /docs-meta — endpoint catalogue generated from the route registry for the web API docs page. */ | |
| 2 | +import type { FastifyInstance } from "fastify"; | |
| 3 | + | |
| 4 | +export async function docsMetaRoutes(_app: FastifyInstance): Promise<void> { | |
| 5 | + // filled in by the intelligence implementation | |
| 6 | +} | |
added
apps/api/src/routes/public/download.ts
+6 −0
@@ -0,0 +1,6 @@ | ||
| 1 | +/** Dataset downloads (CSV / JSON / GeoJSON) with license gating. */ | |
| 2 | +import type { FastifyInstance } from "fastify"; | |
| 3 | + | |
| 4 | +export async function downloadRoutes(_app: FastifyInstance): Promise<void> { | |
| 5 | + // filled in by the intelligence implementation | |
| 6 | +} | |
added
apps/api/src/routes/public/intelligence.ts
+6 −0
@@ -0,0 +1,6 @@ | ||
| 1 | +/** Nearby, explore, AI index, power, connectivity, time machine. */ | |
| 2 | +import type { FastifyInstance } from "fastify"; | |
| 3 | + | |
| 4 | +export async function intelligenceRoutes(_app: FastifyInstance): Promise<void> { | |
| 5 | + // filled in by the intelligence implementation | |
| 6 | +} | |
added
apps/api/src/routes/public/markets.ts
+6 −0
@@ -0,0 +1,6 @@ | ||
| 1 | +/** Pulse, operator comparison, coverage report (owned by the aggregates group). */ | |
| 2 | +import type { FastifyInstance } from "fastify"; | |
| 3 | + | |
| 4 | +export async function marketRoutes(_app: FastifyInstance): Promise<void> { | |
| 5 | + // filled in by the aggregates implementation | |
| 6 | +} | |
added
apps/api/src/routes/public/watchlist.ts
+6 −0
@@ -0,0 +1,6 @@ | ||
| 1 | +/** Private cookie-scoped watchlist (no accounts). */ | |
| 2 | +import type { FastifyInstance } from "fastify"; | |
| 3 | + | |
| 4 | +export async function watchlistRoutes(_app: FastifyInstance): Promise<void> { | |
| 5 | + // filled in by the intelligence implementation | |
| 6 | +} | |
modified
apps/worker/src/configs.ts
+5 −2
@@ -3,7 +3,7 @@ | ||
| 3 | 3 | * GenericConnector) and mirror them into the `connectors` + `sources` tables for the admin UI / scheduler. |
| 4 | 4 | */ |
| 5 | 5 | import { existsSync } from "node:fs"; |
| 6 | −import { GenericConnector, getImplementation, getParser, loadConnectorConfigs, type Connector, type ConnectorConfig } from "@dci/connectors"; | |
| 6 | +import { GenericConnector, assertAgainstRegistry, getImplementation, getParser, loadConnectorConfigs, type Connector, type ConnectorConfig } from "@dci/connectors"; | |
| 7 | 7 | import { sha256, stableId } from "@dci/core"; |
| 8 | 8 | import { getDb, connectors as connectorsTable, sources as sourcesTable, sql, eq, type Db } from "@dci/db"; |
| 9 | 9 | import { getEnv } from "./env.js"; |
@@ -51,7 +51,10 @@ export function loadAllConnectors(opts: { reload?: boolean; dir?: string } = {}) | ||
| 51 | 51 | const dir = opts.dir ?? getEnv().configDir; |
| 52 | 52 | const out = new Map<string, LoadedConnector>(); |
| 53 | 53 | if (existsSync(dir)) { |
| 54 | − for (const cfg of loadConnectorConfigs(dir)) { const connector = buildConnector(cfg); out.set(cfg.id, { cfg, connector, sourceId: sourceIdFor(cfg.id), extractorVersion: effectiveExtractorVersion(cfg, connector) }); } | |
| 54 | + const cfgs = loadConnectorConfigs(dir); | |
| 55 | + // every `extractors.*.parser` / `implementation` must be registered (no-op while the registry is still empty) | |
| 56 | + assertAgainstRegistry(cfgs); | |
| 57 | + for (const cfg of cfgs) { const connector = buildConnector(cfg); out.set(cfg.id, { cfg, connector, sourceId: sourceIdFor(cfg.id), extractorVersion: effectiveExtractorVersion(cfg, connector) }); } | |
| 55 | 58 | } |
| 56 | 59 | cache = out; |
| 57 | 60 | return [...out.values()]; |
modified
apps/worker/src/documents.ts
+2 −1
@@ -152,7 +152,8 @@ export function planFetchBookkeeping(doc: Pick<DocumentRow, "changeFrequencyScor | ||
| 152 | 152 | const errorCount = failed ? (doc.errorCount ?? 0) + 1 : 0; |
| 153 | 153 | const error = hadError ? `${raw.error!.code}: ${raw.error!.message}`.slice(0, 500) : httpFail ? `HTTP ${raw.status}` : null; |
| 154 | 154 | const changeScore = failed ? doc.changeFrequencyScore : updateChangeScore(doc.changeFrequencyScore, outcome.changed); |
| 155 | − const nc = nextCheckAfter({ now, baseMs, changeScore, statusCode: hadError ? null : raw.status, consecutiveErrors: errorCount, hadError }); | |
| 155 | + const retryAfterMs = typeof raw.meta?.retryAfterMs === "number" ? raw.meta.retryAfterMs : null; | |
| 156 | + const nc = nextCheckAfter({ now, baseMs, changeScore, statusCode: hadError ? null : raw.status, consecutiveErrors: errorCount, hadError, retryAfterMs }); | |
| 156 | 157 | return { nextCheck: nc.nextCheck ? nc.nextCheck.toISOString() : null, quarantined: nc.quarantined, changeScore, errorCount, error, reason: nc.reason, changed: outcome.changed && !failed, notModified: raw.notModified, failed }; |
| 157 | 158 | } |
| 158 | 159 | |
modified
apps/worker/src/pipeline.ts
+6 −2
@@ -225,10 +225,14 @@ async function processDocument(ctx: RunContext, loaded: LoadedConnector, doc: Do | ||
| 225 | 225 | } catch { /* storage hiccup: fall through and treat as changed */ } |
| 226 | 226 | } |
| 227 | 227 | |
| 228 | − // archive raw body (dedupe by hash; unchanged pages reuse the existing key) | |
| 228 | + // archive raw body (dedupe by hash; unchanged pages reuse the existing key). Pages carrying an X-Robots-Tag / | |
| 229 | + // <meta name="robots"> `noarchive` (or `noindex`) directive are never archived — facts are still extracted below. | |
| 229 | 230 | let storageKey: string | null = doc.storageKey; |
| 230 | − if (!dryRun && (changed || !doc.storageKey)) { | |
| 231 | + const noarchive = raw.meta?.noarchive === true; | |
| 232 | + if (!dryRun && (changed || !doc.storageKey) && !noarchive) { | |
| 231 | 233 | try { storageKey = (await putRaw(cfg.id, doc.id, hash, raw.body, raw.contentType, raw.finalUrl)).key; } catch (e) { ctx.log("warn", `archive failed for ${doc.url}: ${(e as Error).message}`); } |
| 234 | + } else if (noarchive && (changed || !doc.storageKey)) { | |
| 235 | + ctx.log("debug", `${doc.url} not archived (${String(raw.meta?.robotsDirective ?? "noarchive")})`); | |
| 232 | 236 | } |
| 233 | 237 | |
| 234 | 238 | const skip = markupOnly || shouldSkipExtraction({ newHash: hash, storedHash: doc.contentHash, force, extractorVersion: loaded.extractorVersion, storedExtractorVersion: doc.extractorVersion, notModified: false }); |
modified
apps/worker/src/scheduling.ts
+7 −0
@@ -74,6 +74,8 @@ export interface NextCheckInput { | ||
| 74 | 74 | consecutiveErrors: number; |
| 75 | 75 | /** transport error (dns, timeout, blocked…) on this fetch */ |
| 76 | 76 | hadError: boolean; |
| 77 | + /** server-requested wait from `Retry-After` on a 429 (ms), when the fetcher exposed it (`doc.meta.retryAfterMs`) */ | |
| 78 | + retryAfterMs?: number | null; | |
| 77 | 79 | } |
| 78 | 80 | |
| 79 | 81 | export interface NextCheckResult { |
@@ -92,6 +94,11 @@ export function nextCheckAfter(i: NextCheckInput): NextCheckResult { | ||
| 92 | 94 | if (i.consecutiveErrors >= MAX_CONSECUTIVE_GONE) return { nextCheck: null, quarantined: true, reason: `gone ×${i.consecutiveErrors}` }; |
| 93 | 95 | return { nextCheck: new Date(i.now.getTime() + Math.max(MIN_INTERVAL_MS, i.baseMs * 4)), quarantined: false, reason: "gone backoff ×4" }; |
| 94 | 96 | } |
| 97 | + if (i.statusCode === 429 && i.retryAfterMs != null && Number.isFinite(i.retryAfterMs) && i.retryAfterMs > 0) { | |
| 98 | + // the server told us when to come back: honour it (never sooner than the floor, never later than the error backoff cap) | |
| 99 | + const wait = Math.min(Math.max(MIN_INTERVAL_MS, i.retryAfterMs), Math.max(MIN_INTERVAL_MS, i.baseMs * 8)); | |
| 100 | + return { nextCheck: new Date(i.now.getTime() + wait), quarantined: false, reason: `retry-after ${Math.round(i.retryAfterMs / 1000)}s` }; | |
| 101 | + } | |
| 95 | 102 | if (i.hadError || (i.statusCode != null && i.statusCode >= 400)) { |
| 96 | 103 | // exponential backoff on errors: base × 2^(n-1), capped at 8× base |
| 97 | 104 | const mult = Math.min(8, 2 ** Math.max(0, i.consecutiveErrors - 1)); |
modified
packages/connectors/src/classify.ts
+1 −1
@@ -33,7 +33,7 @@ export const PLANNING_VOCAB_RE = /\b(planning|permit(s|ting)?|zoning|rezon\w*|ap | ||
| 33 | 33 | |
| 34 | 34 | const TEXT_RULES: Array<[RegExp, PageType]> = [ |
| 35 | 35 | [/\b(acquires|acquisition of|to acquire|completes acquisition|agreed to acquire|acquired|übernimmt|übernahme|rachète|acquisition de|adquiere|adquisición)\b/i, "acquisition"], |
| 36 | − [/\b(expansion|expands|expanding|adds? \d+ ?MW|additional (capacity|phase)|new phase|erweitert|erweiterung|agrandit|extension|ampliación|amplía|uitbreiding)\b/i, "expansion"], | |
| 36 | + [/\b(expansion|expands|expanding|adds? \d+ ?MW|additional (capacity|phase)|new phase|erweitert|erweiterung|agrandit|agrandissement|ampliación|amplía|uitbreiding)\b/i, "expansion"], | |
| 37 | 37 | [/\b(closes?|closure|closing|decommission|shut(ting)? down|exit(s|ing)? (the )?market)\b/i, "closure"], |
| 38 | 38 | [/\b(planning (application|permission|commission|board)|rezon(e|ing)|site plan|conditional use|environmental (assessment|impact)|permit(ting)?|bebauungsplan|permis de construire)\b/i, "planning_document"], |
| 39 | 39 | [/\b(under construction|breaks? ground|groundbreaking|topping out|construction (update|progress|milestone)|shell (is )?complete|spatenstich|im bau|en construction|en construcción|em construção|in aanbouw)\b/i, "construction_update"], |
modified
packages/connectors/src/config.ts
+38 −0
@@ -3,6 +3,7 @@ import { parse as parseYaml } from "yaml"; | ||
| 3 | 3 | import { readFileSync, readdirSync } from "node:fs"; |
| 4 | 4 | import { join } from "node:path"; |
| 5 | 5 | import { SOURCE_KINDS, PAGE_TYPES } from "@dci/core"; |
| 6 | +import { getImplementation, listImplementations, listParsers } from "./registry.js"; | |
| 6 | 7 | |
| 7 | 8 | /** |
| 8 | 9 | * Connector configuration (YAML in config/connectors/*.yaml). Declarative first; `parser:` names a |
@@ -107,6 +108,24 @@ export const connectorConfigSchema = z.object({ | ||
| 107 | 108 | implementation: z.string().optional(), |
| 108 | 109 | params: z.record(z.string(), z.unknown()).optional(), |
| 109 | 110 | parserVersion: z.string().default("v1"), |
| 111 | +}).superRefine((cfg, ctx) => { | |
| 112 | + // cross-field rules — a config that passes the schema must also be internally consistent | |
| 113 | + if (cfg.fetch.level > cfg.fetch.maxLevel) ctx.addIssue({ code: "custom", path: ["fetch", "level"], message: `fetch.level ${cfg.fetch.level} exceeds fetch.maxLevel ${cfg.fetch.maxLevel}` }); | |
| 114 | + if (cfg.fetch.maxCreditsPerDay !== undefined && cfg.fetch.maxCreditsPerDay < cfg.fetch.maxCreditsPerRun && cfg.fetch.maxLevel >= 3) ctx.addIssue({ code: "custom", path: ["fetch", "maxCreditsPerRun"], message: `fetch.maxCreditsPerRun ${cfg.fetch.maxCreditsPerRun} exceeds fetch.maxCreditsPerDay ${cfg.fetch.maxCreditsPerDay}` }); | |
| 115 | + for (const s of cfg.discovery.seeds) { const lvl = typeof s === "object" ? s.minLevel : undefined; if (lvl !== undefined && lvl > cfg.fetch.maxLevel) ctx.addIssue({ code: "custom", path: ["discovery", "seeds"], message: `seed ${typeof s === "object" ? s.url : s} minLevel ${lvl} exceeds fetch.maxLevel ${cfg.fetch.maxLevel}` }); } | |
| 116 | + cfg.discovery.classify.forEach((r, i) => { if (r.minLevel !== undefined && r.minLevel > cfg.fetch.maxLevel) ctx.addIssue({ code: "custom", path: ["discovery", "classify", i, "minLevel"], message: `minLevel ${r.minLevel} exceeds fetch.maxLevel ${cfg.fetch.maxLevel}` }); }); | |
| 117 | + // every regex is compiled once here so a bad pattern fails `dci validate` instead of throwing mid-discovery | |
| 118 | + const tryRe = (pattern: string, path: Array<string | number>) => { try { new RegExp(pattern, "i"); } catch (e) { ctx.addIssue({ code: "custom", path, message: `invalid regex /${pattern}/: ${(e as Error).message}` }); } }; | |
| 119 | + cfg.discovery.include.forEach((p, i) => tryRe(p, ["discovery", "include", i])); | |
| 120 | + cfg.discovery.exclude.forEach((p, i) => tryRe(p, ["discovery", "exclude", i])); | |
| 121 | + cfg.discovery.follow.forEach((p, i) => tryRe(p, ["discovery", "follow", i])); | |
| 122 | + cfg.discovery.classify.forEach((r, i) => tryRe(r.pattern, ["discovery", "classify", i, "pattern"])); | |
| 123 | + for (const [name, ex] of Object.entries(cfg.extractors)) { | |
| 124 | + ex.match?.forEach((p, i) => tryRe(p, ["extractors", name, "match", i])); | |
| 125 | + if (!ex.parser && !ex.fields) ctx.addIssue({ code: "custom", path: ["extractors", name], message: "extractor needs either `parser` or `fields`" }); | |
| 126 | + for (const [field, rule] of Object.entries(ex.fields ?? {})) if (typeof rule === "object" && rule.regex) { try { new RegExp(rule.regex, rule.regexFlags ?? "i"); } catch (e) { ctx.addIssue({ code: "custom", path: ["extractors", name, "fields", field, "regex"], message: `invalid regex: ${(e as Error).message}` }); } } | |
| 127 | + } | |
| 128 | + for (const [group, spec] of Object.entries(cfg.schedule)) { try { intervalMs(spec); } catch { ctx.addIssue({ code: "custom", path: ["schedule", group], message: `bad interval "${spec}" (daily | weekly | monthly | 6h | 30m …)` }); } } | |
| 110 | 129 | }); |
| 111 | 130 | |
| 112 | 131 | export type ConnectorConfig = z.infer<typeof connectorConfigSchema>; |
@@ -120,6 +139,25 @@ export function parseConnectorConfig(text: string, file = "<inline>"): Connector | ||
| 120 | 139 | return res.data; |
| 121 | 140 | } |
| 122 | 141 | |
| 142 | +/** | |
| 143 | + * Registry-dependent checks, run lazily once parsers / implementations are registered (the YAML schema cannot know | |
| 144 | + * them): every `extractors.*.parser` and the `implementation` must exist. Returns the list of problems (empty = ok); | |
| 145 | + * `assertAgainstRegistry` throws one clear error listing them. When the registry is still empty the check is skipped | |
| 146 | + * (returns []), so configs can be parsed before the worker groups are loaded. | |
| 147 | + */ | |
| 148 | +export function validateAgainstRegistry(cfg: ConnectorConfig): string[] { | |
| 149 | + if (!listParsers().length && !listImplementations().length) return []; | |
| 150 | + const problems: string[] = []; | |
| 151 | + const known = new Set(listParsers().map((p) => p.name)); | |
| 152 | + for (const [name, ex] of Object.entries(cfg.extractors)) if (ex.parser && !known.has(ex.parser)) problems.push(`extractors.${name}.parser "${ex.parser}" is not registered`); | |
| 153 | + if (cfg.implementation && !getImplementation(cfg.implementation)) problems.push(`implementation "${cfg.implementation}" is not registered`); | |
| 154 | + return problems; | |
| 155 | +} | |
| 156 | +export function assertAgainstRegistry(cfgs: ConnectorConfig[]): void { | |
| 157 | + const all = cfgs.flatMap((c) => validateAgainstRegistry(c).map((p) => `${c.id}: ${p}`)); | |
| 158 | + if (all.length) throw new Error(`connector config(s) reference unknown parsers/implementations — ${all.join("; ")}. Registered parsers: ${listParsers().map((p) => p.name).sort().join(", ") || "none"}`); | |
| 159 | +} | |
| 160 | + | |
| 123 | 161 | export function loadConnectorConfigs(dir: string): ConnectorConfig[] { |
| 124 | 162 | const out: ConnectorConfig[] = []; |
| 125 | 163 | const seen = new Set<string>(); |
modified
packages/core/src/claims.ts
+2 −2
@@ -332,11 +332,11 @@ const CLASS_RULES: Array<[ProjectClass, RegExp]> = [ | ||
| 332 | 332 | ["PERMIT", /\b(planning (?:application|permission|approval|consent|committee|commission|board|inquiry)|permit(?:s|ted|ting)?\b|rezon\w+|zoning (?:approval|change|request|case|board|commission)|(?:files?|filed|submits?|submitted|lodges?|lodged) (?:plans?|an application|a planning|planning|for approval|proposal)|(?:approv(?:es|ed|al)|green-?light(?:s|ed)?|consent(?:s|ed)?|clears?|cleared|greenlit|(?:votes?|voted) to approve|unanimously approved|signs? off|sign-off) (?:for |of |on )?(?:a |the |its |\d+[ -]?(?:mw|gw) )?(?:data ?cent(?:er|re)|campus|project|plans?|development|proposal|application|rezoning|site plan|special use|conditional use)|environmental (?:impact|assessment|review|permit)|eia\b|site plan (?:approval|review)|special (?:use|exception) permit|conditional use permit|comprehensive plan amendment|public hearing|(?:county|city|town|council|board|commission|supervisors) (?:approves?|approved|rejects?|rejected|denies|denied|defers?|deferred|delays?|tables?|tabled))\b/i], |
| 333 | 333 | ["CONSTRUCTION_START", /\b(breaks? ground|broke ground|groundbreaking|ground-?breaking|construction (?:has |have )?(?:started|begins?|began|begun|commenced|commences|kicks? off|is under ?way|under ?way|gets under ?way)|(?:begins?|began|starts?|started|commences?|commenced|kicks? off) (?:construction|building|work|site work|vertical construction)|under construction|topped out|topping out|tops out|steel (?:erection|going up)|first concrete|shovels in the ground|site work (?:begins|has begun|under ?way)|construction (?:milestone|update|progress))\b/i], |
| 334 | 334 | ["EXPANSION", /\b(expan(?:sion|ds?|ded|ding)|adds? (?:\d+[ -]?(?:mw|gw)|capacity|a (?:second|third|fourth|new) (?:building|data hall|phase))|additional (?:\d+[ -]?(?:mw|gw)|capacity|building|data hall|phase)|(?:second|third|fourth|fifth|next|new) (?:phase|building|data hall|hall|facility (?:at|on|in) (?:its|the) (?:existing )?campus)|phase (?:2|3|4|ii|iii|iv|two|three|four)\b|(?:grows?|growing|scales?|scaling|extends?|extending|enlarges?|doubles?|doubling|triples?|tripling) (?:its |the )?(?:capacity|campus|footprint at|data cent(?:er|re)|site|facility)|(?:grows?|expands?|extends?) [A-Z][\w .-]{0,40}?(?:campus|data cent(?:er|re)|facility|site)\b|more capacity (?:at|in|to)|further (?:capacity|building|facility|phase)|extension (?:of|to) (?:its|the) (?:existing )?(?:data cent(?:er|re)|campus|facility|site))\b/i], |
| 335 | − ["NEW_BUILD", /\b((?:to|will|would|could|may|might|plans? to|set to|aims? to|intends? to|proposes? to|wants? to|is|are|is expected to|expected to) (?:build|construct|develop|create|deliver|establish|open|erect|bring)|(?:plans?|planned|planning|proposes?|proposed|proposal|announces?|announced|unveils?|unveiled|reveals?|revealed|confirms?|confirmed|launches?|launched|commits? to|committed to|pledges?|earmarks?|eyes|eyeing|mulls?|weighs?|considering|exploring|in talks) (?:for |to build |to develop |to construct |to invest in |to open |a |an |its |the |new |first |second |two |three |\d+[ -]?(?:mw|gw) |\$[\d.,]+ ?(?:m|bn|b|million|billion)? |€[\d.,]+ ?(?:m|bn|b|million|billion)? |£[\d.,]+ ?(?:m|bn|b|million|billion)? |hyperscale |ai |massive |major |huge |giant |sprawling |\w+-acre )*(?:data ?cent(?:er|re)s?|campus|campuses|facility|facilities|ai factory|ai factories|hub|site|development|project|complex|cluster|supercomputer)|new (?:\d+[ -]?(?:mw|gw) |hyperscale |ai |\$[\d.,]+ ?(?:m|bn|b|million|billion) )?(?:data ?cent(?:er|re)|campus|facility|ai factory|hub) (?:in|near|at|for|to|planned|proposed|coming|slated|set)|(?:\d+[ -]?(?:mw|gw)) (?:ai )?(?:data ?cent(?:er|re)|campus|facility|site|project|development|hub) (?:in|near|at|for|planned|proposed)|(?:build|building|develop|developing|construct|constructing|open|opening) (?:a |an |its |the |new |first |\d+[ -]?(?:mw|gw) )*(?:data ?cent(?:er|re)|campus|facility|ai factory) (?:in|near|at|on|for)|first (?:data ?cent(?:er|re)|campus|facility) in|(?:enters?|entering|entry into) (?:the )?[A-Z][\w.-]+ (?:market|with)|coming to|slated for|(?:to|will|would|could) (?:house|host|feature|include) (?:a |an |up to )?(?:\d+[ -]?(?:mw|gw)|data ?cent(?:er|re)|campus))\b/i], | |
| 335 | + ["NEW_BUILD", /\b((?:to|will|would|could|may|might|plans? to|set to|aims? to|intends? to|proposes? to|wants? to|is|are|is expected to|expected to) (?:build|construct|develop|create|deliver|establish|open|erect|bring)|(?:plans?|planned|planning|proposes?|proposed|proposal|announces?|announced|unveils?|unveiled|reveals?|revealed|confirms?|confirmed|launches?|launched|commits? to|committed to|pledges?|earmarks?|eyes|eyeing|mulls?|weighs?|considering|exploring|in talks) (?:for |to build |to develop |to construct |to invest in |to open |a |an |its |the |new |first |second |two |three |\d+[ -]?(?:mw|gw) |\$[\d.,]+ ?(?:m|bn|b|million|billion)? |€[\d.,]+ ?(?:m|bn|b|million|billion)? |£[\d.,]+ ?(?:m|bn|b|million|billion)? |at least [€$£][\d.,]+ ?(?:m|bn|b|million|billion)? |hyperscale |ai |massive |major |huge |giant |sprawling |\w+-acre |[“"']?[\w.-]{2,14}[”"']?,? ){0,4}(?:data ?cent(?:er|re)s?|campus|campuses|mega-?campus|facility|facilities|ai factory|ai factories|(?:ai |digital |cloud |compute |sovereign )infrastructure|hub|site|development|project|complex|cluster|supercomputer|data hall)|new (?:\d+[ -]?(?:mw|gw) |hyperscale |ai |\$[\d.,]+ ?(?:m|bn|b|million|billion) )?(?:data ?cent(?:er|re)|campus|facility|ai factory|hub) (?:in|near|at|for|to|planned|proposed|coming|slated|set)|(?:\d+[ -]?(?:mw|gw)) (?:ai )?(?:data ?cent(?:er|re)|campus|facility|site|project|development|hub) (?:in|near|at|for|planned|proposed)|(?:build|building|develop|developing|construct|constructing|open|opening) (?:a |an |its |the |new |first |\d+[ -]?(?:mw|gw) )*(?:data ?cent(?:er|re)|campus|facility|ai factory) (?:in|near|at|on|for)|first (?:data ?cent(?:er|re)|campus|facility) in|(?:enters?|entering|entry into) (?:the )?[A-Z][\w.-]+ (?:market|with)|coming to|slated for|(?:to|will|would|could) (?:house|host|feature|include) (?:a |an |up to )?(?:\d+[ -]?(?:mw|gw)|data ?cent(?:er|re)|campus))\b/i], | |
| 336 | 336 | ]; |
| 337 | 337 | |
| 338 | 338 | /** Verbs / nouns that describe physical development (build, construct, break ground, expand, permit, approve, file, acquire land, energize…). */ |
| 339 | −export const DEVELOPMENT_VERB_RE = /\b(build|builds|building|built|construct\w*|develop\w*|break\w* ground|broke ground|groundbreaking|expan\w+|open|opens|opening|opened|permit\w*|approv\w+|file[sd]?|filing|rezon\w+|acquire[sd]? (?:\d[\d,.]*[ -]?(?:acre|hectare)s?|land|a site|the site|a parcel)|land (?:purchase|acquisition)|energi[sz]\w+|deliver\w*|commission\w*|top\w* out|phase|campus|data ?cent(?:er|re)s?|facility|site plan|planning application|zoning)\b/i; | |
| 339 | +export const DEVELOPMENT_VERB_RE = /\b(build|builds|building|built|construct\w*|develop\w*|break\w* ground|broke ground|groundbreaking|expan\w+|open|opens|opening|opened|permit\w*|approv\w+|file[sd]?|filing|rezon\w+|acquire[sd]? (?:\d[\d,.]*[ -]?(?:acre|hectare)s?|land|a site|the site|a parcel)|land (?:purchase|acquisition)|energi[sz]\w+|deliver\w*|commission\w*|top\w* out|takes? shape|rising|going up|nears? completion|commits? (?:at least |up to |over |more than )?[€$£]\d|invest\w* (?:at least |up to |over |more than )?[€$£]\d|(?:ai |digital |cloud |compute |sovereign )infrastructure|phase|campus(?:es)?|mega-?campus(?:es)?|data ?cent(?:er|re)s?|data hall|facilit(?:y|ies)|site plan|planning application|zoning|\d[\d.,]*\s?(?:mw|gw)\s+(?:[\w€$£,]+\s){0,3}(?:campus|project|data ?cent(?:er|re)|facility|site|hub|megacampus))\b/i; | |
| 340 | 340 | |
| 341 | 341 | export interface ProjectClassInput { |
| 342 | 342 | title: string | null | undefined; |
| 343 | 343 | |