Connectors: SCP, Hake's, Morphy, Lelands, BBTS, Noble Knight, Trainz (agent U)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
38 changed files +2,702 −0
added
connectors/api/_memorabilia-lib/_capture-nk.ts
+16 −0
@@ -0,0 +1,16 @@ | ||
| 1 | +import { readFileSync } from 'node:fs'; | |
| 2 | +import { ConnectorMetaSchema, createCrawlContext, createRouter } from '@rareindex/connectors'; | |
| 3 | +import { saveFixture } from '@rareindex/connectors/testing'; | |
| 4 | +import { childLogger } from '@rareindex/shared'; | |
| 5 | +import create from '../noble-knight/index.js'; | |
| 6 | +const meta = ConnectorMetaSchema.parse(JSON.parse(readFileSync('connectors/api/noble-knight/meta.json', 'utf8'))); | |
| 7 | +const c = create(meta); | |
| 8 | +const ctx = createCrawlContext({ router: createRouter({}), meta, options: { mode: 'probe' }, log: childLogger({}) }); | |
| 9 | +for (const [name, url] of [['board-game', 'https://www.nobleknight.com/P/2147993326/007---Spectre-Board-Game'], ['gunpla', 'https://www.nobleknight.com/P/2148351787/002-Ex-S-GUNDAM']] as const) { | |
| 10 | + const raws = await c.lookup!(url, ctx); | |
| 11 | + const raw = raws[0]!; | |
| 12 | + const fetchedAt = raw.fetchedAt ?? new Date(); | |
| 13 | + const records = await c.normalize({ ...raw, externalId: raw.externalId ?? null, fetchedAt }); | |
| 14 | + saveFixture(meta.id, name, { raw: { url: raw.url, externalId: raw.externalId ?? null, kind: raw.kind, engine: raw.engine, fetchedAt, payload: raw.payload }, expect: { count: records.length, kinds: [...new Set(records.map((r) => r.kind))] }, note: `Captured live via lookup(${url}).` }); | |
| 15 | + console.log(name, records.length, records.map((r) => r.kind)); | |
| 16 | +} | |
added
connectors/api/_memorabilia-lib/index.ts
+391 −0
@@ -0,0 +1,391 @@ | ||
| 1 | +import { html as H, type ConnectorMeta } from '@rareindex/connectors'; | |
| 2 | +import { parseGradeFromTitle } from '@rareindex/taxonomy'; | |
| 3 | +import type { AssetAttributes, CurrencyCode, NormalizedAuctionLot, NormalizedCatalogItem, NormalizedListing } from '@rareindex/shared'; | |
| 4 | +import { lotAttributes, money } from '../../firecrawl/_carlib/index.js'; | |
| 5 | + | |
| 6 | +/** | |
| 7 | + * Helpers shared by the memorabilia / toys / dealer connectors (SCP, Hake's, Morphy, Lelands, BBTS, | |
| 8 | + * Noble Knight, Trainz). Source vocabulary stays here; canonical models stay clean. | |
| 9 | + */ | |
| 10 | + | |
| 11 | +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)'; | |
| 12 | + | |
| 13 | +// --------------------------------------------------------------------------------------------- | |
| 14 | +// Plain HTTP helpers for sites whose paging/selection is an ASP.NET postback (what a browser does: | |
| 15 | +// a form POST carrying the page's own hidden fields; no authentication involved). | |
| 16 | +// --------------------------------------------------------------------------------------------- | |
| 17 | +export interface HttpResult { | |
| 18 | + status: number; | |
| 19 | + text: string; | |
| 20 | + cookies: string[]; | |
| 21 | +} | |
| 22 | + | |
| 23 | +export async function httpText(url: string, opts: { method?: 'GET' | 'POST'; form?: Record<string, string>; cookie?: string; referer?: string; timeoutMs?: number } = {}): Promise<HttpResult> { | |
| 24 | + const ctrl = new AbortController(); | |
| 25 | + const timer = setTimeout(() => ctrl.abort(), opts.timeoutMs ?? 45_000); | |
| 26 | + try { | |
| 27 | + const headers: Record<string, string> = { 'user-agent': BOT_UA, accept: 'text/html,application/xhtml+xml,*/*;q=0.8', 'accept-language': 'en-US,en;q=0.9' }; | |
| 28 | + if (opts.cookie) headers.cookie = opts.cookie; | |
| 29 | + if (opts.referer) headers.referer = opts.referer; | |
| 30 | + let body: string | undefined; | |
| 31 | + if (opts.method === 'POST') { | |
| 32 | + headers['content-type'] = 'application/x-www-form-urlencoded'; | |
| 33 | + body = new URLSearchParams(opts.form ?? {}).toString(); | |
| 34 | + } | |
| 35 | + const res = await fetch(url, { method: opts.method ?? 'GET', headers, body, signal: ctrl.signal, redirect: 'follow' }); | |
| 36 | + const text = await res.text(); | |
| 37 | + const cookies: string[] = []; | |
| 38 | + const anyHeaders = res.headers as Headers & { getSetCookie?: () => string[] }; | |
| 39 | + if (typeof anyHeaders.getSetCookie === 'function') cookies.push(...anyHeaders.getSetCookie()); | |
| 40 | + else { | |
| 41 | + const sc = res.headers.get('set-cookie'); | |
| 42 | + if (sc) cookies.push(sc); | |
| 43 | + } | |
| 44 | + return { status: res.status, text, cookies }; | |
| 45 | + } finally { | |
| 46 | + clearTimeout(timer); | |
| 47 | + } | |
| 48 | +} | |
| 49 | + | |
| 50 | +/** Cookie header from Set-Cookie lines (name=value only). */ | |
| 51 | +export function cookieHeader(setCookies: string[], previous = ''): string { | |
| 52 | + const jar = new Map<string, string>(); | |
| 53 | + for (const part of previous.split(';')) { | |
| 54 | + const [k, ...v] = part.trim().split('='); | |
| 55 | + if (k) jar.set(k, v.join('=')); | |
| 56 | + } | |
| 57 | + for (const sc of setCookies) { | |
| 58 | + const first = sc.split(';')[0] ?? ''; | |
| 59 | + const [k, ...v] = first.trim().split('='); | |
| 60 | + if (k) jar.set(k, v.join('=')); | |
| 61 | + } | |
| 62 | + return [...jar.entries()].map(([k, v]) => `${k}=${v}`).join('; '); | |
| 63 | +} | |
| 64 | + | |
| 65 | +/** ASP.NET hidden inputs (__VIEWSTATE, __EVENTVALIDATION, …) plus any other hidden fields. */ | |
| 66 | +export function hiddenFields(htmlText: string): Record<string, string> { | |
| 67 | + const out: Record<string, string> = {}; | |
| 68 | + const re = /<input[^>]+type="hidden"[^>]*>/gi; | |
| 69 | + let m: RegExpExecArray | null; | |
| 70 | + while ((m = re.exec(htmlText))) { | |
| 71 | + const tag = m[0]; | |
| 72 | + const name = tag.match(/name="([^"]+)"/)?.[1]; | |
| 73 | + if (!name) continue; | |
| 74 | + const value = tag.match(/value="([^"]*)"/)?.[1] ?? ''; | |
| 75 | + out[name] = value.replace(/"/g, '"').replace(/&/g, '&'); | |
| 76 | + } | |
| 77 | + return out; | |
| 78 | +} | |
| 79 | + | |
| 80 | +/** Selected value of a <select name="…"> (first option when none is marked). */ | |
| 81 | +export function selectedOption(htmlText: string, name: string): string | null { | |
| 82 | + const m = htmlText.match(new RegExp(`<select[^>]*name="${name.replace(/[$]/g, '\\$')}"[^>]*>([\\s\\S]*?)</select>`, 'i')); | |
| 83 | + if (!m) return null; | |
| 84 | + const sel = m[1]!.match(/<option[^>]*selected(?:="selected")?[^>]*value="([^"]*)"/i) ?? m[1]!.match(/<option[^>]*value="([^"]*)"[^>]*selected/i); | |
| 85 | + if (sel) return sel[1]!; | |
| 86 | + return m[1]!.match(/<option[^>]*value="([^"]*)"/i)?.[1] ?? null; | |
| 87 | +} | |
| 88 | + | |
| 89 | +export function selectOptions(htmlText: string, name: string): Array<{ value: string; label: string }> { | |
| 90 | + const m = htmlText.match(new RegExp(`<select[^>]*name="${name.replace(/[$]/g, '\\$')}"[^>]*>([\\s\\S]*?)</select>`, 'i')); | |
| 91 | + if (!m) return []; | |
| 92 | + const out: Array<{ value: string; label: string }> = []; | |
| 93 | + const re = /<option[^>]*value="([^"]*)"[^>]*>([^<]*)</gi; | |
| 94 | + let o: RegExpExecArray | null; | |
| 95 | + while ((o = re.exec(m[1]!))) out.push({ value: o[1]!, label: o[2]!.replace(/&/g, '&').trim() }); | |
| 96 | + return out; | |
| 97 | +} | |
| 98 | + | |
| 99 | +// --------------------------------------------------------------------------------------------- | |
| 100 | +// Bidsquare-hosted catalog sites (SCP Auctions, Hake's) share one HTML structure. | |
| 101 | +// --------------------------------------------------------------------------------------------- | |
| 102 | +export interface BidsquareEvent { | |
| 103 | + id: string; | |
| 104 | + name: string; | |
| 105 | + url: string; | |
| 106 | + status: 'upcoming' | 'live' | 'past' | 'unknown'; | |
| 107 | + startDate: string | null; | |
| 108 | + endDate: string | null; | |
| 109 | +} | |
| 110 | +export interface BidsquareItem { | |
| 111 | + itemId: string; | |
| 112 | + url: string; | |
| 113 | + title: string; | |
| 114 | + lotNumber: string | null; | |
| 115 | + image: string | null; | |
| 116 | + /** "Sold for" | "Current Bid" | "Starting Bid" | "Passed" | null */ | |
| 117 | + priceLabel: string | null; | |
| 118 | + price: number | null; | |
| 119 | + bids: number | null; | |
| 120 | + estimateLow: number | null; | |
| 121 | + estimateHigh: number | null; | |
| 122 | + /** unix seconds from the countdown script (start, end) when present */ | |
| 123 | + startsAt: number | null; | |
| 124 | + endsAt: number | null; | |
| 125 | +} | |
| 126 | +export interface BidsquareCatalog { | |
| 127 | + event: BidsquareEvent; | |
| 128 | + page: number; | |
| 129 | + totalPages: number | null; | |
| 130 | + items: BidsquareItem[]; | |
| 131 | +} | |
| 132 | + | |
| 133 | +export function parseBidsquareEvents(htmlText: string): BidsquareEvent[] { | |
| 134 | + const $ = H.load(htmlText); | |
| 135 | + const out: BidsquareEvent[] = []; | |
| 136 | + $('.gtm-visible_event').each((_, el) => { | |
| 137 | + const e = $(el); | |
| 138 | + const id = e.attr('data-event_id'); | |
| 139 | + const name = e.attr('data-event_name') ?? H.text(e.find('h1')) ?? ''; | |
| 140 | + const href = e.find('h1 a').attr('href') ?? e.find('a[href*="/auctions/"]').first().attr('href'); | |
| 141 | + if (!id || !href) return; | |
| 142 | + const st = e.attr('data-event_status') ?? 'unknown'; | |
| 143 | + out.push({ id, name, url: href.split('#')[0]!, status: st === 'past' || st === 'upcoming' || st === 'live' ? st : 'unknown', startDate: null, endDate: null }); | |
| 144 | + }); | |
| 145 | + return out; | |
| 146 | +} | |
| 147 | + | |
| 148 | +export function parseBidsquareCatalog(htmlText: string, page: number): BidsquareCatalog | null { | |
| 149 | + const $ = H.load(htmlText); | |
| 150 | + let event: BidsquareEvent | null = null; | |
| 151 | + for (const ld of H.jsonLd(htmlText, 'Event')) { | |
| 152 | + const url = String(ld.url ?? ''); | |
| 153 | + const id = url.match(/-(\d+)$/)?.[1] ?? null; | |
| 154 | + if (!id) continue; | |
| 155 | + event = { id, name: String(ld.name ?? ''), url, status: 'unknown', startDate: ld.startDate ? String(ld.startDate) : null, endDate: ld.endDate ? String(ld.endDate) : null }; | |
| 156 | + break; | |
| 157 | + } | |
| 158 | + const first = $('[data-item_id][data-event_id]').first(); | |
| 159 | + if (!event) { | |
| 160 | + const id = first.attr('data-event_id'); | |
| 161 | + if (!id) return null; | |
| 162 | + event = { id, name: first.attr('data-event_name') ?? '', url: '', status: 'unknown', startDate: null, endDate: null }; | |
| 163 | + } | |
| 164 | + const st = first.attr('data-event_status'); | |
| 165 | + if (st === 'past' || st === 'upcoming' || st === 'live') event.status = st; | |
| 166 | + const items: BidsquareItem[] = []; | |
| 167 | + const seen = new Set<string>(); | |
| 168 | + $('div[id^="stl-"][data-item_id]').each((_, el) => { | |
| 169 | + const c = $(el); | |
| 170 | + const itemId = c.attr('data-item_id')!; | |
| 171 | + if (seen.has(itemId)) return; | |
| 172 | + seen.add(itemId); | |
| 173 | + const link = c.find('.lot_title a').first(); | |
| 174 | + const url = link.attr('href') ?? c.find('.catalog_img a').attr('href') ?? ''; | |
| 175 | + const title = H.text(link) ?? c.find('.catalog_img img').attr('alt') ?? ''; | |
| 176 | + if (!url || !title) return; | |
| 177 | + const lotNumber = H.text(c.find('.lot_Num'))?.replace(/^Lot\s*/i, '') ?? null; | |
| 178 | + const image = c.find('.catalog_img img').attr('src') ?? null; | |
| 179 | + const label = H.text(c.find('.bid_txt span').first()); | |
| 180 | + const priceText = H.text(c.find('.bid_txt .price').first()); | |
| 181 | + const bidsText = H.text(c.find('.bidLength .num').first()); | |
| 182 | + const est = c.find('.estimate_amount').attr('data-exchange'); | |
| 183 | + let estimateLow: number | null = null; | |
| 184 | + let estimateHigh: number | null = null; | |
| 185 | + if (est) { | |
| 186 | + try { | |
| 187 | + const j = JSON.parse(est.replace(/"/g, '"')) as { low_est?: string; high_est?: string }; | |
| 188 | + estimateLow = j.low_est ? Number(j.low_est) : null; | |
| 189 | + estimateHigh = j.high_est ? Number(j.high_est) : null; | |
| 190 | + } catch { | |
| 191 | + /* ignore */ | |
| 192 | + } | |
| 193 | + } | |
| 194 | + const script = c.find('script').text(); | |
| 195 | + const cd = script.match(/countDownTimer\((\d+),\s*(\d+)/); | |
| 196 | + items.push({ | |
| 197 | + itemId, | |
| 198 | + url, | |
| 199 | + title, | |
| 200 | + lotNumber, | |
| 201 | + image, | |
| 202 | + priceLabel: label ?? null, | |
| 203 | + price: money(priceText, 'USD')?.amount ?? null, | |
| 204 | + bids: bidsText ? Number(bidsText.replace(/[^\d]/g, '')) || null : null, | |
| 205 | + estimateLow, | |
| 206 | + estimateHigh, | |
| 207 | + startsAt: cd ? Number(cd[1]) : null, | |
| 208 | + endsAt: cd ? Number(cd[2]) : null, | |
| 209 | + }); | |
| 210 | + }); | |
| 211 | + const pages = $('ul.pagination li[data-page]') | |
| 212 | + .map((_, li) => Number($(li).attr('data-page'))) | |
| 213 | + .get() | |
| 214 | + .filter((n) => Number.isFinite(n) && n > 0); | |
| 215 | + return { event, page, totalPages: pages.length ? Math.max(...pages) : null, items }; | |
| 216 | +} | |
| 217 | + | |
| 218 | +/** "2026-07-26 22:30:00 EDT" → Date (Bidsquare prints US Eastern local time). */ | |
| 219 | +export function bidsquareDate(s: string | null | undefined): Date | null { | |
| 220 | + if (!s) return null; | |
| 221 | + 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)?/); | |
| 222 | + if (!m) return null; | |
| 223 | + const offsets: Record<string, number> = { EDT: 4, EST: 5, CDT: 5, CST: 6, MDT: 6, MST: 7, PDT: 7, PST: 8, UTC: 0 }; | |
| 224 | + const off = offsets[m[7] ?? 'EDT'] ?? 4; | |
| 225 | + 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]))); | |
| 226 | +} | |
| 227 | + | |
| 228 | +// --------------------------------------------------------------------------------------------- | |
| 229 | +// Category mappers (keyword based; null/family fallbacks, never invented attributes) | |
| 230 | +// --------------------------------------------------------------------------------------------- | |
| 231 | +const SPORT_WORDS: Array<[RegExp, string]> = [ | |
| 232 | + [/\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'], | |
| 233 | + [/\b(basketball|nba|fleer basketball|fleer|hoops|jordan rookie|michael jordan|lebron|kobe|lakers|celtics)\b/i, 'basketball'], | |
| 234 | + [/\b(football|nfl|super bowl|quarterback|packers|cowboys|steelers)\b/i, 'football'], | |
| 235 | + [/\b(hockey|nhl|stanley cup|o-pee-chee|opc|canadiens|bruins|maple leafs)\b/i, 'hockey'], | |
| 236 | + [/\b(soccer|fifa|world cup|premier league|messi|ronaldo|pel[eé])\b/i, 'soccer'], | |
| 237 | +]; | |
| 238 | +const CARD_WORDS = /\b(card|rookie|psa|sgc|bgs|beckett|cgc|wax pack|unopened|refractor|parallel|#\s?\d+|1\/1)\b/i; | |
| 239 | +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; | |
| 240 | + | |
| 241 | +/** Sports houses (SCP, Lelands, Hunt): cards by sport vs memorabilia. */ | |
| 242 | +export function sportsCategory(title: string): string { | |
| 243 | + const isCard = CARD_WORDS.test(title) && !MEMORABILIA_WORDS.test(title); | |
| 244 | + if (isCard) { | |
| 245 | + for (const [re, s] of SPORT_WORDS) if (re.test(title)) return `${s}_cards`; | |
| 246 | + if (/\b(pok[eé]mon|charizard)\b/i.test(title)) return 'pokemon'; | |
| 247 | + if (/\b(magic: the gathering|mtg|black lotus)\b/i.test(title)) return 'magic_the_gathering'; | |
| 248 | + return 'other_sports_cards'; | |
| 249 | + } | |
| 250 | + if (/\b(olympic|olympics)\b/i.test(title)) return 'olympic_collectibles'; | |
| 251 | + if (/\b(boxing|ufc|golf|tennis|nascar|wrestling|wwe|wwf|racing)\b/i.test(title)) return 'sports_memorabilia'; | |
| 252 | + return 'sports_memorabilia'; | |
| 253 | +} | |
| 254 | + | |
| 255 | +/** Pop-culture houses (Hake's): toys, comics, political, pins… */ | |
| 256 | +export function popCultureCategory(title: string): string { | |
| 257 | + const t = title; | |
| 258 | + 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)) { | |
| 259 | + if (/\b(marvel|spider-man|x-men|avengers|hulk|fantastic four|iron man|captain america|thor)\b/i.test(t)) return 'marvel_comics'; | |
| 260 | + if (/\b(dc|batman|superman|detective comics|action comics|wonder woman|flash|green lantern)\b/i.test(t)) return 'dc_comics'; | |
| 261 | + return 'independent_comics'; | |
| 262 | + } | |
| 263 | + if (/\b(original art|comic art|cover art|splash page|comic book page|storyboard)\b/i.test(t)) return 'animation_art'; | |
| 264 | + if (/\b(campaign|president|political|button\b.*\b(19|18)\d\d|inaugur|senator|governor|election)\b/i.test(t)) return 'political_memorabilia'; | |
| 265 | + if (/\b(star wars|kenner|luke skywalker|darth vader|boba fett|tusken|stormtrooper|jedi|sith|millennium falcon)\b/i.test(t)) return 'star_wars'; | |
| 266 | + if (/\b(garbage pail kids|gpk|wacky packages|non-sport|trading card set)\b/i.test(t)) return 'non_sport_cards'; | |
| 267 | + if (/\b(psa|sgc|bgs)\s*\d|\btopps\b|\bbowman\b|rookie card/i.test(t)) return sportsCategory(t); | |
| 268 | + if (/\b(pinback|pin-back|pin back|\bpin\b|badge)\b/i.test(t)) return 'pins'; | |
| 269 | + if (/\b(disney|mickey mouse|donald duck|disneyland|walt disney)\b/i.test(t)) return 'disney_collectibles'; | |
| 270 | + if (/\b(lunch box|lunchbox|thermos|pez|board game|premium ring|cereal|advertising|sign\b)\b/i.test(t)) return 'advertising'; | |
| 271 | + if (/\b(animation cel|production cel|cel\b)\b/i.test(t)) return 'animation_art'; | |
| 272 | + if (/\b(record|album|lp\b|concert|beatles|elvis|kiss\b)\b/i.test(t)) return 'music_memorabilia'; | |
| 273 | + if (/\b(movie|film|poster|lobby card|one sheet|screen[- ]used|prop\b)\b/i.test(t)) return 'movie_posters'; | |
| 274 | + if (/\b(signed|autograph)\b/i.test(t)) return 'autographs'; | |
| 275 | + 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'; | |
| 276 | + if (/\b(jersey|game used|game worn|baseball|football|basketball|hockey|boxing)\b/i.test(t)) return 'sports_memorabilia'; | |
| 277 | + return 'vintage_toys'; | |
| 278 | +} | |
| 279 | + | |
| 280 | +/** Morphy: department label + auction/lot titles → slug; firearms lots are excluded upstream. */ | |
| 281 | +export function morphyCategory(dept: string | null, auctionTitle: string, lotTitle: string): string | null { | |
| 282 | + const d = (dept ?? '').toLowerCase(); | |
| 283 | + const a = auctionTitle.toLowerCase(); | |
| 284 | + const t = lotTitle; | |
| 285 | + 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; | |
| 286 | + 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'; | |
| 287 | + if (/\bcoins?\b|currency|banknote|numismat/.test(d + a)) return /\b(note|bill|currency|dollar bill)\b/i.test(t) ? 'banknotes' : 'coins'; | |
| 288 | + if (/perfume|fragrance/.test(d + a)) return 'perfume'; | |
| 289 | + 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'; | |
| 290 | + 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'; | |
| 291 | + if (/doll/.test(d + a) && /\b(doll|bisque|barbie|steiff|teddy|bear)\b/i.test(t)) return 'dolls'; | |
| 292 | + if (/toy/.test(d + a)) return /\b(bank|cast iron)\b/i.test(t) ? 'vintage_toys' : 'vintage_toys'; | |
| 293 | + if (/sport/.test(d + a)) return sportsCategory(t); | |
| 294 | + if (/jewel|watch/.test(d + a)) return /\b(watch|wristwatch|pocket watch)\b/i.test(t) ? 'other_watches' : 'jewelry'; | |
| 295 | + if (/fine|decorative|lamp|glass|art/.test(d + a)) { | |
| 296 | + if (/\b(lamp|tiffany|handel|pairpoint)\b/i.test(t)) return 'antiques'; | |
| 297 | + if (/\b(perfume|atomizer|scent bottle)\b/i.test(t)) return 'perfume'; | |
| 298 | + if (/\b(vase|glass|paperweight|cameo glass|loetz|galle|gallé|daum|steuben|lalique|murano)\b/i.test(t)) return 'glass_crystal'; | |
| 299 | + if (/\b(porcelain|meissen|sevres|s[eè]vres|royal doulton|figurine|pottery|ceramic)\b/i.test(t)) return 'porcelain'; | |
| 300 | + if (/\b(sterling|silver)\b/i.test(t)) return 'silver'; | |
| 301 | + if (/\b(clock|regulator)\b/i.test(t)) return 'clocks'; | |
| 302 | + if (/\b(oil on canvas|painting|bronze|sculpture|lithograph|print)\b/i.test(t)) return 'art'; | |
| 303 | + return 'antiques'; | |
| 304 | + } | |
| 305 | + if (/comic|pop culture|movie|music|entertainment/.test(d + a)) return popCultureCategory(t); | |
| 306 | + return null; | |
| 307 | +} | |
| 308 | + | |
| 309 | +// --------------------------------------------------------------------------------------------- | |
| 310 | +// Builders for non-sale records | |
| 311 | +// --------------------------------------------------------------------------------------------- | |
| 312 | +export interface BaseInput { | |
| 313 | + meta: ConnectorMeta; | |
| 314 | + sourceUrl: string; | |
| 315 | + externalId: string; | |
| 316 | + rawTitle: string; | |
| 317 | + attributes: AssetAttributes; | |
| 318 | + imageUrls?: string[]; | |
| 319 | + description?: string | null; | |
| 320 | + observedAt: Date; | |
| 321 | + parserVersion: string; | |
| 322 | + confidence?: number; | |
| 323 | + grader?: string | null; | |
| 324 | + grade?: string | null; | |
| 325 | + condition?: string | null; | |
| 326 | + conditionRaw?: string | null; | |
| 327 | + completeness?: string | null; | |
| 328 | +} | |
| 329 | + | |
| 330 | +function base(i: BaseInput) { | |
| 331 | + return { | |
| 332 | + connectorId: i.meta.id, | |
| 333 | + sourceId: i.meta.sourceId, | |
| 334 | + sourceUrl: i.sourceUrl, | |
| 335 | + externalId: i.externalId, | |
| 336 | + rawTitle: i.rawTitle, | |
| 337 | + description: i.description ?? null, | |
| 338 | + imageUrls: i.imageUrls ?? [], | |
| 339 | + attributes: i.attributes, | |
| 340 | + grade: { grader: i.grader ?? null, grade: i.grade ?? null, qualifier: null, certificationNumber: null }, | |
| 341 | + condition: { condition: i.condition ?? null, conditionRaw: i.conditionRaw ?? null, completeness: i.completeness ?? null }, | |
| 342 | + observedAt: i.observedAt, | |
| 343 | + confidence: i.confidence ?? 0.85, | |
| 344 | + parserVersion: i.parserVersion, | |
| 345 | + }; | |
| 346 | +} | |
| 347 | + | |
| 348 | +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 { | |
| 349 | + 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 }; | |
| 350 | +} | |
| 351 | + | |
| 352 | +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 { | |
| 353 | + 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 }; | |
| 354 | +} | |
| 355 | + | |
| 356 | +export function makeCatalogItem(i: BaseInput & { releaseDate?: Date | null }): NormalizedCatalogItem { | |
| 357 | + return { kind: 'catalog_item', ...base(i), releaseDate: i.releaseDate ?? null }; | |
| 358 | +} | |
| 359 | + | |
| 360 | +/** Grade for sales/listings: only graded companies, never 'raw' as grader. */ | |
| 361 | +export function gradeOf(title: string): { grader: string | null; grade: string | null } { | |
| 362 | + const g = parseGradeFromTitle(title); | |
| 363 | + if (!g.grader || g.grader === 'raw') return { grader: null, grade: null }; | |
| 364 | + let grade = g.grade; | |
| 365 | + if (!grade) { | |
| 366 | + // Houses often write the label between company and number ("SGC FR 1.5", "PSA VG-EX 4", "PSA NM-MT+ 8.5"). | |
| 367 | + 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); | |
| 368 | + if (m && Number(m[2]) <= 10) grade = m[2]!; | |
| 369 | + } | |
| 370 | + return { grader: g.grader, grade }; | |
| 371 | +} | |
| 372 | + | |
| 373 | +/** AFA / CAS / UKG toy grades ("AFA 85", "AFA Qualified 80") kept as metadata, not graders (not in taxonomy). */ | |
| 374 | +export function toyGrade(title: string): { company: string; grade: string } | null { | |
| 375 | + const m = title.match(/\b(AFA|CAS|UKG)\s*(?:Qualified\s*)?(\d{2,3})/i); | |
| 376 | + return m ? { company: m[1]!.toUpperCase(), grade: m[2]! } : null; | |
| 377 | +} | |
| 378 | + | |
| 379 | +/** Condition words from dealer labels → 'boxed_toys' scale (board games, trains, figures). */ | |
| 380 | +export function dealerCondition(raw: string | null | undefined): { condition: string | null; completeness: string | null } { | |
| 381 | + if (!raw) return { condition: null, completeness: null }; | |
| 382 | + const r = raw.toLowerCase(); | |
| 383 | + if (/\b(new|sealed|shrink|sw\b|mint in box|mib|misb|nib|moc)\b/.test(r)) return { condition: 'mint_in_box', completeness: 'sealed' }; | |
| 384 | + if (/\b(like new|ln\b|near mint|nm\b|mint\b)\b/.test(r)) return { condition: 'near_mint_box', completeness: 'boxed' }; | |
| 385 | + if (/\b(excellent|ex\b|very good|vg\b|vg\+|ex\+)\b/.test(r)) return { condition: 'boxed', completeness: 'boxed' }; | |
| 386 | + if (/\b(good|g\b|fair|played|used|restored)\b/.test(r)) return { condition: 'loose_complete', completeness: 'loose' }; | |
| 387 | + if (/\b(poor|damaged|parts|incomplete)\b/.test(r)) return { condition: 'loose', completeness: 'loose' }; | |
| 388 | + return { condition: null, completeness: null }; | |
| 389 | +} | |
| 390 | + | |
| 391 | +export { lotAttributes, money }; | |
added
connectors/api/hakes/_smoke.ts
+7 −0
@@ -0,0 +1,7 @@ | ||
| 1 | +import path from 'node:path'; | |
| 2 | +import { fileURLToPath } from 'node:url'; | |
| 3 | +import { captureFixture, runSmoke } from '../../firecrawl/_carlib/smoke.js'; | |
| 4 | + | |
| 5 | +const dir = path.dirname(fileURLToPath(import.meta.url)); | |
| 6 | +if (process.argv.includes('--save')) await captureFixture(dir, process.argv[process.argv.indexOf('--save') + 1] ?? 'catalog-page', 4); | |
| 7 | +else await runSmoke(dir, Number(process.argv[2] ?? 2)); | |
added
connectors/api/hakes/index.test.ts
+51 −0
@@ -0,0 +1,51 @@ | ||
| 1 | +import { describe, expect, it } from 'vitest'; | |
| 2 | +import { readFileSync } from 'node:fs'; | |
| 3 | +import path from 'node:path'; | |
| 4 | +import { fileURLToPath } from 'node:url'; | |
| 5 | +import { loadFixture, runFixtureSuite } from '@rareindex/connectors/testing'; | |
| 6 | +import { localMeta } from '../_lib/local-meta.js'; | |
| 7 | +import { popCultureCategory, toyGrade } from '../_memorabilia-lib/index.js'; | |
| 8 | +import createConnector from './index.js'; | |
| 9 | + | |
| 10 | +const dir = path.dirname(fileURLToPath(import.meta.url)); | |
| 11 | +const meta = localMeta(JSON.parse(readFileSync(path.join(dir, 'meta.json'), 'utf8'))); | |
| 12 | +const connector = createConnector(meta); | |
| 13 | + | |
| 14 | +describe('hakes', () => { | |
| 15 | + runFixtureSuite(connector, it, expect); | |
| 16 | + | |
| 17 | + it('maps pop-culture titles to taxonomy slugs', () => { | |
| 18 | + expect(popCultureCategory('STAR WARS (1978) - LUKE SKYWALKER 12 BACK-B AFA 85 Y-NM+')).toBe('star_wars'); | |
| 19 | + expect(popCultureCategory('AMAZING SPIDER-MAN #300 1988 CGC 9.8 NM/MINT')).toBe('marvel_comics'); | |
| 20 | + expect(popCultureCategory('DETECTIVE COMICS #27 1939 CGC 3.0')).toBe('dc_comics'); | |
| 21 | + expect(popCultureCategory('1860 ABRAHAM LINCOLN CAMPAIGN FERROTYPE PIN')).toBe('political_memorabilia'); | |
| 22 | + expect(popCultureCategory('1952 TOPPS #311 MICKEY MANTLE PSA 4')).toBe('baseball_cards'); | |
| 23 | + expect(popCultureCategory('GARBAGE PAIL KIDS 1985 SERIES 1 WAX BOX')).toBe('non_sport_cards'); | |
| 24 | + expect(popCultureCategory('MICKEY MOUSE 1930s LIONEL HANDCAR')).toBe('disney_collectibles'); | |
| 25 | + expect(toyGrade('STAR WARS DROIDS 1985 - A-WING FIGHTER VEHICLE AFA QUALIFIED 60 Q-EX')).toEqual({ company: 'AFA', grade: '60' }); | |
| 26 | + }); | |
| 27 | + | |
| 28 | + it('upcoming catalogs become auction lots with estimates and end times; sold lots would become sales', async () => { | |
| 29 | + const fx = loadFixture('hakes', 'catalog-page'); | |
| 30 | + const out = await connector.normalize(fx.raw); | |
| 31 | + expect(out.length).toBeGreaterThan(0); | |
| 32 | + const lot = out.find((r) => r.kind === 'auction_lot'); | |
| 33 | + if (lot && lot.kind === 'auction_lot') { | |
| 34 | + expect(lot.auctionHouse).toBe("Hake's Auctions"); | |
| 35 | + expect(lot.currency).toBe('USD'); | |
| 36 | + expect(lot.endsAt).toBeInstanceOf(Date); | |
| 37 | + } | |
| 38 | + // Same payload flagged as past with a sold label → sale | |
| 39 | + const p = structuredClone(fx.raw.payload) as { event: { status: string }; items: Array<{ priceLabel: string | null; price: number | null }> }; | |
| 40 | + p.event.status = 'past'; | |
| 41 | + p.items[0]!.priceLabel = 'Sold for'; | |
| 42 | + p.items[0]!.price = 1234; | |
| 43 | + const sold = await connector.normalize({ ...fx.raw, payload: p }); | |
| 44 | + const s = sold.find((r) => r.kind === 'sale'); | |
| 45 | + expect(s).toBeDefined(); | |
| 46 | + if (s && s.kind === 'sale') { | |
| 47 | + expect(s.price).toBe(1234); | |
| 48 | + expect(s.buyerPremiumIncluded).toBeNull(); | |
| 49 | + } | |
| 50 | + }); | |
| 51 | +}); | |
added
connectors/api/hakes/index.ts
+33 −0
@@ -0,0 +1,33 @@ | ||
| 1 | +import type { ConnectorMeta, CrawlContext } from '@rareindex/connectors'; | |
| 2 | +import { parseBidsquareEvents, popCultureCategory } from '../_memorabilia-lib/index.js'; | |
| 3 | +import { BidsquareHouseConnector } from '../scp-auctions/index.js'; | |
| 4 | + | |
| 5 | +/** | |
| 6 | + * Hake's Auctions (Bidsquare platform, same HTML as SCP). Hake's hides its past-auction list | |
| 7 | + * (/auctions/past returns "No Auction Available"), so this connector works in two phases: | |
| 8 | + * upcoming catalogs are crawled for auction lots and remembered in the cursor; once an event's | |
| 9 | + * catalog reports status 'past' the same pages yield realized "Sold for" prices. | |
| 10 | + */ | |
| 11 | +export class HakesConnector extends BidsquareHouseConnector { | |
| 12 | + protected override async listEvents(ctx: CrawlContext): Promise<Array<{ id: string; name: string; url: string; status: string }>> { | |
| 13 | + const known = (Array.isArray(ctx.options.cursor?.knownEvents) ? (ctx.options.cursor!.knownEvents as Array<{ id: string; name: string; url: string }>) : []).filter((e) => e && e.id && e.url); | |
| 14 | + const up = await this.fetchHtml(ctx, `${this.opts.base}/auctions`); | |
| 15 | + const upcoming = up.html ? parseBidsquareEvents(up.html) : []; | |
| 16 | + const upcomingIds = new Set(upcoming.map((e) => e.id)); | |
| 17 | + // Events seen before that are no longer upcoming are revisited as past (their catalog reports the status). | |
| 18 | + const past = known.filter((e) => !upcomingIds.has(e.id)).map((e) => ({ ...e, status: 'past' })); | |
| 19 | + const merged = [...known.filter((e) => upcomingIds.has(e.id)), ...upcoming.filter((e) => !known.some((k) => k.id === e.id)).map((e) => ({ id: e.id, name: e.name, url: e.url }))]; | |
| 20 | + await ctx.setCursor({ ...(ctx.options.cursor ?? {}), knownEvents: merged.slice(-60) }); | |
| 21 | + return [...past, ...upcoming.map((e) => ({ id: e.id, name: e.name, url: e.url, status: 'upcoming' }))]; | |
| 22 | + } | |
| 23 | +} | |
| 24 | + | |
| 25 | +export default (meta: ConnectorMeta) => | |
| 26 | + new HakesConnector(meta, { | |
| 27 | + base: 'https://www.hakes.com', | |
| 28 | + house: "Hake's Auctions", | |
| 29 | + category: popCultureCategory, | |
| 30 | + buyerPremiumIncluded: null, | |
| 31 | + idKey: 'hakes_item_id', | |
| 32 | + location: 'US', | |
| 33 | + }); | |
added
connectors/api/hakes/meta.json
+34 −0
@@ -0,0 +1,34 @@ | ||
| 1 | +{ | |
| 2 | + "id": "hakes", | |
| 3 | + "displayName": "Hake's Auctions (pop culture catalogs)", | |
| 4 | + "sourceId": "hakes", | |
| 5 | + "sourceName": "Hake's Auctions", | |
| 6 | + "sourceType": "auction_house", | |
| 7 | + "sourceUrl": "https://www.hakes.com", | |
| 8 | + "module": "api/hakes", | |
| 9 | + "enginePriority": ["api"], | |
| 10 | + "categories": ["vintage_toys", "star_wars", "marvel_comics", "dc_comics", "independent_comics", "political_memorabilia", "pins", "non_sport_cards", "disney_collectibles", "advertising", "animation_art", "movie_posters", "music_memorabilia", "sports_memorabilia", "autographs"], | |
| 11 | + "regions": ["US"], | |
| 12 | + "languages": ["en"], | |
| 13 | + "currency": ["USD"], | |
| 14 | + "supportsListings": false, | |
| 15 | + "supportsSold": true, | |
| 16 | + "supportsAuctions": true, | |
| 17 | + "supportsImages": true, | |
| 18 | + "supportsCatalog": false, | |
| 19 | + "supportsPopulation": false, | |
| 20 | + "supportsLookup": false, | |
| 21 | + "refreshFrequencyMinutes": 720, | |
| 22 | + "priority": "medium", | |
| 23 | + "trustScore": 0.85, | |
| 24 | + "attributionRequired": true, | |
| 25 | + "termsUrl": "https://www.hakes.com/terms", | |
| 26 | + "accessNotes": "Plain HTTPS on the public Bidsquare-hosted site (robots.txt disallows /search, account pages and filtered/sorted query URLs; we only load /auctions and unfiltered catalog pages with ?page=N, 2 s politeness). Hake's hides its past-auction list, so upcoming catalogs are crawled for auction lots (estimate, current bid, countdown end) and their URLs are remembered in the connector cursor; after an event closes the same catalog pages report status 'past' with 'Sold for $X' → sales dated by the lot/event end. AFA/CAS toy grades are kept in metadata (not graders). Whether 'Sold for' includes the buyer's premium is not stated on the page → null. 0 credits.", | |
| 27 | + "enabled": true, | |
| 28 | + "schemaVersion": "1.0", | |
| 29 | + "config": { | |
| 30 | + "eventsPerRun": 2, | |
| 31 | + "pagesPerEvent": 6, | |
| 32 | + "includeUpcoming": true | |
| 33 | + } | |
| 34 | +} | |
added
connectors/api/morphy/_smoke.ts
+7 −0
@@ -0,0 +1,7 @@ | ||
| 1 | +import path from 'node:path'; | |
| 2 | +import { fileURLToPath } from 'node:url'; | |
| 3 | +import { captureFixture, runSmoke } from '../../firecrawl/_carlib/smoke.js'; | |
| 4 | + | |
| 5 | +const dir = path.dirname(fileURLToPath(import.meta.url)); | |
| 6 | +if (process.argv.includes('--save')) await captureFixture(dir, process.argv[process.argv.indexOf('--save') + 1] ?? 'catalog-page', 4); | |
| 7 | +else await runSmoke(dir, Number(process.argv[2] ?? 2)); | |
added
connectors/api/morphy/index.test.ts
+62 −0
@@ -0,0 +1,62 @@ | ||
| 1 | +import { describe, expect, it } from 'vitest'; | |
| 2 | +import { readFileSync } from 'node:fs'; | |
| 3 | +import path from 'node:path'; | |
| 4 | +import { fileURLToPath } from 'node:url'; | |
| 5 | +import { loadFixture, runFixtureSuite } from '@rareindex/connectors/testing'; | |
| 6 | +import { localMeta } from '../_lib/local-meta.js'; | |
| 7 | +import { morphyCategory } from '../_memorabilia-lib/index.js'; | |
| 8 | +import createConnector, { parseCatalog, parsePastList } from './index.js'; | |
| 9 | + | |
| 10 | +const dir = path.dirname(fileURLToPath(import.meta.url)); | |
| 11 | +const meta = localMeta(JSON.parse(readFileSync(path.join(dir, 'meta.json'), 'utf8'))); | |
| 12 | +const connector = createConnector(meta); | |
| 13 | + | |
| 14 | +const LIST = `<div id="auctionfeed"><article class="event auction clearfix catalog-2"><div class="event-content"><span class="category-title"><a href="/auctions/divisions/fine-decorative-art/">Fine & Decorative Arts</a></span> | |
| 15 | +<h3><a href="https://morphyauctions.com/auctions/past-auctions/online-only-perfume-bottles/">Online Only Perfume Bottles</a></h3><div class="date">August 11, 2026</div> | |
| 16 | +<ul class="event-grid"><li class="pre-bid"><a href="https://auctions.morphyauctions.com/catalog.aspx?auctionid=716"><span>Online Listing</span></a></li></ul></div></article> | |
| 17 | +<article class="event auction"><div class="event-content"><span class="category-title"><a href="#">Firearms</a></span><h3><a href="#">Online Only Firearms & Militaria</a></h3><div class="date">August 04, 2026</div><ul><li><a href="https://auctions.morphyauctions.com/catalog.aspx?auctionid=700">x</a></li></ul></div></article></div>`; | |
| 18 | + | |
| 19 | +const CATALOG = `<h2 id="ofpages"> / 19</h2><div id="galleryList"><ul><li class=""><div class="lot "><div class="lotInner"><h5><span id='LotLabel'>Lot </span><span class='LotNumberSign'>#</span><span id='LotNumber'>1001</span><span id="LotNumber Colon">: </span><span id="LotName"><a href='http://auctions.morphyauctions.com/CAMEO_GLASS_FLORAL_PERFUME_WITH_ATOMIZER-LOT662424.aspx'>CAMEO GLASS FLORAL PERFUME WITH ATOMIZER</a></span></h5> | |
| 20 | +<div class="imageDiv"><a href='#'><img class='lotImage' src='/ItemImages/000662/26320016_1_sm.jpeg'></a></div><div class="lotData"><h6><span># Bids: 6</span></h6><h6><span>Min Bid: $150.00</span></h6><h6><span>Final Price: $369.00</span></h6><h6><span>Estimate: $300 - $500</span></h6></div></div></div></li> | |
| 21 | +<li class=""><div class="lot "><div class="lotInner"><h5><span id='LotNumber'>1002</span><span id="LotName"><a href='http://auctions.morphyauctions.com/UNSOLD-LOT662425.aspx'>UNSOLD PERFUME</a></span></h5><div class="lotData"><h6><span># Bids: 0</span></h6><h6><span>Min Bid: $100.00</span></h6><h6><span>Estimate: $200 - $500</span></h6></div></div></div></li></ul></div>`; | |
| 22 | + | |
| 23 | +describe('morphy', () => { | |
| 24 | + runFixtureSuite(connector, it, expect); | |
| 25 | + | |
| 26 | + it('parses the past-auction list and a catalog page', () => { | |
| 27 | + const list = parsePastList(LIST); | |
| 28 | + expect(list).toEqual([ | |
| 29 | + { id: '716', title: 'Online Only Perfume Bottles', dept: 'Fine & Decorative Arts', dateText: 'August 11, 2026', pageUrl: 'https://morphyauctions.com/auctions/past-auctions/online-only-perfume-bottles/' }, | |
| 30 | + { id: '700', title: 'Online Only Firearms & Militaria', dept: 'Firearms', dateText: 'August 04, 2026', pageUrl: '#' }, | |
| 31 | + ]); | |
| 32 | + const cat = parseCatalog(CATALOG, list[0]!, 1); | |
| 33 | + expect(cat.totalPages).toBe(19); | |
| 34 | + expect(cat.lots.length).toBe(2); | |
| 35 | + expect(cat.lots[0]).toMatchObject({ lotNumber: '1001', finalPrice: 369, minBid: 150, bids: 6, estimateText: '$300 - $500', url: 'https://auctions.morphyauctions.com/CAMEO_GLASS_FLORAL_PERFUME_WITH_ATOMIZER-LOT662424.aspx' }); | |
| 36 | + expect(cat.lots[1]!.finalPrice).toBeNull(); | |
| 37 | + }); | |
| 38 | + | |
| 39 | + it('skips firearms and maps departments to slugs', () => { | |
| 40 | + expect(morphyCategory('Firearms', 'Online Only Firearms & Militaria', 'WINCHESTER MODEL 1873 RIFLE')).toBeNull(); | |
| 41 | + expect(morphyCategory('Fine & Decorative Arts', 'Online Only Perfume Bottles', 'CAMEO GLASS FLORAL PERFUME WITH ATOMIZER')).toBe('perfume'); | |
| 42 | + expect(morphyCategory('Advertising & General Store, Automobilia & Petroliana', 'Automobilia, Petroliana, & Soda Advertising', 'TEXACO PORCELAIN SIGN')).toBe('advertising'); | |
| 43 | + expect(morphyCategory('Coin-Op & Advertising', 'Coin-Op & Antique Advertising', 'MILLS 5 CENT SLOT MACHINE')).toBe('casino_memorabilia'); | |
| 44 | + expect(morphyCategory('Toys, Dolls & Figural Cast Iron', 'Toys & Trains', 'LIONEL STANDARD GAUGE 400E LOCOMOTIVE')).toBe('model_trains'); | |
| 45 | + }); | |
| 46 | + | |
| 47 | + it('normalises sold lots with the auction date and USD final price', async () => { | |
| 48 | + const list = parsePastList(LIST); | |
| 49 | + const out = await connector.normalize({ url: 'x', externalId: 'a', kind: 'sale', engine: 'api', fetchedAt: new Date('2026-09-07T00:00:00Z'), payload: parseCatalog(CATALOG, list[0]!, 1) }); | |
| 50 | + expect(out.length).toBe(1); | |
| 51 | + const s = out[0]!; | |
| 52 | + if (s.kind !== 'sale') throw new Error('sale'); | |
| 53 | + expect(s).toMatchObject({ price: 369, currency: 'USD', auctionHouse: 'Morphy Auctions', lotNumber: '1001', buyerPremiumIncluded: null }); | |
| 54 | + expect(s.saleDate.toISOString()).toBe('2026-08-11T00:00:00.000Z'); | |
| 55 | + expect(s.attributes.categorySlug).toBe('perfume'); | |
| 56 | + expect(s.attributes.identifiers.morphy_lot).toBe('662424'); | |
| 57 | + }); | |
| 58 | + | |
| 59 | + it('fixture normalises', async () => { | |
| 60 | + expect((await connector.normalize(loadFixture('morphy', 'catalog-page').raw)).length).toBeGreaterThan(0); | |
| 61 | + }); | |
| 62 | +}); | |
added
connectors/api/morphy/index.ts
+198 −0
@@ -0,0 +1,198 @@ | ||
| 1 | +import { z } from 'zod'; | |
| 2 | +import { BaseConnector, html as H, type ConnectorMeta, type CrawlContext, type RawRecordInput, type RawRecordLike } from '@rareindex/connectors'; | |
| 3 | +import type { NormalizedRecord } from '@rareindex/shared'; | |
| 4 | +import { isBundleTitle, safeYear } from '../_auction-lib/categories.js'; | |
| 5 | +import { gradeOf, hiddenFields, httpText, lotAttributes, money, morphyCategory, selectedOption } from '../_memorabilia-lib/index.js'; | |
| 6 | +import { dateWords, makeSale } from '../../firecrawl/_carlib/index.js'; | |
| 7 | + | |
| 8 | +const WP = 'https://morphyauctions.com'; | |
| 9 | +const CAT = 'https://auctions.morphyauctions.com'; | |
| 10 | +const PARSER_VERSION = '1.0.0'; | |
| 11 | + | |
| 12 | +export const AuctionSchema = z.object({ id: z.string(), title: z.string(), dept: z.string().nullable(), dateText: z.string().nullable(), pageUrl: z.string().nullable() }); | |
| 13 | +export const LotSchema = z.object({ lotNumber: z.string().nullable(), title: z.string(), url: z.string(), image: z.string().nullable(), finalPrice: z.number().nullable(), minBid: z.number().nullable(), estimateText: z.string().nullable(), bids: z.number().nullable() }); | |
| 14 | +export const PayloadSchema = z.object({ kind: z.literal('catalog_page'), auction: AuctionSchema, page: z.number(), totalPages: z.number().nullable(), lots: z.array(LotSchema) }); | |
| 15 | +export type Payload = z.infer<typeof PayloadSchema>; | |
| 16 | + | |
| 17 | +/** WordPress past-auction list: department label, title, date and the catalog id. */ | |
| 18 | +export function parsePastList(htmlText: string): z.infer<typeof AuctionSchema>[] { | |
| 19 | + const $ = H.load(htmlText); | |
| 20 | + const out: z.infer<typeof AuctionSchema>[] = []; | |
| 21 | + $('article.event').each((_, el) => { | |
| 22 | + const a = $(el); | |
| 23 | + const link = a.find('a[href*="catalog.aspx?auctionid="]').attr('href'); | |
| 24 | + const id = link?.match(/auctionid=(\d+)/)?.[1]; | |
| 25 | + if (!id) return; | |
| 26 | + out.push({ id, title: H.text(a.find('h3')) ?? '', dept: H.text(a.find('.category-title')), dateText: H.text(a.find('.date')), pageUrl: a.find('h3 a').attr('href') ?? null }); | |
| 27 | + }); | |
| 28 | + return out; | |
| 29 | +} | |
| 30 | + | |
| 31 | +export function parseCatalog(htmlText: string, auction: z.infer<typeof AuctionSchema>, page: number): Payload { | |
| 32 | + const $ = H.load(htmlText); | |
| 33 | + const lots: z.infer<typeof LotSchema>[] = []; | |
| 34 | + $('#galleryList li .lot, #galleryList .lot').each((_, el) => { | |
| 35 | + const l = $(el); | |
| 36 | + const link = l.find('#LotName a, span[id="LotName"] a').first(); | |
| 37 | + const title = H.text(link); | |
| 38 | + const href = link.attr('href'); | |
| 39 | + if (!title || !href) return; | |
| 40 | + const data = H.text(l.find('.lotData')) ?? ''; | |
| 41 | + const fp = data.match(/Final Price:\s*\$([\d,]+(?:\.\d+)?)/i); | |
| 42 | + const mb = data.match(/Min Bid:\s*\$([\d,]+(?:\.\d+)?)/i); | |
| 43 | + const bids = data.match(/#\s*Bids:\s*(\d+)/i); | |
| 44 | + const est = data.match(/Estimate:\s*([^\n]+?)(?:\s*$|\s*#|\s*Min|\s*Final)/i); | |
| 45 | + lots.push({ | |
| 46 | + lotNumber: H.text(l.find('#LotNumber, span[id="LotNumber"]')), | |
| 47 | + title, | |
| 48 | + url: href.replace(/^http:/, 'https:'), | |
| 49 | + image: (() => { | |
| 50 | + const src = l.find('img.lotImage').attr('src'); | |
| 51 | + return src ? (src.startsWith('http') ? src : CAT + src) : null; | |
| 52 | + })(), | |
| 53 | + finalPrice: fp ? money(`$${fp[1]}`, 'USD')?.amount ?? null : null, | |
| 54 | + minBid: mb ? money(`$${mb[1]}`, 'USD')?.amount ?? null : null, | |
| 55 | + estimateText: est ? est[1]!.trim() : null, | |
| 56 | + bids: bids ? Number(bids[1]) : null, | |
| 57 | + }); | |
| 58 | + }); | |
| 59 | + const totalPages = Number(htmlText.match(/id\s*=\s*"ofpages">\s*\/\s*(\d+)/)?.[1] ?? '') || null; | |
| 60 | + return { kind: 'catalog_page', auction, page, totalPages, lots }; | |
| 61 | +} | |
| 62 | + | |
| 63 | +/** | |
| 64 | + * Morphy Auctions: past-auction list on morphyauctions.com (WordPress, /page/N/) → auction catalogs on | |
| 65 | + * auctions.morphyauctions.com (ASP.NET). Page 1 is a GET; later pages are the page's own form POST | |
| 66 | + * (page number + "Go" button with the hidden __VIEWSTATE fields) — no login involved. | |
| 67 | + */ | |
| 68 | +export class MorphyConnector extends BaseConnector { | |
| 69 | + readonly version = '1.0.0'; | |
| 70 | + readonly parserVersion = PARSER_VERSION; | |
| 71 | + protected override minIntervalMs = 2000; | |
| 72 | + | |
| 73 | + private stat(ctx: CrawlContext, ok: boolean, ms: number) { | |
| 74 | + const s = (ctx.engineStats.api ??= { attempts: 0, success: 0, credits: 0, ms: 0 }); | |
| 75 | + s.attempts++; | |
| 76 | + if (ok) s.success++; | |
| 77 | + s.ms += ms; | |
| 78 | + } | |
| 79 | + | |
| 80 | + async *crawl(ctx: CrawlContext): AsyncIterable<RawRecordInput> { | |
| 81 | + const auctionsPerRun = Number(this.meta.config.auctionsPerRun ?? 2); | |
| 82 | + const maxPages = Number(this.meta.config.pagesPerAuction ?? 10); | |
| 83 | + const perPage = String(this.meta.config.lotsPerPage ?? 100); | |
| 84 | + const backfill = ctx.options.mode === 'backfill'; | |
| 85 | + const listPage = backfill ? Number(ctx.options.cursor?.listPage ?? 1) : 1; | |
| 86 | + const done = new Set<string>(Array.isArray(ctx.options.cursor?.doneAuctions) ? (ctx.options.cursor!.doneAuctions as string[]) : []); | |
| 87 | + const listUrl = `${WP}/auctions/past-auctions/${listPage > 1 ? `page/${listPage}/` : ''}`; | |
| 88 | + await this.throttle(); | |
| 89 | + const list = await ctx.fetch(listUrl, { engines: ['api'], responseType: 'text', minQuality: 0.3 }); | |
| 90 | + if (!list.success || !list.html) { | |
| 91 | + ctx.anomaly('page_fetch_failed', `${listUrl}: ${list.error ?? list.httpStatus}`); | |
| 92 | + return; | |
| 93 | + } | |
| 94 | + const auctions = parsePastList(list.html).filter((a) => !done.has(a.id) && morphyCategory(a.dept, a.title, '') !== null); | |
| 95 | + let processed = 0; | |
| 96 | + let count = 0; | |
| 97 | + for (const auction of auctions) { | |
| 98 | + if (processed >= auctionsPerRun || ctx.signal?.aborted) break; | |
| 99 | + const url = `${CAT}/catalog.aspx?auctionid=${auction.id}`; | |
| 100 | + await this.throttle(); | |
| 101 | + const t0 = Date.now(); | |
| 102 | + let res; | |
| 103 | + try { | |
| 104 | + res = await httpText(url); | |
| 105 | + } catch (err) { | |
| 106 | + this.stat(ctx, false, Date.now() - t0); | |
| 107 | + ctx.anomaly('page_fetch_failed', `${url}: ${err instanceof Error ? err.message : String(err)}`); | |
| 108 | + continue; | |
| 109 | + } | |
| 110 | + this.stat(ctx, res.status < 400, Date.now() - t0); | |
| 111 | + if (res.status >= 400) { | |
| 112 | + ctx.anomaly('page_fetch_failed', `${url}: HTTP ${res.status}`); | |
| 113 | + continue; | |
| 114 | + } | |
| 115 | + let html = res.text; | |
| 116 | + let payload = parseCatalog(html, auction, 1); | |
| 117 | + let page = 1; | |
| 118 | + // Switch to the larger page size through the form (what the page-size dropdown does). | |
| 119 | + if (perPage !== '25' && payload.lots.length > 0) { | |
| 120 | + const posted = await this.postPage(url, html, 1, perPage, ctx, 'ctl00$ContentPlaceHolder$LotsPerPageDropDownTop'); | |
| 121 | + if (posted) { | |
| 122 | + html = posted; | |
| 123 | + payload = parseCatalog(html, auction, 1); | |
| 124 | + } | |
| 125 | + } | |
| 126 | + while (true) { | |
| 127 | + if (payload.lots.length === 0) break; | |
| 128 | + count++; | |
| 129 | + yield { url: `${url}&page=${page}`, externalId: `auction:${auction.id}:page:${page}`, kind: 'sale', engine: 'api', httpStatus: 200, payload, fetchedAt: new Date() }; | |
| 130 | + page++; | |
| 131 | + if (this.reached(ctx, count) || page > maxPages || (payload.totalPages !== null && page > payload.totalPages) || ctx.signal?.aborted) break; | |
| 132 | + await this.throttle(); | |
| 133 | + const next = await this.postPage(url, html, page, perPage, ctx); | |
| 134 | + if (!next) break; | |
| 135 | + html = next; | |
| 136 | + payload = parseCatalog(html, auction, page); | |
| 137 | + } | |
| 138 | + processed++; | |
| 139 | + done.add(auction.id); | |
| 140 | + await ctx.setCursor({ doneAuctions: [...done].slice(-400), listPage: backfill && auctions.every((a) => done.has(a.id)) ? listPage + 1 : listPage, updatedAt: new Date().toISOString() }); | |
| 141 | + } | |
| 142 | + } | |
| 143 | + | |
| 144 | + /** Jump to `page` with `perPage` lots by posting the catalog form (ASP.NET postback). */ | |
| 145 | + private async postPage(url: string, currentHtml: string, page: number, perPage: string, ctx: CrawlContext, eventTarget?: string): Promise<string | null> { | |
| 146 | + const hidden = hiddenFields(currentHtml); | |
| 147 | + const form: Record<string, string> = {}; | |
| 148 | + for (const [k, v] of Object.entries(hidden)) if (k.startsWith('__') || k.startsWith('categoryView')) form[k] = v; | |
| 149 | + const keep = ['ctl00$ContentPlaceHolder$SortByDDLTop', 'ctl00$ContentPlaceHolder$SortByDDLBot', 'ctl00$ContentPlaceHolder$displayByDropDownTop', 'ctl00$ContentPlaceHolder$displayByDropDownBot', 'ctl00$ContentPlaceHolder$searchByDropDown']; | |
| 150 | + for (const k of keep) { | |
| 151 | + const v = selectedOption(currentHtml, k); | |
| 152 | + if (v !== null) form[k] = v; | |
| 153 | + } | |
| 154 | + // The auction dropdown defaults to the live sale, not the one being viewed: post the requested id explicitly. | |
| 155 | + form['ctl00$ContentPlaceHolder$AuctionDDL'] = url.match(/auctionid=(\d+)/)?.[1] ?? ''; | |
| 156 | + form['ctl00$ContentPlaceHolder$LotsPerPageDropDownTop'] = perPage; | |
| 157 | + form['ctl00$ContentPlaceHolder$LotsPerPageDropDownBot'] = perPage; | |
| 158 | + form['ctl00$ContentPlaceHolder$CurrPageTopTB'] = String(page); | |
| 159 | + form['ctl00$ContentPlaceHolder$CurrPageBotTB'] = String(page); | |
| 160 | + form['ctl00$ContentPlaceHolder$searchTextBox'] = ''; | |
| 161 | + if (eventTarget) form.__EVENTTARGET = eventTarget; | |
| 162 | + else form['ctl00$ContentPlaceHolder$PageJumpBtn'] = 'Go'; | |
| 163 | + const t0 = Date.now(); | |
| 164 | + try { | |
| 165 | + const res = await httpText(url, { method: 'POST', form, referer: url }); | |
| 166 | + this.stat(ctx, res.status < 400, Date.now() - t0); | |
| 167 | + if (res.status >= 400) return null; | |
| 168 | + const cur = Number(res.text.match(/value="(\d+)" id="CurrPageTopTB"/)?.[1] ?? '0'); | |
| 169 | + if (cur !== page) { | |
| 170 | + ctx.anomaly('pagination_mismatch', `${url}: asked page ${page}, got ${cur}`); | |
| 171 | + return null; | |
| 172 | + } | |
| 173 | + return res.text; | |
| 174 | + } catch (err) { | |
| 175 | + this.stat(ctx, false, Date.now() - t0); | |
| 176 | + ctx.anomaly('page_fetch_failed', `${url} (postback): ${err instanceof Error ? err.message : String(err)}`); | |
| 177 | + return null; | |
| 178 | + } | |
| 179 | + } | |
| 180 | + | |
| 181 | + async normalize(raw: RawRecordLike): Promise<NormalizedRecord[]> { | |
| 182 | + const p = PayloadSchema.parse(raw.payload); | |
| 183 | + const saleDate = dateWords(p.auction.dateText); | |
| 184 | + const out: NormalizedRecord[] = []; | |
| 185 | + if (!saleDate) return out; | |
| 186 | + for (const lot of p.lots) { | |
| 187 | + if (lot.finalPrice === null || lot.finalPrice <= 0) continue; | |
| 188 | + const slug = morphyCategory(p.auction.dept, p.auction.title, lot.title); | |
| 189 | + if (!slug) continue; | |
| 190 | + const g = gradeOf(lot.title); | |
| 191 | + const attributes = lotAttributes({ categorySlug: slug, name: lot.title, year: safeYear(lot.title), identifiers: { morphy_lot: lot.url.match(/LOT(\d+)\.aspx/i)?.[1] ?? `${p.auction.id}-${lot.lotNumber}` }, metadata: { auction_id: p.auction.id, auction_title: p.auction.title, department: p.auction.dept, estimate: lot.estimateText, min_bid: lot.minBid, bids: lot.bids } }); | |
| 192 | + out.push(makeSale({ meta: this.meta, sourceUrl: lot.url, externalId: `${p.auction.id}-${lot.lotNumber ?? lot.url}`, rawTitle: lot.title, attributes, price: lot.finalPrice, currency: 'USD', saleDate, buyerPremiumIncluded: null, auctionHouse: 'Morphy Auctions', lotNumber: lot.lotNumber, imageUrls: lot.image ? [lot.image] : [], observedAt: raw.fetchedAt, parserVersion: PARSER_VERSION, grader: g.grader, grade: g.grade, location: 'US', isBundle: isBundleTitle(lot.title) })); | |
| 193 | + } | |
| 194 | + return out; | |
| 195 | + } | |
| 196 | +} | |
| 197 | + | |
| 198 | +export default (meta: ConnectorMeta) => new MorphyConnector(meta); | |
added
connectors/api/morphy/meta.json
+34 −0
@@ -0,0 +1,34 @@ | ||
| 1 | +{ | |
| 2 | + "id": "morphy", | |
| 3 | + "displayName": "Morphy Auctions (prices realized)", | |
| 4 | + "sourceId": "morphy", | |
| 5 | + "sourceName": "Morphy Auctions", | |
| 6 | + "sourceType": "auction_house", | |
| 7 | + "sourceUrl": "https://morphyauctions.com", | |
| 8 | + "module": "api/morphy", | |
| 9 | + "enginePriority": ["api"], | |
| 10 | + "categories": ["vintage_toys", "advertising", "vending_machines", "arcade_pinball", "casino_memorabilia", "dolls", "model_trains", "coins", "banknotes", "perfume", "glass_crystal", "porcelain", "silver", "clocks", "antiques", "art", "jewelry", "other_watches", "automotive_memorabilia", "sports_memorabilia"], | |
| 11 | + "regions": ["US"], | |
| 12 | + "languages": ["en"], | |
| 13 | + "currency": ["USD"], | |
| 14 | + "supportsListings": false, | |
| 15 | + "supportsSold": true, | |
| 16 | + "supportsAuctions": false, | |
| 17 | + "supportsImages": true, | |
| 18 | + "supportsCatalog": false, | |
| 19 | + "supportsPopulation": false, | |
| 20 | + "supportsLookup": false, | |
| 21 | + "refreshFrequencyMinutes": 720, | |
| 22 | + "priority": "medium", | |
| 23 | + "trustScore": 0.9, | |
| 24 | + "attributionRequired": true, | |
| 25 | + "termsUrl": "https://morphyauctions.com/bidding/terms-and-conditions/", | |
| 26 | + "accessNotes": "Two public hosts, plain HTTPS, 0 credits, 2 s politeness. morphyauctions.com/auctions/past-auctions/ (WordPress; robots.txt allows) lists every past sale with department, title and date; auctions.morphyauctions.com/catalog.aspx?auctionid=N shows each lot with 'Final Price: $X', min bid, estimate and bid count. Paging on the ASP.NET catalog is the page's own form POST (page number + Go, 100 lots per page) with its hidden __VIEWSTATE fields — no account or bidding endpoint is used. Firearms & Militaria departments and weapon lots are skipped. Morphy does not state on the catalog whether 'Final Price' includes the buyer's premium → buyer_premium_included=null. Sale date = auction date from the list (first day of multi-day sales).", | |
| 27 | + "enabled": true, | |
| 28 | + "schemaVersion": "1.0", | |
| 29 | + "config": { | |
| 30 | + "auctionsPerRun": 2, | |
| 31 | + "pagesPerAuction": 10, | |
| 32 | + "lotsPerPage": 100 | |
| 33 | + } | |
| 34 | +} | |
added
connectors/api/noble-knight/_smoke.ts
+7 −0
@@ -0,0 +1,7 @@ | ||
| 1 | +import path from 'node:path'; | |
| 2 | +import { fileURLToPath } from 'node:url'; | |
| 3 | +import { captureFixture, runSmoke } from '../../firecrawl/_carlib/smoke.js'; | |
| 4 | + | |
| 5 | +const dir = path.dirname(fileURLToPath(import.meta.url)); | |
| 6 | +if (process.argv.includes('--save')) await captureFixture(dir, process.argv[process.argv.indexOf('--save') + 1] ?? 'product-page', 4); | |
| 7 | +else await runSmoke(dir, Number(process.argv[2] ?? 3)); | |
added
connectors/api/noble-knight/index.test.ts
+45 −0
@@ -0,0 +1,45 @@ | ||
| 1 | +import { describe, expect, it } from 'vitest'; | |
| 2 | +import { readFileSync } from 'node:fs'; | |
| 3 | +import path from 'node:path'; | |
| 4 | +import { fileURLToPath } from 'node:url'; | |
| 5 | +import { loadFixture, runFixtureSuite } from '@rareindex/connectors/testing'; | |
| 6 | +import { localMeta } from '../_lib/local-meta.js'; | |
| 7 | +import createConnector, { nkCategory, parseProduct } from './index.js'; | |
| 8 | + | |
| 9 | +const dir = path.dirname(fileURLToPath(import.meta.url)); | |
| 10 | +const meta = localMeta(JSON.parse(readFileSync(path.join(dir, 'meta.json'), 'utf8'))); | |
| 11 | +const connector = createConnector(meta); | |
| 12 | + | |
| 13 | +const PAGE = `<html><head><script type="application/ld+json">{"@context":"https://schema.org/","@type":"Product","name":"Catan (5th Edition)","image":["https://image.nobleknight.com/g/jpg240/catan.jpg"],"description":"Catan - Catan Studio","sku":"2147354979","mpn":"CN3071","brand":{"@type":"Brand","name":"Catan Studio"},"offers":{"@type":"Offer","url":"https://www.nobleknight.com/P/2147354979/Catan","priceCurrency":"USD","price":"44.95","itemCondition":"https://schema.org/NewCondition","availability":"https://schema.org/InStock"}}</script></head> | |
| 14 | +<body><div class="info-line"><div class="label"><span>Publisher</span></div><div class="value"><a href="/publisher/catan-studio">Catan Studio</a></div></div> | |
| 15 | +<div class="info-line"><div class="label"><span>Product Line</span></div><div class="value"><a href="/Products/Catan">Catan</a></div></div> | |
| 16 | +<div class="info-line"><div class="label"><span>Category</span></div><div class="value"><a href="/main-category/boardgames">Board Games</a></div></div> | |
| 17 | +<div class="conditions">Condition: SW (MINT/New)</div></body></html>`; | |
| 18 | + | |
| 19 | +describe('noble-knight', () => { | |
| 20 | + runFixtureSuite(connector, it, expect); | |
| 21 | + | |
| 22 | + it('parses product JSON-LD and info lines', () => { | |
| 23 | + const p = parseProduct(PAGE, 'https://www.nobleknight.com/P/2147354979/Catan')!; | |
| 24 | + expect(p).toMatchObject({ nkId: '2147354979', name: 'Catan (5th Edition)', mpn: 'CN3071', brand: 'Catan Studio', price: 44.95, currency: 'USD', itemCondition: 'NewCondition', availability: 'InStock', publisher: 'Catan Studio', productLine: 'Catan', category: 'Board Games' }); | |
| 25 | + expect(nkCategory(p)).toBe('board_games'); | |
| 26 | + expect(nkCategory({ ...p, category: 'Miniatures', productLine: 'Warhammer 40,000', publisher: 'Games Workshop' })).toBe('warhammer'); | |
| 27 | + expect(nkCategory({ ...p, category: 'Role Playing Games', productLine: 'D&D' })).toBeNull(); | |
| 28 | + }); | |
| 29 | + | |
| 30 | + it('normalises to a catalog item + listing with a boxed_toys condition', async () => { | |
| 31 | + const p = parseProduct(PAGE, 'https://www.nobleknight.com/P/2147354979/Catan')!; | |
| 32 | + const out = await connector.normalize({ url: p.url, externalId: p.nkId, kind: 'listing', engine: 'api', fetchedAt: new Date('2026-09-07T00:00:00Z'), payload: { kind: 'product_page', product: p } }); | |
| 33 | + expect(out.map((r) => r.kind)).toEqual(['catalog_item', 'listing']); | |
| 34 | + const l = out[1]!; | |
| 35 | + if (l.kind !== 'listing') throw new Error('listing'); | |
| 36 | + expect(l).toMatchObject({ price: 44.95, currency: 'USD', availability: 'available' }); | |
| 37 | + expect(l.condition.condition).toBe('mint_in_box'); | |
| 38 | + expect(l.attributes.identifiers).toMatchObject({ nobleknight_id: '2147354979', mpn: 'CN3071' }); | |
| 39 | + }); | |
| 40 | + | |
| 41 | + it('fixtures normalise', async () => { | |
| 42 | + const out = await connector.normalize(loadFixture('noble-knight', 'board-game').raw); | |
| 43 | + expect(out.length).toBe(2); | |
| 44 | + }); | |
| 45 | +}); | |
added
connectors/api/noble-knight/index.ts
+154 −0
@@ -0,0 +1,154 @@ | ||
| 1 | +import { z } from 'zod'; | |
| 2 | +import { BaseConnector, html as H, type ConnectorMeta, type CrawlContext, type RawRecordInput, type RawRecordLike } from '@rareindex/connectors'; | |
| 3 | +import type { NormalizedRecord } from '@rareindex/shared'; | |
| 4 | +import { dealerCondition, lotAttributes, makeCatalogItem, makeListing } from '../_memorabilia-lib/index.js'; | |
| 5 | + | |
| 6 | +const BASE = 'https://www.nobleknight.com'; | |
| 7 | +const PARSER_VERSION = '1.0.0'; | |
| 8 | + | |
| 9 | +export const ProductSchema = z.object({ | |
| 10 | + url: z.string(), | |
| 11 | + nkId: z.string(), | |
| 12 | + name: z.string(), | |
| 13 | + sku: z.string().nullable(), | |
| 14 | + mpn: z.string().nullable(), | |
| 15 | + brand: z.string().nullable(), | |
| 16 | + image: z.string().nullable(), | |
| 17 | + description: z.string().nullable(), | |
| 18 | + price: z.number().nullable(), | |
| 19 | + currency: z.string().nullable(), | |
| 20 | + itemCondition: z.string().nullable(), | |
| 21 | + availability: z.string().nullable(), | |
| 22 | + publisher: z.string().nullable(), | |
| 23 | + productLine: z.string().nullable(), | |
| 24 | + category: z.string().nullable(), | |
| 25 | + genre: z.string().nullable(), | |
| 26 | + type: z.string().nullable(), | |
| 27 | + conditionText: z.string().nullable(), | |
| 28 | +}); | |
| 29 | +export type Product = z.infer<typeof ProductSchema>; | |
| 30 | +export const PayloadSchema = z.object({ kind: z.literal('product_page'), product: ProductSchema }); | |
| 31 | + | |
| 32 | +/** Product page: schema.org Product JSON-LD + the info lines (Publisher / Product Line / Category / Genre / Type). */ | |
| 33 | +export function parseProduct(htmlText: string, url: string): Product | null { | |
| 34 | + const ld = H.jsonLd(htmlText, 'Product')[0]; | |
| 35 | + if (!ld) return null; | |
| 36 | + const $ = H.load(htmlText); | |
| 37 | + const offers = (Array.isArray(ld.offers) ? ld.offers[0] : ld.offers) as Record<string, unknown> | undefined; | |
| 38 | + const info: Record<string, string> = {}; | |
| 39 | + $('.info-line').each((_, el) => { | |
| 40 | + const label = H.text($(el).find('.label')); | |
| 41 | + const value = H.text($(el).find('.value')); | |
| 42 | + if (label && value) info[label.toLowerCase()] = value; | |
| 43 | + }); | |
| 44 | + const nkId = url.match(/\/P\/(\d+)/)?.[1] ?? String(ld.sku ?? ''); | |
| 45 | + const conditionText = H.text($('.conditions').first()) ?? H.text($('.condition, .item-condition').first()); | |
| 46 | + const brand = ld.brand && typeof ld.brand === 'object' ? String((ld.brand as { name?: string }).name ?? '') : ld.brand ? String(ld.brand) : null; | |
| 47 | + return { | |
| 48 | + url, | |
| 49 | + nkId, | |
| 50 | + name: String(ld.name ?? ''), | |
| 51 | + sku: ld.sku ? String(ld.sku) : null, | |
| 52 | + mpn: ld.mpn ? String(ld.mpn) : null, | |
| 53 | + brand: brand || null, | |
| 54 | + image: Array.isArray(ld.image) ? (ld.image[0] as string | undefined) ?? null : ld.image ? String(ld.image) : null, | |
| 55 | + description: ld.description ? String(ld.description).slice(0, 500) : null, | |
| 56 | + price: offers?.price !== undefined && offers.price !== null ? Number(offers.price) : null, | |
| 57 | + currency: offers?.priceCurrency ? String(offers.priceCurrency) : null, | |
| 58 | + itemCondition: offers?.itemCondition ? String(offers.itemCondition).replace(/^https?:\/\/schema\.org\//, '') : null, | |
| 59 | + availability: offers?.availability ? String(offers.availability).replace(/^https?:\/\/schema\.org\//, '') : null, | |
| 60 | + publisher: info.publisher ?? null, | |
| 61 | + productLine: info['product line'] ?? null, | |
| 62 | + category: info.category ?? null, | |
| 63 | + genre: info.genre ?? null, | |
| 64 | + type: info.type ?? null, | |
| 65 | + conditionText: conditionText ?? null, | |
| 66 | + }; | |
| 67 | +} | |
| 68 | + | |
| 69 | +/** Taxonomy slug from Noble Knight's own labels; null → skip (RPG books, CCG singles, supplies…). */ | |
| 70 | +export function nkCategory(p: Product): string | null { | |
| 71 | + const cat = (p.category ?? '').toLowerCase(); | |
| 72 | + const line = `${p.productLine ?? ''} ${p.publisher ?? ''} ${p.name}`.toLowerCase(); | |
| 73 | + if (/games workshop|warhammer|citadel|forge world|age of sigmar|40k|necromunda|blood bowl/.test(line)) return 'warhammer'; | |
| 74 | + if (/board ?game|war ?game|puzzle/.test(cat)) return 'board_games'; | |
| 75 | + if (/miniature/.test(cat)) return /\b(gundam|gunpla|bandai)\b/.test(line) ? 'gundam' : null; | |
| 76 | + if (/toys?, movies|action figure|toys/.test(cat)) { | |
| 77 | + if (/\b(gundam|gunpla)\b/.test(line)) return 'gundam'; | |
| 78 | + if (/\bfunko\b|\bpop!\b/.test(line)) return 'funko'; | |
| 79 | + if (/\b(figure|figuarts|nendoroid|hot toys|neca|mcfarlane|hasbro|mattel)\b/.test(line)) return 'action_figures'; | |
| 80 | + return null; | |
| 81 | + } | |
| 82 | + return null; | |
| 83 | +} | |
| 84 | + | |
| 85 | +/** | |
| 86 | + * Noble Knight Games (largest US used/new board-game & miniatures dealer): products are enumerated | |
| 87 | + * from the public sitemaps (robots.txt allows /P/ product pages and sitemaps; category listings are | |
| 88 | + * client-rendered and disallowed) and read from each page's schema.org Product data. | |
| 89 | + */ | |
| 90 | +export class NobleKnightConnector extends BaseConnector { | |
| 91 | + readonly version = '1.0.0'; | |
| 92 | + readonly parserVersion = PARSER_VERSION; | |
| 93 | + protected override minIntervalMs = 1500; | |
| 94 | + override readonly urlPatterns = [/^https?:\/\/(www\.)?nobleknight\.com\/P\/\d+/i]; | |
| 95 | + | |
| 96 | + async *crawl(ctx: CrawlContext): AsyncIterable<RawRecordInput> { | |
| 97 | + const sitemaps = Number(this.meta.config.sitemapCount ?? 5); | |
| 98 | + const perRun = Number(this.meta.config.productsPerRun ?? 200); | |
| 99 | + const smIndex = Number(ctx.options.cursor?.sitemapIndex ?? 1); | |
| 100 | + const offset = Number(ctx.options.cursor?.offset ?? 0); | |
| 101 | + const smUrl = `${BASE}/sitemapproducts${smIndex}.xml`; | |
| 102 | + await this.throttle(); | |
| 103 | + const sm = await ctx.fetch(smUrl, { engines: ['api'], responseType: 'text', minQuality: 0.3, timeoutMs: 90_000 }); | |
| 104 | + if (!sm.success || !sm.html) { | |
| 105 | + ctx.anomaly('page_fetch_failed', `${smUrl}: ${sm.error ?? sm.httpStatus}`); | |
| 106 | + return; | |
| 107 | + } | |
| 108 | + const locs = [...sm.html.matchAll(/<loc>([^<]+)<\/loc>/g)].map((m) => m[1]!.trim()).filter((u) => /\/P\/\d+/.test(u)); | |
| 109 | + const slice = locs.slice(offset, offset + perRun); | |
| 110 | + let count = 0; | |
| 111 | + for (const url of slice) { | |
| 112 | + if (ctx.signal?.aborted || this.reached(ctx, count)) break; | |
| 113 | + if (!(await ctx.shouldFetch(url))) continue; | |
| 114 | + await this.throttle(); | |
| 115 | + const res = await ctx.fetch(url, { engines: ['api'], responseType: 'text', expect: ['title', 'price', 'identifiers'], parse: (r) => (r.html ? (() => { const p = parseProduct(r.html!, url); return p ? { title: p.name, price: p.price, identifiers: { sku: p.sku } } : null; })() : null) }); | |
| 116 | + if (!res.success || !res.html) { | |
| 117 | + ctx.anomaly('page_fetch_failed', `${url}: ${res.error ?? res.httpStatus}`); | |
| 118 | + continue; | |
| 119 | + } | |
| 120 | + const product = parseProduct(res.html, url); | |
| 121 | + if (!product) continue; | |
| 122 | + count++; | |
| 123 | + yield { url, externalId: product.nkId, kind: 'listing', engine: res.engine, httpStatus: res.httpStatus, payload: { kind: 'product_page', product }, fetchedAt: res.fetchedAt }; | |
| 124 | + } | |
| 125 | + const nextOffset = offset + slice.length; | |
| 126 | + const exhausted = nextOffset >= locs.length; | |
| 127 | + await ctx.setCursor({ sitemapIndex: exhausted ? (smIndex % sitemaps) + 1 : smIndex, offset: exhausted ? 0 : nextOffset, updatedAt: new Date().toISOString() }); | |
| 128 | + } | |
| 129 | + | |
| 130 | + async lookup(url: string, ctx: CrawlContext): Promise<RawRecordInput[]> { | |
| 131 | + const res = await ctx.fetch(url, { engines: ['api'], responseType: 'text' }); | |
| 132 | + if (!res.success || !res.html) return []; | |
| 133 | + const product = parseProduct(res.html, url); | |
| 134 | + return product ? [{ url, externalId: product.nkId, kind: 'listing', engine: res.engine, httpStatus: res.httpStatus, payload: { kind: 'product_page', product }, fetchedAt: res.fetchedAt }] : []; | |
| 135 | + } | |
| 136 | + | |
| 137 | + async normalize(raw: RawRecordLike): Promise<NormalizedRecord[]> { | |
| 138 | + const { product: p } = PayloadSchema.parse(raw.payload); | |
| 139 | + const slug = nkCategory(p); | |
| 140 | + if (!slug) return []; | |
| 141 | + const condRaw = p.conditionText ?? (p.itemCondition === 'NewCondition' ? 'New' : p.itemCondition === 'UsedCondition' ? 'Used' : null); | |
| 142 | + const cond = dealerCondition(condRaw); | |
| 143 | + const attributes = lotAttributes({ categorySlug: slug, name: p.name, brand: p.publisher ?? p.brand, series: p.productLine, identifiers: { nobleknight_id: p.nkId, ...(p.mpn ? { mpn: p.mpn } : {}) }, metadata: { category: p.category, genre: p.genre, type: p.type, sku: p.sku } }); | |
| 144 | + const common = { meta: this.meta, sourceUrl: p.url, externalId: p.nkId, rawTitle: p.name, attributes, imageUrls: p.image ? [p.image] : [], description: p.description, observedAt: raw.fetchedAt, parserVersion: PARSER_VERSION, condition: cond.condition, conditionRaw: condRaw, completeness: cond.completeness }; | |
| 145 | + const out: NormalizedRecord[] = [makeCatalogItem({ ...common, confidence: 0.8 })]; | |
| 146 | + if (p.price !== null && Number.isFinite(p.price) && p.price > 0) { | |
| 147 | + const cur = (p.currency ?? 'USD') as 'USD'; | |
| 148 | + out.push(makeListing({ ...common, price: p.price, currency: cur, listingType: 'fixed_price', seller: 'Noble Knight Games', location: 'US', availability: /InStock|PreOrder|LimitedAvailability/.test(p.availability ?? '') ? 'available' : 'sold', quantity: 1 })); | |
| 149 | + } | |
| 150 | + return out; | |
| 151 | + } | |
| 152 | +} | |
| 153 | + | |
| 154 | +export default (meta: ConnectorMeta) => new NobleKnightConnector(meta); | |
added
connectors/api/noble-knight/meta.json
+33 −0
@@ -0,0 +1,33 @@ | ||
| 1 | +{ | |
| 2 | + "id": "noble-knight", | |
| 3 | + "displayName": "Noble Knight Games (board games & miniatures dealer)", | |
| 4 | + "sourceId": "noble-knight", | |
| 5 | + "sourceName": "Noble Knight Games", | |
| 6 | + "sourceType": "dealer", | |
| 7 | + "sourceUrl": "https://www.nobleknight.com", | |
| 8 | + "module": "api/noble-knight", | |
| 9 | + "enginePriority": ["api"], | |
| 10 | + "categories": ["board_games", "warhammer", "gundam", "action_figures", "funko"], | |
| 11 | + "regions": ["US"], | |
| 12 | + "languages": ["en"], | |
| 13 | + "currency": ["USD"], | |
| 14 | + "supportsListings": true, | |
| 15 | + "supportsSold": false, | |
| 16 | + "supportsAuctions": false, | |
| 17 | + "supportsImages": true, | |
| 18 | + "supportsCatalog": true, | |
| 19 | + "supportsPopulation": false, | |
| 20 | + "supportsLookup": true, | |
| 21 | + "refreshFrequencyMinutes": 1440, | |
| 22 | + "priority": "low", | |
| 23 | + "trustScore": 0.7, | |
| 24 | + "attributionRequired": true, | |
| 25 | + "termsUrl": "https://www.nobleknight.com/Terms", | |
| 26 | + "accessNotes": "Plain HTTPS. robots.txt disallows the client-rendered category/search listings (/Catalog, /category-search, /AdvancedSearch…) but allows product pages (/P/<id>/<slug>) and the sitemaps (sitemapproducts1-5.xml ≈ 120k products), so products are enumerated from the sitemaps with a rotating cursor and read from each page's schema.org Product JSON-LD (name, sku, mpn, brand, price, itemCondition, availability) plus the Publisher / Product Line / Category / Genre lines. Only board games, Warhammer/Games Workshop miniatures, Gunpla and figure products are kept (RPG books, CCG singles and supplies are skipped). Condition mapped to the boxed_toys scale. 1.5 s politeness, 0 credits, 200 products per run.", | |
| 27 | + "enabled": true, | |
| 28 | + "schemaVersion": "1.0", | |
| 29 | + "config": { | |
| 30 | + "sitemapCount": 5, | |
| 31 | + "productsPerRun": 200 | |
| 32 | + } | |
| 33 | +} | |
added
connectors/api/scp-auctions/_smoke.ts
+7 −0
@@ -0,0 +1,7 @@ | ||
| 1 | +import path from 'node:path'; | |
| 2 | +import { fileURLToPath } from 'node:url'; | |
| 3 | +import { captureFixture, runSmoke } from '../../firecrawl/_carlib/smoke.js'; | |
| 4 | + | |
| 5 | +const dir = path.dirname(fileURLToPath(import.meta.url)); | |
| 6 | +if (process.argv.includes('--save')) await captureFixture(dir, process.argv[process.argv.indexOf('--save') + 1] ?? 'catalog-page', 4); | |
| 7 | +else await runSmoke(dir, Number(process.argv[2] ?? 2)); | |
added
connectors/api/scp-auctions/index.test.ts
+63 −0
@@ -0,0 +1,63 @@ | ||
| 1 | +import { describe, expect, it } from 'vitest'; | |
| 2 | +import { readFileSync } from 'node:fs'; | |
| 3 | +import path from 'node:path'; | |
| 4 | +import { fileURLToPath } from 'node:url'; | |
| 5 | +import { loadFixture, runFixtureSuite } from '@rareindex/connectors/testing'; | |
| 6 | +import { localMeta } from '../_lib/local-meta.js'; | |
| 7 | +import { bidsquareDate, parseBidsquareCatalog, parseBidsquareEvents, sportsCategory } from '../_memorabilia-lib/index.js'; | |
| 8 | +import createConnector from './index.js'; | |
| 9 | + | |
| 10 | +const dir = path.dirname(fileURLToPath(import.meta.url)); | |
| 11 | +const meta = localMeta(JSON.parse(readFileSync(path.join(dir, 'meta.json'), 'utf8'))); | |
| 12 | +const connector = createConnector(meta); | |
| 13 | + | |
| 14 | +const EVENTS = `<ul><li id="st-23246"><div class="row justify-content-center gtm-visible_event" data-event_id='23246' data-event_status='past' data-event_name='2026 Summer Premier'> | |
| 15 | +<div class="list-top"><label>Timed Auction</label><h1><a href="https://catalogs.scpauctions.com/auctions/scp-auctions-inc/2026-summer-premier-23246">2026 Summer Premier</a></h1></div></div></li></ul>`; | |
| 16 | + | |
| 17 | +const CATALOG = `<script type="application/ld+json">{"@context":"https://schema.org","@type":"Event","name":"2026 Summer Premier","url":"https://catalogs.scpauctions.com/auctions/scp-auctions-inc/2026-summer-premier-23246","startDate":"2026-07-09 13:00:00 EDT","endDate":"2026-07-26 22:30:00 EDT"}</script> | |
| 18 | +<div class="row filter-items"> | |
| 19 | +<div class="col-6 el_23246 gtm-visible_item" id="stl-9310176" data-item_id="9310176" data-event_id='23246' data-event_status='past' data-event_name='2026 Summer Premier'> | |
| 20 | + <div class="catalog_img"><a href="https://catalogs.scpauctions.com/online-auctions/scp-auctions-inc/1947-bond-bread-jackie-robinson-9310176"><img src="https://s1.img.bidsquare.com/item/l/3726/37264383.jpeg" alt="x"/></a></div> | |
| 21 | + <div class="catalog_detail"><div class="item-list-top"><div class="lot_Num">Lot 47</div><div class="lot_title"><a href="https://catalogs.scpauctions.com/online-auctions/scp-auctions-inc/1947-bond-bread-jackie-robinson-9310176">1947 Bond Bread Portrait Jackie Robinson Rookie – SGC FR 1.5</a></div></div> | |
| 22 | + <div class="item-list-bottom"><div class="bidding_sec"><div class="bidPrice"><div class="bidLength bid_num"><span class="num">32 Bids</span></div><div class="bid_txt"><span>Sold for</span><div class="currency_container"><span class="price ">$14,400</span></div></div></div></div></div></div> | |
| 23 | +</div> | |
| 24 | +<div class="col-6 el_23246 gtm-visible_item" id="stl-9310177" data-item_id="9310177" data-event_id='23246' data-event_status='past' data-event_name='2026 Summer Premier'> | |
| 25 | + <div class="catalog_detail"><div class="item-list-top"><div class="lot_Num">Lot 48</div><div class="lot_title"><a href="https://catalogs.scpauctions.com/online-auctions/scp-auctions-inc/passed-9310177">1986 Fleer Michael Jordan Rookie PSA 8</a></div></div> | |
| 26 | + <div class="item-list-bottom"><div class="bidding_sec"><div class="bidPrice"><div class="bid_txt"><span>Passed</span></div></div></div></div></div> | |
| 27 | +</div></div> | |
| 28 | +<nav><ul class="pagination"><li data-page="1" class="page-item active"><a>1</a></li><li data-page="2" class="page-item"><a>2</a></li><li data-page="12" class="page-item"><a>12</a></li></ul></nav>`; | |
| 29 | + | |
| 30 | +describe('scp-auctions', () => { | |
| 31 | + runFixtureSuite(connector, it, expect); | |
| 32 | + | |
| 33 | + it('parses the event list and catalog cards', () => { | |
| 34 | + expect(parseBidsquareEvents(EVENTS)).toEqual([{ id: '23246', name: '2026 Summer Premier', url: 'https://catalogs.scpauctions.com/auctions/scp-auctions-inc/2026-summer-premier-23246', status: 'past', startDate: null, endDate: null }]); | |
| 35 | + const cat = parseBidsquareCatalog(CATALOG, 1)!; | |
| 36 | + expect(cat.event).toMatchObject({ id: '23246', status: 'past', endDate: '2026-07-26 22:30:00 EDT' }); | |
| 37 | + expect(cat.totalPages).toBe(12); | |
| 38 | + expect(cat.items.length).toBe(2); | |
| 39 | + expect(cat.items[0]).toMatchObject({ itemId: '9310176', lotNumber: '47', priceLabel: 'Sold for', price: 14400, bids: 32 }); | |
| 40 | + expect(cat.items[1]!.price).toBeNull(); | |
| 41 | + expect(bidsquareDate('2026-07-26 22:30:00 EDT')?.toISOString()).toBe('2026-07-27T02:30:00.000Z'); | |
| 42 | + }); | |
| 43 | + | |
| 44 | + it('normalises sold lots only, dated by the event end, with sport-aware categories', async () => { | |
| 45 | + const cat = parseBidsquareCatalog(CATALOG, 1)!; | |
| 46 | + const out = await connector.normalize({ url: 'x', externalId: 'e', kind: 'sale', engine: 'api', fetchedAt: new Date('2026-09-07T00:00:00Z'), payload: { kind: 'catalog_page', house: 'SCP Auctions', event: cat.event, page: 1, totalPages: 12, items: cat.items } }); | |
| 47 | + expect(out.length).toBe(1); | |
| 48 | + const s = out[0]!; | |
| 49 | + if (s.kind !== 'sale') throw new Error('expected sale'); | |
| 50 | + expect(s).toMatchObject({ price: 14400, currency: 'USD', lotNumber: '47', auctionHouse: 'SCP Auctions', buyerPremiumIncluded: null }); | |
| 51 | + expect(s.saleDate.toISOString()).toBe('2026-07-27T02:30:00.000Z'); | |
| 52 | + expect(s.attributes.categorySlug).toBe('baseball_cards'); | |
| 53 | + expect(s.grade).toMatchObject({ grader: 'sgc', grade: '1.5' }); | |
| 54 | + expect(sportsCategory('1986 Fleer Michael Jordan Rookie PSA 8')).toBe('basketball_cards'); | |
| 55 | + expect(sportsCategory('1927 Babe Ruth Game Used Bat')).toBe('sports_memorabilia'); | |
| 56 | + }); | |
| 57 | + | |
| 58 | + it('fixture sales carry the event end date and USD prices', async () => { | |
| 59 | + const out = await connector.normalize(loadFixture('scp-auctions', 'catalog-page').raw); | |
| 60 | + expect(out.length).toBeGreaterThan(0); | |
| 61 | + for (const r of out) if (r.kind === 'sale') expect(r.currency).toBe('USD'); | |
| 62 | + }); | |
| 63 | +}); | |
added
connectors/api/scp-auctions/index.ts
+165 −0
@@ -0,0 +1,165 @@ | ||
| 1 | +import { z } from 'zod'; | |
| 2 | +import { BaseConnector, type ConnectorMeta, type CrawlContext, type RawRecordInput, type RawRecordLike } from '@rareindex/connectors'; | |
| 3 | +import type { NormalizedRecord } from '@rareindex/shared'; | |
| 4 | +import { isBundleTitle, safeYear } from '../_auction-lib/categories.js'; | |
| 5 | +import { bidsquareDate, gradeOf, lotAttributes, makeLot, parseBidsquareCatalog, parseBidsquareEvents, sportsCategory, toyGrade, type BidsquareCatalog } from '../_memorabilia-lib/index.js'; | |
| 6 | +import { makeSale } from '../../firecrawl/_carlib/index.js'; | |
| 7 | + | |
| 8 | +const PARSER_VERSION = '1.0.0'; | |
| 9 | + | |
| 10 | +export const CatalogPayloadSchema = z.object({ | |
| 11 | + kind: z.literal('catalog_page'), | |
| 12 | + house: z.string(), | |
| 13 | + event: z.object({ id: z.string(), name: z.string(), url: z.string(), status: z.enum(['upcoming', 'live', 'past', 'unknown']), startDate: z.string().nullable(), endDate: z.string().nullable() }), | |
| 14 | + page: z.number(), | |
| 15 | + totalPages: z.number().nullable(), | |
| 16 | + items: z.array( | |
| 17 | + z.object({ | |
| 18 | + itemId: z.string(), | |
| 19 | + url: z.string(), | |
| 20 | + title: z.string(), | |
| 21 | + lotNumber: z.string().nullable(), | |
| 22 | + image: z.string().nullable(), | |
| 23 | + priceLabel: z.string().nullable(), | |
| 24 | + price: z.number().nullable(), | |
| 25 | + bids: z.number().nullable(), | |
| 26 | + estimateLow: z.number().nullable(), | |
| 27 | + estimateHigh: z.number().nullable(), | |
| 28 | + startsAt: z.number().nullable(), | |
| 29 | + endsAt: z.number().nullable(), | |
| 30 | + }), | |
| 31 | + ), | |
| 32 | +}); | |
| 33 | +export type CatalogPayload = z.infer<typeof CatalogPayloadSchema>; | |
| 34 | + | |
| 35 | +export interface BidsquareHouseOptions { | |
| 36 | + base: string; | |
| 37 | + house: string; | |
| 38 | + /** map a lot title to a taxonomy slug */ | |
| 39 | + category: (title: string) => string | null; | |
| 40 | + /** buyer's premium included in the displayed "Sold for" price? null when the house does not state it */ | |
| 41 | + buyerPremiumIncluded: boolean | null; | |
| 42 | + idKey: string; | |
| 43 | + location: string | null; | |
| 44 | +} | |
| 45 | + | |
| 46 | +/** | |
| 47 | + * Bidsquare-hosted auction catalogs (SCP Auctions, Hake's). Public pages only: | |
| 48 | + * /auctions (upcoming), /auctions/past?page=N, /auctions/<house>/<slug>-<id>/catalog?page=N | |
| 49 | + * Past catalogs show "Sold for $X" per lot → sales dated by the event end (schema.org Event JSON-LD); | |
| 50 | + * upcoming catalogs show current/starting bid + estimate → auction lots for the calendar. | |
| 51 | + */ | |
| 52 | +export class BidsquareHouseConnector extends BaseConnector { | |
| 53 | + readonly version = '1.0.0'; | |
| 54 | + readonly parserVersion = PARSER_VERSION; | |
| 55 | + protected override minIntervalMs = 2000; | |
| 56 | + | |
| 57 | + constructor(meta: ConnectorMeta, protected readonly opts: BidsquareHouseOptions) { | |
| 58 | + super(meta); | |
| 59 | + } | |
| 60 | + | |
| 61 | + protected async fetchHtml(ctx: CrawlContext, url: string): Promise<{ html: string | null; engine: 'api' | 'feed' | 'firecrawl' | 'scrapfly' | 'browser' | 'manual'; status: number | null; fetchedAt: Date }> { | |
| 62 | + await this.throttle(); | |
| 63 | + const res = await ctx.fetch(url, { engines: ['api'], responseType: 'text', minQuality: 0.3, timeoutMs: 45_000 }); | |
| 64 | + if (!res.success || !res.html) { | |
| 65 | + ctx.anomaly('page_fetch_failed', `${url}: ${res.error ?? res.httpStatus}`); | |
| 66 | + return { html: null, engine: res.engine, status: res.httpStatus, fetchedAt: res.fetchedAt }; | |
| 67 | + } | |
| 68 | + return { html: res.html, engine: res.engine, status: res.httpStatus, fetchedAt: res.fetchedAt }; | |
| 69 | + } | |
| 70 | + | |
| 71 | + /** Events to visit this run: past (sold) first, then upcoming (lots). Subclasses may override. */ | |
| 72 | + protected async listEvents(ctx: CrawlContext): Promise<Array<{ id: string; name: string; url: string; status: string }>> { | |
| 73 | + const backfill = ctx.options.mode === 'backfill'; | |
| 74 | + const page = backfill ? Number(ctx.options.cursor?.pastPage ?? 1) : 1; | |
| 75 | + const out: Array<{ id: string; name: string; url: string; status: string }> = []; | |
| 76 | + const past = await this.fetchHtml(ctx, `${this.opts.base}/auctions/past${page > 1 ? `?page=${page}` : ''}`); | |
| 77 | + if (past.html) out.push(...parseBidsquareEvents(past.html).map((e) => ({ ...e, status: 'past' }))); | |
| 78 | + if (this.meta.config.includeUpcoming !== false) { | |
| 79 | + const up = await this.fetchHtml(ctx, `${this.opts.base}/auctions`); | |
| 80 | + if (up.html) out.push(...parseBidsquareEvents(up.html).filter((e) => e.status !== 'past').map((e) => ({ ...e, status: e.status === 'past' ? 'past' : 'upcoming' }))); | |
| 81 | + } | |
| 82 | + return out; | |
| 83 | + } | |
| 84 | + | |
| 85 | + async *crawl(ctx: CrawlContext): AsyncIterable<RawRecordInput> { | |
| 86 | + const eventsPerRun = Number(this.meta.config.eventsPerRun ?? 2); | |
| 87 | + const maxPages = Number(this.meta.config.pagesPerEvent ?? 8); | |
| 88 | + const done = new Set<string>(Array.isArray(ctx.options.cursor?.doneEvents) ? (ctx.options.cursor!.doneEvents as string[]) : []); | |
| 89 | + const events = await this.listEvents(ctx); | |
| 90 | + let processed = 0; | |
| 91 | + let count = 0; | |
| 92 | + for (const ev of events) { | |
| 93 | + if (ctx.signal?.aborted || processed >= eventsPerRun) break; | |
| 94 | + if (ev.status === 'past' && done.has(ev.id)) continue; | |
| 95 | + const catalogBase = `${ev.url.replace(/\/$/, '')}/catalog`; | |
| 96 | + let total: number | null = null; | |
| 97 | + for (let page = 1; page <= maxPages; page++) { | |
| 98 | + if (ctx.signal?.aborted || this.reached(ctx, count)) break; | |
| 99 | + const url = page === 1 ? catalogBase : `${catalogBase}?page=${page}`; | |
| 100 | + const r = await this.fetchHtml(ctx, url); | |
| 101 | + if (!r.html) break; | |
| 102 | + const cat = parseBidsquareCatalog(r.html, page); | |
| 103 | + if (!cat || cat.items.length === 0) break; | |
| 104 | + total = cat.totalPages ?? total; | |
| 105 | + const payload: CatalogPayload = { kind: 'catalog_page', house: this.opts.house, event: { ...cat.event, name: cat.event.name || ev.name, url: cat.event.url || ev.url }, page, totalPages: total, items: cat.items }; | |
| 106 | + count++; | |
| 107 | + yield { url, externalId: `event:${cat.event.id}:page:${page}`, kind: cat.event.status === 'past' ? 'sale' : 'auction_lot', engine: r.engine, httpStatus: r.status, payload, fetchedAt: r.fetchedAt }; | |
| 108 | + if (total !== null && page >= total) break; | |
| 109 | + } | |
| 110 | + processed++; | |
| 111 | + if (ev.status === 'past') done.add(ev.id); | |
| 112 | + } | |
| 113 | + const pastPage = Number(ctx.options.cursor?.pastPage ?? 1); | |
| 114 | + await ctx.setCursor({ doneEvents: [...done].slice(-500), pastPage: ctx.options.mode === 'backfill' && events.filter((e) => e.status === 'past').every((e) => done.has(e.id)) ? pastPage + 1 : pastPage, updatedAt: new Date().toISOString() }); | |
| 115 | + } | |
| 116 | + | |
| 117 | + async normalize(raw: RawRecordLike): Promise<NormalizedRecord[]> { | |
| 118 | + const p = CatalogPayloadSchema.parse(raw.payload); | |
| 119 | + const out: NormalizedRecord[] = []; | |
| 120 | + const endDate = bidsquareDate(p.event.endDate); | |
| 121 | + const startDate = bidsquareDate(p.event.startDate); | |
| 122 | + for (const it of p.items) { | |
| 123 | + const slug = this.opts.category(it.title); | |
| 124 | + if (!slug) continue; | |
| 125 | + const g = gradeOf(it.title); | |
| 126 | + const tg = toyGrade(it.title); | |
| 127 | + const attributes = lotAttributes({ | |
| 128 | + categorySlug: slug, | |
| 129 | + name: it.title, | |
| 130 | + year: safeYear(it.title), | |
| 131 | + identifiers: { [this.opts.idKey]: it.itemId }, | |
| 132 | + metadata: { event_id: p.event.id, event_name: p.event.name, estimate_low: it.estimateLow, estimate_high: it.estimateHigh, bids: it.bids, ...(tg ? { toy_grade: `${tg.company} ${tg.grade}` } : {}) }, | |
| 133 | + }); | |
| 134 | + const common = { meta: this.meta, sourceUrl: it.url, externalId: it.itemId, rawTitle: it.title, attributes, imageUrls: it.image ? [it.image] : [], observedAt: raw.fetchedAt, parserVersion: PARSER_VERSION, grader: g.grader, grade: g.grade }; | |
| 135 | + const sold = p.event.status === 'past' && /sold/i.test(it.priceLabel ?? '') && it.price !== null && it.price > 0; | |
| 136 | + if (sold) { | |
| 137 | + const saleDate = (it.endsAt ? new Date(it.endsAt * 1000) : null) ?? endDate; | |
| 138 | + if (!saleDate) continue; | |
| 139 | + out.push(makeSale({ ...common, price: it.price!, currency: 'USD', saleDate, buyerPremiumIncluded: this.opts.buyerPremiumIncluded, auctionHouse: this.opts.house, lotNumber: it.lotNumber, location: this.opts.location, isBundle: isBundleTitle(it.title) })); | |
| 140 | + } else if (p.event.status !== 'past') { | |
| 141 | + const endsAt = (it.endsAt ? new Date(it.endsAt * 1000) : null) ?? endDate; | |
| 142 | + const status = startDate && startDate.getTime() > Date.now() ? 'upcoming' : 'live'; | |
| 143 | + out.push(makeLot({ ...common, auctionHouse: this.opts.house, auctionName: p.event.name, lotNumber: it.lotNumber, startsAt: startDate, endsAt, estimateLow: it.estimateLow, estimateHigh: it.estimateHigh, currentBid: /bid/i.test(it.priceLabel ?? '') ? it.price : null, currency: 'USD', status, location: this.opts.location, confidence: 0.85 })); | |
| 144 | + } | |
| 145 | + } | |
| 146 | + return out; | |
| 147 | + } | |
| 148 | +} | |
| 149 | + | |
| 150 | +export function scpCategory(title: string): string { | |
| 151 | + return sportsCategory(title); | |
| 152 | +} | |
| 153 | + | |
| 154 | +export default (meta: ConnectorMeta) => | |
| 155 | + new BidsquareHouseConnector(meta, { | |
| 156 | + base: 'https://catalogs.scpauctions.com', | |
| 157 | + house: 'SCP Auctions', | |
| 158 | + category: scpCategory, | |
| 159 | + // SCP does not state on the catalog page whether "Sold for" includes the buyer's premium. | |
| 160 | + buyerPremiumIncluded: null, | |
| 161 | + idKey: 'scp_item_id', | |
| 162 | + location: 'US', | |
| 163 | + }); | |
| 164 | + | |
| 165 | +export type { BidsquareCatalog }; | |
added
connectors/api/scp-auctions/meta.json
+34 −0
@@ -0,0 +1,34 @@ | ||
| 1 | +{ | |
| 2 | + "id": "scp-auctions", | |
| 3 | + "displayName": "SCP Auctions (results & catalogs)", | |
| 4 | + "sourceId": "scp-auctions", | |
| 5 | + "sourceName": "SCP Auctions", | |
| 6 | + "sourceType": "auction_house", | |
| 7 | + "sourceUrl": "https://catalogs.scpauctions.com", | |
| 8 | + "module": "api/scp-auctions", | |
| 9 | + "enginePriority": ["api"], | |
| 10 | + "categories": ["sports_memorabilia", "baseball_cards", "basketball_cards", "football_cards", "hockey_cards", "soccer_cards", "other_sports_cards", "olympic_collectibles", "pokemon"], | |
| 11 | + "regions": ["US"], | |
| 12 | + "languages": ["en"], | |
| 13 | + "currency": ["USD"], | |
| 14 | + "supportsListings": false, | |
| 15 | + "supportsSold": true, | |
| 16 | + "supportsAuctions": true, | |
| 17 | + "supportsImages": true, | |
| 18 | + "supportsCatalog": false, | |
| 19 | + "supportsPopulation": false, | |
| 20 | + "supportsLookup": false, | |
| 21 | + "refreshFrequencyMinutes": 720, | |
| 22 | + "priority": "medium", | |
| 23 | + "trustScore": 0.9, | |
| 24 | + "attributionRequired": true, | |
| 25 | + "termsUrl": "https://catalogs.scpauctions.com/terms", | |
| 26 | + "accessNotes": "Plain HTTPS on the public Bidsquare-hosted catalog (robots.txt: only /wp-admin/ disallowed, Crawl-delay 10 → 2 s politeness plus small runs). Past auctions are listed at /auctions/past?page=N; each catalog page (/auctions/scp-auctions-inc/<slug>-<id>/catalog?page=N, 48 lots) shows 'Sold for $X' and the bid count; the event start/end come from the page's schema.org Event JSON-LD (sale date = event end, or the lot's own countdown end). Upcoming catalogs yield auction lots (current/starting bid, estimate). SCP does not state on these pages whether 'Sold for' includes the 20% buyer's premium → buyer_premium_included=null. No login, no bidding endpoints. 0 credits.", | |
| 27 | + "enabled": true, | |
| 28 | + "schemaVersion": "1.0", | |
| 29 | + "config": { | |
| 30 | + "eventsPerRun": 2, | |
| 31 | + "pagesPerEvent": 8, | |
| 32 | + "includeUpcoming": true | |
| 33 | + } | |
| 34 | +} | |
added
connectors/api/trainz/_smoke.ts
+7 −0
@@ -0,0 +1,7 @@ | ||
| 1 | +import path from 'node:path'; | |
| 2 | +import { fileURLToPath } from 'node:url'; | |
| 3 | +import { captureFixture, runSmoke } from '../../firecrawl/_carlib/smoke.js'; | |
| 4 | + | |
| 5 | +const dir = path.dirname(fileURLToPath(import.meta.url)); | |
| 6 | +if (process.argv.includes('--save')) await captureFixture(dir, process.argv[process.argv.indexOf('--save') + 1] ?? 'collection-page', 4); | |
| 7 | +else await runSmoke(dir, Number(process.argv[2] ?? 1)); | |
added
connectors/api/trainz/index.test.ts
+46 −0
@@ -0,0 +1,46 @@ | ||
| 1 | +import { describe, expect, it } from 'vitest'; | |
| 2 | +import { readFileSync } from 'node:fs'; | |
| 3 | +import path from 'node:path'; | |
| 4 | +import { fileURLToPath } from 'node:url'; | |
| 5 | +import { loadFixture, runFixtureSuite } from '@rareindex/connectors/testing'; | |
| 6 | +import { localMeta } from '../_lib/local-meta.js'; | |
| 7 | +import createConnector, { splitTitle, trimProduct } from './index.js'; | |
| 8 | + | |
| 9 | +const dir = path.dirname(fileURLToPath(import.meta.url)); | |
| 10 | +const meta = localMeta(JSON.parse(readFileSync(path.join(dir, 'meta.json'), 'utf8'))); | |
| 11 | +const connector = createConnector(meta); | |
| 12 | + | |
| 13 | +describe('trainz', () => { | |
| 14 | + runFixtureSuite(connector, it, expect); | |
| 15 | + | |
| 16 | + it('splits Trainz condition suffixes and trims products', () => { | |
| 17 | + expect(splitTitle('Lionel 6-18005 O Gauge 700E Hudson Steam Locomotive LN/Box')).toEqual({ name: 'Lionel 6-18005 O Gauge 700E Hudson Steam Locomotive', conditionCode: 'LN/Box' }); | |
| 18 | + expect(splitTitle('S. Soho & Co. HO BRASS Passenger Coach Car- Unpainted EX/Box').conditionCode).toBe('EX/Box'); | |
| 19 | + expect(splitTitle('Marklin 3000 HO Steam Locomotive').conditionCode).toBeNull(); | |
| 20 | + const p = trimProduct({ id: 1, title: 'X LN/Box', handle: 'x', vendor: 'Lionel', product_type: 'Trains', tags: ['condition:Like New', 'foo', 'scale:O Gauge'], variants: [{ id: 2, sku: 'A', price: '10.00', available: true }], images: [] }); | |
| 21 | + expect(p?.tags).toEqual(['condition:Like New', 'scale:O Gauge']); | |
| 22 | + }); | |
| 23 | + | |
| 24 | + it('emits a catalog item and a dealer listing per product with a normalised condition', async () => { | |
| 25 | + const out = await connector.normalize({ | |
| 26 | + url: 'https://www.trainz.com/collections/lionel-o-postwar-trains/products.json?limit=250&page=1', | |
| 27 | + externalId: 'lionel-o-postwar-trains:1', | |
| 28 | + kind: 'listing', | |
| 29 | + engine: 'api', | |
| 30 | + fetchedAt: new Date('2026-09-07T00:00:00Z'), | |
| 31 | + payload: { kind: 'collection_page', collection: 'lionel-o-postwar-trains', page: 1, products: [{ id: 99, title: 'Lionel 2343 O Gauge Santa Fe F3 AA Diesel Locomotive Set EX/Box', handle: 'lionel-2343', vendor: 'Lionel', product_type: 'Trains', tags: ['condition:Excellent', 'era:Postwar', 'scale:O Gauge'], created_at: '2026-09-01T00:00:00-04:00', updated_at: null, variants: [{ id: 1, sku: 'P123', price: '499.99', compare_at_price: null, available: true }], images: [{ src: 'https://cdn.shopify.com/x.jpg' }] }] }, | |
| 32 | + }); | |
| 33 | + expect(out.map((r) => r.kind)).toEqual(['catalog_item', 'listing']); | |
| 34 | + const l = out[1]!; | |
| 35 | + if (l.kind !== 'listing') throw new Error('listing'); | |
| 36 | + expect(l).toMatchObject({ price: 499.99, currency: 'USD', availability: 'available', seller: 'Trainz' }); | |
| 37 | + expect(l.attributes).toMatchObject({ categorySlug: 'model_trains', brand: 'Lionel', series: 'O Gauge', name: 'Lionel 2343 O Gauge Santa Fe F3 AA Diesel Locomotive Set' }); | |
| 38 | + expect(l.condition).toMatchObject({ condition: 'boxed', conditionRaw: 'Excellent' }); | |
| 39 | + expect(l.attributes.identifiers.trainz_sku).toBe('P123'); | |
| 40 | + }); | |
| 41 | + | |
| 42 | + it('fixture products all carry a vendor and a USD price', async () => { | |
| 43 | + const out = await connector.normalize(loadFixture('trainz', 'collection-page').raw); | |
| 44 | + expect(out.filter((r) => r.kind === 'listing').length).toBeGreaterThan(0); | |
| 45 | + }); | |
| 46 | +}); | |
added
connectors/api/trainz/index.ts
+108 −0
@@ -0,0 +1,108 @@ | ||
| 1 | +import { z } from 'zod'; | |
| 2 | +import { BaseConnector, type ConnectorMeta, type CrawlContext, type RawRecordInput, type RawRecordLike } from '@rareindex/connectors'; | |
| 3 | +import type { NormalizedRecord } from '@rareindex/shared'; | |
| 4 | +import { dealerCondition, lotAttributes, makeCatalogItem, makeListing } from '../_memorabilia-lib/index.js'; | |
| 5 | + | |
| 6 | +const BASE = 'https://www.trainz.com'; | |
| 7 | +const PARSER_VERSION = '1.0.0'; | |
| 8 | + | |
| 9 | +export const ProductSchema = z.object({ | |
| 10 | + id: z.number(), | |
| 11 | + title: z.string(), | |
| 12 | + handle: z.string(), | |
| 13 | + vendor: z.string().nullable().default(null), | |
| 14 | + product_type: z.string().nullable().default(null), | |
| 15 | + tags: z.array(z.string()).default([]), | |
| 16 | + created_at: z.string().nullable().default(null), | |
| 17 | + updated_at: z.string().nullable().default(null), | |
| 18 | + variants: z.array(z.object({ id: z.number(), sku: z.string().nullable().default(null), price: z.string(), compare_at_price: z.string().nullable().default(null), available: z.boolean().default(true) })).default([]), | |
| 19 | + images: z.array(z.object({ src: z.string() })).default([]), | |
| 20 | +}); | |
| 21 | +export type Product = z.infer<typeof ProductSchema>; | |
| 22 | +export const PayloadSchema = z.object({ kind: z.literal('collection_page'), collection: z.string(), page: z.number(), products: z.array(ProductSchema) }); | |
| 23 | +export type Payload = z.infer<typeof PayloadSchema>; | |
| 24 | + | |
| 25 | +/** Keep only the fields we use so raw payloads stay small. */ | |
| 26 | +export function trimProduct(p: Record<string, unknown>): Product | null { | |
| 27 | + const parsed = ProductSchema.safeParse(p); | |
| 28 | + if (!parsed.success) return null; | |
| 29 | + const v = parsed.data; | |
| 30 | + return { ...v, variants: v.variants.slice(0, 3), images: v.images.slice(0, 2), tags: v.tags.filter((t) => /^(condition|class|era|scale|gauge|Inventory Type2|roadname|road_name|manufacturer)[:_]/i.test(t) || /^in-stock$|^sold-out$/.test(t)).slice(0, 12) }; | |
| 31 | +} | |
| 32 | + | |
| 33 | +function tag(p: Product, prefix: string): string | null { | |
| 34 | + const t = p.tags.find((x) => x.toLowerCase().startsWith(`${prefix.toLowerCase()}:`)); | |
| 35 | + return t ? t.slice(prefix.length + 1).trim() : null; | |
| 36 | +} | |
| 37 | + | |
| 38 | +/** "Lionel 6-18005 O Gauge 700E Hudson Steam Locomotive LN/Box" → name without the trailing condition code. */ | |
| 39 | +export function splitTitle(title: string): { name: string; conditionCode: string | null } { | |
| 40 | + const m = title.match(/\s+(LN|EX|VG|GD|G|PR|FR|NM|MT|C-?\d{1,2})(?:\/(Box|OB|Sealed|No Box))?\s*$/i); | |
| 41 | + if (!m) return { name: title.trim(), conditionCode: null }; | |
| 42 | + return { name: title.slice(0, m.index).trim(), conditionCode: m[0].trim() }; | |
| 43 | +} | |
| 44 | + | |
| 45 | +const CODE_WORDS: Record<string, string> = { LN: 'Like New', EX: 'Excellent', VG: 'Very Good', GD: 'Good', G: 'Good', PR: 'Poor', FR: 'Fair', NM: 'Near Mint', MT: 'Mint' }; | |
| 46 | + | |
| 47 | +/** | |
| 48 | + * Trainz.com (world's largest model-train dealer): public Shopify product feed per collection | |
| 49 | + * (/collections/<handle>/products.json). Catalog facts + dealer asking price with graded condition. | |
| 50 | + */ | |
| 51 | +export class TrainzConnector extends BaseConnector { | |
| 52 | + readonly version = '1.0.0'; | |
| 53 | + readonly parserVersion = PARSER_VERSION; | |
| 54 | + protected override minIntervalMs = 1500; | |
| 55 | + | |
| 56 | + async *crawl(ctx: CrawlContext): AsyncIterable<RawRecordInput> { | |
| 57 | + const seeds = (this.meta.config.collections as string[] | undefined) ?? ['lionel-postwar-trains', 'american-flyer-postwar-trains']; | |
| 58 | + const pagesPerCollection = Number(this.meta.config.pagesPerCollection ?? 2); | |
| 59 | + const startIdx = Number(ctx.options.cursor?.seedIndex ?? 0) % seeds.length; | |
| 60 | + const perRun = Number(this.meta.config.collectionsPerRun ?? 4); | |
| 61 | + let count = 0; | |
| 62 | + for (let k = 0; k < Math.min(perRun, seeds.length); k++) { | |
| 63 | + const handle = seeds[(startIdx + k) % seeds.length]!; | |
| 64 | + for (let page = 1; page <= pagesPerCollection; page++) { | |
| 65 | + if (ctx.signal?.aborted || this.reached(ctx, count)) break; | |
| 66 | + const url = `${BASE}/collections/${handle}/products.json?limit=250&page=${page}`; | |
| 67 | + await this.throttle(); | |
| 68 | + const res = await ctx.fetch(url, { engines: ['api'], expect: ['title', 'price'], parse: (r) => ({ title: (r.json as { products?: Array<{ title: string }> })?.products?.[0]?.title ?? null, price: (r.json as { products?: Array<{ variants?: Array<{ price: string }> }> })?.products?.[0]?.variants?.[0]?.price ?? null }) }); | |
| 69 | + const list = (res.json as { products?: Record<string, unknown>[] } | null)?.products; | |
| 70 | + if (!res.success || !Array.isArray(list)) { | |
| 71 | + ctx.anomaly('page_fetch_failed', `${url}: ${res.error ?? res.httpStatus}`); | |
| 72 | + break; | |
| 73 | + } | |
| 74 | + const products = list.map(trimProduct).filter((p): p is Product => Boolean(p)); | |
| 75 | + if (products.length === 0) break; | |
| 76 | + count++; | |
| 77 | + yield { url, externalId: `${handle}:${page}`, kind: 'listing', engine: res.engine, httpStatus: res.httpStatus, payload: { kind: 'collection_page', collection: handle, page, products } satisfies Payload, fetchedAt: res.fetchedAt }; | |
| 78 | + if (products.length < 250) break; | |
| 79 | + } | |
| 80 | + } | |
| 81 | + await ctx.setCursor({ seedIndex: (startIdx + perRun) % seeds.length, updatedAt: new Date().toISOString() }); | |
| 82 | + } | |
| 83 | + | |
| 84 | + async normalize(raw: RawRecordLike): Promise<NormalizedRecord[]> { | |
| 85 | + const p = PayloadSchema.parse(raw.payload); | |
| 86 | + const out: NormalizedRecord[] = []; | |
| 87 | + for (const pr of p.products) { | |
| 88 | + const v = pr.variants[0]; | |
| 89 | + if (!v) continue; | |
| 90 | + const { name, conditionCode } = splitTitle(pr.title); | |
| 91 | + const condTag = tag(pr, 'condition'); | |
| 92 | + const condRaw = condTag ?? (conditionCode ? CODE_WORDS[conditionCode.split('/')[0]!.toUpperCase()] ?? conditionCode : null); | |
| 93 | + const cond = dealerCondition(condRaw ?? ''); | |
| 94 | + const scale = tag(pr, 'scale') ?? tag(pr, 'gauge') ?? name.match(/\b(HO|N|O|S|G|Z|TT|O27|Standard)\s+(?:Scale|Gauge)\b/i)?.[0] ?? null; | |
| 95 | + const url = `${BASE}/products/${pr.handle}`; | |
| 96 | + const attributes = lotAttributes({ categorySlug: 'model_trains', name, brand: pr.vendor, series: scale, identifiers: { trainz_sku: v.sku ?? String(pr.id), shopify_product_id: String(pr.id) }, metadata: { product_type: pr.product_type, era: tag(pr, 'era'), class: tag(pr, 'class'), tags: pr.tags } }); | |
| 97 | + const price = Number(v.price); | |
| 98 | + const common = { meta: this.meta, sourceUrl: url, externalId: String(pr.id), rawTitle: pr.title, attributes, imageUrls: pr.images.map((i) => i.src), observedAt: raw.fetchedAt, parserVersion: PARSER_VERSION, condition: cond.condition, conditionRaw: condRaw, completeness: cond.completeness }; | |
| 99 | + out.push(makeCatalogItem({ ...common, confidence: 0.8 })); | |
| 100 | + if (Number.isFinite(price) && price > 0) { | |
| 101 | + out.push(makeListing({ ...common, price, currency: 'USD', listingType: 'fixed_price', seller: 'Trainz', location: 'US', availability: v.available ? 'available' : 'sold', listedAt: pr.created_at ? new Date(pr.created_at) : null, quantity: 1 })); | |
| 102 | + } | |
| 103 | + } | |
| 104 | + return out; | |
| 105 | + } | |
| 106 | +} | |
| 107 | + | |
| 108 | +export default (meta: ConnectorMeta) => new TrainzConnector(meta); | |
added
connectors/api/trainz/meta.json
+61 −0
@@ -0,0 +1,61 @@ | ||
| 1 | +{ | |
| 2 | + "id": "trainz", | |
| 3 | + "displayName": "Trainz (model train dealer)", | |
| 4 | + "sourceId": "trainz", | |
| 5 | + "sourceName": "Trainz.com", | |
| 6 | + "sourceType": "dealer", | |
| 7 | + "sourceUrl": "https://www.trainz.com", | |
| 8 | + "module": "api/trainz", | |
| 9 | + "enginePriority": [ | |
| 10 | + "api" | |
| 11 | + ], | |
| 12 | + "categories": [ | |
| 13 | + "model_trains" | |
| 14 | + ], | |
| 15 | + "regions": [ | |
| 16 | + "US" | |
| 17 | + ], | |
| 18 | + "languages": [ | |
| 19 | + "en" | |
| 20 | + ], | |
| 21 | + "currency": [ | |
| 22 | + "USD" | |
| 23 | + ], | |
| 24 | + "supportsListings": true, | |
| 25 | + "supportsSold": false, | |
| 26 | + "supportsAuctions": false, | |
| 27 | + "supportsImages": true, | |
| 28 | + "supportsCatalog": true, | |
| 29 | + "supportsPopulation": false, | |
| 30 | + "supportsLookup": false, | |
| 31 | + "refreshFrequencyMinutes": 1440, | |
| 32 | + "priority": "low", | |
| 33 | + "trustScore": 0.7, | |
| 34 | + "attributionRequired": true, | |
| 35 | + "termsUrl": "https://www.trainz.com/pages/terms-of-service", | |
| 36 | + "accessNotes": "Public Shopify JSON feed (/collections/<handle>/products.json?limit=250&page=N; robots.txt allows) — the same data the storefront renders. Each product gives title (with Trainz's condition code suffix such as LN/Box, EX/Box), vendor, tags (condition:, class:, era:, Inventory Type2_), variant price/SKU/availability and images. Emits a catalog item (brand = vendor, scale/gauge when present) and a fixed-price dealer listing with the condition mapped to the boxed_toys scale. 1.5 s politeness, 0 credits; collections rotate through the configured seed list each run.", | |
| 37 | + "enabled": true, | |
| 38 | + "schemaVersion": "1.0", | |
| 39 | + "config": { | |
| 40 | + "collectionsPerRun": 4, | |
| 41 | + "pagesPerCollection": 2, | |
| 42 | + "collections": [ | |
| 43 | + "lionel-o-postwar-trains", | |
| 44 | + "lionel-o-prewar-trains", | |
| 45 | + "lionel-standard-gauge-trains", | |
| 46 | + "american-flyer-postwar-trains", | |
| 47 | + "american-flyer-s-gauge", | |
| 48 | + "marklin-trains", | |
| 49 | + "mth-o-gauge-trains", | |
| 50 | + "brass-model-trains", | |
| 51 | + "lgb-trains", | |
| 52 | + "k-line", | |
| 53 | + "williams", | |
| 54 | + "kato-trains", | |
| 55 | + "bachmann-trains", | |
| 56 | + "atlas-o-gauge", | |
| 57 | + "lionel-o-gauge-passenger-cars", | |
| 58 | + "lionel-o-gauge-steam-locomotives" | |
| 59 | + ] | |
| 60 | + } | |
| 61 | +} | |
added
connectors/firecrawl/bbts/_smoke.ts
+7 −0
@@ -0,0 +1,7 @@ | ||
| 1 | +import path from 'node:path'; | |
| 2 | +import { fileURLToPath } from 'node:url'; | |
| 3 | +import { captureFixture, runSmoke } from '../_carlib/smoke.js'; | |
| 4 | + | |
| 5 | +const dir = path.dirname(fileURLToPath(import.meta.url)); | |
| 6 | +if (process.argv.includes('--save')) await captureFixture(dir, process.argv[process.argv.indexOf('--save') + 1] ?? 'search-page', 4); | |
| 7 | +else await runSmoke(dir, Number(process.argv[2] ?? 1)); | |
added
connectors/firecrawl/bbts/index.test.ts
+66 −0
@@ -0,0 +1,66 @@ | ||
| 1 | +import { describe, expect, it } from 'vitest'; | |
| 2 | +import { readFileSync } from 'node:fs'; | |
| 3 | +import path from 'node:path'; | |
| 4 | +import { fileURLToPath } from 'node:url'; | |
| 5 | +import { loadFixture, runFixtureSuite } from '@rareindex/connectors/testing'; | |
| 6 | +import { localMeta } from '../../api/_lib/local-meta.js'; | |
| 7 | +import createConnector, { bbtsCategory, parseSearchMarkdown } from './index.js'; | |
| 8 | + | |
| 9 | +const dir = path.dirname(fileURLToPath(import.meta.url)); | |
| 10 | +const meta = localMeta(JSON.parse(readFileSync(path.join(dir, 'meta.json'), 'utf8'))); | |
| 11 | +const connector = createConnector(meta); | |
| 12 | + | |
| 13 | +const MD = `Hide In Stock | |
| 14 | + | |
| 15 | +## Product Results | |
| 16 | + | |
| 17 | +-  | |
| 18 | + | |
| 19 | +### [Declaration of War MJZ-05 Dark Shadow 1/100 Scale Limited Edition Action Figure](https://www.bigbadtoystore.com/product/declaration-war-mjz-05-dark-shadow-1_100-scale-action-figure-197167?variation=388429) | |
| 20 | + | |
| 21 | +By: Hot General | |
| 22 | + | |
| 23 | +PRE-ORDER | |
| 24 | + | |
| 25 | +$118.99 | |
| 26 | + | |
| 27 | +-  | |
| 28 | + | |
| 29 | +### [Pop! Marvel Deadpool Vinyl Figure - BBTS Exclusive](https://www.bigbadtoystore.com/product/pop-marvel-deadpool-197181?variation=388500) | |
| 30 | + | |
| 31 | +By: Funko | |
| 32 | + | |
| 33 | +SOLD OUT | |
| 34 | + | |
| 35 | +$14.99 | |
| 36 | +`; | |
| 37 | + | |
| 38 | +describe('bbts', () => { | |
| 39 | + runFixtureSuite(connector, it, expect); | |
| 40 | + | |
| 41 | + it('parses product cards from the rendered search markdown', () => { | |
| 42 | + const p = parseSearchMarkdown(MD, 'hot toys', 1); | |
| 43 | + expect(p.items.length).toBe(2); | |
| 44 | + expect(p.items[0]).toMatchObject({ productId: '197167', variation: '388429', brand: 'Hot General', status: 'PRE-ORDER', price: 118.99, url: 'https://www.bigbadtoystore.com/product/declaration-war-mjz-05-dark-shadow-1_100-scale-action-figure-197167' }); | |
| 45 | + expect(p.items[1]).toMatchObject({ productId: '197181', brand: 'Funko', status: 'SOLD OUT', price: 14.99 }); | |
| 46 | + expect(bbtsCategory(p.items[1]!.title, 'Funko')).toBe('funko'); | |
| 47 | + expect(bbtsCategory('MG 1/100 RX-78-2 Gundam Ver. 3.0 Model Kit', 'Bandai')).toBe('gundam'); | |
| 48 | + expect(bbtsCategory('BE@RBRICK KAWS 1000%', 'Medicom')).toBe('designer_toys'); | |
| 49 | + }); | |
| 50 | + | |
| 51 | + it('normalises to catalog items + retailer listings with stock-aware availability', async () => { | |
| 52 | + const out = await connector.normalize({ url: 'x', externalId: 'q:1', kind: 'listing', engine: 'firecrawl', fetchedAt: new Date('2026-09-07T00:00:00Z'), payload: parseSearchMarkdown(MD, 'hot toys', 1) }); | |
| 53 | + expect(out.map((r) => r.kind)).toEqual(['catalog_item', 'listing', 'catalog_item', 'listing']); | |
| 54 | + const soldOut = out[3]!; | |
| 55 | + if (soldOut.kind !== 'listing') throw new Error('listing'); | |
| 56 | + expect(soldOut.availability).toBe('ended'); | |
| 57 | + expect(soldOut.attributes.categorySlug).toBe('funko'); | |
| 58 | + const pre = out[1]!; | |
| 59 | + if (pre.kind !== 'listing') throw new Error('listing'); | |
| 60 | + expect(pre).toMatchObject({ price: 118.99, currency: 'USD', availability: 'available', seller: 'BigBadToyStore' }); | |
| 61 | + }); | |
| 62 | + | |
| 63 | + it('fixture normalises', async () => { | |
| 64 | + expect((await connector.normalize(loadFixture('bbts', 'search-page').raw)).length).toBeGreaterThan(0); | |
| 65 | + }); | |
| 66 | +}); | |
added
connectors/firecrawl/bbts/index.ts
+109 −0
@@ -0,0 +1,109 @@ | ||
| 1 | +import { z } from 'zod'; | |
| 2 | +import { BaseConnector, type ConnectorMeta, type CrawlContext, type RawRecordInput, type RawRecordLike } from '@rareindex/connectors'; | |
| 3 | +import type { NormalizedRecord } from '@rareindex/shared'; | |
| 4 | +import { lotAttributes, makeCatalogItem, makeListing, money } from '../../api/_memorabilia-lib/index.js'; | |
| 5 | +import { md, splitMarkdownItems } from '../_carlib/index.js'; | |
| 6 | + | |
| 7 | +const BASE = 'https://www.bigbadtoystore.com'; | |
| 8 | +const PARSER_VERSION = '1.0.0'; | |
| 9 | + | |
| 10 | +export const ItemSchema = z.object({ productId: z.string(), variation: z.string().nullable(), title: z.string(), url: z.string(), image: z.string().nullable(), brand: z.string().nullable(), status: z.string().nullable(), price: z.number().nullable(), listPrice: z.number().nullable() }); | |
| 11 | +export const PayloadSchema = z.object({ kind: z.literal('search_page'), query: z.string(), page: z.number(), items: z.array(ItemSchema) }); | |
| 12 | +export type Payload = z.infer<typeof PayloadSchema>; | |
| 13 | + | |
| 14 | +/** Firecrawl markdown of a search/department page → product cards. */ | |
| 15 | +export function parseSearchMarkdown(markdown: string, query: string, page: number): Payload { | |
| 16 | + const start = markdown.indexOf('## Product Results'); | |
| 17 | + const body = start >= 0 ? markdown.slice(start) : markdown; | |
| 18 | + const chunks = splitMarkdownItems(body, /^- !\[/m); | |
| 19 | + const items: Payload['items'] = []; | |
| 20 | + for (const c of chunks) { | |
| 21 | + const link = c.match(/###\s*\[([^\]]+)\]\((https:\/\/www\.bigbadtoystore\.com\/product\/[^)\s]+)\)/); | |
| 22 | + if (!link) continue; | |
| 23 | + const url = link[2]!; | |
| 24 | + const productId = url.match(/-(\d+)(?:\?|$)/)?.[1] ?? url.match(/product\/[^/]*?(\d+)/)?.[1]; | |
| 25 | + if (!productId) continue; | |
| 26 | + const prices = [...c.matchAll(/\$([\d,]+\.\d{2})/g)].map((m) => money(`$${m[1]}`, 'USD')?.amount ?? null).filter((n): n is number => n !== null); | |
| 27 | + const status = c.match(/\b(PRE-ORDER|IN STOCK|SOLD OUT|WAITLIST|BACKORDER|COMING SOON|LOW STOCK)\b/i)?.[1]?.toUpperCase() ?? null; | |
| 28 | + items.push({ | |
| 29 | + productId, | |
| 30 | + variation: url.match(/variation=(\d+)/)?.[1] ?? null, | |
| 31 | + title: md.clean(link[1]!), | |
| 32 | + url: url.split('?')[0]!, | |
| 33 | + image: md.image(c), | |
| 34 | + brand: c.match(/By:\s*([^\n]+)/)?.[1]?.trim() ?? null, | |
| 35 | + status, | |
| 36 | + price: prices.length ? Math.min(...prices) : null, | |
| 37 | + listPrice: prices.length > 1 ? Math.max(...prices) : null, | |
| 38 | + }); | |
| 39 | + } | |
| 40 | + return { kind: 'search_page', query, page, items }; | |
| 41 | +} | |
| 42 | + | |
| 43 | +export function bbtsCategory(title: string, brand: string | null): string { | |
| 44 | + const t = `${title} ${brand ?? ''}`.toLowerCase(); | |
| 45 | + if (/\bfunko\b|\bpop!\b|\bsoda\b.*funko/.test(t)) return 'funko'; | |
| 46 | + if (/\b(gundam|gunpla|zaku|master grade|real grade|high grade|perfect grade|\bhg\b|\bmg\b|\brg\b|\bpg\b)\b/.test(t)) return 'gundam'; | |
| 47 | + if (/\b(bearbrick|be@rbrick|medicom|kaws|pop mart|labubu|kidrobot|superplastic|mighty jaxx|designer toy|vinyl figure|art toy)\b/.test(t)) return 'designer_toys'; | |
| 48 | + if (/\b(lego)\b/.test(t)) return 'lego_sets'; | |
| 49 | + if (/\b(plush|plushie)\b/.test(t)) return 'plush'; | |
| 50 | + if (/\b(model kit|1\/24|1\/18 scale|diecast|die-cast)\b/.test(t)) return 'model_cars'; | |
| 51 | + return 'action_figures'; | |
| 52 | +} | |
| 53 | + | |
| 54 | +/** | |
| 55 | + * BigBadToyStore (major US collectibles retailer). Search/department pages rendered through | |
| 56 | + * Firecrawl (plain HTTP gets a bot challenge; not bypassed): title, brand line, stock status and | |
| 57 | + * price per product. Emits catalog items (retail reference) and retailer listings. | |
| 58 | + */ | |
| 59 | +export class BbtsConnector extends BaseConnector { | |
| 60 | + readonly version = '1.0.0'; | |
| 61 | + readonly parserVersion = PARSER_VERSION; | |
| 62 | + protected override minIntervalMs = 2000; | |
| 63 | + | |
| 64 | + async *crawl(ctx: CrawlContext): AsyncIterable<RawRecordInput> { | |
| 65 | + const seeds = (this.meta.config.queries as string[] | undefined) ?? ['hot toys']; | |
| 66 | + const pages = Number(this.meta.config.pagesPerQuery ?? 1); | |
| 67 | + const perRun = Number(this.meta.config.queriesPerRun ?? 4); | |
| 68 | + const start = Number(ctx.options.cursor?.seedIndex ?? 0) % seeds.length; | |
| 69 | + let count = 0; | |
| 70 | + for (let k = 0; k < Math.min(perRun, seeds.length); k++) { | |
| 71 | + const q = seeds[(start + k) % seeds.length]!; | |
| 72 | + for (let page = 1; page <= pages; page++) { | |
| 73 | + if (ctx.signal?.aborted || this.reached(ctx, count)) break; | |
| 74 | + const url = `${BASE}/Search?SearchText=${encodeURIComponent(q)}&PageSize=50&SortOrder=NewAndPopular${page > 1 ? `&PageIndex=${page}` : ''}`; | |
| 75 | + await this.throttle(); | |
| 76 | + const res = await ctx.fetch(url, { engines: ['firecrawl'], expect: ['title', 'price', 'status'], parse: (r) => { const p = r.markdown ? parseSearchMarkdown(r.markdown, q, page).items[0] : null; return p ? { title: p.title, price: p.price, status: p.status } : null; } }); | |
| 77 | + if (!res.success || !res.markdown) { | |
| 78 | + ctx.anomaly('page_fetch_failed', `${url}: ${res.error ?? res.httpStatus}`); | |
| 79 | + break; | |
| 80 | + } | |
| 81 | + const payload = parseSearchMarkdown(res.markdown, q, page); | |
| 82 | + if (payload.items.length === 0) break; | |
| 83 | + count++; | |
| 84 | + yield { url, externalId: `${q}:${page}`, kind: 'listing', engine: res.engine, httpStatus: res.httpStatus, payload, fetchedAt: res.fetchedAt }; | |
| 85 | + if (payload.items.length < 50) break; | |
| 86 | + } | |
| 87 | + } | |
| 88 | + await ctx.setCursor({ seedIndex: (start + perRun) % seeds.length, updatedAt: new Date().toISOString() }); | |
| 89 | + } | |
| 90 | + | |
| 91 | + async normalize(raw: RawRecordLike): Promise<NormalizedRecord[]> { | |
| 92 | + const p = PayloadSchema.parse(raw.payload); | |
| 93 | + const out: NormalizedRecord[] = []; | |
| 94 | + for (const it of p.items) { | |
| 95 | + const slug = bbtsCategory(it.title, it.brand); | |
| 96 | + const attributes = lotAttributes({ categorySlug: slug, name: it.title, brand: it.brand, identifiers: { bbts_product_id: it.productId }, metadata: { status: it.status, list_price: it.listPrice, variation: it.variation, query: p.query } }); | |
| 97 | + if (it.listPrice && it.status && /PRE-ORDER|IN STOCK/.test(it.status)) attributes.originalMsrp = it.listPrice, (attributes.originalMsrpCurrency = 'USD'); | |
| 98 | + const common = { meta: this.meta, sourceUrl: it.url, externalId: it.productId, rawTitle: it.title, attributes, imageUrls: it.image ? [it.image] : [], observedAt: raw.fetchedAt, parserVersion: PARSER_VERSION, condition: 'mint_in_box', conditionRaw: 'New', completeness: 'sealed' }; | |
| 99 | + out.push(makeCatalogItem({ ...common, confidence: 0.8 })); | |
| 100 | + if (it.price !== null && it.price > 0) { | |
| 101 | + const availability = it.status === 'SOLD OUT' ? 'ended' : it.status === 'WAITLIST' ? 'unknown' : 'available'; | |
| 102 | + out.push(makeListing({ ...common, price: it.price, currency: 'USD', listingType: 'fixed_price', seller: 'BigBadToyStore', location: 'US', availability, quantity: null })); | |
| 103 | + } | |
| 104 | + } | |
| 105 | + return out; | |
| 106 | + } | |
| 107 | +} | |
| 108 | + | |
| 109 | +export default (meta: ConnectorMeta) => new BbtsConnector(meta); | |
added
connectors/firecrawl/bbts/meta.json
+34 −0
@@ -0,0 +1,34 @@ | ||
| 1 | +{ | |
| 2 | + "id": "bbts", | |
| 3 | + "displayName": "BigBadToyStore (retail listings)", | |
| 4 | + "sourceId": "bbts", | |
| 5 | + "sourceName": "BigBadToyStore", | |
| 6 | + "sourceType": "dealer", | |
| 7 | + "sourceUrl": "https://www.bigbadtoystore.com", | |
| 8 | + "module": "firecrawl/bbts", | |
| 9 | + "enginePriority": ["firecrawl"], | |
| 10 | + "categories": ["action_figures", "funko", "gundam", "designer_toys", "plush", "model_cars", "lego_sets"], | |
| 11 | + "regions": ["US"], | |
| 12 | + "languages": ["en"], | |
| 13 | + "currency": ["USD"], | |
| 14 | + "supportsListings": true, | |
| 15 | + "supportsSold": false, | |
| 16 | + "supportsAuctions": false, | |
| 17 | + "supportsImages": true, | |
| 18 | + "supportsCatalog": true, | |
| 19 | + "supportsPopulation": false, | |
| 20 | + "supportsLookup": false, | |
| 21 | + "refreshFrequencyMinutes": 1440, | |
| 22 | + "priority": "low", | |
| 23 | + "trustScore": 0.7, | |
| 24 | + "attributionRequired": true, | |
| 25 | + "termsUrl": "https://www.bigbadtoystore.com/Help/Terms", | |
| 26 | + "accessNotes": "Search pages (/Search?SearchText=…&PageSize=50) rendered through Firecrawl at 1 credit per page of 50 products; plain HTTP receives the store's bot challenge page, which is not bypassed — Firecrawl renders the public page like a browser and robots.txt is not served to non-browser agents (403), so runs stay tiny (4 queries/run, 2 s politeness). Each card gives title, 'By: brand', stock status (PRE-ORDER / IN STOCK / SOLD OUT / WAITLIST) and price. Emits a catalog item (retail price as MSRP when in stock/pre-order) and a retailer listing. Retail asks are never treated as market value.", | |
| 27 | + "enabled": true, | |
| 28 | + "schemaVersion": "1.0", | |
| 29 | + "config": { | |
| 30 | + "queriesPerRun": 4, | |
| 31 | + "pagesPerQuery": 1, | |
| 32 | + "queries": ["hot toys", "s.h. figuarts", "mafex", "funko pop exclusive", "gunpla master grade", "bearbrick", "hasbro black series", "neca", "mezco one:12", "transformers masterpiece", "mcfarlane", "pop mart"] | |
| 33 | + } | |
| 34 | +} | |
added
connectors/firecrawl/lelands/_smoke.ts
+7 −0
@@ -0,0 +1,7 @@ | ||
| 1 | +import path from 'node:path'; | |
| 2 | +import { fileURLToPath } from 'node:url'; | |
| 3 | +import { captureFixture, runSmoke } from '../_carlib/smoke.js'; | |
| 4 | + | |
| 5 | +const dir = path.dirname(fileURLToPath(import.meta.url)); | |
| 6 | +if (process.argv.includes('--save')) await captureFixture(dir, process.argv[process.argv.indexOf('--save') + 1] ?? 'gallery-page', 4); | |
| 7 | +else await runSmoke(dir, Number(process.argv[2] ?? 1)); | |
added
connectors/firecrawl/lelands/index.test.ts
+53 −0
@@ -0,0 +1,53 @@ | ||
| 1 | +import { describe, expect, it } from 'vitest'; | |
| 2 | +import { readFileSync } from 'node:fs'; | |
| 3 | +import path from 'node:path'; | |
| 4 | +import { fileURLToPath } from 'node:url'; | |
| 5 | +import { loadFixture, runFixtureSuite } from '@rareindex/connectors/testing'; | |
| 6 | +import { localMeta } from '../../api/_lib/local-meta.js'; | |
| 7 | +import createConnector, { parseGallery } from './index.js'; | |
| 8 | + | |
| 9 | +const dir = path.dirname(fileURLToPath(import.meta.url)); | |
| 10 | +const meta = localMeta(JSON.parse(readFileSync(path.join(dir, 'meta.json'), 'utf8'))); | |
| 11 | +const connector = createConnector(meta); | |
| 12 | + | |
| 13 | +const PAGE = `<html><body> | |
| 14 | +<div class="sidebar-widget"><h5 class="title"><span class="closed">•</span> 2026 Summer Classic </h5><p> Start: 7/26/2026 12:00 PM EST <br> End: 8/15/2026 10:00 PM EST </p><p><span style="color:red;">Prices Shown Include Buyer's Premium.</span></p></div> | |
| 15 | +<select name="ctl00$Auction"><option value="-1">All Auctions</option><option selected="selected" value="1008">2026 Summer Classic</option><option value="1005">2026 Spring Classic</option></select> | |
| 16 | +<div class="item"><h5 class="boxed">1</h5><div class="item-details clearfix"><p class="description"><a href="https://auction.lelands.com/bids/bidplace.aspx?itemid=135734">Late 1930s Lou Gehrig Single-Signed Baseball (PSA)</a></p><div class="item-image"><a href="#"><img src="https://auction.lelands.com/images_items/thumbs/thumb_item_135734_1.jpg"/></a></div><p> Bids: <strong>49</strong> <br> Opening Bid: <strong>$5,000</strong> <br> Status: <strong>Sold</strong> </p></div><div class="item-price"><a href="#">SOLD FOR $92,050</a></div></div> | |
| 17 | +<div class="item"><h5 class="boxed">2</h5><div class="item-details clearfix"><p class="description"><a href="https://auction.lelands.com/bids/bidplace.aspx?itemid=135794">Signed 1949 Bowman Baseball #82 Joe Page Card (PSA)</a></p><p> Bids: <strong>0</strong> <br> Opening Bid: <strong>$300</strong> <br> Status: <strong>Unsold</strong> </p></div><div class="item-price"></div></div> | |
| 18 | +<ul class="pagination pagination-sm"><li class="active"><a href="?size=250&page=1">1</a></li><li><a href="?size=250&page=2">2</a></li><li><a href="?size=250&page=5">5</a></li></ul> | |
| 19 | +</body></html>`; | |
| 20 | + | |
| 21 | +describe('lelands', () => { | |
| 22 | + runFixtureSuite(connector, it, expect); | |
| 23 | + | |
| 24 | + it('parses the gallery: auction dates, premium note, items and pagination', () => { | |
| 25 | + const p = parseGallery(PAGE, 1); | |
| 26 | + expect(p.auction.id).toBe('1008'); | |
| 27 | + expect(p.auction.name).toContain('2026 Summer Classic'); | |
| 28 | + expect(p.auction.endText).toBe('8/15/2026 10:00 PM EST'); | |
| 29 | + expect(p.auction.premiumIncluded).toBe(true); | |
| 30 | + expect(p.totalPages).toBe(5); | |
| 31 | + expect(p.items[0]).toMatchObject({ itemId: '135734', bids: 49, openingBid: 5000, status: 'Sold', soldPrice: 92050, lotNumber: '1' }); | |
| 32 | + expect(p.items[1]!.soldPrice).toBeNull(); | |
| 33 | + }); | |
| 34 | + | |
| 35 | + it('normalises sold lots dated by the auction end with premium included', async () => { | |
| 36 | + const out = await connector.normalize({ url: 'x', externalId: 'a', kind: 'sale', engine: 'firecrawl', fetchedAt: new Date('2026-09-07T00:00:00Z'), payload: parseGallery(PAGE, 1) }); | |
| 37 | + expect(out.length).toBe(1); | |
| 38 | + const s = out[0]!; | |
| 39 | + if (s.kind !== 'sale') throw new Error('sale'); | |
| 40 | + expect(s).toMatchObject({ price: 92050, currency: 'USD', buyerPremiumIncluded: true, auctionHouse: 'Lelands', lotNumber: '1' }); | |
| 41 | + expect(s.saleDate.toISOString()).toBe('2026-08-15T00:00:00.000Z'); | |
| 42 | + expect(s.attributes.categorySlug).toBe('sports_memorabilia'); | |
| 43 | + }); | |
| 44 | + | |
| 45 | + it('emits nothing while the displayed auction is still open', async () => { | |
| 46 | + const future = PAGE.replace('End: 8/15/2026', 'End: 8/15/2099'); | |
| 47 | + expect(await connector.normalize({ url: 'x', externalId: 'a', kind: 'sale', engine: 'firecrawl', fetchedAt: new Date(), payload: parseGallery(future, 1) })).toEqual([]); | |
| 48 | + }); | |
| 49 | + | |
| 50 | + it('fixture normalises', async () => { | |
| 51 | + expect((await connector.normalize(loadFixture('lelands', 'gallery-page').raw)).length).toBeGreaterThan(0); | |
| 52 | + }); | |
| 53 | +}); | |
added
connectors/firecrawl/lelands/index.ts
+109 −0
@@ -0,0 +1,109 @@ | ||
| 1 | +import { z } from 'zod'; | |
| 2 | +import { BaseConnector, html as H, type ConnectorMeta, type CrawlContext, type RawRecordInput, type RawRecordLike } from '@rareindex/connectors'; | |
| 3 | +import type { NormalizedRecord } from '@rareindex/shared'; | |
| 4 | +import { isBundleTitle, safeYear } from '../../api/_auction-lib/categories.js'; | |
| 5 | +import { gradeOf, lotAttributes, money, selectedOption, sportsCategory } from '../../api/_memorabilia-lib/index.js'; | |
| 6 | +import { dateMDY, makeSale } from '../_carlib/index.js'; | |
| 7 | + | |
| 8 | +const BASE = 'https://auction.lelands.com'; | |
| 9 | +const PARSER_VERSION = '1.1.0'; | |
| 10 | + | |
| 11 | +export const ItemSchema = z.object({ itemId: z.string(), title: z.string(), url: z.string(), image: z.string().nullable(), bids: z.number().nullable(), openingBid: z.number().nullable(), status: z.string().nullable(), soldPrice: z.number().nullable(), lotNumber: z.string().nullable() }); | |
| 12 | +export const PayloadSchema = z.object({ kind: z.literal('gallery_page'), auction: z.object({ id: z.string().nullable(), name: z.string(), startText: z.string().nullable(), endText: z.string().nullable(), premiumIncluded: z.boolean() }), page: z.number(), totalPages: z.number().nullable(), items: z.array(ItemSchema) }); | |
| 13 | +export type Payload = z.infer<typeof PayloadSchema>; | |
| 14 | + | |
| 15 | +export function parseGallery(htmlText: string, page: number): Payload { | |
| 16 | + const $ = H.load(htmlText); | |
| 17 | + const side = $('.sidebar-widget').first(); | |
| 18 | + const name = H.text(side.find('h5.title')) ?? ''; | |
| 19 | + const sideText = H.text(side) ?? ''; | |
| 20 | + const startText = sideText.match(/Start:\s*(\d{1,2}\/\d{1,2}\/\d{4}[^E]*E[SD]T)/)?.[1] ?? null; | |
| 21 | + const endText = sideText.match(/End:\s*(\d{1,2}\/\d{1,2}\/\d{4}[^E]*E[SD]T)/)?.[1] ?? null; | |
| 22 | + const premiumIncluded = /Prices Shown Include Buyer'?s Premium/i.test(sideText); | |
| 23 | + const items: Payload['items'] = []; | |
| 24 | + const seen = new Set<string>(); | |
| 25 | + $('.item').each((_, el) => { | |
| 26 | + const it = $(el); | |
| 27 | + const link = it.find('p.description a').first(); | |
| 28 | + const href = link.attr('href'); | |
| 29 | + const title = H.text(link); | |
| 30 | + const itemId = href?.match(/itemid=(\d+)/i)?.[1]; | |
| 31 | + if (!href || !title || !itemId || seen.has(itemId)) return; | |
| 32 | + seen.add(itemId); | |
| 33 | + const meta = H.text(it.find('.item-details > p').last()) ?? ''; | |
| 34 | + const bids = meta.match(/Bids:\s*(\d+)/)?.[1]; | |
| 35 | + const opening = meta.match(/Opening Bid:\s*\$([\d,]+)/)?.[1]; | |
| 36 | + const status = meta.match(/Status:\s*([A-Za-z ]+)/)?.[1]?.trim() ?? null; | |
| 37 | + const priceText = H.text(it.find('.item-price')) ?? ''; | |
| 38 | + const sold = priceText.match(/SOLD FOR\s*\$([\d,]+(?:\.\d+)?)/i)?.[1]; | |
| 39 | + items.push({ itemId, title, url: `${BASE}/bids/bidplace.aspx?itemid=${itemId}`, image: it.find('.item-image img').attr('src') ?? null, bids: bids ? Number(bids) : null, openingBid: opening ? money(`$${opening}`, 'USD')?.amount ?? null : null, status, soldPrice: sold ? money(`$${sold}`, 'USD')?.amount ?? null : null, lotNumber: H.text(it.find('h5.boxed')) }); | |
| 40 | + }); | |
| 41 | + const pages = $('ul.pagination a[href*="page="]') | |
| 42 | + .map((_, a) => Number(($(a).attr('href') ?? '').match(/page=(\d+)/)?.[1] ?? 0)) | |
| 43 | + .get() | |
| 44 | + .filter((n) => n > 0); | |
| 45 | + return { kind: 'gallery_page', auction: { id: selectedOption(htmlText, 'ctl00$Auction'), name, startText, endText, premiumIncluded }, page, totalPages: pages.length ? Math.max(...pages) : null, items }; | |
| 46 | +} | |
| 47 | + | |
| 48 | +/** | |
| 49 | + * Lelands (sports memorabilia & cards). The public gallery auction.lelands.com/Lots/Gallery?size=250&page=N | |
| 50 | + * lists the currently displayed auction's lots with "SOLD FOR $X"; the sidebar gives start/end and states that | |
| 51 | + * prices include the buyer's premium. Direct requests receive a Cloudflare 403, so the pages are rendered | |
| 52 | + * through Firecrawl (a browser render of the public page — no login, no bidding). Past auctions are only | |
| 53 | + * reachable through a form postback, which is not attempted; each auction is captured while it is the | |
| 54 | + * displayed one (lots stay marked SOLD after the close). | |
| 55 | + */ | |
| 56 | +export class LelandsConnector extends BaseConnector { | |
| 57 | + readonly version = '1.1.0'; | |
| 58 | + readonly parserVersion = PARSER_VERSION; | |
| 59 | + protected override minIntervalMs = 2500; | |
| 60 | + | |
| 61 | + async *crawl(ctx: CrawlContext): AsyncIterable<RawRecordInput> { | |
| 62 | + const maxPages = Number(this.meta.config.pagesPerRun ?? 5); | |
| 63 | + const doneAuctions = new Set<string>(Array.isArray(ctx.options.cursor?.doneAuctions) ? (ctx.options.cursor!.doneAuctions as string[]) : []); | |
| 64 | + let count = 0; | |
| 65 | + let auctionId: string | null = null; | |
| 66 | + for (let page = 1; page <= maxPages; page++) { | |
| 67 | + if (ctx.signal?.aborted || this.reached(ctx, count)) break; | |
| 68 | + const url = `${BASE}/Lots/Gallery?size=250${page > 1 ? `&page=${page}` : ''}`; | |
| 69 | + await this.throttle(); | |
| 70 | + const res = await ctx.fetch(url, { engines: ['firecrawl'], expect: ['title', 'price', 'status', 'date'], parse: (r) => { const p = r.html ? parseGallery(r.html, page) : null; const f = p?.items.find((i) => i.soldPrice); return p ? { title: f?.title ?? p.items[0]?.title ?? null, price: f?.soldPrice ?? null, status: f?.status ?? null, date: p.auction.endText } : null; } }); | |
| 71 | + if (!res.success || !res.html) { | |
| 72 | + ctx.anomaly('page_fetch_failed', `${url}: ${res.error ?? res.httpStatus}`); | |
| 73 | + break; | |
| 74 | + } | |
| 75 | + const payload = parseGallery(res.html, page); | |
| 76 | + if (page === 1) { | |
| 77 | + auctionId = payload.auction.id; | |
| 78 | + if (!payload.auction.endText) ctx.anomaly('missing_auction_end', payload.auction.name); | |
| 79 | + // Closed auctions are crawled once; the live one is refreshed each run. | |
| 80 | + if (auctionId && doneAuctions.has(auctionId)) break; | |
| 81 | + } | |
| 82 | + if (payload.items.length === 0) break; | |
| 83 | + count++; | |
| 84 | + yield { url, externalId: `auction:${payload.auction.id ?? 'current'}:page:${page}`, kind: 'sale', engine: res.engine, httpStatus: res.httpStatus, payload, fetchedAt: res.fetchedAt }; | |
| 85 | + if (payload.totalPages !== null && page >= payload.totalPages) { | |
| 86 | + const end = dateMDY(payload.auction.endText); | |
| 87 | + if (auctionId && end && end.getTime() < Date.now()) doneAuctions.add(auctionId); | |
| 88 | + break; | |
| 89 | + } | |
| 90 | + } | |
| 91 | + await ctx.setCursor({ doneAuctions: [...doneAuctions].slice(-100), updatedAt: new Date().toISOString() }); | |
| 92 | + } | |
| 93 | + | |
| 94 | + async normalize(raw: RawRecordLike): Promise<NormalizedRecord[]> { | |
| 95 | + const p = PayloadSchema.parse(raw.payload); | |
| 96 | + const saleDate = dateMDY(p.auction.endText); | |
| 97 | + const out: NormalizedRecord[] = []; | |
| 98 | + if (!saleDate || saleDate.getTime() > Date.now()) return out; // auction still open → no realized prices yet | |
| 99 | + for (const it of p.items) { | |
| 100 | + if (it.soldPrice === null || it.soldPrice <= 0 || !/sold/i.test(it.status ?? '')) continue; | |
| 101 | + const g = gradeOf(it.title); | |
| 102 | + const attributes = lotAttributes({ categorySlug: sportsCategory(it.title), name: it.title, year: safeYear(it.title), identifiers: { lelands_item_id: it.itemId }, metadata: { auction_id: p.auction.id, auction_name: p.auction.name, bids: it.bids, opening_bid: it.openingBid } }); | |
| 103 | + out.push(makeSale({ meta: this.meta, sourceUrl: it.url, externalId: it.itemId, rawTitle: it.title, attributes, price: it.soldPrice, currency: 'USD', saleDate, buyerPremiumIncluded: p.auction.premiumIncluded ? true : null, auctionHouse: 'Lelands', lotNumber: it.lotNumber, imageUrls: it.image ? [it.image] : [], observedAt: raw.fetchedAt, parserVersion: PARSER_VERSION, grader: g.grader, grade: g.grade, location: 'US', isBundle: isBundleTitle(it.title) })); | |
| 104 | + } | |
| 105 | + return out; | |
| 106 | + } | |
| 107 | +} | |
| 108 | + | |
| 109 | +export default (meta: ConnectorMeta) => new LelandsConnector(meta); | |
added
connectors/firecrawl/lelands/meta.json
+49 −0
@@ -0,0 +1,49 @@ | ||
| 1 | +{ | |
| 2 | + "id": "lelands", | |
| 3 | + "displayName": "Lelands (auction results)", | |
| 4 | + "sourceId": "lelands", | |
| 5 | + "sourceName": "Lelands", | |
| 6 | + "sourceType": "auction_house", | |
| 7 | + "sourceUrl": "https://auction.lelands.com", | |
| 8 | + "module": "firecrawl/lelands", | |
| 9 | + "enginePriority": [ | |
| 10 | + "firecrawl" | |
| 11 | + ], | |
| 12 | + "categories": [ | |
| 13 | + "sports_memorabilia", | |
| 14 | + "baseball_cards", | |
| 15 | + "basketball_cards", | |
| 16 | + "football_cards", | |
| 17 | + "hockey_cards", | |
| 18 | + "soccer_cards", | |
| 19 | + "other_sports_cards", | |
| 20 | + "olympic_collectibles" | |
| 21 | + ], | |
| 22 | + "regions": [ | |
| 23 | + "US" | |
| 24 | + ], | |
| 25 | + "languages": [ | |
| 26 | + "en" | |
| 27 | + ], | |
| 28 | + "currency": [ | |
| 29 | + "USD" | |
| 30 | + ], | |
| 31 | + "supportsListings": false, | |
| 32 | + "supportsSold": true, | |
| 33 | + "supportsAuctions": false, | |
| 34 | + "supportsImages": true, | |
| 35 | + "supportsCatalog": false, | |
| 36 | + "supportsPopulation": false, | |
| 37 | + "supportsLookup": false, | |
| 38 | + "refreshFrequencyMinutes": 1440, | |
| 39 | + "priority": "medium", | |
| 40 | + "trustScore": 0.9, | |
| 41 | + "attributionRequired": true, | |
| 42 | + "termsUrl": "https://lelands.com/terms", | |
| 43 | + "accessNotes": "The public gallery auction.lelands.com/Lots/Gallery?size=250&page=N lists the currently displayed auction's lots with title, bids, opening bid, status and 'SOLD FOR $X'; the sidebar gives the auction start/end and states 'Prices Shown Include Buyer's Premium' → buyer_premium_included=true, sale date = auction end (records are emitted only once the end has passed). Direct HTTP requests receive a Cloudflare 403 (robots.txt itself allows 'User-agent: *'), so pages are rendered through Firecrawl at 1 credit per 250 lots — a browser render of the public page, no login, no bidding, no challenge solving. Past auctions sit behind a form postback and are not fetched; each auction is captured while it is the displayed one. 2.5 s politeness, ≤5 pages per run.", | |
| 44 | + "enabled": true, | |
| 45 | + "schemaVersion": "1.0", | |
| 46 | + "config": { | |
| 47 | + "pagesPerRun": 5 | |
| 48 | + } | |
| 49 | +} | |
added
data/fixtures/bbts/search-page.json
+69 −0
@@ -0,0 +1,69 @@ | ||
| 1 | +{ | |
| 2 | + "raw": { | |
| 3 | + "url": "https://www.bigbadtoystore.com/Search?SearchText=hot%20toys&PageSize=50&SortOrder=NewAndPopular", | |
| 4 | + "externalId": "hot toys:1", | |
| 5 | + "kind": "listing", | |
| 6 | + "engine": "firecrawl", | |
| 7 | + "fetchedAt": "2026-09-07T07:17:05.542Z", | |
| 8 | + "payload": { | |
| 9 | + "kind": "search_page", | |
| 10 | + "query": "hot toys", | |
| 11 | + "page": 1, | |
| 12 | + "items": [ | |
| 13 | + { | |
| 14 | + "productId": "197167", | |
| 15 | + "variation": "388429", | |
| 16 | + "title": "Declaration of War MJZ-05 Dark Shadow 1/100 Scale Limited Edition Action Figure", | |
| 17 | + "url": "https://www.bigbadtoystore.com/product/declaration-war-mjz-05-dark-shadow-1_100-scale-action-figure-197167", | |
| 18 | + "image": "https://images.bigbadtoystore.com/images/product/197167/e70cc2d3-1b4c-485a-be62-cef26e2d4e10/360w.png", | |
| 19 | + "brand": "Hot General", | |
| 20 | + "status": "PRE-ORDER", | |
| 21 | + "price": 118.99, | |
| 22 | + "listPrice": null | |
| 23 | + }, | |
| 24 | + { | |
| 25 | + "productId": "197181", | |
| 26 | + "variation": "388448", | |
| 27 | + "title": "Shuga Chara! Chokorin Collection Box of 6 Random Figures", | |
| 28 | + "url": "https://www.bigbadtoystore.com/product/shuga-chara-chokorin-collection-box-6-random-figures-197181", | |
| 29 | + "image": "https://images.bigbadtoystore.com/images/product/197181/0eb86aa6-da96-4bec-baca-e44d6af76740/360w.jpg", | |
| 30 | + "brand": "MegaHouse", | |
| 31 | + "status": "PRE-ORDER", | |
| 32 | + "price": 69.99, | |
| 33 | + "listPrice": null | |
| 34 | + }, | |
| 35 | + { | |
| 36 | + "productId": "197174", | |
| 37 | + "variation": "388470", | |
| 38 | + "title": "Avengers: Doomsday MMS919 Thor (Deluxe Ver.) 1/6th Scale Collectible Figure", | |
| 39 | + "url": "https://www.bigbadtoystore.com/product/avengers-doomsday-mms919-thor-deluxe-ver-1_6th-scale-collectible-figure-197174", | |
| 40 | + "image": "https://images.bigbadtoystore.com/images/product/197174/86163320-bb24-4bec-a86f-5612e2072718/360w.png", | |
| 41 | + "brand": "Hot Toys", | |
| 42 | + "status": "PRE-ORDER", | |
| 43 | + "price": 404.99, | |
| 44 | + "listPrice": null | |
| 45 | + }, | |
| 46 | + { | |
| 47 | + "productId": "197169", | |
| 48 | + "variation": "388461", | |
| 49 | + "title": "Avengers: Doomsday MMS918 Thor 1/6th Scale Collectible Figure", | |
| 50 | + "url": "https://www.bigbadtoystore.com/product/avengers-doomsday-mms918-thor-1_6th-scale-collectible-figure-197169", | |
| 51 | + "image": "https://images.bigbadtoystore.com/images/product/197169/bb8b4a05-807d-474e-a118-e44fa71663d3/360w.png", | |
| 52 | + "brand": "Hot Toys", | |
| 53 | + "status": "PRE-ORDER", | |
| 54 | + "price": 304.99, | |
| 55 | + "listPrice": null | |
| 56 | + } | |
| 57 | + ] | |
| 58 | + } | |
| 59 | + }, | |
| 60 | + "expect": { | |
| 61 | + "count": 8, | |
| 62 | + "kinds": [ | |
| 63 | + "catalog_item", | |
| 64 | + "listing" | |
| 65 | + ] | |
| 66 | + }, | |
| 67 | + "note": "Captured live from https://www.bigbadtoystore.com/Search?SearchText=hot%20toys&PageSize=50&SortOrder=NewAndPopular (lists trimmed to 4).", | |
| 68 | + "capturedAt": "2026-09-07T07:17:05.548Z" | |
| 69 | +} | |
| \ No newline at end of file | ||
added
data/fixtures/hakes/catalog-page.json
+89 −0
@@ -0,0 +1,89 @@ | ||
| 1 | +{ | |
| 2 | + "raw": { | |
| 3 | + "url": "https://www.hakes.com/auctions/hakes-auctions/september-2026-pop-culture-auction-24709/catalog", | |
| 4 | + "externalId": "event:24709:page:1", | |
| 5 | + "kind": "auction_lot", | |
| 6 | + "engine": "api", | |
| 7 | + "fetchedAt": "2026-09-07T07:17:02.112Z", | |
| 8 | + "payload": { | |
| 9 | + "kind": "catalog_page", | |
| 10 | + "house": "Hake's Auctions", | |
| 11 | + "event": { | |
| 12 | + "id": "24709", | |
| 13 | + "name": "September 2026 Pop Culture Auction", | |
| 14 | + "url": "https://www.hakes.com/auctions/hakes-auctions/september-2026-pop-culture-auction-24709", | |
| 15 | + "status": "upcoming", | |
| 16 | + "startDate": "2026-09-09 09:00:00 EDT", | |
| 17 | + "endDate": "2026-09-30 21:40:00 EDT" | |
| 18 | + }, | |
| 19 | + "page": 1, | |
| 20 | + "totalPages": 14, | |
| 21 | + "items": [ | |
| 22 | + { | |
| 23 | + "itemId": "9830652", | |
| 24 | + "url": "https://www.hakes.com/online-auctions/hakes-auctions/star-wars-1978---luke-skywalker-12-back-b-afa-85-y-nm-made-in-taiwan-9830652", | |
| 25 | + "title": "STAR WARS (1978) - LUKE SKYWALKER 12 BACK-B AFA 85 Y-NM+ (MADE IN TAIWAN).", | |
| 26 | + "lotNumber": "1", | |
| 27 | + "image": "https://s1.img.bidsquare.com/item/l/3897/38972921.jpeg?t=1X2ctt", | |
| 28 | + "priceLabel": "Starting Bid", | |
| 29 | + "price": 10, | |
| 30 | + "bids": null, | |
| 31 | + "estimateLow": 1000, | |
| 32 | + "estimateHigh": 3000, | |
| 33 | + "startsAt": 1788958800, | |
| 34 | + "endsAt": 1790818800 | |
| 35 | + }, | |
| 36 | + { | |
| 37 | + "itemId": "9830653", | |
| 38 | + "url": "https://www.hakes.com/online-auctions/hakes-auctions/star-wars-1978---ben-obi-wan-kenobi-12-back-a-afa-75-ex-nm-sku-on-footer-9830653", | |
| 39 | + "title": "STAR WARS (1978) - BEN (OBI-WAN) KENOBI 12 BACK-A AFA 75+ EX+/NM (SKU ON FOOTER).", | |
| 40 | + "lotNumber": "2", | |
| 41 | + "image": "https://s1.img.bidsquare.com/item/l/3897/38972925.jpeg?t=1X2ctu", | |
| 42 | + "priceLabel": "Starting Bid", | |
| 43 | + "price": 10, | |
| 44 | + "bids": null, | |
| 45 | + "estimateLow": 1000, | |
| 46 | + "estimateHigh": 2000, | |
| 47 | + "startsAt": 1788958800, | |
| 48 | + "endsAt": 1790818800 | |
| 49 | + }, | |
| 50 | + { | |
| 51 | + "itemId": "9830654", | |
| 52 | + "url": "https://www.hakes.com/online-auctions/hakes-auctions/star-wars-1978---princess-leia-organa-12-back-c-afa-70-ex-9830654", | |
| 53 | + "title": "STAR WARS (1978) - PRINCESS LEIA ORGANA 12 BACK-C AFA 70+ EX+.", | |
| 54 | + "lotNumber": "3", | |
| 55 | + "image": "https://s1.img.bidsquare.com/item/l/3897/38972929.jpeg?t=1X2ctu", | |
| 56 | + "priceLabel": "Starting Bid", | |
| 57 | + "price": 10, | |
| 58 | + "bids": null, | |
| 59 | + "estimateLow": 500, | |
| 60 | + "estimateHigh": 1000, | |
| 61 | + "startsAt": 1788958800, | |
| 62 | + "endsAt": 1790818800 | |
| 63 | + }, | |
| 64 | + { | |
| 65 | + "itemId": "9830655", | |
| 66 | + "url": "https://www.hakes.com/online-auctions/hakes-auctions/star-wars-1978---sand-people-tusken-raider-12-back-b-afa-85-nm-9830655", | |
| 67 | + "title": "STAR WARS (1978) - SAND PEOPLE (TUSKEN RAIDER) 12 BACK-B AFA 85 NM+.", | |
| 68 | + "lotNumber": "4", | |
| 69 | + "image": "https://s1.img.bidsquare.com/item/l/3897/38972933.jpeg?t=1X2ctu", | |
| 70 | + "priceLabel": "Starting Bid", | |
| 71 | + "price": 10, | |
| 72 | + "bids": null, | |
| 73 | + "estimateLow": 2000, | |
| 74 | + "estimateHigh": 4000, | |
| 75 | + "startsAt": 1788958800, | |
| 76 | + "endsAt": 1790818800 | |
| 77 | + } | |
| 78 | + ] | |
| 79 | + } | |
| 80 | + }, | |
| 81 | + "expect": { | |
| 82 | + "count": 4, | |
| 83 | + "kinds": [ | |
| 84 | + "auction_lot" | |
| 85 | + ] | |
| 86 | + }, | |
| 87 | + "note": "Captured live from https://www.hakes.com/auctions/hakes-auctions/september-2026-pop-culture-auction-24709/catalog (lists trimmed to 4).", | |
| 88 | + "capturedAt": "2026-09-07T07:17:02.164Z" | |
| 89 | +} | |
| \ No newline at end of file | ||
added
data/fixtures/lelands/gallery-page.json
+75 −0
@@ -0,0 +1,75 @@ | ||
| 1 | +{ | |
| 2 | + "raw": { | |
| 3 | + "url": "https://auction.lelands.com/Lots/Gallery?size=250", | |
| 4 | + "externalId": "auction:1008:page:1", | |
| 5 | + "kind": "sale", | |
| 6 | + "engine": "firecrawl", | |
| 7 | + "fetchedAt": "2026-09-07T07:18:48.811Z", | |
| 8 | + "payload": { | |
| 9 | + "kind": "gallery_page", | |
| 10 | + "auction": { | |
| 11 | + "id": "1008", | |
| 12 | + "name": "• 2026 Summer Classic", | |
| 13 | + "startText": "7/26/2026 12:00 PM EST", | |
| 14 | + "endText": "8/15/2026 10:00 PM EST", | |
| 15 | + "premiumIncluded": true | |
| 16 | + }, | |
| 17 | + "page": 1, | |
| 18 | + "totalPages": 5, | |
| 19 | + "items": [ | |
| 20 | + { | |
| 21 | + "itemId": "135734", | |
| 22 | + "title": "Late 1930s Lou Gehrig Single-Signed Baseball (PSA)", | |
| 23 | + "url": "https://auction.lelands.com/bids/bidplace.aspx?itemid=135734", | |
| 24 | + "image": "https://auction.lelands.com/images_items/thumbs/thumb_item_135734_1_499608.jpg", | |
| 25 | + "bids": 49, | |
| 26 | + "openingBid": 5000, | |
| 27 | + "status": "Sold", | |
| 28 | + "soldPrice": 92050, | |
| 29 | + "lotNumber": "1" | |
| 30 | + }, | |
| 31 | + { | |
| 32 | + "itemId": "137205", | |
| 33 | + "title": "1933 Lou Gehrig New York Yankees Player Contract (PSA)", | |
| 34 | + "url": "https://auction.lelands.com/bids/bidplace.aspx?itemid=137205", | |
| 35 | + "image": "https://auction.lelands.com/images_items/thumbs/thumb_item_137205_1_503191.jpg", | |
| 36 | + "bids": 42, | |
| 37 | + "openingBid": 10000, | |
| 38 | + "status": "Sold", | |
| 39 | + "soldPrice": 90190, | |
| 40 | + "lotNumber": "2" | |
| 41 | + }, | |
| 42 | + { | |
| 43 | + "itemId": "136964", | |
| 44 | + "title": "1996 Flair Showcase Basketball Legacy Collection Row 0 #23 Michael Jordan #24/150 PSA NM-MT+ 8.5", | |
| 45 | + "url": "https://auction.lelands.com/bids/bidplace.aspx?itemid=136964", | |
| 46 | + "image": "https://auction.lelands.com/images_items/thumbs/thumb_item_136964_1_494997.jpg", | |
| 47 | + "bids": 19, | |
| 48 | + "openingBid": 5000, | |
| 49 | + "status": "Sold", | |
| 50 | + "soldPrice": 75729, | |
| 51 | + "lotNumber": "3" | |
| 52 | + }, | |
| 53 | + { | |
| 54 | + "itemId": "134967", | |
| 55 | + "title": "1962 Wilt Chamberlain 100-Point Game Ticket Stub PSA VG-EX 4 (MK) - Highest Graded!", | |
| 56 | + "url": "https://auction.lelands.com/bids/bidplace.aspx?itemid=134967", | |
| 57 | + "image": "https://auction.lelands.com/images_items/thumbs/thumb_item_134967_1_489084.jpg", | |
| 58 | + "bids": 36, | |
| 59 | + "openingBid": 5000, | |
| 60 | + "status": "Sold", | |
| 61 | + "soldPrice": 48815, | |
| 62 | + "lotNumber": "4" | |
| 63 | + } | |
| 64 | + ] | |
| 65 | + } | |
| 66 | + }, | |
| 67 | + "expect": { | |
| 68 | + "count": 4, | |
| 69 | + "kinds": [ | |
| 70 | + "sale" | |
| 71 | + ] | |
| 72 | + }, | |
| 73 | + "note": "Captured live from https://auction.lelands.com/Lots/Gallery?size=250 (lists trimmed to 4).", | |
| 74 | + "capturedAt": "2026-09-07T07:18:48.903Z" | |
| 75 | +} | |
| \ No newline at end of file | ||
added
data/fixtures/morphy/catalog-page.json
+71 −0
@@ -0,0 +1,71 @@ | ||
| 1 | +{ | |
| 2 | + "raw": { | |
| 3 | + "url": "https://auctions.morphyauctions.com/catalog.aspx?auctionid=716&page=1", | |
| 4 | + "externalId": "auction:716:page:1", | |
| 5 | + "kind": "sale", | |
| 6 | + "engine": "api", | |
| 7 | + "fetchedAt": "2026-09-07T07:21:53.935Z", | |
| 8 | + "payload": { | |
| 9 | + "kind": "catalog_page", | |
| 10 | + "auction": { | |
| 11 | + "id": "716", | |
| 12 | + "title": "Online Only Perfume Bottles", | |
| 13 | + "dept": "Fine & Decorative Arts", | |
| 14 | + "dateText": "August 11, 2026", | |
| 15 | + "pageUrl": "https://morphyauctions.com/auctions/past-auctions/online-only-perfume-bottles/" | |
| 16 | + }, | |
| 17 | + "page": 1, | |
| 18 | + "totalPages": 19, | |
| 19 | + "lots": [ | |
| 20 | + { | |
| 21 | + "lotNumber": "1001", | |
| 22 | + "title": "CAMEO GLASS FLORAL PERFUME WITH ATOMIZER", | |
| 23 | + "url": "https://auctions.morphyauctions.com/CAMEO_GLASS_FLORAL_PERFUME_WITH_ATOMIZER-LOT662424.aspx", | |
| 24 | + "image": "https://auctions.morphyauctions.com/ItemImages/000662/26320016_1_sm.jpeg", | |
| 25 | + "finalPrice": 369, | |
| 26 | + "minBid": 150, | |
| 27 | + "estimateText": "$300 - $500", | |
| 28 | + "bids": 6 | |
| 29 | + }, | |
| 30 | + { | |
| 31 | + "lotNumber": "1002", | |
| 32 | + "title": "RED & BLUE FOLIAGE CAMEO GLASS PERFUME WITH ATOMIZER", | |
| 33 | + "url": "https://auctions.morphyauctions.com/RED___BLUE_FOLIAGE_CAMEO_GLASS_PERFUME_WITH_ATOMIZ-LOT662428.aspx", | |
| 34 | + "image": "https://auctions.morphyauctions.com/ItemImages/000662/26320020_1_sm.jpeg", | |
| 35 | + "finalPrice": 160, | |
| 36 | + "minBid": 100, | |
| 37 | + "estimateText": "$200 - $500", | |
| 38 | + "bids": 3 | |
| 39 | + }, | |
| 40 | + { | |
| 41 | + "lotNumber": "1003", | |
| 42 | + "title": "ANGEL OLIVERAS GUART LAB TOPPED PERFUME WITH ATOMIZER", | |
| 43 | + "url": "https://auctions.morphyauctions.com/ANGEL_OLIVERAS_GUART_LAB_TOPPED_PERFUME_WITH_ATOMI-LOT662431.aspx", | |
| 44 | + "image": "https://auctions.morphyauctions.com/ItemImages/000662/26320023_1_sm.jpeg", | |
| 45 | + "finalPrice": 128, | |
| 46 | + "minBid": 100, | |
| 47 | + "estimateText": "$200 - $400", | |
| 48 | + "bids": 2 | |
| 49 | + }, | |
| 50 | + { | |
| 51 | + "lotNumber": "1004", | |
| 52 | + "title": "LOT OF 2: PERFUME ATOMIZER & POWDER BOX SETS", | |
| 53 | + "url": "https://auctions.morphyauctions.com/LOT_OF_2__PERFUME_ATOMIZER___POWDER_BOX_SETS-LOT662809.aspx", | |
| 54 | + "image": "https://auctions.morphyauctions.com/ItemImages/000662/26320194_1_sm.jpeg", | |
| 55 | + "finalPrice": 704, | |
| 56 | + "minBid": 300, | |
| 57 | + "estimateText": "$600 - $700", | |
| 58 | + "bids": 12 | |
| 59 | + } | |
| 60 | + ] | |
| 61 | + } | |
| 62 | + }, | |
| 63 | + "expect": { | |
| 64 | + "count": 4, | |
| 65 | + "kinds": [ | |
| 66 | + "sale" | |
| 67 | + ] | |
| 68 | + }, | |
| 69 | + "note": "Captured live from https://auctions.morphyauctions.com/catalog.aspx?auctionid=716&page=1 (lists trimmed to 4).", | |
| 70 | + "capturedAt": "2026-09-07T07:21:53.938Z" | |
| 71 | +} | |
| \ No newline at end of file | ||
added
data/fixtures/noble-knight/board-game.json
+41 −0
@@ -0,0 +1,41 @@ | ||
| 1 | +{ | |
| 2 | + "raw": { | |
| 3 | + "url": "https://www.nobleknight.com/P/2147993326/007---Spectre-Board-Game", | |
| 4 | + "externalId": "2147993326", | |
| 5 | + "kind": "listing", | |
| 6 | + "engine": "api", | |
| 7 | + "fetchedAt": "2026-09-07T07:18:47.151Z", | |
| 8 | + "payload": { | |
| 9 | + "kind": "product_page", | |
| 10 | + "product": { | |
| 11 | + "url": "https://www.nobleknight.com/P/2147993326/007---Spectre-Board-Game", | |
| 12 | + "nkId": "2147993326", | |
| 13 | + "name": "007 - Spectre Board Game", | |
| 14 | + "sku": "2149328593", | |
| 15 | + "mpn": "MUH007", | |
| 16 | + "brand": "Modiphius Entertainment", | |
| 17 | + "image": "https://image.nobleknight.com/m/jpg240/muh007.jpg", | |
| 18 | + "description": "007 - Spectre Board Game - Board Game - Modiphius Entertainment from Modiphius Entertainment - part of our Board Games collection.", | |
| 19 | + "price": 9.95, | |
| 20 | + "currency": "USD", | |
| 21 | + "itemCondition": "NewCondition", | |
| 22 | + "availability": "InStock", | |
| 23 | + "publisher": "Modiphius Entertainment", | |
| 24 | + "productLine": "Board Games (Modiphius Entertainment)", | |
| 25 | + "category": "Board Games", | |
| 26 | + "genre": "Board Game - Family Board Game - Strategy", | |
| 27 | + "type": "Boxed Game", | |
| 28 | + "conditionText": "Quantity: Condition:SW (MINT/New) Our Price $9.95 Add to Cart Quantity: Condition:EX/NM Our Price $9.00 Add to Cart Quantity: Condition:VG+/NM Our Price $9.00 Add to Cart Quantity: Condition:VG+/NM Our Price $9.00 (unpunched) Add to Cart Quantity: Condition:Fair/NM Our Price $9.00 Add to Cart" | |
| 29 | + } | |
| 30 | + } | |
| 31 | + }, | |
| 32 | + "expect": { | |
| 33 | + "count": 2, | |
| 34 | + "kinds": [ | |
| 35 | + "catalog_item", | |
| 36 | + "listing" | |
| 37 | + ] | |
| 38 | + }, | |
| 39 | + "note": "Captured live via lookup(https://www.nobleknight.com/P/2147993326/007---Spectre-Board-Game).", | |
| 40 | + "capturedAt": "2026-09-07T07:18:47.187Z" | |
| 41 | +} | |
| \ No newline at end of file | ||
added
data/fixtures/noble-knight/gunpla.json
+41 −0
@@ -0,0 +1,41 @@ | ||
| 1 | +{ | |
| 2 | + "raw": { | |
| 3 | + "url": "https://www.nobleknight.com/P/2148351787/002-Ex-S-GUNDAM", | |
| 4 | + "externalId": "2148351787", | |
| 5 | + "kind": "listing", | |
| 6 | + "engine": "api", | |
| 7 | + "fetchedAt": "2026-09-07T07:18:47.679Z", | |
| 8 | + "payload": { | |
| 9 | + "kind": "product_page", | |
| 10 | + "product": { | |
| 11 | + "url": "https://www.nobleknight.com/P/2148351787/002-Ex-S-GUNDAM", | |
| 12 | + "nkId": "2148351787", | |
| 13 | + "name": "002 Ex-S GUNDAM", | |
| 14 | + "sku": null, | |
| 15 | + "mpn": null, | |
| 16 | + "brand": "Bandai", | |
| 17 | + "image": "https://image.nobleknight.com/2/jpg240/25274750179nopic.jpg", | |
| 18 | + "description": "002 Ex-S GUNDAM - Gundam Artifact - Bandai from Bandai - part of our Toys, Movies & More collection.", | |
| 19 | + "price": 6.95, | |
| 20 | + "currency": "USD", | |
| 21 | + "itemCondition": "UsedCondition", | |
| 22 | + "availability": "OutOfStock", | |
| 23 | + "publisher": "Bandai", | |
| 24 | + "productLine": "Gundam Artifact (Bandai)", | |
| 25 | + "category": "Toys, Movies & More", | |
| 26 | + "genre": "Model - Science Fiction", | |
| 27 | + "type": "Scale Model", | |
| 28 | + "conditionText": null | |
| 29 | + } | |
| 30 | + } | |
| 31 | + }, | |
| 32 | + "expect": { | |
| 33 | + "count": 2, | |
| 34 | + "kinds": [ | |
| 35 | + "catalog_item", | |
| 36 | + "listing" | |
| 37 | + ] | |
| 38 | + }, | |
| 39 | + "note": "Captured live via lookup(https://www.nobleknight.com/P/2148351787/002-Ex-S-GUNDAM).", | |
| 40 | + "capturedAt": "2026-09-07T07:18:47.722Z" | |
| 41 | +} | |
| \ No newline at end of file | ||
added
data/fixtures/scp-auctions/catalog-page.json
+89 −0
@@ -0,0 +1,89 @@ | ||
| 1 | +{ | |
| 2 | + "raw": { | |
| 3 | + "url": "https://catalogs.scpauctions.com/auctions/scp-auctions-inc/2026-summer-premier-23246/catalog", | |
| 4 | + "externalId": "event:23246:page:1", | |
| 5 | + "kind": "sale", | |
| 6 | + "engine": "api", | |
| 7 | + "fetchedAt": "2026-09-07T07:16:57.070Z", | |
| 8 | + "payload": { | |
| 9 | + "kind": "catalog_page", | |
| 10 | + "house": "SCP Auctions", | |
| 11 | + "event": { | |
| 12 | + "id": "23246", | |
| 13 | + "name": "2026 Summer Premier", | |
| 14 | + "url": "https://catalogs.scpauctions.com/auctions/scp-auctions-inc/2026-summer-premier-23246", | |
| 15 | + "status": "past", | |
| 16 | + "startDate": "2026-07-09 13:00:00 EDT", | |
| 17 | + "endDate": "2026-07-26 22:30:00 EDT" | |
| 18 | + }, | |
| 19 | + "page": 1, | |
| 20 | + "totalPages": 12, | |
| 21 | + "items": [ | |
| 22 | + { | |
| 23 | + "itemId": "9380487", | |
| 24 | + "url": "https://catalogs.scpauctions.com/online-auctions/scp-auctions-inc/september-11-2019-shohei-ohtani-game-used-asics-pro-model-bat-from-mlb-career-home-run-40-psa-dna-gu-9-5-fanatics-mlb-auth-9380487", | |
| 25 | + "title": "September 11, 2019 Shohei Ohtani Game Used Asics Pro Model Bat from MLB Career Home Run #40 – PSA/DNA GU 9.5, Fanatics & MLB Auth.", | |
| 26 | + "lotNumber": "1", | |
| 27 | + "image": "https://s1.img.bidsquare.com/item/l/3808/38089373.jpeg?t=1WMAqa", | |
| 28 | + "priceLabel": "Unsold", | |
| 29 | + "price": null, | |
| 30 | + "bids": 12, | |
| 31 | + "estimateLow": null, | |
| 32 | + "estimateHigh": null, | |
| 33 | + "startsAt": null, | |
| 34 | + "endsAt": null | |
| 35 | + }, | |
| 36 | + { | |
| 37 | + "itemId": "9394853", | |
| 38 | + "url": "https://catalogs.scpauctions.com/online-auctions/scp-auctions-inc/2025-shohei-ohtani-los-angeles-dodgers-game-worn-home-jersey-photomatched-to-30th-hr-of-3rd-straight-mvp-season-sports-investors-loa-mlb-auth-9394853", | |
| 39 | + "title": "2025 Shohei Ohtani Los Angeles Dodgers Game Worn Home Jersey Photomatched to 30th HR of 3rd Straight MVP Season – Sports Investors LOA, MLB Auth.", | |
| 40 | + "lotNumber": "2", | |
| 41 | + "image": "https://s1.img.bidsquare.com/item/l/3800/38007081.jpeg?t=1WKqtX", | |
| 42 | + "priceLabel": "Unsold", | |
| 43 | + "price": null, | |
| 44 | + "bids": 14, | |
| 45 | + "estimateLow": null, | |
| 46 | + "estimateHigh": null, | |
| 47 | + "startsAt": null, | |
| 48 | + "endsAt": null | |
| 49 | + }, | |
| 50 | + { | |
| 51 | + "itemId": "9394841", | |
| 52 | + "url": "https://catalogs.scpauctions.com/online-auctions/scp-auctions-inc/1986-fleer-57-michael-jordan---psa-gem-mint-10-9394841", | |
| 53 | + "title": "1986 Fleer #57 Michael Jordan - PSA GEM MINT 10", | |
| 54 | + "lotNumber": "3", | |
| 55 | + "image": "https://s1.img.bidsquare.com/item/l/3810/38105385.jpeg?t=1WMY9G", | |
| 56 | + "priceLabel": "Sold for", | |
| 57 | + "price": 360000, | |
| 58 | + "bids": 15, | |
| 59 | + "estimateLow": null, | |
| 60 | + "estimateHigh": null, | |
| 61 | + "startsAt": null, | |
| 62 | + "endsAt": null | |
| 63 | + }, | |
| 64 | + { | |
| 65 | + "itemId": "9434158", | |
| 66 | + "url": "https://catalogs.scpauctions.com/online-auctions/scp-auctions-inc/1968-bob-gibson-autographed-st-louis-cardinals-game-worn-road-jersey-from-mvp-cy-young-record-1-12-era-season-sgc-superior-excellent-9434158", | |
| 67 | + "title": "1968 Bob Gibson Autographed St. Louis Cardinals Game Worn Road Jersey from MVP, Cy Young & Record 1.12 ERA Season – SGC Superior/Excellent", | |
| 68 | + "lotNumber": "4", | |
| 69 | + "image": "https://s1.img.bidsquare.com/item/l/3744/37441939.jpeg?t=1Wx0je", | |
| 70 | + "priceLabel": "Sold for", | |
| 71 | + "price": 168000, | |
| 72 | + "bids": 22, | |
| 73 | + "estimateLow": null, | |
| 74 | + "estimateHigh": null, | |
| 75 | + "startsAt": null, | |
| 76 | + "endsAt": null | |
| 77 | + } | |
| 78 | + ] | |
| 79 | + } | |
| 80 | + }, | |
| 81 | + "expect": { | |
| 82 | + "count": 2, | |
| 83 | + "kinds": [ | |
| 84 | + "sale" | |
| 85 | + ] | |
| 86 | + }, | |
| 87 | + "note": "Captured live from https://catalogs.scpauctions.com/auctions/scp-auctions-inc/2026-summer-premier-23246/catalog (lists trimmed to 4).", | |
| 88 | + "capturedAt": "2026-09-07T07:16:57.108Z" | |
| 89 | +} | |
| \ No newline at end of file | ||
added
data/fixtures/trainz/collection-page.json
+230 −0
@@ -0,0 +1,230 @@ | ||
| 1 | +{ | |
| 2 | + "raw": { | |
| 3 | + "url": "https://www.trainz.com/collections/lionel-o-postwar-trains/products.json?limit=250&page=1", | |
| 4 | + "externalId": "lionel-o-postwar-trains:1", | |
| 5 | + "kind": "listing", | |
| 6 | + "engine": "api", | |
| 7 | + "fetchedAt": "2026-09-07T07:18:18.883Z", | |
| 8 | + "payload": { | |
| 9 | + "kind": "collection_page", | |
| 10 | + "collection": "lionel-o-postwar-trains", | |
| 11 | + "page": 1, | |
| 12 | + "products": [ | |
| 13 | + { | |
| 14 | + "id": 8008717238394, | |
| 15 | + "title": "Lionel 622-121 O Gauge 0.180\" x 0.328\" Carbon Slotted Motor Brush", | |
| 16 | + "handle": "lionel-622-121-226e-92-slotted-motor-brushes", | |
| 17 | + "vendor": "Lionel", | |
| 18 | + "product_type": "Train Parts", | |
| 19 | + "tags": [ | |
| 20 | + "class:Model Railroading", | |
| 21 | + "Class_Model Railroading", | |
| 22 | + "condition:Factory New", | |
| 23 | + "Condition_Factory New", | |
| 24 | + "era:Postwar", | |
| 25 | + "Era_Postwar", | |
| 26 | + "in-stock", | |
| 27 | + "Inventory Type2_Brand New", | |
| 28 | + "scale:O Gauge", | |
| 29 | + "Scale_O Gauge" | |
| 30 | + ], | |
| 31 | + "created_at": "2023-10-05T15:08:04-04:00", | |
| 32 | + "updated_at": "2026-09-07T03:18:18-04:00", | |
| 33 | + "variants": [ | |
| 34 | + { | |
| 35 | + "id": 43592139931770, | |
| 36 | + "sku": "P11595961A", | |
| 37 | + "price": "1.49", | |
| 38 | + "compare_at_price": null, | |
| 39 | + "available": true | |
| 40 | + }, | |
| 41 | + { | |
| 42 | + "id": 43592139964538, | |
| 43 | + "sku": "P11595961B", | |
| 44 | + "price": "12.50", | |
| 45 | + "compare_at_price": "17.86", | |
| 46 | + "available": true | |
| 47 | + }, | |
| 48 | + { | |
| 49 | + "id": 43592139997306, | |
| 50 | + "sku": "P11595961C", | |
| 51 | + "price": "44.65", | |
| 52 | + "compare_at_price": "74.42", | |
| 53 | + "available": true | |
| 54 | + } | |
| 55 | + ], | |
| 56 | + "images": [ | |
| 57 | + { | |
| 58 | + "src": "https://cdn.shopify.com/s/files/1/1260/4747/files/20250502-120236-C3-Trainz-11595961-STILL-00.jpg?v=1746202382" | |
| 59 | + }, | |
| 60 | + { | |
| 61 | + "src": "https://cdn.shopify.com/s/files/1/1260/4747/files/20250502-120236-C3-Trainz-11595961-STILL-01.jpg?v=1746202382" | |
| 62 | + } | |
| 63 | + ] | |
| 64 | + }, | |
| 65 | + { | |
| 66 | + "id": 8009053044858, | |
| 67 | + "title": "Lionel 671-181 O Gauge Smoke Stack Gasket", | |
| 68 | + "handle": "lionel-671-181-smoke-stack-gaskets", | |
| 69 | + "vendor": "Lionel", | |
| 70 | + "product_type": "Train Parts", | |
| 71 | + "tags": [ | |
| 72 | + "class:Model Railroading", | |
| 73 | + "Class_Model Railroading", | |
| 74 | + "condition:Factory New", | |
| 75 | + "Condition_Factory New", | |
| 76 | + "era:Postwar", | |
| 77 | + "Era_Postwar", | |
| 78 | + "in-stock", | |
| 79 | + "Inventory Type2_Brand New", | |
| 80 | + "scale:O Gauge", | |
| 81 | + "Scale_O Gauge" | |
| 82 | + ], | |
| 83 | + "created_at": "2023-10-05T18:05:54-04:00", | |
| 84 | + "updated_at": "2026-09-07T03:18:18-04:00", | |
| 85 | + "variants": [ | |
| 86 | + { | |
| 87 | + "id": 44018885525626, | |
| 88 | + "sku": "P11596070A", | |
| 89 | + "price": "1.49", | |
| 90 | + "compare_at_price": null, | |
| 91 | + "available": true | |
| 92 | + }, | |
| 93 | + { | |
| 94 | + "id": 44018885558394, | |
| 95 | + "sku": "P11596070B", | |
| 96 | + "price": "12.50", | |
| 97 | + "compare_at_price": "17.86", | |
| 98 | + "available": true | |
| 99 | + }, | |
| 100 | + { | |
| 101 | + "id": 44018885591162, | |
| 102 | + "sku": "P11596070C", | |
| 103 | + "price": "44.65", | |
| 104 | + "compare_at_price": "74.42", | |
| 105 | + "available": true | |
| 106 | + } | |
| 107 | + ], | |
| 108 | + "images": [ | |
| 109 | + { | |
| 110 | + "src": "https://cdn.shopify.com/s/files/1/1260/4747/files/20241126-103806-C4-Trainz-11596070-STILL-00_64adefbf-314c-4c29-a389-6f879f3ab576.jpg?v=1751506145" | |
| 111 | + }, | |
| 112 | + { | |
| 113 | + "src": "https://cdn.shopify.com/s/files/1/1260/4747/files/20241126-103736-C4-Trainz-11596070-STILL-00.jpg?v=1751506145" | |
| 114 | + } | |
| 115 | + ] | |
| 116 | + }, | |
| 117 | + { | |
| 118 | + "id": 8008645017722, | |
| 119 | + "title": "Lionel 480-18 O Gauge Black Horseshoe Retaining Washer 3/8\" W x 3/16\" ID", | |
| 120 | + "handle": "lionel-480-18-black-horseshoe-washers", | |
| 121 | + "vendor": "Lionel", | |
| 122 | + "product_type": "Train Parts", | |
| 123 | + "tags": [ | |
| 124 | + "class:Model Railroading", | |
| 125 | + "Class_Model Railroading", | |
| 126 | + "condition:Factory New", | |
| 127 | + "Condition_Factory New", | |
| 128 | + "era:Postwar", | |
| 129 | + "Era_Postwar", | |
| 130 | + "in-stock", | |
| 131 | + "Inventory Type2_Brand New", | |
| 132 | + "scale:O Gauge", | |
| 133 | + "Scale_O Gauge" | |
| 134 | + ], | |
| 135 | + "created_at": "2023-10-05T14:30:20-04:00", | |
| 136 | + "updated_at": "2026-09-07T03:18:18-04:00", | |
| 137 | + "variants": [ | |
| 138 | + { | |
| 139 | + "id": 43591991033978, | |
| 140 | + "sku": "P11605867A", | |
| 141 | + "price": "1.78", | |
| 142 | + "compare_at_price": null, | |
| 143 | + "available": true | |
| 144 | + }, | |
| 145 | + { | |
| 146 | + "id": 43591991066746, | |
| 147 | + "sku": "P11605867B", | |
| 148 | + "price": "15.00", | |
| 149 | + "compare_at_price": null, | |
| 150 | + "available": true | |
| 151 | + }, | |
| 152 | + { | |
| 153 | + "id": 43591991099514, | |
| 154 | + "sku": "P11605867C", | |
| 155 | + "price": "26.79", | |
| 156 | + "compare_at_price": null, | |
| 157 | + "available": true | |
| 158 | + } | |
| 159 | + ], | |
| 160 | + "images": [ | |
| 161 | + { | |
| 162 | + "src": "https://cdn.shopify.com/s/files/1/1260/4747/files/20231019-123813-C3-Trainz-11605867-STILL-00.jpg?v=1746045146" | |
| 163 | + } | |
| 164 | + ] | |
| 165 | + }, | |
| 166 | + { | |
| 167 | + "id": 8008905457786, | |
| 168 | + "title": "Lionel 480-16 Coupler Spring (TC-22)", | |
| 169 | + "handle": "lionel-480-16-coupler-springs-tc-22", | |
| 170 | + "vendor": "Lionel", | |
| 171 | + "product_type": "Train Parts", | |
| 172 | + "tags": [ | |
| 173 | + "class:Model Railroading", | |
| 174 | + "Class_Model Railroading", | |
| 175 | + "condition:Factory New", | |
| 176 | + "Condition_Factory New", | |
| 177 | + "era:Postwar", | |
| 178 | + "Era_Postwar", | |
| 179 | + "in-stock", | |
| 180 | + "Inventory Type2_Brand New", | |
| 181 | + "scale:O Gauge", | |
| 182 | + "Scale_O Gauge" | |
| 183 | + ], | |
| 184 | + "created_at": "2023-10-05T16:47:19-04:00", | |
| 185 | + "updated_at": "2026-09-07T03:18:18-04:00", | |
| 186 | + "variants": [ | |
| 187 | + { | |
| 188 | + "id": 43592552906874, | |
| 189 | + "sku": "P11602550A", | |
| 190 | + "price": "1.49", | |
| 191 | + "compare_at_price": null, | |
| 192 | + "available": true | |
| 193 | + }, | |
| 194 | + { | |
| 195 | + "id": 43592552939642, | |
| 196 | + "sku": "P11602550B", | |
| 197 | + "price": "5.00", | |
| 198 | + "compare_at_price": "7.15", | |
| 199 | + "available": true | |
| 200 | + }, | |
| 201 | + { | |
| 202 | + "id": 43592552972410, | |
| 203 | + "sku": "P11602550C", | |
| 204 | + "price": "8.93", | |
| 205 | + "compare_at_price": "14.88", | |
| 206 | + "available": true | |
| 207 | + } | |
| 208 | + ], | |
| 209 | + "images": [ | |
| 210 | + { | |
| 211 | + "src": "https://cdn.shopify.com/s/files/1/1260/4747/files/20230331-115445-C3-Trainz-11602550-STILL-01.jpg?v=1746044687" | |
| 212 | + }, | |
| 213 | + { | |
| 214 | + "src": "https://cdn.shopify.com/s/files/1/1260/4747/files/20230331-115445-C3-Trainz-11602550-STILL-02.jpg?v=1746044687" | |
| 215 | + } | |
| 216 | + ] | |
| 217 | + } | |
| 218 | + ] | |
| 219 | + } | |
| 220 | + }, | |
| 221 | + "expect": { | |
| 222 | + "count": 8, | |
| 223 | + "kinds": [ | |
| 224 | + "catalog_item", | |
| 225 | + "listing" | |
| 226 | + ] | |
| 227 | + }, | |
| 228 | + "note": "Captured live from the Shopify collection feed (products trimmed to 4).", | |
| 229 | + "capturedAt": "2026-09-07T07:18:18.889Z" | |
| 230 | +} | |
| \ No newline at end of file | ||
| 231 | ||