/** * Text / grade helpers shared by the grading-company cert connectors (g2-grading group). * Kept inside connectors/firecrawl (not the framework). */ import { cardCategorySlug } from '../../api/_lib/wave4.js'; /** Collapse whitespace, strip NBSP, trim. */ export function clean(s: string | null | undefined): string { return (s ?? '').replace(/ /g, ' ').replace(/\s+/g, ' ').trim(); } /** "10,272" → 10272 ; "Coming Soon!" → null ; "" → null. Never negative. */ export function toInt(s: string | null | undefined): number | null { if (!s) return null; const m = clean(s).match(/^-?[\d,]+$/); if (!m) return null; const n = Number.parseInt(m[0].replace(/,/g, ''), 10); return Number.isFinite(n) && n >= 0 ? n : null; } /** "$36,584.00" → 36584 ; null when absent. */ export function money(s: string | null | undefined): number | null { if (!s) return null; const m = clean(s).match(/-?\d[\d,]*(?:\.\d+)?/); if (!m) return null; const n = Number.parseFloat(m[0].replace(/,/g, '')); return Number.isFinite(n) && n > 0 ? n : null; } /** "2018-08-15" | "6/38" | "9/8/2026" | "8/9/2022" → UTC Date (US month-first for slashed dates); null otherwise. */ export function parseDate(s: string | null | undefined): Date | null { const t = clean(s); let m = t.match(/^(\d{4})-(\d{2})-(\d{2})/); if (m) return utc(Number(m[1]), Number(m[2]), Number(m[3])); m = t.match(/^(\d{1,2})\/(\d{1,2})\/(\d{4})$/); if (m) return utc(Number(m[3]), Number(m[1]), Number(m[2])); m = t.match(/^(\d{1,2})\/(\d{2})$/); // "6/38" comic cover date (month/2-digit year) if (m) { const yy = Number(m[2]); return utc(yy >= 30 ? 1900 + yy : 2000 + yy, Number(m[1]), 1); } return null; } function utc(y: number, mo: number, d: number): Date | null { if (mo < 1 || mo > 12 || d < 1 || d > 31) return null; const dt = new Date(Date.UTC(y, mo - 1, d)); return Number.isNaN(dt.getTime()) ? null : dt; } /** First plausible year 1500–2099 in the text (coins and banknotes reach back to the 1600s–1700s). */ export function yearOf(s: string | null | undefined): number | null { const m = clean(s).match(/\b(1[5-9]\d{2}|20\d{2})\b/); return m ? Number(m[1]) : null; } /** "CHARIZARD-HOLO" → "Charizard-Holo"; leaves mixed-case input untouched. */ export function titleCase(s: string): string { const t = clean(s); if (t !== t.toUpperCase()) return t; return t.toLowerCase().replace(/(^|[\s\-/(#'])([a-z])/g, (_, p, c: string) => p + c.toUpperCase()); } export interface ParsedGrade { /** canonical grade string ("10", "9.5", "authentic", "MS65", "AU58", "67") or null when unparsable */ grade: string | null; /** qualifier / designation ("OC", "Signature Series", "EPQ", "Ultra Cameo") */ qualifier: string | null; /** the grader's own label as printed */ label: string; } const PSA_QUALIFIERS = new Set(['OC', 'MC', 'ST', 'PD', 'MK', 'OF']); /** * Card-style grades (PSA / CGC / TAG): "GEM MT 10" → 10, "MINT+ 9.5" → 9.5, "NM-MT 8 (OC)" → 8 + OC, * "9.0" → 9.0, "AUTHENTIC" → authentic, "Authentic Altered" → authentic + Altered. */ export function parseCardGrade(raw: string | null | undefined): ParsedGrade { const label = clean(raw); if (!label) return { grade: null, qualifier: null, label }; let qualifier: string | null = null; let rest = label; const paren = rest.match(/\(([^)]+)\)/); if (paren) { qualifier = clean(paren[1]!); rest = rest.replace(paren[0], ' '); } const tokens = rest.split(/\s+/); const trailing = tokens[tokens.length - 1]!.toUpperCase(); if (tokens.length > 1 && PSA_QUALIFIERS.has(trailing)) { qualifier = qualifier ?? trailing; tokens.pop(); rest = tokens.join(' '); } const num = rest.match(/(?:^|\s)(10|[0-9](?:\.\d)?)(?=\s|$)/); if (num) return { grade: num[1]!, qualifier, label }; if (/authentic/i.test(rest)) { const extra = clean(rest.replace(/authentic/i, '')); return { grade: 'authentic', qualifier: qualifier ?? (extra || null), label }; } return { grade: null, qualifier, label }; } /** * Sheldon-scale coin grades (PCGS / NGC): "AU58" → AU58, "MS 61" → MS61, "MS65+" → MS65+, * "PF 70 ULTRA CAMEO" → PF70 + "Ultra Cameo", "NGC Details" / "UNC DETAILS" → null grade + qualifier. */ export function parseCoinGrade(raw: string | null | undefined): ParsedGrade { const label = clean(raw); if (!label) return { grade: null, qualifier: null, label }; if (/\bdetails\b/i.test(label)) { // "AU DETAILS", "UNC DETAILS - CLEANED", "NGC Details": descriptive net grade + Details designation. const d = label.match(/^([A-Z]{2,3})\s+DETAILS\b\s*[-–:]?\s*(.*)$/i); const extra = d?.[2] ? titleCase(clean(d[2])) : null; return { grade: d ? d[1]!.toUpperCase() : null, qualifier: extra ? `Details, ${extra}` : 'Details', label }; } const m = label.match(/^([A-Z]{1,2})\s?(\d{1,2})(\+|★|\*)?\s*(.*)$/i); if (m) { const grade = `${m[1]!.toUpperCase()}${m[2]}${m[3] === '+' ? '+' : ''}`; const tail = clean(m[4]).replace(/^[-–]\s*/, ''); const star = m[3] && m[3] !== '+' ? 'Star' : null; const qualifier = [star, tail ? titleCase(tail) : null].filter(Boolean).join(' ') || null; return { grade, qualifier, label }; } const bare = label.match(/^(\d{1,2})(\+)?$/); if (bare) return { grade: `${bare[1]}${bare[2] ?? ''}`, qualifier: null, label }; return { grade: null, qualifier: null, label }; } /** Banknote grades (PMG): "67 EPQ" → 67 + EPQ, "58" → 58, "Net 30" → 30 + Net. */ export function parseNoteGrade(raw: string | null | undefined): ParsedGrade { const label = clean(raw); if (!label) return { grade: null, qualifier: null, label }; const m = label.match(/(\d{1,2})/); if (!m) return { grade: null, qualifier: label || null, label }; const qualifier = clean(label.replace(m[1]!, '')) || null; return { grade: m[1]!, qualifier: qualifier ? qualifier.toUpperCase() === 'EPQ' ? 'EPQ' : titleCase(qualifier) : null, label }; } /** Comics publisher → taxonomy slug (never guesses beyond the two big publishers). */ export function comicsCategorySlug(publisher: string | null | undefined): string { const p = clean(publisher).toLowerCase(); if (/^(dc|d\.c\.)\b|dc comics/.test(p)) return 'dc_comics'; if (/marvel/.test(p)) return 'marvel_comics'; return 'comics'; } const 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; /** * Card categories for TAG/CGC Cards style descriptions: a known franchise/sport in the text wins, * otherwise a sports-card brand → generic `sports_cards`, otherwise generic `trading_cards`. */ export function cardCategoryFromText(text: string, fallback: 'trading_cards' | 'sports_cards' = 'trading_cards'): { slug: string; confident: boolean } { const slug = cardCategorySlug(text); if (slug && slug !== 'trading_cards') return { slug, confident: true }; if (SPORTS_BRANDS.test(text)) return { slug: 'sports_cards', confident: false }; return { slug: fallback, confident: false }; } /** PSA "Category" + "Brand/Title" → taxonomy slug. */ export function psaCategorySlug(category: string | null | undefined, brand: string | null | undefined, subject?: string | null): { slug: string; confident: boolean } { const cat = clean(category).toLowerCase(); const text = `${clean(brand)} ${clean(subject ?? '')}`; if (/tcg|gaming/.test(cat)) return cardCategoryFromText(text, 'trading_cards'); if (/non-?sport/.test(cat)) return { slug: cardCategorySlug(text) ?? 'non_sport_cards', confident: true }; if (/baseball|basketball|football|hockey|soccer|racing|golf|tennis|boxing|wrestling|multi-?sport|misc/.test(cat)) { const s = cardCategorySlug(cat) ?? 'other_sports_cards'; return { slug: s, confident: true }; } 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 }; if (/comic/.test(cat)) return { slug: comicsCategorySlug(brand), confident: true }; if (/coin|medal|token/.test(cat)) return { slug: 'coins', confident: true }; if (/ticket/.test(cat)) return { slug: 'sports_memorabilia', confident: false }; if (/autograph|dna|signed/.test(cat)) return { slug: 'autographs', confident: true }; if (/funko/.test(cat)) return { slug: 'funko', confident: true }; if (/pack|box|wax/.test(cat)) return cardCategoryFromText(text, 'trading_cards'); return cardCategoryFromText(`${text} ${cat}`, 'trading_cards'); }