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%
8.5 KB · 181 lines typescript
Raw Blame History
1/**2 * Text / grade helpers shared by the grading-company cert connectors (g2-grading group).3 * Kept inside connectors/firecrawl (not the framework).4 */5import { cardCategorySlug } from '../../api/_lib/wave4.js';67/** Collapse whitespace, strip NBSP, trim. */8export function clean(s: string | null | undefined): string {9  return (s ?? '').replace(/ /g, ' ').replace(/\s+/g, ' ').trim();10}1112/** "10,272" → 10272 ; "Coming Soon!" → null ; "" → null. Never negative. */13export function toInt(s: string | null | undefined): number | null {14  if (!s) return null;15  const m = clean(s).match(/^-?[\d,]+$/);16  if (!m) return null;17  const n = Number.parseInt(m[0].replace(/,/g, ''), 10);18  return Number.isFinite(n) && n >= 0 ? n : null;19}2021/** "$36,584.00" → 36584 ; null when absent. */22export function money(s: string | null | undefined): number | null {23  if (!s) return null;24  const m = clean(s).match(/-?\d[\d,]*(?:\.\d+)?/);25  if (!m) return null;26  const n = Number.parseFloat(m[0].replace(/,/g, ''));27  return Number.isFinite(n) && n > 0 ? n : null;28}2930/** "2018-08-15" | "6/38" | "9/8/2026" | "8/9/2022" → UTC Date (US month-first for slashed dates); null otherwise. */31export function parseDate(s: string | null | undefined): Date | null {32  const t = clean(s);33  let m = t.match(/^(\d{4})-(\d{2})-(\d{2})/);34  if (m) return utc(Number(m[1]), Number(m[2]), Number(m[3]));35  m = t.match(/^(\d{1,2})\/(\d{1,2})\/(\d{4})$/);36  if (m) return utc(Number(m[3]), Number(m[1]), Number(m[2]));37  m = t.match(/^(\d{1,2})\/(\d{2})$/); // "6/38" comic cover date (month/2-digit year)38  if (m) {39    const yy = Number(m[2]);40    return utc(yy >= 30 ? 1900 + yy : 2000 + yy, Number(m[1]), 1);41  }42  return null;43}4445function utc(y: number, mo: number, d: number): Date | null {46  if (mo < 1 || mo > 12 || d < 1 || d > 31) return null;47  const dt = new Date(Date.UTC(y, mo - 1, d));48  return Number.isNaN(dt.getTime()) ? null : dt;49}5051/** First plausible year 1500–2099 in the text (coins and banknotes reach back to the 1600s–1700s). */52export function yearOf(s: string | null | undefined): number | null {53  const m = clean(s).match(/\b(1[5-9]\d{2}|20\d{2})\b/);54  return m ? Number(m[1]) : null;55}5657/** "CHARIZARD-HOLO" → "Charizard-Holo"; leaves mixed-case input untouched. */58export function titleCase(s: string): string {59  const t = clean(s);60  if (t !== t.toUpperCase()) return t;61  return t.toLowerCase().replace(/(^|[\s\-/(#'])([a-z])/g, (_, p, c: string) => p + c.toUpperCase());62}6364export interface ParsedGrade {65  /** canonical grade string ("10", "9.5", "authentic", "MS65", "AU58", "67") or null when unparsable */66  grade: string | null;67  /** qualifier / designation ("OC", "Signature Series", "EPQ", "Ultra Cameo") */68  qualifier: string | null;69  /** the grader's own label as printed */70  label: string;71}7273const PSA_QUALIFIERS = new Set(['OC', 'MC', 'ST', 'PD', 'MK', 'OF']);7475/**76 * Card-style grades (PSA / CGC / TAG): "GEM MT 10" → 10, "MINT+ 9.5" → 9.5, "NM-MT 8 (OC)" → 8 + OC,77 * "9.0" → 9.0, "AUTHENTIC" → authentic, "Authentic Altered" → authentic + Altered.78 */79export function parseCardGrade(raw: string | null | undefined): ParsedGrade {80  const label = clean(raw);81  if (!label) return { grade: null, qualifier: null, label };82  let qualifier: string | null = null;83  let rest = label;84  const paren = rest.match(/\(([^)]+)\)/);85  if (paren) {86    qualifier = clean(paren[1]!);87    rest = rest.replace(paren[0], ' ');88  }89  const tokens = rest.split(/\s+/);90  const trailing = tokens[tokens.length - 1]!.toUpperCase();91  if (tokens.length > 1 && PSA_QUALIFIERS.has(trailing)) {92    qualifier = qualifier ?? trailing;93    tokens.pop();94    rest = tokens.join(' ');95  }96  const num = rest.match(/(?:^|\s)(10|[0-9](?:\.\d)?)(?=\s|$)/);97  if (num) return { grade: num[1]!, qualifier, label };98  if (/authentic/i.test(rest)) {99    const extra = clean(rest.replace(/authentic/i, ''));100    return { grade: 'authentic', qualifier: qualifier ?? (extra || null), label };101  }102  return { grade: null, qualifier, label };103}104105/**106 * Sheldon-scale coin grades (PCGS / NGC): "AU58" → AU58, "MS 61" → MS61, "MS65+" → MS65+,107 * "PF 70 ULTRA CAMEO" → PF70 + "Ultra Cameo", "NGC Details" / "UNC DETAILS" → null grade + qualifier.108 */109export function parseCoinGrade(raw: string | null | undefined): ParsedGrade {110  const label = clean(raw);111  if (!label) return { grade: null, qualifier: null, label };112  if (/\bdetails\b/i.test(label)) {113    // "AU DETAILS", "UNC DETAILS - CLEANED", "NGC Details": descriptive net grade + Details designation.114    const d = label.match(/^([A-Z]{2,3})\s+DETAILS\b\s*[-–:]?\s*(.*)$/i);115    const extra = d?.[2] ? titleCase(clean(d[2])) : null;116    return { grade: d ? d[1]!.toUpperCase() : null, qualifier: extra ? `Details, ${extra}` : 'Details', label };117  }118  const m = label.match(/^([A-Z]{1,2})\s?(\d{1,2})(\+|★|\*)?\s*(.*)$/i);119  if (m) {120    const grade = `${m[1]!.toUpperCase()}${m[2]}${m[3] === '+' ? '+' : ''}`;121    const tail = clean(m[4]).replace(/^[-–]\s*/, '');122    const star = m[3] && m[3] !== '+' ? 'Star' : null;123    const qualifier = [star, tail ? titleCase(tail) : null].filter(Boolean).join(' ') || null;124    return { grade, qualifier, label };125  }126  const bare = label.match(/^(\d{1,2})(\+)?$/);127  if (bare) return { grade: `${bare[1]}${bare[2] ?? ''}`, qualifier: null, label };128  return { grade: null, qualifier: null, label };129}130131/** Banknote grades (PMG): "67 EPQ" → 67 + EPQ, "58" → 58, "Net 30" → 30 + Net. */132export function parseNoteGrade(raw: string | null | undefined): ParsedGrade {133  const label = clean(raw);134  if (!label) return { grade: null, qualifier: null, label };135  const m = label.match(/(\d{1,2})/);136  if (!m) return { grade: null, qualifier: label || null, label };137  const qualifier = clean(label.replace(m[1]!, '')) || null;138  return { grade: m[1]!, qualifier: qualifier ? qualifier.toUpperCase() === 'EPQ' ? 'EPQ' : titleCase(qualifier) : null, label };139}140141/** Comics publisher → taxonomy slug (never guesses beyond the two big publishers). */142export function comicsCategorySlug(publisher: string | null | undefined): string {143  const p = clean(publisher).toLowerCase();144  if (/^(dc|d\.c\.)\b|dc comics/.test(p)) return 'dc_comics';145  if (/marvel/.test(p)) return 'marvel_comics';146  return 'comics';147}148149const SPORTS_BRANDS = /\b(topps|bowman|panini|upper deck|fleer|donruss|prizm|select|mosaic|optic|score|leaf|sage|skybox|hoops|o-pee-chee|parkhurst|goudey|play ball|t206|t205|w580|exquisite|national treasures|immaculate|flawless|contenders)\b/i;150151/**152 * Card categories for TAG/CGC Cards style descriptions: a known franchise/sport in the text wins,153 * otherwise a sports-card brand → generic `sports_cards`, otherwise generic `trading_cards`.154 */155export function cardCategoryFromText(text: string, fallback: 'trading_cards' | 'sports_cards' = 'trading_cards'): { slug: string; confident: boolean } {156  const slug = cardCategorySlug(text);157  if (slug && slug !== 'trading_cards') return { slug, confident: true };158  if (SPORTS_BRANDS.test(text)) return { slug: 'sports_cards', confident: false };159  return { slug: fallback, confident: false };160}161162/** PSA "Category" + "Brand/Title" → taxonomy slug. */163export function psaCategorySlug(category: string | null | undefined, brand: string | null | undefined, subject?: string | null): { slug: string; confident: boolean } {164  const cat = clean(category).toLowerCase();165  const text = `${clean(brand)} ${clean(subject ?? '')}`;166  if (/tcg|gaming/.test(cat)) return cardCategoryFromText(text, 'trading_cards');167  if (/non-?sport/.test(cat)) return { slug: cardCategorySlug(text) ?? 'non_sport_cards', confident: true };168  if (/baseball|basketball|football|hockey|soccer|racing|golf|tennis|boxing|wrestling|multi-?sport|misc/.test(cat)) {169    const s = cardCategorySlug(cat) ?? 'other_sports_cards';170    return { slug: s, confident: true };171  }172  if (/video ?game/.test(cat)) return { slug: cardCategorySlug(text) === 'video_games' ? 'video_games' : /nintendo|mario|zelda|pok[eé]mon/i.test(text) ? 'nintendo_games' : 'video_games', confident: true };173  if (/comic/.test(cat)) return { slug: comicsCategorySlug(brand), confident: true };174  if (/coin|medal|token/.test(cat)) return { slug: 'coins', confident: true };175  if (/ticket/.test(cat)) return { slug: 'sports_memorabilia', confident: false };176  if (/autograph|dna|signed/.test(cat)) return { slug: 'autographs', confident: true };177  if (/funko/.test(cat)) return { slug: 'funko', confident: true };178  if (/pack|box|wax/.test(cat)) return cardCategoryFromText(text, 'trading_cards');179  return cardCategoryFromText(`${text} ${cat}`, 'trading_cards');180}181