TypeScript 61.9%
HTML 37.2%
SQL 0.7%
1/**2 * Shared parsing helpers for the comics / toys / games connectors (gcd, comiclink, mycomicshop,3 * entertainment-earth, miniature-market, videogametrader, mattel-creations). Kept inside4 * connectors/ (not the framework). Nothing here guesses: unknown → null (SPEC §192).5 */6import { getGrader } from '@rareindex/taxonomy';78export const BOT_UA = 'RareIndexBot/0.1 (+https://www.rareindex.io/about/data; contact data@rareindex.io)';910/** Comic condition labels (Overstreet scale) as printed by dealers/auction houses. */11export 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)';12const GRADERS = '(CGC|CBCS|PGX|EGS|CGG)';13const GRADED_RE = new RegExp(`\\b${GRADERS}\\b[\\s:-]*(?:${COMIC_GRADE_LABEL}(?![A-Za-z]))?[\\s:-]*(\\d{1,2}(?:\\.\\d)?)`, 'i');14const RAW_RE = new RegExp(`(?<![A-Za-z])(${COMIC_GRADE_LABEL})(?![A-Za-z])[\\s:-]*(\\d{1,2}(?:\\.\\d)?)?`, 'i');1516export interface ComicGrade {17 /** taxonomy grader slug (cgc, cbcs, pgx) or 'raw' when a dealer condition is given, null when nothing found */18 grader: string | null;19 /** numeric grade as printed ("9.4"); null for descriptive-only raw conditions */20 grade: string | null;21 /** condition label as printed ("NM", "VF/NM", "Fine") */22 label: string | null;23 qualifier: string | null;24}2526/** "CGC 9.4 NM" · "SOLD in CBCS 9.6" · "VF- 7.5" · "Fine" → grader/grade/label. */27export function parseComicGrade(text: string | null | undefined): ComicGrade {28 if (!text) return { grader: null, grade: null, label: null, qualifier: null };29 const qualifier = /signature\s*series/i.test(text) ? 'Signature Series' : /restored|\bRESTORED\b|\(R\)/.test(text) && /\bCGC\b|\bCBCS\b/i.test(text) ? 'Restored' : /qualified/i.test(text) ? 'Qualified' : null;30 const g = text.match(GRADED_RE);31 if (g) {32 const slug = getGrader(g[1]!)?.slug ?? g[1]!.toLowerCase();33 const label = text.match(new RegExp(`\\b${GRADERS}\\b[\\s:-]*(${COMIC_GRADE_LABEL})(?![A-Za-z])`, 'i'))?.[2] ?? text.match(new RegExp(`\\d(?:\\.\\d)?\\s+(${COMIC_GRADE_LABEL})(?![A-Za-z])`, 'i'))?.[1] ?? null;34 return { grader: slug, grade: g[2] ?? null, label: label ?? null, qualifier };35 }36 const r = text.match(RAW_RE);37 if (r) return { grader: 'raw', grade: r[2] ?? null, label: r[1]!, qualifier };38 return { grader: null, grade: null, label: null, qualifier };39}4041export interface ComicTitleParts {42 series: string;43 issue: string | null;44 /** "(1963 1st Series)" / "(1963-2011)" style qualifier as printed */45 seriesYears: string | null;46 /** single publication year when the title carries exactly one year */47 year: number | null;48 variant: string | null;49 isLot: boolean;50}5152/**53 * "AMAZING SPIDER-MAN #129" · "Amazing Spider-Man (1963 1st Series) 300" · "X-MEN (1963-2011) #282"54 * → { series: 'Amazing Spider-Man', issue: '129', seriesYears, year }.55 */56export function parseComicTitle(raw: string): ComicTitleParts {57 let t = raw.replace(/\s+/g, ' ').trim();58 const isLot = /\b(group lot|lot of \d+|\d+\s+(?:issue|comic)s?\b.*\blot\b|collection of)\b/i.test(t);59 const paren = t.match(/\(((?:19|20)\d{2})(?:\s*-\s*(\d{2,4}))?(?:\s+([^)]{1,30}))?\)/);60 const seriesYears = paren ? paren[0].slice(1, -1).trim() : null;61 const year = paren && !paren[2] ? Number(paren[1]) : null;62 if (paren) t = t.replace(paren[0], ' ');63 const hashIssue = t.match(/#\s*(\d+[A-Za-z]?(?:\.\d+)?(?:\/\d+)?)/);64 let issue = hashIssue?.[1] ?? null;65 if (hashIssue) t = t.replace(hashIssue[0], ' ');66 else {67 // MyComicShop style: "Amazing Spider-Man (1963 1st Series) 300" → trailing bare number68 const tail = t.match(/\s(\d{1,4}[A-Za-z]?)(?:\s+(?:CGC|CBCS|PGX)\b.*)?$/);69 if (tail) {70 issue = tail[1]!;71 t = t.slice(0, tail.index).trim();72 }73 }74 // strip grade tails ("CGC 9.4 NM", "VF 8.0") and sale words75 t = t.replace(new RegExp(`\\b(?:CGC|CBCS|PGX)\\b.*$`, 'i'), ' ').replace(/\bSOLD\b.*$/i, ' ');76 const variantM = t.match(/\b(variant|newsstand|direct edition|2nd print(?:ing)?|3rd print(?:ing)?|facsimile|sketch cover|virgin)\b/i);77 const variant = variantM ? variantM[1]!.replace(/\b\w/g, (c) => c.toUpperCase()) : null;78 if (variantM) t = t.replace(new RegExp(`\\b${variantM[1]!}(?:\\s+variant)?\\b`, 'i'), ' ');79 const series = t80 .replace(/\s+/g, ' ')81 .trim()82 .replace(/\s+comic books?$/i, '')83 .toLowerCase()84 .replace(/(^|[\s(/-])([a-z])/g, (m, pre: string, c: string) => pre + c.toUpperCase());85 return { series: series || raw.trim(), issue, seriesYears, year, variant, isLot };86}8788/** Publisher (and optional language) → taxonomy slug by publisher family. Never returns null: family 'comics' is the honest fallback. */89export function publisherCategory(publisher: string | null | undefined, language?: string | null): 'marvel_comics' | 'dc_comics' | 'manga' | 'independent_comics' | 'comics' {90 const p = (publisher ?? '').toLowerCase();91 if (language && /^(ja|jp|japanese)$/i.test(language)) return 'manga';92 if (/\b(marvel|timely|atlas comics|atlas \[|marvel comics|epic comics|icon comics|max comics)\b/.test(p)) return 'marvel_comics';93 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';94 if (/\b(shueisha|kodansha|shogakukan|viz media|tokyopop|square enix|kadokawa|hakusensha|akita shoten)\b/.test(p)) return 'manga';95 if (p) return 'independent_comics';96 return 'comics';97}9899/** "Label #4013069001" · "Label #16-19B5702-003" → certification number (CGC/CBCS). */100export function parseLabelNumber(text: string | null | undefined): string | null {101 if (!text) return null;102 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);103 return m ? m[1]! : null;104}105106/** "$1,365" · "$1,030.00" → number (> 0) or null. */107export function usd(s: string | null | undefined): number | null {108 if (!s) return null;109 const m = s.replace(/,/g, '').match(/\$?\s*(\d+(?:\.\d+)?)/);110 if (!m) return null;111 const n = Number(m[1]);112 return Number.isFinite(n) && n > 0 ? n : null;113}114115/** Month names → 0-based month, accepts "Sept". */116export function monthIndex(s: string): number | null {117 const months = ['jan', 'feb', 'mar', 'apr', 'may', 'jun', 'jul', 'aug', 'sep', 'oct', 'nov', 'dec'];118 const i = months.indexOf(s.slice(0, 3).toLowerCase());119 return i < 0 ? null : i;120}121122/**123 * ComicLink session labels → the month the session ended (UTC first-of-month) at MONTH precision.124 * "Spring Featured: Comics (5-6/26)" → 2026-06 · "Fall Featured (Oct-Nov)" + year 2022 → 2022-11 ·125 * "11-12/2019" → 2019-12 · "Jan/Feb 2024 Premium …" → 2024-02 · "March Premium…" + year → year-03.126 */127export function sessionEndMonth(label: string, fallbackYear: number | null): { year: number; month: number } | null {128 const l = label.replace(/\s+/g, ' ').trim();129 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/);130 if (m) {131 const end = m.length === 4 ? Number(m[2]) : Number(m[1]);132 const yr = Number(m[m.length - 1]);133 const year = yr < 100 ? 2000 + yr : yr;134 if (end >= 1 && end <= 12) return { year, month: end };135 }136 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);137 const yearM = l.match(/\b(20\d{2})\b/);138 const year = yearM ? Number(yearM[1]) : fallbackYear;139 if (names.length && year) return { year, month: names[names.length - 1]! + 1 };140 return null;141}142