/** * Live smoke + fixture capture for the g9 connectors (real router + crawl context, no database). * * pnpm tsx connectors/api/_g9-asia-watch-sneaker-lib/capture.ts [--limit 1] [--seeds a,b] [--mode probe|backfill] * [--name fixtureName] [--trim 12] [--no-save] [--cursor '{"json":1}'] [--lookup ] * * Payload arrays (rows/items/tiles/data/list/offers) are trimmed to --trim entries before saving so fixtures stay small * but remain genuine live captures. */ import { existsSync, readFileSync } from 'node:fs'; import path from 'node:path'; import { ConnectorMetaSchema, DOMAINS_DIR, DOMAINS_PATH, createCrawlContext, createRouter, loadDomains, setDomains, type ConnectorMeta, type RareIndexConnector } from '@rareindex/connectors'; import { saveFixture } from '@rareindex/connectors/testing'; /** Another group's domains.d fragment may be temporarily invalid; fall back to domains.json + our own fragment so smoke runs stay possible. */ function ensureDomains() { try { loadDomains(); } catch (err) { console.warn(`[capture] domains.d contains an invalid fragment (${err instanceof Error ? err.message.split('\n')[0] : String(err)}); using domains.json + g9 fragment only`); const base = JSON.parse(readFileSync(DOMAINS_PATH, 'utf8')) as { version?: string; defaults?: unknown; domains?: Record }; const frag = JSON.parse(readFileSync(path.join(DOMAINS_DIR, 'g9-asia-watch-sneaker.json'), 'utf8')) as { domains?: Record }; setDomains({ version: base.version ?? '1.0', defaults: (base.defaults ?? {}) as never, domains: { ...(base.domains ?? {}), ...(frag.domains ?? {}) } as never }); } } function loadEnv() { const p = path.resolve(process.cwd(), '.env'); if (!existsSync(p)) return; for (const line of readFileSync(p, 'utf8').split('\n')) { const m = line.match(/^([A-Z0-9_]+)=(.*)$/); if (m && !process.env[m[1]!]) process.env[m[1]!] = m[2]!.replace(/^"(.*)"$/, '$1'); } } function flag(name: string): string | undefined { const i = process.argv.indexOf(`--${name}`); return i >= 0 ? process.argv[i + 1] : undefined; } function trimPayload(payload: unknown, n: number): unknown { if (!payload || typeof payload !== 'object' || Array.isArray(payload)) return payload; const out: Record = { ...(payload as Record) }; for (const k of ['rows', 'items', 'tiles', 'data', 'list', 'offers', 'cards', 'hits', 'Items', 'products']) { if (Array.isArray(out[k]) && (out[k] as unknown[]).length > n) out[k] = (out[k] as unknown[]).slice(0, n); } return out; } async function main() { loadEnv(); ensureDomains(); const id = process.argv[2]; if (!id || id.startsWith('--')) throw new Error('usage: capture.ts [--limit N] [--seeds a,b] [--name x] [--trim N] [--no-save] [--lookup url]'); const limit = Number(flag('limit') ?? 1); const trim = Number(flag('trim') ?? 12); const save = !process.argv.includes('--no-save'); const seeds = flag('seeds')?.split(',').map((s) => s.trim()).filter(Boolean); const mode = (flag('mode') ?? 'probe') as 'probe' | 'incremental' | 'backfill'; const cursor = flag('cursor') ? (JSON.parse(flag('cursor')!) as Record) : undefined; const dir = ['api', 'firecrawl', 'scrapfly'].map((d) => path.resolve('connectors', d, id)).find((d) => existsSync(path.join(d, 'meta.json'))); if (!dir) throw new Error(`no meta.json for ${id}`); const metaRaw = JSON.parse(readFileSync(path.join(dir, 'meta.json'), 'utf8')) as Record; const meta: ConnectorMeta = ConnectorMetaSchema.parse({ ...metaRaw, module: metaRaw.module ?? `${path.basename(path.dirname(dir))}/${id}` }); const mod = (await import(path.join(dir, 'index.ts'))) as { default: (m: ConnectorMeta) => RareIndexConnector }; const connector = mod.default(meta); const router = createRouter({ firecrawlApiKey: process.env.FIRECRAWL_API_KEY, scrapflyApiKey: process.env.SCRAPFLY_API_KEY }); const ctx = createCrawlContext({ router, meta, options: { mode, limit, seeds, cursor } }); const lookupUrl = flag('lookup'); const raws = lookupUrl ? await connector.lookup!(lookupUrl, ctx) : null; let n = 0; const iter = raws ? (async function* () { for (const r of raws) yield r; })() : connector.crawl(ctx); for await (const raw of iter) { n++; const records = await connector.normalize({ ...raw, externalId: raw.externalId ?? null, fetchedAt: raw.fetchedAt ?? new Date() }); const kinds = records.reduce>((acc, r) => ((acc[r.kind] = (acc[r.kind] ?? 0) + 1), acc), {}); console.log(`\n[${id}] raw #${n} ${raw.url} engine=${raw.engine} http=${raw.httpStatus ?? '-'} → ${records.length} records ${JSON.stringify(kinds)}`); for (const r of records.slice(0, 4)) { const a = r as Record; const price = 'price' in r ? `${(r as { price: unknown }).price} ${(r as { currency?: string }).currency ?? ''}` : 'currentBid' in r ? `bid ${(r as { currentBid: unknown }).currentBid} ${(r as { currency?: string }).currency ?? ''}` : ''; const date = 'saleDate' in r ? (r as { saleDate: Date }).saleDate.toISOString().slice(0, 10) : 'endsAt' in r && (r as { endsAt: Date | null }).endsAt ? `ends ${(r as { endsAt: Date }).endsAt.toISOString().slice(0, 10)}` : ''; const attrs = 'attributes' in r ? JSON.stringify({ cat: r.attributes.categorySlug, brand: r.attributes.brand, ref: r.attributes.reference, ids: r.attributes.identifiers }).slice(0, 200) : ''; console.log(' -', r.kind, '|', String((a.rawTitle as string | undefined) ?? (a.title as string | undefined) ?? '').slice(0, 80), '|', price, '|', date, '|', attrs, '|', 'grade' in r && r.grade.grade ? `${r.grade.grader} ${r.grade.grade}` : ''); } if (save) { const name = flag('name') ? (n === 1 ? flag('name')! : `${flag('name')}-${n}`) : (raw.externalId ?? `raw-${n}`).replace(/[^a-z0-9]+/gi, '-').replace(/^-|-$/g, '').toLowerCase().slice(0, 80); const payload = trimPayload(raw.payload, trim); const trimmedRecords = await connector.normalize({ ...raw, payload, externalId: raw.externalId ?? null, fetchedAt: raw.fetchedAt ?? new Date() }); saveFixture(id, name, { raw: { url: raw.url, externalId: raw.externalId ?? null, kind: raw.kind, engine: raw.engine, fetchedAt: raw.fetchedAt ?? new Date(), payload }, expect: { minCount: Math.min(trimmedRecords.length, 1), kinds: [...new Set(trimmedRecords.map((r) => r.kind))] }, note: `Captured live from ${raw.url} by connectors/api/_g9-asia-watch-sneaker-lib/capture.ts (payload arrays trimmed to ${trim} entries).`, }); console.log(` saved fixture data/fixtures/${id}/${name}.json (${trimmedRecords.length} records after trim)`); } } console.log(`\n[${id}] done: ${n} raw records; engine stats ${JSON.stringify(ctx.engineStats)}; anomalies ${JSON.stringify(ctx.anomalies)}`); } main().catch((e) => { console.error(e); process.exit(1); });