/** Title heuristics specific to Goldin lot titles. Kept out of the canonical domain model (§196.6). */ export interface SeedLike { path: string; categorySlug: string; sport?: string; } const MEMORABILIA = /\b(game[- ]?used|game[- ]?worn|match[- ]?worn|jersey|uniform|helmet|cleats?|sneakers?|shoes?|glove|bat\b|baseball\b(?! card)|basketball\b(?! card)|football\b(?! card)|puck|stick|ball\b|ticket|stub|program|photo(?:graph)?|pennant|poster|ring\b|medal|trophy|magazine|sports illustrated|cover|seat|wristband|shorts|pants|jacket|cap\b|hat\b|signed (?:bat|ball|jersey|helmet|photo)|loa\b|mears)\b/i; const CARD_HINT = /#\d|\bpsa\b|\bbgs\b|\bsgc\b|\bcgc\b|\bbeckett\b|\brookie\b|\brc\b|\brefractor\b|\bprizm\b|\bchrome\b|\bauto(?:graph)?\b.*\/\d+|\bcard\b/i; const COMIC_HINT = /\bcgc\b|\bcbcs\b|\bcomic|#\d+\s*\(19|#\d+\s*\(20|newsstand|1st appearance/i; const CONSOLE_HINT = /\bconsole\b|\bsystem\b|\bcontroller\b|\bgame boy\b(?!.*(?:cartridge|game))/i; const SPORT_CARD_SLUG: Record = { basketball: 'basketball_cards', baseball: 'baseball_cards', football: 'football_cards', hockey: 'hockey_cards', soccer: 'soccer_cards', racing: 'f1_cards' }; export function classifyGoldinTitle(title: string, seed: SeedLike, breadcrumb: string[] = []): string { const t = title; if (seed.sport) { const cardSlug = SPORT_CARD_SLUG[seed.sport] ?? 'other_sports_cards'; if (MEMORABILIA.test(t) && !CARD_HINT.test(t)) return 'sports_memorabilia'; if (MEMORABILIA.test(t) && /\b(jersey|helmet|bat|ball|ticket|photo|program|magazine|cover|ring|medal|trophy)\b/i.test(t) && !/#\d/.test(t)) return 'sports_memorabilia'; if (seed.sport === 'racing' && !/\bf1\b|formula 1|formula one/i.test(t)) return 'other_sports_cards'; return cardSlug; } switch (seed.categorySlug) { case 'marvel_comics': return COMIC_HINT.test(t) ? 'marvel_comics' : 'non_sport_cards'; case 'nintendo_games': return CONSOLE_HINT.test(t) ? 'gaming_hardware' : 'nintendo_games'; case 'sports_memorabilia': { const crumbs = breadcrumb.join(' ').toLowerCase(); if (/pokemon/.test(crumbs) || /\bpokemon\b/i.test(t)) return 'pokemon'; if (/magic: the gathering|\bmtg\b/i.test(t)) return 'magic_the_gathering'; if (/yu-gi-oh/i.test(t)) return 'yugioh'; if (/one piece/i.test(t)) return 'one_piece_card_game'; for (const [sport, slug] of Object.entries(SPORT_CARD_SLUG)) { if (new RegExp(`\\b${sport}\\b`, 'i').test(crumbs)) return MEMORABILIA.test(t) && !CARD_HINT.test(t) ? 'sports_memorabilia' : slug; } if (CARD_HINT.test(t) && !MEMORABILIA.test(t)) return 'other_sports_cards'; return 'sports_memorabilia'; } default: return seed.categorySlug; } } export function isBundleTitle(title: string): boolean { return /\blot of\b|\((\d{2,})\+?\)|\bcollection\b|\bcomplete set\b|\bpartial set\b|\bset break\b|\bbundle\b|\bx\d{2,}\b|\bcase\b.*\bboxes\b/i.test(title); } const BRANDS = ['Topps Chrome', 'Topps Finest', 'Bowman Chrome', 'Panini Prizm', 'Panini Select', 'Panini Mosaic', 'Panini Donruss Optic', 'Panini National Treasures', 'Panini Flawless', 'Panini Immaculate', 'Panini Contenders', 'Upper Deck', 'O-Pee-Chee', 'Topps', 'Bowman', 'Panini', 'Fleer', 'Donruss', 'Score', 'Leaf', 'Futera', 'Skybox', 'Hoops', 'Stadium Club', 'Goudey', 'Play Ball', 'Pokemon', 'Magic: The Gathering', 'Yu-Gi-Oh!', 'One Piece']; const POKEMON_SETS = ['Base Set 2', 'Base Set', 'Shadowless', 'Jungle', 'Fossil', 'Team Rocket', 'Gym Heroes', 'Gym Challenge', 'Neo Genesis', 'Neo Discovery', 'Neo Revelation', 'Neo Destiny', 'Legendary Collection', 'Expedition', 'Aquapolis', 'Skyridge', 'Black Star Promo', 'Southern Islands', 'EX Ruby & Sapphire', 'EX Team Rocket Returns', 'EX Deoxys', 'EX Dragon Frontiers', 'EX Holon Phantoms', 'Evolutions', 'Hidden Fates', 'Shining Fates', 'Champion’s Path', "Champion's Path", 'Celebrations', 'Evolving Skies', 'Brilliant Stars', 'Astral Radiance', 'Lost Origin', 'Silver Tempest', 'Crown Zenith', 'Scarlet & Violet 151', '151', 'Obsidian Flames', 'Paldea Evolved', 'Paradox Rift', 'Paldean Fates', 'Temporal Forces', 'Twilight Masquerade', 'Shrouded Fable', 'Stellar Crown', 'Surging Sparks', 'Prismatic Evolutions', 'Journey Together', 'Destined Rivals', 'Call of Legends', 'Vivid Voltage', 'Chilling Reign', 'Fusion Strike', 'Sword & Shield', 'Sun & Moon', 'XY Evolutions', 'Plasma Storm', 'Mega Evolution']; export interface CardAttrs { name: string | null; brand: string | null; set: string | null; number: string | null; variant: string | null; } /** Best-effort structured fields from a Goldin title; anything uncertain stays null. */ export function parseCardAttributes(title: string, categorySlug: string): CardAttrs { const clean = title.replace(/\s+-\s+(PSA|BGS|SGC|CGC|Beckett|TAG|WATA|VGA)\b.*$/i, '').trim(); let brand: string | null = null; for (const b of BRANDS) { if (new RegExp(`\\b${b.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\b`, 'i').test(clean)) { brand = b; break; } } let set: string | null = null; if (categorySlug === 'pokemon') { for (const s of POKEMON_SETS) { if (new RegExp(`\\b${s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\b`, 'i').test(clean)) { set = s; break; } } } else if (brand && /_cards$/.test(categorySlug)) { const m = clean.match(new RegExp(`\\b(\\d{4}(?:-\\d{2})?)\\s+(${brand.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}(?:\\s+[A-Z][\\w'&.-]*){0,3})`, 'i')); if (m) set = m[2]!.trim(); } const num = clean.match(/#\s?([A-Za-z]{0,4}-?\d{1,4}[A-Za-z]{0,2}(?:\/\d{1,3})?)/); const number = num ? num[1]! : null; const variantBits: string[] = []; for (const re of [/\b1st Edition\b/i, /\bShadowless\b/i, /\bHolo(?:foil)?\b/i, /\bReverse Holo\b/i, /\bRefractor\b/i, /\bSilver Prizm\b/i, /\bGold\b(?! Medal)/i, /\bAuto(?:graph)?\b/i, /\bRookie\b|\bRC\b/, /\bPatch\b/i, /\b\/\d{1,3}\b/]) { const m = clean.match(re); if (m) variantBits.push(m[0]); } const variant = variantBits.length ? variantBits.join(' ') : null; // name: text after the card number, or after brand/set, stripped of variant words let name: string | null = null; if (num && num.index !== undefined) { const after = clean.slice(num.index + num[0].length).replace(/^[\s\-–:]+/, ''); name = after.split(/\s+-\s+|\(|,|\bPSA\b|\bBGS\b|\bCGC\b|\bSGC\b/i)[0]!.trim() || null; } if (!name) { const stripped = clean.replace(/^\d{4}(?:-\d{2})?\s+/, '').replace(brand ? new RegExp(brand.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'i') : /$^/, '').replace(set ? new RegExp(set.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'i') : /$^/, '').replace(/\s+/g, ' ').trim(); name = stripped.split(/\s+-\s+|\(/)[0]!.trim() || null; } if (name) name = name.replace(/\b(1st Edition|Shadowless|Holo(?:foil)?|Reverse Holo|Refractor|Rookie|RC|Auto(?:graph)?)\b/gi, '').replace(/\s+/g, ' ').trim() || null; return { name, brand, set, number, variant }; }