import { existsSync, readFileSync } from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { z } from 'zod'; import { SourceTypeSchema } from '@rareindex/shared'; /** * Machine-readable source registry (SPEC §4): every candidate source we researched, implemented or * rejected, with the access/legal/technical facts needed to decide and to build a connector. * File: data/sources/sources.json. `docs/connectors/SOURCES.md` is generated from it. */ export const SourceStatusSchema = z.enum(['implemented', 'partial', 'planned', 'researching', 'rejected', 'blocked', 'gated']); export const PaginationStyleSchema = z.enum(['page_number', 'cursor', 'offset', 'infinite_scroll', 'sitemap', 'feed', 'bulk_file', 'none', 'unknown']); export const DifficultySchema = z.enum(['trivial', 'easy', 'medium', 'hard', 'very_hard']); export const AntiBotSchema = z.enum(['none', 'low', 'medium', 'high', 'blocking']); export const FreshnessSchema = z.enum(['realtime', 'hourly', 'daily', 'weekly', 'static', 'unknown']); export const SourceEntrySchema = z.object({ id: z.string().regex(/^[a-z0-9-]+$/), name: z.string(), domain: z.string(), country: z.string(), supportedCountries: z.array(z.string()).default([]), languages: z.array(z.string()).default(['en']), sourceType: SourceTypeSchema, categories: z.array(z.string()).min(1), subcategories: z.array(z.string()).default([]), api: z.object({ available: z.boolean(), docsUrl: z.string().nullable().default(null), auth: z.enum(['none', 'api_key', 'oauth', 'partner', 'login']).default('none'), notes: z.string().nullable().default(null) }).prefault({ available: false }), urls: z.object({ search: z.string().nullable().default(null), product: z.string().nullable().default(null), sold: z.string().nullable().default(null), sitemap: z.string().nullable().default(null), robots: z.string().nullable().default(null) }).prefault({}), data: z.object({ liveListings: z.boolean().default(false), soldResults: z.boolean().default(false), auctionResults: z.boolean().default(false), historical: z.enum(['none', 'months', 'years', 'decades']).default('none'), images: z.boolean().default(true), seller: z.boolean().default(false), saleDate: z.boolean().default(false), realizedPrice: z.boolean().default(false), fees: z.boolean().default(false), grading: z.boolean().default(false), certNumber: z.boolean().default(false), itemId: z.boolean().default(true), gtin: z.boolean().default(false), population: z.boolean().default(false), catalog: z.boolean().default(false), }).prefault({}), access: z.object({ pagination: PaginationStyleSchema.default('unknown'), jsRendering: z.boolean().default(false), antiBot: AntiBotSchema.default('none'), cloudflare: z.boolean().default(false), robots: z.string().nullable().default(null), rateLimit: z.string().nullable().default(null), preferredEngine: z.enum(['api', 'feed', 'firecrawl', 'scrapfly', 'shopify', 'woocommerce', 'browser', 'none']).default('api'), }).prefault({}), currency: z.array(z.string()).default(['USD']), freshness: FreshnessSchema.default('unknown'), priority: z.enum(['wave1', 'wave2', 'wave3', 'wave4', 'later']).default('later'), difficulty: DifficultySchema.default('medium'), /** 0–1 expected reliability of parsed data */ reliability: z.number().min(0).max(1).default(0.6), legal: z.string().nullable().default(null), status: SourceStatusSchema.default('researching'), /** connector ids implementing this source */ connectors: z.array(z.string()).default([]), /** why rejected/blocked, or what remains for partial */ statusReason: z.string().nullable().default(null), /** facts verified live (probe) vs desk research */ verified: z.boolean().default(false), verifiedAt: z.string().nullable().default(null), notes: z.string().nullable().default(null), }); export type SourceEntry = z.infer; export const SourcesFileSchema = z.object({ version: z.string().default('1.0'), updatedAt: z.string().optional(), sources: z.array(SourceEntrySchema) }); export type SourcesFile = z.infer; const here = path.dirname(fileURLToPath(import.meta.url)); export const SOURCES_PATH = path.resolve(here, '../../../data/sources/sources.json'); let cached: SourcesFile | null = null; export function loadSources(force = false): SourcesFile { if (cached && !force) return cached; if (!existsSync(SOURCES_PATH)) { cached = { version: '1.0', sources: [] }; return cached; } cached = SourcesFileSchema.parse(JSON.parse(readFileSync(SOURCES_PATH, 'utf8'))); const ids = new Set(); for (const s of cached.sources) { if (ids.has(s.id)) throw new Error(`sources.json: duplicate source id ${s.id}`); ids.add(s.id); } return cached; } export function getSource(id: string): SourceEntry | undefined { return loadSources().sources.find((s) => s.id === id); }