import { z } from 'zod'; import { BaseConnector, type ConnectorMeta, type CrawlContext, type RawRecordInput, type RawRecordLike } from '@rareindex/connectors'; import { type NormalizedRecord } from '@rareindex/shared'; import { attrs, catalogItem, makeTitle, num, parseSlashDate, priceObservation, withRetries, yearOf } from '../_lib/shared.js'; const API = 'https://api.pokemontcg.io/v2'; const SetSchema = z.object({ id: z.string(), name: z.string(), series: z.string().optional(), printedTotal: z.number().optional(), total: z.number().optional(), ptcgoCode: z.string().optional(), releaseDate: z.string().optional(), updatedAt: z.string().optional(), images: z.object({ symbol: z.string().optional(), logo: z.string().optional() }).optional(), }); type PkSet = z.infer; const PriceBlock = z.object({ low: z.number().nullable().optional(), mid: z.number().nullable().optional(), high: z.number().nullable().optional(), market: z.number().nullable().optional(), directLow: z.number().nullable().optional() }); const CardSchema = z.object({ id: z.string(), name: z.string(), supertype: z.string().optional(), subtypes: z.array(z.string()).optional(), hp: z.string().optional(), types: z.array(z.string()).optional(), number: z.string(), artist: z.string().optional(), rarity: z.string().optional(), flavorText: z.string().optional(), nationalPokedexNumbers: z.array(z.number()).optional(), regulationMark: z.string().nullable().optional(), images: z.object({ small: z.string().optional(), large: z.string().optional() }).optional(), set: SetSchema, tcgplayer: z.object({ url: z.string().optional(), updatedAt: z.string().optional(), prices: z.record(z.string(), PriceBlock).optional() }).optional(), cardmarket: z.object({ url: z.string().optional(), updatedAt: z.string().optional(), prices: z.record(z.string(), z.number().nullable()).optional() }).optional(), }); type PkCard = z.infer; const RawPayloadSchema = z.object({ card: CardSchema, source: z.enum(['api', 'github-mirror']).default('api') }); const KEEP = ['id', 'name', 'supertype', 'subtypes', 'hp', 'types', 'number', 'artist', 'rarity', 'flavorText', 'nationalPokedexNumbers', 'regulationMark', 'images', 'set', 'tcgplayer', 'cardmarket'] as const; export function trimCard(card: Record, set?: PkSet): PkCard { const out: Record = {}; for (const k of KEEP) if (card[k] !== undefined) out[k] = card[k]; if (!out.set && set) out.set = set; if (out.set && typeof out.set === 'object') { const s = out.set as Record; delete s.legalities; } return CardSchema.parse(out); } /** TCGplayer price-variant key → RareIndex variant label (null = base printing). */ export const TCGPLAYER_VARIANTS: Record = { normal: null, holofoil: 'Holo', reverseHolofoil: 'Reverse Holo', unlimited: null, unlimitedHolofoil: 'Holo', '1stEdition': '1st Edition', '1stEditionNormal': '1st Edition', '1stEditionHolofoil': '1st Edition Holo', }; export class PokemonTcgConnector extends BaseConnector { readonly version = '1.0.0'; readonly parserVersion = '1.0.0'; protected override minIntervalMs = 2100; // ≈ 28 req/min, under the keyless 30/min ceiling private headers(): Record { const key = process.env.POKEMONTCG_API_KEY; return key ? { 'x-api-key': key } : {}; } private async apiGet(ctx: CrawlContext, url: string) { await this.throttle(); return withRetries( () => ctx.fetch(url, { engines: ['api'], headers: this.headers(), timeoutMs: 120_000 }), (r) => r.success && r.json !== null, 5, 2000, ); } async *crawl(ctx: CrawlContext): AsyncIterable { const pageSize = Number(ctx.meta.config.pageSize ?? 250); const mirror = String(ctx.meta.config.mirrorBase ?? 'https://raw.githubusercontent.com/PokemonTCG/pokemon-tcg-data/master'); let sets: PkSet[] = []; const setsRes = await this.apiGet(ctx, `${API}/sets?pageSize=250&orderBy=releaseDate`); if (setsRes.success && setsRes.json) { sets = z.array(SetSchema).parse((setsRes.json as { data: unknown[] }).data); } else { ctx.anomaly('blocked_or_down', `sets endpoint failed (${setsRes.error}); using GitHub mirror for the set list`); const m = await ctx.fetch(`${mirror}/sets/en.json`, { engines: ['api'] }); if (!m.success || !m.json) throw new Error(`pokemontcg: sets unavailable from API and mirror: ${m.error}`); sets = z.array(SetSchema).parse(m.json); } sets.sort((a, b) => (a.releaseDate ?? '').localeCompare(b.releaseDate ?? '')); const filterIds = ctx.options.seeds?.length ? new Set(ctx.options.seeds) : null; let setIndex = Number(ctx.options.cursor?.setIndex ?? 0); let page = Number(ctx.options.cursor?.page ?? 1); let count = 0; for (; setIndex < sets.length; setIndex++, page = 1) { const set = sets[setIndex]!; if (filterIds && !filterIds.has(set.id)) continue; let usedMirror = false; for (;;) { const url = `${API}/cards?q=set.id:${encodeURIComponent(set.id)}&pageSize=${pageSize}&page=${page}`; const res = await this.apiGet(ctx, url); let cards: Record[] = []; let totalCount = 0; if (res.success && res.json) { const body = res.json as { data: Record[]; totalCount?: number; count?: number }; cards = body.data ?? []; totalCount = body.totalCount ?? cards.length; } else if (page === 1) { // API down for this set → public GitHub mirror (same schema, no prices). const m = await ctx.fetch(`${mirror}/cards/en/${encodeURIComponent(set.id)}.json`, { engines: ['api'] }); if (!m.success || !Array.isArray(m.json)) { ctx.anomaly('empty_page', `set ${set.id}: API ${res.error}; mirror ${m.error ?? 'not an array'}`); break; } cards = m.json as Record[]; totalCount = cards.length; usedMirror = true; ctx.anomaly('fallback_feed', `set ${set.id} served from GitHub mirror (no prices)`); } else { ctx.anomaly('empty_page', `set ${set.id} page ${page}: ${res.error}`); break; } const fetchedAt = new Date(); for (const c of cards) { let card: PkCard; try { card = trimCard(c, set); } catch (err) { ctx.anomaly('parse_failure', `card ${String(c.id)}: ${err instanceof Error ? err.message : String(err)}`); continue; } yield { url: `${API}/cards/${card.id}`, externalId: card.id, kind: 'catalog_item', engine: usedMirror ? 'feed' : 'api', httpStatus: 200, payload: { card, source: usedMirror ? 'github-mirror' : 'api' }, fetchedAt, }; count++; if (this.reached(ctx, count)) return; } if (usedMirror || page * pageSize >= totalCount || cards.length === 0) break; page++; await ctx.setCursor({ setIndex, page }); } await ctx.setCursor({ setIndex: setIndex + 1, page: 1 }); } await ctx.setCursor({ setIndex: 0, page: 1, completedAt: new Date().toISOString() }); ctx.log.info({ count }, 'pokemontcg crawl done'); } async normalize(raw: RawRecordLike): Promise { const { card, source } = RawPayloadSchema.parse(raw.payload); const set = card.set; const year = yearOf(set.releaseDate); const images = [card.images?.large ?? card.images?.small].filter((x): x is string => Boolean(x)); const sourceUrl = card.tcgplayer?.url ?? `https://pokemontcg.io/card/${card.id}`; const setCode = set.ptcgoCode ?? set.id.toUpperCase(); const out: NormalizedRecord[] = []; const buildAttrs = (variant: string | null) => attrs({ categorySlug: 'pokemon', franchise: 'Pokémon', brand: 'The Pokémon Company', series: set.series ?? null, set: set.name, setCode, name: card.name, number: card.number, year, variant, language: 'English', rarity: card.rarity ?? null, identifiers: { pokemontcg_id: card.id, pokemontcg_set_id: set.id, ...(card.tcgplayer?.url ? { tcgplayer_url: card.tcgplayer.url } : {}), ...(card.cardmarket?.url ? { cardmarket_url: card.cardmarket.url } : {}) }, metadata: { supertype: card.supertype ?? null, subtypes: card.subtypes ?? [], hp: card.hp ?? null, types: card.types ?? [], artist: card.artist ?? null, nationalPokedexNumbers: card.nationalPokedexNumbers ?? [], regulationMark: card.regulationMark ?? null, printedTotal: set.printedTotal ?? null, setTotal: set.total ?? null, setSymbol: set.images?.symbol ?? null, dataSource: source, }, }); const tcg = card.tcgplayer; const tcgDate = parseSlashDate(tcg?.updatedAt) ?? raw.fetchedAt; const variantKeys = Object.keys(tcg?.prices ?? {}).filter((k) => k in TCGPLAYER_VARIANTS); const variants: Array = variantKeys.length ? [...new Set(variantKeys.map((k) => TCGPLAYER_VARIANTS[k] ?? null))] : [null]; // Base printing: if the card only exists as holo, the canonical variant is 'Holo'. for (const variant of variants) { const a = buildAttrs(variant); out.push( catalogItem({ kind: 'catalog_item', connectorId: this.meta.id, sourceId: this.meta.sourceId, sourceUrl, externalId: `${card.id}:${variant ?? 'base'}`, rawTitle: makeTitle({ name: card.name, set: set.name, number: card.number, total: set.printedTotal ?? null, year, variant }), description: card.flavorText ?? null, imageUrls: images, attributes: a, observedAt: raw.fetchedAt, confidence: 0.97, parserVersion: this.parserVersion, releaseDate: parseSlashDate(set.releaseDate), }), ); } const kinds: Array<['low' | 'mid' | 'high' | 'market', 'low' | 'mid' | 'high' | 'market']> = [ ['market', 'market'], ['low', 'low'], ['mid', 'mid'], ['high', 'high'], ]; for (const key of variantKeys) { const block = tcg!.prices![key]!; const variant = TCGPLAYER_VARIANTS[key] ?? null; const a = buildAttrs(variant); for (const [field, priceKind] of kinds) { const price = num(block[field]); if (price === null) continue; out.push( priceObservation({ kind: 'price_observation', connectorId: this.meta.id, sourceId: this.meta.sourceId, sourceUrl, externalId: `${card.id}:tcgplayer:${key}:${field}`, rawTitle: makeTitle({ name: card.name, set: set.name, number: card.number, total: set.printedTotal ?? null, year, variant }), imageUrls: images, attributes: { ...a, metadata: { ...a.metadata, priceSource: 'tcgplayer', priceVariantKey: key } }, condition: { condition: 'near_mint', conditionRaw: 'TCGplayer near mint market', completeness: null }, observedAt: tcgDate, confidence: 0.9, parserVersion: this.parserVersion, priceKind, price, currency: 'USD', observationDate: tcgDate, sampleSize: null, }), ); } } const cm = card.cardmarket; if (cm?.prices) { const cmDate = parseSlashDate(cm.updatedAt) ?? raw.fetchedAt; // Cardmarket main prices refer to the card's primary printing; reverseHolo* fields to the reverse holo. const primary = variants.includes('Holo') && !variants.includes(null) ? 'Holo' : variants.includes(null) ? null : (variants[0] ?? null); const main: Array<[string, 'market' | 'low' | 'trend' | 'average_7d' | 'average_30d']> = [ ['averageSellPrice', 'market'], ['lowPrice', 'low'], ['trendPrice', 'trend'], ['avg7', 'average_7d'], ['avg30', 'average_30d'], ]; const reverse: Array<[string, 'market' | 'low' | 'trend' | 'average_7d' | 'average_30d']> = [ ['reverseHoloSell', 'market'], ['reverseHoloLow', 'low'], ['reverseHoloTrend', 'trend'], ['reverseHoloAvg7', 'average_7d'], ['reverseHoloAvg30', 'average_30d'], ]; const emit = (pairs: typeof main, variant: string | null, tag: string) => { const a = buildAttrs(variant); for (const [field, priceKind] of pairs) { const price = num(cm.prices![field]); if (price === null) continue; out.push( priceObservation({ kind: 'price_observation', connectorId: this.meta.id, sourceId: this.meta.sourceId, sourceUrl: cm.url ?? sourceUrl, externalId: `${card.id}:cardmarket:${tag}:${field}`, rawTitle: makeTitle({ name: card.name, set: set.name, number: card.number, total: set.printedTotal ?? null, year, variant }), imageUrls: images, attributes: { ...a, metadata: { ...a.metadata, priceSource: 'cardmarket', priceVariantKey: tag } }, condition: { condition: null, conditionRaw: 'Cardmarket aggregate', completeness: null }, observedAt: cmDate, confidence: 0.75, parserVersion: this.parserVersion, priceKind, price, currency: 'EUR', observationDate: cmDate, sampleSize: null, }), ); } }; emit(main, primary, 'main'); if (variants.includes('Reverse Holo')) emit(reverse, 'Reverse Holo', 'reverse'); } return out; } } export default function createConnector(meta: ConnectorMeta) { return new PokemonTcgConnector(meta); }