import { html as H, type ConnectorMeta } from '@rareindex/connectors'; import { parseGradeFromTitle } from '@rareindex/taxonomy'; import type { AssetAttributes, CurrencyCode, NormalizedAuctionLot, NormalizedCatalogItem, NormalizedListing } from '@rareindex/shared'; import { lotAttributes, money } from '../../firecrawl/_carlib/index.js'; /** * Helpers shared by the memorabilia / toys / dealer connectors (SCP, Hake's, Morphy, Lelands, BBTS, * Noble Knight, Trainz). Source vocabulary stays here; canonical models stay clean. */ export const BOT_UA = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) RareIndexBot/0.1 (+https://www.rareindex.io/about; market-data research)'; // --------------------------------------------------------------------------------------------- // Plain HTTP helpers for sites whose paging/selection is an ASP.NET postback (what a browser does: // a form POST carrying the page's own hidden fields; no authentication involved). // --------------------------------------------------------------------------------------------- export interface HttpResult { status: number; text: string; cookies: string[]; } export async function httpText(url: string, opts: { method?: 'GET' | 'POST'; form?: Record; cookie?: string; referer?: string; timeoutMs?: number } = {}): Promise { const ctrl = new AbortController(); const timer = setTimeout(() => ctrl.abort(), opts.timeoutMs ?? 45_000); try { const headers: Record = { 'user-agent': BOT_UA, accept: 'text/html,application/xhtml+xml,*/*;q=0.8', 'accept-language': 'en-US,en;q=0.9' }; if (opts.cookie) headers.cookie = opts.cookie; if (opts.referer) headers.referer = opts.referer; let body: string | undefined; if (opts.method === 'POST') { headers['content-type'] = 'application/x-www-form-urlencoded'; body = new URLSearchParams(opts.form ?? {}).toString(); } const res = await fetch(url, { method: opts.method ?? 'GET', headers, body, signal: ctrl.signal, redirect: 'follow' }); const text = await res.text(); const cookies: string[] = []; const anyHeaders = res.headers as Headers & { getSetCookie?: () => string[] }; if (typeof anyHeaders.getSetCookie === 'function') cookies.push(...anyHeaders.getSetCookie()); else { const sc = res.headers.get('set-cookie'); if (sc) cookies.push(sc); } return { status: res.status, text, cookies }; } finally { clearTimeout(timer); } } /** Cookie header from Set-Cookie lines (name=value only). */ export function cookieHeader(setCookies: string[], previous = ''): string { const jar = new Map(); for (const part of previous.split(';')) { const [k, ...v] = part.trim().split('='); if (k) jar.set(k, v.join('=')); } for (const sc of setCookies) { const first = sc.split(';')[0] ?? ''; const [k, ...v] = first.trim().split('='); if (k) jar.set(k, v.join('=')); } return [...jar.entries()].map(([k, v]) => `${k}=${v}`).join('; '); } /** ASP.NET hidden inputs (__VIEWSTATE, __EVENTVALIDATION, …) plus any other hidden fields. */ export function hiddenFields(htmlText: string): Record { const out: Record = {}; const re = /]+type="hidden"[^>]*>/gi; let m: RegExpExecArray | null; while ((m = re.exec(htmlText))) { const tag = m[0]; const name = tag.match(/name="([^"]+)"/)?.[1]; if (!name) continue; const value = tag.match(/value="([^"]*)"/)?.[1] ?? ''; out[name] = value.replace(/"/g, '"').replace(/&/g, '&'); } return out; } /** Selected value of a `, 'i')); if (!m) return null; const sel = m[1]!.match(/]*selected(?:="selected")?[^>]*value="([^"]*)"/i) ?? m[1]!.match(/]*value="([^"]*)"[^>]*selected/i); if (sel) return sel[1]!; return m[1]!.match(/]*value="([^"]*)"/i)?.[1] ?? null; } export function selectOptions(htmlText: string, name: string): Array<{ value: string; label: string }> { const m = htmlText.match(new RegExp(`]*name="${name.replace(/[$]/g, '\\$')}"[^>]*>([\\s\\S]*?)`, 'i')); if (!m) return []; const out: Array<{ value: string; label: string }> = []; const re = /]*value="([^"]*)"[^>]*>([^<]*) { const e = $(el); const id = e.attr('data-event_id'); const name = e.attr('data-event_name') ?? H.text(e.find('h1')) ?? ''; const href = e.find('h1 a').attr('href') ?? e.find('a[href*="/auctions/"]').first().attr('href'); if (!id || !href) return; const st = e.attr('data-event_status') ?? 'unknown'; out.push({ id, name, url: href.split('#')[0]!, status: st === 'past' || st === 'upcoming' || st === 'live' ? st : 'unknown', startDate: null, endDate: null }); }); return out; } export function parseBidsquareCatalog(htmlText: string, page: number): BidsquareCatalog | null { const $ = H.load(htmlText); let event: BidsquareEvent | null = null; for (const ld of H.jsonLd(htmlText, 'Event')) { const url = String(ld.url ?? ''); const id = url.match(/-(\d+)$/)?.[1] ?? null; if (!id) continue; event = { id, name: String(ld.name ?? ''), url, status: 'unknown', startDate: ld.startDate ? String(ld.startDate) : null, endDate: ld.endDate ? String(ld.endDate) : null }; break; } const first = $('[data-item_id][data-event_id]').first(); if (!event) { const id = first.attr('data-event_id'); if (!id) return null; event = { id, name: first.attr('data-event_name') ?? '', url: '', status: 'unknown', startDate: null, endDate: null }; } const st = first.attr('data-event_status'); if (st === 'past' || st === 'upcoming' || st === 'live') event.status = st; const items: BidsquareItem[] = []; const seen = new Set(); $('div[id^="stl-"][data-item_id]').each((_, el) => { const c = $(el); const itemId = c.attr('data-item_id')!; if (seen.has(itemId)) return; seen.add(itemId); const link = c.find('.lot_title a').first(); const url = link.attr('href') ?? c.find('.catalog_img a').attr('href') ?? ''; const title = H.text(link) ?? c.find('.catalog_img img').attr('alt') ?? ''; if (!url || !title) return; const lotNumber = H.text(c.find('.lot_Num'))?.replace(/^Lot\s*/i, '') ?? null; const image = c.find('.catalog_img img').attr('src') ?? null; const label = H.text(c.find('.bid_txt span').first()); const priceText = H.text(c.find('.bid_txt .price').first()); const bidsText = H.text(c.find('.bidLength .num').first()); const est = c.find('.estimate_amount').attr('data-exchange'); let estimateLow: number | null = null; let estimateHigh: number | null = null; if (est) { try { const j = JSON.parse(est.replace(/"/g, '"')) as { low_est?: string; high_est?: string }; estimateLow = j.low_est ? Number(j.low_est) : null; estimateHigh = j.high_est ? Number(j.high_est) : null; } catch { /* ignore */ } } const script = c.find('script').text(); const cd = script.match(/countDownTimer\((\d+),\s*(\d+)/); items.push({ itemId, url, title, lotNumber, image, priceLabel: label ?? null, price: money(priceText, 'USD')?.amount ?? null, bids: bidsText ? Number(bidsText.replace(/[^\d]/g, '')) || null : null, estimateLow, estimateHigh, startsAt: cd ? Number(cd[1]) : null, endsAt: cd ? Number(cd[2]) : null, }); }); const pages = $('ul.pagination li[data-page]') .map((_, li) => Number($(li).attr('data-page'))) .get() .filter((n) => Number.isFinite(n) && n > 0); return { event, page, totalPages: pages.length ? Math.max(...pages) : null, items }; } /** "2026-07-26 22:30:00 EDT" → Date (Bidsquare prints US Eastern local time). */ export function bidsquareDate(s: string | null | undefined): Date | null { if (!s) return null; const m = s.match(/(\d{4})-(\d{2})-(\d{2})\s+(\d{2}):(\d{2}):(\d{2})\s*(EDT|EST|PDT|PST|CDT|CST|MDT|MST|UTC)?/); if (!m) return null; const offsets: Record = { EDT: 4, EST: 5, CDT: 5, CST: 6, MDT: 6, MST: 7, PDT: 7, PST: 8, UTC: 0 }; const off = offsets[m[7] ?? 'EDT'] ?? 4; return new Date(Date.UTC(Number(m[1]), Number(m[2]) - 1, Number(m[3]), Number(m[4]) + off, Number(m[5]), Number(m[6]))); } // --------------------------------------------------------------------------------------------- // Category mappers (keyword based; null/family fallbacks, never invented attributes) // --------------------------------------------------------------------------------------------- const SPORT_WORDS: Array<[RegExp, string]> = [ [/\b(baseball|topps|bowman|goudey|t206|bond bread|play ball|leaf 1948|mlb|world series|yankees|red sox|dodgers|jackie robinson|babe ruth|mickey mantle|lou gehrig|ty cobb|honus wagner)\b/i, 'baseball'], [/\b(basketball|nba|fleer basketball|fleer|hoops|jordan rookie|michael jordan|lebron|kobe|lakers|celtics)\b/i, 'basketball'], [/\b(football|nfl|super bowl|quarterback|packers|cowboys|steelers)\b/i, 'football'], [/\b(hockey|nhl|stanley cup|o-pee-chee|opc|canadiens|bruins|maple leafs)\b/i, 'hockey'], [/\b(soccer|fifa|world cup|premier league|messi|ronaldo|pel[eé])\b/i, 'soccer'], ]; const CARD_WORDS = /\b(card|rookie|psa|sgc|bgs|beckett|cgc|wax pack|unopened|refractor|parallel|#\s?\d+|1\/1)\b/i; const MEMORABILIA_WORDS = /\b(jersey|game[- ]used|game[- ]worn|bat\b|glove|helmet|ball\b|puck|ring\b|trophy|ticket|program|contract|photo|photograph|signed|autograph|cleats|shoes|sneakers|medal|pennant|uniform)\b/i; /** Sports houses (SCP, Lelands, Hunt): cards by sport vs memorabilia. */ export function sportsCategory(title: string): string { const isCard = CARD_WORDS.test(title) && !MEMORABILIA_WORDS.test(title); if (isCard) { for (const [re, s] of SPORT_WORDS) if (re.test(title)) return `${s}_cards`; if (/\b(pok[eé]mon|charizard)\b/i.test(title)) return 'pokemon'; if (/\b(magic: the gathering|mtg|black lotus)\b/i.test(title)) return 'magic_the_gathering'; return 'other_sports_cards'; } if (/\b(olympic|olympics)\b/i.test(title)) return 'olympic_collectibles'; if (/\b(boxing|ufc|golf|tennis|nascar|wrestling|wwe|wwf|racing)\b/i.test(title)) return 'sports_memorabilia'; return 'sports_memorabilia'; } /** Pop-culture houses (Hake's): toys, comics, political, pins… */ export function popCultureCategory(title: string): string { const t = title; if (/\bcgc\b|\bcbcs\b|comic book|#\s?\d+\s*\(?(19|20)\d\d|amazing spider-man|detective comics|action comics|x-men|batman|superman|marvel comics|dc comics/i.test(t) && !/\b(afa|figure|playset|vehicle)\b/i.test(t)) { if (/\b(marvel|spider-man|x-men|avengers|hulk|fantastic four|iron man|captain america|thor)\b/i.test(t)) return 'marvel_comics'; if (/\b(dc|batman|superman|detective comics|action comics|wonder woman|flash|green lantern)\b/i.test(t)) return 'dc_comics'; return 'independent_comics'; } if (/\b(original art|comic art|cover art|splash page|comic book page|storyboard)\b/i.test(t)) return 'animation_art'; if (/\b(campaign|president|political|button\b.*\b(19|18)\d\d|inaugur|senator|governor|election)\b/i.test(t)) return 'political_memorabilia'; if (/\b(star wars|kenner|luke skywalker|darth vader|boba fett|tusken|stormtrooper|jedi|sith|millennium falcon)\b/i.test(t)) return 'star_wars'; if (/\b(garbage pail kids|gpk|wacky packages|non-sport|trading card set)\b/i.test(t)) return 'non_sport_cards'; if (/\b(psa|sgc|bgs)\s*\d|\btopps\b|\bbowman\b|rookie card/i.test(t)) return sportsCategory(t); if (/\b(pinback|pin-back|pin back|\bpin\b|badge)\b/i.test(t)) return 'pins'; if (/\b(disney|mickey mouse|donald duck|disneyland|walt disney)\b/i.test(t)) return 'disney_collectibles'; if (/\b(lunch box|lunchbox|thermos|pez|board game|premium ring|cereal|advertising|sign\b)\b/i.test(t)) return 'advertising'; if (/\b(animation cel|production cel|cel\b)\b/i.test(t)) return 'animation_art'; if (/\b(record|album|lp\b|concert|beatles|elvis|kiss\b)\b/i.test(t)) return 'music_memorabilia'; if (/\b(movie|film|poster|lobby card|one sheet|screen[- ]used|prop\b)\b/i.test(t)) return 'movie_posters'; if (/\b(signed|autograph)\b/i.test(t)) return 'autographs'; if (/\b(g\.?i\.? joe|transformers|he-man|masters of the universe|tmnt|teenage mutant|action figure|afa\b|moc\b|mib\b|playset|hot wheels|matchbox|tin toy|wind-up|battery op|marx|ideal|mego|mattel|hasbro|lionel)\b/i.test(t)) return 'vintage_toys'; if (/\b(jersey|game used|game worn|baseball|football|basketball|hockey|boxing)\b/i.test(t)) return 'sports_memorabilia'; return 'vintage_toys'; } /** Morphy: department label + auction/lot titles → slug; firearms lots are excluded upstream. */ export function morphyCategory(dept: string | null, auctionTitle: string, lotTitle: string): string | null { const d = (dept ?? '').toLowerCase(); const a = auctionTitle.toLowerCase(); const t = lotTitle; if (/firearm|weapon|militaria|edged/.test(d) || /firearm|militaria/.test(a) || /\b(rifle|pistol|revolver|shotgun|carbine|ammunition|cartridge|bayonet)\b/i.test(t)) return null; if (/coin-op|coin op|arcade|slot machine|jukebox|vending/.test(d + a)) return /\b(slot machine|trade stimulator|casino)\b/i.test(t) ? 'casino_memorabilia' : /\b(jukebox|arcade|pinball)\b/i.test(t) ? 'arcade_pinball' : /\b(sign|tin|display|calendar|poster)\b/i.test(t) ? 'advertising' : 'vending_machines'; if (/\bcoins?\b|currency|banknote|numismat/.test(d + a)) return /\b(note|bill|currency|dollar bill)\b/i.test(t) ? 'banknotes' : 'coins'; if (/perfume|fragrance/.test(d + a)) return 'perfume'; if (/petroliana|automobilia|gas|oil|soda|advertising/.test(d + a)) return /\b(sign|clock|thermometer|display|calendar|poster|globe)\b/i.test(t) || /advertising|petroliana|soda/.test(a) ? 'advertising' : 'automotive_memorabilia'; if (/train/.test(d + a) || /\b(lionel|american flyer|marklin|märklin|ives|standard gauge|o gauge|ho scale)\b/i.test(t)) return 'model_trains'; if (/doll/.test(d + a) && /\b(doll|bisque|barbie|steiff|teddy|bear)\b/i.test(t)) return 'dolls'; if (/toy/.test(d + a)) return /\b(bank|cast iron)\b/i.test(t) ? 'vintage_toys' : 'vintage_toys'; if (/sport/.test(d + a)) return sportsCategory(t); if (/jewel|watch/.test(d + a)) return /\b(watch|wristwatch|pocket watch)\b/i.test(t) ? 'other_watches' : 'jewelry'; if (/fine|decorative|lamp|glass|art/.test(d + a)) { if (/\b(lamp|tiffany|handel|pairpoint)\b/i.test(t)) return 'antiques'; if (/\b(perfume|atomizer|scent bottle)\b/i.test(t)) return 'perfume'; if (/\b(vase|glass|paperweight|cameo glass|loetz|galle|gallé|daum|steuben|lalique|murano)\b/i.test(t)) return 'glass_crystal'; if (/\b(porcelain|meissen|sevres|s[eè]vres|royal doulton|figurine|pottery|ceramic)\b/i.test(t)) return 'porcelain'; if (/\b(sterling|silver)\b/i.test(t)) return 'silver'; if (/\b(clock|regulator)\b/i.test(t)) return 'clocks'; if (/\b(oil on canvas|painting|bronze|sculpture|lithograph|print)\b/i.test(t)) return 'art'; return 'antiques'; } if (/comic|pop culture|movie|music|entertainment/.test(d + a)) return popCultureCategory(t); return null; } // --------------------------------------------------------------------------------------------- // Builders for non-sale records // --------------------------------------------------------------------------------------------- export interface BaseInput { meta: ConnectorMeta; sourceUrl: string; externalId: string; rawTitle: string; attributes: AssetAttributes; imageUrls?: string[]; description?: string | null; observedAt: Date; parserVersion: string; confidence?: number; grader?: string | null; grade?: string | null; condition?: string | null; conditionRaw?: string | null; completeness?: string | null; } function base(i: BaseInput) { return { connectorId: i.meta.id, sourceId: i.meta.sourceId, sourceUrl: i.sourceUrl, externalId: i.externalId, rawTitle: i.rawTitle, description: i.description ?? null, imageUrls: i.imageUrls ?? [], attributes: i.attributes, grade: { grader: i.grader ?? null, grade: i.grade ?? null, qualifier: null, certificationNumber: null }, condition: { condition: i.condition ?? null, conditionRaw: i.conditionRaw ?? null, completeness: i.completeness ?? null }, observedAt: i.observedAt, confidence: i.confidence ?? 0.85, parserVersion: i.parserVersion, }; } export function makeListing(i: BaseInput & { price: number | null; currency: CurrencyCode | null; listingType?: NormalizedListing['listingType']; availability?: NormalizedListing['availability']; seller?: string | null; location?: string | null; quantity?: number | null; listedAt?: Date | null; endsAt?: Date | null; bidCount?: number | null }): NormalizedListing { return { kind: 'listing', ...base(i), listingType: i.listingType ?? 'fixed_price', price: i.price, currency: i.currency, seller: i.seller ?? null, sellerReputation: null, location: i.location ?? null, shippingCost: null, quantity: i.quantity ?? null, listedAt: i.listedAt ?? null, endsAt: i.endsAt ?? null, availability: i.availability ?? 'available', bidCount: i.bidCount ?? null }; } export function makeLot(i: BaseInput & { auctionHouse: string; auctionName?: string | null; lotNumber?: string | null; startsAt?: Date | null; endsAt?: Date | null; estimateLow?: number | null; estimateHigh?: number | null; currentBid?: number | null; currency: CurrencyCode | null; status?: NormalizedAuctionLot['status']; location?: string | null }): NormalizedAuctionLot { return { kind: 'auction_lot', ...base(i), auctionHouse: i.auctionHouse, auctionName: i.auctionName ?? null, lotNumber: i.lotNumber ?? null, startsAt: i.startsAt ?? null, endsAt: i.endsAt ?? null, estimateLow: i.estimateLow ?? null, estimateHigh: i.estimateHigh ?? null, currentBid: i.currentBid ?? null, currency: i.currency, status: i.status ?? 'unknown', location: i.location ?? null }; } export function makeCatalogItem(i: BaseInput & { releaseDate?: Date | null }): NormalizedCatalogItem { return { kind: 'catalog_item', ...base(i), releaseDate: i.releaseDate ?? null }; } /** Grade for sales/listings: only graded companies, never 'raw' as grader. */ export function gradeOf(title: string): { grader: string | null; grade: string | null } { const g = parseGradeFromTitle(title); if (!g.grader || g.grader === 'raw') return { grader: null, grade: null }; let grade = g.grade; if (!grade) { // Houses often write the label between company and number ("SGC FR 1.5", "PSA VG-EX 4", "PSA NM-MT+ 8.5"). const m = title.match(/\b(PSA|SGC|BGS|CGC|CBCS|TAG)\b[\s:-]*(?:[A-Z]{1,3}(?:[-/][A-Z]{1,3})?\+?\s*)?(\d{1,2}(?:\.\d)?)\b/i); if (m && Number(m[2]) <= 10) grade = m[2]!; } return { grader: g.grader, grade }; } /** AFA / CAS / UKG toy grades ("AFA 85", "AFA Qualified 80") kept as metadata, not graders (not in taxonomy). */ export function toyGrade(title: string): { company: string; grade: string } | null { const m = title.match(/\b(AFA|CAS|UKG)\s*(?:Qualified\s*)?(\d{2,3})/i); return m ? { company: m[1]!.toUpperCase(), grade: m[2]! } : null; } /** Condition words from dealer labels → 'boxed_toys' scale (board games, trains, figures). */ export function dealerCondition(raw: string | null | undefined): { condition: string | null; completeness: string | null } { if (!raw) return { condition: null, completeness: null }; const r = raw.toLowerCase(); if (/\b(new|sealed|shrink|sw\b|mint in box|mib|misb|nib|moc)\b/.test(r)) return { condition: 'mint_in_box', completeness: 'sealed' }; if (/\b(like new|ln\b|near mint|nm\b|mint\b)\b/.test(r)) return { condition: 'near_mint_box', completeness: 'boxed' }; if (/\b(excellent|ex\b|very good|vg\b|vg\+|ex\+)\b/.test(r)) return { condition: 'boxed', completeness: 'boxed' }; if (/\b(good|g\b|fair|played|used|restored)\b/.test(r)) return { condition: 'loose_complete', completeness: 'loose' }; if (/\b(poor|damaged|parts|incomplete)\b/.test(r)) return { condition: 'loose', completeness: 'loose' }; return { condition: null, completeness: null }; } export { lotAttributes, money };