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, priceObservation } from '../_lib/shared.js'; import { BOT_HEADERS, fetchGzipJson, isoDay, magicFinishVariant, magicTreatments, streamGzipDataEntries } from '../_lib/wave4.js'; /** * MTGJSON connector — per-set card files (identifiers across Scryfall/TCGplayer/Cardmarket/Card Kingdom) * joined with the daily price file. One raw record per printing; normalize emits catalog items per * finish and dated price observations per provider × finish. */ const API = 'https://mtgjson.com/api/v5'; const PARSER_VERSION = '1.0.0'; const SetSchema = z.object({ code: z.string(), name: z.string(), releaseDate: z.string().nullable().optional(), type: z.string().nullable().optional(), tcgplayerGroupId: z.number().nullable().optional(), mcmId: z.number().nullable().optional(), isOnlineOnly: z.boolean().optional(), totalSetSize: z.number().optional() }); const CardSchema = z.object({ uuid: z.string(), name: z.string(), number: z.string(), rarity: z.string().nullable().optional(), finishes: z.array(z.string()).default([]), language: z.string().nullable().optional(), identifiers: z.record(z.string(), z.string()).default({}), frameEffects: z.array(z.string()).optional(), promoTypes: z.array(z.string()).optional(), isFullArt: z.boolean().optional(), borderColor: z.string().optional(), frameVersion: z.string().optional(), isPromo: z.boolean().optional(), isReserved: z.boolean().optional(), artist: z.string().nullable().optional(), type: z.string().nullable().optional(), manaCost: z.string().nullable().optional(), }); /** provider → finish → { date: price } */ const ProviderPricesSchema = z.object({ retail: z.record(z.string(), z.record(z.string(), z.number())).optional(), buylist: z.record(z.string(), z.record(z.string(), z.number())).optional(), currency: z.string().optional() }); const PaperPricesSchema = z.record(z.string(), ProviderPricesSchema); const RawPayloadSchema = z.object({ set: SetSchema, card: CardSchema, prices: PaperPricesSchema.nullable(), priceDate: z.string().nullable() }); export type MtgjsonPayload = z.infer; type PriceMap = Map>; const CARD_KEEP = ['uuid', 'name', 'number', 'rarity', 'finishes', 'language', 'identifiers', 'frameEffects', 'promoTypes', 'isFullArt', 'borderColor', 'frameVersion', 'isPromo', 'isReserved', 'artist', 'type', 'manaCost'] as const; export function trimCard(c: Record): z.infer { const out: Record = {}; for (const k of CARD_KEEP) if (c[k] !== undefined) out[k] = c[k]; return CardSchema.parse(out); } export function trimSet(s: Record): z.infer { const out: Record = {}; for (const k of ['code', 'name', 'releaseDate', 'type', 'tcgplayerGroupId', 'mcmId', 'isOnlineOnly', 'totalSetSize']) if (s[k] !== undefined) out[k] = s[k]; return SetSchema.parse(out); } /** Keep only the latest `days` dates per provider/finish (payload compactness). */ function trimPrices(paper: unknown, days: number): z.infer | null { const parsed = PaperPricesSchema.safeParse(paper); if (!parsed.success) return null; const out: z.infer = {}; for (const [provider, p] of Object.entries(parsed.data)) { const trimmed: z.infer = { currency: p.currency }; for (const kind of ['retail', 'buylist'] as const) { const byFinish = p[kind]; if (!byFinish) continue; const tf: Record> = {}; for (const [finish, series] of Object.entries(byFinish)) { const dates = Object.keys(series).sort().slice(-days); tf[finish] = Object.fromEntries(dates.map((d) => [d, series[d]!])); } trimmed[kind] = tf; } out[provider] = trimmed; } return out; } export class MtgjsonConnector extends BaseConnector { readonly version = '1.0.0'; readonly parserVersion = PARSER_VERSION; protected override minIntervalMs = 250; async *crawl(ctx: CrawlContext): AsyncIterable { const backfill = ctx.options.mode === 'backfill'; const maxSets = backfill ? Infinity : Number(this.meta.config.maxSetsPerRun ?? 60); const listRes = await ctx.fetch(`${API}/SetList.json`, { engines: ['api'], headers: BOT_HEADERS }); const list = (listRes.json as { data?: unknown[] } | null)?.data; if (!listRes.success || !Array.isArray(list)) throw new Error(`mtgjson SetList failed: ${listRes.error}`); let sets = list.map((s) => trimSet(s as Record)).filter((s) => !s.isOnlineOnly); if (ctx.options.seeds?.length) sets = sets.filter((s) => ctx.options.seeds!.includes(s.code)); sets.sort((a, b) => (b.releaseDate ?? '').localeCompare(a.releaseDate ?? '')); sets = sets.slice(0, Number.isFinite(maxSets) ? maxSets : sets.length); // Daily prices (small) — joined by uuid. Backfill streams the 90-day history instead. const prices: PriceMap = new Map(); let priceDate: string | null = null; if (!backfill) { const today = await fetchGzipJson<{ meta?: { date?: string }; data?: Record }>(`${API}/AllPricesToday.json.gz`, ctx.signal); priceDate = today.meta?.date ?? null; for (const [uuid, v] of Object.entries(today.data ?? {})) { const t = trimPrices(v.paper, 1); if (t) prices.set(uuid, t); } const s = (ctx.engineStats.api ??= { attempts: 0, success: 0, credits: 0, ms: 0 }); s.attempts++; s.success++; } let count = 0; let setIdx = Number(ctx.options.cursor?.setIdx ?? 0); const cards = new Map; card: z.infer; url: string }>(); for (; setIdx < sets.length; setIdx++) { if (ctx.signal?.aborted) return; const set = sets[setIdx]!; await this.throttle(); const res = await ctx.fetch(`${API}/${set.code}.json`, { engines: ['api'], headers: BOT_HEADERS }); const data = (res.json as { data?: { cards?: unknown[] } } | null)?.data; if (!res.success || !Array.isArray(data?.cards)) { ctx.anomaly('page_fetch_failed', `${set.code}: ${res.error ?? res.httpStatus}`); continue; } for (const raw of data.cards) { let card: z.infer; try { card = trimCard(raw as Record); } catch (err) { ctx.anomaly('parse_failure_card', `${set.code}: ${err instanceof Error ? err.message : String(err)}`); continue; } const url = `https://mtgjson.com/api/v5/${set.code}.json#${card.uuid}`; if (backfill) { cards.set(card.uuid, { set, card, url }); continue; } if (this.reached(ctx, count)) return; count++; const payload: MtgjsonPayload = { set, card, prices: prices.get(card.uuid) ?? null, priceDate }; yield { url, externalId: card.uuid, kind: 'catalog_item', engine: 'api', httpStatus: res.httpStatus, payload, fetchedAt: res.fetchedAt }; } await ctx.setCursor({ setIdx: setIdx + 1, priceDate }); } if (backfill) { // Stream the 90-day history and yield one record per known printing. const days = Number(this.meta.config.historyDays ?? 90); const queue: RawRecordInput[] = []; const seen = await streamGzipDataEntries( `${API}/AllPrices.json.gz`, (uuid, value) => { const c = cards.get(uuid); if (!c) return; const paper = (value as { paper?: unknown })?.paper; const trimmed = trimPrices(paper, days); const payload: MtgjsonPayload = { set: c.set, card: c.card, prices: trimmed, priceDate: null }; queue.push({ url: c.url, externalId: c.card.uuid, kind: 'catalog_item', engine: 'api', httpStatus: 200, payload, fetchedAt: new Date() }); }, { signal: ctx.signal, limit: ctx.options.limit ? ctx.options.limit * 4 : undefined }, ); ctx.log.info({ seen, matched: queue.length }, 'mtgjson AllPrices streamed'); for (const r of queue) { if (this.reached(ctx, count)) return; count++; yield r; } } await ctx.setCursor({ setIdx: 0, priceDate, completedAt: new Date().toISOString() }); } async normalize(raw: RawRecordLike): Promise { const { set, card, prices } = RawPayloadSchema.parse(raw.payload); const year = set.releaseDate ? Number(set.releaseDate.slice(0, 4)) || null : null; const ids: Record = { mtgjson_uuid: card.uuid }; const map: Array<[string, string]> = [['scryfallId', 'scryfall_id'], ['scryfallOracleId', 'oracle_id'], ['tcgplayerProductId', 'tcgplayer_id'], ['tcgplayerEtchedProductId', 'tcgplayer_etched_id'], ['mcmId', 'cardmarket_id'], ['cardKingdomId', 'cardkingdom_id'], ['cardKingdomFoilId', 'cardkingdom_foil_id'], ['cardKingdomEtchedId', 'cardkingdom_etched_id'], ['cardsphereId', 'cardsphere_id'], ['multiverseId', 'multiverse_id'], ['mtgoId', 'mtgo_id']]; for (const [from, to] of map) if (card.identifiers[from]) ids[to] = card.identifiers[from]!; const treat = magicTreatments({ ...card, setType: set.type ?? null, releaseDate: set.releaseDate ?? null }); const baseVariant = treat.length ? treat.join(' ') : null; const finishes = card.finishes.length ? card.finishes : ['nonfoil']; const observedAt = raw.fetchedAt; const providers = new Set((this.meta.config.providers as string[] | undefined) ?? ['tcgplayer', 'cardmarket', 'cardkingdom', 'cardsphere', 'manapool']); const out: NormalizedRecord[] = []; for (const finish of finishes) { const variant = [baseVariant, magicFinishVariant(finish)].filter(Boolean).join(' ') || null; const a = attrs({ categorySlug: 'magic_the_gathering', franchise: 'Magic: The Gathering', brand: 'Wizards of the Coast', set: set.name, setCode: set.code.toUpperCase(), name: card.name, number: card.number, year, variant, language: card.language ?? 'English', rarity: card.rarity ?? null, identifiers: { ...ids, finish }, metadata: { artist: card.artist ?? null, type_line: card.type ?? null, mana_cost: card.manaCost ?? null, promo: card.isPromo ?? false, reserved: card.isReserved ?? false, set_type: set.type ?? null }, }); const rawTitle = makeTitle({ name: card.name, set: set.name, number: card.number, year, variant }); const sourceUrl = `https://scryfall.com/card/${set.code.toLowerCase()}/${encodeURIComponent(card.number)}`; out.push(catalogItem({ kind: 'catalog_item', connectorId: this.meta.id, sourceId: this.meta.sourceId, sourceUrl, externalId: `${card.uuid}:${finish}`, rawTitle, imageUrls: [], attributes: a, observedAt, confidence: 0.85, parserVersion: PARSER_VERSION, releaseDate: set.releaseDate ? new Date(set.releaseDate) : null })); if (!prices) continue; for (const [provider, p] of Object.entries(prices)) { if (!providers.has(provider)) continue; // MTGJSON price files key finishes as normal|foil|etched while card.finishes uses nonfoil|foil|etched. const priceKey = finish === 'nonfoil' ? 'normal' : finish; const retail = p.retail?.[priceKey]; if (!retail) continue; const currency = (p.currency ?? (provider === 'cardmarket' ? 'EUR' : 'USD')) as 'USD' | 'EUR'; const buy = p.buylist?.[priceKey]; for (const [date, price] of Object.entries(retail)) { const observationDate = isoDay(date); if (!observationDate || !(price > 0)) continue; out.push(priceObservation({ kind: 'price_observation', connectorId: this.meta.id, sourceId: this.meta.sourceId, sourceUrl, externalId: `${card.uuid}:${finish}:${provider}:${date}`, rawTitle, imageUrls: [], attributes: { ...a, metadata: { ...a.metadata, provider, buylist: buy?.[date] ?? null } }, observedAt, confidence: provider === 'tcgplayer' || provider === 'cardmarket' ? 0.75 : 0.7, parserVersion: PARSER_VERSION, priceKind: 'market', price, currency, observationDate, sampleSize: null })); } } } return out; } } export default (meta: ConnectorMeta) => new MtgjsonConnector(meta);