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'; /** 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({}), }); export type ConnectorMeta = z.infer; 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) or text */ responseType?: 'json' | 'text'; } 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; } 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; 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 & { id?: string; connectorId?: string; sourceId?: string }; export type ConnectorFactory = () => RareIndexConnector | Promise;