/** * Helpers shared by the TCG catalog/price connectors added in wave 3 (tcgcsv, digimoncard, swu-db, * grand-archive, sorcery-tcg, mtggoldfish, pokemonprice). Kept inside connectors/api (not the framework). */ export const UA_HEADERS = { 'user-agent': 'RareIndexBot/0.1 (+https://www.rareindex.io/about/data)', accept: 'application/json, text/html;q=0.9, */*;q=0.8' }; /** UTC midnight of a Date (observation dates are day-precise). */ export function dayOf(d: Date): Date { return new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate())); } /** "2026-09-06T20:05:30+0000" | "2026-09-06" → UTC midnight Date, else null. */ 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; } /** * TCGplayer "subTypeName" / finish label → RareIndex variant vocabulary shared with the API catalogs * (pokemontcg: 'Holo', 'Reverse Holo', '1st Edition', '1st Edition Holo'; scryfall: 'Foil', 'Etched Foil'). * Returns null for the base printing. */ export function variantFromSubType(subType: string | null | undefined): string | null { if (!subType) return null; const s = subType.trim().toLowerCase(); if (!s || s === 'normal' || s === 'regular' || s === 'standard' || s === 'non-foil' || s === 'nonfoil' || s === 'unlimited') return null; if (s === 'holofoil' || s === 'holo') return 'Holo'; if (s === 'reverse holofoil' || s === 'reverse holo') return 'Reverse Holo'; if (s === '1st edition holofoil' || s === '1st edition holo') return '1st Edition Holo'; if (s === '1st edition normal' || s === '1st edition') return '1st Edition'; if (s === 'unlimited holofoil') return 'Holo'; if (s === 'foil') return 'Foil'; if (s === 'etched foil' || s === 'foil etched') return 'Etched Foil'; // Title-case anything else ("Hyperspace Foil", "Showcase", "Textured Foil") return subType.trim().replace(/\s+/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase()); } /** "PSA9" | "PSA 10" | "BGS9.5" | "CGC 9.8" | "Raw" → grader/grade. */ export function parseCompactGrade(label: string | null | undefined): { grader: string | null; grade: string | null } { if (!label) return { grader: null, grade: null }; const s = label.trim(); if (/^(raw|ungraded)$/i.test(s)) return { grader: 'raw', grade: null }; const m = s.match(/^(PSA|BGS|CGC|SGC|TAG|ACE|CBCS)\s*(\d{1,2}(?:\.\d)?)$/i); if (!m) return { grader: null, grade: null }; return { grader: m[1]!.toLowerCase(), grade: m[2]! }; } /** "101/102" → { number: "101", total: 102 }; "SV049" → { number: "SV049", total: null } */ export function splitNumber(s: string | null | undefined): { number: string | null; total: number | null } { if (!s) return { number: null, total: null }; const t = s.trim(); const m = t.match(/^([A-Za-z0-9-]+)\s*\/\s*([A-Za-z0-9-]+)$/); if (m) { const total = Number(m[2]); return { number: m[1]!, total: Number.isFinite(total) ? total : null }; } return { number: t || null, total: null }; } /** Upscale a TCGplayer CDN thumbnail (…_200w.jpg → …_400w.jpg). */ export function tcgplayerImage(url: string | null | undefined): string[] { if (!url) return []; return [url.replace(/_200w\.jpg$/, '_400w.jpg')]; }