/** * Helpers shared by the wave-4 card/comic connectors (mtgjson, cardkingdom, myslabs, tag-pop, comc, * alt-xyz). Kept inside connectors/api (not the framework). */ import { createGunzip, gunzipSync } from 'node:zlib'; import { Readable } from 'node:stream'; export const BOT_UA = 'RareIndexBot/0.1 (+https://www.rareindex.io/about/data; contact data@rareindex.io)'; export const BOT_HEADERS = { 'user-agent': BOT_UA, accept: 'application/json, text/html;q=0.9, */*;q=0.8' }; /** UTC midnight. */ export function dayOf(d: Date): Date { return new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate())); } /** "2026-09-06" → UTC midnight Date. */ export function isoDay(s: string | null | undefined): Date | null { if (!s) return null; const m = s.match(/^(\d{4})-(\d{2})-(\d{2})/); if (!m) return null; const d = new Date(Date.UTC(Number(m[1]), Number(m[2]) - 1, Number(m[3]))); return Number.isNaN(d.getTime()) ? null : d; } /** "$8,830.00" | "8000.00" → number or null (never ≤ 0). */ export function money(s: string | number | null | undefined): number | null { if (s === null || s === undefined) return null; const n = typeof s === 'number' ? s : Number.parseFloat(String(s).replace(/[^0-9.]/g, '')); return Number.isFinite(n) && n > 0 ? n : null; } /** Download a gzip JSON document fully (small files only, e.g. AllPricesToday ≈ 5 MB gz). */ /** Download a (possibly gzip) document; handles servers that already transparently decode Content-Encoding. */ export async function fetchMaybeGzip(url: string, signal?: AbortSignal): Promise { const res = await fetch(url, { headers: BOT_HEADERS, signal }); if (!res.ok) throw new Error(`${url}: HTTP ${res.status}`); const buf = Buffer.from(await res.arrayBuffer()); if (buf.length >= 2 && buf[0] === 0x1f && buf[1] === 0x8b) return gunzipSync(buf); return buf; } export async function fetchGzipJson(url: string, signal?: AbortSignal): Promise { return JSON.parse((await fetchMaybeGzip(url, signal)).toString('utf8')) as T; } /** * Stream a large gzip JSON document shaped like {"meta":{…},"data":{"":,…}} and invoke * `onEntry(key, value)` for each top-level entry of `data` without materialising the whole file * (MTGJSON AllPrices ≈ 1.5 GB uncompressed). Returns the number of entries seen. */ export async function streamGzipDataEntries(url: string, onEntry: (key: string, value: unknown) => Promise | void, opts: { signal?: AbortSignal; limit?: number } = {}): Promise { const res = await fetch(url, { headers: BOT_HEADERS, signal: opts.signal }); if (!res.ok || !res.body) throw new Error(`${url}: HTTP ${res.status}`); const encoded = (res.headers.get('content-type') ?? '').includes('gzip') || /\.gz(\?|$)/.test(url); const raw = Readable.fromWeb(res.body as never); // Some CDNs serve .gz files with Content-Encoding: gzip, which fetch already decodes → sniff the magic bytes. const first = await new Promise((resolve, reject) => { raw.once('readable', () => resolve((raw.read(2) as Buffer | null) ?? null)); raw.once('error', reject); raw.once('end', () => resolve(null)); }); if (first) raw.unshift(first); const isGz = first ? first[0] === 0x1f && first[1] === 0x8b : encoded; const stream = isGz ? raw.pipe(createGunzip()) : raw; let buf = ''; let inData = false; let count = 0; let done = false; for await (const chunk of stream) { if (done) break; buf += (chunk as Buffer).toString('utf8'); if (!inData) { const i = buf.indexOf('"data"'); if (i < 0) { buf = buf.slice(-16); continue; } const brace = buf.indexOf('{', i); if (brace < 0) continue; buf = buf.slice(brace + 1); inData = true; } // Parse as many complete `"key": {…},` entries as the buffer holds. for (;;) { const k = buf.indexOf('"'); if (k < 0) break; const kEnd = buf.indexOf('"', k + 1); if (kEnd < 0) break; const colon = buf.indexOf(':', kEnd); if (colon < 0) break; const start = colon + 1; const end = scanJsonValue(buf, start); if (end < 0) break; // incomplete value, wait for more data const key = buf.slice(k + 1, kEnd); const raw = buf.slice(start, end).trim(); try { await onEntry(key, JSON.parse(raw)); } catch (err) { if (err instanceof SyntaxError) { /* skip malformed slice */ } else throw err; } count++; buf = buf.slice(end); const comma = buf.indexOf(','); const close = buf.indexOf('}'); if (close >= 0 && (comma < 0 || close < comma)) { done = true; break; } buf = comma >= 0 ? buf.slice(comma + 1) : buf; if (opts.limit && count >= opts.limit) { done = true; break; } } } return count; } /** Index just past a complete JSON value starting at `start` (whitespace allowed); -1 if incomplete. */ function scanJsonValue(s: string, start: number): number { let i = start; while (i < s.length && /\s/.test(s[i]!)) i++; if (i >= s.length) return -1; const c = s[i]!; if (c === '{' || c === '[') { let depth = 0; let inStr = false; for (let j = i; j < s.length; j++) { const ch = s[j]!; if (inStr) { if (ch === '\\') j++; else if (ch === '"') inStr = false; continue; } if (ch === '"') inStr = true; else if (ch === '{' || ch === '[') depth++; else if (ch === '}' || ch === ']') { depth--; if (depth === 0) return j + 1; } } return -1; } if (c === '"') { for (let j = i + 1; j < s.length; j++) { if (s[j] === '\\') j++; else if (s[j] === '"') return j + 1; } return -1; } const m = s.slice(i).match(/^(true|false|null|-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?)/); if (!m) return -1; const end = i + m[0].length; return end < s.length ? end : -1; } /** Parse …… entries from a sitemap. */ export function parseSitemap(xml: string): Array<{ loc: string; lastmod: string | null }> { const out: Array<{ loc: string; lastmod: string | null }> = []; const re = /([\s\S]*?)<\/url>/g; let m: RegExpExecArray | null; while ((m = re.exec(xml))) { const loc = m[1]!.match(/\s*([^<\s]+)\s*<\/loc>/)?.[1]; if (!loc) continue; const lastmod = m[1]!.match(/\s*([^<\s]+)\s*<\/lastmod>/)?.[1] ?? null; out.push({ loc: decodeXml(loc), lastmod }); } return out; } export function decodeXml(s: string): string { return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, "'"); } /** Parse GitHub-flavoured markdown tables (as emitted by Firecrawl) into rows of cells. */ export function markdownTables(md: string): string[][][] { const tables: string[][][] = []; let cur: string[][] | null = null; for (const line of md.split('\n')) { const t = line.trim(); if (t.startsWith('|') && t.endsWith('|')) { const cells = t.slice(1, -1).split('|').map((c) => c.trim()); if (cells.every((c) => /^:?-{2,}:?$/.test(c))) continue; // separator row if (!cur) cur = []; cur.push(cells); } else if (cur) { tables.push(cur); cur = null; } } if (cur) tables.push(cur); return tables; } /** Strip markdown link syntax: "[2](https://…)" → "2"; "[Alakazam](url)
Holo" → "Alakazam Holo". */ export function mdText(cell: string): string { return cell .replace(/!\[[^\]]*\]\([^)]*\)/g, '') .replace(/\[([^\]]*)\]\([^)]*\)/g, '$1') .replace(//gi, ' ') .replace(/\\([\\`*_{}\[\]()#+\-.!|])/g, '$1') .replace(/\s+/g, ' ') .trim(); } export function mdLink(cell: string): string | null { return cell.match(/\]\((https?:[^)\s]+)\)/)?.[1] ?? null; } export function toInt(s: string): number | null { const n = Number.parseInt(s.replace(/[^0-9]/g, ''), 10); return Number.isFinite(n) ? n : null; } /** TAG / COMC / Alt category labels → taxonomy slugs (never invented; null when unknown). */ export function cardCategorySlug(label: string | null | undefined): string | null { if (!label) return null; const s = label.toLowerCase(); if (/pok[eé]mon/.test(s)) return 'pokemon'; if (/magic/.test(s)) return 'magic_the_gathering'; if (/yu-?gi-?oh/.test(s)) return 'yugioh'; if (/lorcana/.test(s)) return 'disney_lorcana'; if (/one piece/.test(s)) return 'one_piece_card_game'; if (/digimon/.test(s)) return 'digimon_tcg'; if (/flesh/.test(s)) return 'flesh_and_blood'; if (/star wars/.test(s)) return 'star_wars_tcg'; if (/dragon ?ball/.test(s)) return 'dragon_ball_tcg'; if (/baseball/.test(s)) return 'baseball_cards'; if (/basketball/.test(s)) return 'basketball_cards'; if (/football/.test(s)) return 'football_cards'; if (/hockey/.test(s)) return 'hockey_cards'; if (/soccer|futbol/.test(s)) return 'soccer_cards'; if (/formula|f1\b|racing/.test(s)) return 'f1_cards'; if (/boxing|golf|tennis|wrestling|wwe|ufc|mma|olympic|lacrosse|rugby|cricket|nascar|multi-?sport/.test(s)) return 'other_sports_cards'; if (/comic/.test(s)) return 'comics'; if (/non-?sport|entertainment|garbage pail|marvel|dc /.test(s)) return 'non_sport_cards'; if (/video game|nintendo|playstation/.test(s)) return 'video_games'; if (/tcg|trading card|gaming/.test(s)) return 'trading_cards'; return null; } /** "Aug 6, 2026" | "Sep 3, 2024" → UTC date. */ export function parseMonthDay(s: string): Date | null { const m = s.match(/([A-Za-z]{3,9})\.?\s+(\d{1,2}),?\s+(\d{4})/); if (!m) return null; const months = ['jan', 'feb', 'mar', 'apr', 'may', 'jun', 'jul', 'aug', 'sep', 'oct', 'nov', 'dec']; const mo = months.indexOf(m[1]!.slice(0, 3).toLowerCase()); if (mo < 0) return null; return new Date(Date.UTC(Number(m[3]), mo, Number(m[2]))); } // ---- Magic printing treatments (mirrors connectors/api/scryfall so identifier matches agree on `variant`) ---- 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' }; export function magicTreatments(card: { frameEffects?: string[]; promoTypes?: string[]; isFullArt?: boolean; borderColor?: string; frameVersion?: string; setType?: string | null; releaseDate?: string | null }): string[] { const t: string[] = []; for (const fe of card.frameEffects ?? []) if (VARIANT_FRAME_EFFECTS.has(fe)) t.push(fe); for (const pt of card.promoTypes ?? []) if (VARIANT_PROMO_TYPES.has(pt)) t.push(pt); if (card.isFullArt) t.push('fullart'); if (card.borderColor === 'borderless') t.push('borderless'); if (card.frameVersion === '1997' && card.setType !== 'core' && card.setType !== 'expansion' && (card.releaseDate ?? '') > '2010') t.push('retro'); return [...new Set(t)].map((k) => LABELS[k] ?? (k === 'fullart' ? 'Full Art' : k.charAt(0).toUpperCase() + k.slice(1))); } export function magicFinishVariant(finish: string): string | null { if (finish === 'foil') return 'Foil'; if (finish === 'etched') return 'Etched Foil'; return null; }