/** * Helpers shared by the North-American auction-house connectors of group g7 (Miller & Miller, Wright, * LAMA, Doyle, Fanatics Collect, Clean Sweep, HiBid). Source vocabulary and page-shape parsing live * here; canonical models stay clean. Everything is pure and unit-testable on saved HTML. */ import { parseGradeFromTitle } from '@rareindex/taxonomy'; import type { CurrencyCode } from '@rareindex/shared'; import { hintFromLabel, isBundleTitle, safeYear, slugFromTitle, type DeptHint } from '../_auction-lib/categories.js'; import { gradeOf, lotAttributes, makeLot, popCultureCategory, sportsCategory } from '../_memorabilia-lib/index.js'; import { makeSale } from '../../firecrawl/_carlib/index.js'; export { gradeOf, isBundleTitle, lotAttributes, makeLot, makeSale, safeYear, sportsCategory }; // --------------------------------------------------------------------------------------------- // JSON scanning inside HTML / JS text // --------------------------------------------------------------------------------------------- /** Index just past the balanced JSON value (object/array) that starts at `start`; -1 when incomplete. */ export function scanJsonEnd(text: string, start: number): number { const open = text[start]; if (open !== '{' && open !== '[') return -1; let depth = 0; let inStr = false; for (let i = start; i < text.length; i++) { const ch = text[i]!; if (inStr) { if (ch === '\\') i++; else if (ch === '"') inStr = false; continue; } if (ch === '"') inStr = true; else if (ch === '{' || ch === '[') depth++; else if (ch === '}' || ch === ']') { depth--; if (depth === 0) return i + 1; } } return -1; } /** Parse the balanced JSON value starting at `start` (null when malformed). */ export function parseJsonAt(text: string, start: number): T | null { const end = scanJsonEnd(text, start); if (end < 0) return null; try { return JSON.parse(text.slice(start, end)) as T; } catch { return null; } } /** * Every JSON object that contains `marker` and starts with `objectStart` (e.g. Auction Mobility rows * start with `{"row_id":"` and are typed by `"type":"auction-lot-summary"`). Nested objects that also * match are skipped by jumping past each parsed object. */ export function jsonObjectsWithMarker>(text: string, marker: string, objectStart = '{"row_id":"'): T[] { const out: T[] = []; let from = 0; for (;;) { const i = text.indexOf(marker, from); if (i < 0) break; const start = text.lastIndexOf(objectStart, i); if (start < 0) { from = i + marker.length; continue; } const end = scanJsonEnd(text, start); if (end < 0 || end < i) { from = i + marker.length; continue; } try { out.push(JSON.parse(text.slice(start, end)) as T); } catch { /* malformed slice: skip */ } from = Math.max(end, i + marker.length); } return out; } /** Minimal HTML entity decoding for attribute payloads (Inertia `data-page`). */ export function decodeEntities(s: string): string { return s .replace(/"/g, '"') .replace(/�?39;|'/g, "'") .replace(/</g, '<') .replace(/>/g, '>') .replace(/ /g, ' ') .replace(/&#(\d+);/g, (_, n: string) => String.fromCodePoint(Number(n))) .replace(/&#x([0-9a-f]+);/gi, (_, n: string) => String.fromCodePoint(Number.parseInt(n, 16))) .replace(/&/g, '&'); } /** Inertia.js page payload: `
` (Wright / LAMA). */ export function inertiaPage>(html: string): T | null { const m = html.match(/]+id="app"[^>]+data-page="([^"]+)"/); if (!m) return null; try { return JSON.parse(decodeEntities(m[1]!)) as T; } catch { return null; } } /** Next.js App Router flight payload: concatenated `self.__next_f.push([1,"…"])` chunks, JS-string decoded. */ export function nextFlightText(html: string): string { const re = /self\.__next_f\.push\(\[1,"((?:[^"\\]|\\.)*)"\]\)/g; const parts: string[] = []; let m: RegExpExecArray | null; while ((m = re.exec(html))) { try { parts.push(JSON.parse(`"${m[1]!}"`) as string); } catch { /* skip undecodable chunk */ } } return parts.join(''); } /** First JSON object following `key` (e.g. `"prefetchedItemData":`) in a flight/JS text. */ export function jsonAfterKey>(text: string, key: string, from = 0): T | null { const i = text.indexOf(key, from); if (i < 0) return null; const start = text.indexOf('{', i + key.length); if (start < 0 || start - (i + key.length) > 4) return null; return parseJsonAt(text, start); } /** HiBid: `