import { z } from 'zod'; import type { Logger } from '@rareindex/shared'; import { EngineSchema, SourceTypeSchema, type Engine, type ExtractionResult, type NormalizedRecord, type RawRecord, type ConnectorHealth, type RecordKind } from '@rareindex/shared'; /** Explicit connector capabilities (SPEC §5). Derived from the supports* flags when omitted. */ export const CapabilitySchema = z.enum([ 'search', 'live_listings', 'sold_listings', 'auction_results', 'grading_population', 'cert_lookup', 'catalog', 'images', 'seller_data', 'price_guide', 'historical_backfill', 'news', 'url_lookup', ]); export type Capability = z.infer; /** Refresh scheduling classes (SPEC §17). */ export const RefreshClassSchema = z.enum(['hot', 'active', 'normal', 'archive']); export type RefreshClass = z.infer; export const REFRESH_CLASS_MINUTES: Record = { hot: { min: 1, max: 5, default: 5 }, active: { min: 15, max: 60, default: 30 }, normal: { min: 360, max: 1440, default: 1440 }, archive: { min: 7 * 1440, max: 31 * 1440, default: 7 * 1440 }, }; export function refreshClassFor(minutes: number): RefreshClass { if (minutes <= 5) return 'hot'; if (minutes <= 60) return 'active'; if (minutes <= 1440) return 'normal'; return 'archive'; } /** Registry entry — mirrors connectors/registry.json (§106). */ export const ConnectorMetaSchema = z.object({ id: z.string().regex(/^[a-z0-9-]+$/), displayName: z.string(), sourceId: z.string(), sourceName: z.string(), sourceType: SourceTypeSchema, sourceUrl: z.string().url(), /** module path relative to /connectors, e.g. "api/scryfall" */ module: z.string(), enginePriority: z.array(EngineSchema).min(1), categories: z.array(z.string()).min(1), regions: z.array(z.string()).default([]), languages: z.array(z.string()).default(['en']), currency: z.array(z.string()).default(['USD']), supportsListings: z.boolean().default(false), supportsSold: z.boolean().default(false), supportsAuctions: z.boolean().default(false), supportsImages: z.boolean().default(true), supportsCatalog: z.boolean().default(false), supportsPopulation: z.boolean().default(false), supportsLookup: z.boolean().default(false), refreshFrequencyMinutes: z.number().int().positive().default(1440), priority: z.enum(['high', 'medium', 'low']).default('medium'), /** 0–1 baseline trust for the source (§143) */ trustScore: z.number().min(0).max(1).default(0.6), attributionRequired: z.boolean().default(true), termsUrl: z.string().optional(), /** legal/ethical notes: what we do and do not fetch from this source */ accessNotes: z.string().optional(), enabled: z.boolean().default(true), schemaVersion: z.string().default('1.0'), config: z.record(z.string(), z.unknown()).default({}), /** Explicit capabilities (SPEC §5); when empty they are derived from the supports* flags. */ capabilities: z.array(CapabilitySchema).default([]), /** Scheduling class (SPEC §17); derived from refreshFrequencyMinutes when omitted. */ refreshClass: RefreshClassSchema.optional(), /** Primary host (derived from sourceUrl by the registry builder). Key into connectors/domains.json. */ domain: z.string().optional(), /** Primary ISO country of the source (defaults to regions[0]). */ country: z.string().optional(), /** Environment variables the connector needs (API keys). Missing → connector reported as `disabled`, never crashes. */ requires: z.array(z.string()).default([]), /** Short human label of how data is acquired: "official API", "Shopify products.json", "Firecrawl markdown", "Scrapfly render"… */ acquisitionMethod: z.string().optional(), logoUrl: z.string().optional(), /** Does the source expose deep historical results worth a resumable backfill (SPEC §9)? */ historicalDepth: z.enum(['none', 'months', 'years', 'decades']).default('none'), }); export type ConnectorMeta = z.infer; /** Effective capability list: declared ∪ derived from the supports* flags (never fewer than the flags say). */ export function capabilitiesOf(meta: ConnectorMeta): Capability[] { const set = new Set(meta.capabilities); if (meta.supportsListings) set.add('live_listings'); if (meta.supportsSold) set.add('sold_listings'); if (meta.supportsAuctions) set.add('auction_results'); if (meta.supportsImages) set.add('images'); if (meta.supportsCatalog) set.add('catalog'); if (meta.supportsPopulation) set.add('grading_population'); if (meta.supportsLookup) set.add('url_lookup'); if (meta.historicalDepth !== 'none') set.add('historical_backfill'); return [...set]; } /** Effective refresh class (declared or derived from the refresh frequency). */ export function effectiveRefreshClass(meta: ConnectorMeta): RefreshClass { return meta.refreshClass ?? refreshClassFor(meta.refreshFrequencyMinutes); } /** Env variables declared in `requires` that are absent from process.env. Empty = runnable. */ export function missingRequirements(meta: ConnectorMeta, envSource: Record = process.env): string[] { return (meta.requires ?? []).filter((k) => !envSource[k] || !String(envSource[k]).trim()); } export function domainOf(url: string): string { try { return new URL(url).hostname.replace(/^www\./, '').toLowerCase(); } catch { return url; } } export const RegistrySchema = z.object({ version: z.string(), connectors: z.array(ConnectorMetaSchema), }); export type Registry = z.infer; /** What a connector yields while crawling; the worker adds ids, run id and persistence. */ export interface RawRecordInput { url: string; externalId?: string | null; kind: RecordKind; engine: Engine; httpStatus?: number | null; payload: unknown; /** optional large text (HTML/markdown) persisted to disk, not the DB */ snapshot?: string | null; fetchedAt?: Date; } export type QualityField = 'title' | 'price' | 'status' | 'date' | 'images' | 'description' | 'identifiers' | 'category' | 'currency'; export interface FetchOptions { /** Restrict/override the engines to try, in order. Defaults to meta.enginePriority. */ engines?: Engine[]; /** Fields the parser is expected to find; drives the quality score (§199). */ expect?: QualityField[]; /** Parse the fetched document; return the fields found so the router can score quality. */ parse?: (res: ExtractionResult) => Partial> | null; /** Minimum quality to accept an engine result before falling back (default 0.6). */ minQuality?: number; /** Engine hints */ renderJs?: boolean; waitForMs?: number; country?: string; headers?: Record; /** Firecrawl JSON extraction schema (when using the json format) */ jsonSchema?: Record; jsonPrompt?: string; /** Scrapfly extraction model (e.g. "product") */ extractionModel?: string; timeoutMs?: number; /** skip cache/budget checks */ force?: boolean; /** treat non-2xx as failure (default true) */ failOnHttpError?: boolean; /** Body for POST requests (api engine) */ method?: 'GET' | 'POST'; body?: unknown; /** Return type for api engine: json (default), text, or binary (PDF, gzip) */ responseType?: 'json' | 'text' | 'binary'; /** Bypass the per-domain circuit breaker / concurrency gate (health probes). */ ignoreCircuit?: boolean; } export interface CrawlOptions { mode: 'incremental' | 'backfill' | 'probe'; /** soft cap on records to yield (probe/tests) */ limit?: number; /** cursor persisted between runs (connector-defined shape) */ cursor?: Record; /** optional category filter (slugs) */ categories?: string[]; /** optional seed URLs/queries (manual runs) */ seeds?: string[]; } export interface EngineStats { attempts: number; success: number; credits: number; ms: number; /** HTTP status histogram, e.g. {"200": 40, "403": 2} (SPEC §12 HTTP error distribution) */ statuses?: Record; /** Anti-bot challenge / captcha / block pages detected (SPEC §12) */ blocked?: number; /** Requests refused by the per-domain circuit breaker */ circuitOpen?: number; } /** Backfill progress reported by connectors during `mode: 'backfill'` (SPEC §9). */ export interface BackfillProgress { /** last fully processed page/cursor position (connector-defined) */ page?: number; totalPages?: number | null; itemsProcessed?: number; /** oldest date reached so far (crawling backwards) */ reachedDate?: Date | null; cursor?: Record; } export interface CrawlContext { meta: ConnectorMeta; log: Logger; options: CrawlOptions; /** Routed fetch: api/http → firecrawl → scrapfly (§198). Records stats and cost. */ fetch(url: string, opts?: FetchOptions): Promise; /** Firecrawl search (when available) — returns urls + snippets/markdown. */ search?(query: string, opts?: { limit?: number; scrape?: boolean; country?: string }): Promise>; /** Firecrawl map — discover URLs of a site. */ map?(url: string, opts?: { search?: string; limit?: number }): Promise; /** Crawl budget: true when the URL content changed since last time or is due (§170). */ shouldFetch(url: string): Promise; /** Persist an updated cursor (called by connector at checkpoints). */ setCursor(cursor: Record): Promise; /** Report a non-fatal anomaly for health tracking (§105). */ anomaly(kind: string, detail?: string): void; /** Report backfill progress (SPEC §9). No-op outside backfill mode. */ progress(p: BackfillProgress): Promise; engineStats: Record; /** Cooperative cancellation */ signal?: AbortSignal; } export interface HealthContext { meta: ConnectorMeta; log: Logger; fetch(url: string, opts?: FetchOptions): Promise; /** Recent run metrics supplied by the worker (may be empty for a fresh connector). */ recentRuns: Array<{ startedAt: Date; status: string; pagesAttempted: number; pagesSuccess: number; recordsRaw: number; recordsDuplicate: number; engineStats: Record; anomalies: string[]; error: string | null }>; } /** * The connector contract (§102). Every source implements exactly this. Everything else — * persistence, dedupe, entity resolution, valuation — is generic pipeline code. */ export interface RareIndexConnector { readonly meta: ConnectorMeta; /** semantic version of connector code; bump when crawl logic changes */ readonly version: string; /** version of the parsing/normalisation logic; bump when field mapping changes */ readonly parserVersion: string; /** Yield raw records. Must be resumable through options.cursor. */ crawl(ctx: CrawlContext): AsyncIterable; /** Pure function: raw payload → canonical normalised records. Must be deterministic and fixture-testable. */ normalize(raw: RawRecordLike): Promise; /** Liveness/probe check. Base implementation derives status from recent runs and a light probe. */ healthCheck(ctx: HealthContext): Promise; /** Optional: resolve a single source URL (scanner/paste URL, §114). */ lookup?(url: string, ctx: CrawlContext): Promise; /** Optional: URL patterns this connector understands (for lookup routing). */ readonly urlPatterns?: RegExp[]; } export type RawRecordLike = Pick & { externalId?: string | null; id?: string; connectorId?: string; sourceId?: string }; export type ConnectorFactory = (meta: ConnectorMeta) => RareIndexConnector | Promise;