/** * Live capture + smoke for the wave-3 TCG connectors. Runs each connector in probe mode through the real * router/crawl context, saves small fixtures and prints sample normalized records. * Usage: pnpm tsx connectors/api/_lib/capture-k.ts [ids…] (default: all seven) */ import { createCrawlContext, createRouter, type RareIndexConnector, type RawRecordInput } from '@rareindex/connectors'; import { localMeta } from './local-meta.js'; import { saveFixture } from '@rareindex/connectors/testing'; import { childLogger } from '@rareindex/shared'; interface Plan { limit: number; seeds?: string[]; /** pick which raw records become fixtures: returns a fixture name or null */ pick: (raw: RawRecordInput, i: number) => string | null; kinds: string[]; requiredFields: string[]; } const PLANS: Record = { tcgcsv: { limit: 40, seeds: ['3'], pick: (raw, i) => { const p = raw.payload as { product: { name: string }; prices: unknown[] }; if (i === 0) return 'pokemon-first-product'; if (p.prices.length > 1 && /charizard|blastoise|venusaur/i.test(p.product.name)) return 'pokemon-multi-subtype'; return null; }, kinds: ['catalog_item', 'price_observation'], requiredFields: ['attributes.identifiers.tcgplayer_id', 'attributes.set'], }, digimoncard: { limit: 12, seeds: ['BT5'], pick: (_r, i) => (i === 0 ? 'bt5-first' : i === 5 ? 'bt5-sixth' : null), kinds: ['catalog_item'], requiredFields: ['attributes.identifiers.digimoncard_id', 'attributes.number'] }, 'swu-db': { limit: 8, seeds: ['SOR'], pick: (_r, i) => (i === 0 ? 'sor-first' : i === 3 ? 'sor-fourth' : null), kinds: ['catalog_item', 'price_observation'], requiredFields: ['attributes.setCode', 'attributes.number'] }, 'grand-archive': { limit: 6, pick: (_r, i) => (i === 0 ? 'page1-first' : i === 2 ? 'page1-third' : null), kinds: ['catalog_item'], requiredFields: ['attributes.identifiers.gatcg_edition_id'] }, 'sorcery-tcg': { limit: 6, pick: (_r, i) => (i === 0 ? 'first' : i === 5 ? 'sixth' : null), kinds: ['catalog_item'], requiredFields: ['attributes.identifiers.sorcery_printing_id', 'attributes.set'] }, mtggoldfish: { limit: 400, seeds: ['Limited+Edition+Alpha'], pick: (raw) => { const p = raw.payload as { kind: string; card: { display_name: string; foil?: boolean } }; if (p.kind === 'card' && p.card.display_name === 'Black Lotus' && !p.card.foil) return 'lea-black-lotus'; if (p.kind === 'history' && p.card.display_name === 'Black Lotus') return 'lea-black-lotus-history'; if (p.kind === 'card' && p.card.display_name === 'Air Elemental') return 'lea-air-elemental'; return null; }, kinds: ['catalog_item', 'price_observation'], requiredFields: ['attributes.setCode', 'attributes.number'], }, pokemonprice: { limit: 3, seeds: ['base-set-1st-edition'], pick: (raw, i) => (i === 0 ? 'base-set-1st-edition-first' : i === 2 ? 'base-set-1st-edition-third' : null), kinds: ['catalog_item', 'price_observation'], requiredFields: ['attributes.set', 'attributes.number'] }, }; const ids = process.argv.slice(2).length ? process.argv.slice(2) : Object.keys(PLANS); const router = createRouter({ firecrawlApiKey: process.env.FIRECRAWL_API_KEY, scrapflyApiKey: process.env.SCRAPFLY_API_KEY }); for (const id of ids) { const plan = PLANS[id]; if (!plan) { console.log(`[${id}] no plan`); continue; } const started = Date.now(); const mod = (await import(`../${id}/index.ts`)) as { default: (m: ReturnType) => RareIndexConnector }; const metaJson = (await import(`../${id}/meta.json`, { with: { type: 'json' } })) as { default: unknown }; const connector = mod.default(localMeta(metaJson.default)); const ctx = createCrawlContext({ router, meta: connector.meta, options: { mode: 'probe', limit: plan.limit, ...(plan.seeds ? { seeds: plan.seeds } : {}) }, log: childLogger({ connector: id, level: 'warn' }) }); let raws = 0; let normalized = 0; let printed = 0; let saved = 0; for await (const raw of connector.crawl(ctx)) { const rawLike = { ...raw, externalId: raw.externalId ?? null, fetchedAt: raw.fetchedAt ?? new Date() }; const out = await connector.normalize(rawLike); normalized += out.length; const name = plan.pick(raw, raws); raws++; if (name) { saveFixture(id, name, { raw: rawLike, expect: { minCount: 1, kinds: plan.kinds, requiredFields: plan.requiredFields }, note: `Live capture ${new Date().toISOString().slice(0, 10)} from ${raw.url}` }); saved++; } if (printed < 3) { for (const r of out.slice(0, 3)) { const summary = r.kind === 'price_observation' ? `${r.priceKind} ${r.price} ${r.currency} @ ${r.observationDate.toISOString().slice(0, 10)}${r.grade.grader ? ` [${r.grade.grader} ${r.grade.grade ?? ''}]` : ''}` : r.kind === 'catalog_item' ? `variant=${r.attributes.variant ?? '-'} set=${r.attributes.set ?? '-'} #${r.attributes.number ?? '-'} ids=${JSON.stringify(r.attributes.identifiers)}` : ''; console.log(` [${id}] ${r.kind}: ${'rawTitle' in r ? r.rawTitle : ''} → ${summary}`); } printed++; } } console.log(`[${id}] raw=${raws} normalized=${normalized} fixtures=${saved} engineStats=${JSON.stringify(ctx.engineStats)} anomalies=${ctx.anomalies.slice(0, 3).join(' | ')} ms=${Date.now() - started}`); }