TypeScript 61.9%
HTML 37.2%
SQL 0.7%
1/**2 * Helpers shared by the North-American auction-house connectors of group g7 (Miller & Miller, Wright,3 * LAMA, Doyle, Fanatics Collect, Clean Sweep, HiBid). Source vocabulary and page-shape parsing live4 * here; canonical models stay clean. Everything is pure and unit-testable on saved HTML.5 */6import { parseGradeFromTitle } from '@rareindex/taxonomy';7import type { CurrencyCode } from '@rareindex/shared';8import { hintFromLabel, isBundleTitle, safeYear, slugFromTitle, type DeptHint } from '../_auction-lib/categories.js';9import { gradeOf, lotAttributes, makeLot, popCultureCategory, sportsCategory } from '../_memorabilia-lib/index.js';10import { makeSale } from '../../firecrawl/_carlib/index.js';1112export { gradeOf, isBundleTitle, lotAttributes, makeLot, makeSale, safeYear, sportsCategory };1314// ---------------------------------------------------------------------------------------------15// JSON scanning inside HTML / JS text16// ---------------------------------------------------------------------------------------------1718/** Index just past the balanced JSON value (object/array) that starts at `start`; -1 when incomplete. */19export function scanJsonEnd(text: string, start: number): number {20 const open = text[start];21 if (open !== '{' && open !== '[') return -1;22 let depth = 0;23 let inStr = false;24 for (let i = start; i < text.length; i++) {25 const ch = text[i]!;26 if (inStr) {27 if (ch === '\\') i++;28 else if (ch === '"') inStr = false;29 continue;30 }31 if (ch === '"') inStr = true;32 else if (ch === '{' || ch === '[') depth++;33 else if (ch === '}' || ch === ']') {34 depth--;35 if (depth === 0) return i + 1;36 }37 }38 return -1;39}4041/** Parse the balanced JSON value starting at `start` (null when malformed). */42export function parseJsonAt<T = unknown>(text: string, start: number): T | null {43 const end = scanJsonEnd(text, start);44 if (end < 0) return null;45 try {46 return JSON.parse(text.slice(start, end)) as T;47 } catch {48 return null;49 }50}5152/**53 * Every JSON object that contains `marker` and starts with `objectStart` (e.g. Auction Mobility rows54 * start with `{"row_id":"` and are typed by `"type":"auction-lot-summary"`). Nested objects that also55 * match are skipped by jumping past each parsed object.56 */57export function jsonObjectsWithMarker<T = Record<string, unknown>>(text: string, marker: string, objectStart = '{"row_id":"'): T[] {58 const out: T[] = [];59 let from = 0;60 for (;;) {61 const i = text.indexOf(marker, from);62 if (i < 0) break;63 const start = text.lastIndexOf(objectStart, i);64 if (start < 0) {65 from = i + marker.length;66 continue;67 }68 const end = scanJsonEnd(text, start);69 if (end < 0 || end < i) {70 from = i + marker.length;71 continue;72 }73 try {74 out.push(JSON.parse(text.slice(start, end)) as T);75 } catch {76 /* malformed slice: skip */77 }78 from = Math.max(end, i + marker.length);79 }80 return out;81}8283/** Minimal HTML entity decoding for attribute payloads (Inertia `data-page`). */84export function decodeEntities(s: string): string {85 return s86 .replace(/"/g, '"')87 .replace(/�?39;|'/g, "'")88 .replace(/</g, '<')89 .replace(/>/g, '>')90 .replace(/ /g, ' ')91 .replace(/&#(\d+);/g, (_, n: string) => String.fromCodePoint(Number(n)))92 .replace(/&#x([0-9a-f]+);/gi, (_, n: string) => String.fromCodePoint(Number.parseInt(n, 16)))93 .replace(/&/g, '&');94}9596/** Inertia.js page payload: `<div id="app" data-page="{…}">` (Wright / LAMA). */97export function inertiaPage<T = Record<string, unknown>>(html: string): T | null {98 const m = html.match(/<div[^>]+id="app"[^>]+data-page="([^"]+)"/);99 if (!m) return null;100 try {101 return JSON.parse(decodeEntities(m[1]!)) as T;102 } catch {103 return null;104 }105}106107/** Next.js App Router flight payload: concatenated `self.__next_f.push([1,"…"])` chunks, JS-string decoded. */108export function nextFlightText(html: string): string {109 const re = /self\.__next_f\.push\(\[1,"((?:[^"\\]|\\.)*)"\]\)/g;110 const parts: string[] = [];111 let m: RegExpExecArray | null;112 while ((m = re.exec(html))) {113 try {114 parts.push(JSON.parse(`"${m[1]!}"`) as string);115 } catch {116 /* skip undecodable chunk */117 }118 }119 return parts.join('');120}121122/** First JSON object following `key` (e.g. `"prefetchedItemData":`) in a flight/JS text. */123export function jsonAfterKey<T = Record<string, unknown>>(text: string, key: string, from = 0): T | null {124 const i = text.indexOf(key, from);125 if (i < 0) return null;126 const start = text.indexOf('{', i + key.length);127 if (start < 0 || start - (i + key.length) > 4) return null;128 return parseJsonAt<T>(text, start);129}130131/** HiBid: `<script id="hibid-state" type="application/json">` → Apollo normalised cache. */132export function hibidApolloState(html: string): Record<string, Record<string, unknown>> | null {133 const m = html.match(/<script id="hibid-state" type="application\/json">([\s\S]*?)<\/script>/);134 if (!m) return null;135 try {136 const st = JSON.parse(decodeEntities(m[1]!)) as Record<string, unknown>;137 const cache = (st['apollo.state'] ?? st) as Record<string, Record<string, unknown>>;138 return cache && typeof cache === 'object' ? cache : null;139 } catch {140 return null;141 }142}143144/** Resolve an Apollo `{ __ref }` (or inline object) against the cache. */145export function apolloRef<T = Record<string, unknown>>(cache: Record<string, Record<string, unknown>>, v: unknown): T | null {146 if (!v || typeof v !== 'object') return null;147 const ref = (v as { __ref?: string }).__ref;148 if (ref) return (cache[ref] as T | undefined) ?? null;149 return v as T;150}151152// ---------------------------------------------------------------------------------------------153// Dates & money154// ---------------------------------------------------------------------------------------------155156const MONTHS: Record<string, number> = { jan: 0, feb: 1, mar: 2, apr: 3, may: 4, jun: 5, jul: 6, aug: 7, sep: 8, sept: 8, oct: 9, nov: 10, dec: 11 };157158/** "Sep 2, 2026 10:00 EST" | "Apr 16, 2026" → UTC date (US Eastern wall clock converted when a time zone is given). */159export function parseUsDate(s: string | null | undefined): Date | null {160 if (!s) return null;161 const m = s.match(/([A-Za-z]{3,9})\.?\s+(\d{1,2}),?\s+(\d{4})(?:\s+(\d{1,2}):(\d{2})\s*([AP]M)?\s*(E[SD]T|C[SD]T|M[SD]T|P[SD]T|UTC)?)?/);162 if (!m) return null;163 const mo = MONTHS[m[1]!.slice(0, 4).toLowerCase()] ?? MONTHS[m[1]!.slice(0, 3).toLowerCase()];164 if (mo === undefined) return null;165 const y = Number(m[3]);166 const d = Number(m[2]);167 if (!m[4]) return new Date(Date.UTC(y, mo, d));168 let hour = Number(m[4]);169 if (m[6] === 'PM' && hour < 12) hour += 12;170 if (m[6] === 'AM' && hour === 12) hour = 0;171 const offsets: Record<string, number> = { EST: 5, EDT: 4, CST: 6, CDT: 5, MST: 7, MDT: 6, PST: 8, PDT: 7, UTC: 0 };172 const off = offsets[m[7] ?? 'EST'] ?? 5;173 return new Date(Date.UTC(y, mo, d, hour + off, Number(m[5])));174}175176/** "april-2025" | "April 2025" → first day of that month (UTC). Precision is the month — callers must say so. */177export function monthYearDate(s: string | null | undefined): Date | null {178 if (!s) return null;179 const m = s.match(/([A-Za-z]{3,9})[\s-]+(\d{4})/);180 if (!m) return null;181 const mo = MONTHS[m[1]!.slice(0, 3).toLowerCase()];182 if (mo === undefined) return null;183 return new Date(Date.UTC(Number(m[2]), mo, 1));184}185186/** ISO 8601 (with or without zone; zone-less values are treated as UTC) → Date or null. */187export function isoDate(s: string | null | undefined): Date | null {188 if (!s) return null;189 const iso = /[zZ]$|[+-]\d{2}:?\d{2}$/.test(s) ? s : `${s}Z`;190 const d = new Date(iso);191 return Number.isNaN(d.getTime()) ? null : d;192}193194/** "$2,048" | "2048.00" | 2048 → positive number or null. */195export function amount(v: unknown): number | null {196 if (v === null || v === undefined || v === '') return null;197 const n = typeof v === 'number' ? v : Number.parseFloat(String(v).replace(/[^0-9.-]/g, ''));198 return Number.isFinite(n) && n > 0 ? n : null;199}200201export function isCurrency(s: string | null | undefined): s is CurrencyCode {202 return s === 'USD' || s === 'CAD' || s === 'EUR' || s === 'GBP' || s === 'CHF' || s === 'HKD' || s === 'AUD' || s === 'JPY';203}204205// ---------------------------------------------------------------------------------------------206// Category mapping for general-purpose houses207// ---------------------------------------------------------------------------------------------208209const SPORTS_LABEL = /sports?\s*(cards?|memorabilia)|hockey|baseball|basketball|football|trading cards?|game[- ]used/i;210const POP_LABEL = /pop culture|toys?|comics?|advertising|petroliana|coin[- ]op|movie|music|entertainment|disney|star wars/i;211212/**213 * Department/sale label + lot title → taxonomy slug. Sports-card houses route through `sportsCategory`;214 * pop-culture sales through `popCultureCategory`; everything else through the shared auction mapper.215 * Returns null when nothing confident matched (the connector counts an anomaly and skips the lot).216 */217export function houseCategory(label: string | null | undefined, title: string, fallback: string | null = null): string | null {218 const l = label ?? '';219 if (SPORTS_LABEL.test(l)) return sportsCategory(title);220 if (/petroliana|advertising|soda|gas|oil|signs?/i.test(l)) return /\b(sign|clock|thermometer|display|calendar|poster|globe|tin|can|bottle|crate)\b/i.test(title) ? 'advertising' : slugFromTitle(title, 'unknown') ?? 'advertising';221 if (POP_LABEL.test(l)) return popCultureCategory(title);222 const hint: DeptHint = hintFromLabel(l);223 const slug = slugFromTitle(title, hint);224 if (slug) return slug;225 if (hint === 'design') return 'design_furniture';226 if (hint === 'art' || hint === 'contemporary' || hint === 'prints') return 'art';227 if (hint === 'furniture' || hint === 'decorative' || hint === 'asian' || hint === 'tribal' || hint === 'antiquities') return 'antiques';228 if (hint === 'books') return 'books';229 if (hint === 'jewelry') return 'jewelry';230 if (hint === 'watches') return 'other_watches';231 if (hint === 'coins') return 'coins';232 return fallback;233}234235/** Card/TCG-aware mapper for sports-card marketplaces (Fanatics Collect). */236export function cardHouseCategory(title: string): string {237 if (/\bpok[eé]mon\b|\bcharizard\b|\bpikachu\b/i.test(title)) return 'pokemon';238 if (/magic:? the gathering|\bmtg\b|black lotus|\bmox\b/i.test(title)) return 'magic_the_gathering';239 if (/yu-?gi-?oh/i.test(title)) return 'yugioh';240 if (/\bone piece\b/i.test(title) && /\b(card|tcg|op0\d|leader|alt art|manga)\b/i.test(title)) return 'one_piece_card_game';241 if (/\blorcana\b/i.test(title)) return 'disney_lorcana';242 if (/\bdragon ?ball\b/i.test(title) && /\bcard|tcg|fusion world\b/i.test(title)) return 'dragon_ball_tcg';243 if (/\bdigimon\b/i.test(title)) return 'digimon_tcg';244 if (/\bflesh and blood\b/i.test(title)) return 'flesh_and_blood';245 if (/\bweiss schwarz\b/i.test(title)) return 'weiss_schwarz';246 if (/\b(marvel|dc|star wars|garbage pail|wacky pack|non-?sport)\b/i.test(title) && /\bcard|topps|panini|upper deck|fleer|skybox\b/i.test(title)) return 'non_sport_cards';247 if (/\b(sealed|booster box|hobby box|wax box|blaster|case)\b/i.test(title) && !/\bcard\b/i.test(title)) return sportsCategory(title);248 if (/\b(cgc|cbcs)\b.*\b#\s?\d+/i.test(title) && !/\bcard\b/i.test(title)) return popCultureCategory(title);249 return sportsCategory(title);250}251252/** Grade for sale records: title grading via taxonomy, plus PSA/DNA-style authentication left as null grader. */253export function saleGrade(title: string): { grader: string | null; grade: string | null; qualifier: string | null } {254 const g = gradeOf(title);255 const q = parseGradeFromTitle(title).qualifier;256 return { grader: g.grader, grade: g.grade, qualifier: q };257}258259/** Certification number written in a title, e.g. "PSA 10 (cert 12345678)" / "Cert #: 12345678". */260export function certFromTitle(title: string): string | null {261 const m = title.match(/\bcert(?:ification)?\.?\s*(?:no\.?|#|number)?[:\s#]*(\d{7,12})\b/i) ?? title.match(/\b(?:PSA|BGS|SGC|CGC|CBCS)\b[^()]{0,40}\((\d{7,12})\)/i);262 return m ? m[1]! : null;263}264