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: cars, whisky, wine auction results (agent V); registry 93 connectors

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

36 changed files +4,971 −136

added connectors/api/gooding/_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] ?? 'realized-auction', Number(process.argv[4] ?? 5));
7 +else await runSmoke(dir);
added connectors/api/gooding/index.test.ts +38 −0
@@ -0,0 +1,38 @@
1 +import { describe, expect, it } from 'vitest';
2 +import { ConnectorMetaSchema } from '@rareindex/connectors';
3 +import { loadFixture, runFixtureSuite } from '@rareindex/connectors/testing';
4 +import metaJson from './meta.json' with { type: 'json' };
5 +import createConnector, { auctionSaleDate, discoverRealizedSlugs, imageUrl, parseAuctionPageData } from './index.js';
6 +
7 +const connector = createConnector(ConnectorMetaSchema.parse(metaJson));
8 +
9 +describe('gooding', () => {
10 + runFixtureSuite(connector, it, expect);
11 +
12 + it('maps realized lots to auction sales in the auction currency', async () => {
13 + const fx = loadFixture('gooding', 'realized-auction');
14 + const out = await connector.normalize(fx.raw);
15 + expect(out.length).toBeGreaterThan(0);
16 + for (const r of out) {
17 + if (r.kind !== 'sale') throw new Error('expected sale');
18 + expect(['USD', 'GBP', 'EUR']).toContain(r.currency);
19 + expect(r.saleType).toBe('auction');
20 + expect(r.buyerPremiumIncluded).toBeNull();
21 + expect(['automobiles', 'motorcycles', 'automotive_memorabilia']).toContain(r.attributes.categorySlug);
22 + expect(r.attributes.identifiers.gooding_lot).toBeTruthy();
23 + expect(r.sourceUrl).toMatch(/^https:\/\/www\.goodingco\.com\/lot\//);
24 + expect(r.auctionHouse).toBe('Gooding & Company');
25 + }
26 + });
27 +
28 + it('parses page-data JSON, dates and images', () => {
29 + const json = { result: { data: { contentfulWebPageAuction: { title: 'x', auction: { name: 'Pebble Beach Auctions', currency: 'USD', sellThroughRate: 0.94, subEvents: [{ __typename: 'ContentfulSubEventViewing' }, { __typename: 'ContentfulSubEventAuction', startDate: '2026-08-14T16:00-08:00', endDate: '2026-08-14T21:00-08:00' }, { __typename: 'ContentfulSubEventAuction', startDate: '2026-08-15T11:00-08:00', endDate: '2026-08-15T16:00-08:00' }], lot: [{ slug: '1961-jaguar-xk150', lotNumber: 116, salePrice: 72800, privateSalesPrice: false, item: { __typename: 'ContentfulVehicle', title: '1961 Jaguar XK150 3.8-Litre Fixed Head Coupe', modelYear: 1961, make: { name: 'Jaguar' }, model: 'XK150', cloudinaryImagesCombined: [{ public_id: 'Prod/PB26 Pebble/1961_Jaguar' }] } }, { slug: 'unsold', lotNumber: 1, salePrice: null, item: { title: 'Unsold car' } }] } } } } };
30 + const p = parseAuctionPageData(json, 'pebble-beach-auctions-2026')!;
31 + expect(p.saleDate).toBe('2026-08-15T00:00:00.000Z');
32 + expect(p.lots).toHaveLength(2);
33 + expect(p.lots[0]!.image).toBe('https://res.cloudinary.com/goodingco/image/upload/c_fill,w_1200/Prod/PB26%20Pebble/1961_Jaguar');
34 + expect(auctionSaleDate(undefined)).toBeNull();
35 + expect(imageUrl(null)).toBeNull();
36 + expect(discoverRealizedSlugs('<a href="/auction/realized/pebble-beach-auctions-2026">x</a><a href="https://www.goodingco.com/auction/realized/amelia-island-auctions-2026/">y</a>')).toEqual(['pebble-beach-auctions-2026', 'amelia-island-auctions-2026']);
37 + });
38 +});
added connectors/api/gooding/index.ts +197 −0
@@ -0,0 +1,197 @@
1 +import { z } from 'zod';
2 +import { BaseConnector, type ConnectorMeta, type CrawlContext, type RawRecordInput, type RawRecordLike } from '@rareindex/connectors';
3 +import type { CurrencyCode, NormalizedRecord } from '@rareindex/shared';
4 +import { makeSale, vehicleAttributes } from '../../firecrawl/_carlib/index.js';
5 +
6 +/**
7 + * Gooding & Company prices realized. The Gatsby site serves every realized auction's lot list as
8 + * static JSON (page-data); one raw record per auction (compact lots), one sale per lot with a price.
9 + */
10 +const BASE = 'https://www.goodingco.com';
11 +const CLOUDINARY = 'https://res.cloudinary.com/goodingco/image/upload/c_fill,w_1200';
12 +const PARSER_VERSION = '1.0.0';
13 +
14 +export const LotSchema = z.object({
15 + slug: z.string(),
16 + lotNumber: z.union([z.number(), z.string()]).nullable(),
17 + salePrice: z.number().nullable(),
18 + privateSalesPrice: z.boolean().nullable().optional(),
19 + title: z.string(),
20 + modelYear: z.number().nullable().optional(),
21 + make: z.string().nullable().optional(),
22 + model: z.string().nullable().optional(),
23 + itemType: z.string().nullable().optional(),
24 + image: z.string().nullable().optional(),
25 +});
26 +export type Lot = z.infer<typeof LotSchema>;
27 +export const AuctionPayloadSchema = z.object({
28 + kind: z.literal('realized_auction'),
29 + slug: z.string(),
30 + url: z.string(),
31 + name: z.string(),
32 + currency: z.string(),
33 + saleDate: z.string().nullable(),
34 + sellThroughRate: z.number().nullable(),
35 + lots: z.array(LotSchema),
36 +});
37 +export type AuctionPayload = z.infer<typeof AuctionPayloadSchema>;
38 +
39 +interface PageData {
40 + result?: { data?: { contentfulWebPageAuction?: { title?: string; auction?: RawAuction }; contentfulLot?: RawLot & { auction?: RawAuction } } };
41 +}
42 +interface RawAuction {
43 + name?: string;
44 + currency?: string;
45 + sellThroughRate?: number | null;
46 + subEvents?: Array<{ __typename?: string; startDate?: string; endDate?: string }>;
47 + lot?: RawLot[];
48 +}
49 +interface RawLot {
50 + slug?: string;
51 + lotNumber?: number | string | null;
52 + salePrice?: number | null;
53 + privateSalesPrice?: boolean | null;
54 + item?: { __typename?: string; title?: string; modelYear?: number | null; make?: { name?: string } | null; model?: string | null; cloudinaryImagesCombined?: Array<{ public_id?: string }> | null } | null;
55 +}
56 +
57 +export function imageUrl(publicId: string | undefined | null): string | null {
58 + if (!publicId) return null;
59 + return `${CLOUDINARY}/${publicId.split('/').map(encodeURIComponent).join('/')}`;
60 +}
61 +
62 +/** Sale date = end of the last auction sub-event (calendar day in the venue's offset, stored as UTC midnight). */
63 +export function auctionSaleDate(a: RawAuction | undefined): string | null {
64 + const days = (a?.subEvents ?? []).filter((s) => s.__typename === 'ContentfulSubEventAuction' && (s.endDate || s.startDate)).map((s) => (s.endDate ?? s.startDate)!.slice(0, 10));
65 + if (!days.length) return null;
66 + const last = days.sort().at(-1)!;
67 + return `${last}T00:00:00.000Z`;
68 +}
69 +
70 +export function trimLot(l: RawLot): Lot | null {
71 + if (!l.slug || !l.item?.title) return null;
72 + return LotSchema.parse({
73 + slug: l.slug,
74 + lotNumber: l.lotNumber ?? null,
75 + salePrice: typeof l.salePrice === 'number' ? l.salePrice : null,
76 + privateSalesPrice: l.privateSalesPrice ?? null,
77 + title: l.item.title,
78 + modelYear: l.item.modelYear ?? null,
79 + make: l.item.make?.name ?? null,
80 + model: l.item.model ?? null,
81 + itemType: l.item.__typename ?? null,
82 + image: imageUrl(l.item.cloudinaryImagesCombined?.[0]?.public_id),
83 + });
84 +}
85 +
86 +export function parseAuctionPageData(json: unknown, slug: string): AuctionPayload | null {
87 + const page = (json as PageData)?.result?.data?.contentfulWebPageAuction;
88 + const a = page?.auction;
89 + if (!a) return null;
90 + const lots = (a.lot ?? []).map(trimLot).filter((x): x is Lot => Boolean(x));
91 + return { kind: 'realized_auction', slug, url: `${BASE}/auction/realized/${slug}`, name: a.name ?? page?.title ?? slug, currency: (a.currency ?? 'USD').toUpperCase(), saleDate: auctionSaleDate(a), sellThroughRate: a.sellThroughRate ?? null, lots };
92 +}
93 +
94 +export function discoverRealizedSlugs(html: string): string[] {
95 + return [...new Set([...html.matchAll(/\/auction\/realized\/([a-z0-9-]+)/g)].map((m) => m[1]!))];
96 +}
97 +
98 +export class GoodingConnector extends BaseConnector {
99 + readonly version = '1.0.0';
100 + readonly parserVersion = PARSER_VERSION;
101 + protected override minIntervalMs = 1500;
102 + override readonly urlPatterns = [/^https?:\/\/www\.goodingco\.com\/lot\/[a-z0-9-]+\/?$/i];
103 +
104 + async *crawl(ctx: CrawlContext): AsyncIterable<RawRecordInput> {
105 + const seeds = (this.meta.config.seeds as string[] | undefined) ?? [];
106 + const perRun = Number(this.meta.config.auctionsPerRun ?? 3);
107 + const done = new Set<string>((ctx.options.cursor?.done as string[] | undefined) ?? []);
108 + const backfill = ctx.options.mode === 'backfill';
109 + // Discover currently linked realized auctions from the homepage (plain HTML).
110 + let discovered: string[] = [];
111 + const home = await ctx.fetch(`${BASE}/`, { engines: ['api'], responseType: 'text', minQuality: 0 });
112 + if (home.success && home.html) discovered = discoverRealizedSlugs(home.html);
113 + const slugs = [...new Set([...discovered, ...seeds])];
114 + // Incremental: newest first, skip auctions already ingested unless backfilling.
115 + const todo = slugs.filter((s) => backfill || !done.has(s)).slice(0, perRun);
116 + let count = 0;
117 + for (const slug of todo) {
118 + if (ctx.signal?.aborted || this.reached(ctx, count)) break;
119 + const url = `${BASE}/page-data/auction/realized/${slug}/page-data.json`;
120 + await this.throttle();
121 + const res = await ctx.fetch(url, {
122 + engines: ['api'],
123 + expect: ['title', 'price', 'currency', 'date'],
124 + parse: (r) => {
125 + const p = parseAuctionPageData(r.json, slug);
126 + const sold = p?.lots.find((l) => l.salePrice);
127 + return p ? { title: p.name, price: sold?.salePrice ?? null, currency: p.currency, date: p.saleDate } : null;
128 + },
129 + });
130 + const payload = res.success ? parseAuctionPageData(res.json, slug) : null;
131 + if (!payload) {
132 + ctx.anomaly('page_fetch_failed', `${url}: ${res.error ?? res.httpStatus}`);
133 + continue;
134 + }
135 + if (!payload.lots.length) ctx.anomaly('empty_page', url);
136 + count++;
137 + yield { url: payload.url, externalId: `auction:${slug}`, kind: 'sale', engine: res.engine, httpStatus: res.httpStatus, payload, fetchedAt: res.fetchedAt };
138 + done.add(slug);
139 + await ctx.setCursor({ done: [...done], updatedAt: new Date().toISOString() });
140 + }
141 + }
142 +
143 + async lookup(url: string, ctx: CrawlContext): Promise<RawRecordInput[]> {
144 + const slug = url.match(/\/lot\/([a-z0-9-]+)/i)?.[1];
145 + if (!slug) return [];
146 + const res = await ctx.fetch(`${BASE}/page-data/lot/${slug}/page-data.json`, { engines: ['api'], minQuality: 0 });
147 + const lot = (res.json as PageData)?.result?.data?.contentfulLot;
148 + if (!res.success || !lot?.auction) return [];
149 + const item = trimLot({ ...lot, slug });
150 + if (!item) return [];
151 + const payload: AuctionPayload = { kind: 'realized_auction', slug: `lot-${slug}`, url, name: lot.auction.name ?? '', currency: (lot.auction.currency ?? 'USD').toUpperCase(), saleDate: auctionSaleDate(lot.auction), sellThroughRate: null, lots: [item] };
152 + return [{ url, externalId: `lot:${slug}`, kind: 'sale', engine: res.engine, httpStatus: res.httpStatus, payload, fetchedAt: res.fetchedAt }];
153 + }
154 +
155 + async normalize(raw: RawRecordLike): Promise<NormalizedRecord[]> {
156 + const p = AuctionPayloadSchema.parse(raw.payload);
157 + if (!p.saleDate) return [];
158 + const saleDate = new Date(p.saleDate);
159 + const currency = p.currency as CurrencyCode;
160 + const out: NormalizedRecord[] = [];
161 + for (const lot of p.lots) {
162 + if (!lot.salePrice || lot.salePrice <= 0 || lot.privateSalesPrice) continue;
163 + const isVehicle = lot.itemType !== 'ContentfulAutomobilia' && Boolean(lot.make || lot.modelYear);
164 + const attributes = vehicleAttributes(lot.title, {
165 + ...(isVehicle ? {} : { categorySlug: 'automotive_memorabilia' }),
166 + identifiers: { gooding_lot: lot.slug },
167 + metadata: { auction: p.name, lot_number: lot.lotNumber, make_field: lot.make, model_field: lot.model, model_year_field: lot.modelYear, sell_through_rate: p.sellThroughRate },
168 + });
169 + if (lot.make) attributes.brand = lot.make;
170 + if (lot.modelYear) attributes.year = lot.modelYear;
171 + if (lot.model) attributes.model = lot.model;
172 + out.push(
173 + makeSale({
174 + meta: this.meta,
175 + sourceUrl: `${BASE}/lot/${lot.slug}`,
176 + externalId: lot.slug,
177 + rawTitle: lot.title,
178 + attributes,
179 + price: lot.salePrice,
180 + currency,
181 + saleDate,
182 + buyerPremiumIncluded: null,
183 + auctionHouse: 'Gooding & Company',
184 + lotNumber: lot.lotNumber === null ? null : String(lot.lotNumber),
185 + imageUrls: lot.image ? [lot.image] : [],
186 + observedAt: raw.fetchedAt,
187 + parserVersion: PARSER_VERSION,
188 + }),
189 + );
190 + }
191 + return out;
192 + }
193 +}
194 +
195 +export default function createConnector(meta: ConnectorMeta): GoodingConnector {
196 + return new GoodingConnector(meta);
197 +}
added connectors/api/gooding/meta.json +43 −0
@@ -0,0 +1,43 @@
1 +{
2 + "id": "gooding",
3 + "displayName": "Gooding & Company (prices realized)",
4 + "sourceId": "gooding",
5 + "sourceName": "Gooding & Company",
6 + "sourceType": "auction_house",
7 + "sourceUrl": "https://www.goodingco.com",
8 + "module": "api/gooding",
9 + "enginePriority": ["api"],
10 + "categories": ["automobiles", "motorcycles", "automotive_memorabilia"],
11 + "regions": ["US", "GB", "FR"],
12 + "languages": ["en"],
13 + "currency": ["USD", "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": 1440,
22 + "priority": "medium",
23 + "trustScore": 0.9,
24 + "attributionRequired": true,
25 + "termsUrl": "https://www.goodingco.com/terms-of-use",
26 + "accessNotes": "Gooding & Company's public 'Prices Realized' pages are a Gatsby site; each realized auction page ships its full lot list as static JSON at /page-data/auction/realized/<slug>/page-data.json (one plain HTTPS request per auction, 0 credits; robots.txt has no disallow rules). Each lot carries salePrice (auction currency), lotNumber, title, model year, make, model and Cloudinary image ids (cloud 'goodingco'). Sale date = the last ContentfulSubEventAuction end date of the auction. Lots without a salePrice (unsold/withdrawn) and private-sale prices are skipped. The page does not state whether salePrice includes the buyer's premium, so buyer_premium_included is null (Gooding's published totals are customarily quoted inclusive of premium). Auction slugs come from config.seeds plus /auction/realized/ links found on the homepage; lookup() handles /lot/<slug> pages via their page-data JSON.",
27 + "enabled": true,
28 + "schemaVersion": "1.0",
29 + "config": {
30 + "seeds": [
31 + "pebble-beach-auctions-2026",
32 + "amelia-island-auctions-2026",
33 + "retromobile-paris-2026",
34 + "retromobile-new-york-auctions-2026",
35 + "pebble-beach-auctions-2025",
36 + "pebble-beach-auctions-2024",
37 + "london-auction-2024",
38 + "pebble-beach-auctions-2023",
39 + "london-auction-2023"
40 + ],
41 + "auctionsPerRun": 3
42 + }
43 +}
added connectors/api/historics/_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] ?? 'lots-page', Number(process.argv[4] ?? 6));
7 +else await runSmoke(dir);
added connectors/api/historics/index.test.ts +42 −0
@@ -0,0 +1,42 @@
1 +import { describe, expect, it } from 'vitest';
2 +import { ConnectorMetaSchema } from '@rareindex/connectors';
3 +import { loadFixture, runFixtureSuite } from '@rareindex/connectors/testing';
4 +import metaJson from './meta.json' with { type: 'json' };
5 +import createConnector, { categoryFor, parseLotsPage, parseResultsList, parseUkDate } from './index.js';
6 +
7 +const connector = createConnector(ConnectorMetaSchema.parse(metaJson));
8 +
9 +describe('historics', () => {
10 + runFixtureSuite(connector, it, expect);
11 +
12 + it('maps lots to GBP hammer sales dated by the sale', async () => {
13 + const fx = loadFixture('historics', 'lots-page');
14 + const out = await connector.normalize(fx.raw);
15 + expect(out.length).toBeGreaterThan(0);
16 + for (const r of out) {
17 + if (r.kind !== 'sale') throw new Error('expected sale');
18 + expect(r.currency).toBe('GBP');
19 + expect(r.buyerPremiumIncluded).toBe(false);
20 + expect(r.attributes.identifiers.historics_lot).toMatch(/^\d+$/);
21 + expect(r.sourceUrl).toMatch(/^https:\/\/www\.historics\.co\.uk\/auction\/lot\//);
22 + expect(r.auctionHouse).toBe('Historics Auctioneers');
23 + }
24 + });
25 +
26 + it('parses dates, the results list and lot cards', () => {
27 + expect(parseUkDate('18th Jul, 2026 9:30')).toEqual(new Date(Date.UTC(2026, 6, 18)));
28 + expect(parseUkDate('6th Aug, 2026 19:30')).toEqual(new Date(Date.UTC(2026, 7, 6)));
29 + const list = `<div class="auction-calendar-item"><a href='/auction/details/a075-the-summer-serenade-windsorview-lakes?au=107'><img/></a><div class="auction-calendar-text"><h3>The Summer Serenade; Windsorview Lakes</h3> Date: 18th Jul, 2026 9:30 Sale number: A075 Lots: 194 blah</div></div><hr />`;
30 + const a = parseResultsList(list);
31 + expect(a).toHaveLength(1);
32 + expect(a[0]).toMatchObject({ au: '107', slug: 'a075-the-summer-serenade-windsorview-lakes', saleNumber: 'A075', lotCount: 194, endedOn: '2026-07-18T00:00:00.000Z' });
33 + const lots = `<div class="auction-lot"><div class="auction-lot-image"><a href="/auction/lot/lot-101---1955-phillips-panda-autocycle/?lot=20016&amp;so=0&amp;au=107"><img src="https://storagegohistorics.goauction.co.uk/stock/19969-0-small.jpg?v=1" /></a></div><div class="auction-lot-text"><p class="auction-lot-title"><a href="/auction/lot/lot-101---1955-phillips-panda-autocycle/?lot=20016&amp;so=0&amp;au=107"><span class='lot-title cat-29'>Lot 101 - <span class="req-tag"></span>1955 Phillips Panda Autocycle <span class='sub-title cat-29'>Offered without reserve</span></span></a></p><p><strong>Sold £801</strong></p></div></div><div class="auction-lot"><div class="auction-lot-text"><p class="auction-lot-title"><a href="/auction/lot/x/?lot=1&amp;au=107"><span class='lot-title'>Lot 102 - 2006 Nissan Micra</span></a></p><p><strong>Sold for an undisclosed fee</strong></p></div></div>`;
34 + const l = parseLotsPage(lots);
35 + expect(l).toHaveLength(2);
36 + expect(l[0]).toMatchObject({ lotId: '20016', lotNo: '101', title: '1955 Phillips Panda Autocycle', subtitle: 'Offered without reserve', priceGbp: 801, image: 'https://storagegohistorics.goauction.co.uk/stock/19969-0-small.jpg' });
37 + expect(l[1]!.priceGbp).toBeNull();
38 + expect(categoryFor(a[0]!, '1955 Phillips Panda Autocycle')).toMatchObject({ vehicle: true });
39 + expect(categoryFor({ ...a[0]!, saleNumber: 'W017' }, 'Porsche dealership sign')).toMatchObject({ categorySlug: 'automotive_memorabilia' });
40 + expect(categoryFor({ ...a[0]!, saleNumber: 'W015' }, 'Registration number 1 ABC')).toMatchObject({ categorySlug: 'license_plates' });
41 + });
42 +});
added connectors/api/historics/index.ts +174 −0
@@ -0,0 +1,174 @@
1 +import { z } from 'zod';
2 +import { BaseConnector, html as H, type ConnectorMeta, type CrawlContext, type RawRecordInput, type RawRecordLike } from '@rareindex/connectors';
3 +import type { NormalizedRecord } from '@rareindex/shared';
4 +import { lotAttributes, makeSale, money, vehicleAttributes } from '../../firecrawl/_carlib/index.js';
5 +
6 +/**
7 + * Historics Auctioneers — results list → per-sale "Past lots" grid (96 lots per page) with "Sold £X".
8 + * One raw record per lots page; one sale per lot with a price.
9 + */
10 +const BASE = 'https://www.historics.co.uk';
11 +const PARSER_VERSION = '1.0.0';
12 +
13 +export const AuctionSchema = z.object({ au: z.string(), slug: z.string(), title: z.string(), saleNumber: z.string().nullable(), endedOn: z.string().nullable(), lotCount: z.number().nullable() });
14 +export type Auction = z.infer<typeof AuctionSchema>;
15 +export const LotSchema = z.object({ lotId: z.string(), url: z.string(), lotNo: z.string().nullable(), title: z.string(), subtitle: z.string().nullable(), soldText: z.string().nullable(), priceGbp: z.number().nullable(), image: z.string().nullable() });
16 +export type Lot = z.infer<typeof LotSchema>;
17 +export const PagePayloadSchema = z.object({ kind: z.literal('lots_page'), url: z.string(), auction: AuctionSchema, page: z.number(), lots: z.array(LotSchema) });
18 +
19 +const MONTHS: Record<string, number> = { jan: 0, feb: 1, mar: 2, apr: 3, may: 4, jun: 5, jul: 6, aug: 7, sep: 8, oct: 9, nov: 10, dec: 11 };
20 +/** "18th Jul, 2026 9:30" | "6th Aug, 2026 19:30" → UTC midnight. */
21 +export function parseUkDate(s: string | null | undefined): Date | null {
22 + const m = s?.match(/(\d{1,2})(?:st|nd|rd|th)?\s+([A-Za-z]{3})[a-z]*,?\s+(\d{4})/);
23 + if (!m) return null;
24 + const mo = MONTHS[m[2]!.toLowerCase()];
25 + return mo === undefined ? null : new Date(Date.UTC(Number(m[3]), mo, Number(m[1])));
26 +}
27 +
28 +/** Parse /auction-results: calendar items with title, "Date:"/"Ends:" and "Sale number:". */
29 +export function parseResultsList(htmlText: string): Auction[] {
30 + const $ = H.load(htmlText);
31 + const out: Auction[] = [];
32 + const seen = new Set<string>();
33 + $('.auction-calendar-item').each((_, el) => {
34 + const href = $(el).find('a[href*="/auction/details/"]').first().attr('href') ?? '';
35 + const m = href.match(/\/auction\/details\/([^/?]+)\?au=(\d+)/);
36 + if (!m || seen.has(m[2]!)) return;
37 + const text = $(el).text().replace(/\s+/g, ' ').trim();
38 + const title = H.text($(el).find('.auction-calendar-text h2, .auction-calendar-text h3, .auction-calendar-text h4').first()) ?? text.split(/\s(?:Date|Ends):/)[0]!.trim();
39 + const dateTxt = text.match(/(?:Date|Ends):\s*([^S]+?)\s+Sale number/i)?.[1] ?? text.match(/(?:Date|Ends):\s*(\d{1,2}(?:st|nd|rd|th)?\s+[A-Za-z]{3,9},?\s+\d{4})/i)?.[1] ?? null;
40 + const saleNumber = text.match(/Sale number:\s*([A-Z]{1,2}O?\d{2,4})/i)?.[1] ?? null;
41 + const lots = text.match(/Lots:\s*(\d+)/i)?.[1];
42 + seen.add(m[2]!);
43 + out.push({ au: m[2]!, slug: m[1]!, title, saleNumber, endedOn: parseUkDate(dateTxt)?.toISOString() ?? null, lotCount: lots ? Number(lots) : null });
44 + });
45 + return out;
46 +}
47 +
48 +/** Parse a lots page: cards with lot number, title, "Sold £X". */
49 +export function parseLotsPage(htmlText: string): Lot[] {
50 + const $ = H.load(htmlText);
51 + const out: Lot[] = [];
52 + $('.auction-lot').each((_, el) => {
53 + const a = $(el).find('.auction-lot-title a').first();
54 + const href = a.attr('href') ?? '';
55 + const lotId = href.match(/[?&]lot=(\d+)/)?.[1];
56 + if (!lotId) return;
57 + const titleEl = a.find('.lot-title').clone();
58 + const subtitle = H.text(titleEl.find('.sub-title'));
59 + titleEl.find('.sub-title').remove();
60 + const full = H.text(titleEl) ?? '';
61 + const lotNo = full.match(/^Lot\s+([A-Z]?\d+[A-Z]?)\s*-\s*/i)?.[1] ?? null;
62 + const title = full.replace(/^Lot\s+[A-Z]?\d+[A-Z]?\s*-\s*/i, '').trim();
63 + if (!title) return;
64 + const soldText = H.text($(el).find('strong').filter((_, s) => /^Sold/i.test($(s).text().trim())).first());
65 + const priceTxt = soldText?.match(/Sold\s*£\s*([\d,]+(?:\.\d+)?)/i)?.[1] ?? null;
66 + const img = $(el).find('.auction-lot-image img').attr('src') ?? null;
67 + out.push({ lotId, url: `${BASE}${href.split('&so=')[0]!.replace(/&amp;/g, '&')}`, lotNo, title, subtitle, soldText, priceGbp: priceTxt ? Number(priceTxt.replace(/,/g, '')) : null, image: img ? img.replace(/\?v=.*$/, '') : null });
68 + });
69 + return out;
70 +}
71 +
72 +export function categoryFor(auction: Auction, title: string): { categorySlug: string; vehicle: boolean } {
73 + const sale = (auction.saleNumber ?? '').toUpperCase();
74 + if (/registration|number plate|cherished/i.test(title) || /^[A-Z]{1,2}\d*\s*[A-Z]{0,3}$/.test(title.replace(/\s+/g, ' ').trim()) && sale.startsWith('W')) return { categorySlug: 'license_plates', vehicle: false };
75 + if (sale.startsWith('W') && !/^(19|20)\d{2}\b/.test(title)) return { categorySlug: 'automotive_memorabilia', vehicle: false };
76 + return { categorySlug: 'automobiles', vehicle: true };
77 +}
78 +
79 +export class HistoricsConnector extends BaseConnector {
80 + readonly version = '1.0.0';
81 + readonly parserVersion = PARSER_VERSION;
82 + protected override minIntervalMs = 10_000; // robots.txt crawl-delay: 10
83 +
84 + async *crawl(ctx: CrawlContext): AsyncIterable<RawRecordInput> {
85 + const perRun = Number(this.meta.config.auctionsPerRun ?? 1);
86 + const pagesPerRun = Number(this.meta.config.pagesPerRun ?? 3);
87 + const progress = { ...((ctx.options.cursor?.progress as Record<string, number> | undefined) ?? {}) };
88 + const complete = new Set<string>((ctx.options.cursor?.complete as string[] | undefined) ?? []);
89 + await this.throttle();
90 + const list = await ctx.fetch(`${BASE}/auction-results`, { engines: ['api'], responseType: 'text', expect: ['title', 'date'], parse: (r) => (r.html ? { title: parseResultsList(r.html)[0]?.title, date: parseResultsList(r.html)[0]?.endedOn } : null) });
91 + if (!list.success || !list.html) {
92 + ctx.anomaly('page_fetch_failed', `results list: ${list.error ?? list.httpStatus}`);
93 + return;
94 + }
95 + const auctions = parseResultsList(list.html).filter((a) => a.endedOn && new Date(a.endedOn).getTime() < Date.now());
96 + const todo = auctions.filter((a) => !complete.has(a.au)).slice(0, perRun);
97 + let pagesFetched = 0;
98 + let count = 0;
99 + for (const auction of todo) {
100 + let page = (progress[auction.au] ?? 0) + 1;
101 + while (true) {
102 + if (ctx.signal?.aborted || this.reached(ctx, count) || pagesFetched >= pagesPerRun) return void (await ctx.setCursor({ progress, complete: [...complete] }));
103 + const url = `${BASE}/auction/details/${auction.slug}?au=${auction.au}&pp=96&pn=${page}`;
104 + await this.throttle();
105 + const res = await ctx.fetch(url, {
106 + engines: ['api'],
107 + responseType: 'text',
108 + expect: ['title', 'price', 'currency', 'status'],
109 + parse: (r) => {
110 + const lots = r.html ? parseLotsPage(r.html) : [];
111 + const sold = lots.find((l) => l.priceGbp);
112 + return lots.length ? { title: lots[0]!.title, price: sold?.priceGbp ?? null, currency: sold ? 'GBP' : null, status: sold ? 'sold' : null } : null;
113 + },
114 + });
115 + pagesFetched++;
116 + const lots = res.success && res.html ? parseLotsPage(res.html) : [];
117 + if (!lots.length) {
118 + if (!res.success) ctx.anomaly('page_fetch_failed', `${url}: ${res.error ?? res.httpStatus}`);
119 + complete.add(auction.au);
120 + break;
121 + }
122 + count++;
123 + yield { url, externalId: `auction:${auction.au}:page:${page}`, kind: 'sale', engine: res.engine, httpStatus: res.httpStatus, payload: { kind: 'lots_page' as const, url, auction, page, lots }, fetchedAt: res.fetchedAt };
124 + progress[auction.au] = page;
125 + if (lots.length < 96) {
126 + complete.add(auction.au);
127 + break;
128 + }
129 + page++;
130 + }
131 + }
132 + await ctx.setCursor({ progress, complete: [...complete] });
133 + }
134 +
135 + async normalize(raw: RawRecordLike): Promise<NormalizedRecord[]> {
136 + const p = PagePayloadSchema.parse(raw.payload);
137 + if (!p.auction.endedOn) return [];
138 + const saleDate = new Date(p.auction.endedOn);
139 + const out: NormalizedRecord[] = [];
140 + for (const lot of p.lots) {
141 + if (!lot.priceGbp || lot.priceGbp <= 0) continue;
142 + const m = money(`£${lot.priceGbp}`, 'GBP');
143 + if (!m) continue;
144 + const cat = categoryFor(p.auction, lot.title);
145 + const meta = { auction: p.auction.title, sale_number: p.auction.saleNumber, subtitle: lot.subtitle, sold_text: lot.soldText };
146 + const attributes = cat.vehicle ? vehicleAttributes(lot.title, { country: 'GB', identifiers: { historics_lot: lot.lotId }, metadata: meta }) : lotAttributes({ categorySlug: cat.categorySlug, name: lot.title, country: 'GB', identifiers: { historics_lot: lot.lotId }, metadata: meta });
147 + out.push(
148 + makeSale({
149 + meta: this.meta,
150 + sourceUrl: lot.url,
151 + externalId: lot.lotId,
152 + rawTitle: lot.title,
153 + attributes,
154 + price: m.amount,
155 + currency: 'GBP',
156 + saleDate,
157 + buyerPremiumIncluded: false,
158 + auctionHouse: 'Historics Auctioneers',
159 + lotNumber: lot.lotNo,
160 + imageUrls: lot.image ? [lot.image] : [],
161 + description: lot.subtitle,
162 + location: 'United Kingdom',
163 + observedAt: raw.fetchedAt,
164 + parserVersion: PARSER_VERSION,
165 + }),
166 + );
167 + }
168 + return out;
169 + }
170 +}
171 +
172 +export default function createConnector(meta: ConnectorMeta): HistoricsConnector {
173 + return new HistoricsConnector(meta);
174 +}
added connectors/api/historics/meta.json +33 −0
@@ -0,0 +1,33 @@
1 +{
2 + "id": "historics",
3 + "displayName": "Historics Auctioneers (results)",
4 + "sourceId": "historics",
5 + "sourceName": "Historics Auctioneers",
6 + "sourceType": "auction_house",
7 + "sourceUrl": "https://www.historics.co.uk",
8 + "module": "api/historics",
9 + "enginePriority": ["api"],
10 + "categories": ["automobiles", "motorcycles", "automotive_memorabilia", "license_plates"],
11 + "regions": ["GB"],
12 + "languages": ["en"],
13 + "currency": ["GBP"],
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": "low",
23 + "trustScore": 0.85,
24 + "attributionRequired": true,
25 + "termsUrl": "https://www.historics.co.uk/terms-and-conditions",
26 + "accessNotes": "Historics (UK classic-car auctions, Ascot/Windsor/Brooklands + online automobilia & registration sales) lists past sales at /auction-results (title, sale number, end date) and renders each sale's 'Past lots' server-side at /auction/details/<slug>?au=<id>&pp=96&pn=<page> with 'Sold £X' per lot card (plain HTTPS, 0 credits). robots.txt sets crawl-delay 10, so the connector waits 10 s between requests and caps pages per run. 'Sold £X' is the hammer price (Historics adds a buyer's premium + VAT on top) → buyer_premium_included=false; lots 'sold for an undisclosed fee' and unsold lots are skipped. Sale numbers starting with A = vehicle sales (cars/motorcycles from the title), W = online automobilia/registration sales (automotive_memorabilia; registration plates → license_plates). Sale date = the end date printed on the results list.",
27 + "enabled": true,
28 + "schemaVersion": "1.0",
29 + "config": {
30 + "auctionsPerRun": 1,
31 + "pagesPerRun": 3
32 + }
33 +}
added connectors/api/just-whisky/_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] ?? 'lots-page', Number(process.argv[4] ?? 8));
7 +else await runSmoke(dir);
added connectors/api/just-whisky/index.test.ts +39 −0
@@ -0,0 +1,39 @@
1 +import { describe, expect, it } from 'vitest';
2 +import { ConnectorMetaSchema } from '@rareindex/connectors';
3 +import { loadFixture, runFixtureSuite } from '@rareindex/connectors/testing';
4 +import metaJson from './meta.json' with { type: 'json' };
5 +import createConnector, { lotsUrl, trimLot } from './index.js';
6 +
7 +const connector = createConnector(ConnectorMetaSchema.parse(metaJson));
8 +
9 +describe('just-whisky', () => {
10 + runFixtureSuite(connector, it, expect);
11 +
12 + it('maps reserve-met lots to GBP hammer sales', async () => {
13 + const fx = loadFixture('just-whisky', 'lots-page');
14 + const out = await connector.normalize(fx.raw);
15 + expect(out.length).toBeGreaterThan(0);
16 + for (const r of out) {
17 + if (r.kind !== 'sale') throw new Error('expected sale');
18 + expect(r.currency).toBe('GBP');
19 + expect(r.buyerPremiumIncluded).toBe(false);
20 + expect(['whisky', 'rum', 'cognac']).toContain(r.attributes.categorySlug);
21 + expect(r.attributes.identifiers.justwhisky_lot).toMatch(/^\d+$/);
22 + expect(r.sourceUrl).toMatch(/^https:\/\/www\.just-whisky\.co\.uk\/lot\//);
23 + }
24 + });
25 +
26 + it('skips reserve-not-met lots and builds API urls', async () => {
27 + const met = trimLot({ id: 1, slug: 'port-ellen-1981-feis-ile-2008-1', reserve_met: true, hammer_price: '2750.00', bid_stats: { current_bid: 2750 }, seller_sheet: { auction: { id: 1160, end_date: '2026-07-20T19:00:00' } }, item: { title: 'Port Ellen 1981', subtitle: 'Feis Ile 2008', strength: { name: '54.7%' }, size: { name: '70 cl' } } })!;
28 + const notMet = trimLot({ id: 2, slug: 'springbank-36-2', reserve_met: false, hammer_price: null, bid_stats: { current_bid: 3150 }, seller_sheet: { auction: { id: 1160, end_date: '2026-07-20T19:00:00' } }, item: { title: 'Springbank 36 Years Old 1965' } })!;
29 + const out = await connector.normalize({ url: 'u', externalId: null, kind: 'sale', engine: 'api', fetchedAt: new Date(), payload: { kind: 'lots_page', url: 'u', page: 1, count: 2, lots: [met, notMet] } });
30 + expect(out).toHaveLength(1);
31 + const s = out[0]!;
32 + if (s.kind !== 'sale') throw new Error('sale');
33 + expect(s.price).toBe(2750);
34 + expect(s.saleDate.toISOString()).toBe('2026-07-20T19:00:00.000Z');
35 + expect(s.attributes.size).toBe('70cl');
36 + expect(s.attributes.year).toBe(1981);
37 + expect(lotsUrl(new Date(Date.UTC(2026, 6, 14)), new Date(Date.UTC(2026, 6, 21)), 2, 200)).toBe('https://www.just-whisky.co.uk/api/lots/?min_end_date=14%2F07%2F2026&max_end_date=21%2F07%2F2026&ordering=-price&page_size=200&page=2');
38 + });
39 +});
added connectors/api/just-whisky/index.ts +210 −0
@@ -0,0 +1,210 @@
1 +import { z } from 'zod';
2 +import { BaseConnector, type ConnectorMeta, type CrawlContext, type RawRecordInput, type RawRecordLike } from '@rareindex/connectors';
3 +import { AssetAttributesSchema, NormalizedSaleSchema, type NormalizedRecord } from '@rareindex/shared';
4 +import { whiskyFacts } from '../scotch-whisky-auctions/index.js';
5 +
6 +/**
7 + * Just Whisky past auctions — public JSON API behind the Past Auctions page. One raw record per API page
8 + * (compact lots); one sale per lot whose reserve was met (hammer price).
9 + */
10 +const BASE = 'https://www.just-whisky.co.uk';
11 +const PARSER_VERSION = '1.0.0';
12 +
13 +export const LotSchema = z.object({
14 + id: z.number(),
15 + slug: z.string(),
16 + title: z.string(),
17 + subtitle: z.string().nullable(),
18 + reserveMet: z.boolean(),
19 + hammerPrice: z.number().nullable(),
20 + currentBid: z.number().nullable(),
21 + isGroupLot: z.boolean(),
22 + auctionId: z.number().nullable(),
23 + auctionEnd: z.string().nullable(),
24 + strength: z.string().nullable(),
25 + size: z.string().nullable(),
26 + distillery: z.string().nullable(),
27 + bottler: z.string().nullable(),
28 + region: z.string().nullable(),
29 + estimatedValue: z.number().nullable(),
30 + image: z.string().nullable(),
31 +});
32 +export type Lot = z.infer<typeof LotSchema>;
33 +export const PagePayloadSchema = z.object({ kind: z.literal('lots_page'), url: z.string(), page: z.number(), count: z.number().nullable(), lots: z.array(LotSchema) });
34 +
35 +interface ApiLot {
36 + id: number;
37 + slug: string;
38 + reserve_met?: boolean;
39 + hammer_price?: string | null;
40 + is_group_lot?: boolean;
41 + custom_title?: string | null;
42 + custom_subtitle?: string | null;
43 + bid_stats?: { current_bid?: number | null };
44 + seller_sheet?: { auction?: { id?: number; end_date?: string } };
45 + item?: { title?: string; subtitle?: string; strength?: { name?: string } | null; size?: { name?: string } | null; distillery?: { name?: string } | string | null; bottler?: { name?: string } | string | null; region?: { name?: string } | string | null; estimated_value?: string | null; photo?: { file?: string } | null };
46 + photos?: Array<{ file?: string }>;
47 +}
48 +const name = (v: { name?: string } | string | null | undefined): string | null => (typeof v === 'string' ? v : v?.name ?? null) || null;
49 +
50 +export function trimLot(l: ApiLot): Lot | null {
51 + const title = (l.custom_title || l.item?.title || '').trim();
52 + if (!l.id || !l.slug || !title) return null;
53 + const hp = l.hammer_price ? Number(l.hammer_price) : null;
54 + return LotSchema.parse({
55 + id: l.id,
56 + slug: l.slug,
57 + title,
58 + subtitle: (l.custom_subtitle || l.item?.subtitle || null)?.trim() || null,
59 + reserveMet: Boolean(l.reserve_met),
60 + hammerPrice: hp && Number.isFinite(hp) ? hp : null,
61 + currentBid: typeof l.bid_stats?.current_bid === 'number' ? l.bid_stats.current_bid : null,
62 + isGroupLot: Boolean(l.is_group_lot),
63 + auctionId: l.seller_sheet?.auction?.id ?? null,
64 + auctionEnd: l.seller_sheet?.auction?.end_date ?? null,
65 + strength: name(l.item?.strength),
66 + size: name(l.item?.size),
67 + distillery: name(l.item?.distillery),
68 + bottler: name(l.item?.bottler),
69 + region: name(l.item?.region),
70 + estimatedValue: l.item?.estimated_value ? Number(l.item.estimated_value) || null : null,
71 + image: l.photos?.[0]?.file ?? l.item?.photo?.file ?? null,
72 + });
73 +}
74 +
75 +const ddmmyyyy = (d: Date) => `${String(d.getUTCDate()).padStart(2, '0')}/${String(d.getUTCMonth() + 1).padStart(2, '0')}/${d.getUTCFullYear()}`;
76 +
77 +export function lotsUrl(from: Date, to: Date, page: number, pageSize: number): string {
78 + return `${BASE}/api/lots/?min_end_date=${encodeURIComponent(ddmmyyyy(from))}&max_end_date=${encodeURIComponent(ddmmyyyy(to))}&ordering=-price&page_size=${pageSize}&page=${page}`;
79 +}
80 +
81 +export class JustWhiskyConnector extends BaseConnector {
82 + readonly version = '1.0.0';
83 + readonly parserVersion = PARSER_VERSION;
84 + protected override minIntervalMs = 1500;
85 + override readonly urlPatterns = [/^https?:\/\/www\.just-whisky\.co\.uk\/lot\/[a-z0-9-]+/i];
86 +
87 + /** Auction windows: [start-1d, end+1d] for each completed auction (newest first). */
88 + private async windows(ctx: CrawlContext): Promise<Array<{ id: number; from: Date; to: Date; name: string }>> {
89 + const res = await ctx.fetch(`${BASE}/api/auctions/?page_size=200`, { engines: ['api'], minQuality: 0 });
90 + const data = (res.json as { data?: { results?: Array<{ id: number; name: string; start_date: string; end_date: string; is_published: boolean }> } } | null)?.data?.results ?? [];
91 + const now = Date.now();
92 + return data
93 + .filter((a) => a.is_published && new Date(a.end_date).getTime() < now && new Date(a.end_date).getUTCFullYear() >= 2013)
94 + .map((a) => ({ id: a.id, name: a.name, from: new Date(new Date(a.start_date).getTime() - 86_400_000), to: new Date(new Date(a.end_date).getTime() + 86_400_000) }))
95 + .sort((a, b) => b.to.getTime() - a.to.getTime());
96 + }
97 +
98 + async *crawl(ctx: CrawlContext): AsyncIterable<RawRecordInput> {
99 + const pageSize = Number(this.meta.config.pageSize ?? 200);
100 + const pagesPerRun = Number(this.meta.config.pagesPerRun ?? 8);
101 + const auctionsPerRun = Number(this.meta.config.auctionsPerRun ?? 2);
102 + const backfill = ctx.options.mode === 'backfill';
103 + const done = new Set<number>((ctx.options.cursor?.done as number[] | undefined) ?? []);
104 + const all = await this.windows(ctx);
105 + if (!all.length) {
106 + ctx.anomaly('empty_page', 'no auctions from /api/auctions/');
107 + return;
108 + }
109 + const todo = (backfill ? [...all].reverse() : all).filter((w) => !done.has(w.id)).slice(0, auctionsPerRun);
110 + let pages = 0;
111 + let count = 0;
112 + for (const w of todo) {
113 + let page = 1;
114 + while (pages < pagesPerRun && !ctx.signal?.aborted && !this.reached(ctx, count)) {
115 + const url = lotsUrl(w.from, w.to, page, pageSize);
116 + await this.throttle();
117 + const res = await ctx.fetch(url, {
118 + engines: ['api'],
119 + expect: ['title', 'price', 'date', 'status'],
120 + parse: (r) => {
121 + const first = (r.json as { data?: { results?: ApiLot[] } } | null)?.data?.results?.[0];
122 + return first ? { title: first.item?.title ?? first.custom_title, price: first.hammer_price ?? first.bid_stats?.current_bid, date: first.seller_sheet?.auction?.end_date, status: first.reserve_met } : null;
123 + },
124 + });
125 + pages++;
126 + const data = (res.json as { data?: { results?: ApiLot[]; count?: number; total_pages?: number } } | null)?.data;
127 + if (!res.success || !data?.results) {
128 + ctx.anomaly('page_fetch_failed', `${url}: ${res.error ?? res.httpStatus}`);
129 + break;
130 + }
131 + const lots = data.results.map(trimLot).filter((x): x is Lot => Boolean(x));
132 + if (!lots.length) break;
133 + count++;
134 + yield { url, externalId: `auction:${w.id}:page:${page}`, kind: 'sale', engine: res.engine, httpStatus: res.httpStatus, payload: { kind: 'lots_page' as const, url, page, count: data.count ?? null, lots }, fetchedAt: res.fetchedAt };
135 + if (!data.total_pages || page >= data.total_pages) {
136 + done.add(w.id);
137 + break;
138 + }
139 + page++;
140 + }
141 + await ctx.setCursor({ done: [...done], updatedAt: new Date().toISOString() });
142 + }
143 + }
144 +
145 + async lookup(url: string, ctx: CrawlContext): Promise<RawRecordInput[]> {
146 + const slug = url.match(/\/lot\/([a-z0-9-]+)/i)?.[1];
147 + const id = slug?.match(/(\d+)$/)?.[1];
148 + if (!id) return [];
149 + const res = await ctx.fetch(`${BASE}/api/lots/${id}/`, { engines: ['api'], minQuality: 0 });
150 + const data = (res.json as { data?: ApiLot } | null)?.data;
151 + const lot = data ? trimLot(data) : null;
152 + if (!res.success || !lot) return [];
153 + return [{ url, externalId: `lot:${id}`, kind: 'sale', engine: res.engine, httpStatus: res.httpStatus, payload: { kind: 'lots_page' as const, url, page: 0, count: 1, lots: [lot] }, fetchedAt: res.fetchedAt }];
154 + }
155 +
156 + async normalize(raw: RawRecordLike): Promise<NormalizedRecord[]> {
157 + const p = PagePayloadSchema.parse(raw.payload);
158 + const out: NormalizedRecord[] = [];
159 + for (const lot of p.lots) {
160 + const price = lot.hammerPrice ?? (lot.reserveMet ? lot.currentBid : null);
161 + if (!lot.reserveMet || !price || price <= 0 || !lot.auctionEnd) continue;
162 + const saleDate = new Date(lot.auctionEnd.endsWith('Z') ? lot.auctionEnd : `${lot.auctionEnd}Z`);
163 + if (Number.isNaN(saleDate.getTime())) continue;
164 + const fullTitle = lot.subtitle ? `${lot.title} ${lot.subtitle}` : lot.title;
165 + const f = whiskyFacts(fullTitle);
166 + const attributes = AssetAttributesSchema.parse({
167 + categorySlug: f.categorySlug,
168 + brand: lot.distillery ?? f.brand,
169 + name: fullTitle,
170 + year: f.vintage,
171 + size: lot.size && /\d/.test(lot.size) ? lot.size.replace(/\s+/g, '') : f.size,
172 + country: /scotch|islay|speyside|highland|campbeltown|lowland|scotland/i.test(`${fullTitle} ${lot.region ?? ''}`) ? 'GB' : null,
173 + identifiers: { justwhisky_lot: String(lot.id) },
174 + metadata: { age_statement: f.age, strength: lot.strength, bottler: lot.bottler, region: lot.region, estimated_value_gbp: lot.estimatedValue, auction_id: lot.auctionId, group_lot: lot.isGroupLot },
175 + });
176 + out.push(
177 + NormalizedSaleSchema.parse({
178 + kind: 'sale',
179 + connectorId: this.meta.id,
180 + sourceId: this.meta.sourceId,
181 + sourceUrl: `${BASE}/lot/${lot.slug}`,
182 + externalId: String(lot.id),
183 + rawTitle: fullTitle,
184 + imageUrls: lot.image ? [lot.image] : [],
185 + attributes,
186 + grade: { grader: null, grade: null, qualifier: null, certificationNumber: null },
187 + condition: { condition: null, conditionRaw: null, completeness: null },
188 + observedAt: raw.fetchedAt,
189 + confidence: 0.9,
190 + parserVersion: PARSER_VERSION,
191 + saleType: 'auction',
192 + saleDate,
193 + price,
194 + currency: 'GBP',
195 + buyerPremiumIncluded: false,
196 + quantity: 1,
197 + isBundle: lot.isGroupLot || /\bx\s?\d|\(x\d+\)|\bset of\b/i.test(fullTitle),
198 + location: 'Scotland, United Kingdom',
199 + auctionHouse: 'Just Whisky',
200 + lotNumber: null,
201 + }),
202 + );
203 + }
204 + return out;
205 + }
206 +}
207 +
208 +export default function createConnector(meta: ConnectorMeta): JustWhiskyConnector {
209 + return new JustWhiskyConnector(meta);
210 +}
added connectors/api/just-whisky/meta.json +34 −0
@@ -0,0 +1,34 @@
1 +{
2 + "id": "just-whisky",
3 + "displayName": "Just Whisky (past auctions)",
4 + "sourceId": "just-whisky",
5 + "sourceName": "Just Whisky",
6 + "sourceType": "auction_house",
7 + "sourceUrl": "https://www.just-whisky.co.uk",
8 + "module": "api/just-whisky",
9 + "enginePriority": ["api"],
10 + "categories": ["whisky", "rum", "cognac"],
11 + "regions": ["GB"],
12 + "languages": ["en"],
13 + "currency": ["GBP"],
14 + "supportsListings": false,
15 + "supportsSold": true,
16 + "supportsAuctions": false,
17 + "supportsImages": true,
18 + "supportsCatalog": false,
19 + "supportsPopulation": false,
20 + "supportsLookup": true,
21 + "refreshFrequencyMinutes": 1440,
22 + "priority": "high",
23 + "trustScore": 0.9,
24 + "attributionRequired": true,
25 + "termsUrl": "https://www.just-whisky.co.uk/terms-and-conditions",
26 + "accessNotes": "Just Whisky (Scotland, monthly online whisky auctions since 2013) renders its Past Auctions page from a public JSON API that we read directly: GET /api/lots/?min_end_date=dd/mm/yyyy&max_end_date=dd/mm/yyyy&ordering=-price&page_size=200&page=N and GET /api/auctions/ (plain HTTPS, 0 credits; robots.txt disallows only /account/ and /checkout). A lot is a sale when reserve_met is true (hammer_price is then populated); price = hammer price in GBP, buyer's commission is charged separately → buyer_premium_included=false. Sale date = the lot's auction end_date (seller_sheet.auction). Item facts (strength, size, distillery/bottler when filled) go to attributes/metadata; titles are parsed with the shared whisky heuristics. Incremental runs read the last two auction windows; backfill walks auctions oldest→newest from /api/auctions/. 1.5 s politeness.",
27 + "enabled": true,
28 + "schemaVersion": "1.0",
29 + "config": {
30 + "pageSize": 200,
31 + "pagesPerRun": 8,
32 + "auctionsPerRun": 2
33 + }
34 +}
added connectors/api/pcarmarket/_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] ?? 6));
7 +else await runSmoke(dir);
added connectors/api/pcarmarket/index.test.ts +42 −0
@@ -0,0 +1,42 @@
1 +import { describe, expect, it } from 'vitest';
2 +import { ConnectorMetaSchema } from '@rareindex/connectors';
3 +import { loadFixture, runFixtureSuite } from '@rareindex/connectors/testing';
4 +import metaJson from './meta.json' with { type: 'json' };
5 +import createConnector, { classify, trimItem, watchReference } from './index.js';
6 +
7 +const connector = createConnector(ConnectorMetaSchema.parse(metaJson));
8 +
9 +describe('pcarmarket', () => {
10 + runFixtureSuite(connector, it, expect);
11 +
12 + it('maps sold items to USD auction sales without premium', async () => {
13 + const fx = loadFixture('pcarmarket', 'results-page');
14 + const out = await connector.normalize(fx.raw);
15 + expect(out.length).toBeGreaterThan(0);
16 + for (const r of out) {
17 + if (r.kind !== 'sale') throw new Error('expected sale');
18 + expect(r.currency).toBe('USD');
19 + expect(r.buyerPremiumIncluded).toBe(false);
20 + expect(r.attributes.identifiers.pcarmarket_id).toMatch(/^\d+$/);
21 + expect(r.sourceUrl).toMatch(/^https:\/\/www\.pcarmarket\.com\/auction\//);
22 + expect(r.saleDate.getTime()).toBeLessThan(Date.now());
23 + }
24 + });
25 +
26 + it('classifies cars, watches and automobilia', () => {
27 + const car = trimItem({ id: 1, title: 'One-Owner 1983 Porsche 911SC Coupe', slug: 'x', vehicle: { id: 5, make: 'Porsche', model: '911SC', year: 1983, slug_model: 's' }, high_bid: 65000, end_date: '2026-09-04T15:00:07-04:00', status: 'Sold' })!;
28 + expect(classify(car)).toMatchObject({ kind: 'vehicle', categorySlug: 'automobiles', brand: 'Porsche' });
29 + const watch = trimItem({ id: 2, title: 'TAG Heuer Gulf Special Edition Watch Ref. CAVZ101 Full Set', slug: 'y', vehicle: null, high_bid: 1250, end_date: '2026-09-03T15:00:00-04:00', status: 'Sold' })!;
30 + expect(classify(watch)).toMatchObject({ kind: 'watch', categorySlug: 'other_watches' });
31 + expect(watchReference(watch.title)).toBe('CAVZ101');
32 + const rolex = trimItem({ id: 3, title: '2023 Rolex Daytona Panda Watch Ref. 116500LN Full Set', slug: 'z', vehicle: null, high_bid: 30000, end_date: '2026-09-03T15:00:00-04:00', status: 'Sold' })!;
33 + expect(classify(rolex)).toMatchObject({ kind: 'watch', categorySlug: 'rolex' });
34 + const sign = trimItem({ id: 4, title: 'No Reserve Illuminated Porsche Eye Chart Sign', slug: 'w', vehicle: null, high_bid: 250, end_date: '2026-09-03T15:00:00-04:00', status: 'Sold' })!;
35 + expect(classify(sign)).toMatchObject({ kind: 'memorabilia', categorySlug: 'automotive_memorabilia' });
36 + });
37 +
38 + it('drops unsold and priceless items', async () => {
39 + const out = await connector.normalize({ url: 'u', externalId: null, kind: 'sale', engine: 'api', fetchedAt: new Date(), payload: { kind: 'results_page', page: 1, count: 1, items: [{ id: 9, title: '1990 Porsche 964', slug: 'a', vehicle: { make: 'Porsche', model: '964', year: 1990 }, high_bid: 50000, end_date: '2026-09-01T00:00:00Z', status: 'Reserve Not Met' }] } });
40 + expect(out).toHaveLength(0);
41 + });
42 +});
added connectors/api/pcarmarket/index.ts +166 −0
@@ -0,0 +1,166 @@
1 +import { z } from 'zod';
2 +import { BaseConnector, type ConnectorMeta, type CrawlContext, type RawRecordInput, type RawRecordLike } from '@rareindex/connectors';
3 +import type { AssetAttributes, NormalizedRecord } from '@rareindex/shared';
4 +import { lotAttributes, makeSale, vehicleAttributes } from '../../firecrawl/_carlib/index.js';
5 +
6 +/**
7 + * PCARMARKET sold auctions (Porsche-centric enthusiast marketplace; cars, watches, automobilia).
8 + * One raw record per API page (compact items); one sale per item with status "Sold".
9 + */
10 +const BASE = 'https://www.pcarmarket.com';
11 +const API = `${BASE}/api/auctions/`;
12 +const PARSER_VERSION = '1.0.0';
13 +
14 +export const ItemSchema = z.object({
15 + id: z.number(),
16 + title: z.string(),
17 + slug: z.string(),
18 + vehicle: z.object({ make: z.string().nullable().optional(), model: z.string().nullable().optional(), year: z.number().nullable().optional() }).nullable().optional(),
19 + high_bid: z.number().nullable().optional(),
20 + end_date: z.string().nullable().optional(),
21 + status: z.string().nullable().optional(),
22 + country: z.string().nullable().optional(),
23 + zip_code: z.string().nullable().optional(),
24 + mileage_body: z.number().nullable().optional(),
25 + odometer_type: z.string().nullable().optional(),
26 + bid_count: z.number().nullable().optional(),
27 + reserve_status: z.string().nullable().optional(),
28 + is_marketplace: z.boolean().nullable().optional(),
29 + featured_image_large_url: z.string().nullable().optional(),
30 +});
31 +export type Item = z.infer<typeof ItemSchema>;
32 +export const PagePayloadSchema = z.object({ kind: z.literal('results_page'), page: z.number(), count: z.number().nullable(), items: z.array(ItemSchema) });
33 +
34 +const KEEP = ['id', 'title', 'slug', 'vehicle', 'high_bid', 'end_date', 'status', 'country', 'zip_code', 'mileage_body', 'odometer_type', 'bid_count', 'reserve_status', 'is_marketplace', 'featured_image_large_url'] as const;
35 +
36 +export function trimItem(raw: Record<string, unknown>): Item | null {
37 + const out: Record<string, unknown> = {};
38 + for (const k of KEEP) if (raw[k] !== undefined) out[k] = raw[k];
39 + if (out.vehicle && typeof out.vehicle === 'object') {
40 + const v = out.vehicle as Record<string, unknown>;
41 + out.vehicle = { make: v.make ?? null, model: v.model ?? null, year: v.year ?? null };
42 + }
43 + const p = ItemSchema.safeParse(out);
44 + return p.success ? p.data : null;
45 +}
46 +
47 +const WATCH_BRANDS: Array<[RegExp, string]> = [
48 + [/\brolex\b/i, 'rolex'],
49 + [/\bomega\b/i, 'omega'],
50 + [/\bpatek\b/i, 'patek_philippe'],
51 + [/\baudemars\b/i, 'audemars_piguet'],
52 + [/\b(tag heuer|heuer|tissot|panerai|cartier|breitling|iwc|tudor|hublot|zenith|seiko|grand seiko|franck muller|chopard|longines|oris|bell & ross|jaeger|montblanc|richard mille|vacheron|a\.? lange|girard|chronograph watch|watch ref)\b/i, 'other_watches'],
53 +];
54 +
55 +/** Classify a sold lot: vehicle object → car/moto; watch keywords → watch slugs; else automobilia. */
56 +export function classify(it: Item): { kind: 'vehicle' | 'watch' | 'memorabilia'; categorySlug: string; brand: string | null } {
57 + const t = it.title;
58 + if (it.vehicle && (it.vehicle.make || it.vehicle.year)) return { kind: 'vehicle', categorySlug: 'automobiles', brand: it.vehicle.make ?? null };
59 + if (/\bwatch(es)?\b|\bref\.? ?[a-z0-9.-]{4,}\b.*(full set|box)/i.test(t) || WATCH_BRANDS.slice(0, 4).some(([re]) => re.test(t) && /watch|ref\b|ref\.|full set|dial|bracelet/i.test(t))) {
60 + for (const [re, slug] of WATCH_BRANDS) if (re.test(t)) return { kind: 'watch', categorySlug: slug, brand: t.match(re)?.[0]?.replace(/\bwatch ref\b|\bchronograph watch\b/i, '').trim() || null };
61 + return { kind: 'watch', categorySlug: 'other_watches', brand: null };
62 + }
63 + return { kind: 'memorabilia', categorySlug: 'automotive_memorabilia', brand: null };
64 +}
65 +
66 +export function watchReference(title: string): string | null {
67 + const m = title.match(/\bRef\.?\s*([A-Z0-9][A-Z0-9.\-/]{3,})/i);
68 + return m ? m[1]!.replace(/[.,]$/, '') : null;
69 +}
70 +
71 +export class PcarmarketConnector extends BaseConnector {
72 + readonly version = '1.0.0';
73 + readonly parserVersion = PARSER_VERSION;
74 + protected override minIntervalMs = 1200;
75 +
76 + async *crawl(ctx: CrawlContext): AsyncIterable<RawRecordInput> {
77 + const limit = Number(this.meta.config.limit ?? 50);
78 + const pages = Number(this.meta.config.pagesPerRun ?? 10);
79 + const backfill = ctx.options.mode === 'backfill';
80 + const start = backfill ? Number(ctx.options.cursor?.nextPage ?? 1) : 1;
81 + const newestSeen = !backfill && typeof ctx.options.cursor?.newestEnd === 'string' ? String(ctx.options.cursor.newestEnd) : '';
82 + let count = 0;
83 + let maxEnd = newestSeen;
84 + for (let page = start; page < start + pages; page++) {
85 + if (ctx.signal?.aborted || this.reached(ctx, count)) break;
86 + const url = `${API}?limit=${limit}&page=${page}&sort_by=ending_soon&status=sold&type=all`;
87 + await this.throttle();
88 + const res = await ctx.fetch(url, {
89 + engines: ['api'],
90 + expect: ['title', 'price', 'date', 'status'],
91 + parse: (r) => {
92 + const first = (r.json as { results?: Array<Record<string, unknown>> } | null)?.results?.[0];
93 + return first ? { title: first.title, price: first.high_bid, date: first.end_date, status: first.status } : null;
94 + },
95 + });
96 + const data = res.json as { results?: Array<Record<string, unknown>>; count?: number; next?: string | null } | null;
97 + if (!res.success || !data?.results) {
98 + ctx.anomaly('page_fetch_failed', `${url}: ${res.error ?? res.httpStatus}`);
99 + break;
100 + }
101 + const items = data.results.map(trimItem).filter((x): x is Item => Boolean(x));
102 + if (!items.length) {
103 + ctx.anomaly('empty_page', `page ${page}`);
104 + break;
105 + }
106 + for (const it of items) if (it.end_date && it.end_date > maxEnd) maxEnd = it.end_date;
107 + count++;
108 + yield { url: `${BASE}/results/?page=${page}`, externalId: `results:${page}:${items[0]!.id}`, kind: 'sale', engine: res.engine, httpStatus: res.httpStatus, payload: { kind: 'results_page' as const, page, count: data.count ?? null, items }, fetchedAt: res.fetchedAt };
109 + const oldest = items.map((i) => i.end_date ?? '').filter(Boolean).sort()[0] ?? '';
110 + if (backfill) await ctx.setCursor({ nextPage: page + 1, updatedAt: new Date().toISOString() });
111 + else if (newestSeen && oldest && oldest <= newestSeen) break;
112 + if (!data.next) break;
113 + }
114 + if (!backfill && maxEnd) await ctx.setCursor({ newestEnd: maxEnd, updatedAt: new Date().toISOString() });
115 + }
116 +
117 + async normalize(raw: RawRecordLike): Promise<NormalizedRecord[]> {
118 + const p = PagePayloadSchema.parse(raw.payload);
119 + const out: NormalizedRecord[] = [];
120 + for (const it of p.items) {
121 + if (it.status !== 'Sold' || !it.high_bid || it.high_bid <= 0 || !it.end_date) continue;
122 + const saleDate = new Date(it.end_date);
123 + if (Number.isNaN(saleDate.getTime())) continue;
124 + const c = classify(it);
125 + const country = it.country === 'United States of America' ? 'US' : it.country === 'Canada' ? 'CA' : (it.country ?? null);
126 + const meta = { bid_count: it.bid_count ?? null, reserve_status: it.reserve_status ?? null, mileage: it.mileage_body ?? null, odometer_type: it.odometer_type ?? null, zip_code: it.zip_code ?? null, marketplace: it.is_marketplace ?? null };
127 + let attributes: AssetAttributes;
128 + if (c.kind === 'vehicle') {
129 + attributes = vehicleAttributes(it.title, { country, identifiers: { pcarmarket_id: String(it.id) }, metadata: meta });
130 + if (it.vehicle?.make) attributes.brand = it.vehicle.make;
131 + if (it.vehicle?.model) attributes.model = it.vehicle.model;
132 + if (it.vehicle?.year) attributes.year = it.vehicle.year;
133 + } else if (c.kind === 'watch') {
134 + const ref = watchReference(it.title);
135 + attributes = lotAttributes({ categorySlug: c.categorySlug, name: it.title.replace(/^No Reserve\s+/i, ''), brand: c.brand, country, identifiers: { pcarmarket_id: String(it.id), ...(ref ? { reference: ref } : {}) }, metadata: meta });
136 + attributes.reference = ref;
137 + } else {
138 + attributes = lotAttributes({ categorySlug: 'automotive_memorabilia', name: it.title.replace(/^No Reserve\s+/i, ''), country, identifiers: { pcarmarket_id: String(it.id) }, metadata: meta });
139 + }
140 + out.push(
141 + makeSale({
142 + meta: this.meta,
143 + sourceUrl: `${BASE}/auction/${it.slug}/`,
144 + externalId: String(it.id),
145 + rawTitle: it.title,
146 + attributes,
147 + price: it.high_bid,
148 + currency: 'USD',
149 + saleDate,
150 + buyerPremiumIncluded: false,
151 + auctionHouse: 'PCARMARKET',
152 + imageUrls: it.featured_image_large_url ? [it.featured_image_large_url] : [],
153 + location: [it.zip_code, country].filter(Boolean).join(', ') || null,
154 + observedAt: raw.fetchedAt,
155 + parserVersion: PARSER_VERSION,
156 + confidence: c.kind === 'vehicle' ? 0.92 : 0.85,
157 + }),
158 + );
159 + }
160 + return out;
161 + }
162 +}
163 +
164 +export default function createConnector(meta: ConnectorMeta): PcarmarketConnector {
165 + return new PcarmarketConnector(meta);
166 +}
added connectors/api/pcarmarket/meta.json +33 −0
@@ -0,0 +1,33 @@
1 +{
2 + "id": "pcarmarket",
3 + "displayName": "PCARMARKET (sold auctions)",
4 + "sourceId": "pcarmarket",
5 + "sourceName": "PCARMARKET",
6 + "sourceType": "auction_house",
7 + "sourceUrl": "https://www.pcarmarket.com",
8 + "module": "api/pcarmarket",
9 + "enginePriority": ["api"],
10 + "categories": ["automobiles", "motorcycles", "automotive_memorabilia", "rolex", "omega", "other_watches"],
11 + "regions": ["US", "CA"],
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": 360,
22 + "priority": "high",
23 + "trustScore": 0.85,
24 + "attributionRequired": true,
25 + "termsUrl": "https://www.pcarmarket.com/terms/",
26 + "accessNotes": "PCARMARKET's public results page (/results/) is fed by the same JSON endpoint we read: GET /api/auctions/?status=sold&type=all&sort_by=ending_soon&limit=50&page=N (plain HTTPS, 0 credits; robots.txt: 'User-agent: * Allow: /'). Each item has title, vehicle {make, model, year}, high_bid (USD winning bid), end_date, status 'Sold', country, images. Price = winning bid; PCARMARKET charges the buyer a separate 5% fee, so buyer_premium_included=false. Items with a vehicle object are cars/motorcycles; other lots are classified from the title (watches by brand keyword → watch slugs, everything else → automotive_memorabilia: signs, models, parts, helmets). Incremental runs stop at the newest end_date seen previously; backfill mode walks older pages (≈7,900 sold lots as of 2026-09).",
27 + "enabled": true,
28 + "schemaVersion": "1.0",
29 + "config": {
30 + "pagesPerRun": 10,
31 + "limit": 50
32 + }
33 +}
added connectors/api/the-market-bonhams/_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] ?? 6));
7 +else await runSmoke(dir);
added connectors/api/the-market-bonhams/index.test.ts +36 −0
@@ -0,0 +1,36 @@
1 +import { describe, expect, it } from 'vitest';
2 +import { ConnectorMetaSchema } from '@rareindex/connectors';
3 +import { loadFixture, runFixtureSuite } from '@rareindex/connectors/testing';
4 +import metaJson from './meta.json' with { type: 'json' };
5 +import createConnector, { parseResultsPage, parseSoldText } from './index.js';
6 +
7 +const connector = createConnector(ConnectorMetaSchema.parse(metaJson));
8 +
9 +describe('the-market-bonhams', () => {
10 + runFixtureSuite(connector, it, expect);
11 +
12 + it('maps result cards to sales dated by the card', async () => {
13 + const fx = loadFixture('the-market-bonhams', 'results-page');
14 + const out = await connector.normalize(fx.raw);
15 + expect(out.length).toBeGreaterThan(0);
16 + for (const r of out) {
17 + if (r.kind !== 'sale') throw new Error('expected sale');
18 + expect(['GBP', 'EUR', 'AUD', 'USD']).toContain(r.currency);
19 + expect(r.buyerPremiumIncluded).toBe(false);
20 + expect(['automobiles', 'motorcycles']).toContain(r.attributes.categorySlug);
21 + expect(r.attributes.identifiers.themarket_id).toMatch(/^[0-9a-f-]{36}$/);
22 + expect(r.sourceUrl).toMatch(/^https:\/\/www\.themarket\.co\.uk\/listings\//);
23 + }
24 + });
25 +
26 + it('parses sold text and cards', () => {
27 + expect(parseSoldText('Sold for £56,000 on 17 Aug 2026')).toMatchObject({ amount: 56000, currency: 'GBP', date: new Date(Date.UTC(2026, 7, 17)) });
28 + expect(parseSoldText('Sold for €25,100 on 05 Jan 2023')).toMatchObject({ amount: 25100, currency: 'EUR' });
29 + expect(parseSoldText('Sold for A$41,000 on 1 Feb 2024')).toMatchObject({ amount: 41000, currency: 'AUD' });
30 + expect(parseSoldText('Bid to £10,000')).toBeNull();
31 + const html = `<a href="/listings/range-rover/classic/4f12faa9-7308-4e71-a17f-e3ca8db972c3" data-qa="listing card"><div class="listing-card"><div class="listing-card__image"><img src="https://cdn.themarket.co.uk/x/y.jpg?optimizer=image"/></div><div class="listing-card__heading"><p class="heading-text">Sold for £56,000 on 17 Aug 2026</p></div><div class="listing-card__content"><h3 class="listing-title">1991 Range Rover Classic</h3><p class="listing-intro">£170k Restoration</p><span class="icon-text listing-location"><span class="text">THE MARKET HQ, GB</span></span></div><div class="listing-card__footer"><p class="bids-count footer__item">43 bids</p></div></div></a>`;
32 + const cards = parseResultsPage(html);
33 + expect(cards).toHaveLength(1);
34 + expect(cards[0]).toMatchObject({ id: '4f12faa9-7308-4e71-a17f-e3ca8db972c3', title: '1991 Range Rover Classic', bids: 43, location: 'THE MARKET HQ, GB', image: 'https://cdn.themarket.co.uk/x/y.jpg' });
35 + });
36 +});
added connectors/api/the-market-bonhams/index.ts +130 −0
@@ -0,0 +1,130 @@
1 +import { z } from 'zod';
2 +import { BaseConnector, html as H, type ConnectorMeta, type CrawlContext, type RawRecordInput, type RawRecordLike } from '@rareindex/connectors';
3 +import type { NormalizedRecord } from '@rareindex/shared';
4 +import { dateWords, makeSale, money, vehicleAttributes } from '../../firecrawl/_carlib/index.js';
5 +
6 +/**
7 + * The Market by Bonhams — server-rendered results grid. One raw record per results page; one sale per card
8 + * carrying "Sold for <price> on <date>".
9 + */
10 +const BASE = 'https://www.themarket.co.uk';
11 +const PARSER_VERSION = '1.0.0';
12 +
13 +export const CardSchema = z.object({ id: z.string(), url: z.string(), title: z.string(), intro: z.string().nullable(), soldText: z.string(), bids: z.number().nullable(), location: z.string().nullable(), image: z.string().nullable() });
14 +export type Card = z.infer<typeof CardSchema>;
15 +export const PagePayloadSchema = z.object({ kind: z.literal('results_page'), page: z.number(), url: z.string(), cards: z.array(CardSchema) });
16 +
17 +export function parseResultsPage(htmlText: string): Card[] {
18 + const $ = H.load(htmlText);
19 + const out: Card[] = [];
20 + $('a[data-qa="listing card"]').each((_, a) => {
21 + const href = $(a).attr('href') ?? '';
22 + const id = href.match(/\/listings\/[^/]+\/[^/]+\/([0-9a-f-]{36})/)?.[1];
23 + const title = H.text($(a).find('.listing-title'));
24 + const soldText = H.text($(a).find('.listing-card__heading .heading-text'));
25 + if (!id || !title || !soldText || !/^Sold for/i.test(soldText)) return;
26 + const bidsTxt = H.text($(a).find('.bids-count'));
27 + const img = $(a).find('img').attr('src') ?? null;
28 + out.push({
29 + id,
30 + url: BASE + href,
31 + title,
32 + intro: H.text($(a).find('.listing-intro')),
33 + soldText,
34 + bids: bidsTxt ? Number(bidsTxt.replace(/\D/g, '')) || null : null,
35 + location: H.text($(a).find('.listing-location .text')),
36 + image: img ? img.replace(/\?.*$/, '') : null,
37 + });
38 + });
39 + return out;
40 +}
41 +
42 +/** "Sold for £56,000 on 17 Aug 2026" → price/currency/date. */
43 +export function parseSoldText(s: string): { amount: number; currency: 'GBP' | 'EUR' | 'AUD' | 'USD'; date: Date } | null {
44 + const m = s.match(/Sold for\s+(A\$|US\$|\$|£|€)\s?([\d,]+(?:\.\d+)?)\s+on\s+(.+)$/i);
45 + if (!m) return null;
46 + const sym = m[1]!;
47 + const currency = sym === '£' ? 'GBP' : sym === '€' ? 'EUR' : sym === 'A$' ? 'AUD' : 'USD';
48 + const amt = money(`${m[2]} ${currency}`, currency);
49 + const date = dateWords(m[3]!);
50 + if (!amt || !date) return null;
51 + return { amount: amt.amount, currency, date };
52 +}
53 +
54 +export class TheMarketConnector extends BaseConnector {
55 + readonly version = '1.0.0';
56 + readonly parserVersion = PARSER_VERSION;
57 + protected override minIntervalMs = 1500;
58 +
59 + async *crawl(ctx: CrawlContext): AsyncIterable<RawRecordInput> {
60 + const pages = Number(this.meta.config.pagesPerRun ?? 8);
61 + const backfill = ctx.options.mode === 'backfill';
62 + const start = backfill ? Number(ctx.options.cursor?.nextPage ?? 1) : 1;
63 + const newestSeen = !backfill && typeof ctx.options.cursor?.newestDate === 'string' ? String(ctx.options.cursor.newestDate) : '';
64 + let maxDate = newestSeen;
65 + let count = 0;
66 + for (let page = start; page < start + pages; page++) {
67 + if (ctx.signal?.aborted || this.reached(ctx, count)) break;
68 + const url = `${BASE}/auctions/results?page=${page}`;
69 + await this.throttle();
70 + const res = await ctx.fetch(url, {
71 + engines: ['api'],
72 + responseType: 'text',
73 + expect: ['title', 'price', 'date', 'status'],
74 + parse: (r) => {
75 + const c = r.html ? parseResultsPage(r.html) : [];
76 + const p = c[0] ? parseSoldText(c[0].soldText) : null;
77 + return c.length ? { title: c[0]!.title, price: p?.amount ?? null, date: p?.date ?? null, status: 'sold' } : null;
78 + },
79 + });
80 + const cards = res.success && res.html ? parseResultsPage(res.html) : [];
81 + if (!cards.length) {
82 + ctx.anomaly(res.success ? 'empty_page' : 'page_fetch_failed', `${url}: ${res.error ?? res.httpStatus}`);
83 + break;
84 + }
85 + const dates = cards.map((c) => parseSoldText(c.soldText)?.date.toISOString() ?? '').filter(Boolean);
86 + for (const d of dates) if (d > maxDate) maxDate = d;
87 + count++;
88 + yield { url, externalId: `results:${page}:${cards[0]!.id}`, kind: 'sale', engine: res.engine, httpStatus: res.httpStatus, payload: { kind: 'results_page' as const, page, url, cards }, fetchedAt: res.fetchedAt };
89 + const oldest = dates.sort()[0] ?? '';
90 + if (backfill) await ctx.setCursor({ nextPage: page + 1, updatedAt: new Date().toISOString() });
91 + else if (newestSeen && oldest && oldest <= newestSeen) break;
92 + }
93 + if (!backfill && maxDate) await ctx.setCursor({ newestDate: maxDate, updatedAt: new Date().toISOString() });
94 + }
95 +
96 + async normalize(raw: RawRecordLike): Promise<NormalizedRecord[]> {
97 + const p = PagePayloadSchema.parse(raw.payload);
98 + const out: NormalizedRecord[] = [];
99 + for (const c of p.cards) {
100 + const sold = parseSoldText(c.soldText);
101 + if (!sold) continue;
102 + const country = c.location?.match(/,\s*([A-Z]{2})$/)?.[1] ?? null;
103 + const attributes = vehicleAttributes(c.title, { country, identifiers: { themarket_id: c.id }, metadata: { intro: c.intro, bids: c.bids, location: c.location } });
104 + out.push(
105 + makeSale({
106 + meta: this.meta,
107 + sourceUrl: c.url,
108 + externalId: c.id,
109 + rawTitle: c.title,
110 + attributes,
111 + price: sold.amount,
112 + currency: sold.currency,
113 + saleDate: sold.date,
114 + buyerPremiumIncluded: false,
115 + auctionHouse: 'The Market by Bonhams',
116 + imageUrls: c.image ? [c.image] : [],
117 + location: c.location,
118 + description: c.intro,
119 + observedAt: raw.fetchedAt,
120 + parserVersion: PARSER_VERSION,
121 + }),
122 + );
123 + }
124 + return out;
125 + }
126 +}
127 +
128 +export default function createConnector(meta: ConnectorMeta): TheMarketConnector {
129 + return new TheMarketConnector(meta);
130 +}
added connectors/api/the-market-bonhams/meta.json +32 −0
@@ -0,0 +1,32 @@
1 +{
2 + "id": "the-market-bonhams",
3 + "displayName": "The Market by Bonhams (results)",
4 + "sourceId": "the-market-bonhams",
5 + "sourceName": "The Market by Bonhams",
6 + "sourceType": "auction_house",
7 + "sourceUrl": "https://www.themarket.co.uk",
8 + "module": "api/the-market-bonhams",
9 + "enginePriority": ["api"],
10 + "categories": ["automobiles", "motorcycles"],
11 + "regions": ["GB", "EU", "AU"],
12 + "languages": ["en"],
13 + "currency": ["GBP", "EUR", "AUD", "USD"],
14 + "supportsListings": false,
15 + "supportsSold": true,
16 + "supportsAuctions": false,
17 + "supportsImages": true,
18 + "supportsCatalog": false,
19 + "supportsPopulation": false,
20 + "supportsLookup": false,
21 + "refreshFrequencyMinutes": 720,
22 + "priority": "medium",
23 + "trustScore": 0.85,
24 + "attributionRequired": true,
25 + "termsUrl": "https://www.themarket.co.uk/terms-and-conditions",
26 + "accessNotes": "The Market by Bonhams (online collector-car auctions, UK/EU/AU) publishes results server-side at /auctions/results?page=N (18 cards per page, ≈330 pages back to 2019; plain HTTPS with the RareIndex user agent; robots.txt 'User-agent: * Allow: /'). Each card: title, 'Sold for £56,000 on 17 Aug 2026' (native currency symbol £/€/A$), listing URL, bid count, location. Price = winning bid; The Market charges the buyer a separate fee, so buyer_premium_included=false. Sale date = the date printed on the card. Incremental runs stop once cards older than the newest previously seen date appear; backfill walks older pages. 1.5 s politeness.",
27 + "enabled": true,
28 + "schemaVersion": "1.0",
29 + "config": {
30 + "pagesPerRun": 8
31 + }
32 +}
added connectors/firecrawl/hdh-wine/_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] ?? 'results-pdf', Number(process.argv[4] ?? 12));
7 +else await runSmoke(dir);
added connectors/firecrawl/hdh-wine/index.test.ts +49 −0
@@ -0,0 +1,49 @@
1 +import { describe, expect, it } from 'vitest';
2 +import { ConnectorMetaSchema } from '@rareindex/connectors';
3 +import { loadFixture, runFixtureSuite } from '@rareindex/connectors/testing';
4 +import metaJson from './meta.json' with { type: 'json' };
5 +import createConnector, { parseArchive, parseResultsPdf, wineFacts } from './index.js';
6 +
7 +const connector = createConnector(ConnectorMetaSchema.parse(metaJson));
8 +
9 +describe('hdh-wine', () => {
10 + runFixtureSuite(connector, it, expect);
11 +
12 + it('maps PDF rows to USD hammer sales, grouping multi-row lots', async () => {
13 + const fx = loadFixture('hdh-wine', 'results-pdf');
14 + const out = await connector.normalize(fx.raw);
15 + expect(out.length).toBeGreaterThan(0);
16 + for (const r of out) {
17 + if (r.kind !== 'sale') throw new Error('expected sale');
18 + expect(r.currency).toBe('USD');
19 + expect(r.buyerPremiumIncluded).toBe(false);
20 + expect(['wine', 'whisky', 'cognac']).toContain(r.attributes.categorySlug);
21 + expect(r.attributes.identifiers.hdh_lot).toMatch(/:\d+/);
22 + expect(r.auctionHouse).toBe('Hart Davis Hart Wine Co.');
23 + }
24 + });
25 +
26 + it('parses the PDF table, header date and archive links', () => {
27 + const md = `## Aggregate: 3,892,330 August 27-28, 2026\n\n| Lot | Qty | Description | Estimate | Hammer | Aggregate |\n| --- | --- | --- | --- | --- | --- |\n| 1 | 6 | 1982 Château Ducru-Beaucaillou | 1,600 - 2,400 | 2,200 | 2,629.00 |\n| 5 | 1 | 2009 Château Cos d'Estournel | 500 - 750 | 900 | 1,075.50 |\n| 5 | 1 | 2010 Château Léoville Poyferré | | | |\n| 8 | 1 | 2009 Château d'Yquem (375ml) | 280 - 420 | 500 | 597.50 |\n`;
28 + const p = parseResultsPdf(md);
29 + expect(p.saleDate).toBe('2026-08-27T00:00:00.000Z');
30 + expect(p.rows).toHaveLength(4);
31 + expect(p.rows[0]).toMatchObject({ lot: '1', qty: 6, hammer: 2200, aggregate: 2629 });
32 + expect(p.rows[2]!.hammer).toBeNull();
33 + expect(wineFacts('2009 Château d\'Yquem (375ml)')).toMatchObject({ vintage: 2009, size: '375ml', name: "Château d'Yquem", categorySlug: 'wine' });
34 + expect(wineFacts('2017 La Tâche, Domaine de la Romanée-Conti')).toMatchObject({ producer: 'Domaine de la Romanée-Conti' });
35 + const arch = parseArchive('**An Auction of Finest & Rarest Wines**\n\nJune 25 & 26, 2026\n\n**100% SOLD**\n\n[View auction results](https://hdhauctions.com/wp-content/uploads/2026/07/2606_AuctionResults_PDF.pdf)');
36 + expect(arch).toHaveLength(1);
37 + expect(arch[0]).toMatchObject({ pdfUrl: 'https://hdhauctions.com/wp-content/uploads/2026/07/2606_AuctionResults_PDF.pdf', title: 'An Auction of Finest & Rarest Wines', dateText: 'June 25 & 26, 2026' });
38 + });
39 +
40 + it('groups a two-row lot into one bundle sale', async () => {
41 + const out = await connector.normalize({ url: 'https://hdhauctions.com/wp-content/uploads/2026/08/2608_AuctionResults.pdf', externalId: null, kind: 'sale', engine: 'firecrawl', fetchedAt: new Date(), payload: { kind: 'results_pdf', url: 'https://hdhauctions.com/wp-content/uploads/2026/08/2608_AuctionResults.pdf', auctionName: 'x', saleDate: '2026-08-27T00:00:00.000Z', rows: [{ lot: '5', qty: 1, description: "2009 Château Cos d'Estournel", estimate: '500 - 750', hammer: 900, aggregate: 1075.5 }, { lot: '5', qty: 1, description: '2010 Château Montrose', estimate: null, hammer: null, aggregate: null }] } });
42 + expect(out).toHaveLength(1);
43 + const s = out[0]!;
44 + if (s.kind !== 'sale') throw new Error('sale');
45 + expect(s.isBundle).toBe(true);
46 + expect(s.price).toBe(900);
47 + expect(s.lotNumber).toBe('5');
48 + });
49 +});
added connectors/firecrawl/hdh-wine/index.ts +163 −0
@@ -0,0 +1,163 @@
1 +import { z } from 'zod';
2 +import { BaseConnector, type ConnectorMeta, type CrawlContext, type RawRecordInput, type RawRecordLike } from '@rareindex/connectors';
3 +import { AssetAttributesSchema, NormalizedSaleSchema, type NormalizedRecord } from '@rareindex/shared';
4 +import { dateWords } from '../_carlib/index.js';
5 +
6 +/**
7 + * Hart Davis Hart prices realized (PDF per auction, parsed to markdown tables by Firecrawl).
8 + * One raw record per PDF (compact rows); one sale per lot with a hammer price.
9 + */
10 +const PARSER_VERSION = '1.0.0';
11 +
12 +export const RowSchema = z.object({ lot: z.string(), qty: z.number().nullable(), description: z.string(), estimate: z.string().nullable(), hammer: z.number().nullable(), aggregate: z.number().nullable() });
13 +export type Row = z.infer<typeof RowSchema>;
14 +export const PdfPayloadSchema = z.object({ kind: z.literal('results_pdf'), url: z.string(), auctionName: z.string().nullable(), saleDate: z.string().nullable(), rows: z.array(RowSchema) });
15 +export type PdfPayload = z.infer<typeof PdfPayloadSchema>;
16 +
17 +const num = (s: string | undefined): number | null => {
18 + const v = Number((s ?? '').replace(/[,$\s]/g, ''));
19 + return Number.isFinite(v) && v > 0 ? v : null;
20 +};
21 +
22 +/** Archive page markdown → [{ pdfUrl, title, dateText }] newest first. */
23 +export function parseArchive(md: string): Array<{ pdfUrl: string; title: string | null; dateText: string | null }> {
24 + const out: Array<{ pdfUrl: string; title: string | null; dateText: string | null }> = [];
25 + const seen = new Set<string>();
26 + for (const m of md.matchAll(/\[View auction results\]\((https:\/\/hdhauctions\.com\/wp-content\/uploads\/[^)\s]+\.pdf)\)/gi)) {
27 + const url = m[1]!;
28 + if (seen.has(url)) continue;
29 + seen.add(url);
30 + const before = md.slice(Math.max(0, m.index! - 1200), m.index);
31 + const title = [...before.matchAll(/\*\*([^*\n]{6,120})\*\*/g)].map((x) => x[1]!.trim()).find((t) => !/SOLD|\$|estimate/i.test(t)) ?? null;
32 + const dateText = [...before.matchAll(/([A-Z][a-z]+ \d{1,2}(?:\s*[-–&]\s*\d{1,2})?,? \d{4})/g)].at(-1)?.[1] ?? null;
33 + out.push({ pdfUrl: url, title, dateText });
34 + }
35 + return out;
36 +}
37 +
38 +/** PDF markdown → header facts + table rows (Lot | Qty | Description | Estimate | Hammer | Aggregate). */
39 +export function parseResultsPdf(md: string): { auctionName: string | null; saleDate: string | null; rows: Row[] } {
40 + const rows: Row[] = [];
41 + for (const line of md.split('\n')) {
42 + const m = line.match(/^\|\s*(\d+[A-Z]?)\s*\|\s*(\d*)\s*\|\s*(.+?)\s*\|\s*([\d,]*\s*-?\s*[\d,]*)\s*\|\s*([\d,]*)\s*\|\s*([\d,.]*)\s*\|/);
43 + if (!m) continue;
44 + const description = m[3]!.replace(/\\/g, '').trim();
45 + if (!description || /^Description$/i.test(description)) continue;
46 + rows.push({ lot: m[1]!, qty: m[2] ? Number(m[2]) : null, description, estimate: m[4]?.trim() || null, hammer: num(m[5]), aggregate: num(m[6]) });
47 + }
48 + const header = md.slice(0, 4000);
49 + const dateText = header.match(/([A-Z][a-z]+ \d{1,2}(?:\s*[-–]\s*\d{1,2})?,? \d{4})/)?.[1] ?? null;
50 + const saleDate = dateText ? dateWords(dateText)?.toISOString() ?? null : null;
51 + const auctionName = header.match(/^#+\s*(.+)$/m)?.[1]?.replace(/Aggregate:.*$/, '').trim() || null;
52 + return { auctionName, saleDate, rows };
53 +}
54 +
55 +const SIZE_RE = /\((\d+(?:\.\d+)?\s?(?:ml|L|l|cl))\)/;
56 +/** "2010 Château Ducru-Beaucaillou" → vintage, producer/name, bottle size. */
57 +export function wineFacts(description: string): { vintage: number | null; name: string; size: string; producer: string | null; categorySlug: 'wine' | 'whisky' | 'cognac' } {
58 + const vintage = Number(description.match(/^((?:18|19|20)\d{2})\b/)?.[1]) || null;
59 + const sizeM = description.match(SIZE_RE);
60 + const size = sizeM ? sizeM[1]!.replace(/\s/g, '') : '750ml';
61 + const name = description.replace(/^(?:18|19|20)\d{2}\s+/, '').replace(SIZE_RE, '').replace(/\s+/g, ' ').trim();
62 + const producer = name.includes(',') ? name.split(',').at(-1)!.trim() : name.split(/\s+/).slice(0, 3).join(' ');
63 + const categorySlug = /whisky|whiskey|scotch|bourbon/i.test(description) ? 'whisky' : /cognac|armagnac/i.test(description) ? 'cognac' : 'wine';
64 + return { vintage, name, size, producer: producer || null, categorySlug };
65 +}
66 +
67 +export class HdhWineConnector extends BaseConnector {
68 + readonly version = '1.0.0';
69 + readonly parserVersion = PARSER_VERSION;
70 + protected override minIntervalMs = 3000;
71 +
72 + async *crawl(ctx: CrawlContext): AsyncIterable<RawRecordInput> {
73 + const archiveUrl = String(this.meta.config.archiveUrl ?? 'https://hdhauctions.com/auction-archives/');
74 + const perRun = Number(this.meta.config.pdfsPerRun ?? 1);
75 + const done = new Set<string>((ctx.options.cursor?.done as string[] | undefined) ?? []);
76 + const archive = await ctx.fetch(archiveUrl, { engines: ['firecrawl'], expect: ['title'], parse: (r) => (r.markdown ? { title: parseArchive(r.markdown)[0]?.pdfUrl ?? null } : null) });
77 + const entries = archive.success && archive.markdown ? parseArchive(archive.markdown) : [];
78 + if (!entries.length) {
79 + ctx.anomaly('page_fetch_failed', `archive: ${archive.error ?? archive.httpStatus}`);
80 + return;
81 + }
82 + const order = ctx.options.mode === 'backfill' ? [...entries].reverse() : entries;
83 + let count = 0;
84 + for (const e of order.filter((x) => !done.has(x.pdfUrl)).slice(0, perRun)) {
85 + if (ctx.signal?.aborted || this.reached(ctx, count)) break;
86 + await this.throttle();
87 + const res = await ctx.fetch(e.pdfUrl, { engines: ['firecrawl'], timeoutMs: 180_000, expect: ['title', 'price', 'date'], parse: (r) => {
88 + const p = r.markdown ? parseResultsPdf(r.markdown) : null;
89 + const sold = p?.rows.find((x) => x.hammer);
90 + return p && p.rows.length ? { title: sold?.description ?? p.rows[0]!.description, price: sold?.hammer ?? null, date: p.saleDate ?? e.dateText } : null;
91 + } });
92 + const parsed = res.success && res.markdown ? parseResultsPdf(res.markdown) : null;
93 + if (!parsed || !parsed.rows.length) {
94 + ctx.anomaly(parsed ? 'empty_page' : 'page_fetch_failed', `${e.pdfUrl}: ${res.error ?? res.httpStatus}`);
95 + continue;
96 + }
97 + const saleDate = parsed.saleDate ?? (e.dateText ? dateWords(e.dateText)?.toISOString() ?? null : null);
98 + count++;
99 + yield { url: e.pdfUrl, externalId: `pdf:${e.pdfUrl.split('/').pop()}`, kind: 'sale', engine: res.engine, httpStatus: res.httpStatus, payload: { kind: 'results_pdf' as const, url: e.pdfUrl, auctionName: e.title ?? parsed.auctionName, saleDate, rows: parsed.rows }, fetchedAt: res.fetchedAt };
100 + done.add(e.pdfUrl);
101 + await ctx.setCursor({ done: [...done], updatedAt: new Date().toISOString() });
102 + }
103 + }
104 +
105 + async normalize(raw: RawRecordLike): Promise<NormalizedRecord[]> {
106 + const p = PdfPayloadSchema.parse(raw.payload);
107 + if (!p.saleDate) return [];
108 + const saleDate = new Date(p.saleDate);
109 + const pdfName = p.url.split('/').pop()!.replace(/\.pdf$/i, '');
110 + // Group multi-row lots: the first row of a lot carries the prices; following rows list the other wines.
111 + const groups = new Map<string, Row[]>();
112 + for (const r of p.rows) (groups.get(r.lot) ?? groups.set(r.lot, []).get(r.lot)!).push(r);
113 + const out: NormalizedRecord[] = [];
114 + for (const [lot, rows] of groups) {
115 + const head = rows.find((r) => r.hammer) ?? rows[0]!;
116 + if (!head.hammer || head.hammer <= 0) continue;
117 + const f = wineFacts(head.description);
118 + const bundle = rows.length > 1;
119 + const title = bundle ? `${head.description} (+${rows.length - 1} more)` : head.description;
120 + const attributes = AssetAttributesSchema.parse({
121 + categorySlug: f.categorySlug,
122 + brand: f.producer,
123 + name: f.name,
124 + year: f.vintage,
125 + size: f.size,
126 + identifiers: { hdh_lot: `${pdfName}:${lot}` },
127 + metadata: { auction: p.auctionName, estimate: head.estimate, aggregate_usd: head.aggregate, bottles: head.qty, lines: rows.map((r) => `${r.qty ?? ''} × ${r.description}`.trim()) },
128 + });
129 + out.push(
130 + NormalizedSaleSchema.parse({
131 + kind: 'sale',
132 + connectorId: this.meta.id,
133 + sourceId: this.meta.sourceId,
134 + sourceUrl: `${p.url}#lot-${lot}`,
135 + externalId: `${pdfName}:${lot}`,
136 + rawTitle: title,
137 + imageUrls: [],
138 + attributes,
139 + grade: { grader: null, grade: null, qualifier: null, certificationNumber: null },
140 + condition: { condition: null, conditionRaw: null, completeness: null },
141 + observedAt: raw.fetchedAt,
142 + confidence: 0.92,
143 + parserVersion: PARSER_VERSION,
144 + saleType: 'auction',
145 + saleDate,
146 + price: head.hammer,
147 + currency: 'USD',
148 + buyerPremiumIncluded: false,
149 + quantity: head.qty ?? 1,
150 + isBundle: bundle || (head.qty ?? 1) > 1,
151 + location: 'Chicago, IL, United States',
152 + auctionHouse: 'Hart Davis Hart Wine Co.',
153 + lotNumber: lot,
154 + }),
155 + );
156 + }
157 + return out;
158 + }
159 +}
160 +
161 +export default function createConnector(meta: ConnectorMeta): HdhWineConnector {
162 + return new HdhWineConnector(meta);
163 +}
added connectors/firecrawl/hdh-wine/meta.json +33 −0
@@ -0,0 +1,33 @@
1 +{
2 + "id": "hdh-wine",
3 + "displayName": "Hart Davis Hart Wine Co. (auction results)",
4 + "sourceId": "hdh-wine",
5 + "sourceName": "Hart Davis Hart Wine Co.",
6 + "sourceType": "auction_house",
7 + "sourceUrl": "https://hdhauctions.com",
8 + "module": "firecrawl/hdh-wine",
9 + "enginePriority": ["firecrawl"],
10 + "categories": ["wine", "whisky", "cognac"],
11 + "regions": ["US"],
12 + "languages": ["en"],
13 + "currency": ["USD"],
14 + "supportsListings": false,
15 + "supportsSold": true,
16 + "supportsAuctions": false,
17 + "supportsImages": false,
18 + "supportsCatalog": false,
19 + "supportsPopulation": false,
20 + "supportsLookup": false,
21 + "refreshFrequencyMinutes": 10080,
22 + "priority": "medium",
23 + "trustScore": 0.95,
24 + "attributionRequired": true,
25 + "termsUrl": "https://hdhauctions.com/conditions-of-sale/",
26 + "accessNotes": "Hart Davis Hart (Chicago; ~8 fine-wine auctions a year, 100% sold rates) publishes every auction's full prices-realized as a PDF linked from /auction-archives/ ('View auction results'). Plain HTTP is 403 for non-browser clients, so the archive page (1 credit) and each PDF (Firecrawl PDF parser, 1 credit per page, ≈130–140 pages for a 2,500-lot sale) are fetched with Firecrawl; config.pdfsPerRun caps spend and the cursor remembers processed PDFs. Table columns: Lot | Qty | Description | Estimate | Hammer | Aggregate — price = hammer (USD) with buyer_premium_included=false; the aggregate (hammer + 19.5% premium) is kept in metadata. Lots spanning several rows (mixed cases) are single sales flagged as bundles; quantity = bottle count of the first row. Sale date = the auction date range printed in the PDF header (first day). No images.",
27 + "enabled": true,
28 + "schemaVersion": "1.0",
29 + "config": {
30 + "archiveUrl": "https://hdhauctions.com/auction-archives/",
31 + "pdfsPerRun": 1
32 + }
33 +}
added connectors/firecrawl/whisky-hammer/_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] ?? 'lot-page', Number(process.argv[4] ?? 6));
7 +else await runSmoke(dir);
added connectors/firecrawl/whisky-hammer/index.test.ts +36 −0
@@ -0,0 +1,36 @@
1 +import { describe, expect, it } from 'vitest';
2 +import { ConnectorMetaSchema } from '@rareindex/connectors';
3 +import { loadFixture, runFixtureSuite } from '@rareindex/connectors/testing';
4 +import metaJson from './meta.json' with { type: 'json' };
5 +import createConnector, { parseAuctionList, parseLotMarkdown } from './index.js';
6 +
7 +const connector = createConnector(ConnectorMetaSchema.parse(metaJson));
8 +
9 +describe('whisky-hammer', () => {
10 + runFixtureSuite(connector, it, expect);
11 +
12 + it('maps sold lots to GBP hammer sales with per-lot dates', async () => {
13 + const fx = loadFixture('whisky-hammer', 'lot-page');
14 + const out = await connector.normalize(fx.raw);
15 + expect(out.length).toBeGreaterThan(0);
16 + for (const r of out) {
17 + if (r.kind !== 'sale') throw new Error('expected sale');
18 + expect(r.currency).toBe('GBP');
19 + expect(r.buyerPremiumIncluded).toBe(false);
20 + expect(r.attributes.identifiers.whiskyhammer_item).toMatch(/^\d+$/);
21 + expect(r.sourceUrl).toMatch(/^https:\/\/www\.whiskyhammer\.com\/item\/\d+\//);
22 + expect(r.saleDate.getUTCFullYear()).toBeGreaterThan(2015);
23 + }
24 + });
25 +
26 + it('parses markdown lot blocks and the auction list', () => {
27 + const md = `4354 Items\n\n[![Macallan - 40 Year Old](https://www.whiskyhammer.com/uploads/images/products/newthumbs/1.jpg)](https://www.whiskyhammer.com/item/239555/Macallan/x.html "t")\n\nThis lot is being sold from our EU warehouse in Alphen aan den Rijn, The Netherlands.\n\nLot #239555\n\n\n[Macallan - 40 Year Old (The Red Collection) 2025 Release](https://www.whiskyhammer.com/item/239555/Macallan/x.html)\n\nSold 22/02/2026£9,100.00€10,634.26US$12,213.11\n\n[View Lot](https://www.whiskyhammer.com/item/239555/Macallan/x.html)\n\nLot #234781\n\n\n[Unsold Bottle](https://www.whiskyhammer.com/item/234781/Macallan/y.html)\n\n[View Lot](https://www.whiskyhammer.com/item/234781/Macallan/y.html)\n\n- [2](https://www.whiskyhammer.com/auction/past/auc-129/?page=2)\n- [88](https://www.whiskyhammer.com/auction/past/auc-129/?page=88)\n`;
28 + const p = parseLotMarkdown(md);
29 + expect(p.totalItems).toBe(4354);
30 + expect(p.totalPages).toBe(88);
31 + expect(p.lots).toHaveLength(2);
32 + expect(p.lots[0]).toMatchObject({ itemId: '239555', priceGbp: 9100, soldOn: '22/02/2026', warehouse: 'NL', image: 'https://www.whiskyhammer.com/uploads/images/products/newthumbs/1.jpg' });
33 + expect(p.lots[1]!.priceGbp).toBeNull();
34 + expect(parseAuctionList('<a href="/auction/past/auc-100/">a</a><a href="/auction/past/auc-129/">b</a>')).toEqual(['129', '100']);
35 + });
36 +});
added connectors/firecrawl/whisky-hammer/index.ts +162 −0
@@ -0,0 +1,162 @@
1 +import { z } from 'zod';
2 +import { BaseConnector, html as H, type ConnectorMeta, type CrawlContext, type RawRecordInput, type RawRecordLike } from '@rareindex/connectors';
3 +import { AssetAttributesSchema, NormalizedSaleSchema, type NormalizedRecord } from '@rareindex/shared';
4 +import { whiskyFacts } from '../../api/scotch-whisky-auctions/index.js';
5 +import { dateDMY } from '../_carlib/index.js';
6 +
7 +/**
8 + * Whisky Hammer previous auctions. Auction list is plain HTML; lot pages come through Firecrawl markdown.
9 + * One raw record per lot page (compact lots); one sale per lot with a "Sold dd/mm/yyyy £X" line.
10 + */
11 +const BASE = 'https://www.whiskyhammer.com';
12 +const PARSER_VERSION = '1.0.0';
13 +
14 +export const LotSchema = z.object({ itemId: z.string(), url: z.string(), title: z.string(), soldOn: z.string().nullable(), priceGbp: z.number().nullable(), image: z.string().nullable(), warehouse: z.string().nullable() });
15 +export type Lot = z.infer<typeof LotSchema>;
16 +export const PagePayloadSchema = z.object({ kind: z.literal('lot_page'), url: z.string(), auctionId: z.string(), page: z.number(), totalPages: z.number().nullable(), totalItems: z.number().nullable(), lots: z.array(LotSchema) });
17 +
18 +/** /previous-auctions → auction ids (auc-129 …), newest first as listed. */
19 +export function parseAuctionList(htmlText: string): string[] {
20 + const $ = H.load(htmlText);
21 + const ids: string[] = [];
22 + $('a[href*="/auction/past/auc-"]').each((_, a) => {
23 + const id = ($(a).attr('href') ?? '').match(/auc-(\d+)/)?.[1];
24 + if (id && !ids.includes(id)) ids.push(id);
25 + });
26 + return ids.sort((a, b) => Number(b) - Number(a));
27 +}
28 +
29 +/**
30 + * Parse a Firecrawl markdown lot page. Blocks look like:
31 + * [![Title](img)](/item/239555/…) … Lot #239555 … [Title](/item/239555/…) … Sold 22/02/2026£9,100.00€… … [View Lot](…)
32 + */
33 +export function parseLotMarkdown(md: string): { lots: Lot[]; totalPages: number | null; totalItems: number | null } {
34 + const lots: Lot[] = [];
35 + const seen = new Set<string>();
36 + const totalItems = Number(md.match(/(\d[\d,]*)\s+Items/)?.[1]?.replace(/,/g, '')) || null;
37 + const pageNums = [...md.matchAll(/\?page=(\d+)\)/g)].map((m) => Number(m[1]));
38 + const totalPages = pageNums.length ? Math.max(...pageNums) : null;
39 + const re = /Lot #(\d+)\s*\n+\s*\[([^\]]+)\]\((https:\/\/www\.whiskyhammer\.com\/item\/\1\/[^)\s]+)\)\s*\n+\s*(Sold\s+(\d{2}\/\d{2}\/\d{4})\s*£([\d,]+(?:\.\d+)?))?/g;
40 + let m: RegExpExecArray | null;
41 + while ((m = re.exec(md))) {
42 + const itemId = m[1]!;
43 + if (seen.has(itemId)) continue;
44 + seen.add(itemId);
45 + const before = md.slice(Math.max(0, m.index - 1500), m.index);
46 + const img = [...before.matchAll(/!\[[^\]]*\]\((https:\/\/www\.whiskyhammer\.com\/uploads\/images\/products\/[^)\s]+)\)/g)].at(-1)?.[1] ?? null;
47 + const warehouse = /EU warehouse/i.test(before.slice(-600)) ? 'NL' : 'GB';
48 + lots.push({ itemId, url: m[3]!, title: m[2]!.replace(/\\/g, '').trim(), soldOn: m[5] ?? null, priceGbp: m[6] ? Number(m[6].replace(/,/g, '')) : null, image: img, warehouse });
49 + }
50 + return { lots, totalPages, totalItems };
51 +}
52 +
53 +export class WhiskyHammerConnector extends BaseConnector {
54 + readonly version = '1.0.0';
55 + readonly parserVersion = PARSER_VERSION;
56 + protected override minIntervalMs = 2000;
57 +
58 + async *crawl(ctx: CrawlContext): AsyncIterable<RawRecordInput> {
59 + const perRun = Number(this.meta.config.auctionsPerRun ?? 1);
60 + const pagesPerRun = Number(this.meta.config.pagesPerRun ?? 6);
61 + const progress = { ...((ctx.options.cursor?.progress as Record<string, number> | undefined) ?? {}) };
62 + const complete = new Set<string>((ctx.options.cursor?.complete as string[] | undefined) ?? []);
63 + const list = await ctx.fetch(`${BASE}/previous-auctions`, { engines: ['api', 'firecrawl'], responseType: 'text', minQuality: 0 });
64 + const ids = list.success && list.html ? parseAuctionList(list.html) : [];
65 + if (!ids.length) {
66 + ctx.anomaly('page_fetch_failed', `auction list: ${list.error ?? list.httpStatus}`);
67 + return;
68 + }
69 + // The newest entry can still be closing (page not yet published); tolerate a few unreachable auctions per run.
70 + const candidates = ids.filter((id) => !complete.has(id)).slice(0, perRun + 3);
71 + let pages = 0;
72 + let count = 0;
73 + let started = 0;
74 + for (const auctionId of candidates) {
75 + if (started >= perRun) break;
76 + let page = (progress[auctionId] ?? 0) + 1;
77 + while (pages < pagesPerRun && !ctx.signal?.aborted && !this.reached(ctx, count)) {
78 + const url = `${BASE}/auction/past/auc-${auctionId}/${page > 1 ? `?page=${page}` : ''}`;
79 + await this.throttle();
80 + const res = await ctx.fetch(url, {
81 + engines: ['firecrawl'],
82 + timeoutMs: 150_000,
83 + expect: ['title', 'price', 'currency', 'date'],
84 + parse: (r) => {
85 + const p = r.markdown ? parseLotMarkdown(r.markdown) : null;
86 + const sold = p?.lots.find((l) => l.priceGbp);
87 + return p && p.lots.length ? { title: p.lots[0]!.title, price: sold?.priceGbp ?? null, currency: sold ? 'GBP' : null, date: sold?.soldOn ?? null } : null;
88 + },
89 + });
90 + pages++;
91 + const parsed = res.success && res.markdown ? parseLotMarkdown(res.markdown) : null;
92 + if (!parsed || !parsed.lots.length) {
93 + ctx.anomaly(parsed ? 'empty_page' : 'page_fetch_failed', `${url}: ${res.error ?? res.httpStatus}`);
94 + if (page > 1 || parsed) complete.add(auctionId); // unreachable first page: retry next run
95 + break;
96 + }
97 + if (page === 1) started++;
98 + count++;
99 + yield { url, externalId: `auction:${auctionId}:page:${page}`, kind: 'sale', engine: res.engine, httpStatus: res.httpStatus, payload: { kind: 'lot_page' as const, url, auctionId, page, totalPages: parsed.totalPages, totalItems: parsed.totalItems, lots: parsed.lots }, fetchedAt: res.fetchedAt };
100 + progress[auctionId] = page;
101 + if (parsed.totalPages && page >= parsed.totalPages) {
102 + complete.add(auctionId);
103 + break;
104 + }
105 + page++;
106 + }
107 + await ctx.setCursor({ progress, complete: [...complete] });
108 + }
109 + }
110 +
111 + async normalize(raw: RawRecordLike): Promise<NormalizedRecord[]> {
112 + const p = PagePayloadSchema.parse(raw.payload);
113 + const out: NormalizedRecord[] = [];
114 + for (const lot of p.lots) {
115 + const saleDate = dateDMY(lot.soldOn);
116 + if (!lot.priceGbp || lot.priceGbp <= 0 || !saleDate) continue;
117 + const f = whiskyFacts(lot.title.replace(/\s+-\s+/, ' '));
118 + const attributes = AssetAttributesSchema.parse({
119 + categorySlug: f.categorySlug,
120 + brand: lot.title.split(/\s+-\s+/)[0]?.trim() || f.brand,
121 + name: lot.title,
122 + year: f.vintage,
123 + size: f.size,
124 + country: /scotch|islay|speyside|highland|campbeltown|lowland/i.test(lot.title) ? 'GB' : null,
125 + identifiers: { whiskyhammer_item: lot.itemId },
126 + metadata: { age_statement: f.age, auction_id: p.auctionId, warehouse: lot.warehouse },
127 + });
128 + out.push(
129 + NormalizedSaleSchema.parse({
130 + kind: 'sale',
131 + connectorId: this.meta.id,
132 + sourceId: this.meta.sourceId,
133 + sourceUrl: lot.url,
134 + externalId: lot.itemId,
135 + rawTitle: lot.title,
136 + imageUrls: lot.image ? [lot.image] : [],
137 + attributes,
138 + grade: { grader: null, grade: null, qualifier: null, certificationNumber: null },
139 + condition: { condition: null, conditionRaw: null, completeness: null },
140 + observedAt: raw.fetchedAt,
141 + confidence: 0.9,
142 + parserVersion: PARSER_VERSION,
143 + saleType: 'auction',
144 + saleDate,
145 + price: lot.priceGbp,
146 + currency: 'GBP',
147 + buyerPremiumIncluded: false,
148 + quantity: 1,
149 + isBundle: /\bx\s?\d|\(\d+\s?x\)|\bset of\b|\blot of\b/i.test(lot.title),
150 + location: lot.warehouse === 'NL' ? 'Alphen aan den Rijn, Netherlands' : 'Aberdeenshire, United Kingdom',
151 + auctionHouse: 'Whisky Hammer',
152 + lotNumber: lot.itemId,
153 + }),
154 + );
155 + }
156 + return out;
157 + }
158 +}
159 +
160 +export default function createConnector(meta: ConnectorMeta): WhiskyHammerConnector {
161 + return new WhiskyHammerConnector(meta);
162 +}
added connectors/firecrawl/whisky-hammer/meta.json +33 −0
@@ -0,0 +1,33 @@
1 +{
2 + "id": "whisky-hammer",
3 + "displayName": "Whisky Hammer (previous auctions)",
4 + "sourceId": "whisky-hammer",
5 + "sourceName": "Whisky Hammer",
6 + "sourceType": "auction_house",
7 + "sourceUrl": "https://www.whiskyhammer.com",
8 + "module": "firecrawl/whisky-hammer",
9 + "enginePriority": ["firecrawl"],
10 + "categories": ["whisky", "rum", "cognac"],
11 + "regions": ["GB", "NL"],
12 + "languages": ["en"],
13 + "currency": ["GBP"],
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.whiskyhammer.com/buying-and-selling/terms-conditions",
26 + "accessNotes": "Whisky Hammer (Aberdeenshire, monthly auctions, UK + EU warehouses) lists previous auctions at /previous-auctions (plain HTTPS) but the per-auction lot pages (/auction/past/auc-<n>/?page=N, 50 lots per page sorted high→low bid) sit behind a Cloudflare interstitial for plain clients, so they are fetched with Firecrawl (1 credit per page, ≈88 pages for a 4,300-lot auction; capped by config.pagesPerRun). Each lot block: title + /item/<id>/ link, 'Lot #<id>', and 'Sold dd/mm/yyyy£X' — the GBP hammer price (Whisky Hammer's buyer commission is separate → buyer_premium_included=false) with the individual sale date. Unsold lots have no 'Sold' line and are skipped. robots.txt only disallows sort/page-size/return_name query variants, which are not used. Scrapfly fallback disabled to keep cost predictable.",
27 + "enabled": true,
28 + "schemaVersion": "1.0",
29 + "config": {
30 + "auctionsPerRun": 1,
31 + "pagesPerRun": 6
32 + }
33 +}
modified connectors/registry.json +2158 −136
@@ -1,6 +1,62 @@
1 1 {
2 2 "version": "1.0",
3 3 "connectors": [
4 + {
5 + "id": "alt-xyz",
6 + "displayName": "Alt (graded card values & transactions)",
7 + "sourceId": "alt",
8 + "sourceName": "Alt",
9 + "sourceType": "analytics_provider",
10 + "sourceUrl": "https://alt.xyz",
11 + "module": "firecrawl/alt-xyz",
12 + "enginePriority": [
13 + "firecrawl"
14 + ],
15 + "categories": [
16 + "sports_cards",
17 + "baseball_cards",
18 + "basketball_cards",
19 + "football_cards",
20 + "hockey_cards",
21 + "soccer_cards",
22 + "other_sports_cards",
23 + "pokemon",
24 + "magic_the_gathering",
25 + "yugioh",
26 + "non_sport_cards"
27 + ],
28 + "regions": [
29 + "US"
30 + ],
31 + "languages": [
32 + "en"
33 + ],
34 + "currency": [
35 + "USD"
36 + ],
37 + "supportsListings": true,
38 + "supportsSold": true,
39 + "supportsAuctions": false,
40 + "supportsImages": true,
41 + "supportsCatalog": false,
42 + "supportsPopulation": false,
43 + "supportsLookup": true,
44 + "refreshFrequencyMinutes": 1440,
45 + "priority": "low",
46 + "trustScore": 0.7,
47 + "attributionRequired": true,
48 + "termsUrl": "https://alt.xyz/terms",
49 + "accessNotes": "Alt's public item pages (robots.txt allows everything; discovery through the public sitemaps with lastmod) show the card, grade, PSA/BGS population, Alt's list price, the 'Alt Value' model estimate with its range, and a 'Recent transactions' list of observed sales (mostly eBay auctions/best offers) with date and price. Pages are client-rendered → Firecrawl, 1 credit per item, capped by `maxItemsPerRun`. Alt Value is stored as a guide_value observation (confidence 0.6); recent transactions become sales attributed to Alt with the original listing URL in metadata (confidence 0.65 — second-hand aggregation); the Alt list price is a fixed-price listing.",
50 + "enabled": true,
51 + "schemaVersion": "1.0",
52 + "config": {
53 + "sitemaps": [
54 + "fixed-price",
55 + "auctions"
56 + ],
57 + "maxItemsPerRun": 100
58 + }
59 + },
4 60 {
5 61 "id": "amiami",
6 62 "displayName": "AmiAmi (figures & hobby — new + pre-owned, JPY)",
@@ -70,6 +126,55 @@
70 126 "pageSize": 50
71 127 }
72 128 },
129 + {
130 + "id": "analog-shift",
131 + "displayName": "Analog:Shift (vintage watch dealer, USD)",
132 + "sourceId": "analog-shift",
133 + "sourceName": "Analog:Shift",
134 + "sourceType": "dealer",
135 + "sourceUrl": "https://analogshift.com",
136 + "module": "api/analog-shift",
137 + "enginePriority": [
138 + "api"
139 + ],
140 + "categories": [
141 + "rolex",
142 + "patek_philippe",
143 + "audemars_piguet",
144 + "omega",
145 + "other_watches"
146 + ],
147 + "regions": [
148 + "US"
149 + ],
150 + "languages": [
151 + "en"
152 + ],
153 + "currency": [
154 + "USD"
155 + ],
156 + "supportsListings": true,
157 + "supportsSold": false,
158 + "supportsAuctions": false,
159 + "supportsImages": true,
160 + "supportsCatalog": false,
161 + "supportsPopulation": false,
162 + "supportsLookup": true,
163 + "refreshFrequencyMinutes": 1440,
164 + "priority": "low",
165 + "trustScore": 0.85,
166 + "attributionRequired": true,
167 + "termsUrl": "https://analogshift.com/policies/terms-of-service",
168 + "accessNotes": "Public Shopify storefront feed (analogshift.com/products.json, 250 products/page) over plain HTTPS with the RareIndex user agent; robots.txt allows / for generic agents. Each product is one vintage/pre-owned watch: vendor = brand, description carries Reference / Year / Case Size / Condition lines which are parsed into attributes; sold watches stay published with available=false and are recorded as listings with availability=sold (asking price, not a transaction). Identifiers: analogshift_sku + reference (shared with Chrono24/Subdial for entity resolution).",
169 + "enabled": true,
170 + "schemaVersion": "1.0",
171 + "config": {
172 + "seeds": [
173 + "all"
174 + ],
175 + "pagesPerSeed": 3
176 + }
177 + },
73 178 {
74 179 "id": "antiquorum",
75 180 "displayName": "Antiquorum (watch auction results & upcoming lots)",
@@ -349,6 +454,117 @@
349 454 "pagesPerSeed": 2
350 455 }
351 456 },
457 + {
458 + "id": "bbts",
459 + "displayName": "BigBadToyStore (retail listings)",
460 + "sourceId": "bbts",
461 + "sourceName": "BigBadToyStore",
462 + "sourceType": "dealer",
463 + "sourceUrl": "https://www.bigbadtoystore.com",
464 + "module": "firecrawl/bbts",
465 + "enginePriority": [
466 + "firecrawl"
467 + ],
468 + "categories": [
469 + "action_figures",
470 + "funko",
471 + "gundam",
472 + "designer_toys",
473 + "plush",
474 + "model_cars",
475 + "lego_sets"
476 + ],
477 + "regions": [
478 + "US"
479 + ],
480 + "languages": [
481 + "en"
482 + ],
483 + "currency": [
484 + "USD"
485 + ],
486 + "supportsListings": true,
487 + "supportsSold": false,
488 + "supportsAuctions": false,
489 + "supportsImages": true,
490 + "supportsCatalog": true,
491 + "supportsPopulation": false,
492 + "supportsLookup": false,
493 + "refreshFrequencyMinutes": 1440,
494 + "priority": "low",
495 + "trustScore": 0.7,
496 + "attributionRequired": true,
497 + "termsUrl": "https://www.bigbadtoystore.com/Help/Terms",
498 + "accessNotes": "Search pages (/Search?SearchText=…&PageSize=50) rendered through Firecrawl at 1 credit per page of 50 products; plain HTTP receives the store's bot challenge page, which is not bypassed — Firecrawl renders the public page like a browser and robots.txt is not served to non-browser agents (403), so runs stay tiny (4 queries/run, 2 s politeness). Each card gives title, 'By: brand', stock status (PRE-ORDER / IN STOCK / SOLD OUT / WAITLIST) and price. Emits a catalog item (retail price as MSRP when in stock/pre-order) and a retailer listing. Retail asks are never treated as market value.",
499 + "enabled": true,
500 + "schemaVersion": "1.0",
501 + "config": {
502 + "queriesPerRun": 4,
503 + "pagesPerQuery": 1,
504 + "queries": [
505 + "hot toys",
506 + "s.h. figuarts",
507 + "mafex",
508 + "funko pop exclusive",
509 + "gunpla master grade",
510 + "bearbrick",
511 + "hasbro black series",
512 + "neca",
513 + "mezco one:12",
514 + "transformers masterpiece",
515 + "mcfarlane",
516 + "pop mart"
517 + ]
518 + }
519 + },
520 + {
521 + "id": "bh-used",
522 + "displayName": "B&H Photo — Used Department (cameras & lenses, USD)",
523 + "sourceId": "bh-photo",
524 + "sourceName": "B&H Photo Video",
525 + "sourceType": "dealer",
526 + "sourceUrl": "https://www.bhphotovideo.com",
527 + "module": "firecrawl/bh-used",
528 + "enginePriority": [
529 + "firecrawl"
530 + ],
531 + "categories": [
532 + "cameras"
533 + ],
534 + "regions": [
535 + "US"
536 + ],
537 + "languages": [
538 + "en"
539 + ],
540 + "currency": [
541 + "USD"
542 + ],
543 + "supportsListings": true,
544 + "supportsSold": false,
545 + "supportsAuctions": false,
546 + "supportsImages": true,
547 + "supportsCatalog": false,
548 + "supportsPopulation": false,
549 + "supportsLookup": false,
550 + "refreshFrequencyMinutes": 1440,
551 + "priority": "low",
552 + "trustScore": 0.85,
553 + "attributionRequired": true,
554 + "termsUrl": "https://www.bhphotovideo.com/find/HelpCenter/TermsAndConditions.jsp",
555 + "accessNotes": "Used-department category pages (bhphotovideo.com/c/buy/Used-…/ci/<id>) rendered by Firecrawl (1 credit per page; plain HTTPS returns an Akamai browser check). robots.txt allows these /c/buy/ paths but disallows ?pn= pagination and search, so only the first page (~24–30 items) of each seeded category is read; coverage grows by adding category seeds. Each row gives name, B&H #, MFR #, the B&H used grade (10, 9+, 9, 8+, 8, 7), shutter count, used price (superscript cents flattened, e.g. $1,79995 → 1,799.95) and stock state. Dealer asking prices only; the new price is stored as original MSRP.",
556 + "enabled": true,
557 + "schemaVersion": "1.0",
558 + "config": {
559 + "seeds": [
560 + "/c/buy/Used-Digital-Cameras/ci/32820/N/4288586282",
561 + "/c/buy/used-mirrorless-cameras/ci/21264/N/4040479538",
562 + "/c/browse/used-cameras-used-photography/ci/6387/N/4294246666",
563 + "/c/browse/used-lenses-lens-accessories/ci/21426/N/4036297805",
564 + "/c/browse/leica/ci/24708/N/3933929672"
565 + ]
566 + }
567 + },
352 568 {
353 569 "id": "bobs-watches",
354 570 "displayName": "Bob's Watches (pre-owned Rolex listings)",
@@ -699,6 +915,49 @@
699 915 "perPage": 36
700 916 }
701 917 },
918 + {
919 + "id": "cardkingdom",
920 + "displayName": "Card Kingdom (retail & buylist prices)",
921 + "sourceId": "cardkingdom",
922 + "sourceName": "Card Kingdom",
923 + "sourceType": "dealer",
924 + "sourceUrl": "https://www.cardkingdom.com",
925 + "module": "api/cardkingdom",
926 + "enginePriority": [
927 + "api"
928 + ],
929 + "categories": [
930 + "magic_the_gathering"
931 + ],
932 + "regions": [
933 + "US"
934 + ],
935 + "languages": [
936 + "en"
937 + ],
938 + "currency": [
939 + "USD"
940 + ],
941 + "supportsListings": false,
942 + "supportsSold": false,
943 + "supportsAuctions": false,
944 + "supportsImages": false,
945 + "supportsCatalog": true,
946 + "supportsPopulation": false,
947 + "supportsLookup": false,
948 + "refreshFrequencyMinutes": 1440,
949 + "priority": "medium",
950 + "trustScore": 0.8,
951 + "attributionRequired": true,
952 + "termsUrl": "https://www.cardkingdom.com/help/terms",
953 + "accessNotes": "Card Kingdom publishes its full singles price list (https://api.cardkingdom.com/api/pricelist, ~46 MB JSON, refreshed daily) and sealed price list (/api/sealed_pricelist) publicly; each singles row carries the Scryfall id, edition, foil flag, retail price, retail stock, buylist price and buylist demand. Fetched once per run with an identifying User-Agent (two requests). Retail = dealer asking price stored as price_observation kind 'market' with metadata.buylist; not a transaction. Identifiers: cardkingdom_id, scryfall_id. Sealed products get catalog items with metadata.sealed=true.",
954 + "enabled": true,
955 + "schemaVersion": "1.0",
956 + "config": {
957 + "includeSealed": true,
958 + "minRetailUsd": 0.5
959 + }
960 + },
702 961 {
703 962 "id": "cars-and-bids",
704 963 "displayName": "Cars & Bids (past auctions)",
@@ -926,6 +1185,51 @@
926 1185 "harvestDelayMinutes": 20
927 1186 }
928 1187 },
1188 + {
1189 + "id": "cherrystone",
1190 + "displayName": "Cherrystone Auctions (prices realized)",
1191 + "sourceId": "cherrystone",
1192 + "sourceName": "Cherrystone Philatelic Auctioneers",
1193 + "sourceType": "auction_house",
1194 + "sourceUrl": "https://auctions.cherrystoneauctions.com",
1195 + "module": "firecrawl/cherrystone",
1196 + "enginePriority": [
1197 + "firecrawl"
1198 + ],
1199 + "categories": [
1200 + "stamps",
1201 + "banknotes",
1202 + "coins"
1203 + ],
1204 + "regions": [
1205 + "US"
1206 + ],
1207 + "languages": [
1208 + "en"
1209 + ],
1210 + "currency": [
1211 + "USD"
1212 + ],
1213 + "supportsListings": false,
1214 + "supportsSold": true,
1215 + "supportsAuctions": false,
1216 + "supportsImages": true,
1217 + "supportsCatalog": false,
1218 + "supportsPopulation": false,
1219 + "supportsLookup": false,
1220 + "refreshFrequencyMinutes": 1440,
1221 + "priority": "medium",
1222 + "trustScore": 0.9,
1223 + "attributionRequired": true,
1224 + "termsUrl": "https://auctions.cherrystoneauctions.com/viewuserdefinedpage.aspx?pn=terms",
1225 + "accessNotes": "Firecrawl (rawHtml), 1 credit per page; plain HTTPS returns 403 (not bypassed; robots.txt is empty → nothing disallowed). Sources: the auction site home page, which links 'Prices Realized from <sale> - <date>' → catalog.aspx?auctionid=N; the catalog page lists category pages (/Category/<name>-<id>.html?auctionid=N) whose lot cards show lot number, title (with LOT<id>.aspx link), image, 'Final Price: $X' and estimate. Lots with no final price are unsold and skipped. Sale date = the sale date published on the home page (first day of multi-day sales). Cherrystone does not state whether 'Final Price' includes the buyer's premium → buyer_premium_included=null. Categories: stamps by default; banknotes/coins when the lot title says so. 2 s politeness.",
1226 + "enabled": true,
1227 + "schemaVersion": "1.0",
1228 + "config": {
1229 + "auctionsPerRun": 1,
1230 + "categoriesPerRun": 25
1231 + }
1232 + },
929 1233 {
930 1234 "id": "christies",
931 1235 "displayName": "Christie's (auction results)",
@@ -1141,23 +1445,28 @@
1141 1445 }
1142 1446 },
1143 1447 {
1144 − "id": "comicconnect",
1145 − "displayName": "ComicConnect (sold archive)",
1146 − "sourceId": "comicconnect",
1147 − "sourceName": "ComicConnect",
1148 − "sourceType": "auction_house",
1149 − "sourceUrl": "https://www.comicconnect.com",
1150 − "module": "api/comicconnect",
1448 + "id": "comc",
1449 + "displayName": "COMC (Check Out My Cards)",
1450 + "sourceId": "comc",
1451 + "sourceName": "COMC",
1452 + "sourceType": "marketplace",
1453 + "sourceUrl": "https://www.comc.com",
1454 + "module": "firecrawl/comc",
1151 1455 "enginePriority": [
1152 − "api",
1153 − "firecrawl",
1154 − "scrapfly"
1456 + "firecrawl"
1155 1457 ],
1156 1458 "categories": [
1157 − "comics",
1158 − "marvel_comics",
1159 − "dc_comics",
1160 − "independent_comics"
1459 + "sports_cards",
1460 + "baseball_cards",
1461 + "basketball_cards",
1462 + "football_cards",
1463 + "hockey_cards",
1464 + "soccer_cards",
1465 + "other_sports_cards",
1466 + "pokemon",
1467 + "magic_the_gathering",
1468 + "yugioh",
1469 + "non_sport_cards"
1161 1470 ],
1162 1471 "regions": [
1163 1472 "US"
@@ -1168,10 +1477,73 @@
1168 1477 "currency": [
1169 1478 "USD"
1170 1479 ],
1171 − "supportsListings": false,
1172 − "supportsSold": true,
1173 − "supportsAuctions": true,
1174 − "supportsImages": true,
1480 + "supportsListings": true,
1481 + "supportsSold": false,
1482 + "supportsAuctions": false,
1483 + "supportsImages": true,
1484 + "supportsCatalog": false,
1485 + "supportsPopulation": false,
1486 + "supportsLookup": false,
1487 + "refreshFrequencyMinutes": 1440,
1488 + "priority": "medium",
1489 + "trustScore": 0.75,
1490 + "attributionRequired": true,
1491 + "termsUrl": "https://www.comc.com/Legal",
1492 + "accessNotes": "COMC's category browse pages (/Cards/<Sport>/<Year>/<Set>) are public and allowed by robots.txt (only /Search/, /ItemDetails/, /Item/ and account paths are disallowed — item detail pages are therefore never fetched, only linked). Plain HTTP returns 403 to non-browser agents; pages are rendered through Firecrawl (1 credit per 100-listing page). Each listing row gives set/year/number, player, the grade in brackets ([PSA 10 GEM MT], [CSG 10 Gem Mint]) and the asking price (USD); sold-out items are excluded by default. Asking prices are listings, never sales. Seeds and pages per seed are configurable.",
1493 + "enabled": true,
1494 + "schemaVersion": "1.0",
1495 + "config": {
1496 + "seeds": [
1497 + "Cards/Basketball/1986/Fleer",
1498 + "Cards/Basketball/2003/Topps_Chrome",
1499 + "Cards/Basketball/2018/Panini_Prizm",
1500 + "Cards/Baseball/1952/Topps",
1501 + "Cards/Baseball/1989/Upper_Deck",
1502 + "Cards/Baseball/2011/Topps_Update",
1503 + "Cards/Football/2000/Playoff_Contenders",
1504 + "Cards/Football/2017/Panini_Prizm",
1505 + "Cards/Hockey/1979/O-Pee-Chee",
1506 + "Cards/Hockey/2005/Upper_Deck",
1507 + "Cards/Soccer/2018/Panini_Prizm_World_Cup",
1508 + "Cards/Pokemon/1999/Base_Set",
1509 + "Cards/Magic_The_Gathering",
1510 + "Cards/Yu-Gi-Oh"
1511 + ],
1512 + "pagesPerSeed": 2
1513 + }
1514 + },
1515 + {
1516 + "id": "comicconnect",
1517 + "displayName": "ComicConnect (sold archive)",
1518 + "sourceId": "comicconnect",
1519 + "sourceName": "ComicConnect",
1520 + "sourceType": "auction_house",
1521 + "sourceUrl": "https://www.comicconnect.com",
1522 + "module": "api/comicconnect",
1523 + "enginePriority": [
1524 + "api",
1525 + "firecrawl",
1526 + "scrapfly"
1527 + ],
1528 + "categories": [
1529 + "comics",
1530 + "marvel_comics",
1531 + "dc_comics",
1532 + "independent_comics"
1533 + ],
1534 + "regions": [
1535 + "US"
1536 + ],
1537 + "languages": [
1538 + "en"
1539 + ],
1540 + "currency": [
1541 + "USD"
1542 + ],
1543 + "supportsListings": false,
1544 + "supportsSold": true,
1545 + "supportsAuctions": true,
1546 + "supportsImages": true,
1175 1547 "supportsCatalog": false,
1176 1548 "supportsPopulation": false,
1177 1549 "supportsLookup": false,
@@ -1371,6 +1743,49 @@
1371 1743 "statsPerMaster": 6
1372 1744 }
1373 1745 },
1746 + {
1747 + "id": "e-rocks",
1748 + "displayName": "e-Rocks (online mineral auctions, EUR)",
1749 + "sourceId": "e-rocks",
1750 + "sourceName": "e-Rocks",
1751 + "sourceType": "marketplace",
1752 + "sourceUrl": "https://e-rocks.com",
1753 + "module": "firecrawl/e-rocks",
1754 + "enginePriority": [
1755 + "firecrawl"
1756 + ],
1757 + "categories": [
1758 + "minerals"
1759 + ],
1760 + "regions": [
1761 + "GB",
1762 + "EU"
1763 + ],
1764 + "languages": [
1765 + "en"
1766 + ],
1767 + "currency": [
1768 + "EUR"
1769 + ],
1770 + "supportsListings": false,
1771 + "supportsSold": false,
1772 + "supportsAuctions": true,
1773 + "supportsImages": true,
1774 + "supportsCatalog": false,
1775 + "supportsPopulation": false,
1776 + "supportsLookup": false,
1777 + "refreshFrequencyMinutes": 1440,
1778 + "priority": "low",
1779 + "trustScore": 0.7,
1780 + "attributionRequired": true,
1781 + "termsUrl": "https://e-rocks.com/terms-and-conditions",
1782 + "accessNotes": "e-rocks.com returns 403 to plain HTTPS clients; Firecrawl renders the public auction index and auction pages (1 credit each). robots.txt sets Crawl-delay: 10 (honoured: 10 s between requests) and disallows /search/ and /itemssearch, which are not used. Each weekly dealer auction page lists its lots with locality, size class, current bid in EUR, bid count and seller; the header gives start/end times (BST/GMT). Past auctions are not archived on the site, so lots are stored as auction lots (current bid, status live/ended at fetch time) — final hammer prices are not asserted. At most 4 auctions per run.",
1783 + "enabled": true,
1784 + "schemaVersion": "1.0",
1785 + "config": {
1786 + "auctionsPerRun": 4
1787 + }
1788 + },
1374 1789 {
1375 1790 "id": "fashionphile",
1376 1791 "displayName": "FASHIONPHILE (pre-owned luxury listings & sold items)",
@@ -1673,6 +2088,65 @@
1673 2088 "renderingWaitMs": 6000
1674 2089 }
1675 2090 },
2091 + {
2092 + "id": "gooding",
2093 + "displayName": "Gooding & Company (prices realized)",
2094 + "sourceId": "gooding",
2095 + "sourceName": "Gooding & Company",
2096 + "sourceType": "auction_house",
2097 + "sourceUrl": "https://www.goodingco.com",
2098 + "module": "api/gooding",
2099 + "enginePriority": [
2100 + "api"
2101 + ],
2102 + "categories": [
2103 + "automobiles",
2104 + "motorcycles",
2105 + "automotive_memorabilia"
2106 + ],
2107 + "regions": [
2108 + "US",
2109 + "GB",
2110 + "FR"
2111 + ],
2112 + "languages": [
2113 + "en"
2114 + ],
2115 + "currency": [
2116 + "USD",
2117 + "GBP",
2118 + "EUR"
2119 + ],
2120 + "supportsListings": false,
2121 + "supportsSold": true,
2122 + "supportsAuctions": false,
2123 + "supportsImages": true,
2124 + "supportsCatalog": false,
2125 + "supportsPopulation": false,
2126 + "supportsLookup": true,
2127 + "refreshFrequencyMinutes": 1440,
2128 + "priority": "medium",
2129 + "trustScore": 0.9,
2130 + "attributionRequired": true,
2131 + "termsUrl": "https://www.goodingco.com/terms-of-use",
2132 + "accessNotes": "Gooding & Company's public 'Prices Realized' pages are a Gatsby site; each realized auction page ships its full lot list as static JSON at /page-data/auction/realized/<slug>/page-data.json (one plain HTTPS request per auction, 0 credits; robots.txt has no disallow rules). Each lot carries salePrice (auction currency), lotNumber, title, model year, make, model and Cloudinary image ids (cloud 'goodingco'). Sale date = the last ContentfulSubEventAuction end date of the auction. Lots without a salePrice (unsold/withdrawn) and private-sale prices are skipped. The page does not state whether salePrice includes the buyer's premium, so buyer_premium_included is null (Gooding's published totals are customarily quoted inclusive of premium). Auction slugs come from config.seeds plus /auction/realized/ links found on the homepage; lookup() handles /lot/<slug> pages via their page-data JSON.",
2133 + "enabled": true,
2134 + "schemaVersion": "1.0",
2135 + "config": {
2136 + "seeds": [
2137 + "pebble-beach-auctions-2026",
2138 + "amelia-island-auctions-2026",
2139 + "retromobile-paris-2026",
2140 + "retromobile-new-york-auctions-2026",
2141 + "pebble-beach-auctions-2025",
2142 + "pebble-beach-auctions-2024",
2143 + "london-auction-2024",
2144 + "pebble-beach-auctions-2023",
2145 + "london-auction-2023"
2146 + ],
2147 + "auctionsPerRun": 3
2148 + }
2149 + },
1676 2150 {
1677 2151 "id": "grand-archive",
1678 2152 "displayName": "Grand Archive Index (official card API)",
@@ -1714,139 +2188,437 @@
1714 2188 }
1715 2189 },
1716 2190 {
1717 − "id": "hobbysearch",
1718 − "displayName": "HobbySearch 1999.co.jp (Gunpla & model kits, JPY)",
1719 − "sourceId": "hobbysearch",
1720 − "sourceName": "HobbySearch",
1721 − "sourceType": "dealer",
1722 − "sourceUrl": "https://www.1999.co.jp",
1723 − "module": "firecrawl/hobbysearch",
2191 + "id": "hakes",
2192 + "displayName": "Hake's Auctions (pop culture catalogs)",
2193 + "sourceId": "hakes",
2194 + "sourceName": "Hake's Auctions",
2195 + "sourceType": "auction_house",
2196 + "sourceUrl": "https://www.hakes.com",
2197 + "module": "api/hakes",
1724 2198 "enginePriority": [
1725 − "firecrawl",
1726 − "scrapfly"
2199 + "api"
1727 2200 ],
1728 2201 "categories": [
1729 − "gundam",
1730 − "action_figures",
1731 − "model_cars"
2202 + "vintage_toys",
2203 + "star_wars",
2204 + "marvel_comics",
2205 + "dc_comics",
2206 + "independent_comics",
2207 + "political_memorabilia",
2208 + "pins",
2209 + "non_sport_cards",
2210 + "disney_collectibles",
2211 + "advertising",
2212 + "animation_art",
2213 + "movie_posters",
2214 + "music_memorabilia",
2215 + "sports_memorabilia",
2216 + "autographs"
1732 2217 ],
1733 2218 "regions": [
1734 − "JP"
2219 + "US"
1735 2220 ],
1736 2221 "languages": [
1737 − "en",
1738 − "ja"
2222 + "en"
1739 2223 ],
1740 2224 "currency": [
1741 − "JPY"
2225 + "USD"
1742 2226 ],
1743 − "supportsListings": true,
1744 − "supportsSold": false,
1745 − "supportsAuctions": false,
2227 + "supportsListings": false,
2228 + "supportsSold": true,
2229 + "supportsAuctions": true,
1746 2230 "supportsImages": true,
1747 − "supportsCatalog": true,
2231 + "supportsCatalog": false,
1748 2232 "supportsPopulation": false,
1749 − "supportsLookup": true,
1750 − "refreshFrequencyMinutes": 1440,
1751 − "priority": "low",
1752 − "trustScore": 0.8,
2233 + "supportsLookup": false,
2234 + "refreshFrequencyMinutes": 720,
2235 + "priority": "medium",
2236 + "trustScore": 0.85,
1753 2237 "attributionRequired": true,
1754 − "termsUrl": "https://www.1999.co.jp/eng/guide/",
1755 − "accessNotes": "Public English search/list pages (1999.co.jp/eng/search?...; robots.txt allows all for generic agents) and item pages. Direct HTTPS returns 403 to non-browser clients, so pages go through Firecrawl (1 credit per page of 60 items). Parsed per card: HobbySearch item id, title, street price and list price (JPY), discount, stock state (In Stock / Sold Out / Pre-Order / Back-order), image. Emits catalog items (list price = manufacturer MSRP) and the retailer's fixed-price listing. Gundam categorisation is keyword-based on the seed + title; other kits fall back to the seed's category.",
2238 + "termsUrl": "https://www.hakes.com/terms",
2239 + "accessNotes": "Plain HTTPS on the public Bidsquare-hosted site (robots.txt disallows /search, account pages and filtered/sorted query URLs; we only load /auctions and unfiltered catalog pages with ?page=N, 2 s politeness). Hake's hides its past-auction list, so upcoming catalogs are crawled for auction lots (estimate, current bid, countdown end) and their URLs are remembered in the connector cursor; after an event closes the same catalog pages report status 'past' with 'Sold for $X' → sales dated by the lot/event end. AFA/CAS toy grades are kept in metadata (not graders). Whether 'Sold for' includes the buyer's premium is not stated on the page → null. 0 credits.",
1756 2240 "enabled": true,
1757 2241 "schemaVersion": "1.0",
1758 2242 "config": {
1759 − "seeds": [
1760 − {
1761 − "key": "Gundam MG",
1762 − "categorySlug": "gundam"
1763 − },
1764 − {
1765 − "key": "Gundam RG",
1766 − "categorySlug": "gundam"
1767 − },
1768 − {
1769 − "key": "Gundam PG",
1770 − "categorySlug": "gundam"
1771 − },
1772 − {
1773 − "key": "HGUC",
1774 − "categorySlug": "gundam"
1775 − },
1776 − {
1777 − "key": "Nendoroid",
1778 − "categorySlug": "action_figures"
1779 − },
1780 − {
1781 − "key": "figma",
1782 − "categorySlug": "action_figures"
1783 − }
1784 − ],
1785 − "pagesPerSeed": 1
2243 + "eventsPerRun": 2,
2244 + "pagesPerEvent": 6,
2245 + "includeUpcoming": true
1786 2246 }
1787 2247 },
1788 2248 {
1789 − "id": "hypeboost",
1790 − "displayName": "Hypeboost (EU sneaker marketplace — lowest asks)",
1791 − "sourceId": "hypeboost",
1792 − "sourceName": "Hypeboost",
1793 − "sourceType": "marketplace",
1794 − "sourceUrl": "https://hypeboost.com",
1795 − "module": "firecrawl/hypeboost",
2249 + "id": "hdh-wine",
2250 + "displayName": "Hart Davis Hart Wine Co. (auction results)",
2251 + "sourceId": "hdh-wine",
2252 + "sourceName": "Hart Davis Hart Wine Co.",
2253 + "sourceType": "auction_house",
2254 + "sourceUrl": "https://hdhauctions.com",
2255 + "module": "firecrawl/hdh-wine",
1796 2256 "enginePriority": [
1797 − "firecrawl",
1798 − "scrapfly"
2257 + "firecrawl"
1799 2258 ],
1800 2259 "categories": [
1801 − "sneakers",
1802 − "nike_jordan",
1803 − "adidas_yeezy",
1804 − "new_balance_asics_other"
2260 + "wine",
2261 + "whisky",
2262 + "cognac"
1805 2263 ],
1806 2264 "regions": [
1807 − "NL",
1808 − "EU"
2265 + "US"
1809 2266 ],
1810 2267 "languages": [
1811 2268 "en"
1812 2269 ],
1813 2270 "currency": [
1814 − "EUR"
2271 + "USD"
1815 2272 ],
1816 − "supportsListings": true,
1817 − "supportsSold": false,
2273 + "supportsListings": false,
2274 + "supportsSold": true,
1818 2275 "supportsAuctions": false,
1819 − "supportsImages": true,
1820 − "supportsCatalog": true,
2276 + "supportsImages": false,
2277 + "supportsCatalog": false,
1821 2278 "supportsPopulation": false,
1822 − "supportsLookup": true,
1823 − "refreshFrequencyMinutes": 1440,
1824 − "priority": "low",
1825 − "trustScore": 0.7,
2279 + "supportsLookup": false,
2280 + "refreshFrequencyMinutes": 10080,
2281 + "priority": "medium",
2282 + "trustScore": 0.95,
1826 2283 "attributionRequired": true,
1827 − "termsUrl": "https://hypeboost.com/en/terms-and-conditions",
1828 − "accessNotes": "Plain HTTPS gets 403 (robots.txt not served to non-browser agents); Firecrawl's standard fetch returns the public category grid (hypeboost.com/en/category/sneakers/<brand>[?page=N]) with product name, EUR lowest price, brand/category data attributes and product id (36 per page, 1 credit). Product pages (lookup) expose the style code in schema.org Product sku. 2 s between pages.",
2284 + "termsUrl": "https://hdhauctions.com/conditions-of-sale/",
2285 + "accessNotes": "Hart Davis Hart (Chicago; ~8 fine-wine auctions a year, 100% sold rates) publishes every auction's full prices-realized as a PDF linked from /auction-archives/ ('View auction results'). Plain HTTP is 403 for non-browser clients, so the archive page (1 credit) and each PDF (Firecrawl PDF parser, 1 credit per page, ≈130–140 pages for a 2,500-lot sale) are fetched with Firecrawl; config.pdfsPerRun caps spend and the cursor remembers processed PDFs. Table columns: Lot | Qty | Description | Estimate | Hammer | Aggregate — price = hammer (USD) with buyer_premium_included=false; the aggregate (hammer + 19.5% premium) is kept in metadata. Lots spanning several rows (mixed cases) are single sales flagged as bundles; quantity = bottle count of the first row. Sale date = the auction date range printed in the PDF header (first day). No images.",
1829 2286 "enabled": true,
1830 2287 "schemaVersion": "1.0",
1831 2288 "config": {
1832 − "seeds": [
1833 − "air-jordan",
1834 − "nike",
1835 − "adidas",
1836 − "yeezy",
1837 − "new-balance",
1838 − "asics"
1839 − ],
1840 − "pagesPerSeed": 2
2289 + "archiveUrl": "https://hdhauctions.com/auction-archives/",
2290 + "pdfsPerRun": 1
1841 2291 }
1842 2292 },
1843 2293 {
1844 − "id": "idealwine",
1845 − "displayName": "iDealwine Price Estimate (wine price index)",
1846 − "sourceId": "idealwine",
1847 − "sourceName": "iDealwine",
1848 − "sourceType": "pricing_guide",
1849 − "sourceUrl": "https://www.idealwine.com",
2294 + "id": "heffel",
2295 + "displayName": "Heffel Fine Art Auction House (auction results)",
2296 + "sourceId": "heffel",
2297 + "sourceName": "Heffel Fine Art Auction House",
2298 + "sourceType": "auction_house",
2299 + "sourceUrl": "https://www.heffel.com",
2300 + "module": "api/heffel",
2301 + "enginePriority": [
2302 + "api",
2303 + "firecrawl"
2304 + ],
2305 + "categories": [
2306 + "art",
2307 + "contemporary_art",
2308 + "photography",
2309 + "design_furniture"
2310 + ],
2311 + "regions": [
2312 + "CA"
2313 + ],
2314 + "languages": [
2315 + "en",
2316 + "fr"
2317 + ],
2318 + "currency": [
2319 + "CAD"
2320 + ],
2321 + "supportsListings": false,
2322 + "supportsSold": true,
2323 + "supportsAuctions": false,
2324 + "supportsImages": true,
2325 + "supportsCatalog": false,
2326 + "supportsPopulation": false,
2327 + "supportsLookup": false,
2328 + "refreshFrequencyMinutes": 1440,
2329 + "priority": "medium",
2330 + "trustScore": 0.9,
2331 + "attributionRequired": true,
2332 + "termsUrl": "https://www.heffel.com/Auction/Terms_Defined_E.pdf",
2333 + "accessNotes": "Plain HTTPS, 0 credits. heffel.com serves no robots.txt (404 → nothing disallowed). Sources: the public 'Auction Results' index (/Links/Results_Choose_E.aspx, ~16 recent live + online sales) and each results page (/Links/Results_E?Request=<token>) which lists lot number, thumbnail, title, artist and price for every sold lot. Heffel states on the page: 'Prices include Buyer's Premium' and prices are in Canadian dollars (CAD) → buyer_premium_included=true. Sale date parsed from the page's 'Sale date:' line; when a monthly online sale has no explicit date, the month named in the index is used (day 1, confidence 0.7). Artist is stored as brand. 2 s politeness delay.",
2334 + "enabled": true,
2335 + "schemaVersion": "1.0",
2336 + "config": {
2337 + "salesPerRun": 3
2338 + }
2339 + },
2340 + {
2341 + "id": "hifishark",
2342 + "displayName": "HiFiShark (second-hand hi-fi aggregator)",
2343 + "sourceId": "hifishark",
2344 + "sourceName": "HiFiShark",
2345 + "sourceType": "analytics_provider",
2346 + "sourceUrl": "https://www.hifishark.com",
2347 + "module": "api/hifishark",
2348 + "enginePriority": [
2349 + "api",
2350 + "firecrawl"
2351 + ],
2352 + "categories": [
2353 + "audio_equipment"
2354 + ],
2355 + "regions": [
2356 + "EU",
2357 + "US",
2358 + "GB",
2359 + "JP"
2360 + ],
2361 + "languages": [
2362 + "en"
2363 + ],
2364 + "currency": [
2365 + "EUR",
2366 + "USD",
2367 + "GBP",
2368 + "JPY",
2369 + "CHF",
2370 + "SEK",
2371 + "DKK",
2372 + "NOK",
2373 + "PLN",
2374 + "CAD",
2375 + "AUD"
2376 + ],
2377 + "supportsListings": true,
2378 + "supportsSold": false,
2379 + "supportsAuctions": false,
2380 + "supportsImages": true,
2381 + "supportsCatalog": false,
2382 + "supportsPopulation": false,
2383 + "supportsLookup": false,
2384 + "refreshFrequencyMinutes": 1440,
2385 + "priority": "low",
2386 + "trustScore": 0.6,
2387 + "attributionRequired": true,
2388 + "termsUrl": "https://www.hifishark.com/terms",
2389 + "accessNotes": "Search pages (hifishark.com/search?q=<model>) are fetched over plain HTTPS with the RareIndex user agent, 2 s apart; robots.txt allows /search for generic agents. Only the server-rendered 'For Sale' tab is used (title, asking price in the seller's currency, marketplace, country, listing date, image); the Sold/Expired tab and the /searchrt, /searchSlice and /api endpoints are disallowed by robots.txt and are not requested, so no transactions are recorded. Listings are attributed to HiFiShark and the originating marketplace (metadata.marketplace); the source URL is HiFiShark's redirect link, which is not crawled. Seeds are collectible hi-fi models (query = brand + model, used as the asset name).",
2390 + "enabled": true,
2391 + "schemaVersion": "1.0",
2392 + "config": {
2393 + "queries": [
2394 + "Marantz 2270",
2395 + "Marantz 2325",
2396 + "Marantz Model 7",
2397 + "McIntosh MC275",
2398 + "McIntosh MC240",
2399 + "McIntosh C22",
2400 + "Technics SL-1200",
2401 + "Technics SP-10",
2402 + "Nakamichi Dragon",
2403 + "Nakamichi 1000ZXL",
2404 + "JBL L100",
2405 + "JBL Paragon",
2406 + "JBL 4343",
2407 + "Klipschorn",
2408 + "Klipsch La Scala",
2409 + "Sansui 9090DB",
2410 + "Sansui AU-111",
2411 + "Pioneer SX-1980",
2412 + "Pioneer SX-1250",
2413 + "Revox B77",
2414 + "Revox A77",
2415 + "Studer A80",
2416 + "Linn LP12",
2417 + "Thorens TD124",
2418 + "Thorens TD160",
2419 + "Garrard 301",
2420 + "Garrard 401",
2421 + "Tannoy Monitor Gold",
2422 + "Tannoy Westminster",
2423 + "Quad ESL 57",
2424 + "Quad 405",
2425 + "Sennheiser HD800",
2426 + "Stax SR-009",
2427 + "Yamaha NS-1000",
2428 + "Luxman L-550",
2429 + "Accuphase E-303",
2430 + "Bang & Olufsen Beogram 4000",
2431 + "Bang & Olufsen Beomaster 1900",
2432 + "Naim NAP 250",
2433 + "Rega Planar 3",
2434 + "Denon DP-3000",
2435 + "Micro Seiki RX-5000",
2436 + "Kenwood L-07",
2437 + "Leak Stereo 20",
2438 + "Marantz 10B"
2439 + ]
2440 + }
2441 + },
2442 + {
2443 + "id": "historics",
2444 + "displayName": "Historics Auctioneers (results)",
2445 + "sourceId": "historics",
2446 + "sourceName": "Historics Auctioneers",
2447 + "sourceType": "auction_house",
2448 + "sourceUrl": "https://www.historics.co.uk",
2449 + "module": "api/historics",
2450 + "enginePriority": [
2451 + "api"
2452 + ],
2453 + "categories": [
2454 + "automobiles",
2455 + "motorcycles",
2456 + "automotive_memorabilia",
2457 + "license_plates"
2458 + ],
2459 + "regions": [
2460 + "GB"
2461 + ],
2462 + "languages": [
2463 + "en"
2464 + ],
2465 + "currency": [
2466 + "GBP"
2467 + ],
2468 + "supportsListings": false,
2469 + "supportsSold": true,
2470 + "supportsAuctions": false,
2471 + "supportsImages": true,
2472 + "supportsCatalog": false,
2473 + "supportsPopulation": false,
2474 + "supportsLookup": false,
2475 + "refreshFrequencyMinutes": 1440,
2476 + "priority": "low",
2477 + "trustScore": 0.85,
2478 + "attributionRequired": true,
2479 + "termsUrl": "https://www.historics.co.uk/terms-and-conditions",
2480 + "accessNotes": "Historics (UK classic-car auctions, Ascot/Windsor/Brooklands + online automobilia & registration sales) lists past sales at /auction-results (title, sale number, end date) and renders each sale's 'Past lots' server-side at /auction/details/<slug>?au=<id>&pp=96&pn=<page> with 'Sold £X' per lot card (plain HTTPS, 0 credits). robots.txt sets crawl-delay 10, so the connector waits 10 s between requests and caps pages per run. 'Sold £X' is the hammer price (Historics adds a buyer's premium + VAT on top) → buyer_premium_included=false; lots 'sold for an undisclosed fee' and unsold lots are skipped. Sale numbers starting with A = vehicle sales (cars/motorcycles from the title), W = online automobilia/registration sales (automotive_memorabilia; registration plates → license_plates). Sale date = the end date printed on the results list.",
2481 + "enabled": true,
2482 + "schemaVersion": "1.0",
2483 + "config": {
2484 + "auctionsPerRun": 1,
2485 + "pagesPerRun": 3
2486 + }
2487 + },
2488 + {
2489 + "id": "hobbysearch",
2490 + "displayName": "HobbySearch 1999.co.jp (Gunpla & model kits, JPY)",
2491 + "sourceId": "hobbysearch",
2492 + "sourceName": "HobbySearch",
2493 + "sourceType": "dealer",
2494 + "sourceUrl": "https://www.1999.co.jp",
2495 + "module": "firecrawl/hobbysearch",
2496 + "enginePriority": [
2497 + "firecrawl",
2498 + "scrapfly"
2499 + ],
2500 + "categories": [
2501 + "gundam",
2502 + "action_figures",
2503 + "model_cars"
2504 + ],
2505 + "regions": [
2506 + "JP"
2507 + ],
2508 + "languages": [
2509 + "en",
2510 + "ja"
2511 + ],
2512 + "currency": [
2513 + "JPY"
2514 + ],
2515 + "supportsListings": true,
2516 + "supportsSold": false,
2517 + "supportsAuctions": false,
2518 + "supportsImages": true,
2519 + "supportsCatalog": true,
2520 + "supportsPopulation": false,
2521 + "supportsLookup": true,
2522 + "refreshFrequencyMinutes": 1440,
2523 + "priority": "low",
2524 + "trustScore": 0.8,
2525 + "attributionRequired": true,
2526 + "termsUrl": "https://www.1999.co.jp/eng/guide/",
2527 + "accessNotes": "Public English search/list pages (1999.co.jp/eng/search?...; robots.txt allows all for generic agents) and item pages. Direct HTTPS returns 403 to non-browser clients, so pages go through Firecrawl (1 credit per page of 60 items). Parsed per card: HobbySearch item id, title, street price and list price (JPY), discount, stock state (In Stock / Sold Out / Pre-Order / Back-order), image. Emits catalog items (list price = manufacturer MSRP) and the retailer's fixed-price listing. Gundam categorisation is keyword-based on the seed + title; other kits fall back to the seed's category.",
2528 + "enabled": true,
2529 + "schemaVersion": "1.0",
2530 + "config": {
2531 + "seeds": [
2532 + {
2533 + "key": "Gundam MG",
2534 + "categorySlug": "gundam"
2535 + },
2536 + {
2537 + "key": "Gundam RG",
2538 + "categorySlug": "gundam"
2539 + },
2540 + {
2541 + "key": "Gundam PG",
2542 + "categorySlug": "gundam"
2543 + },
2544 + {
2545 + "key": "HGUC",
2546 + "categorySlug": "gundam"
2547 + },
2548 + {
2549 + "key": "Nendoroid",
2550 + "categorySlug": "action_figures"
2551 + },
2552 + {
2553 + "key": "figma",
2554 + "categorySlug": "action_figures"
2555 + }
2556 + ],
2557 + "pagesPerSeed": 1
2558 + }
2559 + },
2560 + {
2561 + "id": "hypeboost",
2562 + "displayName": "Hypeboost (EU sneaker marketplace — lowest asks)",
2563 + "sourceId": "hypeboost",
2564 + "sourceName": "Hypeboost",
2565 + "sourceType": "marketplace",
2566 + "sourceUrl": "https://hypeboost.com",
2567 + "module": "firecrawl/hypeboost",
2568 + "enginePriority": [
2569 + "firecrawl",
2570 + "scrapfly"
2571 + ],
2572 + "categories": [
2573 + "sneakers",
2574 + "nike_jordan",
2575 + "adidas_yeezy",
2576 + "new_balance_asics_other"
2577 + ],
2578 + "regions": [
2579 + "NL",
2580 + "EU"
2581 + ],
2582 + "languages": [
2583 + "en"
2584 + ],
2585 + "currency": [
2586 + "EUR"
2587 + ],
2588 + "supportsListings": true,
2589 + "supportsSold": false,
2590 + "supportsAuctions": false,
2591 + "supportsImages": true,
2592 + "supportsCatalog": true,
2593 + "supportsPopulation": false,
2594 + "supportsLookup": true,
2595 + "refreshFrequencyMinutes": 1440,
2596 + "priority": "low",
2597 + "trustScore": 0.7,
2598 + "attributionRequired": true,
2599 + "termsUrl": "https://hypeboost.com/en/terms-and-conditions",
2600 + "accessNotes": "Plain HTTPS gets 403 (robots.txt not served to non-browser agents); Firecrawl's standard fetch returns the public category grid (hypeboost.com/en/category/sneakers/<brand>[?page=N]) with product name, EUR lowest price, brand/category data attributes and product id (36 per page, 1 credit). Product pages (lookup) expose the style code in schema.org Product sku. 2 s between pages.",
2601 + "enabled": true,
2602 + "schemaVersion": "1.0",
2603 + "config": {
2604 + "seeds": [
2605 + "air-jordan",
2606 + "nike",
2607 + "adidas",
2608 + "yeezy",
2609 + "new-balance",
2610 + "asics"
2611 + ],
2612 + "pagesPerSeed": 2
2613 + }
2614 + },
2615 + {
2616 + "id": "idealwine",
2617 + "displayName": "iDealwine Price Estimate (wine price index)",
2618 + "sourceId": "idealwine",
2619 + "sourceName": "iDealwine",
2620 + "sourceType": "pricing_guide",
2621 + "sourceUrl": "https://www.idealwine.com",
1850 2622 "module": "api/idealwine",
1851 2623 "enginePriority": [
1852 2624 "api",
@@ -1898,6 +2670,98 @@
1898 2670 "pagesPerRegion": 3
1899 2671 }
1900 2672 },
2673 + {
2674 + "id": "just-whisky",
2675 + "displayName": "Just Whisky (past auctions)",
2676 + "sourceId": "just-whisky",
2677 + "sourceName": "Just Whisky",
2678 + "sourceType": "auction_house",
2679 + "sourceUrl": "https://www.just-whisky.co.uk",
2680 + "module": "api/just-whisky",
2681 + "enginePriority": [
2682 + "api"
2683 + ],
2684 + "categories": [
2685 + "whisky",
2686 + "rum",
2687 + "cognac"
2688 + ],
2689 + "regions": [
2690 + "GB"
2691 + ],
2692 + "languages": [
2693 + "en"
2694 + ],
2695 + "currency": [
2696 + "GBP"
2697 + ],
2698 + "supportsListings": false,
2699 + "supportsSold": true,
2700 + "supportsAuctions": false,
2701 + "supportsImages": true,
2702 + "supportsCatalog": false,
2703 + "supportsPopulation": false,
2704 + "supportsLookup": true,
2705 + "refreshFrequencyMinutes": 1440,
2706 + "priority": "high",
2707 + "trustScore": 0.9,
2708 + "attributionRequired": true,
2709 + "termsUrl": "https://www.just-whisky.co.uk/terms-and-conditions",
2710 + "accessNotes": "Just Whisky (Scotland, monthly online whisky auctions since 2013) renders its Past Auctions page from a public JSON API that we read directly: GET /api/lots/?min_end_date=dd/mm/yyyy&max_end_date=dd/mm/yyyy&ordering=-price&page_size=200&page=N and GET /api/auctions/ (plain HTTPS, 0 credits; robots.txt disallows only /account/ and /checkout). A lot is a sale when reserve_met is true (hammer_price is then populated); price = hammer price in GBP, buyer's commission is charged separately → buyer_premium_included=false. Sale date = the lot's auction end_date (seller_sheet.auction). Item facts (strength, size, distillery/bottler when filled) go to attributes/metadata; titles are parsed with the shared whisky heuristics. Incremental runs read the last two auction windows; backfill walks auctions oldest→newest from /api/auctions/. 1.5 s politeness.",
2711 + "enabled": true,
2712 + "schemaVersion": "1.0",
2713 + "config": {
2714 + "pageSize": 200,
2715 + "pagesPerRun": 8,
2716 + "auctionsPerRun": 2
2717 + }
2718 + },
2719 + {
2720 + "id": "kelleher",
2721 + "displayName": "Daniel F. Kelleher Auctions (prices realized via Stamp Auction Network)",
2722 + "sourceId": "kelleher",
2723 + "sourceName": "Daniel F. Kelleher Auctions",
2724 + "sourceType": "auction_house",
2725 + "sourceUrl": "https://stampauctionnetwork.com/kelleher.cfm",
2726 + "module": "api/kelleher",
2727 + "enginePriority": [
2728 + "api",
2729 + "firecrawl"
2730 + ],
2731 + "categories": [
2732 + "stamps",
2733 + "banknotes",
2734 + "coins"
2735 + ],
2736 + "regions": [
2737 + "US"
2738 + ],
2739 + "languages": [
2740 + "en"
2741 + ],
2742 + "currency": [
2743 + "USD"
2744 + ],
2745 + "supportsListings": false,
2746 + "supportsSold": true,
2747 + "supportsAuctions": false,
2748 + "supportsImages": true,
2749 + "supportsCatalog": false,
2750 + "supportsPopulation": false,
2751 + "supportsLookup": false,
2752 + "refreshFrequencyMinutes": 1440,
2753 + "priority": "medium",
2754 + "trustScore": 0.85,
2755 + "attributionRequired": true,
2756 + "termsUrl": "https://www.kelleherauctions.com/auction-rules/",
2757 + "accessNotes": "Plain HTTPS, 0 credits. Kelleher publishes its catalogues and prices realized on Stamp Auction Network (stampauctionnetwork.com; robots.txt has no disallow rules). Sources: the firm page (/Kelleher.cfm, 'Prices Realized' section) → cCatalog.cfm?SrchFirm=V&SrchSale=<sale> → major groups (cCatalog2.cfm) → category pages (cCatalog3.cfm) whose lot tables show Sale/Lot/Cat numbers, description, image, 'Sold...US$ X', 'Closed..Mon-DD-YYYY' and 'Sold For X'. 'Sold' = winning bid (hammer) → buyer_premium_included=false; unsold lots (no 'Sold For') are skipped. Sale date = the lot's own Closed date. Attribution: Kelleher via Stamp Auction Network. Only stamp/postal-history catalogues in the cCatalog format are crawled (coin sales use another layout and are skipped). 2 s politeness.",
2758 + "enabled": true,
2759 + "schemaVersion": "1.0",
2760 + "config": {
2761 + "salesPerRun": 1,
2762 + "categoryPagesPerRun": 30
2763 + }
2764 + },
1901 2765 {
1902 2766 "id": "laced",
1903 2767 "displayName": "Laced (UK sneaker marketplace — asks per size)",
@@ -2009,6 +2873,55 @@
2009 2873 "pagesPerTheme": 1
2010 2874 }
2011 2875 },
2876 + {
2877 + "id": "lelands",
2878 + "displayName": "Lelands (auction results)",
2879 + "sourceId": "lelands",
2880 + "sourceName": "Lelands",
2881 + "sourceType": "auction_house",
2882 + "sourceUrl": "https://auction.lelands.com",
2883 + "module": "firecrawl/lelands",
2884 + "enginePriority": [
2885 + "firecrawl"
2886 + ],
2887 + "categories": [
2888 + "sports_memorabilia",
2889 + "baseball_cards",
2890 + "basketball_cards",
2891 + "football_cards",
2892 + "hockey_cards",
2893 + "soccer_cards",
2894 + "other_sports_cards",
2895 + "olympic_collectibles"
2896 + ],
2897 + "regions": [
2898 + "US"
2899 + ],
2900 + "languages": [
2901 + "en"
2902 + ],
2903 + "currency": [
2904 + "USD"
2905 + ],
2906 + "supportsListings": false,
2907 + "supportsSold": true,
2908 + "supportsAuctions": false,
2909 + "supportsImages": true,
2910 + "supportsCatalog": false,
2911 + "supportsPopulation": false,
2912 + "supportsLookup": false,
2913 + "refreshFrequencyMinutes": 1440,
2914 + "priority": "medium",
2915 + "trustScore": 0.9,
2916 + "attributionRequired": true,
2917 + "termsUrl": "https://lelands.com/terms",
2918 + "accessNotes": "The public gallery auction.lelands.com/Lots/Gallery?size=250&page=N lists the currently displayed auction's lots with title, bids, opening bid, status and 'SOLD FOR $X'; the sidebar gives the auction start/end and states 'Prices Shown Include Buyer's Premium' → buyer_premium_included=true, sale date = auction end (records are emitted only once the end has passed). Direct HTTP requests receive a Cloudflare 403 (robots.txt itself allows 'User-agent: *'), so pages are rendered through Firecrawl at 1 credit per 250 lots — a browser render of the public page, no login, no bidding, no challenge solving. Past auctions sit behind a form postback and are not fetched; each auction is captured while it is the displayed one. 2.5 s politeness, ≤5 pages per run.",
2919 + "enabled": true,
2920 + "schemaVersion": "1.0",
2921 + "config": {
2922 + "pagesPerRun": 5
2923 + }
2924 + },
2012 2925 {
2013 2926 "id": "lorcast",
2014 2927 "displayName": "Lorcast (Disney Lorcana)",
@@ -2050,6 +2963,76 @@
2050 2963 "schemaVersion": "1.0",
2051 2964 "config": {}
2052 2965 },
2966 + {
2967 + "id": "lukie-games",
2968 + "displayName": "Lukie Games (retro video-game dealer, USD)",
2969 + "sourceId": "lukie-games",
2970 + "sourceName": "Lukie Games",
2971 + "sourceType": "dealer",
2972 + "sourceUrl": "https://www.lukiegames.com",
2973 + "module": "api/lukie-games",
2974 + "enginePriority": [
2975 + "api"
2976 + ],
2977 + "categories": [
2978 + "nintendo_games",
2979 + "playstation_games",
2980 + "xbox_games",
2981 + "sega_games",
2982 + "atari_retro_games"
2983 + ],
2984 + "regions": [
2985 + "US"
2986 + ],
2987 + "languages": [
2988 + "en"
2989 + ],
2990 + "currency": [
2991 + "USD"
2992 + ],
2993 + "supportsListings": true,
2994 + "supportsSold": false,
2995 + "supportsAuctions": false,
2996 + "supportsImages": true,
2997 + "supportsCatalog": false,
2998 + "supportsPopulation": false,
2999 + "supportsLookup": false,
3000 + "refreshFrequencyMinutes": 1440,
3001 + "priority": "low",
3002 + "trustScore": 0.75,
3003 + "attributionRequired": true,
3004 + "termsUrl": "https://www.lukiegames.com/terms.html",
3005 + "accessNotes": "Lukie's category pages render products through SearchSpring; the connector calls the same public JSON feed the storefront uses (dytuzo.a.searchspring.io/api/search/search.json, filtered by platform via bgfilter.extrafield5, 100 results/page, sorted by price desc). No login or key; 1.5 s between requests. Only games are kept (systems/accessories filtered by name and SKU); completeness defaults to 'loose' unless the product name says complete/sealed, and the platform is mapped to the PriceCharting console vocabulary (attributes.set) so the dealer asks attach to the same assets as PriceCharting sales. Prices are Lukie asking prices (USD), never transactions.",
3006 + "enabled": true,
3007 + "schemaVersion": "1.0",
3008 + "config": {
3009 + "perPage": 100,
3010 + "pagesPerPlatform": 2,
3011 + "backfillPages": 10,
3012 + "platforms": [
3013 + "Nintendo 64",
3014 + "Super Nintendo",
3015 + "Nintendo NES",
3016 + "Gamecube",
3017 + "Gameboy",
3018 + "Gameboy Color",
3019 + "Gameboy Advance",
3020 + "Nintendo DS",
3021 + "Playstation",
3022 + "Playstation 2",
3023 + "Playstation 3",
3024 + "PSP",
3025 + "Xbox",
3026 + "Xbox 360",
3027 + "Sega Genesis",
3028 + "Sega Dreamcast",
3029 + "Sega Saturn",
3030 + "Sega Game Gear",
3031 + "Atari 2600",
3032 + "Nintendo Wii"
3033 + ]
3034 + }
3035 + },
2053 3036 {
2054 3037 "id": "lyon-turnbull",
2055 3038 "displayName": "Lyon & Turnbull (auction results)",
@@ -2241,6 +3224,122 @@
2241 3224 "lotPagesPerAuction": 10
2242 3225 }
2243 3226 },
3227 + {
3228 + "id": "morphy",
3229 + "displayName": "Morphy Auctions (prices realized)",
3230 + "sourceId": "morphy",
3231 + "sourceName": "Morphy Auctions",
3232 + "sourceType": "auction_house",
3233 + "sourceUrl": "https://morphyauctions.com",
3234 + "module": "api/morphy",
3235 + "enginePriority": [
3236 + "api"
3237 + ],
3238 + "categories": [
3239 + "vintage_toys",
3240 + "advertising",
3241 + "vending_machines",
3242 + "arcade_pinball",
3243 + "casino_memorabilia",
3244 + "dolls",
3245 + "model_trains",
3246 + "coins",
3247 + "banknotes",
3248 + "perfume",
3249 + "glass_crystal",
3250 + "porcelain",
3251 + "silver",
3252 + "clocks",
3253 + "antiques",
3254 + "art",
3255 + "jewelry",
3256 + "other_watches",
3257 + "automotive_memorabilia",
3258 + "sports_memorabilia"
3259 + ],
3260 + "regions": [
3261 + "US"
3262 + ],
3263 + "languages": [
3264 + "en"
3265 + ],
3266 + "currency": [
3267 + "USD"
3268 + ],
3269 + "supportsListings": false,
3270 + "supportsSold": true,
3271 + "supportsAuctions": false,
3272 + "supportsImages": true,
3273 + "supportsCatalog": false,
3274 + "supportsPopulation": false,
3275 + "supportsLookup": false,
3276 + "refreshFrequencyMinutes": 720,
3277 + "priority": "medium",
3278 + "trustScore": 0.9,
3279 + "attributionRequired": true,
3280 + "termsUrl": "https://morphyauctions.com/bidding/terms-and-conditions/",
3281 + "accessNotes": "Two public hosts, plain HTTPS, 0 credits, 2 s politeness. morphyauctions.com/auctions/past-auctions/ (WordPress; robots.txt allows) lists every past sale with department, title and date; auctions.morphyauctions.com/catalog.aspx?auctionid=N shows each lot with 'Final Price: $X', min bid, estimate and bid count. Paging on the ASP.NET catalog is the page's own form POST (page number + Go, 100 lots per page) with its hidden __VIEWSTATE fields — no account or bidding endpoint is used. Firearms & Militaria departments and weapon lots are skipped. Morphy does not state on the catalog whether 'Final Price' includes the buyer's premium → buyer_premium_included=null. Sale date = auction date from the list (first day of multi-day sales).",
3282 + "enabled": true,
3283 + "schemaVersion": "1.0",
3284 + "config": {
3285 + "auctionsPerRun": 2,
3286 + "pagesPerAuction": 10,
3287 + "lotsPerPage": 100
3288 + }
3289 + },
3290 + {
3291 + "id": "mpb",
3292 + "displayName": "MPB (used cameras & lenses, US/UK/EU)",
3293 + "sourceId": "mpb",
3294 + "sourceName": "MPB",
3295 + "sourceType": "dealer",
3296 + "sourceUrl": "https://www.mpb.com",
3297 + "module": "firecrawl/mpb",
3298 + "enginePriority": [
3299 + "firecrawl"
3300 + ],
3301 + "categories": [
3302 + "cameras"
3303 + ],
3304 + "regions": [
3305 + "US",
3306 + "GB",
3307 + "EU"
3308 + ],
3309 + "languages": [
3310 + "en"
3311 + ],
3312 + "currency": [
3313 + "USD",
3314 + "GBP",
3315 + "EUR"
3316 + ],
3317 + "supportsListings": true,
3318 + "supportsSold": false,
3319 + "supportsAuctions": false,
3320 + "supportsImages": true,
3321 + "supportsCatalog": false,
3322 + "supportsPopulation": false,
3323 + "supportsLookup": false,
3324 + "refreshFrequencyMinutes": 1440,
3325 + "priority": "low",
3326 + "trustScore": 0.85,
3327 + "attributionRequired": true,
3328 + "termsUrl": "https://www.mpb.com/en-us/terms-and-conditions",
3329 + "accessNotes": "MPB blocks non-browser clients (403 'Security check', robots.txt itself returns 403) but category pages render for Firecrawl (1 credit per page, ~2.5 s wait). The connector reads the model cards of seeded category pages (name, units available, price range, model URL, image) for the US storefront (add en-uk / en-eu seeds for GBP/EUR). One listing per model at the cheapest available unit; per-unit cosmetic grades live on the model page and are not fetched. Dealer asks only; no transactions.",
3330 + "enabled": true,
3331 + "schemaVersion": "1.0",
3332 + "config": {
3333 + "seeds": [
3334 + "/en-us/category/used-cameras/medium-format-cameras",
3335 + "/en-us/category/used-cameras/mirrorless-cameras/fujifilm-mirrorless-cameras",
3336 + "/en-us/category/used-cameras/mirrorless-cameras/sony-e-mirrorless-cameras",
3337 + "/en-us/category/used-cameras/dslr-cameras/canon-dslr-cameras",
3338 + "/en-us/category/used-cameras/premium-compact-cameras",
3339 + "/en-us/category/used-photo-and-video-lenses"
3340 + ]
3341 + }
3342 + },
2244 3343 {
2245 3344 "id": "mtggoldfish",
2246 3345 "displayName": "MTGGoldfish (paper prices & history)",
@@ -2276,19 +3375,175 @@
2276 3375 "priority": "medium",
2277 3376 "trustScore": 0.75,
2278 3377 "attributionRequired": true,
2279 − "termsUrl": "https://www.mtggoldfish.com/robots.txt",
2280 − "accessNotes": "Public set pages (https://www.mtggoldfish.com/sets/<Set+Name>) embed a JSON payload with every printing (card_uuid, set code, collector number, finish, current paper/online price, images). robots.txt allows '/' for generic agents (only widgets/embeds are disallowed; Content-Signal ai-train=no is respected — data is used as market reference, not for training). Paper price = TCGplayer-derived market price → price_observation 'market' dated by fetch day (confidence 0.75). For cards above `history.minPrice` the public price-history component (/price_history_component, daily series since 2010) is fetched, capped per run → dated observations that give assets a real multi-year guide-price history. Politeness 1.5 s/page; ~340 sets.",
3378 + "termsUrl": "https://www.mtggoldfish.com/robots.txt",
3379 + "accessNotes": "Public set pages (https://www.mtggoldfish.com/sets/<Set+Name>) embed a JSON payload with every printing (card_uuid, set code, collector number, finish, current paper/online price, images). robots.txt allows '/' for generic agents (only widgets/embeds are disallowed; Content-Signal ai-train=no is respected — data is used as market reference, not for training). Paper price = TCGplayer-derived market price → price_observation 'market' dated by fetch day (confidence 0.75). For cards above `history.minPrice` the public price-history component (/price_history_component, daily series since 2010) is fetched, capped per run → dated observations that give assets a real multi-year guide-price history. Politeness 1.5 s/page; ~340 sets.",
3380 + "enabled": true,
3381 + "schemaVersion": "1.0",
3382 + "config": {
3383 + "seeds": [],
3384 + "maxSetsPerRun": 60,
3385 + "history": {
3386 + "enabled": true,
3387 + "minPrice": 100,
3388 + "maxPerRun": 150,
3389 + "days": 1095
3390 + }
3391 + }
3392 + },
3393 + {
3394 + "id": "mtgjson",
3395 + "displayName": "MTGJSON (prices & identifiers)",
3396 + "sourceId": "mtgjson",
3397 + "sourceName": "MTGJSON",
3398 + "sourceType": "pricing_guide",
3399 + "sourceUrl": "https://mtgjson.com",
3400 + "module": "api/mtgjson",
3401 + "enginePriority": [
3402 + "api"
3403 + ],
3404 + "categories": [
3405 + "magic_the_gathering"
3406 + ],
3407 + "regions": [
3408 + "US",
3409 + "EU"
3410 + ],
3411 + "languages": [
3412 + "en"
3413 + ],
3414 + "currency": [
3415 + "USD",
3416 + "EUR"
3417 + ],
3418 + "supportsListings": false,
3419 + "supportsSold": false,
3420 + "supportsAuctions": false,
3421 + "supportsImages": false,
3422 + "supportsCatalog": true,
3423 + "supportsPopulation": false,
3424 + "supportsLookup": false,
3425 + "refreshFrequencyMinutes": 1440,
3426 + "priority": "high",
3427 + "trustScore": 0.85,
3428 + "attributionRequired": true,
3429 + "termsUrl": "https://mtgjson.com/faq/",
3430 + "accessNotes": "Open MTG data project (free bulk files, no key). Incremental runs download SetList.json, the newest `maxSetsPerRun` set files (LEA.json… with per-card identifiers: scryfallId, tcgplayerProductId, cardKingdomId, mcmId, cardsphereId, multiverseId) and AllPricesToday.json.gz (~5 MB gz) which carries the latest daily retail/buylist price per printing from TCGplayer, Cardmarket (EUR), Card Kingdom, Cardsphere and ManaPool. Backfill streams AllPrices.json.gz (~150 MB gz, 90 days of daily history) entry by entry. Retail prices are stored as price_observations (priceKind 'market', metadata.provider), buylist prices only in metadata; every point is dated by MTGJSON's own date keys. Variant vocabulary mirrors the scryfall connector (Foil / Etched Foil + treatments) so identifier matches agree.",
3431 + "enabled": true,
3432 + "schemaVersion": "1.0",
3433 + "config": {
3434 + "maxSetsPerRun": 60,
3435 + "providers": [
3436 + "tcgplayer",
3437 + "cardmarket",
3438 + "cardkingdom",
3439 + "cardsphere",
3440 + "manapool"
3441 + ],
3442 + "historyDays": 90
3443 + }
3444 + },
3445 + {
3446 + "id": "myslabs",
3447 + "displayName": "MySlabs (graded card & comic marketplace)",
3448 + "sourceId": "myslabs",
3449 + "sourceName": "MySlabs",
3450 + "sourceType": "marketplace",
3451 + "sourceUrl": "https://myslabs.com",
3452 + "module": "api/myslabs",
3453 + "enginePriority": [
3454 + "api"
3455 + ],
3456 + "categories": [
3457 + "sports_cards",
3458 + "baseball_cards",
3459 + "basketball_cards",
3460 + "football_cards",
3461 + "hockey_cards",
3462 + "soccer_cards",
3463 + "other_sports_cards",
3464 + "pokemon",
3465 + "magic_the_gathering",
3466 + "yugioh",
3467 + "non_sport_cards",
3468 + "comics",
3469 + "marvel_comics",
3470 + "dc_comics",
3471 + "independent_comics"
3472 + ],
3473 + "regions": [
3474 + "US"
3475 + ],
3476 + "languages": [
3477 + "en"
3478 + ],
3479 + "currency": [
3480 + "USD"
3481 + ],
3482 + "supportsListings": true,
3483 + "supportsSold": false,
3484 + "supportsAuctions": false,
3485 + "supportsImages": true,
3486 + "supportsCatalog": false,
3487 + "supportsPopulation": false,
3488 + "supportsLookup": true,
3489 + "refreshFrequencyMinutes": 720,
3490 + "priority": "medium",
3491 + "trustScore": 0.7,
3492 + "attributionRequired": true,
3493 + "termsUrl": "https://myslabs.com/static/terms",
3494 + "accessNotes": "Public fixed-price marketplace for graded slabs. robots.txt only disallows /account/, /api/, /admin/, /ajax_select/. Discovery uses the public sitemaps (sitemap-slabs-*.xml, newest ids first); slab pages are plain HTML with a schema.org Product JSON-LD (name, images, offers.price USD, availability InStock/OutOfStock). OutOfStock slabs are stored as listings with availability 'sold' at their last asking price — never as confirmed sales. Politeness 1.5 s between pages; ~250 slabs per incremental run (`maxSlabsPerRun`).",
3495 + "enabled": true,
3496 + "schemaVersion": "1.0",
3497 + "config": {
3498 + "maxSlabsPerRun": 250,
3499 + "backfillSlabs": 2000
3500 + }
3501 + },
3502 + {
3503 + "id": "noble-knight",
3504 + "displayName": "Noble Knight Games (board games & miniatures dealer)",
3505 + "sourceId": "noble-knight",
3506 + "sourceName": "Noble Knight Games",
3507 + "sourceType": "dealer",
3508 + "sourceUrl": "https://www.nobleknight.com",
3509 + "module": "api/noble-knight",
3510 + "enginePriority": [
3511 + "api"
3512 + ],
3513 + "categories": [
3514 + "board_games",
3515 + "warhammer",
3516 + "gundam",
3517 + "action_figures",
3518 + "funko"
3519 + ],
3520 + "regions": [
3521 + "US"
3522 + ],
3523 + "languages": [
3524 + "en"
3525 + ],
3526 + "currency": [
3527 + "USD"
3528 + ],
3529 + "supportsListings": true,
3530 + "supportsSold": false,
3531 + "supportsAuctions": false,
3532 + "supportsImages": true,
3533 + "supportsCatalog": true,
3534 + "supportsPopulation": false,
3535 + "supportsLookup": true,
3536 + "refreshFrequencyMinutes": 1440,
3537 + "priority": "low",
3538 + "trustScore": 0.7,
3539 + "attributionRequired": true,
3540 + "termsUrl": "https://www.nobleknight.com/Terms",
3541 + "accessNotes": "Plain HTTPS. robots.txt disallows the client-rendered category/search listings (/Catalog, /category-search, /AdvancedSearch…) but allows product pages (/P/<id>/<slug>) and the sitemaps (sitemapproducts1-5.xml ≈ 120k products), so products are enumerated from the sitemaps with a rotating cursor and read from each page's schema.org Product JSON-LD (name, sku, mpn, brand, price, itemCondition, availability) plus the Publisher / Product Line / Category / Genre lines. Only board games, Warhammer/Games Workshop miniatures, Gunpla and figure products are kept (RPG books, CCG singles and supplies are skipped). Condition mapped to the boxed_toys scale. 1.5 s politeness, 0 credits, 200 products per run.",
2281 3542 "enabled": true,
2282 3543 "schemaVersion": "1.0",
2283 3544 "config": {
2284 − "seeds": [],
2285 − "maxSetsPerRun": 60,
2286 − "history": {
2287 − "enabled": true,
2288 − "minPrice": 100,
2289 − "maxPerRun": 150,
2290 − "days": 1095
2291 − }
3545 + "sitemapCount": 5,
3546 + "productsPerRun": 200
2292 3547 }
2293 3548 },
2294 3549 {
@@ -2353,6 +3608,292 @@
2353 3608 "pagesPerSeed": 3
2354 3609 }
2355 3610 },
3611 + {
3612 + "id": "openlibrary",
3613 + "displayName": "Open Library (bibliographic catalog — collectible first editions)",
3614 + "sourceId": "openlibrary",
3615 + "sourceName": "Open Library (Internet Archive)",
3616 + "sourceType": "catalog",
3617 + "sourceUrl": "https://openlibrary.org",
3618 + "module": "api/openlibrary",
3619 + "enginePriority": [
3620 + "api"
3621 + ],
3622 + "categories": [
3623 + "books",
3624 + "harry_potter",
3625 + "lord_of_the_rings"
3626 + ],
3627 + "regions": [
3628 + "GB",
3629 + "US"
3630 + ],
3631 + "languages": [
3632 + "en"
3633 + ],
3634 + "currency": [
3635 + "USD"
3636 + ],
3637 + "supportsListings": false,
3638 + "supportsSold": false,
3639 + "supportsAuctions": false,
3640 + "supportsImages": true,
3641 + "supportsCatalog": true,
3642 + "supportsPopulation": false,
3643 + "supportsLookup": false,
3644 + "refreshFrequencyMinutes": 10080,
3645 + "priority": "low",
3646 + "trustScore": 0.9,
3647 + "attributionRequired": true,
3648 + "termsUrl": "https://openlibrary.org/developers/api",
3649 + "accessNotes": "Open Library's JSON endpoints /isbn/<isbn>.json, /works/<id>.json and /authors/<id>.json (allowed by robots.txt; /search and /api are disallowed and not used) with a descriptive user agent and 1 request/second, as their API terms ask. Seeds are ISBNs of collectible modern first editions/first printings (config.seeds with a note); each yields one catalog item (title, author, publisher, place, year, edition name, pages, cover) with identifiers isbn + openlibrary_id so dealer/auction book records can resolve against it. No prices come from this source.",
3650 + "enabled": true,
3651 + "schemaVersion": "1.0",
3652 + "config": {
3653 + "seeds": [
3654 + {
3655 + "isbn": "0747532699",
3656 + "note": "Harry Potter and the Philosopher's Stone, Bloomsbury 1997 first edition"
3657 + },
3658 + {
3659 + "isbn": "0747538492",
3660 + "note": "Harry Potter and the Chamber of Secrets, Bloomsbury 1998 first edition"
3661 + },
3662 + {
3663 + "isbn": "0747542155",
3664 + "note": "Harry Potter and the Prisoner of Azkaban, Bloomsbury 1999 first edition"
3665 + },
3666 + {
3667 + "isbn": "074754624X",
3668 + "note": "Harry Potter and the Goblet of Fire, Bloomsbury 2000 first edition"
3669 + },
3670 + {
3671 + "isbn": "0590353403",
3672 + "note": "Harry Potter and the Sorcerer's Stone, Scholastic 1998 first US edition"
3673 + },
3674 + {
3675 + "isbn": "0395489318",
3676 + "note": "The Lord of the Rings, Houghton Mifflin 1987 one-volume edition"
3677 + },
3678 + {
3679 + "isbn": "0048231878",
3680 + "note": "The Hobbit, Allen & Unwin 1978 fourth edition"
3681 + },
3682 + {
3683 + "isbn": "0553380168",
3684 + "note": "A Brief History of Time, Bantam 1998"
3685 + },
3686 + {
3687 + "isbn": "0553103547",
3688 + "note": "A Game of Thrones, Bantam 1996 first edition"
3689 + },
3690 + {
3691 + "isbn": "0553108034",
3692 + "note": "A Clash of Kings, Bantam 1999 first US edition"
3693 + },
3694 + {
3695 + "isbn": "0670813028",
3696 + "note": "It, Viking 1986 first edition (Stephen King)"
3697 + },
3698 + {
3699 + "isbn": "0385121679",
3700 + "note": "The Shining, Doubleday 1977 first edition"
3701 + },
3702 + {
3703 + "isbn": "0385086954",
3704 + "note": "Carrie, Doubleday 1974 first edition"
3705 + },
3706 + {
3707 + "isbn": "0394800168",
3708 + "note": "The Cat in the Hat, Random House"
3709 + },
3710 + {
3711 + "isbn": "0060256656",
3712 + "note": "Where the Wild Things Are, Harper & Row"
3713 + },
3714 + {
3715 + "isbn": "0316769487",
3716 + "note": "The Catcher in the Rye, Little Brown"
3717 + },
3718 + {
3719 + "isbn": "0451524934",
3720 + "note": "1984 (Signet)"
3721 + },
3722 + {
3723 + "isbn": "0743273567",
3724 + "note": "The Great Gatsby, Scribner"
3725 + },
3726 + {
3727 + "isbn": "0446310786",
3728 + "note": "To Kill a Mockingbird, Warner Books"
3729 + },
3730 + {
3731 + "isbn": "0345339681",
3732 + "note": "The Hobbit, Ballantine"
3733 + },
3734 + {
3735 + "isbn": "0441172717",
3736 + "note": "Dune, Ace"
3737 + },
3738 + {
3739 + "isbn": "0345391802",
3740 + "note": "The Hitchhiker's Guide to the Galaxy, Del Rey"
3741 + },
3742 + {
3743 + "isbn": "0399501487",
3744 + "note": "Lord of the Flies, Perigee"
3745 + },
3746 + {
3747 + "isbn": "0679783261",
3748 + "note": "Pride and Prejudice, Modern Library"
3749 + },
3750 + {
3751 + "isbn": "0553296981",
3752 + "note": "Anne Frank: The Diary of a Young Girl, Bantam"
3753 + },
3754 + {
3755 + "isbn": "0064400557",
3756 + "note": "Charlotte's Web, HarperTrophy"
3757 + },
3758 + {
3759 + "isbn": "0140177396",
3760 + "note": "Of Mice and Men, Penguin"
3761 + },
3762 + {
3763 + "isbn": "0743297334",
3764 + "note": "The Old Man and the Sea, Scribner"
3765 + },
3766 + {
3767 + "isbn": "0618260307",
3768 + "note": "The Hobbit, Houghton Mifflin 2001"
3769 + },
3770 + {
3771 + "isbn": "0618002227",
3772 + "note": "The Fellowship of the Ring, Houghton Mifflin 1999"
3773 + },
3774 + {
3775 + "isbn": "0439139597",
3776 + "note": "Harry Potter and the Goblet of Fire, Scholastic 2000 first US edition"
3777 + },
3778 + {
3779 + "isbn": "043935806X",
3780 + "note": "Harry Potter and the Order of the Phoenix, Scholastic 2003 first US edition"
3781 + },
3782 + {
3783 + "isbn": "0439784549",
3784 + "note": "Harry Potter and the Half-Blood Prince, Scholastic 2005 first US edition"
3785 + },
3786 + {
3787 + "isbn": "0545010225",
3788 + "note": "Harry Potter and the Deathly Hallows, Scholastic 2007 first US edition"
3789 + },
3790 + {
3791 + "isbn": "0747551006",
3792 + "note": "Harry Potter and the Order of the Phoenix, Bloomsbury 2003 first edition"
3793 + },
3794 + {
3795 + "isbn": "0747581088",
3796 + "note": "Harry Potter and the Half-Blood Prince, Bloomsbury 2005 first edition"
3797 + },
3798 + {
3799 + "isbn": "0747591059",
3800 + "note": "Harry Potter and the Deathly Hallows, Bloomsbury 2007 first edition"
3801 + },
3802 + {
3803 + "isbn": "0399226907",
3804 + "note": "The Very Hungry Caterpillar, Philomel"
3805 + },
3806 + {
3807 + "isbn": "0394900014",
3808 + "note": "Green Eggs and Ham, Random House"
3809 + },
3810 + {
3811 + "isbn": "0060935464",
3812 + "note": "To Kill a Mockingbird, Perennial Classics"
3813 + },
3814 + {
3815 + "isbn": "0192833553",
3816 + "note": "Frankenstein, Oxford"
3817 + },
3818 + {
3819 + "isbn": "0553213113",
3820 + "note": "Dracula, Bantam"
3821 + },
3822 + {
3823 + "isbn": "0451526341",
3824 + "note": "Animal Farm, Signet"
3825 + },
3826 + {
3827 + "isbn": "0060850523",
3828 + "note": "Brave New World, Harper Perennial"
3829 + },
3830 + {
3831 + "isbn": "0393975959",
3832 + "note": "Heart of Darkness, Norton"
3833 + },
3834 + {
3835 + "isbn": "0140283331",
3836 + "note": "The Grapes of Wrath, Penguin"
3837 + },
3838 + {
3839 + "isbn": "0316346624",
3840 + "note": "Infinite Jest, Little Brown 1996 first edition"
3841 + },
3842 + {
3843 + "isbn": "0394758285",
3844 + "note": "Beloved, Knopf 1987 first edition"
3845 + },
3846 + {
3847 + "isbn": "0394587146",
3848 + "note": "American Psycho, Vintage 1991"
3849 + },
3850 + {
3851 + "isbn": "0679720200",
3852 + "note": "The Stranger, Vintage"
3853 + },
3854 + {
3855 + "isbn": "0061120081",
3856 + "note": "To Kill a Mockingbird, Harper 50th anniversary"
3857 + },
3858 + {
3859 + "isbn": "0385333846",
3860 + "note": "Slaughterhouse-Five, Dial"
3861 + },
3862 + {
3863 + "isbn": "0684801221",
3864 + "note": "The Sun Also Rises, Scribner"
3865 + },
3866 + {
3867 + "isbn": "0394429575",
3868 + "note": "Gravity's Rainbow, Viking 1973 first edition"
3869 + },
3870 + {
3871 + "isbn": "0385490816",
3872 + "note": "Fight Club, Norton 1996 first edition"
3873 + },
3874 + {
3875 + "isbn": "0399137580",
3876 + "note": "Jurassic Park, Knopf 1990 first edition"
3877 + },
3878 + {
3879 + "isbn": "0394549937",
3880 + "note": "The Silence of the Lambs, St. Martin's 1988 first edition"
3881 + },
3882 + {
3883 + "isbn": "0553380958",
3884 + "note": "Neuromancer, Ace"
3885 + },
3886 + {
3887 + "isbn": "0345342968",
3888 + "note": "Fahrenheit 451, Ballantine"
3889 + },
3890 + {
3891 + "isbn": "0525947647",
3892 + "note": "The Road, Knopf 2006 first edition"
3893 + }
3894 + ]
3895 + }
3896 + },
2356 3897 {
2357 3898 "id": "optcg",
2358 3899 "displayName": "OPTCG API (One Piece Card Game)",
@@ -2445,6 +3986,55 @@
2445 3986 "catalogPagesPerAuction": 5
2446 3987 }
2447 3988 },
3989 + {
3990 + "id": "pcarmarket",
3991 + "displayName": "PCARMARKET (sold auctions)",
3992 + "sourceId": "pcarmarket",
3993 + "sourceName": "PCARMARKET",
3994 + "sourceType": "auction_house",
3995 + "sourceUrl": "https://www.pcarmarket.com",
3996 + "module": "api/pcarmarket",
3997 + "enginePriority": [
3998 + "api"
3999 + ],
4000 + "categories": [
4001 + "automobiles",
4002 + "motorcycles",
4003 + "automotive_memorabilia",
4004 + "rolex",
4005 + "omega",
4006 + "other_watches"
4007 + ],
4008 + "regions": [
4009 + "US",
4010 + "CA"
4011 + ],
4012 + "languages": [
4013 + "en"
4014 + ],
4015 + "currency": [
4016 + "USD"
4017 + ],
4018 + "supportsListings": false,
4019 + "supportsSold": true,
4020 + "supportsAuctions": false,
4021 + "supportsImages": true,
4022 + "supportsCatalog": false,
4023 + "supportsPopulation": false,
4024 + "supportsLookup": false,
4025 + "refreshFrequencyMinutes": 360,
4026 + "priority": "high",
4027 + "trustScore": 0.85,
4028 + "attributionRequired": true,
4029 + "termsUrl": "https://www.pcarmarket.com/terms/",
4030 + "accessNotes": "PCARMARKET's public results page (/results/) is fed by the same JSON endpoint we read: GET /api/auctions/?status=sold&type=all&sort_by=ending_soon&limit=50&page=N (plain HTTPS, 0 credits; robots.txt: 'User-agent: * Allow: /'). Each item has title, vehicle {make, model, year}, high_bid (USD winning bid), end_date, status 'Sold', country, images. Price = winning bid; PCARMARKET charges the buyer a separate 5% fee, so buyer_premium_included=false. Items with a vehicle object are cars/motorcycles; other lots are classified from the title (watches by brand keyword → watch slugs, everything else → automotive_memorabilia: signs, models, parts, helmets). Incremental runs stop at the newest end_date seen previously; backfill mode walks older pages (≈7,900 sold lots as of 2026-09).",
4031 + "enabled": true,
4032 + "schemaVersion": "1.0",
4033 + "config": {
4034 + "pagesPerRun": 10,
4035 + "limit": 50
4036 + }
4037 + },
2448 4038 {
2449 4039 "id": "pcgs-priceguide",
2450 4040 "displayName": "PCGS Price Guide (US coins)",
@@ -2712,35 +4302,95 @@
2712 4302 "pokemon"
2713 4303 ],
2714 4304 "regions": [
2715 − "US",
2716 − "EU"
4305 + "US",
4306 + "EU"
4307 + ],
4308 + "languages": [
4309 + "en"
4310 + ],
4311 + "currency": [
4312 + "USD",
4313 + "EUR"
4314 + ],
4315 + "supportsListings": false,
4316 + "supportsSold": false,
4317 + "supportsAuctions": false,
4318 + "supportsImages": true,
4319 + "supportsCatalog": true,
4320 + "supportsPopulation": false,
4321 + "supportsLookup": false,
4322 + "refreshFrequencyMinutes": 1440,
4323 + "priority": "high",
4324 + "trustScore": 0.8,
4325 + "attributionRequired": true,
4326 + "termsUrl": "https://docs.pokemontcg.io/",
4327 + "accessNotes": "Public REST API v2 (https://api.pokemontcg.io/v2). Optional X-Api-Key (POKEMONTCG_API_KEY) raises limits (20k/day) — without a key ~1k/day and 30/min. Crawl = sets, then cards per set (pageSize 250, cursor {setIndex,page}). The API is intermittently unavailable (5xx); the connector retries with backoff and falls back to the maintainers' public GitHub mirror (PokemonTCG/pokemon-tcg-data, same card schema, no prices) for the catalog when the API keeps failing. Prices are TCGplayer (USD, per printing variant: normal/holofoil/reverseHolofoil/1stEdition…) and Cardmarket (EUR) daily aggregates with their own updatedAt → stored as price_observations, never as sales. Pokémon © Nintendo/Creatures/GAME FREAK; data attributed to pokemontcg.io.",
4328 + "enabled": true,
4329 + "schemaVersion": "1.0",
4330 + "config": {
4331 + "pageSize": 250,
4332 + "requestIntervalMs": 2100,
4333 + "mirrorBase": "https://raw.githubusercontent.com/PokemonTCG/pokemon-tcg-data/master"
4334 + }
4335 + },
4336 + {
4337 + "id": "potter-auctions",
4338 + "displayName": "Potter & Potter Auctions (prices realized)",
4339 + "sourceId": "potter-auctions",
4340 + "sourceName": "Potter & Potter Auctions",
4341 + "sourceType": "auction_house",
4342 + "sourceUrl": "https://auction.potterauctions.com",
4343 + "module": "api/potter-auctions",
4344 + "enginePriority": [
4345 + "api",
4346 + "firecrawl"
4347 + ],
4348 + "categories": [
4349 + "playing_cards",
4350 + "casino_memorabilia",
4351 + "advertising",
4352 + "movie_posters",
4353 + "movie_memorabilia",
4354 + "books",
4355 + "photography",
4356 + "art",
4357 + "contemporary_art",
4358 + "vending_machines",
4359 + "vintage_toys",
4360 + "antiques",
4361 + "historical_documents",
4362 + "autographs",
4363 + "music_memorabilia",
4364 + "sports_memorabilia"
4365 + ],
4366 + "regions": [
4367 + "US"
2717 4368 ],
2718 4369 "languages": [
2719 4370 "en"
2720 4371 ],
2721 4372 "currency": [
2722 − "USD",
2723 − "EUR"
4373 + "USD"
2724 4374 ],
2725 4375 "supportsListings": false,
2726 − "supportsSold": false,
4376 + "supportsSold": true,
2727 4377 "supportsAuctions": false,
2728 4378 "supportsImages": true,
2729 − "supportsCatalog": true,
4379 + "supportsCatalog": false,
2730 4380 "supportsPopulation": false,
2731 4381 "supportsLookup": false,
2732 − "refreshFrequencyMinutes": 1440,
2733 − "priority": "high",
2734 − "trustScore": 0.8,
4382 + "refreshFrequencyMinutes": 720,
4383 + "priority": "medium",
4384 + "trustScore": 0.9,
2735 4385 "attributionRequired": true,
2736 − "termsUrl": "https://docs.pokemontcg.io/",
2737 − "accessNotes": "Public REST API v2 (https://api.pokemontcg.io/v2). Optional X-Api-Key (POKEMONTCG_API_KEY) raises limits (20k/day) — without a key ~1k/day and 30/min. Crawl = sets, then cards per set (pageSize 250, cursor {setIndex,page}). The API is intermittently unavailable (5xx); the connector retries with backoff and falls back to the maintainers' public GitHub mirror (PokemonTCG/pokemon-tcg-data, same card schema, no prices) for the catalog when the API keeps failing. Prices are TCGplayer (USD, per printing variant: normal/holofoil/reverseHolofoil/1stEdition…) and Cardmarket (EUR) daily aggregates with their own updatedAt → stored as price_observations, never as sales. Pokémon © Nintendo/Creatures/GAME FREAK; data attributed to pokemontcg.io.",
4386 + "termsUrl": "https://auction.potterauctions.com/terms-conditions",
4387 + "accessNotes": "Plain HTTPS, 0 credits. robots.txt only disallows /s3cr3t/. Sources: the public past-auction list (auction.potterauctions.com/auctions/past?page=N — event id, title, start date) and each closed auction's catalog pages (/auctions/potter-potter/<slug>-<id>/catalog?page=N, ~47 lots/page, server-rendered on the Bidsquare platform) whose lot cards show 'Sold for $X' (winning bid = hammer, buyer's premium NOT included → buyer_premium_included=false), estimate, bid count, image and lot URL; unsold/passed lots are skipped. Sale date = the auction's published start date. Categories from the auction title (department) plus lot-title keywords; Potter's core magicana/conjuring apparatus falls back to 'antiques' with metadata.department. 2 s politeness delay.",
2738 4388 "enabled": true,
2739 4389 "schemaVersion": "1.0",
2740 4390 "config": {
2741 − "pageSize": 250,
2742 − "requestIntervalMs": 2100,
2743 − "mirrorBase": "https://raw.githubusercontent.com/PokemonTCG/pokemon-tcg-data/master"
4391 + "auctionsPerRun": 2,
4392 + "pagesPerAuction": 12,
4393 + "listPagesPerRun": 1
2744 4394 }
2745 4395 },
2746 4396 {
@@ -2957,6 +4607,108 @@
2957 4607 "pagesPerSeed": 2
2958 4608 }
2959 4609 },
4610 + {
4611 + "id": "reverb",
4612 + "displayName": "Reverb (musical instruments & hi-fi marketplace)",
4613 + "sourceId": "reverb",
4614 + "sourceName": "Reverb",
4615 + "sourceType": "marketplace",
4616 + "sourceUrl": "https://reverb.com",
4617 + "module": "api/reverb",
4618 + "enginePriority": [
4619 + "api"
4620 + ],
4621 + "categories": [
4622 + "musical_instruments",
4623 + "audio_equipment"
4624 + ],
4625 + "regions": [
4626 + "US",
4627 + "EU",
4628 + "GB",
4629 + "CA",
4630 + "AU",
4631 + "JP"
4632 + ],
4633 + "languages": [
4634 + "en"
4635 + ],
4636 + "currency": [
4637 + "USD"
4638 + ],
4639 + "supportsListings": true,
4640 + "supportsSold": false,
4641 + "supportsAuctions": false,
4642 + "supportsImages": true,
4643 + "supportsCatalog": false,
4644 + "supportsPopulation": false,
4645 + "supportsLookup": true,
4646 + "refreshFrequencyMinutes": 720,
4647 + "priority": "medium",
4648 + "trustScore": 0.8,
4649 + "attributionRequired": true,
4650 + "termsUrl": "https://reverb.com/page/terms",
4651 + "accessNotes": "Read-only public listings API (api.reverb.com/api/listings, HAL JSON with Accept-Version 3.0) — no token is required for listing search; the price-guide endpoint is no longer public and sold data needs an authenticated app, so only live asking prices are collected. robots.txt allows /api/listings (only /api/my and per-listing upsell helpers are disallowed). Reverb returns prices converted to USD; the original listing currency is stored in metadata and the confidence is lowered when a conversion happened. Seeds are collectible-model queries (vintage Fender/Gibson/Martin, synths, pedals, hi-fi). 1.2 s between requests; one query page = 50 listings.",
4652 + "enabled": true,
4653 + "schemaVersion": "1.0",
4654 + "config": {
4655 + "perPage": 50,
4656 + "pagesPerQuery": 2,
4657 + "backfillPages": 6,
4658 + "queries": [
4659 + "fender stratocaster 1960s",
4660 + "fender telecaster 1950s",
4661 + "gibson les paul standard 1959",
4662 + "gibson les paul custom 1960s",
4663 + "gibson sg 1960s",
4664 + "gibson es-335 1960s",
4665 + "gibson flying v",
4666 + "fender jazzmaster 1960s",
4667 + "fender precision bass 1960s",
4668 + "fender jazz bass 1960s",
4669 + "rickenbacker 4001",
4670 + "rickenbacker 360",
4671 + "gretsch 6120",
4672 + "martin d-28 1960s",
4673 + "martin d-45",
4674 + "gibson j-45 1950s",
4675 + "gibson hummingbird vintage",
4676 + "prs private stock",
4677 + "moog minimoog model d",
4678 + "roland tr-808",
4679 + "roland tb-303",
4680 + "roland jupiter-8",
4681 + "sequential prophet-5",
4682 + "yamaha cs-80",
4683 + "oberheim ob-x",
4684 + "korg ms-20 vintage",
4685 + "arp 2600",
4686 + "hammond b3",
4687 + "fender rhodes mark i",
4688 + "wurlitzer 200a",
4689 + "klon centaur",
4690 + "dumble overdrive special",
4691 + "marshall plexi 1968",
4692 + "fender tweed bassman 1959",
4693 + "vox ac30 1960s",
4694 + "mesa boogie mark i",
4695 + "neumann u47",
4696 + "neumann u67",
4697 + "ludwig black beauty vintage",
4698 + "gibson mandolin f-5 loar",
4699 + "mcintosh mc275",
4700 + "marantz 2270",
4701 + "technics sl-1200 mk2",
4702 + "nakamichi dragon",
4703 + "jbl l100",
4704 + "klipschorn",
4705 + "linn lp12",
4706 + "tannoy monitor gold",
4707 + "sansui 9090db",
4708 + "revox b77"
4709 + ]
4710 + }
4711 + },
2960 4712 {
2961 4713 "id": "rm-sothebys",
2962 4714 "displayName": "RM Sotheby's (results)",
@@ -3184,6 +4936,58 @@
3184 4936 "pagesPerRun": 12
3185 4937 }
3186 4938 },
4939 + {
4940 + "id": "scp-auctions",
4941 + "displayName": "SCP Auctions (results & catalogs)",
4942 + "sourceId": "scp-auctions",
4943 + "sourceName": "SCP Auctions",
4944 + "sourceType": "auction_house",
4945 + "sourceUrl": "https://catalogs.scpauctions.com",
4946 + "module": "api/scp-auctions",
4947 + "enginePriority": [
4948 + "api"
4949 + ],
4950 + "categories": [
4951 + "sports_memorabilia",
4952 + "baseball_cards",
4953 + "basketball_cards",
4954 + "football_cards",
4955 + "hockey_cards",
4956 + "soccer_cards",
4957 + "other_sports_cards",
4958 + "olympic_collectibles",
4959 + "pokemon"
4960 + ],
4961 + "regions": [
4962 + "US"
4963 + ],
4964 + "languages": [
4965 + "en"
4966 + ],
4967 + "currency": [
4968 + "USD"
4969 + ],
4970 + "supportsListings": false,
4971 + "supportsSold": true,
4972 + "supportsAuctions": true,
4973 + "supportsImages": true,
4974 + "supportsCatalog": false,
4975 + "supportsPopulation": false,
4976 + "supportsLookup": false,
4977 + "refreshFrequencyMinutes": 720,
4978 + "priority": "medium",
4979 + "trustScore": 0.9,
4980 + "attributionRequired": true,
4981 + "termsUrl": "https://catalogs.scpauctions.com/terms",
4982 + "accessNotes": "Plain HTTPS on the public Bidsquare-hosted catalog (robots.txt: only /wp-admin/ disallowed, Crawl-delay 10 → 2 s politeness plus small runs). Past auctions are listed at /auctions/past?page=N; each catalog page (/auctions/scp-auctions-inc/<slug>-<id>/catalog?page=N, 48 lots) shows 'Sold for $X' and the bid count; the event start/end come from the page's schema.org Event JSON-LD (sale date = event end, or the lot's own countdown end). Upcoming catalogs yield auction lots (current/starting bid, estimate). SCP does not state on these pages whether 'Sold for' includes the 20% buyer's premium → buyer_premium_included=null. No login, no bidding endpoints. 0 credits.",
4983 + "enabled": true,
4984 + "schemaVersion": "1.0",
4985 + "config": {
4986 + "eventsPerRun": 2,
4987 + "pagesPerEvent": 8,
4988 + "includeUpcoming": true
4989 + }
4990 + },
3187 4991 {
3188 4992 "id": "scryfall",
3189 4993 "displayName": "Scryfall (Magic: The Gathering)",
@@ -3679,6 +5483,69 @@
3679 5483 }
3680 5484 }
3681 5485 },
5486 + {
5487 + "id": "tag-pop",
5488 + "displayName": "TAG Grading population report",
5489 + "sourceId": "tag",
5490 + "sourceName": "TAG Grading",
5491 + "sourceType": "grading_company",
5492 + "sourceUrl": "https://my.taggrading.com/pop-report",
5493 + "module": "firecrawl/tag-pop",
5494 + "enginePriority": [
5495 + "firecrawl"
5496 + ],
5497 + "categories": [
5498 + "pokemon",
5499 + "magic_the_gathering",
5500 + "yugioh",
5501 + "disney_lorcana",
5502 + "one_piece_card_game",
5503 + "baseball_cards",
5504 + "basketball_cards",
5505 + "football_cards",
5506 + "hockey_cards",
5507 + "soccer_cards",
5508 + "other_sports_cards",
5509 + "non_sport_cards"
5510 + ],
5511 + "regions": [
5512 + "US"
5513 + ],
5514 + "languages": [
5515 + "en"
5516 + ],
5517 + "currency": [
5518 + "USD"
5519 + ],
5520 + "supportsListings": false,
5521 + "supportsSold": false,
5522 + "supportsAuctions": false,
5523 + "supportsImages": false,
5524 + "supportsCatalog": false,
5525 + "supportsPopulation": true,
5526 + "supportsLookup": false,
5527 + "refreshFrequencyMinutes": 10080,
5528 + "priority": "low",
5529 + "trustScore": 0.95,
5530 + "attributionRequired": true,
5531 + "termsUrl": "https://www.taggrading.com/terms",
5532 + "accessNotes": "TAG's population report is public (robots.txt explicitly allows /pop-report/ and /card/). The pages are a client-rendered app, so set-level pages are rendered with Firecrawl (1 credit per set page, waitFor 8 s) and the markdown table is parsed: one row per card/variation with counts per grade (VA, 1–10, 10P = Pristine) and a total. Discovery is free through the public sitemap (pop.xml, ~26k URLs; set pages carry ?setName=). Category seeds and `maxPagesPerRun` bound the weekly cost. Report date = fetch day (the report is live).",
5533 + "enabled": true,
5534 + "schemaVersion": "1.0",
5535 + "config": {
5536 + "seeds": [
5537 + "Pokemon",
5538 + "Basketball",
5539 + "Baseball",
5540 + "Football",
5541 + "Hockey",
5542 + "Soccer",
5543 + "Magic",
5544 + "Yu-Gi-Oh"
5545 + ],
5546 + "maxPagesPerRun": 40
5547 + }
5548 + },
3682 5549 {
3683 5550 "id": "tcgcsv",
3684 5551 "displayName": "TCGCSV (TCGplayer catalog & prices)",
@@ -3922,6 +5789,115 @@
3922 5789 ]
3923 5790 }
3924 5791 },
5792 + {
5793 + "id": "the-market-bonhams",
5794 + "displayName": "The Market by Bonhams (results)",
5795 + "sourceId": "the-market-bonhams",
5796 + "sourceName": "The Market by Bonhams",
5797 + "sourceType": "auction_house",
5798 + "sourceUrl": "https://www.themarket.co.uk",
5799 + "module": "api/the-market-bonhams",
5800 + "enginePriority": [
5801 + "api"
5802 + ],
5803 + "categories": [
5804 + "automobiles",
5805 + "motorcycles"
5806 + ],
5807 + "regions": [
5808 + "GB",
5809 + "EU",
5810 + "AU"
5811 + ],
5812 + "languages": [
5813 + "en"
5814 + ],
5815 + "currency": [
5816 + "GBP",
5817 + "EUR",
5818 + "AUD",
5819 + "USD"
5820 + ],
5821 + "supportsListings": false,
5822 + "supportsSold": true,
5823 + "supportsAuctions": false,
5824 + "supportsImages": true,
5825 + "supportsCatalog": false,
5826 + "supportsPopulation": false,
5827 + "supportsLookup": false,
5828 + "refreshFrequencyMinutes": 720,
5829 + "priority": "medium",
5830 + "trustScore": 0.85,
5831 + "attributionRequired": true,
5832 + "termsUrl": "https://www.themarket.co.uk/terms-and-conditions",
5833 + "accessNotes": "The Market by Bonhams (online collector-car auctions, UK/EU/AU) publishes results server-side at /auctions/results?page=N (18 cards per page, ≈330 pages back to 2019; plain HTTPS with the RareIndex user agent; robots.txt 'User-agent: * Allow: /'). Each card: title, 'Sold for £56,000 on 17 Aug 2026' (native currency symbol £/€/A$), listing URL, bid count, location. Price = winning bid; The Market charges the buyer a separate fee, so buyer_premium_included=false. Sale date = the date printed on the card. Incremental runs stop once cards older than the newest previously seen date appear; backfill walks older pages. 1.5 s politeness.",
5834 + "enabled": true,
5835 + "schemaVersion": "1.0",
5836 + "config": {
5837 + "pagesPerRun": 8
5838 + }
5839 + },
5840 + {
5841 + "id": "trainz",
5842 + "displayName": "Trainz (model train dealer)",
5843 + "sourceId": "trainz",
5844 + "sourceName": "Trainz.com",
5845 + "sourceType": "dealer",
5846 + "sourceUrl": "https://www.trainz.com",
5847 + "module": "api/trainz",
5848 + "enginePriority": [
5849 + "api"
5850 + ],
5851 + "categories": [
5852 + "model_trains"
5853 + ],
5854 + "regions": [
5855 + "US"
5856 + ],
5857 + "languages": [
5858 + "en"
5859 + ],
5860 + "currency": [
5861 + "USD"
5862 + ],
5863 + "supportsListings": true,
5864 + "supportsSold": false,
5865 + "supportsAuctions": false,
5866 + "supportsImages": true,
5867 + "supportsCatalog": true,
5868 + "supportsPopulation": false,
5869 + "supportsLookup": false,
5870 + "refreshFrequencyMinutes": 1440,
5871 + "priority": "low",
5872 + "trustScore": 0.7,
5873 + "attributionRequired": true,
5874 + "termsUrl": "https://www.trainz.com/pages/terms-of-service",
5875 + "accessNotes": "Public Shopify JSON feed (/collections/<handle>/products.json?limit=250&page=N; robots.txt allows) — the same data the storefront renders. Each product gives title (with Trainz's condition code suffix such as LN/Box, EX/Box), vendor, tags (condition:, class:, era:, Inventory Type2_), variant price/SKU/availability and images. Emits a catalog item (brand = vendor, scale/gauge when present) and a fixed-price dealer listing with the condition mapped to the boxed_toys scale. 1.5 s politeness, 0 credits; collections rotate through the configured seed list each run.",
5876 + "enabled": true,
5877 + "schemaVersion": "1.0",
5878 + "config": {
5879 + "collectionsPerRun": 4,
5880 + "pagesPerCollection": 2,
5881 + "collections": [
5882 + "lionel-o-postwar-trains",
5883 + "lionel-o-prewar-trains",
5884 + "lionel-standard-gauge-trains",
5885 + "american-flyer-postwar-trains",
5886 + "american-flyer-s-gauge",
5887 + "marklin-trains",
5888 + "mth-o-gauge-trains",
5889 + "brass-model-trains",
5890 + "lgb-trains",
5891 + "k-line",
5892 + "williams",
5893 + "kato-trains",
5894 + "bachmann-trains",
5895 + "atlas-o-gauge",
5896 + "lionel-o-gauge-passenger-cars",
5897 + "lionel-o-gauge-steam-locomotives"
5898 + ]
5899 + }
5900 + },
3925 5901 {
3926 5902 "id": "watchfinder",
3927 5903 "displayName": "Watchfinder & Co. (UK pre-owned watch listings)",
@@ -3988,6 +5964,52 @@
3988 5964 "pagesPerSeed": 2
3989 5965 }
3990 5966 },
5967 + {
5968 + "id": "whisky-hammer",
5969 + "displayName": "Whisky Hammer (previous auctions)",
5970 + "sourceId": "whisky-hammer",
5971 + "sourceName": "Whisky Hammer",
5972 + "sourceType": "auction_house",
5973 + "sourceUrl": "https://www.whiskyhammer.com",
5974 + "module": "firecrawl/whisky-hammer",
5975 + "enginePriority": [
5976 + "firecrawl"
5977 + ],
5978 + "categories": [
5979 + "whisky",
5980 + "rum",
5981 + "cognac"
5982 + ],
5983 + "regions": [
5984 + "GB",
5985 + "NL"
5986 + ],
5987 + "languages": [
5988 + "en"
5989 + ],
5990 + "currency": [
5991 + "GBP"
5992 + ],
5993 + "supportsListings": false,
5994 + "supportsSold": true,
5995 + "supportsAuctions": false,
5996 + "supportsImages": true,
5997 + "supportsCatalog": false,
5998 + "supportsPopulation": false,
5999 + "supportsLookup": false,
6000 + "refreshFrequencyMinutes": 1440,
6001 + "priority": "medium",
6002 + "trustScore": 0.9,
6003 + "attributionRequired": true,
6004 + "termsUrl": "https://www.whiskyhammer.com/buying-and-selling/terms-conditions",
6005 + "accessNotes": "Whisky Hammer (Aberdeenshire, monthly auctions, UK + EU warehouses) lists previous auctions at /previous-auctions (plain HTTPS) but the per-auction lot pages (/auction/past/auc-<n>/?page=N, 50 lots per page sorted high→low bid) sit behind a Cloudflare interstitial for plain clients, so they are fetched with Firecrawl (1 credit per page, ≈88 pages for a 4,300-lot auction; capped by config.pagesPerRun). Each lot block: title + /item/<id>/ link, 'Lot #<id>', and 'Sold dd/mm/yyyy£X' — the GBP hammer price (Whisky Hammer's buyer commission is separate → buyer_premium_included=false) with the individual sale date. Unsold lots have no 'Sold' line and are skipped. robots.txt only disallows sort/page-size/return_name query variants, which are not used. Scrapfly fallback disabled to keep cost predictable.",
6006 + "enabled": true,
6007 + "schemaVersion": "1.0",
6008 + "config": {
6009 + "auctionsPerRun": 1,
6010 + "pagesPerRun": 6
6011 + }
6012 + },
3991 6013 {
3992 6014 "id": "winebid",
3993 6015 "displayName": "WineBid (recent sales per wine)",
added data/fixtures/gooding/realized-auction.json +88 −0
@@ -0,0 +1,88 @@
1 +{
2 + "raw": {
3 + "url": "https://www.goodingco.com/auction/realized/pebble-beach-auctions-2026",
4 + "externalId": "auction:pebble-beach-auctions-2026",
5 + "kind": "sale",
6 + "engine": "api",
7 + "fetchedAt": "2026-09-07T07:10:28.645Z",
8 + "payload": {
9 + "kind": "realized_auction",
10 + "slug": "pebble-beach-auctions-2026",
11 + "url": "https://www.goodingco.com/auction/realized/pebble-beach-auctions-2026",
12 + "name": "Pebble Beach Auctions",
13 + "currency": "USD",
14 + "saleDate": "2026-08-15T00:00:00.000Z",
15 + "sellThroughRate": 0.941,
16 + "lots": [
17 + {
18 + "slug": "1961-jaguar-xk150-38-litre-fixed-head-coupe",
19 + "lotNumber": 116,
20 + "salePrice": 72800,
21 + "privateSalesPrice": false,
22 + "title": "1961 Jaguar XK150 3.8-Litre Fixed Head Coupe",
23 + "modelYear": 1961,
24 + "make": "Jaguar",
25 + "model": "XK150 3.8-Litre Fixed Head Coupe",
26 + "itemType": "ContentfulVehicle",
27 + "image": "https://res.cloudinary.com/goodingco/image/upload/c_fill,w_1200/Prod/PB26_Pebble%20Beach%20Auctions%202026/756_1961%20Jaguar%20XK150%203.8-Litre%20Fixed%20Head%20Coupe/1961_Jaguar_XK150_FHC_3_ejggol"
28 + },
29 + {
30 + "slug": "1963-lotus-elite-se-pb26",
31 + "lotNumber": 110,
32 + "salePrice": 81200,
33 + "privateSalesPrice": false,
34 + "title": "1963 Lotus Elite SE (PB26)",
35 + "modelYear": 1963,
36 + "make": "Lotus",
37 + "model": "Elite SE",
38 + "itemType": "ContentfulVehicle",
39 + "image": "https://res.cloudinary.com/goodingco/image/upload/c_fill,w_1200/Prod/PB26_Pebble%20Beach%20Auctions%202026/753_1963%20Lotus%20Elite%20SE/1963_Lotus_Elite_SE_1_JPEG_Large_nvu8kt"
40 + },
41 + {
42 + "slug": "1957-alfa-romeo-1900c-ssz-lusso",
43 + "lotNumber": 144,
44 + "salePrice": 1380000,
45 + "privateSalesPrice": false,
46 + "title": "1957 Alfa Romeo 1900C SSZ 'Lusso'",
47 + "modelYear": 1957,
48 + "make": "Alfa Romeo",
49 + "model": "1900C SSZ 'Lusso'",
50 + "itemType": "ContentfulVehicle",
51 + "image": "https://res.cloudinary.com/goodingco/image/upload/c_fill,w_1200/Prod/PB26_Pebble%20Beach%20Auctions%202026/744_1957%20Alfa%20Romeo%201900C%20SSZ%20Lusso/1957_Alfa_Romeo_1900C_SSZ_Lusso_62_wjcvsw"
52 + },
53 + {
54 + "slug": "2015-ferrari-458-speciale-pb26",
55 + "lotNumber": 21,
56 + "salePrice": 1022500,
57 + "privateSalesPrice": false,
58 + "title": "2015 Ferrari 458 Speciale (PB26)",
59 + "modelYear": 2015,
60 + "make": "Ferrari",
61 + "model": "458 Speciale",
62 + "itemType": "ContentfulVehicle",
63 + "image": "https://res.cloudinary.com/goodingco/image/upload/c_fill,w_1200/Prod/PB26_Pebble%20Beach%20Auctions%202026/714_2015%20Ferrari%20458%20Speciale/2015_Ferrari_458_Speciale_49_ywqvp8"
64 + },
65 + {
66 + "slug": "1964-shelby-cobra-daytona-coupe",
67 + "lotNumber": 39,
68 + "salePrice": 42905000,
69 + "privateSalesPrice": false,
70 + "title": "1964 Shelby Cobra Daytona Coupe",
71 + "modelYear": 1964,
72 + "make": "Shelby",
73 + "model": "Cobra Daytona Coupe",
74 + "itemType": "ContentfulVehicle",
75 + "image": "https://res.cloudinary.com/goodingco/image/upload/c_fill,w_1200/Prod/PB26_Pebble%20Beach%20Auctions%202026/690_1964%20Shelby%20Cobra%20Daytona%20Coupe/64_Shelby_F3Q_11_Web_1_aeqnue"
76 + }
77 + ]
78 + }
79 + },
80 + "expect": {
81 + "count": 5,
82 + "kinds": [
83 + "sale"
84 + ]
85 + },
86 + "note": "Captured live from https://www.goodingco.com/auction/realized/pebble-beach-auctions-2026 (lists trimmed to 5).",
87 + "capturedAt": "2026-09-07T07:10:28.650Z"
88 +}
\ No newline at end of file
added data/fixtures/hdh-wine/results-pdf.json +345 −0
@@ -0,0 +1,345 @@
1 +{
2 + "raw": {
3 + "url": "https://hdhauctions.com/wp-content/uploads/2026/07/2606_AuctionResults_PDF.pdf",
4 + "externalId": "pdf:2606_AuctionResults_PDF.pdf",
5 + "kind": "sale",
6 + "engine": "firecrawl",
7 + "fetchedAt": "2026-09-07T07:19:42.237Z",
8 + "payload": {
9 + "kind": "results_pdf",
10 + "url": "https://hdhauctions.com/wp-content/uploads/2026/07/2606_AuctionResults_PDF.pdf",
11 + "auctionName": "An Auction of Finest & Rarest Wines",
12 + "saleDate": "2026-06-25T00:00:00.000Z",
13 + "rows": [
14 + {
15 + "lot": "1",
16 + "qty": 1,
17 + "description": "1959 Château Latour",
18 + "estimate": "2,400 - 3,500",
19 + "hammer": 4800,
20 + "aggregate": 5736
21 + },
22 + {
23 + "lot": "2",
24 + "qty": 1,
25 + "description": "1959 Château Margaux",
26 + "estimate": "900 - 1,400",
27 + "hammer": 1300,
28 + "aggregate": 1553.5
29 + },
30 + {
31 + "lot": "3",
32 + "qty": 1,
33 + "description": "1959 Château Mouton Rothschild",
34 + "estimate": "2,600 - 3,800",
35 + "hammer": 2800,
36 + "aggregate": 3346
37 + },
38 + {
39 + "lot": "4",
40 + "qty": 11,
41 + "description": "1982 Château Léoville Las Cases",
42 + "estimate": "3,200 - 4,800",
43 + "hammer": 4500,
44 + "aggregate": 5377.5
45 + },
46 + {
47 + "lot": "5",
48 + "qty": 6,
49 + "description": "1986 Château Léoville Las Cases",
50 + "estimate": "1,800 - 2,800",
51 + "hammer": 2600,
52 + "aggregate": 3107
53 + },
54 + {
55 + "lot": "6",
56 + "qty": 7,
57 + "description": "1989 Château La Conseillante",
58 + "estimate": "1,700 - 2,600",
59 + "hammer": 2400,
60 + "aggregate": 2868
61 + },
62 + {
63 + "lot": "7",
64 + "qty": 8,
65 + "description": "1990 Château La Conseillante",
66 + "estimate": "2,200 - 3,200",
67 + "hammer": 3000,
68 + "aggregate": 3585
69 + },
70 + {
71 + "lot": "8",
72 + "qty": 6,
73 + "description": "1990 Château Haut-Brion",
74 + "estimate": "3,500 - 5,500",
75 + "hammer": 4500,
76 + "aggregate": 5377.5
77 + },
78 + {
79 + "lot": "9",
80 + "qty": 3,
81 + "description": "1996 Château Lafite Rothschild",
82 + "estimate": "1,800 - 2,800",
83 + "hammer": 2200,
84 + "aggregate": 2629
85 + },
86 + {
87 + "lot": "10",
88 + "qty": 9,
89 + "description": "2003 Château Montrose",
90 + "estimate": "1,100 - 1,700",
91 + "hammer": 1500,
92 + "aggregate": 1792.5
93 + },
94 + {
95 + "lot": "11",
96 + "qty": 1,
97 + "description": "1990 Château Lafite Rothschild",
98 + "estimate": "1,400 - 2,000",
99 + "hammer": 1800,
100 + "aggregate": 2151
101 + },
102 + {
103 + "lot": "11",
104 + "qty": 1,
105 + "description": "1991 Château Lafite Rothschild",
106 + "estimate": null,
107 + "hammer": null,
108 + "aggregate": null
109 + },
110 + {
111 + "lot": "11",
112 + "qty": 1,
113 + "description": "1993 Château Lafite Rothschild",
114 + "estimate": null,
115 + "hammer": null,
116 + "aggregate": null
117 + },
118 + {
119 + "lot": "11",
120 + "qty": 1,
121 + "description": "1994 Château Lafite Rothschild",
122 + "estimate": null,
123 + "hammer": null,
124 + "aggregate": null
125 + },
126 + {
127 + "lot": "12",
128 + "qty": 1,
129 + "description": "1997 Château Lafite Rothschild",
130 + "estimate": "1,000 - 1,500",
131 + "hammer": 1500,
132 + "aggregate": 1792.5
133 + },
134 + {
135 + "lot": "12",
136 + "qty": 1,
137 + "description": "1998 Château Lafite Rothschild",
138 + "estimate": null,
139 + "hammer": null,
140 + "aggregate": null
141 + },
142 + {
143 + "lot": "12",
144 + "qty": 1,
145 + "description": "1999 Château Lafite Rothschild",
146 + "estimate": null,
147 + "hammer": null,
148 + "aggregate": null
149 + },
150 + {
151 + "lot": "13",
152 + "qty": 8,
153 + "description": "1990 Musigny, Vieilles Vignes, Comte Georges de Vogué",
154 + "estimate": "4,000 - 6,000",
155 + "hammer": 4800,
156 + "aggregate": 5736
157 + },
158 + {
159 + "lot": "14",
160 + "qty": 4,
161 + "description": "1990 Louis Roederer, Cristal",
162 + "estimate": "1,600 - 2,400",
163 + "hammer": 2400,
164 + "aggregate": 2868
165 + },
166 + {
167 + "lot": "15",
168 + "qty": 6,
169 + "description": "2008 Louis Roederer, Cristal",
170 + "estimate": "1,700 - 2,600",
171 + "hammer": 1900,
172 + "aggregate": 2270.5
173 + },
174 + {
175 + "lot": "16",
176 + "qty": 11,
177 + "description": "1995 Amarone della Valpolicella Classico Riserva, Giuseppe Quintarelli",
178 + "estimate": "7,000 - 10,000",
179 + "hammer": 7500,
180 + "aggregate": 8962.5
181 + },
182 + {
183 + "lot": "17",
184 + "qty": 10,
185 + "description": "1998 Amarone della Valpolicella Classico, Giuseppe Quintarelli",
186 + "estimate": "2,800 - 4,200",
187 + "hammer": 3800,
188 + "aggregate": 4541
189 + },
190 + {
191 + "lot": "18",
192 + "qty": 4,
193 + "description": "1998 Amarone della Valpolicella Classico, Giuseppe Quintarelli (1.5L)",
194 + "estimate": "2,200 - 3,200",
195 + "hammer": 3200,
196 + "aggregate": 3824
197 + },
198 + {
199 + "lot": "19",
200 + "qty": 4,
201 + "description": "2001 Barbaresco Riserva, Rabajà, Bruno Giacosa",
202 + "estimate": "1,500 - 2,200",
203 + "hammer": 1900,
204 + "aggregate": 2270.5
205 + },
206 + {
207 + "lot": "20",
208 + "qty": 5,
209 + "description": "2006 Bond Winery Red Wine Assortment",
210 + "estimate": "1,100 - 1,700",
211 + "hammer": 1300,
212 + "aggregate": 1553.5
213 + },
214 + {
215 + "lot": "21",
216 + "qty": 5,
217 + "description": "2006 Bond Winery Red Wine Assortment",
218 + "estimate": "1,100 - 1,700",
219 + "hammer": 1400,
220 + "aggregate": 1673
221 + },
222 + {
223 + "lot": "22",
224 + "qty": 5,
225 + "description": "2006 Bond Winery Red Wine Assortment (1.5L)",
226 + "estimate": "2,200 - 3,200",
227 + "hammer": 2600,
228 + "aggregate": 3107
229 + },
230 + {
231 + "lot": "23",
232 + "qty": 5,
233 + "description": "2007 Bond Winery Red Wine Assortment",
234 + "estimate": "1,300 - 1,900",
235 + "hammer": 1700,
236 + "aggregate": 2031.5
237 + },
238 + {
239 + "lot": "24",
240 + "qty": 5,
241 + "description": "2007 Bond Winery Red Wine Assortment",
242 + "estimate": "1,300 - 1,900",
243 + "hammer": 1800,
244 + "aggregate": 2151
245 + },
246 + {
247 + "lot": "25",
248 + "qty": 5,
249 + "description": "2007 Bond Winery Red Wine Assortment (1.5L)",
250 + "estimate": "2,600 - 3,800",
251 + "hammer": 3200,
252 + "aggregate": 3824
253 + },
254 + {
255 + "lot": "26",
256 + "qty": 5,
257 + "description": "2008 Bond Winery Red Wine Assortment",
258 + "estimate": "1,100 - 1,700",
259 + "hammer": 1500,
260 + "aggregate": 1792.5
261 + },
262 + {
263 + "lot": "27",
264 + "qty": 5,
265 + "description": "2008 Bond Winery Red Wine Assortment",
266 + "estimate": "1,100 - 1,700",
267 + "hammer": 1400,
268 + "aggregate": 1673
269 + },
270 + {
271 + "lot": "28",
272 + "qty": 5,
273 + "description": "2009 Bond Winery Red Wine Assortment",
274 + "estimate": "1,100 - 1,700",
275 + "hammer": 1500,
276 + "aggregate": 1792.5
277 + },
278 + {
279 + "lot": "29",
280 + "qty": 5,
281 + "description": "2009 Bond Winery Red Wine Assortment",
282 + "estimate": "1,100 - 1,700",
283 + "hammer": 1500,
284 + "aggregate": 1792.5
285 + },
286 + {
287 + "lot": "30",
288 + "qty": 5,
289 + "description": "2010 Bond Winery Red Wine Assortment",
290 + "estimate": "1,200 - 1,800",
291 + "hammer": 1500,
292 + "aggregate": 1792.5
293 + },
294 + {
295 + "lot": "31",
296 + "qty": 5,
297 + "description": "2012 Bond Winery Red Wine Assortment",
298 + "estimate": "1,500 - 2,200",
299 + "hammer": 1600,
300 + "aggregate": 1912
301 + },
302 + {
303 + "lot": "32",
304 + "qty": 5,
305 + "description": "2013 Bond Winery Red Wine Assortment",
306 + "estimate": "1,700 - 2,600",
307 + "hammer": 2000,
308 + "aggregate": 2390
309 + },
310 + {
311 + "lot": "33",
312 + "qty": 5,
313 + "description": "2014 Bond Winery Red Wine Assortment",
314 + "estimate": "1,200 - 1,800",
315 + "hammer": 1500,
316 + "aggregate": 1792.5
317 + },
318 + {
319 + "lot": "34",
320 + "qty": 6,
321 + "description": "2004 Colgin Red Wine, IX Estate",
322 + "estimate": "1,000 - 1,500",
323 + "hammer": 1600,
324 + "aggregate": 1912
325 + },
326 + {
327 + "lot": "35",
328 + "qty": 6,
329 + "description": "2006 Colgin Red Wine, Cariad",
330 + "estimate": "1,100 - 1,700",
331 + "hammer": 1700,
332 + "aggregate": 2031.5
333 + }
334 + ]
335 + }
336 + },
337 + "expect": {
338 + "count": 35,
339 + "kinds": [
340 + "sale"
341 + ]
342 + },
343 + "note": "Captured live from https://hdhauctions.com/wp-content/uploads/2026/07/2606_AuctionResults_PDF.pdf via Firecrawl PDF parsing (rows trimmed to the first lots).",
344 + "capturedAt": "2026-09-07T07:19:42.274Z"
345 +}
\ No newline at end of file
added data/fixtures/historics/lots-page.json +92 −0
@@ -0,0 +1,92 @@
1 +{
2 + "raw": {
3 + "url": "https://www.historics.co.uk/auction/details/wo27-online-collectibles?au=127&pp=96&pn=1",
4 + "externalId": "auction:127:page:1",
5 + "kind": "sale",
6 + "engine": "api",
7 + "fetchedAt": "2026-09-07T07:14:02.478Z",
8 + "payload": {
9 + "kind": "lots_page",
10 + "url": "https://www.historics.co.uk/auction/details/wo27-online-collectibles?au=127&pp=96&pn=1",
11 + "auction": {
12 + "au": "127",
13 + "slug": "wo27-online-collectibles",
14 + "title": "Online Collectibles",
15 + "saleNumber": "WO27",
16 + "endedOn": "2026-08-06T00:00:00.000Z",
17 + "lotCount": 21
18 + },
19 + "page": 1,
20 + "lots": [
21 + {
22 + "lotId": "19949",
23 + "url": "https://www.historics.co.uk/auction/lot/lot-1976-van-diemen-rf-76-project/?lot=19949",
24 + "lotNo": null,
25 + "title": "1976 Van Diemen RF 76 Project",
26 + "subtitle": null,
27 + "soldText": null,
28 + "priceGbp": null,
29 + "image": "https://storagegohistorics.goauction.co.uk/stock/19903-387-small.jpg"
30 + },
31 + {
32 + "lotId": "19950",
33 + "url": "https://www.historics.co.uk/auction/lot/lot-1981-fiat-x-19-x-20-conversion/?lot=19950",
34 + "lotNo": null,
35 + "title": "1981 Fiat X 1/9 x 2/0 Conversion",
36 + "subtitle": "Offered Without Reserve",
37 + "soldText": "Sold £2,680",
38 + "priceGbp": 2680,
39 + "image": "https://storagegohistorics.goauction.co.uk/stock/19904-2-small.jpg"
40 + },
41 + {
42 + "lotId": "20126",
43 + "url": "https://www.historics.co.uk/auction/lot/lot-1986-lotus-elan-m100-prototype/?lot=20126",
44 + "lotNo": null,
45 + "title": "1986 Lotus Elan M100 Prototype",
46 + "subtitle": "Offered Without Reserve",
47 + "soldText": "Sold £1,822",
48 + "priceGbp": 1822,
49 + "image": "https://storagegohistorics.goauction.co.uk/stock/20079-0-small.jpg"
50 + },
51 + {
52 + "lotId": "20129",
53 + "url": "https://www.historics.co.uk/auction/lot/lot-2006-jaguar-xk-42-convertible/?lot=20129",
54 + "lotNo": null,
55 + "title": "2006 Jaguar XK 4.2 Convertible",
56 + "subtitle": "Sold for an undisclosed fee",
57 + "soldText": null,
58 + "priceGbp": null,
59 + "image": "https://storagegohistorics.goauction.co.uk/stock/20082-5-small.jpg"
60 + },
61 + {
62 + "lotId": "20128",
63 + "url": "https://www.historics.co.uk/auction/lot/lot-2011-jaguar-xj-50-litre-v8-x351/?lot=20128",
64 + "lotNo": null,
65 + "title": "2011 Jaguar XJ 5.0 litre V8 (X351)",
66 + "subtitle": "49,958 mile ULEZ compliant stunner & just £360 road tax",
67 + "soldText": null,
68 + "priceGbp": null,
69 + "image": "https://storagegohistorics.goauction.co.uk/stock/20081-2-small.jpg"
70 + },
71 + {
72 + "lotId": "20130",
73 + "url": "https://www.historics.co.uk/auction/lot/lot-2000-jaguar-xjr-x308/?lot=20130",
74 + "lotNo": null,
75 + "title": "2000 Jaguar XJR X308",
76 + "subtitle": "1 Owner 44,000 mile example",
77 + "soldText": null,
78 + "priceGbp": null,
79 + "image": "https://storagegohistorics.goauction.co.uk/stock/20083-1-small.jpg"
80 + }
81 + ]
82 + }
83 + },
84 + "expect": {
85 + "count": 2,
86 + "kinds": [
87 + "sale"
88 + ]
89 + },
90 + "note": "Captured live from https://www.historics.co.uk/auction/details/wo27-online-collectibles?au=127&pp=96&pn=1 (lists trimmed to 6).",
91 + "capturedAt": "2026-09-07T07:14:02.492Z"
92 +}
\ No newline at end of file
added data/fixtures/just-whisky/lots-page.json +177 −0
@@ -0,0 +1,177 @@
1 +{
2 + "raw": {
3 + "url": "https://www.just-whisky.co.uk/api/lots/?min_end_date=14%2F07%2F2026&max_end_date=21%2F07%2F2026&ordering=-price&page_size=200&page=1",
4 + "externalId": "auction:1160:page:1",
5 + "kind": "sale",
6 + "engine": "api",
7 + "fetchedAt": "2026-09-07T07:17:02.228Z",
8 + "payload": {
9 + "kind": "lots_page",
10 + "url": "https://www.just-whisky.co.uk/api/lots/?min_end_date=14%2F07%2F2026&max_end_date=21%2F07%2F2026&ordering=-price&page_size=200&page=1",
11 + "page": 1,
12 + "count": 989,
13 + "lots": [
14 + {
15 + "id": 1507830,
16 + "slug": "blair-castle-scotch-whisky-1833-1841-rebottled-1932-1507830",
17 + "title": "Blair Castle Scotch Whisky 1833",
18 + "subtitle": "1841 (Rebottled 1932)",
19 + "reserveMet": true,
20 + "hammerPrice": 15000,
21 + "currentBid": 15000,
22 + "isGroupLot": false,
23 + "auctionId": 1160,
24 + "auctionEnd": "2026-07-20T19:00:00",
25 + "strength": "61.36%",
26 + "size": "Not stated.",
27 + "distillery": null,
28 + "bottler": null,
29 + "region": null,
30 + "estimatedValue": 15000,
31 + "image": "https://www.just-whisky.co.uk/media/image/lot/blair_castle_scotch_whisky_1833_1841_rebottled_1932_2808586_480464.webp"
32 + },
33 + {
34 + "id": 1508444,
35 + "slug": "springbank-36-years-old-1965-2001-local-barley-cask-no8-1508444",
36 + "title": "Springbank 36 Years Old 1965",
37 + "subtitle": "2001 Local Barley Cask No.8",
38 + "reserveMet": false,
39 + "hammerPrice": null,
40 + "currentBid": 3150,
41 + "isGroupLot": false,
42 + "auctionId": 1160,
43 + "auctionEnd": "2026-07-20T19:00:00",
44 + "strength": "47.6%",
45 + "size": "70 cl",
46 + "distillery": null,
47 + "bottler": null,
48 + "region": null,
49 + "estimatedValue": null,
50 + "image": "https://www.just-whisky.co.uk/media/image/lot/springbank_36_years_old_1965_2001_local_barley_cask_no8_2803113_477792.webp"
51 + },
52 + {
53 + "id": 1508000,
54 + "slug": "port-ellen-1981-feis-ile-2008-1508000",
55 + "title": "Port Ellen 1981",
56 + "subtitle": "Feis Ile 2008",
57 + "reserveMet": true,
58 + "hammerPrice": 2750,
59 + "currentBid": 2750,
60 + "isGroupLot": false,
61 + "auctionId": 1160,
62 + "auctionEnd": "2026-07-20T19:00:00",
63 + "strength": "54.7%",
64 + "size": "70 cl",
65 + "distillery": null,
66 + "bottler": null,
67 + "region": null,
68 + "estimatedValue": 2750,
69 + "image": "https://www.just-whisky.co.uk/media/image/lot/port_ellen_1981_feis_ile_2008_2808906_192408.webp"
70 + },
71 + {
72 + "id": 1507526,
73 + "slug": "ardbeg-31-years-old-1974-2006-single-cask-no4989-1507526",
74 + "title": "Ardbeg 31 Years Old 1974",
75 + "subtitle": "2006 Single Cask No.4989",
76 + "reserveMet": true,
77 + "hammerPrice": 2100,
78 + "currentBid": 2100,
79 + "isGroupLot": false,
80 + "auctionId": 1160,
81 + "auctionEnd": "2026-07-20T19:00:00",
82 + "strength": "50.7%",
83 + "size": "70 cl",
84 + "distillery": null,
85 + "bottler": null,
86 + "region": null,
87 + "estimatedValue": 2100,
88 + "image": "https://www.just-whisky.co.uk/media/image/lot/ardbeg_31_years_old_1974_2006_single_cask_no4989_2807743_343515.webp"
89 + },
90 + {
91 + "id": 1507852,
92 + "slug": "north-british-60-years-old-single-grain-scotch-whisky-1507852",
93 + "title": "North British 60 Years Old",
94 + "subtitle": "Single Grain Scotch Whisky",
95 + "reserveMet": true,
96 + "hammerPrice": 2000,
97 + "currentBid": 2000,
98 + "isGroupLot": false,
99 + "auctionId": 1160,
100 + "auctionEnd": "2026-07-20T19:00:00",
101 + "strength": "50.6%",
102 + "size": "70 cl",
103 + "distillery": null,
104 + "bottler": null,
105 + "region": null,
106 + "estimatedValue": 2000,
107 + "image": "https://www.just-whisky.co.uk/media/image/lot/north_british_60_years_old_single_grain_scotch_whisky_2808603_135265.webp"
108 + },
109 + {
110 + "id": 1507817,
111 + "slug": "highland-park-31-years-old-1974-2005-silver-seal-french-crystal-decanter-1-of-1-1507817",
112 + "title": "Highland Park 31 Years Old 1974",
113 + "subtitle": "2005 Silver Seal | French Crystal Decanter (1 of 1)",
114 + "reserveMet": true,
115 + "hammerPrice": 2000,
116 + "currentBid": 2000,
117 + "isGroupLot": false,
118 + "auctionId": 1160,
119 + "auctionEnd": "2026-07-20T19:00:00",
120 + "strength": "50%",
121 + "size": "90cl",
122 + "distillery": null,
123 + "bottler": null,
124 + "region": null,
125 + "estimatedValue": 2000,
126 + "image": "https://www.just-whisky.co.uk/media/image/lot/highland_park_31_years_old_1974_2005_silver_seal_french_crystal_decanter_1_of_cFFPCYu.webp"
127 + },
128 + {
129 + "id": 1508409,
130 + "slug": "tamdhu-26-years-old-1970-1996-signatory-vintage-sherry-cask-no373-1508409",
131 + "title": "Tamdhu 26 Years Old 1970",
132 + "subtitle": "1996 Signatory Vintage | Sherry Cask No.373",
133 + "reserveMet": true,
134 + "hammerPrice": 1950,
135 + "currentBid": 1950,
136 + "isGroupLot": false,
137 + "auctionId": 1160,
138 + "auctionEnd": "2026-07-20T19:00:00",
139 + "strength": "51.5%",
140 + "size": "70 cl",
141 + "distillery": null,
142 + "bottler": null,
143 + "region": null,
144 + "estimatedValue": 1950,
145 + "image": "https://www.just-whisky.co.uk/media/image/lot/tamdhu_26_years_old_1970_1996_signatory_vintage_sherry_cask_no373_2809757_725755.webp"
146 + },
147 + {
148 + "id": 1508385,
149 + "slug": "bowmore-40-years-old-1970-signatory-vintage-cask-strength-collection-no4686-1508385",
150 + "title": "Bowmore 40 Years Old 1970",
151 + "subtitle": "Signatory Vintage Cask Strength Collection (No.4686)",
152 + "reserveMet": true,
153 + "hammerPrice": 1950,
154 + "currentBid": 1950,
155 + "isGroupLot": false,
156 + "auctionId": 1160,
157 + "auctionEnd": "2026-07-20T19:00:00",
158 + "strength": "51.5% vol",
159 + "size": "70 cl",
160 + "distillery": null,
161 + "bottler": null,
162 + "region": null,
163 + "estimatedValue": 1950,
164 + "image": "https://www.just-whisky.co.uk/media/image/lot/bowmore_40_years_old_1970_signatory_vintage_cask_strength_collection_no4686_2_2QzKI6b.webp"
165 + }
166 + ]
167 + }
168 + },
169 + "expect": {
170 + "count": 7,
171 + "kinds": [
172 + "sale"
173 + ]
174 + },
175 + "note": "Captured live from https://www.just-whisky.co.uk/api/lots/?min_end_date=14%2F07%2F2026&max_end_date=21%2F07%2F2026&ordering=-price&page_size=200&page=1 (lists trimmed to 8).",
176 + "capturedAt": "2026-09-07T07:17:02.232Z"
177 +}
\ No newline at end of file
added data/fixtures/pcarmarket/results-page.json +172 −0
@@ -0,0 +1,172 @@
1 +{
2 + "raw": {
3 + "url": "https://www.pcarmarket.com/results/?page=1",
4 + "externalId": "results:1:67405",
5 + "kind": "sale",
6 + "engine": "api",
7 + "fetchedAt": "2026-09-07T07:11:38.101Z",
8 + "payload": {
9 + "kind": "results_page",
10 + "page": 1,
11 + "count": 7957,
12 + "items": [
13 + {
14 + "id": 67405,
15 + "title": "No Reserve Porsche 917 Gulf Mat",
16 + "slug": "no-porsche-917-gulf-mat",
17 + "vehicle": null,
18 + "high_bid": 625,
19 + "end_date": "2026-09-04T16:08:00-04:00",
20 + "status": "Sold",
21 + "country": "Hungary",
22 + "zip_code": null,
23 + "mileage_body": null,
24 + "odometer_type": "mi",
25 + "bid_count": 6,
26 + "reserve_status": "none",
27 + "is_marketplace": false,
28 + "featured_image_large_url": "https://d2niwqq19lf86s.cloudfront.net/htwritable/media/prod/eyJ0aXRsZSI6IjY3NDA1IC0gcmV6ZXpcdTAwZWR0NiIsInNsdWciOiI2NzQwNS1yZXpleml0Ni00ZmIyZmNmMSJ9/.thumbnails/f5c27b29-8886-4a7d-b892-2e2611715a43.webp/f5c27b29-8886-4a7d-b892-2e2611715a43-tiny-810x0.webp"
29 + },
30 + {
31 + "id": 67401,
32 + "title": "RS-Style 1972 Porsche 911E Coupe \"Olklappe\" 4-Speed",
33 + "slug": "1972-porsche-911e-2",
34 + "vehicle": {
35 + "make": "Porsche",
36 + "model": "911E",
37 + "year": 1972
38 + },
39 + "high_bid": 58250,
40 + "end_date": "2026-09-04T15:00:07-04:00",
41 + "status": "Sold",
42 + "country": "United States of America",
43 + "zip_code": "78735",
44 + "mileage_body": 15302,
45 + "odometer_type": "mi",
46 + "bid_count": 44,
47 + "reserve_status": "met",
48 + "is_marketplace": false,
49 + "featured_image_large_url": "https://d2niwqq19lf86s.cloudfront.net/htwritable/media/.thumbnails/8bbdafa9-a5a0-4d9d-a65b-c870b39f6c06-92099127-2b0f-4b18-91ed-00bd0438d03d-tiny-2048x0.webp/8bbdafa9-a5a0-4d9d-a65b-c870b39f6c06-92099127-2b0f-4b18-91ed-00bd0438d03d-tiny-2048x0-tiny-810x0.webp"
50 + },
51 + {
52 + "id": 67424,
53 + "title": "One-Owner 1983 Porsche 911SC Coupe",
54 + "slug": "1983-porsche-911sc-13",
55 + "vehicle": {
56 + "make": "Porsche",
57 + "model": "911SC",
58 + "year": 1983
59 + },
60 + "high_bid": 65000,
61 + "end_date": "2026-09-04T14:40:00-04:00",
62 + "status": "Sold",
63 + "country": "United States of America",
64 + "zip_code": "10019",
65 + "mileage_body": 5514,
66 + "odometer_type": "mi",
67 + "bid_count": 42,
68 + "reserve_status": "met",
69 + "is_marketplace": false,
70 + "featured_image_large_url": "https://d2niwqq19lf86s.cloudfront.net/htwritable/media/prod/eyJ0aXRsZSI6IjY3NDI0IC0gMTk4MyBQb3JzY2hlIDkxMVNDIiwic2x1ZyI6IjY3NDI0LTE5ODMtcG9yc2NoZS05MTFzYy1lNmI5ZWM3NCJ9/.thumbnails/b0391157-5ade-4f4f-86ff-1df4261c5bb0.webp/b0391157-5ade-4f4f-86ff-1df4261c5bb0-tiny-810x0.webp"
71 + },
72 + {
73 + "id": 67426,
74 + "title": "No Reserve Tissot PR516 Chronograph Watch Ref.T1494171104100 Full Set",
75 + "slug": "tissot-pr516-chronograph-watch-reft1494171104100-full-set",
76 + "vehicle": null,
77 + "high_bid": 175,
78 + "end_date": "2026-09-03T16:18:00-04:00",
79 + "status": "Sold",
80 + "country": "United States of America",
81 + "zip_code": "11590",
82 + "mileage_body": null,
83 + "odometer_type": "mi",
84 + "bid_count": 4,
85 + "reserve_status": "none",
86 + "is_marketplace": false,
87 + "featured_image_large_url": "https://d2niwqq19lf86s.cloudfront.net/htwritable/media/prod/eyJ0aXRsZSI6IjY3NDI2IC0gQWlyY29vbGVkb3V0bGF3Iiwic2x1ZyI6IjY3NDI2LWFpcmNvb2xlZG91dGxhdy1hMjNlMzY4ZiJ9/.thumbnails/3cbcf04b-5b84-49e7-9bff-56d8b6e21bb8.webp/3cbcf04b-5b84-49e7-9bff-56d8b6e21bb8-tiny-810x0.webp"
88 + },
89 + {
90 + "id": 67358,
91 + "title": "TAG Heuer Gulf Special Edition Watch Ref. CAVZ101 Full Set",
92 + "slug": "tag-heuer-gulf-special-edition-watch-ref-cavz101-full-set",
93 + "vehicle": null,
94 + "high_bid": 1250,
95 + "end_date": "2026-09-03T16:13:58-04:00",
96 + "status": "Sold",
97 + "country": "United States of America",
98 + "zip_code": "92210",
99 + "mileage_body": null,
100 + "odometer_type": "mi",
101 + "bid_count": 21,
102 + "reserve_status": "met",
103 + "is_marketplace": false,
104 + "featured_image_large_url": "https://d2niwqq19lf86s.cloudfront.net/htwritable/media/.thumbnails/1b5a8f5f-8eee-42b5-a499-e5aff7005961-caec2cba-f157-4c60-99de-61fb97d72b6b-IMG_0296.JPG.webp/1b5a8f5f-8eee-42b5-a499-e5aff7005961-caec2cba-f157-4c60-99de-61fb97d72b6b-IMG_0296.JPG-tiny-810x0.webp"
105 + },
106 + {
107 + "id": 67322,
108 + "title": "No Reserve 2000-2003 Porsche Dealer Sample Kit Color Case",
109 + "slug": "2000-2003-porsche-dealer-sample-kit-color-case",
110 + "vehicle": null,
111 + "high_bid": 250,
112 + "end_date": "2026-09-03T16:03:00-04:00",
113 + "status": "Sold",
114 + "country": "The Netherlands",
115 + "zip_code": null,
116 + "mileage_body": null,
117 + "odometer_type": "mi",
118 + "bid_count": 1,
119 + "reserve_status": "none",
120 + "is_marketplace": false,
121 + "featured_image_large_url": "https://d2niwqq19lf86s.cloudfront.net/htwritable/media/prod/eyJ0aXRsZSI6IjY3MzIyIC0gbS52YW5kZXJ3ZWVyZCIsInNsdWciOiI2NzMyMi1tLXZhbmRlcndlZXJkLWY1NzM3N2RjIn0/.thumbnails/e549ea72-aabb-41b3-9c5e-32175340dda7.webp/e549ea72-aabb-41b3-9c5e-32175340dda7-tiny-810x0.webp"
122 + },
123 + {
124 + "id": 67032,
125 + "title": "No Reserve Illuminated Porsche Eye Chart Sign",
126 + "slug": "no-reserve-illuminated-porsche-eye-chart-sign",
127 + "vehicle": null,
128 + "high_bid": 250,
129 + "end_date": "2026-09-03T16:00:00-04:00",
130 + "status": "Sold",
131 + "country": "United States of America",
132 + "zip_code": null,
133 + "mileage_body": null,
134 + "odometer_type": "mi",
135 + "bid_count": 7,
136 + "reserve_status": "none",
137 + "is_marketplace": false,
138 + "featured_image_large_url": "https://d2niwqq19lf86s.cloudfront.net/htwritable/media/.thumbnails/9ed3dd86-bc40-4bec-9680-b15d4a7e6bb1-3f88dc41-0d24-4bcc-94f1-cfdacd8e538d-IMG_6887-tiny-2048x0.webp/9ed3dd86-bc40-4bec-9680-b15d4a7e6bb1-3f88dc41-0d24-4bcc-94f1-cfdacd8e538d-IMG_6887-tiny-2048x0-tiny-810x0.webp"
139 + },
140 + {
141 + "id": 66880,
142 + "title": "11k-Mile 2008 Porsche 987 Boxster RS 60 Spyder 6-Speed",
143 + "slug": "2008-porsche-rs60-spyder",
144 + "vehicle": {
145 + "make": "Porsche",
146 + "model": "Boxster",
147 + "year": 2008
148 + },
149 + "high_bid": 43000,
150 + "end_date": "2026-09-03T14:49:47-04:00",
151 + "status": "Sold",
152 + "country": "United States of America",
153 + "zip_code": "21042",
154 + "mileage_body": 11520,
155 + "odometer_type": "mi",
156 + "bid_count": 40,
157 + "reserve_status": "met",
158 + "is_marketplace": false,
159 + "featured_image_large_url": "https://d2niwqq19lf86s.cloudfront.net/htwritable/media/.thumbnails/55071cfe-a500-46bf-a5b1-580c1a43fa07-f77ddb34-5260-41f0-bb1b-1d7b2bf4af49-tiny-2048x0.webp/55071cfe-a500-46bf-a5b1-580c1a43fa07-f77ddb34-5260-41f0-bb1b-1d7b2bf4af49-tiny-2048x0-tiny-810x0.webp"
160 + }
161 + ]
162 + }
163 + },
164 + "expect": {
165 + "count": 8,
166 + "kinds": [
167 + "sale"
168 + ]
169 + },
170 + "note": "Captured live from https://www.pcarmarket.com/results/?page=1 (lists trimmed to 8).",
171 + "capturedAt": "2026-09-07T07:11:38.108Z"
172 +}
\ No newline at end of file
added data/fixtures/the-market-bonhams/results-page.json +84 −0
@@ -0,0 +1,84 @@
1 +{
2 + "raw": {
3 + "url": "https://www.themarket.co.uk/auctions/results?page=1",
4 + "externalId": "results:1:399417d0-ade7-49b4-8125-4b85c12035d5",
5 + "kind": "sale",
6 + "engine": "api",
7 + "fetchedAt": "2026-09-07T07:13:37.878Z",
8 + "payload": {
9 + "kind": "results_page",
10 + "page": 1,
11 + "url": "https://www.themarket.co.uk/auctions/results?page=1",
12 + "cards": [
13 + {
14 + "id": "399417d0-ade7-49b4-8125-4b85c12035d5",
15 + "url": "https://www.themarket.co.uk/listings/jaguar/xj6-s3-42/399417d0-ade7-49b4-8125-4b85c12035d5",
16 + "title": "1982 Jaguar XJ6 S3 4.2",
17 + "intro": "Lovely Fuel Injection XJ - Good History",
18 + "soldText": "Sold for £4,350 on 02 Sep 2026",
19 + "bids": 16,
20 + "location": "THE MARKET HQ, GB",
21 + "image": "https://cdn.themarket.co.uk/399417d0-ade7-49b4-8125-4b85c12035d5/c7ab5b87-a31e-4c3b-9f3c-18413eaf1f46.jpg"
22 + },
23 + {
24 + "id": "eea042ad-dcaa-48c2-a19b-76e9fe16903e",
25 + "url": "https://www.themarket.co.uk/listings/bentley/arnage-r-auto/eea042ad-dcaa-48c2-a19b-76e9fe16903e",
26 + "title": "2002 Bentley Arnage R Auto",
27 + "intro": "Desirable 'R' Specification - Enthusiast Owned",
28 + "soldText": "Sold for £14,000 on 01 Sep 2026",
29 + "bids": 18,
30 + "location": "THE MARKET HQ, GB",
31 + "image": "https://cdn.themarket.co.uk/eea042ad-dcaa-48c2-a19b-76e9fe16903e/fb5b0fbe-4d3c-4cc8-a4e2-3d611bc2df77.jpg"
32 + },
33 + {
34 + "id": "03c8afe7-49d7-4306-b29f-81536c0d4672",
35 + "url": "https://www.themarket.co.uk/listings/bentley/bentayga-w12-auto/03c8afe7-49d7-4306-b29f-81536c0d4672",
36 + "title": "2018 Bentley Bentayga W12 Auto",
37 + "intro": "Exceptional Spec - Low Mileage",
38 + "soldText": "Sold for £61,000 on 01 Sep 2026",
39 + "bids": 17,
40 + "location": "THE MARKET HQ, GB",
41 + "image": "https://cdn.themarket.co.uk/03c8afe7-49d7-4306-b29f-81536c0d4672/8268a1f8-82fe-4083-889e-af4f798c3e35.jpg"
42 + },
43 + {
44 + "id": "39c23c0a-1a91-4314-8e7a-01ebce1c1496",
45 + "url": "https://www.themarket.co.uk/listings/mg/midget/39c23c0a-1a91-4314-8e7a-01ebce1c1496",
46 + "title": "1973 MG Midget",
47 + "intro": "Restored - Delightful - Useable",
48 + "soldText": "Sold for £5,750 on 01 Sep 2026",
49 + "bids": 17,
50 + "location": "THE MARKET HQ, GB",
51 + "image": "https://cdn.themarket.co.uk/39c23c0a-1a91-4314-8e7a-01ebce1c1496/07cccc19-66a7-496b-8e1e-babee9c76bb9.jpg"
52 + },
53 + {
54 + "id": "d04363ec-e52b-4a7d-8751-4387d56c3d6f",
55 + "url": "https://www.themarket.co.uk/listings/lea-francis/25-ltr-sports/d04363ec-e52b-4a7d-8751-4387d56c3d6f",
56 + "title": "1950 Lea Francis 2.5 ltr Sports",
57 + "intro": "Special - Built by Roger East",
58 + "soldText": "Sold for £8,750 on 01 Sep 2026",
59 + "bids": 11,
60 + "location": "THE MARKET HQ, GB",
61 + "image": "https://cdn.themarket.co.uk/d04363ec-e52b-4a7d-8751-4387d56c3d6f/9f0ae6f0-910b-4b89-9ae3-49a6175016b6.jpg"
62 + },
63 + {
64 + "id": "478475ce-64f3-45a3-8a46-79203d73081e",
65 + "url": "https://www.themarket.co.uk/listings/land-rover/series-2a-109/478475ce-64f3-45a3-8a46-79203d73081e",
66 + "title": "1966 Land Rover Series 2a 109\"",
67 + "intro": "Rare - Petrol V8 - Safari Roof",
68 + "soldText": "Sold for £10,750 on 27 Aug 2026",
69 + "bids": 17,
70 + "location": "THE MARKET HQ, GB",
71 + "image": "https://cdn.themarket.co.uk/478475ce-64f3-45a3-8a46-79203d73081e/443e8d1e-81e3-490b-9467-5545a610203f.jpg"
72 + }
73 + ]
74 + }
75 + },
76 + "expect": {
77 + "count": 6,
78 + "kinds": [
79 + "sale"
80 + ]
81 + },
82 + "note": "Captured live from https://www.themarket.co.uk/auctions/results?page=1 (lists trimmed to 6).",
83 + "capturedAt": "2026-09-07T07:13:37.903Z"
84 +}
\ No newline at end of file
added data/fixtures/whisky-hammer/lot-page.json +81 −0
@@ -0,0 +1,81 @@
1 +{
2 + "raw": {
3 + "url": "https://www.whiskyhammer.com/auction/past/auc-135/",
4 + "externalId": "auction:135:page:1",
5 + "kind": "sale",
6 + "engine": "firecrawl",
7 + "fetchedAt": "2026-09-07T07:25:19.417Z",
8 + "payload": {
9 + "kind": "lot_page",
10 + "url": "https://www.whiskyhammer.com/auction/past/auc-135/",
11 + "auctionId": "135",
12 + "page": 1,
13 + "totalPages": 63,
14 + "totalItems": 3124,
15 + "lots": [
16 + {
17 + "itemId": "261971",
18 + "url": "https://www.whiskyhammer.com/item/261971/Glenfarclas/Glenfarclas---62-Year-Old-1954-Pagoda-Ruby-Reserve-Collectors-Gold-Edition-Bottle-1.html",
19 + "title": "Glenfarclas - 62 Year Old (1954) Pagoda Ruby Reserve (Collector's Gold Edition) Bottle #1",
20 + "soldOn": "23/08/2026",
21 + "priceGbp": 9600,
22 + "image": "https://www.whiskyhammer.com/uploads/images/products/newthumbs/1786544996IMG_0074.jpg",
23 + "warehouse": "GB"
24 + },
25 + {
26 + "itemId": "260582",
27 + "url": "https://www.whiskyhammer.com/item/260582/Bowmore/Bowmore---Black-Bowmore-30-Year-Old-1964-2nd-Edition.html",
28 + "title": "Bowmore - 'Black Bowmore' 30 Year Old (1964) 2nd Edition",
29 + "soldOn": null,
30 + "priceGbp": null,
31 + "image": "https://www.whiskyhammer.com/uploads/images/products/newthumbs/1786352997IMG_0020.jpg",
32 + "warehouse": "GB"
33 + },
34 + {
35 + "itemId": "258785",
36 + "url": "https://www.whiskyhammer.com/item/258785/Macallan/Macallan---James-Bond-60th-Anniversary-Complete-Collection-Decade-I-VI--18-Year-Old-2007-James-Bond-Diamonds-Are-Forever-55th-Anniversary-7-x-70cl.html",
37 + "title": "Macallan - James Bond 60th Anniversary Complete Collection (Decade I-VI) & 18 Year Old (2007) James Bond Diamonds Are Forever 55th Anniversary (7 x 70cl)",
38 + "soldOn": "23/08/2026",
39 + "priceGbp": 5800,
40 + "image": "https://www.whiskyhammer.com/uploads/images/products/newthumbs/1784190855IMG_0060.jpg",
41 + "warehouse": "GB"
42 + },
43 + {
44 + "itemId": "256419",
45 + "url": "https://www.whiskyhammer.com/item/256419/Macallan/Macallan---The-Archival-Series---Folio-1.html",
46 + "title": "Macallan - The Archival Series - Folio 1",
47 + "soldOn": "23/08/2026",
48 + "priceGbp": 5400,
49 + "image": "https://www.whiskyhammer.com/uploads/images/products/newthumbs/1784205111IMG_0030.jpg",
50 + "warehouse": "GB"
51 + },
52 + {
53 + "itemId": "261928",
54 + "url": "https://www.whiskyhammer.com/item/261928/Dalmore/Dalmore---40-Year-Old-2023-Release.html",
55 + "title": "Dalmore - 40 Year Old (2023 Release)",
56 + "soldOn": null,
57 + "priceGbp": null,
58 + "image": "https://www.whiskyhammer.com/uploads/images/products/newthumbs/1786542672IMG_0043.jpg",
59 + "warehouse": "GB"
60 + },
61 + {
62 + "itemId": "262379",
63 + "url": "https://www.whiskyhammer.com/item/262379/Glenlivet/Glenlivet---Decades-Collection-Gordon--MacPhail-Private-Collection-5x70cl.html",
64 + "title": "Glenlivet - Decades Collection (Gordon & MacPhail) Private Collection (5x70cl)",
65 + "soldOn": "23/08/2026",
66 + "priceGbp": 3700,
67 + "image": "https://www.whiskyhammer.com/uploads/images/products/newthumbs/1786714597IMG_0035.jpg",
68 + "warehouse": "NL"
69 + }
70 + ]
71 + }
72 + },
73 + "expect": {
74 + "count": 4,
75 + "kinds": [
76 + "sale"
77 + ]
78 + },
79 + "note": "Captured live from https://www.whiskyhammer.com/auction/past/auc-135/ (lists trimmed to 6).",
80 + "capturedAt": "2026-09-07T07:25:19.422Z"
81 +}
\ No newline at end of file
82