SPB Git forge

spb/rareindex

Public
54commits 1branches 0releases
7.1 MBsize
maindefault branch
10 days agolast push
TypeScript 61.9% HTML 37.2% SQL 0.7%

Connectors: car and major auction results (8 sources, agent L)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Simon-Pierre Boucher committed 17 days ago (Sep 7, 2026) parent 7ea2cc2

42 changed files +3,088 −0

added connectors/api/bring-a-trailer/_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[2] === 'capture') await captureFixture(dir, process.argv[3] ?? 'results-page', Number(process.argv[4] ?? 5));
7 +else await runSmoke(dir);
added connectors/api/bring-a-trailer/index.test.ts +48 −0
@@ -0,0 +1,48 @@
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 { ConnectorMetaSchema } from '@rareindex/connectors';
6 +import { loadFixture, runFixtureSuite } from '@rareindex/connectors/testing';
7 +import createConnector, { parseListingHtml, parseSoldText, trimItem } from './index.js';
8 +import { parseVehicleTitle } from '../../firecrawl/_carlib/index.js';
9 +
10 +const dir = path.dirname(fileURLToPath(import.meta.url));
11 +const meta = ConnectorMetaSchema.parse(JSON.parse(readFileSync(path.join(dir, 'meta.json'), 'utf8')));
12 +const connector = createConnector(meta);
13 +
14 +describe('bring-a-trailer', () => {
15 + runFixtureSuite(connector, it, expect);
16 +
17 + it('only turns "Sold for" items into sales, with the winning bid, currency and end timestamp', async () => {
18 + const fx = loadFixture('bring-a-trailer', 'results-page');
19 + const items = (fx.raw.payload as { items: Array<{ sold_text?: string | null; timestamp_end?: number }> }).items;
20 + const out = await connector.normalize(fx.raw);
21 + const soldItems = items.filter((i) => /^Sold for/.test((i.sold_text ?? '').replace(/<[^>]+>/g, '')));
22 + expect(out.length).toBe(soldItems.length);
23 + for (const r of out) {
24 + if (r.kind !== 'sale') throw new Error('expected sale');
25 + expect(r.buyerPremiumIncluded).toBe(false);
26 + expect(r.auctionHouse).toBe('Bring a Trailer');
27 + expect(['automobiles', 'motorcycles']).toContain(r.attributes.categorySlug);
28 + expect(r.attributes.identifiers.bat_listing_id).toMatch(/^\d+$/);
29 + expect(r.saleDate.getTime()).toBeLessThanOrEqual(Date.now());
30 + }
31 + });
32 +
33 + it('parses sold text and vehicle titles', () => {
34 + expect(parseSoldText('Sold for USD $23,250 <span> on 9/6/2026 </span>')).toEqual({ sold: true, date: new Date(Date.UTC(2026, 8, 6)) });
35 + expect(parseSoldText('Bid to USD $11,250 <span> on 9/6/2026 </span>').sold).toBe(false);
36 + expect(parseVehicleTitle('2004 GMC Sierra 2500HD SLT Crew Cab 4×4 Duramax')).toMatchObject({ year: 2004, make: 'GMC', model: 'Sierra 2500HD', categorySlug: 'automobiles' });
37 + expect(parseVehicleTitle('1975 Harley-Davidson FLH Electra Glide')).toMatchObject({ make: 'Harley-Davidson', categorySlug: 'motorcycles' });
38 + expect(parseVehicleTitle('1967 Land Rover Series IIA 88')).toMatchObject({ make: 'Land Rover', model: 'Series IIA' });
39 + });
40 +
41 + it('trims items and parses a listing page', () => {
42 + expect(trimItem({ id: 1, title: 'x', url: 'u', watch_url: 'noise', current_bid: 5 })).toEqual({ id: 1, title: 'x', url: 'u', current_bid: 5 });
43 + const html = `<h1 class="post-title listing-post-title">2004 GMC Sierra 2500HD</h1><span>Sold for USD $23,250 on 9/6/26</span><li>Chassis: <a href="#">1GTHK23214F211880</a></li><li>133k Miles</li><span>Lot #261</span>`;
44 + const p = parseListingHtml(html, 'https://bringatrailer.com/listing/2004-gmc-sierra-2500hd-12/');
45 + expect(p).toMatchObject({ title: '2004 GMC Sierra 2500HD', vin: '1GTHK23214F211880', mileage: '133k Miles', lotNumber: '261' });
46 + expect(p.soldLine).toContain('Sold for USD $23,250');
47 + });
48 +});
added connectors/api/bring-a-trailer/index.ts +167 −0
@@ -0,0 +1,167 @@
1 +import { z } from 'zod';
2 +import { BaseConnector, type ConnectorMeta, type CrawlContext, type RawRecordInput, type RawRecordLike } from '@rareindex/connectors';
3 +import type { NormalizedRecord, NormalizedSale } from '@rareindex/shared';
4 +import { dateMDY, makeSale, money, vehicleAttributes } from '../../firecrawl/_carlib/index.js';
5 +
6 +/**
7 + * Bring a Trailer completed auctions. One raw record per results page (compact items); one sale per
8 + * "Sold for" item. Source: the public JSON feeding /auctions/results/.
9 + */
10 +const BASE = 'https://bringatrailer.com';
11 +const FILTER_URL = `${BASE}/wp-json/bringatrailer/1.0/data/listings-filter`;
12 +const PARSER_VERSION = '1.0.0';
13 +
14 +export const ItemSchema = z.object({
15 + id: z.number(),
16 + title: z.string(),
17 + url: z.string(),
18 + year: z.union([z.string(), z.number()]).nullable().optional(),
19 + currency: z.string().nullable().optional(),
20 + current_bid: z.number().nullable().optional(),
21 + sold_text: z.string().nullable().optional(),
22 + timestamp_end: z.number().nullable().optional(),
23 + country_code: z.string().nullable().optional(),
24 + noreserve: z.boolean().nullable().optional(),
25 + premium: z.boolean().nullable().optional(),
26 + thumbnail_url: z.string().nullable().optional(),
27 + excerpt: z.string().nullable().optional(),
28 +});
29 +export type Item = z.infer<typeof ItemSchema>;
30 +export const PagePayloadSchema = z.object({ kind: z.literal('results_page'), page: z.number(), itemsTotal: z.number().nullable(), pagesTotal: z.number().nullable(), items: z.array(ItemSchema) });
31 +export const ListingPayloadSchema = z.object({ kind: z.literal('listing'), url: z.string(), title: z.string(), soldLine: z.string().nullable(), vin: z.string().nullable(), mileage: z.string().nullable(), lotNumber: z.string().nullable(), image: z.string().nullable() });
32 +
33 +const KEEP: Array<keyof Item> = ['id', 'title', 'url', 'year', 'currency', 'current_bid', 'sold_text', 'timestamp_end', 'country_code', 'noreserve', 'premium', 'thumbnail_url', 'excerpt'];
34 +
35 +export function trimItem(raw: Record<string, unknown>): Item | null {
36 + const out: Record<string, unknown> = {};
37 + for (const k of KEEP) if (raw[k] !== undefined) out[k] = raw[k];
38 + const p = ItemSchema.safeParse(out);
39 + return p.success ? p.data : null;
40 +}
41 +
42 +/** "Sold for USD $23,250 <span> on 9/6/2026 </span>" → { sold: true, date } ; "Bid to …" → sold false */
43 +export function parseSoldText(s: string | null | undefined): { sold: boolean; date: Date | null } {
44 + if (!s) return { sold: false, date: null };
45 + const text = s.replace(/<[^>]+>/g, ' ');
46 + return { sold: /^\s*Sold for/i.test(text), date: dateMDY(text) };
47 +}
48 +
49 +export function parseListingHtml(html: string, url: string) {
50 + const title = html.match(/<h1[^>]*class="[^"]*post-title[^"]*"[^>]*>([\s\S]*?)<\/h1>/)?.[1]?.replace(/<[^>]+>/g, '').trim() ?? html.match(/<title>([^<|]+)/)?.[1]?.trim() ?? '';
51 + const soldLine = html.match(/(Sold for[^<]{0,80}on\s+\d{1,2}\/\d{1,2}\/\d{2,4})/)?.[1] ?? null;
52 + const vin = html.match(/Chassis:\s*(?:<a[^>]*>)?\s*([A-HJ-NPR-Z0-9]{6,20})/i)?.[1] ?? null;
53 + const mileage = html.match(/<li>\s*([\d,]+k?\s*(?:Miles|Kilometers)[^<]{0,40})<\/li>/i)?.[1]?.trim() ?? null;
54 + const lotNumber = html.match(/Lot #(\d+)/)?.[1] ?? null;
55 + const image = html.match(/property="og:image"\s+content="([^"]+)"/)?.[1] ?? null;
56 + return ListingPayloadSchema.parse({ kind: 'listing', url, title, soldLine, vin, mileage, lotNumber, image });
57 +}
58 +
59 +export class BringATrailerConnector extends BaseConnector {
60 + readonly version = '1.0.0';
61 + readonly parserVersion = PARSER_VERSION;
62 + protected override minIntervalMs = 1500;
63 + override readonly urlPatterns = [/^https?:\/\/bringatrailer\.com\/listing\/[a-z0-9-]+\/?$/i];
64 +
65 + async *crawl(ctx: CrawlContext): AsyncIterable<RawRecordInput> {
66 + const perPage = Number(this.meta.config.perPage ?? 36);
67 + const pages = Number(this.meta.config.pagesPerRun ?? 20);
68 + const backfill = ctx.options.mode === 'backfill';
69 + const start = backfill ? Number(ctx.options.cursor?.nextPage ?? 1) : 1;
70 + const newestSeen = !backfill && typeof ctx.options.cursor?.newestEnd === 'number' ? Number(ctx.options.cursor.newestEnd) : 0;
71 + let count = 0;
72 + let maxEnd = newestSeen;
73 + for (let page = start; page < start + pages; page++) {
74 + if (ctx.signal?.aborted || this.reached(ctx, count)) break;
75 + await this.throttle();
76 + const res = await ctx.fetch(FILTER_URL, {
77 + engines: ['api'],
78 + method: 'POST',
79 + body: { per_page: perPage, page, get_items: 1, get_stats: 0, sort: 'td' },
80 + expect: ['title', 'price', 'date', 'status'],
81 + parse: (r) => {
82 + const d = r.json as { items?: Array<Record<string, unknown>> } | null;
83 + const first = d?.items?.[0];
84 + return first ? { title: first.title, price: first.current_bid, date: first.timestamp_end, status: first.sold_text } : null;
85 + },
86 + });
87 + const data = res.json as { items?: Array<Record<string, unknown>>; items_total?: number; pages_total?: number } | null;
88 + if (!res.success || !data?.items) {
89 + ctx.anomaly('page_fetch_failed', `${FILTER_URL} page ${page}: ${res.error ?? res.httpStatus}`);
90 + break;
91 + }
92 + const items = data.items.map(trimItem).filter((x): x is Item => Boolean(x));
93 + if (items.length === 0) {
94 + ctx.anomaly('empty_page', `page ${page}`);
95 + break;
96 + }
97 + for (const it of items) if (it.timestamp_end && it.timestamp_end > maxEnd) maxEnd = it.timestamp_end;
98 + const payload = { kind: 'results_page' as const, page, itemsTotal: data.items_total ?? null, pagesTotal: data.pages_total ?? null, items };
99 + count++;
100 + yield { url: `${BASE}/auctions/results/?page=${page}`, externalId: `results:${page}:${items[0]!.id}`, kind: 'sale', engine: res.engine, httpStatus: res.httpStatus, payload, fetchedAt: res.fetchedAt };
101 + const oldest = Math.min(...items.map((i) => i.timestamp_end ?? Number.MAX_SAFE_INTEGER));
102 + if (backfill) await ctx.setCursor({ nextPage: page + 1, updatedAt: new Date().toISOString() });
103 + else if (newestSeen && oldest <= newestSeen) break; // caught up with the previous run
104 + }
105 + if (!backfill && maxEnd) await ctx.setCursor({ newestEnd: maxEnd, updatedAt: new Date().toISOString() });
106 + }
107 +
108 + async lookup(url: string, ctx: CrawlContext): Promise<RawRecordInput[]> {
109 + const res = await ctx.fetch(url, { engines: ['api'], responseType: 'text', expect: ['title'], parse: (r) => (r.html ? { title: parseListingHtml(r.html, url).title } : null) });
110 + if (!res.success || !res.html) return [];
111 + const payload = parseListingHtml(res.html, url);
112 + return [{ url, externalId: url.replace(/\/$/, '').split('/').pop() ?? url, kind: 'sale', engine: res.engine, httpStatus: res.httpStatus, payload, fetchedAt: res.fetchedAt }];
113 + }
114 +
115 + async normalize(raw: RawRecordLike): Promise<NormalizedRecord[]> {
116 + const payload = raw.payload as { kind?: string };
117 + if (payload?.kind === 'listing') return this.normalizeListing(raw);
118 + const p = PagePayloadSchema.parse(raw.payload);
119 + const out: NormalizedSale[] = [];
120 + for (const it of p.items) {
121 + const { sold, date } = parseSoldText(it.sold_text);
122 + if (!sold || !it.current_bid || it.current_bid <= 0) continue;
123 + const saleDate = it.timestamp_end ? new Date(it.timestamp_end * 1000) : date;
124 + if (!saleDate) continue;
125 + const currency = (it.currency ?? 'USD').toUpperCase();
126 + const m = money(`${it.current_bid} ${currency}`, 'USD');
127 + if (!m) continue;
128 + const attributes = vehicleAttributes(it.title, { country: it.country_code ?? null, identifiers: { bat_listing_id: String(it.id) }, metadata: { no_reserve: it.noreserve ?? null, premium_listing: it.premium ?? null, year_field: it.year ?? null } });
129 + out.push(
130 + makeSale({
131 + meta: this.meta,
132 + sourceUrl: it.url,
133 + externalId: String(it.id),
134 + rawTitle: it.title,
135 + attributes,
136 + price: it.current_bid,
137 + currency: m.currency,
138 + saleDate,
139 + buyerPremiumIncluded: false,
140 + auctionHouse: 'Bring a Trailer',
141 + imageUrls: it.thumbnail_url ? [it.thumbnail_url.replace(/\?.*$/, '')] : [],
142 + description: it.excerpt ?? null,
143 + location: it.country_code ?? null,
144 + observedAt: raw.fetchedAt,
145 + parserVersion: PARSER_VERSION,
146 + confidence: 0.92,
147 + }),
148 + );
149 + }
150 + return out;
151 + }
152 +
153 + private async normalizeListing(raw: RawRecordLike): Promise<NormalizedRecord[]> {
154 + const p = ListingPayloadSchema.parse(raw.payload);
155 + if (!p.soldLine) return [];
156 + const m = money(p.soldLine, 'USD');
157 + const date = dateMDY(p.soldLine);
158 + if (!m || !date) return [];
159 + const identifiers: Record<string, string> = {};
160 + if (p.vin) identifiers.vin = p.vin;
161 + if (p.lotNumber) identifiers.bat_lot = p.lotNumber;
162 + const attributes = vehicleAttributes(p.title, { identifiers, metadata: { mileage: p.mileage } });
163 + return [makeSale({ meta: this.meta, sourceUrl: p.url, externalId: raw.externalId ?? p.url, rawTitle: p.title, attributes, price: m.amount, currency: m.currency, saleDate: date, buyerPremiumIncluded: false, auctionHouse: 'Bring a Trailer', lotNumber: p.lotNumber, imageUrls: p.image ? [p.image] : [], observedAt: raw.fetchedAt, parserVersion: PARSER_VERSION })];
164 + }
165 +}
166 +
167 +export default (meta: ConnectorMeta) => new BringATrailerConnector(meta);
added connectors/api/bring-a-trailer/meta.json +33 −0
@@ -0,0 +1,33 @@
1 +{
2 + "id": "bring-a-trailer",
3 + "displayName": "Bring a Trailer (auction results)",
4 + "sourceId": "bring-a-trailer",
5 + "sourceName": "Bring a Trailer",
6 + "sourceType": "auction_house",
7 + "sourceUrl": "https://bringatrailer.com",
8 + "module": "api/bring-a-trailer",
9 + "enginePriority": ["api"],
10 + "categories": ["automobiles", "motorcycles"],
11 + "regions": ["US", "CA", "GB", "EU"],
12 + "languages": ["en"],
13 + "currency": ["USD", "CAD", "GBP", "EUR"],
14 + "supportsListings": false,
15 + "supportsSold": true,
16 + "supportsAuctions": false,
17 + "supportsImages": true,
18 + "supportsCatalog": false,
19 + "supportsPopulation": false,
20 + "supportsLookup": true,
21 + "refreshFrequencyMinutes": 360,
22 + "priority": "high",
23 + "trustScore": 0.9,
24 + "attributionRequired": true,
25 + "termsUrl": "https://bringatrailer.com/terms-of-use/",
26 + "accessNotes": "Completed auctions read from the same public JSON the /auctions/results/ page uses (POST /wp-json/bringatrailer/1.0/data/listings-filter, 36 items/page, ~262k results, newest first) over plain HTTPS with the RareIndex user agent; no login, no bidder data. robots.txt allows the results pages (search and member areas are disallowed and not used). 'Sold for <CUR> <amount> on <date>' entries become sales; 'Bid to' (reserve not met) entries are skipped. Prices are the winning bid: BaT's buyer fee (5%, capped) is NOT included → buyer_premium_included=false. Lookup of a listing URL parses the public listing page (title, sold line, chassis/VIN, mileage). 1.5 s politeness delay.",
27 + "enabled": true,
28 + "schemaVersion": "1.0",
29 + "config": {
30 + "pagesPerRun": 20,
31 + "perPage": 36
32 + }
33 +}
added connectors/api/swann/_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[2] === 'capture') await captureFixture(dir, process.argv[3] ?? 'results-page', Number(process.argv[4] ?? 5));
7 +else await runSmoke(dir);
added connectors/api/swann/index.test.ts +56 −0
@@ -0,0 +1,56 @@
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 { ConnectorMetaSchema } from '@rareindex/connectors';
6 +import { loadFixture, runFixtureSuite } from '@rareindex/connectors/testing';
7 +import createConnector, { parseCatalogPage, parsePastAuctions, swannCategory } from './index.js';
8 +
9 +const dir = path.dirname(fileURLToPath(import.meta.url));
10 +const meta = ConnectorMetaSchema.parse(JSON.parse(readFileSync(path.join(dir, 'meta.json'), 'utf8')));
11 +const connector = createConnector(meta);
12 +
13 +const LIST = `<div class="event__content"><div class="event__date">Thursday, November 6, 2025</div><h2 class="event__title">Autographs</h2>
14 +<div class="event__department"><a href="https://www.swanngalleries.com/books-manuscripts/autographs/">Autographs</a> - Sale 2719</div>
15 +<ul><li><a class="btn btn-primary btn-cta--view_lots" href="https://www.swanngalleries.com/auction-catalog/autographs_JCJOCA54V5">View Lots</a></li></ul></div>`;
16 +
17 +const CATALOG = `<div data-lot-ref="C337D422B9"><a href="https://www.swanngalleries.com/auction-lot/marlon-brando_c337d422b9"><img src="https://image.invaluable.com/h.jpg"/></a>
18 +<p class="lot-card_card-title__EEZRj">2<!-- -->: <!-- -->Marlon Brando Slip of paper Signed and Inscribed. Np, nd.</p>
19 +<div class="lot-card_estimate-bid__8g2bA"><span>Estimate<!-- -->: </span><span>$600 - $900</span></div>
20 +<div><div class="lot-card_bid-amount__Xp4jF"><strong class="lot-card_amount__WX83V"><span>Sold<!-- -->:</span><span>$1,188</span></strong></div><span>Sold price includes buyer's premium</span></div></div>
21 +<div data-lot-ref="C3359DB2B8"><a href="https://www.swanngalleries.com/auction-lot/tony-bennett_c3359db2b8"><img src="https://image.invaluable.com/t.jpg"/></a>
22 +<p class="lot-card_card-title__EEZRj">1<!-- -->: <!-- -->Tony Bennett. Group of 5 photographs.</p>
23 +<div class="lot-card_estimate-bid__8g2bA"><span>Estimate: </span><span>$600 - $900</span></div><div><span>Passed</span></div></div>`;
24 +
25 +describe('swann', () => {
26 + runFixtureSuite(connector, it, expect);
27 +
28 + it('parses the past-auction list and catalog cards', () => {
29 + const auctions = parsePastAuctions(LIST);
30 + expect(auctions).toEqual([{ slug: 'autographs_JCJOCA54V5', url: 'https://www.swanngalleries.com/auction-catalog/autographs_JCJOCA54V5', title: 'Autographs', dateText: 'Thursday, November 6, 2025', department: 'Autographs', saleNumber: '2719' }]);
31 + const page = parseCatalogPage(CATALOG, auctions[0]!, 1);
32 + expect(page.lots.length).toBe(2);
33 + expect(page.lots[0]).toMatchObject({ ref: 'C337D422B9', lotNumber: '2', soldText: '$1,188', premiumNote: true, passed: false, estimateText: '$600 - $900' });
34 + expect(page.lots[1]).toMatchObject({ lotNumber: '1', soldText: null, passed: true });
35 + });
36 +
37 + it('normalises sold lots only, premium included, dated by the sale', async () => {
38 + const auction = parsePastAuctions(LIST)[0]!;
39 + const out = await connector.normalize({ url: 'x', externalId: 's', kind: 'sale', engine: 'api', fetchedAt: new Date('2026-09-07T00:00:00Z'), payload: parseCatalogPage(CATALOG, auction, 1) });
40 + expect(out.length).toBe(1);
41 + const s = out[0]!;
42 + if (s.kind !== 'sale') throw new Error('sale');
43 + expect(s).toMatchObject({ price: 1188, currency: 'USD', buyerPremiumIncluded: true, lotNumber: '2', auctionHouse: 'Swann Auction Galleries' });
44 + expect(s.saleDate.toISOString()).toBe('2025-11-06T00:00:00.000Z');
45 + expect(s.attributes.categorySlug).toBe('autographs');
46 + expect(swannCategory('Photographs & Photobooks', 'Photographs', 'Ansel Adams')).toBe('photography');
47 + expect(swannCategory('Vintage Posters', 'Posters', 'Casablanca one sheet')).toBe('movie_posters');
48 + expect(swannCategory('Maps & Atlases', 'Maps', 'Blaeu, Americae')).toBe('maps');
49 + });
50 +
51 + it('fixture sales are USD with lot refs', async () => {
52 + const out = await connector.normalize(loadFixture('swann', 'catalog-page').raw);
53 + expect(out.length).toBeGreaterThan(0);
54 + for (const r of out) if (r.kind === 'sale') expect(r.attributes.identifiers.swann_lot_ref).toBeTruthy();
55 + });
56 +});
added connectors/api/swann/index.ts +141 −0
@@ -0,0 +1,141 @@
1 +import { z } from 'zod';
2 +import { BaseConnector, html as H, type ConnectorMeta, type CrawlContext, type RawRecordInput, type RawRecordLike } from '@rareindex/connectors';
3 +import { parseGradeFromTitle } from '@rareindex/taxonomy';
4 +import type { NormalizedRecord, NormalizedSale } from '@rareindex/shared';
5 +import { dateWords, lotAttributes, makeSale, money } from '../../firecrawl/_carlib/index.js';
6 +
7 +const BASE = 'https://www.swanngalleries.com';
8 +const PARSER_VERSION = '1.0.0';
9 +/** Swann answers 403 to bare product tokens; a Mozilla-compatible bot UA (still identifying RareIndex) is accepted. */
10 +const UA = { 'user-agent': 'Mozilla/5.0 (compatible; RareIndexBot/0.1; +https://www.rareindex.io/about)' };
11 +
12 +export const AuctionSchema = z.object({ slug: z.string(), url: z.string(), title: z.string(), dateText: z.string().nullable(), department: z.string().nullable(), saleNumber: z.string().nullable() });
13 +export const LotSchema = z.object({ ref: z.string(), url: z.string(), lotNumber: z.string().nullable(), title: z.string(), estimateText: z.string().nullable(), soldText: z.string().nullable(), passed: z.boolean(), premiumNote: z.boolean(), image: z.string().nullable() });
14 +export const PagePayloadSchema = z.object({ kind: z.literal('catalog_page'), auction: AuctionSchema, page: z.number(), lots: z.array(LotSchema) });
15 +export type PagePayload = z.infer<typeof PagePayloadSchema>;
16 +
17 +export function parsePastAuctions(htmlText: string): z.infer<typeof AuctionSchema>[] {
18 + const $ = H.load(htmlText);
19 + const out: z.infer<typeof AuctionSchema>[] = [];
20 + $('a.btn-cta--view_lots').each((_, a) => {
21 + const href = $(a).attr('href') ?? '';
22 + const m = href.match(/auction-catalog\/([^/?#]+)/);
23 + if (!m) return;
24 + // walk up to the nearest ancestor that carries the event title (markup nests the CTA deep inside the card)
25 + const card = $(a).parents().filter((_, el) => $(el).find('.event__title').length > 0).first();
26 + const scope = card.length ? card : $(a).parent();
27 + const title = H.text(scope.find('.event__title').first()) ?? H.text(scope.find('h2').first()) ?? '';
28 + const dateText = H.text(scope.find('.event__date').first());
29 + const dept = H.text(scope.find('.event__department a').first());
30 + const saleNumber = (H.text(scope.find('.event__department').first()) ?? '').match(/Sale\s+(\d+)/)?.[1] ?? null;
31 + if (title && !out.some((x) => x.slug === m[1])) out.push({ slug: m[1]!, url: href, title, dateText, department: dept, saleNumber });
32 + });
33 + return out;
34 +}
35 +
36 +export function parseCatalogPage(htmlText: string, auction: z.infer<typeof AuctionSchema>, page: number): PagePayload {
37 + const $ = H.load(htmlText);
38 + const lots: z.infer<typeof LotSchema>[] = [];
39 + $('[data-lot-ref]').each((_, el) => {
40 + const $el = $(el);
41 + const ref = $el.attr('data-lot-ref') ?? '';
42 + const a = $el.find('a[href*="/auction-lot/"]').first();
43 + const url = a.attr('href') ?? '';
44 + const titleRaw = H.text($el.find('[class*="card-title"]').first()) ?? '';
45 + const tm = titleRaw.match(/^(\d+[A-Za-z]?):\s*(.*)$/s);
46 + const estimateText = $el.find('[class*="estimate-bid"] span').last().text().trim() || null;
47 + const amount = $el.find('[class*="bid-amount"] [class*="amount"] span').last().text().trim();
48 + const passed = /passed|unsold|withdrawn/i.test($el.text());
49 + if (!ref || !url || !titleRaw) return;
50 + lots.push({ ref, url, lotNumber: tm?.[1] ?? null, title: (tm?.[2] ?? titleRaw).trim(), estimateText, soldText: amount && /\d/.test(amount) ? amount : null, passed, premiumNote: /includes buyer/i.test($el.text()), image: $el.find('img').first().attr('src') ?? null });
51 + });
52 + return { kind: 'catalog_page', auction, page, lots };
53 +}
54 +
55 +export function swannCategory(department: string | null, saleTitle: string, lotTitle: string): string {
56 + const d = `${department ?? ''} ${saleTitle}`.toLowerCase();
57 + const t = lotTitle.toLowerCase();
58 + if (/autograph/.test(d)) return /letter|document|manuscript|signed document|archive/.test(t) ? 'historical_documents' : 'autographs';
59 + if (/photograph/.test(d)) return 'photography';
60 + if (/poster/.test(d)) return 'movie_posters';
61 + if (/map|atlas/.test(d)) return 'maps';
62 + if (/illustration|animation|comic/.test(d)) return /cel\b|animation/.test(t) ? 'animation_art' : 'art';
63 + if (/contemporary|modern|african-american art|19th|20th|prints|drawings|art/.test(d)) return /contemporary|post-war/.test(d) ? 'contemporary_art' : 'art';
64 + if (/printed|manuscript|americana|books|literature|children|early printed/.test(d)) return /letter|manuscript|document|archive|autograph/.test(t) ? 'historical_documents' : 'books';
65 + return 'books';
66 +}
67 +
68 +export class SwannConnector extends BaseConnector {
69 + readonly version = '1.0.0';
70 + readonly parserVersion = PARSER_VERSION;
71 + protected override minIntervalMs = 1500;
72 +
73 + async *crawl(ctx: CrawlContext): AsyncIterable<RawRecordInput> {
74 + const auctionsPerRun = Number(this.meta.config.auctionsPerRun ?? 2);
75 + const pagesPerAuction = Number(this.meta.config.pagesPerAuction ?? 15);
76 + const done = new Set<string>(Array.isArray(ctx.options.cursor?.doneAuctions) ? (ctx.options.cursor!.doneAuctions as string[]) : []);
77 + const listUrl = `${BASE}/auctions/past-auctions/`;
78 + await this.throttle();
79 + const list = await ctx.fetch(listUrl, { engines: ['api', 'firecrawl'], headers: UA, responseType: 'text', expect: ['title', 'date'], parse: (r) => (r.html ? { title: parsePastAuctions(r.html)[0]?.title ?? null, date: parsePastAuctions(r.html)[0]?.dateText ?? null } : null) });
80 + if (!list.success || !list.html) {
81 + ctx.anomaly('page_fetch_failed', `${listUrl}: ${list.error ?? list.httpStatus}`);
82 + return;
83 + }
84 + const auctions = parsePastAuctions(list.html).filter((a) => !done.has(a.slug));
85 + let count = 0;
86 + let processed = 0;
87 + for (const auction of auctions) {
88 + if (processed >= auctionsPerRun || ctx.signal?.aborted) break;
89 + for (let page = 1; page <= pagesPerAuction; page++) {
90 + if (ctx.signal?.aborted || this.reached(ctx, count)) break;
91 + const url = `${BASE}/auction-catalog/${auction.slug}?algoliaParam=${encodeURIComponent(`archive_lotNumber_asc_prod[page]=${page}`)}`;
92 + await this.throttle();
93 + const res = await ctx.fetch(url, {
94 + engines: ['api', 'firecrawl'],
95 + headers: UA,
96 + responseType: 'text',
97 + expect: ['title', 'price', 'status'],
98 + parse: (r) => {
99 + const p = r.html ? parseCatalogPage(r.html, auction, page) : null;
100 + const f = p?.lots.find((l) => l.soldText);
101 + return f ? { title: f.title, price: money(f.soldText, 'USD')?.amount ?? null, status: 'sold' } : p?.lots.length ? { title: p.lots[0]!.title } : null;
102 + },
103 + minQuality: 0.2,
104 + });
105 + if (!res.success || !res.html) {
106 + ctx.anomaly('page_fetch_failed', `${url}: ${res.error ?? res.httpStatus}`);
107 + break;
108 + }
109 + const payload = parseCatalogPage(res.html, auction, page);
110 + if (payload.lots.length === 0) break;
111 + count++;
112 + yield { url, externalId: `catalog:${auction.slug}:page:${page}`, kind: 'sale', engine: res.engine, httpStatus: res.httpStatus, payload, fetchedAt: res.fetchedAt };
113 + if (payload.lots.length < 20) break;
114 + }
115 + processed++;
116 + done.add(auction.slug);
117 + await ctx.setCursor({ doneAuctions: [...done].slice(-300), updatedAt: new Date().toISOString() });
118 + }
119 + }
120 +
121 + async normalize(raw: RawRecordLike): Promise<NormalizedRecord[]> {
122 + const p = PagePayloadSchema.parse(raw.payload);
123 + const saleDate = dateWords(p.auction.dateText);
124 + if (!saleDate) return [];
125 + const out: NormalizedSale[] = [];
126 + for (const lot of p.lots) {
127 + if (!lot.soldText || lot.passed) continue;
128 + const m = money(lot.soldText, 'USD');
129 + if (!m) continue;
130 + const categorySlug = swannCategory(p.auction.department, p.auction.title, lot.title);
131 + const g = parseGradeFromTitle(lot.title);
132 + const year = lot.title.match(/\b(1[5-9]\d{2}|20\d{2})\b/)?.[1];
133 + const artist = lot.title.match(/^([A-Z][A-Za-z.'\- ]+?)(?:\.|,|\s\()/)?.[1] ?? null;
134 + const attributes = lotAttributes({ categorySlug, name: lot.title, brand: artist, year: year ? Number(year) : null, identifiers: { swann_lot_ref: lot.ref }, metadata: { sale_number: p.auction.saleNumber, sale_title: p.auction.title, department: p.auction.department, estimate: lot.estimateText } });
135 + out.push(makeSale({ meta: this.meta, sourceUrl: lot.url, externalId: lot.ref, rawTitle: lot.title, attributes, price: m.amount, currency: 'USD', saleDate, buyerPremiumIncluded: lot.premiumNote ? true : null, auctionHouse: 'Swann Auction Galleries', lotNumber: lot.lotNumber, imageUrls: lot.image ? [lot.image] : [], observedAt: raw.fetchedAt, parserVersion: PARSER_VERSION, grader: g.grader && g.grader !== 'raw' ? g.grader : null, grade: g.grader && g.grader !== 'raw' ? g.grade : null, location: 'US' }));
136 + }
137 + return out;
138 + }
139 +}
140 +
141 +export default (meta: ConnectorMeta) => new SwannConnector(meta);
added connectors/api/swann/meta.json +33 −0
@@ -0,0 +1,33 @@
1 +{
2 + "id": "swann",
3 + "displayName": "Swann Auction Galleries (results)",
4 + "sourceId": "swann",
5 + "sourceName": "Swann Auction Galleries",
6 + "sourceType": "auction_house",
7 + "sourceUrl": "https://www.swanngalleries.com",
8 + "module": "api/swann",
9 + "enginePriority": ["api", "firecrawl"],
10 + "categories": ["autographs", "photography", "movie_posters", "maps", "books", "art", "contemporary_art", "historical_documents", "comics", "animation_art"],
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": 1440,
22 + "priority": "medium",
23 + "trustScore": 0.9,
24 + "attributionRequired": true,
25 + "termsUrl": "https://www.swanngalleries.com/conditions-of-sale/",
26 + "accessNotes": "Public pages over plain HTTPS with the RareIndex user agent (robots.txt: Disallow empty): the past-auctions list (/auctions/past-auctions/: date, sale number, department, catalog link) and archived catalogs (/auction-catalog/<slug>?algoliaParam=archive_lotNumber_asc_prod[page]=N, 20 lots per page) whose server-rendered cards show lot number, title, estimate and 'Sold: $X' with the note 'Sold price includes buyer's premium' → buyer_premium_included=true; 'Passed' lots are skipped. Category from the sale's department. 1.5 s politeness delay.",
27 + "enabled": true,
28 + "schemaVersion": "1.0",
29 + "config": {
30 + "auctionsPerRun": 2,
31 + "pagesPerAuction": 15
32 + }
33 +}
added connectors/firecrawl/_carlib/index.ts +246 −0
@@ -0,0 +1,246 @@
1 +import { parsePrice, type AssetAttributes, type CurrencyCode, type NormalizedSale } from '@rareindex/shared';
2 +import type { ConnectorMeta } from '@rareindex/connectors';
3 +
4 +/**
5 + * Helpers shared by the auction-house connectors (cars, memorabilia, books, comics).
6 + * Kept outside the framework: source-specific vocabulary lives here, canonical models stay clean.
7 + */
8 +
9 +/** Multi-word makes first so "Land Rover" wins over "Land". `moto` marks motorcycle makes. */
10 +const MAKES: Array<{ name: string; moto?: boolean; aliases?: string[] }> = [
11 + { name: 'Alfa Romeo' }, { name: 'Aston Martin' }, { name: 'Land Rover' }, { name: 'Range Rover' }, { name: 'Mercedes-Benz', aliases: ['Mercedes Benz', 'Mercedes'] }, { name: 'Rolls-Royce', aliases: ['Rolls Royce'] },
12 + { name: 'De Tomaso' }, { name: 'American Motors', aliases: ['AMC'] }, { name: 'Austin-Healey', aliases: ['Austin Healey'] }, { name: 'Harley-Davidson', moto: true, aliases: ['Harley Davidson'] }, { name: 'Moto Guzzi', moto: true },
13 + { name: 'MV Agusta', moto: true }, { name: 'Royal Enfield', moto: true }, { name: 'International Harvester', aliases: ['International'] }, { name: 'Willys-Overland', aliases: ['Willys'] }, { name: 'Pierce-Arrow' },
14 + { name: 'Duesenberg' }, { name: 'Bugatti' }, { name: 'Ferrari' }, { name: 'Porsche' }, { name: 'Lamborghini' }, { name: 'McLaren' }, { name: 'Pagani' }, { name: 'Koenigsegg' }, { name: 'Maserati' }, { name: 'Lancia' }, { name: 'Fiat' }, { name: 'Lotus' }, { name: 'Jaguar' },
15 + { name: 'Bentley' }, { name: 'MG' }, { name: 'Triumph' }, { name: 'Mini' }, { name: 'BMW' }, { name: 'Audi' }, { name: 'Volkswagen', aliases: ['VW'] }, { name: 'Ford' }, { name: 'Chevrolet', aliases: ['Chevy'] }, { name: 'Dodge' }, { name: 'Plymouth' }, { name: 'Chrysler' },
16 + { name: 'Pontiac' }, { name: 'Buick' }, { name: 'Cadillac' }, { name: 'Oldsmobile' }, { name: 'GMC' }, { name: 'Lincoln' }, { name: 'Mercury' }, { name: 'Jeep' }, { name: 'Shelby' }, { name: 'Tesla' }, { name: 'Toyota' }, { name: 'Lexus' }, { name: 'Honda' }, { name: 'Acura' }, { name: 'Nissan' }, { name: 'Datsun' }, { name: 'Infiniti' },
17 + { name: 'Mazda' }, { name: 'Subaru' }, { name: 'Mitsubishi' }, { name: 'Suzuki' }, { name: 'Isuzu' }, { name: 'Hyundai' }, { name: 'Kia' }, { name: 'Genesis' }, { name: 'Volvo' }, { name: 'Saab' }, { name: 'Peugeot' }, { name: 'Citroën', aliases: ['Citroen'] }, { name: 'Renault' }, { name: 'Alpine' }, { name: 'Opel' }, { name: 'Skoda' },
18 + { name: 'Morgan' }, { name: 'TVR' }, { name: 'Caterham' }, { name: 'Noble' }, { name: 'Rover' }, { name: 'Sunbeam' }, { name: 'Hillman' }, { name: 'Vauxhall' }, { name: 'Packard' }, { name: 'Studebaker' }, { name: 'Hudson' }, { name: 'Nash' }, { name: 'DeLorean' }, { name: 'Saturn' }, { name: 'Hummer' }, { name: 'Ram' }, { name: 'Rivian' }, { name: 'Lucid' },
19 + { name: 'Ducati', moto: true }, { name: 'Kawasaki', moto: true }, { name: 'Yamaha', moto: true }, { name: 'Indian', moto: true }, { name: 'Norton', moto: true }, { name: 'BSA', moto: true }, { name: 'Vespa', moto: true }, { name: 'Aprilia', moto: true }, { name: 'KTM', moto: true }, { name: 'Husqvarna', moto: true }, { name: 'Vincent', moto: true }, { name: 'Brough Superior', moto: true }, { name: 'Zero', moto: true },
20 +];
21 +
22 +const MOTO_HINTS = /\b(motorcycle|motorbike|scooter|sidecar|caf[eé] racer|superbike|\d{2,4}cc)\b/i;
23 +
24 +export interface ParsedVehicleTitle {
25 + year: number | null;
26 + make: string | null;
27 + model: string | null;
28 + rest: string | null;
29 + name: string;
30 + categorySlug: 'automobiles' | 'motorcycles';
31 +}
32 +
33 +/** "2004 GMC Sierra 2500HD SLT Crew Cab 4×4 Duramax" → year 2004, make GMC, model "Sierra 2500HD", rest, category. */
34 +export function parseVehicleTitle(title: string, hint: { moto?: boolean } = {}): ParsedVehicleTitle {
35 + const t = title.replace(/\s+/g, ' ').trim();
36 + const ym = t.match(/^(?:c\.?\s*|circa\s+)?((?:18|19|20)\d{2})(?:\s*[-–/]\s*(?:\d{2,4}))?\b\s*/);
37 + const year = ym ? Number(ym[1]) : null;
38 + let body = ym ? t.slice(ym[0].length) : t;
39 + body = body.replace(/^(?:No Reserve:|Modified|Original-Owner|One-Owner|\d+k?-Mile)\s+/i, '');
40 + let make: string | null = null;
41 + let moto = Boolean(hint.moto);
42 + for (const m of MAKES) {
43 + const names = [m.name, ...(m.aliases ?? [])];
44 + const hit = names.find((n) => new RegExp(`^${n.replace(/[-/\\^$*+?.()|[\]{}]/g, '\\$&')}\\b`, 'i').test(body));
45 + if (hit) {
46 + make = m.name;
47 + body = body.slice(hit.length).trim();
48 + if (m.moto) moto = true;
49 + break;
50 + }
51 + }
52 + if (!make) {
53 + const first = body.split(' ')[0] ?? null;
54 + make = first && /^[A-Z][A-Za-z-]+$/.test(first) ? first : null;
55 + if (make) body = body.slice(make.length).trim();
56 + }
57 + if (MOTO_HINTS.test(t)) moto = true;
58 + const tokens = body.split(' ').filter(Boolean);
59 + const model = tokens.length ? tokens.slice(0, Math.min(2, tokens.length)).join(' ') : null;
60 + const rest = tokens.length > 2 ? tokens.slice(2).join(' ') : null;
61 + return { year, make, model, rest, name: ym ? t.slice(ym[0].length).trim() || t : t, categorySlug: moto ? 'motorcycles' : 'automobiles' };
62 +}
63 +
64 +export function vehicleAttributes(title: string, extra: Partial<AssetAttributes> & { identifiers?: Record<string, string>; metadata?: Record<string, unknown>; moto?: boolean } = {}): AssetAttributes {
65 + const v = parseVehicleTitle(title, { moto: extra.moto });
66 + return {
67 + categorySlug: extra.categorySlug ?? v.categorySlug,
68 + subcategorySlug: null,
69 + franchise: null,
70 + brand: v.make,
71 + series: null,
72 + set: null,
73 + setCode: null,
74 + name: v.name,
75 + model: v.model,
76 + reference: null,
77 + number: null,
78 + year: v.year,
79 + edition: null,
80 + variant: v.rest,
81 + language: null,
82 + region: extra.region ?? null,
83 + country: extra.country ?? null,
84 + material: null,
85 + size: null,
86 + color: null,
87 + rarity: null,
88 + productionQuantity: null,
89 + originalMsrp: null,
90 + originalMsrpCurrency: null,
91 + identifiers: extra.identifiers ?? {},
92 + metadata: extra.metadata ?? {},
93 + };
94 +}
95 +
96 +/** Generic attributes for non-vehicle lots (memorabilia, books, comics…) with a taxonomy slug supplied by the connector. */
97 +export function lotAttributes(input: { categorySlug: string; name: string; brand?: string | null; series?: string | null; set?: string | null; number?: string | null; year?: number | null; variant?: string | null; country?: string | null; identifiers?: Record<string, string>; metadata?: Record<string, unknown> }): AssetAttributes {
98 + return {
99 + categorySlug: input.categorySlug,
100 + subcategorySlug: null,
101 + franchise: null,
102 + brand: input.brand ?? null,
103 + series: input.series ?? null,
104 + set: input.set ?? null,
105 + setCode: null,
106 + name: input.name,
107 + model: null,
108 + reference: null,
109 + number: input.number ?? null,
110 + year: input.year ?? null,
111 + edition: null,
112 + variant: input.variant ?? null,
113 + language: null,
114 + region: null,
115 + country: input.country ?? null,
116 + material: null,
117 + size: null,
118 + color: null,
119 + rarity: null,
120 + productionQuantity: null,
121 + originalMsrp: null,
122 + originalMsrpCurrency: null,
123 + identifiers: input.identifiers ?? {},
124 + metadata: input.metadata ?? {},
125 + };
126 +}
127 +
128 +/** Money helper returning null unless a positive amount with a currency was found. */
129 +export function money(text: string | null | undefined, fallback?: CurrencyCode): { amount: number; currency: CurrencyCode } | null {
130 + const p = parsePrice(text, fallback);
131 + if (!p || !p.currency || p.amount <= 0) return null;
132 + return { amount: p.amount, currency: p.currency };
133 +}
134 +
135 +/** m/d/yy or m/d/yyyy → UTC midnight. */
136 +export function dateMDY(s: string | null | undefined): Date | null {
137 + const m = s?.match(/(\d{1,2})\/(\d{1,2})\/(\d{2,4})/);
138 + if (!m) return null;
139 + const y = m[3]!.length === 2 ? 2000 + Number(m[3]) : Number(m[3]);
140 + return new Date(Date.UTC(y, Number(m[1]) - 1, Number(m[2])));
141 +}
142 +/** dd/mm/yyyy → UTC midnight. */
143 +export function dateDMY(s: string | null | undefined): Date | null {
144 + const m = s?.match(/(\d{1,2})\/(\d{1,2})\/(\d{4})/);
145 + if (!m) return null;
146 + return new Date(Date.UTC(Number(m[3]), Number(m[2]) - 1, Number(m[1])));
147 +}
148 +const MONTHS: Record<string, number> = { january: 0, february: 1, march: 2, april: 3, may: 4, june: 5, july: 6, august: 7, september: 8, october: 9, november: 10, december: 11, jan: 0, feb: 1, mar: 2, apr: 3, jun: 5, jul: 6, aug: 7, sep: 8, sept: 8, oct: 9, nov: 10, dec: 11 };
149 +/** "October 16, 2025" | "16 August 2025" | "August 14-16" (+year) → UTC date of the FIRST day; range kept by caller. */
150 +export function dateWords(s: string | null | undefined, yearHint?: number | null): Date | null {
151 + if (!s) return null;
152 + let m = s.match(/([A-Za-z]{3,9})\.?\s+(\d{1,2})(?:\s*[-–]\s*\d{1,2})?,?\s+(\d{4})/);
153 + if (m && MONTHS[m[1]!.toLowerCase()] !== undefined) return new Date(Date.UTC(Number(m[3]), MONTHS[m[1]!.toLowerCase()]!, Number(m[2])));
154 + m = s.match(/(\d{1,2})(?:\s*[-–]\s*\d{1,2})?\s+([A-Za-z]{3,9})\.?,?\s+(\d{4})/);
155 + if (m && MONTHS[m[2]!.toLowerCase()] !== undefined) return new Date(Date.UTC(Number(m[3]), MONTHS[m[2]!.toLowerCase()]!, Number(m[1])));
156 + if (yearHint) {
157 + m = s.match(/([A-Za-z]{3,9})\.?\s+(\d{1,2})/);
158 + if (m && MONTHS[m[1]!.toLowerCase()] !== undefined) return new Date(Date.UTC(yearHint, MONTHS[m[1]!.toLowerCase()]!, Number(m[2])));
159 + }
160 + return null;
161 +}
162 +
163 +export interface SaleInput {
164 + meta: ConnectorMeta;
165 + sourceUrl: string;
166 + externalId: string;
167 + rawTitle: string;
168 + attributes: AssetAttributes;
169 + price: number;
170 + currency: CurrencyCode;
171 + saleDate: Date;
172 + buyerPremiumIncluded: boolean | null;
173 + auctionHouse: string;
174 + lotNumber?: string | null;
175 + imageUrls?: string[];
176 + description?: string | null;
177 + location?: string | null;
178 + observedAt: Date;
179 + confidence?: number;
180 + parserVersion: string;
181 + grader?: string | null;
182 + grade?: string | null;
183 + condition?: string | null;
184 + conditionRaw?: string | null;
185 + isBundle?: boolean;
186 + saleType?: NormalizedSale['saleType'];
187 +}
188 +
189 +export function makeSale(i: SaleInput): NormalizedSale {
190 + return {
191 + kind: 'sale',
192 + connectorId: i.meta.id,
193 + sourceId: i.meta.sourceId,
194 + sourceUrl: i.sourceUrl,
195 + externalId: i.externalId,
196 + rawTitle: i.rawTitle,
197 + description: i.description ?? null,
198 + imageUrls: i.imageUrls ?? [],
199 + attributes: i.attributes,
200 + grade: { grader: i.grader ?? null, grade: i.grade ?? null, qualifier: null, certificationNumber: null },
201 + condition: { condition: i.condition ?? null, conditionRaw: i.conditionRaw ?? null, completeness: null },
202 + observedAt: i.observedAt,
203 + confidence: i.confidence ?? 0.9,
204 + parserVersion: i.parserVersion,
205 + saleType: i.saleType ?? 'auction',
206 + saleDate: i.saleDate,
207 + price: i.price,
208 + currency: i.currency,
209 + buyerPremiumIncluded: i.buyerPremiumIncluded,
210 + quantity: 1,
211 + isBundle: i.isBundle ?? false,
212 + location: i.location ?? null,
213 + auctionHouse: i.auctionHouse,
214 + lotNumber: i.lotNumber ?? null,
215 + };
216 +}
217 +
218 +/** Split a Firecrawl markdown list into item chunks that each contain a link matching `linkRe`. */
219 +export function splitMarkdownItems(md: string, startRe: RegExp): string[] {
220 + const idx: number[] = [];
221 + const re = new RegExp(startRe.source, startRe.flags.includes('g') ? startRe.flags : `${startRe.flags}g`);
222 + let m: RegExpExecArray | null;
223 + while ((m = re.exec(md))) idx.push(m.index);
224 + return idx.map((s, k) => md.slice(s, idx[k + 1] ?? md.length));
225 +}
226 +
227 +export function firstMatch(text: string, re: RegExp): string | null {
228 + const m = text.match(re);
229 + return m ? (m[1] ?? m[0]) : null;
230 +}
231 +
232 +export const md = {
233 + /** first markdown link matching an href regex → { text, href } */
234 + link(text: string, hrefRe: RegExp): { text: string; href: string } | null {
235 + const re = new RegExp(`\\[([^\\]]*)\\]\\((${hrefRe.source})(?:\\s+"[^"]*")?\\)`, hrefRe.flags.replace('g', ''));
236 + const m = text.match(re);
237 + return m ? { text: m[1]!.replace(/\\/g, '').trim(), href: m[2]! } : null;
238 + },
239 + image(text: string): string | null {
240 + const m = text.match(/!\[[^\]]*\]\((https?:[^)\s]+)\)/);
241 + return m ? m[1]! : null;
242 + },
243 + clean(s: string): string {
244 + return s.replace(/\\([\\*_#.[\]()\-+|])/g, '$1').replace(/\*\*/g, '').replace(/\s+/g, ' ').trim();
245 + },
246 +};
added connectors/firecrawl/_carlib/smoke.ts +59 −0
@@ -0,0 +1,59 @@
1 +/**
2 + * Shared live smoke/capture runner for the auction-house connectors. Loads meta.json + index.ts from
3 + * the connector folder directly (works before the registry is rebuilt).
4 + * Usage: set -a; . ./.env; set +a; pnpm tsx connectors/<engine>/<id>/_smoke.ts [limit]
5 + */
6 +import { readFileSync } from 'node:fs';
7 +import path from 'node:path';
8 +import { ConnectorMetaSchema, createCrawlContext, createRouter, type ConnectorMeta, type RareIndexConnector } from '@rareindex/connectors';
9 +import { saveFixture } from '@rareindex/connectors/testing';
10 +import { childLogger } from '@rareindex/shared';
11 +
12 +export async function loadLocal(dir: string): Promise<{ meta: ConnectorMeta; connector: RareIndexConnector }> {
13 + const raw = JSON.parse(readFileSync(path.join(dir, 'meta.json'), 'utf8'));
14 + const meta = ConnectorMetaSchema.parse(raw);
15 + const mod = (await import(path.join(dir, 'index.ts'))) as { default: (m: ConnectorMeta) => RareIndexConnector };
16 + return { meta, connector: mod.default(meta) };
17 +}
18 +
19 +export async function runSmoke(dir: string, limit = Number(process.argv[2] ?? 2)): Promise<void> {
20 + const { meta, connector } = await loadLocal(dir);
21 + const router = createRouter({ firecrawlApiKey: process.env.FIRECRAWL_API_KEY, scrapflyApiKey: process.env.SCRAPFLY_API_KEY });
22 + const ctx = createCrawlContext({ router, meta, options: { mode: 'probe', limit }, log: childLogger({ connector: meta.id, smoke: true }) });
23 + let raws = 0;
24 + let total = 0;
25 + const samples: unknown[] = [];
26 + for await (const raw of connector.crawl(ctx)) {
27 + raws++;
28 + const records = await connector.normalize({ ...raw, externalId: raw.externalId ?? null, fetchedAt: raw.fetchedAt ?? new Date() });
29 + total += records.length;
30 + console.log(`raw ${raw.url} → ${records.length} records`);
31 + for (const r of records.slice(0, 3)) samples.push(r);
32 + if (raws >= limit) break;
33 + }
34 + for (const s of samples.slice(0, 3)) console.log(JSON.stringify(s, null, 1));
35 + console.log(JSON.stringify({ raws, totalNormalized: total, engineStats: ctx.engineStats, anomalies: ctx.anomalies }));
36 +}
37 +
38 +/** Capture the first probe raw record as a fixture (item lists trimmed to `keep`). */
39 +export async function captureFixture(dir: string, name: string, keep = 4): Promise<void> {
40 + const { meta, connector } = await loadLocal(dir);
41 + const router = createRouter({ firecrawlApiKey: process.env.FIRECRAWL_API_KEY, scrapflyApiKey: process.env.SCRAPFLY_API_KEY });
42 + const ctx = createCrawlContext({ router, meta, options: { mode: 'probe', limit: 1 }, log: childLogger({ connector: meta.id, capture: true }) });
43 + for await (const raw of connector.crawl(ctx)) {
44 + const payload = raw.payload as Record<string, unknown>;
45 + for (const k of ['items', 'lots', 'cards', 'listings']) {
46 + if (Array.isArray(payload[k])) payload[k] = (payload[k] as unknown[]).slice(0, keep);
47 + }
48 + const fetchedAt = raw.fetchedAt ?? new Date();
49 + const records = await connector.normalize({ ...raw, externalId: raw.externalId ?? null, fetchedAt });
50 + saveFixture(meta.id, name, {
51 + raw: { url: raw.url, externalId: raw.externalId ?? null, kind: raw.kind, engine: raw.engine, fetchedAt, payload },
52 + expect: { count: records.length, kinds: [...new Set(records.map((r) => r.kind))] },
53 + note: `Captured live from ${raw.url} (lists trimmed to ${keep}).`,
54 + });
55 + console.log(`saved data/fixtures/${meta.id}/${name}.json (${records.length} records)`);
56 + return;
57 + }
58 + throw new Error('crawl yielded nothing');
59 +}
added connectors/firecrawl/cars-and-bids/_smoke.ts +7 −0
@@ -0,0 +1,7 @@
1 +import path from 'node:path';
2 +import { fileURLToPath } from 'node:url';
3 +import { captureFixture, runSmoke } from '../_carlib/smoke.js';
4 +
5 +const dir = path.dirname(fileURLToPath(import.meta.url));
6 +if (process.argv[2] === 'capture') await captureFixture(dir, process.argv[3] ?? 'page-1', Number(process.argv[4] ?? 4));
7 +else await runSmoke(dir);
added connectors/firecrawl/cars-and-bids/index.test.ts +65 −0
@@ -0,0 +1,65 @@
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 { ConnectorMetaSchema } from '@rareindex/connectors';
6 +import { loadFixture, runFixtureSuite } from '@rareindex/connectors/testing';
7 +import createConnector, { parsePastPage } from './index.js';
8 +
9 +const dir = path.dirname(fileURLToPath(import.meta.url));
10 +const meta = ConnectorMetaSchema.parse(JSON.parse(readFileSync(path.join(dir, 'meta.json'), 'utf8')));
11 +const connector = createConnector(meta);
12 +
13 +const SAMPLE = `- [![2023 Porsche Panamera Turbo S](https://media.carsandbids.com/x.jpg)\\\\
14 +\\\\
15 +Featured\\\\
16 +\\\\
17 + - Bid to $138,000](https://carsandbids.com/auctions/rJv85YAw/2023-porsche-panamera-turbo-s "2023 Porsche Panamera Turbo S")
18 +
19 +[2023 Porsche Panamera Turbo S](https://carsandbids.com/auctions/rJv85YAw/2023-porsche-panamera-turbo-s "2023 Porsche Panamera Turbo S") Watch
20 +
21 +620-hp Twin-Turbo V8, AWD
22 +
23 +Ended 9/4/26
24 +
25 +- [![1977 Mercury Marquis Colony Park Brougham Wagon](https://media.carsandbids.com/y.jpg)\\\\
26 +\\\\
27 + - Sold for $7,400](https://carsandbids.com/auctions/3BOndoY4/1977-mercury-marquis-colony-park-brougham-wagon "1977 Mercury Marquis Colony Park Brougham Wagon")
28 +
29 +[1977 Mercury Marquis Colony Park Brougham Wagon](https://carsandbids.com/auctions/3BOndoY4/1977-mercury-marquis-colony-park-brougham-wagon "1977 Mercury Marquis Colony Park Brougham Wagon") Watch
30 +
31 +460ci V8, A/C, Third-Row Seating
32 +
33 +Ended 9/4/26
34 +`;
35 +
36 +describe('cars-and-bids', () => {
37 + runFixtureSuite(connector, it, expect);
38 +
39 + it('parses the past-auctions markdown', () => {
40 + const p = parsePastPage(SAMPLE, 1);
41 + expect(p.items.length).toBe(2);
42 + expect(p.items[0]).toMatchObject({ id: 'rJv85YAw', statusText: 'Bid to $138,000', ended: '9/4/26' });
43 + expect(p.items[1]).toMatchObject({ id: '3BOndoY4', title: '1977 Mercury Marquis Colony Park Brougham Wagon', statusText: 'Sold for $7,400', ended: '9/4/26', subtitle: '460ci V8, A/C, Third-Row Seating' });
44 + });
45 +
46 + it('normalises only sold rows, hammer price without buyer fee', async () => {
47 + const out = await connector.normalize({ url: 'https://carsandbids.com/past-auctions/', externalId: 'past:1', kind: 'sale', engine: 'firecrawl', fetchedAt: new Date('2026-09-07T00:00:00Z'), payload: parsePastPage(SAMPLE, 1) });
48 + expect(out.length).toBe(1);
49 + const s = out[0]!;
50 + if (s.kind !== 'sale') throw new Error('sale expected');
51 + expect(s).toMatchObject({ price: 7400, currency: 'USD', buyerPremiumIncluded: false, auctionHouse: 'Cars & Bids', externalId: '3BOndoY4' });
52 + expect(s.saleDate.toISOString()).toBe('2026-09-04T00:00:00.000Z');
53 + expect(s.attributes).toMatchObject({ categorySlug: 'automobiles', brand: 'Mercury', year: 1977 });
54 + });
55 +
56 + it('fixture sales carry real end dates and ids', async () => {
57 + const fx = loadFixture('cars-and-bids', 'past-page');
58 + const out = await connector.normalize(fx.raw);
59 + for (const r of out) {
60 + if (r.kind !== 'sale') continue;
61 + expect(r.attributes.identifiers.carsandbids_id).toMatch(/^[A-Za-z0-9]+$/);
62 + expect(r.saleDate.getUTCFullYear()).toBeGreaterThanOrEqual(2020);
63 + }
64 + });
65 +});
added connectors/firecrawl/cars-and-bids/index.ts +101 −0
@@ -0,0 +1,101 @@
1 +import { z } from 'zod';
2 +import { BaseConnector, type ConnectorMeta, type CrawlContext, type RawRecordInput, type RawRecordLike } from '@rareindex/connectors';
3 +import type { NormalizedRecord, NormalizedSale } from '@rareindex/shared';
4 +import { dateMDY, makeSale, md, money, splitMarkdownItems, vehicleAttributes } from '../_carlib/index.js';
5 +
6 +const BASE = 'https://carsandbids.com';
7 +const PARSER_VERSION = '1.0.0';
8 +
9 +export const ItemSchema = z.object({
10 + id: z.string(),
11 + url: z.string(),
12 + title: z.string(),
13 + statusText: z.string(),
14 + ended: z.string().nullable(),
15 + subtitle: z.string().nullable(),
16 + image: z.string().nullable(),
17 +});
18 +export const PagePayloadSchema = z.object({ kind: z.literal('past_page'), page: z.number(), items: z.array(ItemSchema) });
19 +export type PagePayload = z.infer<typeof PagePayloadSchema>;
20 +
21 +/** Parse the Firecrawl markdown of /past-auctions/ into compact items. */
22 +export function parsePastPage(markdown: string, page: number): PagePayload {
23 + const chunks = splitMarkdownItems(markdown, /^- \[!\[/m);
24 + const items: z.infer<typeof ItemSchema>[] = [];
25 + for (const c of chunks) {
26 + const link = md.link(c, /https:\/\/carsandbids\.com\/auctions\/([A-Za-z0-9]+)\/[a-z0-9-]+/);
27 + if (!link) continue;
28 + const id = link.href.match(/\/auctions\/([A-Za-z0-9]+)\//)?.[1];
29 + if (!id) continue;
30 + const title = c.match(/\]\(https:\/\/carsandbids\.com\/auctions\/[A-Za-z0-9]+\/[a-z0-9-]+\s+"([^"]+)"\)/)?.[1] ?? link.text;
31 + const statusText = c.match(/-\s*((?:Sold for|Bid to)\s+[^\]\n]+)\]/)?.[1]?.trim() ?? '';
32 + const ended = c.match(/Ended\s+(\d{1,2}\/\d{1,2}\/\d{2,4})/)?.[1] ?? null;
33 + const lines = c.split('\n').map((l) => l.trim()).filter(Boolean);
34 + const titleLineIdx = lines.findIndex((l) => l.startsWith(`[${title}](`) || l.startsWith(`[${title.replace(/"/g, '')}]`));
35 + const subtitle = titleLineIdx >= 0 ? lines.slice(titleLineIdx + 1).find((l) => !/^Ended|^Featured|^Watch|^!\[|^- \[/.test(l) && !l.startsWith('[')) ?? null : null;
36 + if (!statusText) continue;
37 + items.push({ id, url: link.href, title: md.clean(title), statusText, ended, subtitle: subtitle ? md.clean(subtitle) : null, image: md.image(c) });
38 + }
39 + return { kind: 'past_page', page, items };
40 +}
41 +
42 +export class CarsAndBidsConnector extends BaseConnector {
43 + readonly version = '1.0.0';
44 + readonly parserVersion = PARSER_VERSION;
45 + protected override minIntervalMs = 2000;
46 +
47 + async *crawl(ctx: CrawlContext): AsyncIterable<RawRecordInput> {
48 + const pages = Number(this.meta.config.pagesPerRun ?? 10);
49 + const backfill = ctx.options.mode === 'backfill';
50 + const start = backfill ? Number(ctx.options.cursor?.nextPage ?? 1) : 1;
51 + const newest = !backfill && typeof ctx.options.cursor?.newestEnded === 'string' ? new Date(ctx.options.cursor.newestEnded as string) : null;
52 + let count = 0;
53 + let maxEnded: Date | null = newest;
54 + for (let page = start; page < start + pages; page++) {
55 + if (ctx.signal?.aborted || this.reached(ctx, count)) break;
56 + const url = `${BASE}/past-auctions/${page > 1 ? `?page=${page}` : ''}`;
57 + await this.throttle();
58 + const res = await ctx.fetch(url, {
59 + expect: ['title', 'price', 'date', 'status'],
60 + parse: (r) => {
61 + const p = r.markdown ? parsePastPage(r.markdown, page) : null;
62 + const f = p?.items[0];
63 + return f ? { title: f.title, price: money(f.statusText, 'USD')?.amount ?? null, date: f.ended, status: f.statusText } : null;
64 + },
65 + });
66 + if (!res.success || !res.markdown) {
67 + ctx.anomaly('page_fetch_failed', `${url}: ${res.error ?? res.httpStatus}`);
68 + break;
69 + }
70 + const payload = parsePastPage(res.markdown, page);
71 + if (payload.items.length === 0) {
72 + ctx.anomaly('empty_page', url);
73 + break;
74 + }
75 + count++;
76 + yield { url, externalId: `past:${page}:${payload.items[0]!.id}`, kind: 'sale', engine: res.engine, httpStatus: res.httpStatus, payload, fetchedAt: res.fetchedAt };
77 + const dates = payload.items.map((i) => dateMDY(i.ended)).filter((d): d is Date => Boolean(d));
78 + for (const d of dates) if (!maxEnded || d > maxEnded) maxEnded = d;
79 + const oldest = dates.length ? new Date(Math.min(...dates.map((d) => d.getTime()))) : null;
80 + if (backfill) await ctx.setCursor({ nextPage: page + 1, updatedAt: new Date().toISOString() });
81 + else if (newest && oldest && oldest < newest) break;
82 + }
83 + if (!backfill && maxEnded) await ctx.setCursor({ newestEnded: maxEnded.toISOString(), updatedAt: new Date().toISOString() });
84 + }
85 +
86 + async normalize(raw: RawRecordLike): Promise<NormalizedRecord[]> {
87 + const p = PagePayloadSchema.parse(raw.payload);
88 + const out: NormalizedSale[] = [];
89 + for (const it of p.items) {
90 + if (!/^Sold for/i.test(it.statusText)) continue;
91 + const m = money(it.statusText, 'USD');
92 + const saleDate = dateMDY(it.ended);
93 + if (!m || !saleDate) continue;
94 + const attributes = vehicleAttributes(it.title, { identifiers: { carsandbids_id: it.id }, metadata: { highlights: it.subtitle } });
95 + out.push(makeSale({ meta: this.meta, sourceUrl: it.url, externalId: it.id, rawTitle: it.title, attributes, price: m.amount, currency: m.currency, saleDate, buyerPremiumIncluded: false, auctionHouse: 'Cars & Bids', imageUrls: it.image ? [it.image] : [], description: it.subtitle, observedAt: raw.fetchedAt, parserVersion: PARSER_VERSION }));
96 + }
97 + return out;
98 + }
99 +}
100 +
101 +export default (meta: ConnectorMeta) => new CarsAndBidsConnector(meta);
added connectors/firecrawl/cars-and-bids/meta.json +44 −0
@@ -0,0 +1,44 @@
1 +{
2 + "id": "cars-and-bids",
3 + "displayName": "Cars & Bids (past auctions)",
4 + "sourceId": "cars-and-bids",
5 + "sourceName": "Cars & Bids",
6 + "sourceType": "auction_house",
7 + "sourceUrl": "https://carsandbids.com",
8 + "module": "firecrawl/cars-and-bids",
9 + "enginePriority": [
10 + "firecrawl"
11 + ],
12 + "categories": [
13 + "automobiles"
14 + ],
15 + "regions": [
16 + "US",
17 + "CA"
18 + ],
19 + "languages": [
20 + "en"
21 + ],
22 + "currency": [
23 + "USD",
24 + "CAD"
25 + ],
26 + "supportsListings": false,
27 + "supportsSold": true,
28 + "supportsAuctions": false,
29 + "supportsImages": true,
30 + "supportsCatalog": false,
31 + "supportsPopulation": false,
32 + "supportsLookup": false,
33 + "refreshFrequencyMinutes": 360,
34 + "priority": "high",
35 + "trustScore": 0.9,
36 + "attributionRequired": true,
37 + "termsUrl": "https://carsandbids.com/terms-of-use/",
38 + "accessNotes": "Public 'Past Results' list (/past-auctions/?page=N, ~50 auctions per page, newest first) rendered through Firecrawl (plain HTTPS returns a Cloudflare interstitial; we do not bypass it — Firecrawl fetches the public page like a browser). robots.txt allows the page (only /sell-car/, /widgets/, /dealers/ are disallowed). 'Sold for $X' + 'Ended m/d/yy' rows become sales; 'Bid to' rows (reserve not met) are skipped. Price is the winning bid — the 4.5% buyer fee is NOT included → buyer_premium_included=false. 1 Firecrawl credit per page, 2 s politeness delay.",
39 + "enabled": true,
40 + "schemaVersion": "1.0",
41 + "config": {
42 + "pagesPerRun": 10
43 + }
44 +}
added connectors/firecrawl/collecting-cars/_smoke.ts +7 −0
@@ -0,0 +1,7 @@
1 +import path from 'node:path';
2 +import { fileURLToPath } from 'node:url';
3 +import { captureFixture, runSmoke } from '../_carlib/smoke.js';
4 +
5 +const dir = path.dirname(fileURLToPath(import.meta.url));
6 +if (process.argv[2] === 'capture') await captureFixture(dir, process.argv[3] ?? 'page-1', Number(process.argv[4] ?? 4));
7 +else await runSmoke(dir);
added connectors/firecrawl/collecting-cars/index.test.ts +69 −0
@@ -0,0 +1,69 @@
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 { ConnectorMetaSchema } from '@rareindex/connectors';
6 +import { loadFixture, runFixtureSuite } from '@rareindex/connectors/testing';
7 +import createConnector, { parseSoldPage } from './index.js';
8 +
9 +const dir = path.dirname(fileURLToPath(import.meta.url));
10 +const meta = ConnectorMetaSchema.parse(JSON.parse(readFileSync(path.join(dir, 'meta.json'), 'utf8')));
11 +const connector = createConnector(meta);
12 +
13 +const SAMPLE = `Showing 26,189 lots
14 +
15 +- [![2013 Land Rover Discovery 4 5.0 V8](https://images.collectingcars.com/091453/AS-17-08-14.jpg?w=3840&q=75)\\\\
16 +\\\\
17 +Sold](https://collectingcars.com/for-sale/2013-land-rover-discovery-4-5-0l-v8-2)
18 +
19 +[Sold\\\\
20 +\\\\
21 +**2013 Land Rover Discovery 4 5.0 V8** \\\\
22 +\\\\
23 +Sold\\\\
24 +\\\\
25 +£17,000\\\\
26 +\\\\
27 +06/09/2026\\\\
28 +\\\\
29 +![United Kingdom](https://flagcdn.com/gb.svg)Gatwick](https://collectingcars.com/for-sale/2013-land-rover-discovery-4-5-0l-v8-2)
30 +
31 +- [![1997 Porsche 911 (993) Targa - Manual](https://images.collectingcars.com/091764/25-08-26-JJBB-06.jpg?w=3840&q=75)\\\\
32 +\\\\
33 +Sold](https://collectingcars.com/for-sale/1997-porsche-911-993-targa-17)
34 +
35 +[Sold\\\\
36 +\\\\
37 +**1997 Porsche 911 (993) Targa - Manual** \\\\
38 +\\\\
39 +06/09/2026\\\\
40 +Sign in to view sold price\\\\
41 +\\\\
42 +![United Kingdom](https://flagcdn.com/gb.svg)Bristol](https://collectingcars.com/for-sale/1997-porsche-911-993-targa-17)
43 +`;
44 +
45 +describe('collecting-cars', () => {
46 + runFixtureSuite(connector, it, expect);
47 +
48 + it('keeps only lots whose price is public (never the sign-in-gated ones)', () => {
49 + const p = parseSoldPage(SAMPLE, 1);
50 + expect(p.total).toBe(26189);
51 + expect(p.items.length).toBe(1);
52 + expect(p.items[0]).toMatchObject({ slug: '2013-land-rover-discovery-4-5-0l-v8-2', priceText: '£17,000', dateText: '06/09/2026', country: 'United Kingdom', town: 'Gatwick' });
53 + });
54 +
55 + it('normalises with GBP, dd/mm/yyyy date and hammer semantics', async () => {
56 + const out = await connector.normalize({ url: 'https://collectingcars.com/sold', externalId: 'sold:1', kind: 'sale', engine: 'firecrawl', fetchedAt: new Date('2026-09-07T00:00:00Z'), payload: parseSoldPage(SAMPLE, 1) });
57 + expect(out.length).toBe(1);
58 + const s = out[0]!;
59 + if (s.kind !== 'sale') throw new Error('sale expected');
60 + expect(s).toMatchObject({ price: 17000, currency: 'GBP', buyerPremiumIncluded: false, auctionHouse: 'Collecting Cars', location: 'Gatwick, United Kingdom' });
61 + expect(s.saleDate.toISOString()).toBe('2026-09-06T00:00:00.000Z');
62 + expect(s.attributes).toMatchObject({ brand: 'Land Rover', year: 2013, categorySlug: 'automobiles' });
63 + });
64 +
65 + it('fixture records have currencies and dates', async () => {
66 + const out = await connector.normalize(loadFixture('collecting-cars', 'sold-page').raw);
67 + for (const r of out) if (r.kind === 'sale') expect(['GBP', 'EUR', 'AUD', 'USD', 'AED', 'CAD', 'NZD', 'CHF']).toContain(r.currency);
68 + });
69 +});
added connectors/firecrawl/collecting-cars/index.ts +104 −0
@@ -0,0 +1,104 @@
1 +import { z } from 'zod';
2 +import { BaseConnector, type ConnectorMeta, type CrawlContext, type RawRecordInput, type RawRecordLike } from '@rareindex/connectors';
3 +import type { NormalizedRecord, NormalizedSale } from '@rareindex/shared';
4 +import { dateDMY, makeSale, md, money, splitMarkdownItems, vehicleAttributes } from '../_carlib/index.js';
5 +
6 +const BASE = 'https://collectingcars.com';
7 +const PARSER_VERSION = '1.0.0';
8 +
9 +export const ItemSchema = z.object({
10 + slug: z.string(),
11 + url: z.string(),
12 + title: z.string(),
13 + priceText: z.string(),
14 + dateText: z.string().nullable(),
15 + country: z.string().nullable(),
16 + town: z.string().nullable(),
17 + image: z.string().nullable(),
18 +});
19 +export const PagePayloadSchema = z.object({ kind: z.literal('sold_page'), page: z.number(), total: z.number().nullable(), items: z.array(ItemSchema) });
20 +export type PagePayload = z.infer<typeof PagePayloadSchema>;
21 +
22 +const CURRENCY_LINE = /^(?:£|€|A\$|US\$|CA\$|NZ\$|AED|CHF|kr|\$)\s?[0-9][0-9,.]*/;
23 +
24 +export function parseSoldPage(markdown: string, page: number): PagePayload {
25 + const total = markdown.match(/Showing\s+([\d,]+)\s+lots/i)?.[1];
26 + const chunks = splitMarkdownItems(markdown, /^- \[!\[/m);
27 + const items: z.infer<typeof ItemSchema>[] = [];
28 + for (const c of chunks) {
29 + const href = c.match(/\((https:\/\/collectingcars\.com\/for-sale\/[a-z0-9-]+)\)/)?.[1];
30 + if (!href) continue;
31 + const slug = href.split('/').pop()!;
32 + const title = c.match(/\*\*([^*]+)\*\*/)?.[1]?.trim() ?? c.match(/^- \[!\[([^\]]+)\]/)?.[1] ?? null;
33 + if (!title) continue;
34 + const lines = c.split('\n').map((l) => l.replace(/\\+$/, '').trim()).filter(Boolean);
35 + const priceText = lines.find((l) => CURRENCY_LINE.test(l)) ?? null;
36 + if (!priceText) continue; // no price → not sold
37 + const dateText = lines.find((l) => /^\d{2}\/\d{2}\/\d{4}$/.test(l)) ?? null;
38 + const flag = c.match(/!\[([^\]]+)\]\(https:\/\/flagcdn\.com\/[a-z]{2}\.svg\)([^\]\n]*)/);
39 + items.push({ slug, url: href, title: md.clean(title), priceText, dateText, country: flag?.[1]?.trim() ?? null, town: flag?.[2]?.trim() || null, image: md.image(c) });
40 + }
41 + return { kind: 'sold_page', page, total: total ? Number(total.replace(/,/g, '')) : null, items };
42 +}
43 +
44 +const MEMORABILIA = /\b(watch|helmet|poster|sign|number plate|registration|memorabilia|model|artwork|painting|print|petrol pump|engine|wheel set|wheels|seat|suit|jacket|book|literature|steering wheel|trophy)\b/i;
45 +
46 +export class CollectingCarsConnector extends BaseConnector {
47 + readonly version = '1.0.0';
48 + readonly parserVersion = PARSER_VERSION;
49 + protected override minIntervalMs = 2000;
50 +
51 + async *crawl(ctx: CrawlContext): AsyncIterable<RawRecordInput> {
52 + const pages = Number(this.meta.config.pagesPerRun ?? 10);
53 + const backfill = ctx.options.mode === 'backfill';
54 + const start = backfill ? Number(ctx.options.cursor?.nextPage ?? 1) : 1;
55 + const newest = !backfill && typeof ctx.options.cursor?.newestSold === 'string' ? new Date(ctx.options.cursor.newestSold as string) : null;
56 + let count = 0;
57 + let maxSold: Date | null = newest;
58 + for (let page = start; page < start + pages; page++) {
59 + if (ctx.signal?.aborted || this.reached(ctx, count)) break;
60 + const url = `${BASE}/sold${page > 1 ? `?page=${page}` : ''}`;
61 + await this.throttle();
62 + const res = await ctx.fetch(url, {
63 + expect: ['title', 'price', 'date', 'status'],
64 + parse: (r) => {
65 + const f = r.markdown ? parseSoldPage(r.markdown, page).items[0] : null;
66 + return f ? { title: f.title, price: money(f.priceText)?.amount ?? null, date: f.dateText, status: 'sold' } : null;
67 + },
68 + });
69 + if (!res.success || !res.markdown) {
70 + ctx.anomaly('page_fetch_failed', `${url}: ${res.error ?? res.httpStatus}`);
71 + break;
72 + }
73 + const payload = parseSoldPage(res.markdown, page);
74 + if (payload.items.length === 0) {
75 + ctx.anomaly('empty_page', url);
76 + break;
77 + }
78 + count++;
79 + yield { url, externalId: `sold:${page}:${payload.items[0]!.slug}`, kind: 'sale', engine: res.engine, httpStatus: res.httpStatus, payload, fetchedAt: res.fetchedAt };
80 + const dates = payload.items.map((i) => dateDMY(i.dateText)).filter((d): d is Date => Boolean(d));
81 + for (const d of dates) if (!maxSold || d > maxSold) maxSold = d;
82 + const oldest = dates.length ? new Date(Math.min(...dates.map((d) => d.getTime()))) : null;
83 + if (backfill) await ctx.setCursor({ nextPage: page + 1, updatedAt: new Date().toISOString() });
84 + else if (newest && oldest && oldest < newest) break;
85 + }
86 + if (!backfill && maxSold) await ctx.setCursor({ newestSold: maxSold.toISOString(), updatedAt: new Date().toISOString() });
87 + }
88 +
89 + async normalize(raw: RawRecordLike): Promise<NormalizedRecord[]> {
90 + const p = PagePayloadSchema.parse(raw.payload);
91 + const out: NormalizedSale[] = [];
92 + for (const it of p.items) {
93 + const m = money(it.priceText, 'GBP');
94 + const saleDate = dateDMY(it.dateText);
95 + if (!m || !saleDate) continue;
96 + const memorabilia = MEMORABILIA.test(it.title) && !/^\d{4}\s/.test(it.title);
97 + const attributes = vehicleAttributes(it.title, { country: it.country, identifiers: { collectingcars_slug: it.slug }, metadata: { town: it.town }, ...(memorabilia ? { categorySlug: 'automotive_memorabilia' as const } : {}) });
98 + out.push(makeSale({ meta: this.meta, sourceUrl: it.url, externalId: it.slug, rawTitle: it.title, attributes, price: m.amount, currency: m.currency, saleDate, buyerPremiumIncluded: false, auctionHouse: 'Collecting Cars', imageUrls: it.image ? [it.image.replace(/\?.*$/, '')] : [], location: [it.town, it.country].filter(Boolean).join(', ') || null, observedAt: raw.fetchedAt, parserVersion: PARSER_VERSION }));
99 + }
100 + return out;
101 + }
102 +}
103 +
104 +export default (meta: ConnectorMeta) => new CollectingCarsConnector(meta);
added connectors/firecrawl/collecting-cars/meta.json +52 −0
@@ -0,0 +1,52 @@
1 +{
2 + "id": "collecting-cars",
3 + "displayName": "Collecting Cars (results)",
4 + "sourceId": "collecting-cars",
5 + "sourceName": "Collecting Cars",
6 + "sourceType": "auction_house",
7 + "sourceUrl": "https://collectingcars.com",
8 + "module": "firecrawl/collecting-cars",
9 + "enginePriority": [
10 + "firecrawl"
11 + ],
12 + "categories": [
13 + "automobiles",
14 + "motorcycles",
15 + "automotive_memorabilia"
16 + ],
17 + "regions": [
18 + "GB",
19 + "EU",
20 + "AU",
21 + "US",
22 + "AE"
23 + ],
24 + "languages": [
25 + "en"
26 + ],
27 + "currency": [
28 + "GBP",
29 + "EUR",
30 + "AUD",
31 + "USD",
32 + "AED"
33 + ],
34 + "supportsListings": false,
35 + "supportsSold": true,
36 + "supportsAuctions": false,
37 + "supportsImages": true,
38 + "supportsCatalog": false,
39 + "supportsPopulation": false,
40 + "supportsLookup": false,
41 + "refreshFrequencyMinutes": 720,
42 + "priority": "low",
43 + "trustScore": 0.9,
44 + "attributionRequired": true,
45 + "termsUrl": "https://collectingcars.com/terms-and-conditions",
46 + "accessNotes": "Public results list (/sold?page=N, 48 lots per page, ~26k sold lots, newest first) rendered through Firecrawl (plain HTTPS gets a Cloudflare interstitial; not bypassed). robots.txt: Allow / for generic agents (named AI-training crawlers are disallowed; RareIndexBot is not one — we only record public sale facts and link back). Most sold prices are shown only to signed-in users ('Sign in to view sold price'); we NEVER log in, so only lots whose price is displayed publicly (a minority, typically the most recent/featured results) become sales — expect a few sales per page. Prices are hammer prices — the buyer's premium is charged on top → buyer_premium_included=false. 1 Firecrawl credit per page, 2 s politeness delay.",
47 + "enabled": true,
48 + "schemaVersion": "1.0",
49 + "config": {
50 + "pagesPerRun": 10
51 + }
52 +}
added connectors/firecrawl/mecum/_smoke.ts +7 −0
@@ -0,0 +1,7 @@
1 +import path from 'node:path';
2 +import { fileURLToPath } from 'node:url';
3 +import { captureFixture, runSmoke } from '../_carlib/smoke.js';
4 +
5 +const dir = path.dirname(fileURLToPath(import.meta.url));
6 +if (process.argv[2] === 'capture') await captureFixture(dir, process.argv[3] ?? 'page-1', Number(process.argv[4] ?? 4));
7 +else await runSmoke(dir);
added connectors/firecrawl/mecum/index.test.ts +70 −0
@@ -0,0 +1,70 @@
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 { ConnectorMetaSchema } from '@rareindex/connectors';
6 +import { loadFixture, runFixtureSuite } from '@rareindex/connectors/testing';
7 +import createConnector, { parseAuctionDates, parseLotsPage, parseResultsSlugs } from './index.js';
8 +
9 +const dir = path.dirname(fileURLToPath(import.meta.url));
10 +const meta = ConnectorMetaSchema.parse(JSON.parse(readFileSync(path.join(dir, 'meta.json'), 'utf8')));
11 +const connector = createConnector(meta);
12 +
13 +const MD = `# Monterey 2026
14 +
15 +August 13-15
16 +
17 +[View 1963 Ferrari 250 GT/L Berlinetta Lusso](https://www.mecum.com/lots/1175988/1963-ferrari-250-gt-l-berlinetta-lusso?aa_id=793591-0) [![sold](https://www.mecum.com/_next/static/media/lot-listing-sold.webp)\\\\
18 +\\\\
19 +$1,650,000\\\\
20 +\\\\
21 +![1963 Ferrari 250 GT/L Berlinetta Lusso](https://images.mecum.com/image/upload/a.jpg?)](https://www.mecum.com/lots/1175988/1963-ferrari-250-gt-l-berlinetta-lusso?aa_id=793591-0)
22 +
23 +Lot S126
24 +
25 +[Monterey 2026](https://www.mecum.com/auctions/monterey-2026/)
26 +
27 +[1963 Ferrari 250 GT/L Berlinetta Lusso](https://www.mecum.com/lots/1175988/1963-ferrari-250-gt-l-berlinetta-lusso?aa_id=793591-0)
28 +
29 +S/N 4635, The 79th of 350 Produced From 1962-64
30 +
31 +[View 1969 Chevrolet Camaro](https://www.mecum.com/lots/1175999/1969-chevrolet-camaro?aa_id=1) [![bid goes on](https://www.mecum.com/_next/static/media/x.webp)\\\\
32 +\\\\
33 +$50,000\\\\
34 +\\\\
35 +![1969 Chevrolet Camaro](https://images.mecum.com/image/upload/b.jpg?)](https://www.mecum.com/lots/1175999/1969-chevrolet-camaro?aa_id=1)
36 +
37 +Lot S127
38 +`;
39 +
40 +describe('mecum', () => {
41 + runFixtureSuite(connector, it, expect);
42 +
43 + it('parses lots, sold badges, results slugs and auction dates', () => {
44 + const p = parseLotsPage(MD, 'monterey-2026', 1);
45 + expect(p.auctionName).toBe('Monterey 2026');
46 + expect(p.dateText).toBe('August 13-15');
47 + expect(p.lots.length).toBe(2);
48 + expect(p.lots[0]).toMatchObject({ lotId: '1175988', lotNumber: 'S126', sold: true, priceText: '$1,650,000', subtitle: 'S/N 4635, The 79th of 350 Produced From 1962-64' });
49 + expect(p.lots[1]!.sold).toBe(false);
50 + expect(parseResultsSlugs('<a href=\\"/auctions/monterey-2026/\\"></a><a href=\\"/auctions/houston-2026/\\">')).toEqual(['monterey-2026', 'houston-2026']);
51 + expect(parseAuctionDates('{\\"startDate\\":\\"2026-08-13T00:00:00+00:00\\",\\"endDate\\":\\"2026-08-15T00:00:00+00:00\\"}')).toEqual({ startDate: '2026-08-13T00:00:00+00:00', endDate: '2026-08-15T00:00:00+00:00' });
52 + });
53 +
54 + it('normalises only sold lots, dated by the auction start, premium unknown', async () => {
55 + const payload = { ...parseLotsPage(MD, 'monterey-2026', 1), startDate: '2026-08-13T00:00:00+00:00', endDate: '2026-08-15T00:00:00+00:00' };
56 + const out = await connector.normalize({ url: 'x', externalId: 'm', kind: 'sale', engine: 'firecrawl', fetchedAt: new Date('2026-09-07T00:00:00Z'), payload });
57 + expect(out.length).toBe(1);
58 + const s = out[0]!;
59 + if (s.kind !== 'sale') throw new Error('sale');
60 + expect(s).toMatchObject({ price: 1650000, currency: 'USD', buyerPremiumIncluded: null, lotNumber: 'S126', auctionHouse: 'Mecum Auctions' });
61 + expect(s.saleDate.toISOString()).toBe('2026-08-13T00:00:00.000Z');
62 + expect(s.attributes).toMatchObject({ brand: 'Ferrari', year: 1963, categorySlug: 'automobiles' });
63 + });
64 +
65 + it('fixture sales carry Mecum lot ids', async () => {
66 + const out = await connector.normalize(loadFixture('mecum', 'lots-page').raw);
67 + expect(out.length).toBeGreaterThan(0);
68 + for (const r of out) if (r.kind === 'sale') expect(r.attributes.identifiers.mecum_lot_id).toMatch(/^\d+$/);
69 + });
70 +});
added connectors/firecrawl/mecum/index.ts +138 −0
@@ -0,0 +1,138 @@
1 +import { z } from 'zod';
2 +import { BaseConnector, type ConnectorMeta, type CrawlContext, type RawRecordInput, type RawRecordLike } from '@rareindex/connectors';
3 +import type { NormalizedRecord, NormalizedSale } from '@rareindex/shared';
4 +import { dateWords, makeSale, md, money, splitMarkdownItems, vehicleAttributes } from '../_carlib/index.js';
5 +
6 +const BASE = 'https://www.mecum.com';
7 +const PARSER_VERSION = '1.0.0';
8 +
9 +export const LotSchema = z.object({ lotId: z.string(), url: z.string(), title: z.string(), lotNumber: z.string().nullable(), sold: z.boolean(), priceText: z.string().nullable(), subtitle: z.string().nullable(), image: z.string().nullable() });
10 +export const PagePayloadSchema = z.object({ kind: z.literal('lots_page'), auctionSlug: z.string(), auctionName: z.string().nullable(), dateText: z.string().nullable(), startDate: z.string().nullable().optional(), endDate: z.string().nullable().optional(), page: z.number(), lots: z.array(LotSchema) });
11 +export type PagePayload = z.infer<typeof PagePayloadSchema>;
12 +
13 +/** Completed auction slugs from the /results/ page HTML (Next.js payload, escaped). Newest first as listed. */
14 +export function parseResultsSlugs(html: string): string[] {
15 + const u = html.replace(/\\"/g, '"').replace(/\\\//g, '/');
16 + const out: string[] = [];
17 + for (const m of u.matchAll(/\/auctions\/([a-z0-9-]+-(?:19|20)\d{2})\//g)) if (!out.includes(m[1]!)) out.push(m[1]!);
18 + return out;
19 +}
20 +
21 +/** Auction page HTML → { startDate, endDate } from the embedded schema.org Event (ISO strings) */
22 +export function parseAuctionDates(html: string): { startDate: string | null; endDate: string | null } {
23 + const u = html.replace(/\\"/g, '"');
24 + return { startDate: u.match(/"startDate":"([^"]+)"/)?.[1] ?? null, endDate: u.match(/"endDate":"([^"]+)"/)?.[1] ?? null };
25 +}
26 +
27 +export function parseLotsPage(markdown: string, auctionSlug: string, page: number): PagePayload {
28 + const header = markdown.match(/(?:^|\n)# ([^\n]+)\n\n([^\n]+)\n/);
29 + const chunks = splitMarkdownItems(markdown, /^\[View /m);
30 + const lots: z.infer<typeof LotSchema>[] = [];
31 + for (const c of chunks) {
32 + const head = c.match(/^\[View ([^\]]+)\]\((https:\/\/www\.mecum\.com\/lots\/(\d+)\/[a-z0-9-]+\/?)[^)]*\)/);
33 + if (!head) continue;
34 + const lines = c.split('\n').map((l) => l.replace(/\\+$/, '').trim()).filter(Boolean);
35 + const priceText = lines.find((l) => /^\$[\d,]+$/.test(l)) ?? null;
36 + const sold = /!\[sold\]/i.test(c);
37 + const lotNumber = c.match(/\nLot ([A-Z]?\d+(?:\.\d+)?)\b/)?.[1] ?? null;
38 + const titleIdx = lines.findIndex((l) => l.startsWith(`[${head[1]}](`));
39 + const subtitle = titleIdx >= 0 ? lines.slice(titleIdx + 1).find((l) => !l.startsWith('[') && !l.startsWith('!')) ?? null : null;
40 + lots.push({ lotId: head[3]!, url: head[2]!, title: md.clean(head[1]!), lotNumber, sold, priceText, subtitle: subtitle ? md.clean(subtitle) : null, image: c.match(/!\[[^\]]*\]\((https:\/\/images\.mecum\.com\/[^)\s]+)\)/)?.[1] ?? null });
41 + }
42 + return { kind: 'lots_page', auctionSlug, auctionName: header?.[1]?.trim() ?? null, dateText: header?.[2]?.trim() ?? null, page, lots };
43 +}
44 +
45 +const MEMORABILIA = /\b(sign|neon|gas pump|petroliana|pedal car|poster|helmet|memorabilia|collection of|display|clock|toy)\b/i;
46 +
47 +export class MecumConnector extends BaseConnector {
48 + readonly version = '1.0.0';
49 + readonly parserVersion = PARSER_VERSION;
50 + protected override minIntervalMs = 2000;
51 +
52 + async *crawl(ctx: CrawlContext): AsyncIterable<RawRecordInput> {
53 + const auctionsPerRun = Number(this.meta.config.auctionsPerRun ?? 1);
54 + const lotPages = Number(this.meta.config.lotPagesPerAuction ?? 10);
55 + const progress = (ctx.options.cursor?.progress ?? {}) as Record<string, number | 'done'>;
56 + await this.throttle();
57 + const list = await ctx.fetch(`${BASE}/results/`, { engines: ['api'], responseType: 'text', expect: ['title'], parse: (r) => (r.html ? { title: parseResultsSlugs(r.html)[0] ?? null } : null) });
58 + if (!list.success || !list.html) {
59 + ctx.anomaly('page_fetch_failed', `${BASE}/results/: ${list.error ?? list.httpStatus}`);
60 + return;
61 + }
62 + const skipUntil = (ctx.options.cursor?.skipUntil ?? {}) as Record<string, string>;
63 + const nowIso = new Date().toISOString();
64 + const slugs = parseResultsSlugs(list.html).filter((s) => progress[s] !== 'done' && !(skipUntil[s] && skipUntil[s]! > nowIso));
65 + let count = 0;
66 + let processed = 0;
67 + for (const slug of slugs) {
68 + if (processed >= auctionsPerRun || ctx.signal?.aborted) break;
69 + // Auction page (plain HTTPS, free) carries schema.org Event dates: skip sales not yet held.
70 + await this.throttle();
71 + const ap = await ctx.fetch(`${BASE}/auctions/${slug}/`, { engines: ['api'], responseType: 'text', expect: ['date'], parse: (r) => (r.html ? { date: parseAuctionDates(r.html).startDate } : null), minQuality: 0 });
72 + const dates = ap.html ? parseAuctionDates(ap.html) : { startDate: null, endDate: null };
73 + if (dates.startDate && new Date(dates.startDate).getTime() > Date.now()) {
74 + skipUntil[slug] = dates.startDate;
75 + await ctx.setCursor({ progress, skipUntil, updatedAt: new Date().toISOString() });
76 + ctx.log.info({ slug, start: dates.startDate }, 'mecum auction not yet held; skipping');
77 + continue;
78 + }
79 + const startPage = typeof progress[slug] === 'number' ? (progress[slug] as number) : 1;
80 + let page = startPage;
81 + let finished = false;
82 + for (; page < startPage + lotPages; page++) {
83 + if (ctx.signal?.aborted || this.reached(ctx, count)) break;
84 + const url = `${BASE}/auctions/${slug}/lots/?page=${page}`;
85 + await this.throttle();
86 + const res = await ctx.fetch(url, {
87 + engines: ['firecrawl'],
88 + waitForMs: 9000,
89 + expect: ['title', 'price', 'status'],
90 + parse: (r) => {
91 + const p = r.markdown ? parseLotsPage(r.markdown, slug, page) : null;
92 + const f = p?.lots.find((l) => l.sold && l.priceText);
93 + return f ? { title: f.title, price: money(f.priceText, 'USD')?.amount ?? null, status: 'sold' } : p?.lots.length ? { title: p.lots[0]!.title } : null;
94 + },
95 + minQuality: 0.3,
96 + });
97 + if (!res.success || !res.markdown) {
98 + ctx.anomaly('page_fetch_failed', `${url}: ${res.error ?? res.httpStatus}`);
99 + break;
100 + }
101 + const payload = { ...parseLotsPage(res.markdown, slug, page), startDate: dates.startDate, endDate: dates.endDate };
102 + if (payload.lots.length === 0) {
103 + finished = true;
104 + break;
105 + }
106 + count++;
107 + yield { url, externalId: `auction:${slug}:page:${page}`, kind: 'sale', engine: res.engine, httpStatus: res.httpStatus, payload, fetchedAt: res.fetchedAt };
108 + if (!res.markdown.includes(`/auctions/${slug}/lots/?page=${page + 1}`)) {
109 + finished = true;
110 + page++;
111 + break;
112 + }
113 + }
114 + progress[slug] = finished ? 'done' : page;
115 + processed++;
116 + await ctx.setCursor({ progress, skipUntil, updatedAt: new Date().toISOString() });
117 + }
118 + }
119 +
120 + async normalize(raw: RawRecordLike): Promise<NormalizedRecord[]> {
121 + const p = PagePayloadSchema.parse(raw.payload);
122 + const year = Number(p.auctionSlug.match(/(19|20)\d{2}$/)?.[0] ?? NaN);
123 + const saleDate = (p.startDate ? new Date(p.startDate) : null) ?? dateWords(p.dateText ?? '', Number.isFinite(year) ? year : null);
124 + if (!saleDate) return [];
125 + const out: NormalizedSale[] = [];
126 + for (const lot of p.lots) {
127 + if (!lot.sold || !lot.priceText) continue;
128 + const m = money(lot.priceText, 'USD');
129 + if (!m) continue;
130 + const memorabilia = MEMORABILIA.test(lot.title) && !/^\d{4}\s/.test(lot.title);
131 + const attributes = vehicleAttributes(lot.title, { country: 'US', identifiers: { mecum_lot_id: lot.lotId }, metadata: { auction: p.auctionName ?? p.auctionSlug, auction_dates: p.dateText, auction_start: p.startDate ?? null, auction_end: p.endDate ?? null, highlights: lot.subtitle }, ...(memorabilia ? { categorySlug: 'automotive_memorabilia' as const } : {}), moto: /motorcycle/i.test(p.auctionSlug) });
132 + out.push(makeSale({ meta: this.meta, sourceUrl: lot.url, externalId: lot.lotId, rawTitle: lot.title, attributes, price: m.amount, currency: 'USD', saleDate, buyerPremiumIncluded: null, auctionHouse: 'Mecum Auctions', lotNumber: lot.lotNumber, imageUrls: lot.image ? [lot.image] : [], description: lot.subtitle, location: 'US', observedAt: raw.fetchedAt, parserVersion: PARSER_VERSION, confidence: 0.85 }));
133 + }
134 + return out;
135 + }
136 +}
137 +
138 +export default (meta: ConnectorMeta) => new MecumConnector(meta);
added connectors/firecrawl/mecum/meta.json +45 −0
@@ -0,0 +1,45 @@
1 +{
2 + "id": "mecum",
3 + "displayName": "Mecum Auctions (results)",
4 + "sourceId": "mecum",
5 + "sourceName": "Mecum Auctions",
6 + "sourceType": "auction_house",
7 + "sourceUrl": "https://www.mecum.com",
8 + "module": "firecrawl/mecum",
9 + "enginePriority": [
10 + "firecrawl"
11 + ],
12 + "categories": [
13 + "automobiles",
14 + "motorcycles",
15 + "automotive_memorabilia"
16 + ],
17 + "regions": [
18 + "US"
19 + ],
20 + "languages": [
21 + "en"
22 + ],
23 + "currency": [
24 + "USD"
25 + ],
26 + "supportsListings": false,
27 + "supportsSold": true,
28 + "supportsAuctions": false,
29 + "supportsImages": true,
30 + "supportsCatalog": false,
31 + "supportsPopulation": false,
32 + "supportsLookup": false,
33 + "refreshFrequencyMinutes": 720,
34 + "priority": "high",
35 + "trustScore": 0.9,
36 + "attributionRequired": true,
37 + "termsUrl": "https://www.mecum.com/terms-and-conditions/",
38 + "accessNotes": "Public results: the /results/ page lists completed auctions (plain HTTPS), each auction's lot list (/auctions/<slug>/lots/?page=N, 24 lots per page) is rendered through Firecrawl because prices are client-rendered. robots.txt allows the pages (only /search/ is disallowed). Lots carrying the 'sold' badge with a price become sales; 'bid goes on'/unsold lots are skipped. Sale date = first day of the auction's date range (range kept in metadata). Mecum does not state on the results page whether displayed prices include the buyer's premium → buyer_premium_included=null (flagged for review). 1 credit per page, 2 s politeness delay.",
39 + "enabled": true,
40 + "schemaVersion": "1.0",
41 + "config": {
42 + "auctionsPerRun": 1,
43 + "lotPagesPerAuction": 10
44 + }
45 +}
added connectors/firecrawl/pba-galleries/_smoke.ts +7 −0
@@ -0,0 +1,7 @@
1 +import path from 'node:path';
2 +import { fileURLToPath } from 'node:url';
3 +import { captureFixture, runSmoke } from '../_carlib/smoke.js';
4 +
5 +const dir = path.dirname(fileURLToPath(import.meta.url));
6 +if (process.argv[2] === 'capture') await captureFixture(dir, process.argv[3] ?? 'page-1', Number(process.argv[4] ?? 4));
7 +else await runSmoke(dir);
added connectors/firecrawl/pba-galleries/index.test.ts +77 −0
@@ -0,0 +1,77 @@
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 { ConnectorMetaSchema } from '@rareindex/connectors';
6 +import { loadFixture, runFixtureSuite } from '@rareindex/connectors/testing';
7 +import createConnector, { parseAuctionList, parseCatalogPage, pbaCategory } from './index.js';
8 +
9 +const dir = path.dirname(fileURLToPath(import.meta.url));
10 +const meta = ConnectorMetaSchema.parse(JSON.parse(readFileSync(path.join(dir, 'meta.json'), 'utf8')));
11 +const connector = createConnector(meta);
12 +
13 +const LIST = `- [![Sale 3034](https://pbagalleries.com/images/auction/617_m.jpg)](https://pbagalleries.com/auctions/info/id/667)
14 +- ###### [3034 Sale 3034: EC, MAD, PRE-CODE HORROR and R. CRUMB](https://pbagalleries.com/auctions/info/id/667)
15 +
16 +[Live](https://pbagalleries.com/auctions/info/id/667),
17 +[04/25/2024 11:00 AM PDT](https://pbagalleries.com/auctions/info/id/667),
18 +[Sale closed](https://pbagalleries.com/auctions/info/id/667)
19 +
20 +Lots: 429
21 +`;
22 +
23 +const CATALOG = `- [![ADVENTURES INTO TERROR No. 43](https://pbagalleries.com/images/lot/5136/513638_m.jpg?ts=1)](https://pbagalleries.com/lot-details/index/catalog/667/lot/225917/ADVENTURES-INTO-TERROR-No-43-1st-Issue?url=%2Fauctions%2Fcatalog%2Fid%2F667)
24 +
25 +_[Lot #2](https://pbagalleries.com/lot-details/index/catalog/667/lot/225917/ADVENTURES-INTO-TERROR-No-43-1st-Issue?url=x)_
26 +
27 +## [ADVENTURES INTO TERROR \\#43 \\* CGC 4.5 \\* Russ Heath Cover](https://pbagalleries.com/lot-details/index/catalog/667/lot/225917/ADVENTURES-INTO-TERROR-No-43-1st-Issue?url=x)
28 +
29 + - Title
30 +
31 + ADVENTURES INTO TERROR No. 43 \\* 1st Issue
32 +
33 + - Publisher
34 +
35 + Atlas \\[Indicia: Cutlass Comics\\]
36 +
37 + - Date Published
38 +
39 + December, 1952
40 +
41 + - Estimate$300 \\- $500
42 + - Sold for$281.25
43 + - StatusSold
44 +`;
45 +
46 +describe('pba-galleries', () => {
47 + runFixtureSuite(connector, it, expect);
48 +
49 + it('parses the auction list and a catalog page', () => {
50 + const auctions = parseAuctionList(LIST);
51 + expect(auctions).toEqual([{ catalogId: '667', saleNumber: '3034', title: 'Sale 3034: EC, MAD, PRE-CODE HORROR and R. CRUMB', dateText: '04/25/2024', lots: 429, closed: true }]);
52 + const page = parseCatalogPage(CATALOG, auctions[0]!, 1);
53 + expect(page.lots.length).toBe(1);
54 + expect(page.lots[0]).toMatchObject({ lotNumber: '2', lotId: '225917', soldText: '$281.25', status: 'Sold', estimateText: '$300 - $500' });
55 + expect(page.lots[0]!.fields.Publisher).toContain('Atlas');
56 + });
57 +
58 + it('normalises with 25% premium included, comics category and issue number', async () => {
59 + const auction = parseAuctionList(LIST)[0]!;
60 + const out = await connector.normalize({ url: 'x', externalId: 'c', kind: 'sale', engine: 'firecrawl', fetchedAt: new Date('2026-09-07T00:00:00Z'), payload: parseCatalogPage(CATALOG, auction, 1) });
61 + expect(out.length).toBe(1);
62 + const s = out[0]!;
63 + if (s.kind !== 'sale') throw new Error('sale');
64 + expect(s).toMatchObject({ price: 281.25, currency: 'USD', buyerPremiumIncluded: true, lotNumber: '2', auctionHouse: 'PBA Galleries' });
65 + expect(s.saleDate.toISOString()).toBe('2024-04-25T00:00:00.000Z');
66 + expect(s.attributes).toMatchObject({ categorySlug: 'marvel_comics', number: '43', year: 1952 });
67 + expect(s.grade).toMatchObject({ grader: 'cgc', grade: '4.5' });
68 + expect(pbaCategory('Fine Books & Manuscripts', { title: 'Moby-Dick, first edition', fields: {} })).toBe('books');
69 + expect(pbaCategory('Photographs', { title: 'Ansel Adams, Moonrise', fields: {} })).toBe('photography');
70 + });
71 +
72 + it('fixture sales are premium-inclusive USD', async () => {
73 + const out = await connector.normalize(loadFixture('pba-galleries', 'catalog-page').raw);
74 + expect(out.length).toBeGreaterThan(0);
75 + for (const r of out) if (r.kind === 'sale') expect(r.currency).toBe('USD');
76 + });
77 +});
added connectors/firecrawl/pba-galleries/index.ts +167 −0
@@ -0,0 +1,167 @@
1 +import { z } from 'zod';
2 +import { BaseConnector, type ConnectorMeta, type CrawlContext, type RawRecordInput, type RawRecordLike } from '@rareindex/connectors';
3 +import { parseGradeFromTitle } from '@rareindex/taxonomy';
4 +import type { NormalizedRecord, NormalizedSale } from '@rareindex/shared';
5 +import { dateMDY, lotAttributes, makeSale, md, money, splitMarkdownItems } from '../_carlib/index.js';
6 +
7 +const BASE = 'https://pbagalleries.com';
8 +const PARSER_VERSION = '1.0.0';
9 +
10 +export const AuctionSchema = z.object({ catalogId: z.string(), saleNumber: z.string().nullable(), title: z.string(), dateText: z.string().nullable(), lots: z.number().nullable(), closed: z.boolean() });
11 +export const LotSchema = z.object({
12 + lotNumber: z.string().nullable(),
13 + lotId: z.string(),
14 + title: z.string(),
15 + url: z.string(),
16 + soldText: z.string().nullable(),
17 + status: z.string().nullable(),
18 + estimateText: z.string().nullable(),
19 + fields: z.record(z.string(), z.string()),
20 + image: z.string().nullable(),
21 +});
22 +export const CatalogPayloadSchema = z.object({ kind: z.literal('catalog_page'), auction: AuctionSchema, page: z.number(), lots: z.array(LotSchema) });
23 +export type CatalogPayload = z.infer<typeof CatalogPayloadSchema>;
24 +
25 +export function parseAuctionList(markdown: string): z.infer<typeof AuctionSchema>[] {
26 + const out: z.infer<typeof AuctionSchema>[] = [];
27 + const chunks = splitMarkdownItems(markdown, /^- \[!\[/m);
28 + for (const c of chunks) {
29 + const info = c.match(/\((https:\/\/pbagalleries\.com\/auctions\/info\/id\/(\d+))\)/);
30 + if (!info) continue;
31 + const catalogId = info[2]!;
32 + const head = c.match(/######\s*\[(?:(\d+)\s+)?([^\]]+)\]/);
33 + const title = md.clean(head?.[2] ?? '');
34 + if (!title) continue;
35 + const dateText = c.match(/\[(\d{2}\/\d{2}\/\d{4})[^\]]*\]/)?.[1] ?? null;
36 + const lots = c.match(/Lots:\s*(\d+)/)?.[1];
37 + const closed = /Sale closed/i.test(c);
38 + if (!out.some((a) => a.catalogId === catalogId)) out.push({ catalogId, saleNumber: head?.[1] ?? null, title, dateText, lots: lots ? Number(lots) : null, closed });
39 + }
40 + return out;
41 +}
42 +
43 +export function parseCatalogPage(markdown: string, auction: z.infer<typeof AuctionSchema>, page: number): CatalogPayload {
44 + const chunks = splitMarkdownItems(markdown, /^- \[!\[/m);
45 + const lots: z.infer<typeof LotSchema>[] = [];
46 + for (const c of chunks) {
47 + const url = c.match(/\((https:\/\/pbagalleries\.com\/lot-details\/index\/catalog\/\d+\/lot\/(\d+)\/[^)?\s]+)/);
48 + if (!url) continue;
49 + const title = c.match(/##\s*\[([^\]]+)\]/)?.[1];
50 + if (!title) continue;
51 + const fields: Record<string, string> = {};
52 + const fre = /^\s*-\s+([A-Z][A-Za-z /]+)\n\n\s+(.+)$/gm;
53 + let fm: RegExpExecArray | null;
54 + while ((fm = fre.exec(c))) fields[fm[1]!.trim()] = md.clean(fm[2]!);
55 + lots.push({
56 + lotNumber: c.match(/\[Lot #(\d+)\]/)?.[1] ?? null,
57 + lotId: url[2]!,
58 + title: md.clean(title),
59 + url: url[1]!,
60 + soldText: c.match(/Sold for\s*(\$[\d,]+(?:\.\d+)?)/)?.[1] ?? null,
61 + status: c.match(/Status\s*([A-Za-z ]+)/)?.[1]?.trim() ?? null,
62 + estimateText: md.clean(c.match(/Estimate\s*(\$[^\n]+)/)?.[1] ?? '') || null,
63 + fields,
64 + image: md.image(c),
65 + });
66 + }
67 + return { kind: 'catalog_page', auction, page, lots };
68 +}
69 +
70 +export function pbaCategory(saleTitle: string, lot: { title: string; fields: Record<string, string> }): string {
71 + const s = `${saleTitle}`.toLowerCase();
72 + const t = `${lot.title} ${Object.values(lot.fields).join(' ')}`.toLowerCase();
73 + if (/comic|pre-code|ec,|mad,|graphic novel/.test(s) || /cgc|cbcs|no\. \d+ \*|comic/.test(t)) {
74 + const pub = (lot.fields.Publisher ?? '').toLowerCase();
75 + if (/marvel|timely|atlas/.test(pub)) return 'marvel_comics';
76 + if (/\bdc\b|national|vertigo/.test(pub)) return 'dc_comics';
77 + return 'independent_comics';
78 + }
79 + if (/photograph/.test(s) && !/book/.test(t)) return 'photography';
80 + if (/map|atlas|cartograph/.test(s)) return 'maps';
81 + if (/poster/.test(s) || /poster/.test(t)) return 'movie_posters';
82 + if (/\b(autograph letter|letter signed|typed letter|manuscript (?:leaf|page|document)|signed document|archive of|telegram|deed|land grant)\b/.test(t)) return 'historical_documents';
83 + if (/fine art|print|painting/.test(s) && !/book/.test(t)) return 'art';
84 + return 'books';
85 +}
86 +
87 +export class PbaGalleriesConnector extends BaseConnector {
88 + readonly version = '1.0.0';
89 + readonly parserVersion = PARSER_VERSION;
90 + protected override minIntervalMs = 2000;
91 +
92 + async *crawl(ctx: CrawlContext): AsyncIterable<RawRecordInput> {
93 + const auctionsPerRun = Number(this.meta.config.auctionsPerRun ?? 2);
94 + const pagesPerAuction = Number(this.meta.config.catalogPagesPerAuction ?? 5);
95 + const done = new Set<string>(Array.isArray(ctx.options.cursor?.doneCatalogs) ? (ctx.options.cursor!.doneCatalogs as string[]) : []);
96 + const listPage = ctx.options.mode === 'backfill' ? Number(ctx.options.cursor?.listPage ?? 1) : 1;
97 + const listUrl = `${BASE}/auctions/${listPage > 1 ? `?page=${listPage}` : ''}`;
98 + await this.throttle();
99 + const list = await ctx.fetch(listUrl, { expect: ['title', 'date'], parse: (r) => (r.markdown ? { title: parseAuctionList(r.markdown)[0]?.title ?? null, date: parseAuctionList(r.markdown)[0]?.dateText ?? null } : null) });
100 + if (!list.success || !list.markdown) {
101 + ctx.anomaly('page_fetch_failed', `${listUrl}: ${list.error ?? list.httpStatus}`);
102 + return;
103 + }
104 + const auctions = parseAuctionList(list.markdown).filter((a) => a.closed && !done.has(a.catalogId));
105 + let count = 0;
106 + let processed = 0;
107 + for (const auction of auctions) {
108 + if (processed >= auctionsPerRun || ctx.signal?.aborted) break;
109 + for (let page = 1; page <= pagesPerAuction; page++) {
110 + if (ctx.signal?.aborted || this.reached(ctx, count)) break;
111 + const url = `${BASE}/auctions/catalog/id/${auction.catalogId}${page > 1 ? `?page=${page}` : ''}`;
112 + await this.throttle();
113 + const res = await ctx.fetch(url, {
114 + expect: ['title', 'price', 'date', 'status'],
115 + parse: (r) => {
116 + const f = r.markdown ? parseCatalogPage(r.markdown, auction, page).lots.find((l) => l.soldText) : null;
117 + return f ? { title: f.title, price: money(f.soldText, 'USD')?.amount ?? null, date: auction.dateText, status: f.status } : null;
118 + },
119 + });
120 + if (!res.success || !res.markdown) {
121 + ctx.anomaly('page_fetch_failed', `${url}: ${res.error ?? res.httpStatus}`);
122 + break;
123 + }
124 + const payload = parseCatalogPage(res.markdown, auction, page);
125 + if (payload.lots.length === 0) break;
126 + count++;
127 + yield { url, externalId: `catalog:${auction.catalogId}:page:${page}`, kind: 'sale', engine: res.engine, httpStatus: res.httpStatus, payload, fetchedAt: res.fetchedAt };
128 + if (!res.markdown.includes(`catalog/id/${auction.catalogId}?page=${page + 1}`)) break;
129 + }
130 + processed++;
131 + done.add(auction.catalogId);
132 + await ctx.setCursor({ doneCatalogs: [...done].slice(-300), listPage: ctx.options.mode === 'backfill' && auctions.every((a) => done.has(a.catalogId)) ? listPage + 1 : listPage, updatedAt: new Date().toISOString() });
133 + }
134 + }
135 +
136 + async normalize(raw: RawRecordLike): Promise<NormalizedRecord[]> {
137 + const p = CatalogPayloadSchema.parse(raw.payload);
138 + const saleDate = dateMDY(p.auction.dateText);
139 + if (!saleDate) return [];
140 + const out: NormalizedSale[] = [];
141 + for (const lot of p.lots) {
142 + if (!lot.soldText || (lot.status && !/sold/i.test(lot.status))) continue;
143 + const m = money(lot.soldText, 'USD');
144 + if (!m) continue;
145 + const categorySlug = pbaCategory(p.auction.title, lot);
146 + const g = parseGradeFromTitle(lot.title);
147 + const issue = lot.title.match(/#\s?(\d+[A-Za-z]?)/)?.[1] ?? lot.fields.Title?.match(/No\.\s*(\d+)/)?.[1] ?? null;
148 + const yearField = Object.entries(lot.fields).find(([k]) => /date|year/i.test(k))?.[1] ?? null;
149 + const year = yearField?.match(/\b(1[6-9]\d{2}|20\d{2})\b/)?.[1];
150 + const attributes = lotAttributes({
151 + categorySlug,
152 + name: lot.title,
153 + brand: lot.fields.Publisher ?? lot.fields.Author ?? null,
154 + series: categorySlug.endsWith('_comics') ? (lot.fields.Title?.replace(/\s*No\.\s*\d+.*$/i, '') ?? null) : null,
155 + set: categorySlug.endsWith('_comics') ? (lot.fields.Title?.replace(/\s*No\.\s*\d+.*$/i, '') ?? null) : null,
156 + number: categorySlug.endsWith('_comics') ? issue : null,
157 + year: year ? Number(year) : null,
158 + identifiers: { pba_lot: lot.lotId },
159 + metadata: { sale_number: p.auction.saleNumber, sale_title: p.auction.title, estimate: lot.estimateText, fields: lot.fields },
160 + });
161 + out.push(makeSale({ meta: this.meta, sourceUrl: lot.url, externalId: lot.lotId, rawTitle: lot.title, attributes, price: m.amount, currency: 'USD', saleDate, buyerPremiumIncluded: true, auctionHouse: 'PBA Galleries', lotNumber: lot.lotNumber, imageUrls: lot.image ? [lot.image] : [], observedAt: raw.fetchedAt, parserVersion: PARSER_VERSION, grader: g.grader && g.grader !== 'raw' ? g.grader : null, grade: g.grader && g.grader !== 'raw' ? g.grade : null, location: 'US' }));
162 + }
163 + return out;
164 + }
165 +}
166 +
167 +export default (meta: ConnectorMeta) => new PbaGalleriesConnector(meta);
added connectors/firecrawl/pba-galleries/meta.json +52 −0
@@ -0,0 +1,52 @@
1 +{
2 + "id": "pba-galleries",
3 + "displayName": "PBA Galleries (prices realized)",
4 + "sourceId": "pba-galleries",
5 + "sourceName": "PBA Galleries",
6 + "sourceType": "auction_house",
7 + "sourceUrl": "https://www.pbagalleries.com",
8 + "module": "firecrawl/pba-galleries",
9 + "enginePriority": [
10 + "firecrawl"
11 + ],
12 + "categories": [
13 + "books",
14 + "comics",
15 + "marvel_comics",
16 + "dc_comics",
17 + "independent_comics",
18 + "photography",
19 + "maps",
20 + "movie_posters",
21 + "art",
22 + "historical_documents"
23 + ],
24 + "regions": [
25 + "US"
26 + ],
27 + "languages": [
28 + "en"
29 + ],
30 + "currency": [
31 + "USD"
32 + ],
33 + "supportsListings": false,
34 + "supportsSold": true,
35 + "supportsAuctions": false,
36 + "supportsImages": true,
37 + "supportsCatalog": false,
38 + "supportsPopulation": false,
39 + "supportsLookup": false,
40 + "refreshFrequencyMinutes": 1440,
41 + "priority": "medium",
42 + "trustScore": 0.85,
43 + "attributionRequired": true,
44 + "termsUrl": "https://www.pbagalleries.com/terms-conditions/",
45 + "accessNotes": "Public auction list (pbagalleries.com/auctions/?page=N: sale number, title, date, catalog id) and closed catalogs (/auctions/catalog/id/<id>?page=N, 'Sold for $X' + 'Status Sold' per lot with title/publisher/date fields) rendered through Firecrawl (plain HTTPS gets a Cloudflare interstitial; not bypassed; robots.txt allows the site). PBA's realized prices include the 25% buyer's premium (verified: every price is a hammer bid × 1.25) → buyer_premium_included=true. Category from the sale title (comics/books/photographs/maps/posters). 1 credit per page, 2 s politeness delay.",
46 + "enabled": true,
47 + "schemaVersion": "1.0",
48 + "config": {
49 + "auctionsPerRun": 2,
50 + "catalogPagesPerAuction": 5
51 + }
52 +}
added connectors/firecrawl/rm-sothebys/_smoke.ts +7 −0
@@ -0,0 +1,7 @@
1 +import path from 'node:path';
2 +import { fileURLToPath } from 'node:url';
3 +import { captureFixture, runSmoke } from '../_carlib/smoke.js';
4 +
5 +const dir = path.dirname(fileURLToPath(import.meta.url));
6 +if (process.argv[2] === 'capture') await captureFixture(dir, process.argv[3] ?? 'page-1', Number(process.argv[4] ?? 4));
7 +else await runSmoke(dir);
added connectors/firecrawl/rm-sothebys/index.test.ts +92 −0
@@ -0,0 +1,92 @@
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 { ConnectorMetaSchema } from '@rareindex/connectors';
6 +import { loadFixture, runFixtureSuite } from '@rareindex/connectors/testing';
7 +import createConnector, { parseLotsPage, parseResults } from './index.js';
8 +
9 +const dir = path.dirname(fileURLToPath(import.meta.url));
10 +const meta = ConnectorMetaSchema.parse(JSON.parse(readFileSync(path.join(dir, 'meta.json'), 'utf8')));
11 +const connector = createConnector(meta);
12 +
13 +const RESULTS = `![](https://cdn.rmsothebys.com/a.webp)
14 +
15 +‹›
16 +
17 +The Monterey Auction
18 +
19 +13 - 15 August 2026
20 +
21 +[View Results](https://rmsothebys.com/auctions/mo26/lots/)
22 +
23 +![](https://cdn.rmsothebys.com/b.webp)
24 +
25 +‹›
26 +
27 +Sealed July
28 +
29 +Bidding Closes 16 July 2026
30 +
31 +[View Results](https://rmsothebys.com/auctions/s0726/lots/r0001-x/)
32 +`;
33 +
34 +const LOTS = `[![F-Racer Junior](https://cdn.rmsothebys.com/c.webp)](https://rmsothebys.com/auctions/mo25/lots/n0001-fracer-junior/)
35 +
36 +![](https://rmsothebys.com/media/General/Flags/us.png)[**Monterey 2025** \\\\
37 +F-Racer Junior\\\\
38 +\\\\
39 +Lot 101 \\| $24,000 USD\\\\
40 +\\\\
41 +Sold\\\\
42 +\\\\
43 +n0001 - \\\\
44 +\\\\
45 +Current bid: \\\\
46 +\\\\
47 +Final Bid: \\| Lot Sold Lot Closed\\\\
48 +\\\\
49 +Bid](https://rmsothebys.com/auctions/mo25/lots/n0001-fracer-junior/)
50 +
51 +[![1997 Porsche 911 Cup 3.8 RSR](https://cdn.rmsothebys.com/d.webp)](https://rmsothebys.com/auctions/mo25/lots/r0085-1997-porsche-911-cup-38-rsr/)
52 +
53 +![](https://rmsothebys.com/media/General/Flags/us.png)[**Monterey 2025** \\\\
54 +1997 Porsche 911 Cup 3.8 RSR\\\\
55 +\\\\
56 +Lot 126 \\| Estimate Available Upon Request\\\\
57 +\\\\
58 +Not Sold\\\\
59 +\\\\
60 +r0085 - \\\\
61 +\\\\
62 +Bid](https://rmsothebys.com/auctions/mo25/lots/r0085-1997-porsche-911-cup-38-rsr/)
63 +`;
64 +
65 +describe('rm-sothebys', () => {
66 + runFixtureSuite(connector, it, expect);
67 +
68 + it('parses the results list and the lot grid', () => {
69 + const auctions = parseResults(RESULTS);
70 + expect(auctions[0]).toEqual({ code: 'mo26', name: 'The Monterey Auction', dateText: '13 - 15 August 2026' });
71 + expect(auctions[1]).toMatchObject({ code: 's0726', name: 'Sealed July', dateText: '16 July 2026' });
72 + const lots = parseLotsPage(LOTS, { code: 'mo25', name: 'Monterey 2025', dateText: '15 - 16 August 2025' }).lots;
73 + expect(lots.length).toBe(2);
74 + expect(lots[0]).toMatchObject({ slug: 'n0001-fracer-junior', title: 'F-Racer Junior', lotNumber: '101', priceText: '$24,000 USD', status: 'Sold' });
75 + expect(lots[1]).toMatchObject({ lotNumber: '126', priceText: null, status: 'Not Sold' });
76 + });
77 +
78 + it('normalises sold lots with premium included and the auction start date', async () => {
79 + const out = await connector.normalize({ url: 'x', externalId: 'r', kind: 'sale', engine: 'firecrawl', fetchedAt: new Date('2026-09-07T00:00:00Z'), payload: parseLotsPage(LOTS, { code: 'mo25', name: 'Monterey 2025', dateText: '15 - 16 August 2025' }) });
80 + expect(out.length).toBe(1);
81 + const s = out[0]!;
82 + if (s.kind !== 'sale') throw new Error('sale');
83 + expect(s).toMatchObject({ price: 24000, currency: 'USD', buyerPremiumIncluded: true, lotNumber: '101', auctionHouse: "RM Sotheby's" });
84 + expect(s.saleDate.toISOString()).toBe('2025-08-15T00:00:00.000Z');
85 + });
86 +
87 + it('fixture sales have currencies and lot numbers', async () => {
88 + const out = await connector.normalize(loadFixture('rm-sothebys', 'lots-page').raw);
89 + expect(out.length).toBeGreaterThan(0);
90 + for (const r of out) if (r.kind === 'sale') expect(r.lotNumber).toBeTruthy();
91 + });
92 +});
added connectors/firecrawl/rm-sothebys/index.ts +122 −0
@@ -0,0 +1,122 @@
1 +import { z } from 'zod';
2 +import { BaseConnector, type ConnectorMeta, type CrawlContext, type RawRecordInput, type RawRecordLike } from '@rareindex/connectors';
3 +import type { NormalizedRecord, NormalizedSale } from '@rareindex/shared';
4 +import { dateWords, makeSale, md, money, splitMarkdownItems, vehicleAttributes } from '../_carlib/index.js';
5 +
6 +const BASE = 'https://rmsothebys.com';
7 +const PARSER_VERSION = '1.0.0';
8 +
9 +export const AuctionSchema = z.object({ code: z.string(), name: z.string().nullable(), dateText: z.string().nullable() });
10 +export const LotSchema = z.object({ slug: z.string(), url: z.string(), title: z.string(), lotNumber: z.string().nullable(), priceText: z.string().nullable(), status: z.string().nullable(), image: z.string().nullable() });
11 +export const PagePayloadSchema = z.object({ kind: z.literal('lots_page'), auction: AuctionSchema, lots: z.array(LotSchema) });
12 +export type PagePayload = z.infer<typeof PagePayloadSchema>;
13 +
14 +/** /results/ markdown: "Auction Name | 13 - 15 August 2026 | [View Results](…/auctions/mo26/lots/)" blocks. */
15 +export function parseResults(markdown: string): z.infer<typeof AuctionSchema>[] {
16 + const out: z.infer<typeof AuctionSchema>[] = [];
17 + const re = /\[View Results\]\(https:\/\/rmsothebys\.com\/auctions\/([a-z0-9]+)\/lots\/[^)]*\)/g;
18 + let m: RegExpExecArray | null;
19 + while ((m = re.exec(markdown))) {
20 + const before = markdown.slice(Math.max(0, m.index - 600), m.index);
21 + const lines = before.split('\n').map((l) => l.trim()).filter((l) => l && !l.startsWith('!') && !/^[‹›]+$/.test(l));
22 + const dateText = [...lines].reverse().find((l) => /\d{1,2}\s*[-–]?\s*\d{0,2}\s*[A-Z][a-z]+\s+\d{4}|^[A-Z][a-z]+\s+\d{1,2},?\s+\d{4}|Bidding Closes/.test(l)) ?? null;
23 + const name = [...lines].reverse().find((l) => l !== dateText && !/View Results|Bidding Closes/.test(l) && l.length > 3) ?? null;
24 + if (!out.some((a) => a.code === m![1])) out.push({ code: m[1]!, name, dateText: dateText?.replace(/^Bidding Closes\s*/i, '') ?? null });
25 + }
26 + return out;
27 +}
28 +
29 +export function parseLotsPage(markdown: string, auction: z.infer<typeof AuctionSchema>): PagePayload {
30 + const chunks = splitMarkdownItems(markdown, /^\[!\[[^\]]*\]\([^)]+\)\]\(https:\/\/rmsothebys\.com\/auctions\/[a-z0-9]+\/lots\//m);
31 + const lots: z.infer<typeof LotSchema>[] = [];
32 + for (const c of chunks) {
33 + const url = c.match(/\]\((https:\/\/rmsothebys\.com\/auctions\/[a-z0-9]+\/lots\/([a-z0-9-]+)\/)\)/);
34 + if (!url) continue;
35 + const body = c.match(/\[\*\*[^*]+\*\*\s*\\?\s*\n?([\s\S]*?)\]\(https:\/\/rmsothebys\.com\/auctions\/[a-z0-9]+\/lots\//)?.[1] ?? c;
36 + const lines = body.split('\n').map((l) => l.replace(/\\+$/, '').replace(/^\\+/, '').trim()).filter(Boolean);
37 + const title = lines[0] ?? null;
38 + if (!title) continue;
39 + const lotLine = lines.find((l) => /^Lot\s+\S+/.test(l)) ?? '';
40 + const lotNumber = lotLine.match(/^Lot\s+([A-Za-z0-9.]+)/)?.[1] ?? null;
41 + const priceText = lotLine.match(/\|\s*(.+)$/)?.[1]?.trim() ?? null;
42 + const status = lines.find((l) => /^(Sold|Not Sold|Withdrawn|Lot Sold|Lot Closed)$/i.test(l)) ?? null;
43 + lots.push({ slug: url[2]!, url: url[1]!, title: md.clean(title), lotNumber, priceText: priceText && /\d/.test(priceText) ? priceText : null, status, image: md.image(c) });
44 + }
45 + return { kind: 'lots_page', auction, lots };
46 +}
47 +
48 +const MEMORABILIA = /\b(sculpture|poster|sign|helmet|model|artwork|painting|trophy|pedal car|neon|literature|memorabilia|watch)\b/i;
49 +
50 +export class RMSothebysConnector extends BaseConnector {
51 + readonly version = '1.0.0';
52 + readonly parserVersion = PARSER_VERSION;
53 + protected override minIntervalMs = 2000;
54 +
55 + async *crawl(ctx: CrawlContext): AsyncIterable<RawRecordInput> {
56 + const auctionsPerRun = Number(this.meta.config.auctionsPerRun ?? 3);
57 + const done = new Set<string>(Array.isArray(ctx.options.cursor?.doneAuctions) ? (ctx.options.cursor!.doneAuctions as string[]) : []);
58 + const year = ctx.options.mode === 'backfill' ? Number(ctx.options.cursor?.year ?? new Date().getUTCFullYear()) : new Date().getUTCFullYear();
59 + const listUrl = `${BASE}/results/${ctx.options.mode === 'backfill' ? `?year=${year}` : ''}`;
60 + await this.throttle();
61 + const list = await ctx.fetch(listUrl, { waitForMs: 5000, expect: ['title', 'date'], parse: (r) => (r.markdown ? { title: parseResults(r.markdown)[0]?.name ?? null, date: parseResults(r.markdown)[0]?.dateText ?? null } : null), minQuality: 0.3 });
62 + if (!list.success || !list.markdown) {
63 + ctx.anomaly('page_fetch_failed', `${listUrl}: ${list.error ?? list.httpStatus}`);
64 + return;
65 + }
66 + const now = Date.now();
67 + const skipSealed = this.meta.config.skipSealed !== false;
68 + const auctions = parseResults(list.markdown).filter((a) => !done.has(a.code)).filter((a) => !(skipSealed && /^sealed/i.test(a.name ?? ''))).filter((a) => {
69 + const d = dateWords(a.dateText);
70 + return !d || d.getTime() <= now;
71 + });
72 + let count = 0;
73 + let processed = 0;
74 + for (const auction of auctions) {
75 + if (processed >= auctionsPerRun || ctx.signal?.aborted || this.reached(ctx, count)) break;
76 + const url = `${BASE}/auctions/${auction.code}/lots/`;
77 + await this.throttle();
78 + const res = await ctx.fetch(url, {
79 + waitForMs: 7000,
80 + expect: ['title', 'price', 'status'],
81 + parse: (r) => {
82 + const p = r.markdown ? parseLotsPage(r.markdown, auction) : null;
83 + const f = p?.lots.find((l) => l.priceText && /sold/i.test(l.status ?? ''));
84 + return f ? { title: f.title, price: money(f.priceText)?.amount ?? null, status: f.status } : p?.lots.length ? { title: p.lots[0]!.title } : null;
85 + },
86 + minQuality: 0.3,
87 + });
88 + processed++;
89 + if (!res.success || !res.markdown) {
90 + ctx.anomaly('page_fetch_failed', `${url}: ${res.error ?? res.httpStatus}`);
91 + continue;
92 + }
93 + const payload = parseLotsPage(res.markdown, auction);
94 + if (payload.lots.length === 0) {
95 + ctx.anomaly('empty_page', url);
96 + continue;
97 + }
98 + count++;
99 + done.add(auction.code);
100 + yield { url, externalId: `auction:${auction.code}:first40`, kind: 'sale', engine: res.engine, httpStatus: res.httpStatus, payload, fetchedAt: res.fetchedAt };
101 + await ctx.setCursor({ doneAuctions: [...done].slice(-200), year: ctx.options.mode === 'backfill' && auctions.every((a) => done.has(a.code)) ? year - 1 : year, updatedAt: new Date().toISOString() });
102 + }
103 + }
104 +
105 + async normalize(raw: RawRecordLike): Promise<NormalizedRecord[]> {
106 + const p = PagePayloadSchema.parse(raw.payload);
107 + const saleDate = dateWords(p.auction.dateText);
108 + if (!saleDate) return [];
109 + const out: NormalizedSale[] = [];
110 + for (const lot of p.lots) {
111 + if (!lot.priceText || !/^(sold|lot sold)$/i.test(lot.status ?? '')) continue;
112 + const m = money(lot.priceText, 'USD');
113 + if (!m) continue;
114 + const memorabilia = MEMORABILIA.test(lot.title) && !/^\d{4}\s/.test(lot.title);
115 + const attributes = vehicleAttributes(lot.title, { identifiers: { rm_lot: `${p.auction.code}-${lot.slug}` }, metadata: { auction: p.auction.name, auction_code: p.auction.code, auction_dates: p.auction.dateText }, ...(memorabilia ? { categorySlug: 'automotive_memorabilia' as const } : {}) });
116 + out.push(makeSale({ meta: this.meta, sourceUrl: lot.url, externalId: `${p.auction.code}-${lot.slug}`, rawTitle: lot.title, attributes, price: m.amount, currency: m.currency, saleDate, buyerPremiumIncluded: true, auctionHouse: "RM Sotheby's", lotNumber: lot.lotNumber, imageUrls: lot.image ? [lot.image] : [], observedAt: raw.fetchedAt, parserVersion: PARSER_VERSION }));
117 + }
118 + return out;
119 + }
120 +}
121 +
122 +export default (meta: ConnectorMeta) => new RMSothebysConnector(meta);
added connectors/firecrawl/rm-sothebys/meta.json +51 −0
@@ -0,0 +1,51 @@
1 +{
2 + "id": "rm-sothebys",
3 + "displayName": "RM Sotheby's (results)",
4 + "sourceId": "rm-sothebys",
5 + "sourceName": "RM Sotheby's",
6 + "sourceType": "auction_house",
7 + "sourceUrl": "https://rmsothebys.com",
8 + "module": "firecrawl/rm-sothebys",
9 + "enginePriority": [
10 + "firecrawl"
11 + ],
12 + "categories": [
13 + "automobiles",
14 + "motorcycles",
15 + "automotive_memorabilia"
16 + ],
17 + "regions": [
18 + "US",
19 + "EU",
20 + "GB",
21 + "AE"
22 + ],
23 + "languages": [
24 + "en"
25 + ],
26 + "currency": [
27 + "USD",
28 + "EUR",
29 + "GBP",
30 + "CHF",
31 + "AED"
32 + ],
33 + "supportsListings": false,
34 + "supportsSold": true,
35 + "supportsAuctions": false,
36 + "supportsImages": true,
37 + "supportsCatalog": false,
38 + "supportsPopulation": false,
39 + "supportsLookup": false,
40 + "refreshFrequencyMinutes": 1440,
41 + "priority": "medium",
42 + "trustScore": 0.9,
43 + "attributionRequired": true,
44 + "termsUrl": "https://rmsothebys.com/terms-and-conditions/",
45 + "accessNotes": "Public results: /results/ (auction list per year with date ranges) and each auction's lot grid (/auctions/<code>/lots/) rendered through Firecrawl because RM's site is an Angular app (no robots.txt is served; nothing is login-gated). Limitation: the lot grid paginates client-side (40 lots per view) and does not expose a page URL, so only the first 40 lots of each auction are captured per run — partial coverage, stated in the data. Lots marked 'Sold' with a price become sales; 'Not Sold' lots are skipped. RM publishes results inclusive of buyer's premium → buyer_premium_included=true. Sale date = first day of the auction (range in metadata). 1 credit per page, 2 s politeness delay.",
46 + "enabled": true,
47 + "schemaVersion": "1.0",
48 + "config": {
49 + "auctionsPerRun": 3
50 + }
51 +}
added connectors/firecrawl/rr-auction/_smoke.ts +7 −0
@@ -0,0 +1,7 @@
1 +import path from 'node:path';
2 +import { fileURLToPath } from 'node:url';
3 +import { captureFixture, runSmoke } from '../_carlib/smoke.js';
4 +
5 +const dir = path.dirname(fileURLToPath(import.meta.url));
6 +if (process.argv[2] === 'capture') await captureFixture(dir, process.argv[3] ?? 'page-1', Number(process.argv[4] ?? 4));
7 +else await runSmoke(dir);
added connectors/firecrawl/rr-auction/index.test.ts +87 −0
@@ -0,0 +1,87 @@
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 { ConnectorMetaSchema } from '@rareindex/connectors';
6 +import { loadFixture, runFixtureSuite } from '@rareindex/connectors/testing';
7 +import createConnector, { parseCalendar, parseLotsPage, rrCategory } from './index.js';
8 +
9 +const dir = path.dirname(fileURLToPath(import.meta.url));
10 +const meta = ConnectorMetaSchema.parse(JSON.parse(readFileSync(path.join(dir, 'meta.json'), 'utf8')));
11 +const connector = createConnector(meta);
12 +
13 +const CAL = `## Space Exploration
14 +
15 +##### 10/16/2025
16 +
17 +##### Realized $1,234,567
18 +
19 +[View Lots](https://www.rrauction.com/auctions/details/728-space-exploration)
20 +
21 +## Remarkable Rarities
22 +
23 +##### 9/20/2025
24 +
25 +##### Realized $2,400,456
26 +
27 +[View Lots](https://www.rrauction.com/auctions/details/726-remarkable-rarities)
28 +`;
29 +
30 +const LOTS = `[![Lot #8671 Campo del Cielo Iron Meteorite](https://cdn.rrauction.com/auction/728/preview/3506706_13.jpeg)](https://www.rrauction.com/auctions/lot-detail/350670607288671-campo-del-cielo-iron-meteorite-individual/?cat=0)
31 +
32 +[**8671\\. Campo del Cielo Iron Meteorite Individual**](https://www.rrauction.com/auctions/lot-detail/350670607288671-campo-del-cielo-iron-meteorite-individual/?cat=0 "Lot #8671. Campo del Cielo Iron Meteorite Individual")
33 +
34 +Sold For: $4,273
35 +(w/BP)
36 +
37 +Estimate: $700+
38 +
39 +Auction #728 - October 16, 2025
40 +
41 +Closed
42 +
43 +[![Lot #8579 ESA Flight Suit](https://cdn.rrauction.com/auction/728/preview/3505730_1.jpg)](https://www.rrauction.com/auctions/lot-detail/350573007288579-esa-european-space-agency-flight-suit/?cat=0)
44 +
45 +[**8579\\. ESA (European Space Agency) Flight Suit**](https://www.rrauction.com/auctions/lot-detail/350573007288579-esa-european-space-agency-flight-suit/?cat=0 "Lot #8579")
46 +
47 +Estimate: $500+
48 +
49 +Auction #728 - October 16, 2025
50 +`;
51 +
52 +describe('rr-auction', () => {
53 + runFixtureSuite(connector, it, expect);
54 +
55 + it('parses the past calendar and lot gallery', () => {
56 + const auctions = parseCalendar(CAL);
57 + expect(auctions).toEqual([
58 + { id: '728', slug: 'space-exploration', title: 'Space Exploration', dateText: '10/16/2025', realizedText: '$1,234,567' },
59 + { id: '726', slug: 'remarkable-rarities', title: 'Remarkable Rarities', dateText: '9/20/2025', realizedText: '$2,400,456' },
60 + ]);
61 + const lots = parseLotsPage(LOTS, auctions[0]!, 1).lots;
62 + expect(lots.length).toBe(2);
63 + expect(lots[0]).toMatchObject({ lotNumber: '8671', title: 'Campo del Cielo Iron Meteorite Individual', estimateText: '$700+', auctionLine: 'October 16, 2025' });
64 + expect(lots[0]!.soldText).toContain('$4,273');
65 + expect(lots[1]!.soldText).toBeNull();
66 + });
67 +
68 + it('normalises sold lots with BP flag, auction date and keyword categories', async () => {
69 + const auction = parseCalendar(CAL)[0]!;
70 + const out = await connector.normalize({ url: 'x', externalId: 'a', kind: 'sale', engine: 'firecrawl', fetchedAt: new Date('2026-09-07T00:00:00Z'), payload: parseLotsPage(LOTS, auction, 1) });
71 + expect(out.length).toBe(1);
72 + const s = out[0]!;
73 + if (s.kind !== 'sale') throw new Error('sale');
74 + expect(s).toMatchObject({ price: 4273, currency: 'USD', buyerPremiumIncluded: true, lotNumber: '8671', auctionHouse: 'RR Auction' });
75 + expect(s.saleDate.toISOString()).toBe('2025-10-16T00:00:00.000Z');
76 + expect(s.attributes.categorySlug).toBe('meteorites');
77 + expect(rrCategory('Space Exploration', 'Apollo 11 Flown Flag')).toBe('space');
78 + expect(rrCategory('Fine Autographs and Artifacts', 'Steve Jobs Signed Apple II Manual')).toBe('apple_collectibles');
79 + expect(rrCategory('Fine Autographs and Artifacts', 'Albert Einstein Signed Photograph')).toBe('autographs');
80 + });
81 +
82 + it('fixture sales are dated by the auction and priced with premium', async () => {
83 + const out = await connector.normalize(loadFixture('rr-auction', 'lots-page').raw);
84 + expect(out.length).toBeGreaterThan(0);
85 + for (const r of out) if (r.kind === 'sale') expect(r.buyerPremiumIncluded).toBe(true);
86 + });
87 +});
added connectors/firecrawl/rr-auction/index.ts +139 −0
@@ -0,0 +1,139 @@
1 +import { z } from 'zod';
2 +import { BaseConnector, type ConnectorMeta, type CrawlContext, type RawRecordInput, type RawRecordLike } from '@rareindex/connectors';
3 +import { parseGradeFromTitle } from '@rareindex/taxonomy';
4 +import type { NormalizedRecord, NormalizedSale } from '@rareindex/shared';
5 +import { dateMDY, dateWords, lotAttributes, makeSale, md, money, splitMarkdownItems } from '../_carlib/index.js';
6 +
7 +const BASE = 'https://www.rrauction.com';
8 +const PARSER_VERSION = '1.0.0';
9 +
10 +export const AuctionSchema = z.object({ id: z.string(), slug: z.string(), title: z.string(), dateText: z.string().nullable(), realizedText: z.string().nullable() });
11 +export const LotSchema = z.object({
12 + lotNumber: z.string().nullable(),
13 + title: z.string(),
14 + url: z.string(),
15 + soldText: z.string().nullable(),
16 + estimateText: z.string().nullable(),
17 + auctionLine: z.string().nullable(),
18 + image: z.string().nullable(),
19 +});
20 +export const LotsPayloadSchema = z.object({ kind: z.literal('lots_page'), auction: AuctionSchema, page: z.number(), lots: z.array(LotSchema) });
21 +export type LotsPayload = z.infer<typeof LotsPayloadSchema>;
22 +
23 +export function parseCalendar(markdown: string): z.infer<typeof AuctionSchema>[] {
24 + const out: z.infer<typeof AuctionSchema>[] = [];
25 + const chunks = splitMarkdownItems(markdown, /^## /m);
26 + for (const c of chunks) {
27 + const link = c.match(/\((https:\/\/www\.rrauction\.com\/auctions\/details\/(\d+)-([a-z0-9-]+))\)/);
28 + if (!link) continue;
29 + const title = md.clean(c.split('\n')[0]!.replace(/^##\s*/, ''));
30 + const dateText = c.match(/#####\s*(\d{1,2}\/\d{1,2}\/\d{4})/)?.[1] ?? null;
31 + const realizedText = c.match(/Realized\s+(\$[\d,]+)/)?.[1] ?? null;
32 + if (!out.some((a) => a.id === link[2])) out.push({ id: link[2]!, slug: link[3]!, title, dateText, realizedText });
33 + }
34 + return out;
35 +}
36 +
37 +export function parseLotsPage(markdown: string, auction: z.infer<typeof AuctionSchema>, page: number): LotsPayload {
38 + const chunks = splitMarkdownItems(markdown, /^\[!\[Lot #/m);
39 + const lots: z.infer<typeof LotSchema>[] = [];
40 + for (const c of chunks) {
41 + const t = c.match(/\[\*\*(\d+)\\?\.\s*([^\]]+?)\*\*\]\((https:\/\/www\.rrauction\.com\/auctions\/lot-detail\/[^)\s"]+)/);
42 + if (!t) continue;
43 + lots.push({
44 + lotNumber: t[1]!,
45 + title: md.clean(t[2]!),
46 + url: t[3]!.replace(/\?cat=\d+$/, ''),
47 + soldText: c.match(/Sold For:\s*(\$[\d,]+(?:\.\d+)?)\s*(\(w\/BP\))?/)?.[0] ?? null,
48 + estimateText: c.match(/Estimate:\s*([^\n]+)/)?.[1]?.trim() ?? null,
49 + auctionLine: c.match(/Auction #\d+\s*-\s*([A-Za-z]+\s+\d{1,2},\s+\d{4})/)?.[1] ?? null,
50 + image: md.image(c),
51 + });
52 + }
53 + return { kind: 'lots_page', auction, page, lots };
54 +}
55 +
56 +/** Map RR auction/lot vocabulary to taxonomy slugs (never guesses beyond keywords; defaults to autographs for RR's core business). */
57 +export function rrCategory(auctionTitle: string, lotTitle: string): string {
58 + const t = `${auctionTitle} ${lotTitle}`.toLowerCase();
59 + if (/meteorite|tektite|moldavite/.test(t)) return 'meteorites';
60 + if (/apple|steve jobs|macintosh|iphone|wozniak/.test(t)) return 'apple_collectibles';
61 + if (/space|apollo|nasa|astronaut|flown|shuttle|gemini|mercury program|cosmonaut|soyuz/.test(t)) return 'space';
62 + if (/aviation|aircraft|pilot|lindbergh|wright brothers/.test(t)) return 'aviation';
63 + if (/computer|commodore|altair|enigma|typewriter/.test(t)) return 'vintage_computers';
64 + if (/animation|cel\b|disney|production cel/.test(t)) return 'animation_art';
65 + if (/guitar|beatles|elvis|rolling stones|concert|album|music|jimi hendrix|bob dylan|led zeppelin/.test(t)) return 'music_memorabilia';
66 + if (/sports|baseball|basketball|football|hockey|boxing|game-used|game used|jersey|olympic|babe ruth|jordan/.test(t)) return 'sports_memorabilia';
67 + if (/hollywood|movie|film|screen-used|prop|star wars|star trek|marilyn monroe|costume/.test(t)) return 'movie_memorabilia';
68 + if (/document|manuscript|letter signed|treaty|declaration|presidential|lincoln|washington|jefferson|kennedy|constitution/.test(t)) return 'historical_documents';
69 + return 'autographs';
70 +}
71 +
72 +export class RRAuctionConnector extends BaseConnector {
73 + readonly version = '1.0.0';
74 + readonly parserVersion = PARSER_VERSION;
75 + protected override minIntervalMs = 2000;
76 +
77 + async *crawl(ctx: CrawlContext): AsyncIterable<RawRecordInput> {
78 + const auctionsPerRun = Number(this.meta.config.auctionsPerRun ?? 2);
79 + const lotPages = Number(this.meta.config.lotPagesPerAuction ?? 6);
80 + const backfill = ctx.options.mode === 'backfill';
81 + const done = new Set<string>(Array.isArray(ctx.options.cursor?.doneAuctions) ? (ctx.options.cursor!.doneAuctions as string[]) : []);
82 + const calPage = backfill ? Number(ctx.options.cursor?.calendarPage ?? 1) : 1;
83 + const calUrl = `${BASE}/auctions/auction-calendar/cron/past/${calPage > 1 ? `?page=${calPage}` : ''}`;
84 + await this.throttle();
85 + const cal = await ctx.fetch(calUrl, { expect: ['title', 'date'], parse: (r) => (r.markdown ? { title: parseCalendar(r.markdown)[0]?.title ?? null, date: parseCalendar(r.markdown)[0]?.dateText ?? null } : null) });
86 + if (!cal.success || !cal.markdown) {
87 + ctx.anomaly('page_fetch_failed', `${calUrl}: ${cal.error ?? cal.httpStatus}`);
88 + return;
89 + }
90 + const auctions = parseCalendar(cal.markdown).filter((a) => !done.has(a.id));
91 + let count = 0;
92 + let processed = 0;
93 + for (const auction of auctions) {
94 + if (processed >= auctionsPerRun || ctx.signal?.aborted) break;
95 + for (let page = 1; page <= lotPages; page++) {
96 + if (ctx.signal?.aborted || this.reached(ctx, count)) break;
97 + const url = `${BASE}/auctions/auction-details/${auction.id}?page=${page}&itemQty=96&view=gallery&sort=time&cat=0`;
98 + await this.throttle();
99 + const res = await ctx.fetch(url, {
100 + expect: ['title', 'price', 'date', 'status'],
101 + parse: (r) => {
102 + const f = r.markdown ? parseLotsPage(r.markdown, auction, page).lots.find((l) => l.soldText) : null;
103 + return f ? { title: f.title, price: money(f.soldText, 'USD')?.amount ?? null, date: f.auctionLine ?? auction.dateText, status: f.soldText } : null;
104 + },
105 + });
106 + if (!res.success || !res.markdown) {
107 + ctx.anomaly('page_fetch_failed', `${url}: ${res.error ?? res.httpStatus}`);
108 + break;
109 + }
110 + const payload = parseLotsPage(res.markdown, auction, page);
111 + if (payload.lots.length === 0) break;
112 + count++;
113 + yield { url, externalId: `auction:${auction.id}:page:${page}`, kind: 'sale', engine: res.engine, httpStatus: res.httpStatus, payload, fetchedAt: res.fetchedAt };
114 + if (payload.lots.length < 96) break;
115 + }
116 + processed++;
117 + done.add(auction.id);
118 + await ctx.setCursor({ doneAuctions: [...done].slice(-400), calendarPage: backfill && auctions.every((a) => done.has(a.id)) ? calPage + 1 : calPage, updatedAt: new Date().toISOString() });
119 + }
120 + }
121 +
122 + async normalize(raw: RawRecordLike): Promise<NormalizedRecord[]> {
123 + const p = LotsPayloadSchema.parse(raw.payload);
124 + const out: NormalizedSale[] = [];
125 + for (const lot of p.lots) {
126 + if (!lot.soldText) continue;
127 + const m = money(lot.soldText, 'USD');
128 + const saleDate = dateWords(lot.auctionLine) ?? dateMDY(p.auction.dateText);
129 + if (!m || !saleDate) continue;
130 + const g = parseGradeFromTitle(lot.title);
131 + const categorySlug = rrCategory(p.auction.title, lot.title);
132 + const attributes = lotAttributes({ categorySlug, name: lot.title, identifiers: { rr_lot: lot.url.match(/lot-detail\/(\d+)/)?.[1] ?? `${p.auction.id}-${lot.lotNumber}` }, metadata: { auction_id: p.auction.id, auction_title: p.auction.title, estimate: lot.estimateText } });
133 + out.push(makeSale({ meta: this.meta, sourceUrl: lot.url, externalId: `${p.auction.id}-${lot.lotNumber}`, rawTitle: lot.title, attributes, price: m.amount, currency: 'USD', saleDate, buyerPremiumIncluded: /w\/BP/.test(lot.soldText), auctionHouse: 'RR Auction', lotNumber: lot.lotNumber, imageUrls: lot.image ? [lot.image] : [], observedAt: raw.fetchedAt, parserVersion: PARSER_VERSION, grader: g.grader && g.grader !== 'raw' ? g.grader : null, grade: g.grader && g.grader !== 'raw' ? g.grade : null, location: 'US' }));
134 + }
135 + return out;
136 + }
137 +}
138 +
139 +export default (meta: ConnectorMeta) => new RRAuctionConnector(meta);
added connectors/firecrawl/rr-auction/meta.json +54 −0
@@ -0,0 +1,54 @@
1 +{
2 + "id": "rr-auction",
3 + "displayName": "RR Auction (past auctions)",
4 + "sourceId": "rr-auction",
5 + "sourceName": "RR Auction",
6 + "sourceType": "auction_house",
7 + "sourceUrl": "https://www.rrauction.com",
8 + "module": "firecrawl/rr-auction",
9 + "enginePriority": [
10 + "firecrawl"
11 + ],
12 + "categories": [
13 + "space",
14 + "autographs",
15 + "historical_documents",
16 + "apple_collectibles",
17 + "music_memorabilia",
18 + "sports_memorabilia",
19 + "movie_memorabilia",
20 + "meteorites",
21 + "vintage_computers",
22 + "animation_art",
23 + "aviation"
24 + ],
25 + "regions": [
26 + "US"
27 + ],
28 + "languages": [
29 + "en"
30 + ],
31 + "currency": [
32 + "USD"
33 + ],
34 + "supportsListings": false,
35 + "supportsSold": true,
36 + "supportsAuctions": false,
37 + "supportsImages": true,
38 + "supportsCatalog": false,
39 + "supportsPopulation": false,
40 + "supportsLookup": false,
41 + "refreshFrequencyMinutes": 720,
42 + "priority": "high",
43 + "trustScore": 0.9,
44 + "attributionRequired": true,
45 + "termsUrl": "https://www.rrauction.com/terms-and-conditions",
46 + "accessNotes": "Two public pages rendered through Firecrawl (plain HTTPS gets a Cloudflare interstitial; not bypassed; robots.txt allows everything except /admin): the past-auction calendar (/auctions/auction-calendar/cron/past/?page=N: auction id, title, date, realized total) and each auction's lot gallery (/auctions/auction-details/<id>?page=N&itemQty=96&view=gallery&sort=time&cat=0) whose cards show 'Sold For: $X (w/BP)', estimate and the auction date. RR's published prices explicitly include the buyer's premium → buyer_premium_included=true. Categories come from the auction title and lot title keywords. 1 credit per page (96 lots), 2 s politeness delay.",
47 + "enabled": true,
48 + "schemaVersion": "1.0",
49 + "config": {
50 + "auctionsPerRun": 2,
51 + "lotPagesPerAuction": 6,
52 + "calendarPagesPerRun": 1
53 + }
54 +}
added data/fixtures/bring-a-trailer/results-page.json +115 −0
@@ -0,0 +1,115 @@
1 +{
2 + "raw": {
3 + "url": "https://bringatrailer.com/auctions/results/?page=1",
4 + "externalId": "results:1:120772265",
5 + "kind": "sale",
6 + "engine": "api",
7 + "fetchedAt": "2026-09-07T06:36:08.552Z",
8 + "payload": {
9 + "kind": "results_page",
10 + "page": 1,
11 + "itemsTotal": 261826,
12 + "pagesTotal": 7273,
13 + "items": [
14 + {
15 + "id": 120772265,
16 + "title": "2004 GMC Sierra 2500HD SLT Crew Cab 4×4 Duramax",
17 + "url": "https://bringatrailer.com/listing/2004-gmc-sierra-2500hd-12/",
18 + "year": null,
19 + "currency": "USD",
20 + "current_bid": 23250,
21 + "sold_text": "Sold for USD $23,250 <span> on 9/6/2026 </span>",
22 + "timestamp_end": 1788717951,
23 + "country_code": "US",
24 + "noreserve": true,
25 + "premium": false,
26 + "thumbnail_url": "https://bringatrailer.com/wp-content/uploads/2026/08/2004_gmc_sierra-2500hd_2004_gmc_sierra-2500hd_46582a70-b1c4-4c7f-9582-b40b53077b54-ZixbpS-63088-63089-scaled-1-82376.jpg?w=470&h=318&crop=1",
27 + "excerpt": "This 2004 GMC Sierra 2500HD SLT 4×4 Crew Cab pickup has remained registered in California since new and was purchased by the seller from the original owner in 2024. Finished in Silver Birch Metallic over Dark Pewter leather, the truck is powered by a 6.6-liter Duramax turbodiesel V8 linked to a five-speed automatic transmission, a push-button…"
28 + },
29 + {
30 + "id": 121072989,
31 + "title": "390-Powered 1972 Ford F-250 Sport Custom Camper Special",
32 + "url": "https://bringatrailer.com/listing/1972-ford-f-250-141/",
33 + "year": null,
34 + "currency": "USD",
35 + "current_bid": 23750,
36 + "sold_text": "Sold for USD $23,750 <span> on 9/6/2026 </span>",
37 + "timestamp_end": 1788717941,
38 + "country_code": "US",
39 + "noreserve": false,
40 + "premium": false,
41 + "thumbnail_url": "https://bringatrailer.com/wp-content/uploads/2026/08/IMG_7987-scaled-copy-2026-08-25-tx6-96643.jpeg?w=470&h=318&crop=1",
42 + "excerpt": "This 1972 Ford F-250 Sport Custom Camper Special pickup was sold new in Portland, Oregon, and is said to have two previous owners. The most recent owner oversaw a rebuild of the replacement 390ci V8 as well as a refresh of the three-speed automatic transmission and brake system. The truck has been repainted in two-tone orange and white, and the…"
43 + },
44 + {
45 + "id": 120647547,
46 + "title": "Single-Family-Owned 2001 Chevrolet S-10 LS Extended Cab 5-Speed",
47 + "url": "https://bringatrailer.com/listing/2001-chevrolet-s-10-20/",
48 + "year": null,
49 + "currency": "USD",
50 + "current_bid": 10250,
51 + "sold_text": "Sold for USD $10,250 <span> on 9/6/2026 </span>",
52 + "timestamp_end": 1788717340,
53 + "country_code": "US",
54 + "noreserve": true,
55 + "premium": false,
56 + "thumbnail_url": "https://bringatrailer.com/wp-content/uploads/2026/08/IMG_7240-scaled-copy-2026-08-25-ppb-76278.jpg?w=470&h=318&crop=1",
57 + "excerpt": "This 2001 Chevrolet S-10 LS is an extended cab pickup that was purchased new by the seller&#039;s father and subsequently acquired by the seller in 2019. Showing registration history in California and Texas, the truck now shows 82k miles. It is powered by a 2.2-liter inline-four paired with a five-speed manual transmission."
58 + },
59 + {
60 + "id": 120394481,
61 + "title": "2024 Chevrolet Corvette Z06 Coupe 3LZ",
62 + "url": "https://bringatrailer.com/listing/2024-chevrolet-corvette-z06-156/",
63 + "year": null,
64 + "currency": "USD",
65 + "current_bid": 110500,
66 + "sold_text": "Sold for USD $110,500 <span> on 9/6/2026 </span>",
67 + "timestamp_end": 1788717263,
68 + "country_code": "US",
69 + "noreserve": false,
70 + "premium": false,
71 + "thumbnail_url": "https://bringatrailer.com/wp-content/uploads/2026/08/DSCF0225-copy-2026-08-21-fob-53497.jpg?w=470&h=318&crop=1",
72 + "excerpt": "This 2024 Chevrolet Corvette Z06 3LZ coupe has 1,500 miles and was optioned with the ~$14k carbon-fiber 20&quot; and 21&quot; wheels in addition to carbon-ceramic brakes with Bright Red-finished calipers. Dual racing stripes complement the Hypersonic Gray Metallic paintwork, and power is provided by a 5.5-liter LT6 V8 paired with an eight-speed dual-clutch…"
73 + },
74 + {
75 + "id": 119150783,
76 + "title": "No: Reserve 1961 Ford F-250 Custom 4-Speed w/Camper",
77 + "url": "https://bringatrailer.com/listing/1961-ford-f-250-2/",
78 + "year": null,
79 + "currency": "USD",
80 + "current_bid": 5200,
81 + "sold_text": "Sold for USD $5,200 <span> on 9/6/2026 </span>",
82 + "timestamp_end": 1788717182,
83 + "country_code": "US",
84 + "noreserve": true,
85 + "premium": false,
86 + "thumbnail_url": "https://bringatrailer.com/wp-content/uploads/2026/07/1961_ford_f-250_1961_ford_f-250_f678161a-c4c9-4dc2-85c4-30d7af2f6a0c-smQxsA-77639-77640-scaled.jpg?w=470&h=318&crop=1",
87 + "excerpt": "This 1961 Ford F-250 Styleside pickup is said to have been purchased new by its previous owner in California, where it has remained through the seller&#039;s purchase in 2014. It is equipped with a slide-in camper with a three-burner stove, a sink, a refrigerator, a wet bath, Jalousie windows, and an over-cab sleeping area."
88 + },
89 + {
90 + "id": 120819037,
91 + "title": "2004 Kawasaki ZRX1200R",
92 + "url": "https://bringatrailer.com/listing/2004-kawasaki-zrx-1200r-2/",
93 + "year": null,
94 + "currency": "USD",
95 + "current_bid": 6100,
96 + "sold_text": "Sold for USD $6,100 <span> on 9/6/2026 </span>",
97 + "timestamp_end": 1788717131,
98 + "country_code": "US",
99 + "noreserve": true,
100 + "premium": false,
101 + "thumbnail_url": "https://bringatrailer.com/wp-content/uploads/2026/08/20260814_193631-scaled-copy-2026-08-21-2t7-23218.jpg?w=470&h=318&crop=1",
102 + "excerpt": "This 2004 Kawasaki ZRX1200R was initially sold through Keene Motorsports in Swanzey, New Hampshire and was purchased by the seller in 2024 from the original owner. Finished in Galaxy Silver II, it is powered by a liquid-cooled 1,164cc inline-four paired with a five-speed transmission. Equipment includes four Keihin carburetors, a four-into-one…"
103 + }
104 + ]
105 + }
106 + },
107 + "expect": {
108 + "count": 6,
109 + "kinds": [
110 + "sale"
111 + ]
112 + },
113 + "note": "Captured live from https://bringatrailer.com/auctions/results/?page=1 (lists trimmed to 6).",
114 + "capturedAt": "2026-09-07T06:36:08.555Z"
115 +}
\ No newline at end of file
added data/fixtures/cars-and-bids/past-page.json +77 −0
@@ -0,0 +1,77 @@
1 +{
2 + "raw": {
3 + "url": "https://carsandbids.com/past-auctions/",
4 + "externalId": "past:1:rJv85YAw",
5 + "kind": "sale",
6 + "engine": "firecrawl",
7 + "fetchedAt": "2026-09-07T06:37:56.393Z",
8 + "payload": {
9 + "kind": "past_page",
10 + "page": 1,
11 + "items": [
12 + {
13 + "id": "rJv85YAw",
14 + "url": "https://carsandbids.com/auctions/rJv85YAw/2023-porsche-panamera-turbo-s",
15 + "title": "2023 Porsche Panamera Turbo S",
16 + "statusText": "Bid to $138,000",
17 + "ended": "9/4/26",
18 + "subtitle": "620-hp Twin-Turbo V8, AWD, ~$48,000 in Options, 8k Miles",
19 + "image": "https://media.carsandbids.com/cdn-cgi/image/width=768,quality=70/438ad923cef6d8239e95d61e7d6849486bae11d9/photos/36ElqdaP-3wvyYrLT72/edit/E6Jem.jpg?t=178787030826"
20 + },
21 + {
22 + "id": "3BOndoY4",
23 + "url": "https://carsandbids.com/auctions/3BOndoY4/1977-mercury-marquis-colony-park-brougham-wagon",
24 + "title": "1977 Mercury Marquis Colony Park Brougham Wagon",
25 + "statusText": "Sold for $7,400",
26 + "ended": "9/4/26",
27 + "subtitle": "460ci V8, A/C, Third-Row Seating, Dual-Action Tailgate with Power Window",
28 + "image": "https://media.carsandbids.com/cdn-cgi/image/width=768,quality=70/3c629a2a018247a67d2fbfc428052d25e4575001/photos/MercuryMarquisColonyParkWagon1977002.jpg?t=178751911197"
29 + },
30 + {
31 + "id": "rjEp2jOx",
32 + "url": "https://carsandbids.com/auctions/rjEp2jOx/2019-audi-s4-premium-plus",
33 + "title": "2019 Audi S4 Premium Plus",
34 + "statusText": "Sold for $20,254",
35 + "ended": "9/4/26",
36 + "subtitle": "Turbocharged V6, AWD, S Sport Package, APR Modifications",
37 + "image": "https://media.carsandbids.com/cdn-cgi/image/width=768,quality=70/ee7f173e46ec801a48d1673c50f9cebaa1bf2854/photos/exterior/3g5qpqJb-4eBjfrT/edit/nw5sq.jpg?t=178726472623"
38 + },
39 + {
40 + "id": "9aB7e8A3",
41 + "url": "https://carsandbids.com/auctions/9aB7e8A3/2018-mercedes-amg-gt-coupe",
42 + "title": "2018 Mercedes-AMG GT Coupe",
43 + "statusText": "Sold for $71,000",
44 + "ended": "9/4/26",
45 + "subtitle": "13k Miles, 469-hp Twin-Turbo V8, Designo Selenite Grey Magno",
46 + "image": "https://media.carsandbids.com/cdn-cgi/image/width=768,quality=70/9004500a220bf3a3d455d15ee052cf8c332606f8/photos/exterior/rGNeeEyN-uerTsVh/edit/ibDfF.jpg?t=178784556410"
47 + },
48 + {
49 + "id": "rj4QPvYN",
50 + "url": "https://carsandbids.com/auctions/rj4QPvYN/2003-mitsubishi-montero-limited-4x4",
51 + "title": "2003 Mitsubishi Montero Limited 4x4",
52 + "statusText": "Sold for $6,755",
53 + "ended": "9/4/26",
54 + "subtitle": "V6 Power, 4WD, Premium Package, Mostly Unmodified, Southern-Owned",
55 + "image": "https://media.carsandbids.com/cdn-cgi/image/width=768,quality=70/0af19f06cf2b6dc0a15fa74fe9d5ed08a85fbf38/photos/MitsubishiMonteroLimited4x42003007.jpg?t=178760021451"
56 + },
57 + {
58 + "id": "KPZOY6a9",
59 + "url": "https://carsandbids.com/auctions/KPZOY6a9/2007-toyota-fj-cruiser-trd-special-edition",
60 + "title": "2007 Toyota FJ Cruiser TRD Special Edition",
61 + "statusText": "Bid to $13,800",
62 + "ended": "9/4/26",
63 + "subtitle": "Rare 6-Speed Manual, 4WD, Locking Center Differential, Unmodified",
64 + "image": "https://media.carsandbids.com/cdn-cgi/image/width=768,quality=70/775b5f04b6899df2a3512ca30bee5bd15eb33cac/photos/ToyotaFJCruiser2007010.jpg?t=178767123389"
65 + }
66 + ]
67 + }
68 + },
69 + "expect": {
70 + "count": 4,
71 + "kinds": [
72 + "sale"
73 + ]
74 + },
75 + "note": "Captured live from https://carsandbids.com/past-auctions/ (lists trimmed to 6).",
76 + "capturedAt": "2026-09-07T06:37:56.401Z"
77 +}
\ No newline at end of file
added data/fixtures/collecting-cars/sold-page.json +54 −0
@@ -0,0 +1,54 @@
1 +{
2 + "raw": {
3 + "url": "https://collectingcars.com/sold",
4 + "externalId": "sold:1:2013-land-rover-discovery-4-5-0l-v8-2",
5 + "kind": "sale",
6 + "engine": "firecrawl",
7 + "fetchedAt": "2026-09-07T06:36:08.454Z",
8 + "payload": {
9 + "kind": "sold_page",
10 + "page": 1,
11 + "total": 26189,
12 + "items": [
13 + {
14 + "slug": "2013-land-rover-discovery-4-5-0l-v8-2",
15 + "url": "https://collectingcars.com/for-sale/2013-land-rover-discovery-4-5-0l-v8-2",
16 + "title": "2013 Land Rover Discovery 4 5.0 V8",
17 + "priceText": "£17,000",
18 + "dateText": "06/09/2026",
19 + "country": "United Kingdom",
20 + "town": "Gatwick",
21 + "image": "https://images.collectingcars.com/091453/AS-17-08-14.jpg?w=3840&q=75"
22 + },
23 + {
24 + "slug": "1994-porsche-911-993-carrera-cabriolet-26",
25 + "url": "https://collectingcars.com/for-sale/1994-porsche-911-993-carrera-cabriolet-26",
26 + "title": "1994 Porsche 911 (993) Carrera Cabriolet - Manual",
27 + "priceText": "£29,500",
28 + "dateText": "06/09/2026",
29 + "country": "United Kingdom",
30 + "town": "Halstead",
31 + "image": "https://images.collectingcars.com/090999/AS-04-08-01.jpg?w=3840&q=75"
32 + },
33 + {
34 + "slug": "2016-land-rover-defender-110-heritage-5",
35 + "url": "https://collectingcars.com/for-sale/2016-land-rover-defender-110-heritage-5",
36 + "title": "2016 Land Rover Defender 110 Heritage - 3,687 Miles",
37 + "priceText": "£71,000",
38 + "dateText": "06/09/2026",
39 + "country": "United Kingdom",
40 + "town": "Ilminster, Somerset",
41 + "image": "https://images.collectingcars.com/091457/17-08-26-JJBB-12.jpg?w=3840&q=75"
42 + }
43 + ]
44 + }
45 + },
46 + "expect": {
47 + "count": 3,
48 + "kinds": [
49 + "sale"
50 + ]
51 + },
52 + "note": "Captured live from https://collectingcars.com/sold (lists trimmed to 8).",
53 + "capturedAt": "2026-09-07T06:36:08.456Z"
54 +}
\ No newline at end of file
added data/fixtures/mecum/lots-page.json +88 −0
@@ -0,0 +1,88 @@
1 +{
2 + "raw": {
3 + "url": "https://www.mecum.com/auctions/monterey-2026/lots/?page=1",
4 + "externalId": "auction:monterey-2026:page:1",
5 + "kind": "sale",
6 + "engine": "firecrawl",
7 + "fetchedAt": "2026-09-07T06:36:20.265Z",
8 + "payload": {
9 + "kind": "lots_page",
10 + "auctionSlug": "monterey-2026",
11 + "auctionName": "Monterey 2026",
12 + "dateText": "August 13-15",
13 + "page": 1,
14 + "lots": [
15 + {
16 + "lotId": "1175988",
17 + "url": "https://www.mecum.com/lots/1175988/1963-ferrari-250-gt-l-berlinetta-lusso",
18 + "title": "1963 Ferrari 250 GT/L Berlinetta Lusso",
19 + "lotNumber": "S126",
20 + "sold": true,
21 + "priceText": "$1,650,000",
22 + "subtitle": "S/N 4635, The 79th of 350 Produced From 1962-64",
23 + "image": "https://images.mecum.com/image/upload/c_fill,f_auto,g_center,q_80,w_640,h_360/v1782162259/auctions/CA26/1175988/351681.jpg?"
24 + },
25 + {
26 + "lotId": "1173415",
27 + "url": "https://www.mecum.com/lots/1173415/1933-duesenberg-model-j-disappearing-top-convertible-coupe",
28 + "title": "1933 Duesenberg Model J Disappearing-Top Convertible Coupe",
29 + "lotNumber": "S125",
30 + "sold": true,
31 + "priceText": "$3,410,000",
32 + "subtitle": "J-429/2446, Walter M. Murphy Co. Disappearing-Top Coachwork",
33 + "image": "https://images.mecum.com/image/upload/c_fill,f_auto,g_center,q_80,w_640,h_360/v1777561767/auctions/CA26/1173415/662340.jpg?"
34 + },
35 + {
36 + "lotId": "1173405",
37 + "url": "https://www.mecum.com/lots/1173405/1996-ferrari-f50",
38 + "title": "1996 Ferrari F50",
39 + "lotNumber": "S123",
40 + "sold": true,
41 + "priceText": "$14,575,000",
42 + "subtitle": "S/N 106690, 1 of 31 Finished in Giallo Modena, No. 239 of 349 Produced",
43 + "image": "https://images.mecum.com/image/upload/c_fill,f_auto,g_center,q_80,w_640,h_360/v1778679361/auctions/CA26/1173405/818783.jpg?"
44 + },
45 + {
46 + "lotId": "1173404",
47 + "url": "https://www.mecum.com/lots/1173404/1972-ducati-750-imola-racer",
48 + "title": "1972 Ducati 750 Imola Racer",
49 + "lotNumber": "S121",
50 + "sold": false,
51 + "priceText": "$460,000",
52 + "subtitle": "1 of 7 Sent to the 1972 Imola 200 by Ducati, 748cc L-Twin",
53 + "image": "https://images.mecum.com/image/upload/c_fill,f_auto,g_center,q_75,w_640,h_360/v1785269369/auctions/CA26/1173404/213501.jpg?"
54 + },
55 + {
56 + "lotId": "1176500",
57 + "url": "https://www.mecum.com/lots/1176500/2004-gemballa-mirage-gt",
58 + "title": "2004 Gemballa Mirage GT",
59 + "lotNumber": "S120",
60 + "sold": true,
61 + "priceText": "$3,960,000",
62 + "subtitle": "Chassis No. 24 of 25, Created as the Ultimate Expression of Porsche's Carrera GT, 5.7L/670 HP V-10",
63 + "image": "https://images.mecum.com/image/upload/c_fill,f_auto,g_center,q_75,w_640,h_360/v1783956046/auctions/CA26/1176500/946748.jpg?"
64 + },
65 + {
66 + "lotId": "1178075",
67 + "url": "https://www.mecum.com/lots/1178075/1997-toyota-land-cruiser",
68 + "title": "1997 Toyota Land Cruiser",
69 + "lotNumber": "S160",
70 + "sold": false,
71 + "priceText": "$130,000",
72 + "subtitle": "4.5L/212 HP Inline-6, Automatic, 4,688 Miles",
73 + "image": "https://images.mecum.com/image/upload/c_fill,f_auto,g_center,q_75,w_640,h_360/v1785447356/auctions/CA26/1178075/392922.jpg?"
74 + }
75 + ],
76 + "startDate": "2026-08-13T00:00:00+00:00",
77 + "endDate": "2026-08-15T00:00:00+00:00"
78 + }
79 + },
80 + "expect": {
81 + "count": 4,
82 + "kinds": [
83 + "sale"
84 + ]
85 + },
86 + "note": "Captured live from https://www.mecum.com/auctions/monterey-2026/lots/?page=1 (lists trimmed to 6).",
87 + "capturedAt": "2026-09-07T06:36:20.269Z"
88 +}
\ No newline at end of file
added data/fixtures/pba-galleries/catalog-page.json +124 −0
@@ -0,0 +1,124 @@
1 +{
2 + "raw": {
3 + "url": "https://pbagalleries.com/auctions/catalog/id/783",
4 + "externalId": "catalog:783:page:1",
5 + "kind": "sale",
6 + "engine": "firecrawl",
7 + "fetchedAt": "2026-09-07T06:36:10.304Z",
8 + "payload": {
9 + "kind": "catalog_page",
10 + "auction": {
11 + "catalogId": "783",
12 + "saleNumber": "871",
13 + "title": "Sale 871: Rare Books, Manuscripts, and Art: A PBA Platinum Auction",
14 + "dateText": "09/03/2026",
15 + "lots": 63,
16 + "closed": true
17 + },
18 + "page": 1,
19 + "lots": [
20 + {
21 + "lotNumber": "1",
22 + "lotId": "261536",
23 + "title": "Ancient Greek battle tactics",
24 + "url": "https://pbagalleries.com/lot-details/index/catalog/783/lot/261536/The-Tactiks-of-Aelian-Or-art-of-embattailing-an-army-after-ye-Grecian-manner",
25 + "soldText": "$1,250",
26 + "status": "Sold",
27 + "estimateText": "$2,000 \\- $3,000",
28 + "fields": {
29 + "Author": "Aelianus Tacticus",
30 + "Title": "The Tactiks of Aelian; Or, art of embattailing an army after ye Grecian manner...",
31 + "Publisher": "Lawrence Lisle",
32 + "Date Published": "[1616]; 1631"
33 + },
34 + "image": "https://pbagalleries.com/images/lot/6245/624505_m.jpg?ts=1785808510"
35 + },
36 + {
37 + "lotNumber": "2",
38 + "lotId": "261364",
39 + "title": "Lord Hatherley photographed by Julia Margaret Cameron signed",
40 + "url": "https://pbagalleries.com/lot-details/index/catalog/783/lot/261364/William-Page-Wood-Lord-Hatherley",
41 + "soldText": null,
42 + "status": "Unsold",
43 + "estimateText": "$5,000 \\- $8,000",
44 + "fields": {
45 + "Author": "Cameron, Julia Margaret",
46 + "Title": "William Page Wood, Lord Hatherley",
47 + "Date Published": "1868"
48 + },
49 + "image": "https://pbagalleries.com/images/lot/6243/624316_m.jpg?ts=1785779680"
50 + },
51 + {
52 + "lotNumber": "3",
53 + "lotId": "261222",
54 + "title": "Paul Celan poems w/ etchings by Gisèle Celan-Lestrange, signed",
55 + "url": "https://pbagalleries.com/lot-details/index/catalog/783/lot/261222/Schwarzmaut",
56 + "soldText": "$6,875",
57 + "status": "Sold",
58 + "estimateText": "$3,000 \\- $5,000",
59 + "fields": {
60 + "Author": "Celan, Paul",
61 + "Title": "Schwarzmaut",
62 + "Publisher": "Brunidor",
63 + "Date Published": "1969"
64 + },
65 + "image": "https://pbagalleries.com/images/lot/6242/624216_m.jpg?ts=1785779209"
66 + },
67 + {
68 + "lotNumber": "4",
69 + "lotId": "261532",
70 + "title": "Chagall Jerusalem Windows with 15 signatures!",
71 + "url": "https://pbagalleries.com/lot-details/index/catalog/783/lot/261532/The-Jerusalem-Windows-with-15-signatures",
72 + "soldText": "$11,250",
73 + "status": "Sold",
74 + "estimateText": "$2,000 \\- $3,000",
75 + "fields": {
76 + "Author": "Chagall, Marc",
77 + "Title": "The Jerusalem Windows with 15 signatures",
78 + "Publisher": "George Braziller by arrangement with André Sauret",
79 + "Date Published": "[1962]"
80 + },
81 + "image": "https://pbagalleries.com/images/lot/6241/624188_m.jpg?ts=1785779108"
82 + },
83 + {
84 + "lotNumber": "5",
85 + "lotId": "261380",
86 + "title": "Stephen Crane ALS to editor of the Critic, 1896",
87 + "url": "https://pbagalleries.com/lot-details/index/catalog/783/lot/261380/Autograph-letter-signed-from-Stephen-Crane-to-an-editor-of-the-Critic",
88 + "soldText": "$1,750",
89 + "status": "Sold",
90 + "estimateText": "$2,000 \\- $3,000",
91 + "fields": {
92 + "Author": "Crane, Stephen",
93 + "Title": "Autograph letter, signed, from Stephen Crane to an editor of the Critic",
94 + "Date Published": "1896"
95 + },
96 + "image": "https://pbagalleries.com/images/lot/6244/624421_m.jpg?ts=1785780173"
97 + },
98 + {
99 + "lotNumber": "6",
100 + "lotId": "261358",
101 + "title": "Sketchbooks & drawing by interned artist, Henry Fukuhara",
102 + "url": "https://pbagalleries.com/lot-details/index/catalog/783/lot/261358/Artist-sketchbooks-and-early-charcoal-drawing-by-interned-Japanese-American-artist-Henry-Fukuhara",
103 + "soldText": "$2,000",
104 + "status": "Sold",
105 + "estimateText": "$2,000 \\- $3,000",
106 + "fields": {
107 + "Author": "Fukuhara, Henry",
108 + "Title": "Artist sketchbooks and early charcoal drawing by interned Japanese American artist, Henry Fukuhara",
109 + "Date Published": "1932-1997"
110 + },
111 + "image": "https://pbagalleries.com/images/lot/6240/624008_m.jpg?ts=1785778259"
112 + }
113 + ]
114 + }
115 + },
116 + "expect": {
117 + "count": 5,
118 + "kinds": [
119 + "sale"
120 + ]
121 + },
122 + "note": "Captured live from https://pbagalleries.com/auctions/catalog/id/783 (lists trimmed to 6).",
123 + "capturedAt": "2026-09-07T06:36:10.308Z"
124 +}
\ No newline at end of file
added data/fixtures/rm-sothebys/lots-page.json +81 −0
@@ -0,0 +1,81 @@
1 +{
2 + "raw": {
3 + "url": "https://rmsothebys.com/auctions/mo26/lots/",
4 + "externalId": "auction:mo26:first40",
5 + "kind": "sale",
6 + "engine": "firecrawl",
7 + "fetchedAt": "2026-09-07T06:36:10.283Z",
8 + "payload": {
9 + "kind": "lots_page",
10 + "auction": {
11 + "code": "mo26",
12 + "name": "The Monterey Auction",
13 + "dateText": "13 - 15 August 2026"
14 + },
15 + "lots": [
16 + {
17 + "slug": "s0023-1919-harleydavidson-motorcyke-model-419-bicycle",
18 + "url": "https://rmsothebys.com/auctions/mo26/lots/s0023-1919-harleydavidson-motorcyke-model-419-bicycle/",
19 + "title": "1919 Harley-Davidson Motorcyke Model 4-19 Bicycle",
20 + "lotNumber": "101",
21 + "priceText": "$15,000 USD",
22 + "status": "Sold",
23 + "image": "https://cdn.rmsothebys.com/2/8/6/b/5/6/286b569ffeb425effdc029f7dc17754846f8f017.webp"
24 + },
25 + {
26 + "slug": "s0020-1915-indian-8valve-twin-board-track-racer",
27 + "url": "https://rmsothebys.com/auctions/mo26/lots/s0020-1915-indian-8valve-twin-board-track-racer/",
28 + "title": "1915 Indian 8-Valve Twin Board Track Racer",
29 + "lotNumber": "102",
30 + "priceText": "$60,000 USD",
31 + "status": "Sold",
32 + "image": "https://cdn.rmsothebys.com/b/a/9/f/c/f/ba9fcf56e6c848a9bfc3f8ef7276393dc4e41543.webp"
33 + },
34 + {
35 + "slug": "s0021-1908-indian-twin",
36 + "url": "https://rmsothebys.com/auctions/mo26/lots/s0021-1908-indian-twin/",
37 + "title": "1908 Indian Twin",
38 + "lotNumber": "103",
39 + "priceText": "$48,000 USD",
40 + "status": "Sold",
41 + "image": "https://cdn.rmsothebys.com/1/f/d/1/c/8/1fd1c872061e76c58c519728bae1275168c9c97f.webp"
42 + },
43 + {
44 + "slug": "s0022-1912-marsh-metz",
45 + "url": "https://rmsothebys.com/auctions/mo26/lots/s0022-1912-marsh-metz/",
46 + "title": "1912 Marsh Metz",
47 + "lotNumber": "104",
48 + "priceText": "$69,000 USD",
49 + "status": "Sold",
50 + "image": "https://cdn.rmsothebys.com/b/9/9/7/c/3/b997c39f30fcc3bfe4dca4eb85028c03f3ee3cdf.webp"
51 + },
52 + {
53 + "slug": "s0019-1910-pierce-four",
54 + "url": "https://rmsothebys.com/auctions/mo26/lots/s0019-1910-pierce-four/",
55 + "title": "1910 Pierce Four",
56 + "lotNumber": "105",
57 + "priceText": "$150,000 USD",
58 + "status": "Sold",
59 + "image": "https://cdn.rmsothebys.com/8/d/6/d/4/0/8d6d404269ac0aae696f553e9bc792e6658c6aa1.webp"
60 + },
61 + {
62 + "slug": "r0007-1954-buick-skylark",
63 + "url": "https://rmsothebys.com/auctions/mo26/lots/r0007-1954-buick-skylark/",
64 + "title": "1954 Buick Skylark",
65 + "lotNumber": "106",
66 + "priceText": "$100,800 USD",
67 + "status": "Sold",
68 + "image": "https://cdn.rmsothebys.com/5/5/5/e/f/1/555ef1df625f370fc1a327ee900f3eefdcfdfeb2.webp"
69 + }
70 + ]
71 + }
72 + },
73 + "expect": {
74 + "count": 6,
75 + "kinds": [
76 + "sale"
77 + ]
78 + },
79 + "note": "Captured live from https://rmsothebys.com/auctions/mo26/lots/ (lists trimmed to 6).",
80 + "capturedAt": "2026-09-07T06:36:10.288Z"
81 +}
\ No newline at end of file
added data/fixtures/rr-auction/lots-page.json +84 −0
@@ -0,0 +1,84 @@
1 +{
2 + "raw": {
3 + "url": "https://www.rrauction.com/auctions/auction-details/748?page=1&itemQty=96&view=gallery&sort=time&cat=0",
4 + "externalId": "auction:748:page:1",
5 + "kind": "sale",
6 + "engine": "firecrawl",
7 + "fetchedAt": "2026-09-07T06:36:10.226Z",
8 + "payload": {
9 + "kind": "lots_page",
10 + "auction": {
11 + "id": "748",
12 + "slug": "steve-jobs-the-computer-revolution-the-apple-50th-anniversary-auction-part-two",
13 + "title": "Steve Jobs & the Computer Revolution: The Apple 50th Anniversary Auction Part Two",
14 + "dateText": "8/20/2026",
15 + "realizedText": "$2,058,024"
16 + },
17 + "page": 1,
18 + "lots": [
19 + {
20 + "lotNumber": "4011",
21 + "title": "Steve Jobs's Personally-Owned Apple Computer Rainbow Logo Hot Air Balloon Promotional Poster, Given to His Father",
22 + "url": "https://www.rrauction.com/auctions/lot-detail/351632307484011-steve-jobss-personally-owned-apple-computer-rainbow-logo-hot-air-balloon-promotional-poster-given-to-his-father/",
23 + "soldText": "Sold For: $9,733\n(w/BP)",
24 + "estimateText": "$3,000+",
25 + "auctionLine": "August 20, 2026",
26 + "image": "https://cdn.rrauction.com/auction/748/preview/3516323_1.jpg"
27 + },
28 + {
29 + "lotNumber": "4020",
30 + "title": "Steve Jobs Vintage Rainbow Apple Logo Stained Glass Suncatcher, Which Hung in His Boyhood Bedroom",
31 + "url": "https://www.rrauction.com/auctions/lot-detail/351631507484020-steve-jobs-vintage-rainbow-apple-logo-stained-glass-suncatcher-which-hung-in-his-boyhood-bedroom/",
32 + "soldText": "Sold For: $4,365\n(w/BP)",
33 + "estimateText": "$800+",
34 + "auctionLine": "August 20, 2026",
35 + "image": "https://cdn.rrauction.com/auction/748/preview/3516315_1.jpg"
36 + },
37 + {
38 + "lotNumber": "4134",
39 + "title": "Apple iPod Collection of (11) Advertising Posters",
40 + "url": "https://www.rrauction.com/auctions/lot-detail/351473207484134-apple-ipod-collection-of-11-advertising-posters/",
41 + "soldText": "Sold For: $4,973\n(w/BP)",
42 + "estimateText": "$4,000+",
43 + "auctionLine": "August 20, 2026",
44 + "image": "https://cdn.rrauction.com/auction/748/preview/3514732_1.jpg"
45 + },
46 + {
47 + "lotNumber": "4138",
48 + "title": "Apple Macintosh Technical Cutaway Poster (22˝ x 28˝)",
49 + "url": "https://www.rrauction.com/auctions/lot-detail/351599807484138-apple-macintosh-technical-cutaway-poster-22-x-28/",
50 + "soldText": "Sold For: $2,565\n(w/BP)",
51 + "estimateText": "$1,500+",
52 + "auctionLine": "August 20, 2026",
53 + "image": "https://cdn.rrauction.com/auction/748/preview/3515998_1.jpg"
54 + },
55 + {
56 + "lotNumber": "4027",
57 + "title": "Steve Jobs Typed Letter Signed, Declining Speaking Invitation from the University of Michigan (1983)",
58 + "url": "https://www.rrauction.com/auctions/lot-detail/351457807484027-steve-jobs-typed-letter-signed-declining-speaking-invitation-from-the-university-of-michigan-1983/",
59 + "soldText": "Sold For: $24,695\n(w/BP)",
60 + "estimateText": "$10,000+",
61 + "auctionLine": "August 20, 2026",
62 + "image": "https://cdn.rrauction.com/auction/748/preview/3514578_1.jpg"
63 + },
64 + {
65 + "lotNumber": "4008",
66 + "title": "Steve Jobs Macintosh 512K Computer Given to His Father, Complete with Original Accessories and Software",
67 + "url": "https://www.rrauction.com/auctions/lot-detail/351632407484008-steve-jobs-macintosh-512k-computer-given-to-his-father-complete-with-original-accessories-and-software/",
68 + "soldText": "Sold For: $32,333\n(w/BP)",
69 + "estimateText": "$5,000+",
70 + "auctionLine": "August 20, 2026",
71 + "image": "https://cdn.rrauction.com/auction/748/preview/3516324_1.jpg"
72 + }
73 + ]
74 + }
75 + },
76 + "expect": {
77 + "count": 6,
78 + "kinds": [
79 + "sale"
80 + ]
81 + },
82 + "note": "Captured live from https://www.rrauction.com/auctions/auction-details/748?page=1&itemQty=96&view=gallery&sort=time&cat=0 (lists trimmed to 6).",
83 + "capturedAt": "2026-09-07T06:36:10.229Z"
84 +}
\ No newline at end of file
added data/fixtures/swann/catalog-page.json +97 −0
@@ -0,0 +1,97 @@
1 +{
2 + "raw": {
3 + "url": "https://www.swanngalleries.com/auction-catalog/lgbtq-art-material-culture-history_AXESV67S3V?algoliaParam=archive_lotNumber_asc_prod%5Bpage%5D%3D1",
4 + "externalId": "catalog:lgbtq-art-material-culture-history_AXESV67S3V:page:1",
5 + "kind": "sale",
6 + "engine": "api",
7 + "fetchedAt": "2026-09-07T06:36:13.409Z",
8 + "payload": {
9 + "kind": "catalog_page",
10 + "auction": {
11 + "slug": "lgbtq-art-material-culture-history_AXESV67S3V",
12 + "url": "https://www.swanngalleries.com/auction-catalog/lgbtq-art-material-culture-history_AXESV67S3V",
13 + "title": "LGBTQ+ Art, Material Culture & History",
14 + "dateText": "Thursday, August 13, 2026",
15 + "department": "LGBTQ+ Art, Material Culture & History",
16 + "saleNumber": "2747"
17 + },
18 + "page": 1,
19 + "lots": [
20 + {
21 + "ref": "65BF40147A",
22 + "url": "https://www.swanngalleries.com/auction-lot/portrait-of-walt-whitman-with-a-butterfly-1873-pr_65bf40147a",
23 + "lotNumber": "1",
24 + "title": "Portrait of Walt Whitman with a butterfly. 1873; printed 1920s.",
25 + "estimateText": "$1,000 - $1,500",
26 + "soldText": "$1,143",
27 + "passed": false,
28 + "premiumNote": true,
29 + "image": "https://image.invaluable.com/housePhotos/swanngalleries/95/812995/H0132-LhqAuE1ePGvRvC.JPG"
30 + },
31 + {
32 + "ref": "04E032B122",
33 + "url": "https://www.swanngalleries.com/auction-lot/napoleon-sarony-portrait-of-irish-poet-and-playwr_04e032b122",
34 + "lotNumber": "2",
35 + "title": "Napoleon Sarony, Portrait of Irish poet and playwright Oscar Wilde. 1882.",
36 + "estimateText": "$3,000 - $4,500",
37 + "soldText": "$4,318",
38 + "passed": false,
39 + "premiumNote": true,
40 + "image": "https://image.invaluable.com/housePhotos/swanngalleries/95/812995/H0132-LNxKzEPToIXyvB.JPG"
41 + },
42 + {
43 + "ref": "65E02DC514",
44 + "url": "https://www.swanngalleries.com/auction-lot/designer-unknown-henry-hannay-la-femelle-de-persi_65e02dc514",
45 + "lotNumber": "3",
46 + "title": "Designer Unknown, Henry Hannay / La Femelle de Persiflage. 1892.",
47 + "estimateText": "$700 - $1,000",
48 + "soldText": "$698",
49 + "passed": false,
50 + "premiumNote": true,
51 + "image": "https://image.invaluable.com/housePhotos/swanngalleries/95/812995/H0132-LnY2CDEQolJZCO.JPG"
52 + },
53 + {
54 + "ref": "B12A1EC48E",
55 + "url": "https://www.swanngalleries.com/auction-lot/vesta-tilley-two-period-images-of-the-performer-d_b12a1ec48e",
56 + "lotNumber": "4",
57 + "title": "Vesta Tilley, Two Period Images of the Performer Dressed as a Man.",
58 + "estimateText": "$600 - $900",
59 + "soldText": null,
60 + "passed": true,
61 + "premiumNote": false,
62 + "image": "https://image.invaluable.com/housePhotos/swanngalleries/95/812995/H0132-L4YaKDa5jycyj7.JPG"
63 + },
64 + {
65 + "ref": "EB338C9639",
66 + "url": "https://www.swanngalleries.com/auction-lot/vesta-tilley-lithograph-poster-circa-1900_eb338c9639",
67 + "lotNumber": "5",
68 + "title": "Vesta Tilley. Lithograph poster, Circa 1900.",
69 + "estimateText": "$800 - $1,200",
70 + "soldText": null,
71 + "passed": true,
72 + "premiumNote": false,
73 + "image": "https://image.invaluable.com/housePhotos/swanngalleries/95/812995/H0132-L6hkZ1H1czR066.JPG"
74 + },
75 + {
76 + "ref": "1EBFD6709B",
77 + "url": "https://www.swanngalleries.com/auction-lot/man-de-wirth-lithograph-poster-circa-1900_1ebfd6709b",
78 + "lotNumber": "6",
79 + "title": "Man de Wirth. Lithograph poster, Circa 1900.",
80 + "estimateText": "$700 - $1,000",
81 + "soldText": null,
82 + "passed": true,
83 + "premiumNote": false,
84 + "image": "https://image.invaluable.com/housePhotos/swanngalleries/95/812995/H0132-LUe5TjyZ8DERy4.JPG"
85 + }
86 + ]
87 + }
88 + },
89 + "expect": {
90 + "count": 3,
91 + "kinds": [
92 + "sale"
93 + ]
94 + },
95 + "note": "Captured live from https://www.swanngalleries.com/auction-catalog/lgbtq-art-material-culture-history_AXESV67S3V?algoliaParam=archive_lotNumber_asc_prod%5Bpage%5D%3D1 (lists trimmed to 6).",
96 + "capturedAt": "2026-09-07T06:36:13.427Z"
97 +}
\ No newline at end of file
98