SPB Git

spb/rareindex

Public

Connectors: SCP, Hake's, Morphy, Lelands, BBTS, Noble Knight, Trainz (agent U)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Simon-Pierre Boucher committed (Sep 7, 2026) parent f5091c0 Browse files

38 changed files +2,702 −0

38 changed files
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(/&quot;/g, '"').replace(/&amp;/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(/&amp;/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(/&quot;/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 &amp; 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 &amp; 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
Large diff (61 lines) is not loaded by default to keep the page fast.Load diff
added connectors/firecrawl/bbts/_smoke.ts +7 −0
Large diff (7 lines) is not loaded by default to keep the page fast.Load diff
added connectors/firecrawl/bbts/index.test.ts +66 −0
Large diff (66 lines) is not loaded by default to keep the page fast.Load diff
added connectors/firecrawl/bbts/index.ts +109 −0
Large diff (109 lines) is not loaded by default to keep the page fast.Load diff
added connectors/firecrawl/bbts/meta.json +34 −0
Large diff (34 lines) is not loaded by default to keep the page fast.Load diff
added connectors/firecrawl/lelands/_smoke.ts +7 −0
Large diff (7 lines) is not loaded by default to keep the page fast.Load diff
added connectors/firecrawl/lelands/index.test.ts +53 −0
Large diff (53 lines) is not loaded by default to keep the page fast.Load diff
added connectors/firecrawl/lelands/index.ts +109 −0
Large diff (109 lines) is not loaded by default to keep the page fast.Load diff
added connectors/firecrawl/lelands/meta.json +49 −0
Large diff (49 lines) is not loaded by default to keep the page fast.Load diff
added data/fixtures/bbts/search-page.json +69 −0
Large diff (70 lines) is not loaded by default to keep the page fast.Load diff
added data/fixtures/hakes/catalog-page.json +89 −0
Large diff (90 lines) is not loaded by default to keep the page fast.Load diff
added data/fixtures/lelands/gallery-page.json +75 −0
Large diff (76 lines) is not loaded by default to keep the page fast.Load diff
added data/fixtures/morphy/catalog-page.json +71 −0
Large diff (72 lines) is not loaded by default to keep the page fast.Load diff
added data/fixtures/noble-knight/board-game.json +41 −0
Large diff (42 lines) is not loaded by default to keep the page fast.Load diff
added data/fixtures/noble-knight/gunpla.json +41 −0
Large diff (42 lines) is not loaded by default to keep the page fast.Load diff
added data/fixtures/scp-auctions/catalog-page.json +89 −0
Large diff (90 lines) is not loaded by default to keep the page fast.Load diff
added data/fixtures/trainz/collection-page.json +230 −0
Large diff (232 lines) is not loaded by default to keep the page fast.Load diff
Menu

Theme