/** * Shared parsing helpers for the comics / toys / games connectors (gcd, comiclink, mycomicshop, * entertainment-earth, miniature-market, videogametrader, mattel-creations). Kept inside * connectors/ (not the framework). Nothing here guesses: unknown → null (SPEC §192). */ import { getGrader } from '@rareindex/taxonomy'; export const BOT_UA = 'RareIndexBot/0.1 (+https://www.rareindex.io/about/data; contact data@rareindex.io)'; /** Comic condition labels (Overstreet scale) as printed by dealers/auction houses. */ export const COMIC_GRADE_LABEL = '(?:GEM\\s*MT|GEM|MT|NM/MT|NM/M|NM\\+|NM-|NM|VF/NM|VFNM|VF\\+|VF-|VF|FN/VF|FNVF|FN\\+|FN-|FN|VG/FN|VGFN|VGF|VG\\+|VG-|VG|GD/VG|GDVG|GVG|GD\\+|GD-|GD|FR/GD|FRGD|FR|PR|P|M|Mint|Near Mint|Very Fine|Fine|Very Good|Good|Fair|Poor)'; const GRADERS = '(CGC|CBCS|PGX|EGS|CGG)'; const GRADED_RE = new RegExp(`\\b${GRADERS}\\b[\\s:-]*(?:${COMIC_GRADE_LABEL}(?![A-Za-z]))?[\\s:-]*(\\d{1,2}(?:\\.\\d)?)`, 'i'); const RAW_RE = new RegExp(`(? c.toUpperCase()) : null; if (variantM) t = t.replace(new RegExp(`\\b${variantM[1]!}(?:\\s+variant)?\\b`, 'i'), ' '); const series = t .replace(/\s+/g, ' ') .trim() .replace(/\s+comic books?$/i, '') .toLowerCase() .replace(/(^|[\s(/-])([a-z])/g, (m, pre: string, c: string) => pre + c.toUpperCase()); return { series: series || raw.trim(), issue, seriesYears, year, variant, isLot }; } /** Publisher (and optional language) → taxonomy slug by publisher family. Never returns null: family 'comics' is the honest fallback. */ export function publisherCategory(publisher: string | null | undefined, language?: string | null): 'marvel_comics' | 'dc_comics' | 'manga' | 'independent_comics' | 'comics' { const p = (publisher ?? '').toLowerCase(); if (language && /^(ja|jp|japanese)$/i.test(language)) return 'manga'; if (/\b(marvel|timely|atlas comics|atlas \[|marvel comics|epic comics|icon comics|max comics)\b/.test(p)) return 'marvel_comics'; if (/^dc\b|\bdc comics\b|\bdetective comics\b|\bnational (?:periodical|comics|allied)|\bvertigo\b|\bwildstorm\b|\ball-american\b/.test(p)) return 'dc_comics'; if (/\b(shueisha|kodansha|shogakukan|viz media|tokyopop|square enix|kadokawa|hakusensha|akita shoten)\b/.test(p)) return 'manga'; if (p) return 'independent_comics'; return 'comics'; } /** "Label #4013069001" · "Label #16-19B5702-003" → certification number (CGC/CBCS). */ export function parseLabelNumber(text: string | null | undefined): string | null { if (!text) return null; const m = text.match(/\bLabel\s*#\s*([0-9][0-9A-Za-z-]{5,})/i) ?? text.match(/\b(?:cert(?:ification)?|serial)\s*#?\s*:?\s*([0-9][0-9A-Za-z-]{6,})/i); return m ? m[1]! : null; } /** "$1,365" · "$1,030.00" → number (> 0) or null. */ export function usd(s: string | null | undefined): number | null { if (!s) return null; const m = s.replace(/,/g, '').match(/\$?\s*(\d+(?:\.\d+)?)/); if (!m) return null; const n = Number(m[1]); return Number.isFinite(n) && n > 0 ? n : null; } /** Month names → 0-based month, accepts "Sept". */ export function monthIndex(s: string): number | null { const months = ['jan', 'feb', 'mar', 'apr', 'may', 'jun', 'jul', 'aug', 'sep', 'oct', 'nov', 'dec']; const i = months.indexOf(s.slice(0, 3).toLowerCase()); return i < 0 ? null : i; } /** * ComicLink session labels → the month the session ended (UTC first-of-month) at MONTH precision. * "Spring Featured: Comics (5-6/26)" → 2026-06 · "Fall Featured (Oct-Nov)" + year 2022 → 2022-11 · * "11-12/2019" → 2019-12 · "Jan/Feb 2024 Premium …" → 2024-02 · "March Premium…" + year → year-03. */ export function sessionEndMonth(label: string, fallbackYear: number | null): { year: number; month: number } | null { const l = label.replace(/\s+/g, ' ').trim(); let m = l.match(/(\d{1,2})\s*-\s*(\d{1,2})\s*\/\s*(\d{2,4})/) ?? l.match(/\b(\d{1,2})\s*\/\s*(\d{2,4})\b/); if (m) { const end = m.length === 4 ? Number(m[2]) : Number(m[1]); const yr = Number(m[m.length - 1]); const year = yr < 100 ? 2000 + yr : yr; if (end >= 1 && end <= 12) return { year, month: end }; } const names = [...l.matchAll(/\b(Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)[a-z]*\b/gi)].map((x) => monthIndex(x[1]!)).filter((x): x is number => x !== null); const yearM = l.match(/\b(20\d{2})\b/); const year = yearM ? Number(yearM[1]) : fallbackYear; if (names.length && year) return { year, month: names[names.length - 1]! + 1 }; return null; }