import { createGunzip } from 'node:zlib'; import { Readable } from 'node:stream'; import { createInterface } from 'node:readline'; 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, priceObservation, yearOf } from '../_lib/shared.js'; const API = 'https://api.scryfall.com'; const HEADERS = { 'user-agent': 'RareIndexBot/0.1 (+https://www.rareindex.io/about/data)', accept: 'application/json;q=0.9,*/*;q=0.8' }; /** Trimmed printing payload we persist as the raw record (everything normalize() needs, nothing more). */ const CardPayloadSchema = z.object({ id: z.string(), oracle_id: z.string().optional(), name: z.string(), lang: z.string().optional(), released_at: z.string().optional(), set: z.string(), set_name: z.string(), set_type: z.string().optional(), collector_number: z.string(), rarity: z.string().optional(), layout: z.string().optional(), finishes: z.array(z.string()).optional(), frame_effects: z.array(z.string()).optional().nullable(), promo_types: z.array(z.string()).optional().nullable(), promo: z.boolean().optional(), reserved: z.boolean().optional(), reprint: z.boolean().optional(), variation: z.boolean().optional(), full_art: z.boolean().optional(), border_color: z.string().optional(), frame: z.string().optional(), digital: z.boolean().optional(), artist: z.string().optional(), mana_cost: z.string().optional(), type_line: z.string().optional(), cmc: z.number().optional(), colors: z.array(z.string()).optional(), tcgplayer_id: z.number().optional(), tcgplayer_etched_id: z.number().optional(), cardmarket_id: z.number().optional(), mtgo_id: z.number().optional(), arena_id: z.number().optional(), multiverse_ids: z.array(z.number()).optional(), scryfall_uri: z.string().optional(), image_uris: z.record(z.string(), z.string()).optional(), card_faces: z.array(z.object({ name: z.string().optional(), image_uris: z.record(z.string(), z.string()).optional() })).optional(), prices: z.record(z.string(), z.string().nullable()).optional(), }); type CardPayload = z.infer; const RawPayloadSchema = z.object({ card: CardPayloadSchema, /** ISO timestamp of the bulk file (prices' observation time); null for single-card lookups */ bulkUpdatedAt: z.string().nullable().default(null), }); const KEEP: Array = ['id', 'oracle_id', 'name', 'lang', 'released_at', 'set', 'set_name', 'set_type', 'collector_number', 'rarity', 'layout', 'finishes', 'frame_effects', 'promo_types', 'promo', 'reserved', 'reprint', 'variation', 'full_art', 'border_color', 'frame', 'digital', 'artist', 'mana_cost', 'type_line', 'cmc', 'colors', 'tcgplayer_id', 'tcgplayer_etched_id', 'cardmarket_id', 'mtgo_id', 'arena_id', 'multiverse_ids', 'scryfall_uri', 'image_uris', 'card_faces', 'prices']; export function trimCard(card: Record): CardPayload { const out: Record = {}; for (const k of KEEP) if (card[k] !== undefined) out[k] = card[k]; if (Array.isArray(out.card_faces)) out.card_faces = (out.card_faces as Array>).map((f) => ({ name: f.name, image_uris: f.image_uris })); return CardPayloadSchema.parse(out); } function finishVariant(finish: string): string | null { if (finish === 'foil') return 'Foil'; if (finish === 'etched') return 'Etched Foil'; return null; } /** Frame effects / promo types that materially identify a distinct printing treatment on the market. */ const VARIANT_FRAME_EFFECTS = new Set(['showcase', 'extendedart', 'inverted', 'shatteredglass', 'etched', 'textured']); const VARIANT_PROMO_TYPES = new Set(['serialized', 'prerelease', 'promopack', 'judgegift', 'buyabox', 'gameday', 'textured', 'galaxyfoil', 'surgefoil', 'stepandcompleat', 'confettifoil', 'oilslick', 'halofoil', 'neonink', 'ripplefoil', 'fracturefoil', 'rainbowfoil', 'raisedfoil', 'invisibleink', 'doublerainbow', 'manafoil', 'firstplacefoil', 'dragonscalefoil', 'silverfoil', 'gilded', 'embossed', 'startercollection', 'schinesealtart', 'datestamped', 'playerrewards', 'arenaleague', 'fnm', 'release', 'launch', 'convention', 'mediainsert', 'wizardsplaynetwork', 'thick', 'poster']); const LABELS: Record = { extendedart: 'Extended Art', promopack: 'Promo Pack', judgegift: 'Judge Promo', buyabox: 'Buy-a-Box', gameday: 'Game Day', galaxyfoil: 'Galaxy Foil', surgefoil: 'Surge Foil', stepandcompleat: 'Step-and-Compleat', confettifoil: 'Confetti Foil', oilslick: 'Oil Slick', halofoil: 'Halo Foil', neonink: 'Neon Ink', ripplefoil: 'Ripple Foil', fracturefoil: 'Fracture Foil', rainbowfoil: 'Rainbow Foil', raisedfoil: 'Raised Foil', invisibleink: 'Invisible Ink', doublerainbow: 'Double Rainbow', manafoil: 'Mana Foil', firstplacefoil: 'First Place Foil', dragonscalefoil: 'Dragon Scale Foil', silverfoil: 'Silver Foil', startercollection: 'Starter Collection', schinesealtart: 'Chinese Alt Art', datestamped: 'Date Stamped', playerrewards: 'Player Rewards', arenaleague: 'Arena League', fnm: 'FNM', mediainsert: 'Media Insert', wizardsplaynetwork: 'WPN', shatteredglass: 'Shattered Glass' }; function treatments(card: CardPayload): string[] { const t: string[] = []; for (const fe of card.frame_effects ?? []) if (VARIANT_FRAME_EFFECTS.has(fe)) t.push(fe); for (const pt of card.promo_types ?? []) if (VARIANT_PROMO_TYPES.has(pt)) t.push(pt); if (card.full_art) t.push('fullart'); if (card.border_color === 'borderless') t.push('borderless'); if (card.frame === '1997' && card.set_type !== 'core' && card.set_type !== 'expansion' && (card.released_at ?? '') > '2010') t.push('retro'); return [...new Set(t)].map((k) => LABELS[k] ?? (k === 'fullart' ? 'Full Art' : k.charAt(0).toUpperCase() + k.slice(1))); } export class ScryfallConnector extends BaseConnector { readonly version = '1.0.0'; readonly parserVersion = '1.0.0'; override readonly urlPatterns = [/^https?:\/\/(www\.)?scryfall\.com\/card\/[^/]+\/[^/?#]+/i]; protected override minIntervalMs = 100; async *crawl(ctx: CrawlContext): AsyncIterable { const bulkType = String(ctx.meta.config.bulkType ?? 'default_cards'); const list = await ctx.fetch(`${API}/bulk-data`, { engines: ['api'], headers: HEADERS }); if (!list.success || !list.json) throw new Error(`scryfall bulk-data listing failed: ${list.error}`); const entries = (list.json as { data: Array<{ type: string; updated_at: string; download_uri?: string; jsonl_download_uri?: string }> }).data; const entry = entries.find((e) => e.type === bulkType); if (!entry) throw new Error(`scryfall bulk type ${bulkType} not found`); const prev = ctx.options.cursor?.updatedAt as string | undefined; if (ctx.options.mode === 'incremental' && prev && prev === entry.updated_at) { ctx.log.info({ updatedAt: prev }, 'scryfall bulk unchanged, skipping'); return; } const uri = entry.jsonl_download_uri ?? entry.download_uri; if (!uri) throw new Error('scryfall bulk entry has no download uri'); const jsonl = Boolean(entry.jsonl_download_uri); const started = Date.now(); const res = await fetch(uri, { headers: HEADERS, signal: ctx.signal }); const stats = (ctx.engineStats.api ??= { attempts: 0, success: 0, credits: 0, ms: 0 }); stats.attempts++; if (!res.ok || !res.body) throw new Error(`scryfall bulk download HTTP ${res.status}`); stats.success++; let count = 0; const fetchedAt = new Date(); let body: NodeJS.ReadableStream = Readable.fromWeb(res.body as import('node:stream/web').ReadableStream); if (uri.endsWith('.gz')) body = body.pipe(createGunzip()); const rl = createInterface({ input: body, crlfDelay: Infinity }); for await (const rawLine of rl) { let line = rawLine.trim(); if (!jsonl) { // legacy JSON array format: one object per line, wrapped by [ ] and separated by commas if (line === '[' || line === ']' || !line) continue; if (line.endsWith(',')) line = line.slice(0, -1); } if (!line) continue; let card: Record; try { card = JSON.parse(line) as Record; } catch { ctx.anomaly('parse_failure', 'bulk line is not JSON'); continue; } if (card.object !== 'card' || card.digital === true) continue; let payload: CardPayload; try { payload = trimCard(card); } catch (err) { ctx.anomaly('parse_failure', `card ${String(card.id)}: ${err instanceof Error ? err.message : String(err)}`); continue; } yield { url: payload.scryfall_uri ?? `${API}/cards/${payload.id}`, externalId: payload.id, kind: 'catalog_item', engine: 'api', httpStatus: 200, payload: { card: payload, bulkUpdatedAt: entry.updated_at }, fetchedAt, }; count++; if (this.reached(ctx, count)) break; } stats.ms += Date.now() - started; rl.close(); if (!this.reached(ctx, count)) await ctx.setCursor({ updatedAt: entry.updated_at, count }); ctx.log.info({ count, ms: Date.now() - started }, 'scryfall bulk crawl done'); } async lookup(url: string, ctx: CrawlContext): Promise { const m = url.match(/scryfall\.com\/card\/([^/]+)\/([^/?#]+)/i); if (!m) return []; await this.throttle(); const res = await ctx.fetch(`${API}/cards/${encodeURIComponent(m[1]!)}/${encodeURIComponent(m[2]!)}`, { engines: ['api'], headers: HEADERS }); if (!res.success || !res.json) return []; const payload = trimCard(res.json as Record); return [{ url: payload.scryfall_uri ?? url, externalId: payload.id, kind: 'catalog_item', engine: 'api', httpStatus: res.httpStatus, payload: { card: payload, bulkUpdatedAt: null }, fetchedAt: res.fetchedAt }]; } async normalize(raw: RawRecordLike): Promise { const { card, bulkUpdatedAt } = RawPayloadSchema.parse(raw.payload); const year = yearOf(card.released_at); const sourceUrl = card.scryfall_uri ?? raw.url; const images = card.image_uris ? [card.image_uris.large ?? card.image_uris.normal ?? card.image_uris.png].filter((x): x is string => Boolean(x)) : (card.card_faces ?? []).map((f) => f.image_uris?.large ?? f.image_uris?.normal).filter((x): x is string => Boolean(x)); const identifiers: Record = { scryfall_id: card.id }; if (card.oracle_id) identifiers.oracle_id = card.oracle_id; if (card.tcgplayer_id) identifiers.tcgplayer_id = String(card.tcgplayer_id); if (card.tcgplayer_etched_id) identifiers.tcgplayer_etched_id = String(card.tcgplayer_etched_id); if (card.cardmarket_id) identifiers.cardmarket_id = String(card.cardmarket_id); if (card.mtgo_id) identifiers.mtgo_id = String(card.mtgo_id); if (card.arena_id) identifiers.arena_id = String(card.arena_id); if (card.multiverse_ids?.length) identifiers.multiverse_id = String(card.multiverse_ids[0]); const treat = treatments(card); const baseVariant = treat.length ? treat.join(' ') : null; const finishes = card.finishes?.length ? card.finishes : ['nonfoil']; const observedAt = bulkUpdatedAt ? new Date(bulkUpdatedAt) : raw.fetchedAt; const out: NormalizedRecord[] = []; const buildAttrs = (finish: string) => { const fv = finishVariant(finish); const variant = [baseVariant, fv].filter(Boolean).join(' ') || null; return attrs({ categorySlug: 'magic_the_gathering', franchise: 'Magic: The Gathering', brand: 'Wizards of the Coast', set: card.set_name, setCode: card.set.toUpperCase(), name: card.name, number: card.collector_number, year, variant, language: card.lang ?? null, rarity: card.rarity ?? null, identifiers: { ...identifiers, finish }, metadata: { mana_cost: card.mana_cost ?? null, type_line: card.type_line ?? null, cmc: card.cmc ?? null, colors: card.colors ?? [], artist: card.artist ?? null, reserved: card.reserved ?? false, promo: card.promo ?? false, reprint: card.reprint ?? false, set_type: card.set_type ?? null, layout: card.layout ?? null, frame: card.frame ?? null, border_color: card.border_color ?? null, finish, treatments: treat, }, }); }; for (const finish of finishes) { const a = buildAttrs(finish); out.push( catalogItem({ kind: 'catalog_item', connectorId: this.meta.id, sourceId: this.meta.sourceId, sourceUrl, externalId: `${card.id}:${finish}`, rawTitle: makeTitle({ name: card.name, set: card.set_name, number: card.collector_number, year, variant: a.variant }), description: card.type_line ?? null, imageUrls: images, attributes: a, observedAt, confidence: 0.98, parserVersion: this.parserVersion, releaseDate: card.released_at ?? null, }), ); } const prices = card.prices ?? {}; const obsDate = observedAt; const priceMap: Array<[key: string, finish: string, currency: 'USD' | 'EUR']> = [ ['usd', 'nonfoil', 'USD'], ['usd_foil', 'foil', 'USD'], ['usd_etched', 'etched', 'USD'], ['eur', 'nonfoil', 'EUR'], ['eur_foil', 'foil', 'EUR'], ]; for (const [key, finish, currency] of priceMap) { const price = num(prices[key]); if (price === null) continue; if (!finishes.includes(finish)) continue; const a = buildAttrs(finish); out.push( priceObservation({ kind: 'price_observation', connectorId: this.meta.id, sourceId: this.meta.sourceId, sourceUrl, externalId: `${card.id}:${key}`, rawTitle: makeTitle({ name: card.name, set: card.set_name, number: card.collector_number, year, variant: a.variant }), imageUrls: images, attributes: a, condition: { condition: 'near_mint', conditionRaw: 'NM (market price)', completeness: null }, observedAt, confidence: 0.9, parserVersion: this.parserVersion, priceKind: 'market', price, currency, observationDate: obsDate, sampleSize: null, }), ); } return out; } } export default function createConnector(meta: ConnectorMeta) { return new ScryfallConnector(meta); }