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%
7.6 KB · 142 lines typescript
Raw Blame History
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