SPB Git forge

spb/rareindex

Public
54commits 1branches 0releases
7.1 MBsize
maindefault branch
10 days agolast push
TypeScript 61.9% HTML 37.2% SQL 0.7%
6.8 KB · 106 lines typescript
Raw Blame History
1/** Title heuristics specific to Goldin lot titles. Kept out of the canonical domain model (§196.6). */23export interface SeedLike {4  path: string;5  categorySlug: string;6  sport?: string;7}89const 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;10const 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;11const COMIC_HINT = /\bcgc\b|\bcbcs\b|\bcomic|#\d+\s*\(19|#\d+\s*\(20|newsstand|1st appearance/i;12const CONSOLE_HINT = /\bconsole\b|\bsystem\b|\bcontroller\b|\bgame boy\b(?!.*(?:cartridge|game))/i;1314const SPORT_CARD_SLUG: Record<string, string> = { basketball: 'basketball_cards', baseball: 'baseball_cards', football: 'football_cards', hockey: 'hockey_cards', soccer: 'soccer_cards', racing: 'f1_cards' };1516export function classifyGoldinTitle(title: string, seed: SeedLike, breadcrumb: string[] = []): string {17  const t = title;18  if (seed.sport) {19    const cardSlug = SPORT_CARD_SLUG[seed.sport] ?? 'other_sports_cards';20    if (MEMORABILIA.test(t) && !CARD_HINT.test(t)) return 'sports_memorabilia';21    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';22    if (seed.sport === 'racing' && !/\bf1\b|formula 1|formula one/i.test(t)) return 'other_sports_cards';23    return cardSlug;24  }25  switch (seed.categorySlug) {26    case 'marvel_comics':27      return COMIC_HINT.test(t) ? 'marvel_comics' : 'non_sport_cards';28    case 'nintendo_games':29      return CONSOLE_HINT.test(t) ? 'gaming_hardware' : 'nintendo_games';30    case 'sports_memorabilia': {31      const crumbs = breadcrumb.join(' ').toLowerCase();32      if (/pokemon/.test(crumbs) || /\bpokemon\b/i.test(t)) return 'pokemon';33      if (/magic: the gathering|\bmtg\b/i.test(t)) return 'magic_the_gathering';34      if (/yu-gi-oh/i.test(t)) return 'yugioh';35      if (/one piece/i.test(t)) return 'one_piece_card_game';36      for (const [sport, slug] of Object.entries(SPORT_CARD_SLUG)) {37        if (new RegExp(`\\b${sport}\\b`, 'i').test(crumbs)) return MEMORABILIA.test(t) && !CARD_HINT.test(t) ? 'sports_memorabilia' : slug;38      }39      if (CARD_HINT.test(t) && !MEMORABILIA.test(t)) return 'other_sports_cards';40      return 'sports_memorabilia';41    }42    default:43      return seed.categorySlug;44  }45}4647export function isBundleTitle(title: string): boolean {48  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);49}5051const 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'];5253const 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'];5455export interface CardAttrs {56  name: string | null;57  brand: string | null;58  set: string | null;59  number: string | null;60  variant: string | null;61}6263/** Best-effort structured fields from a Goldin title; anything uncertain stays null. */64export function parseCardAttributes(title: string, categorySlug: string): CardAttrs {65  const clean = title.replace(/\s+-\s+(PSA|BGS|SGC|CGC|Beckett|TAG|WATA|VGA)\b.*$/i, '').trim();66  let brand: string | null = null;67  for (const b of BRANDS) {68    if (new RegExp(`\\b${b.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\b`, 'i').test(clean)) {69      brand = b;70      break;71    }72  }73  let set: string | null = null;74  if (categorySlug === 'pokemon') {75    for (const s of POKEMON_SETS) {76      if (new RegExp(`\\b${s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\b`, 'i').test(clean)) {77        set = s;78        break;79      }80    }81  } else if (brand && /_cards$/.test(categorySlug)) {82    const m = clean.match(new RegExp(`\\b(\\d{4}(?:-\\d{2})?)\\s+(${brand.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}(?:\\s+[A-Z][\\w'&.-]*){0,3})`, 'i'));83    if (m) set = m[2]!.trim();84  }85  const num = clean.match(/#\s?([A-Za-z]{0,4}-?\d{1,4}[A-Za-z]{0,2}(?:\/\d{1,3})?)/);86  const number = num ? num[1]! : null;87  const variantBits: string[] = [];88  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/]) {89    const m = clean.match(re);90    if (m) variantBits.push(m[0]);91  }92  const variant = variantBits.length ? variantBits.join(' ') : null;93  // name: text after the card number, or after brand/set, stripped of variant words94  let name: string | null = null;95  if (num && num.index !== undefined) {96    const after = clean.slice(num.index + num[0].length).replace(/^[\s\-–:]+/, '');97    name = after.split(/\s+-\s+|\(|,|\bPSA\b|\bBGS\b|\bCGC\b|\bSGC\b/i)[0]!.trim() || null;98  }99  if (!name) {100    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();101    name = stripped.split(/\s+-\s+|\(/)[0]!.trim() || null;102  }103  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;104  return { name, brand, set, number, variant };105}106