SPB Git

spb/rareindex

Public

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 (Sep 7, 2026) parent d185538 Browse files

36 changed files +4,971 −136

36 changed files
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
Large diff (33 lines) is not loaded by default to keep the page fast.Load diff
added connectors/firecrawl/whisky-hammer/_smoke.ts +7 −0
Large diff (7 lines) is not loaded by default to keep the page fast.Load diff
added connectors/firecrawl/whisky-hammer/index.test.ts +36 −0
Large diff (36 lines) is not loaded by default to keep the page fast.Load diff
added connectors/firecrawl/whisky-hammer/index.ts +162 −0
Large diff (162 lines) is not loaded by default to keep the page fast.Load diff
added connectors/firecrawl/whisky-hammer/meta.json +33 −0
Large diff (33 lines) is not loaded by default to keep the page fast.Load diff
modified connectors/registry.json +2158 −136
Large diff (2,492 lines) is not loaded by default to keep the page fast.Load diff
added data/fixtures/gooding/realized-auction.json +88 −0
Large diff (89 lines) is not loaded by default to keep the page fast.Load diff
added data/fixtures/hdh-wine/results-pdf.json +345 −0
Large diff (346 lines) is not loaded by default to keep the page fast.Load diff
added data/fixtures/historics/lots-page.json +92 −0
Large diff (93 lines) is not loaded by default to keep the page fast.Load diff
added data/fixtures/just-whisky/lots-page.json +177 −0
Large diff (178 lines) is not loaded by default to keep the page fast.Load diff
added data/fixtures/pcarmarket/results-page.json +172 −0
Large diff (173 lines) is not loaded by default to keep the page fast.Load diff
added data/fixtures/the-market-bonhams/results-page.json +84 −0
Large diff (85 lines) is not loaded by default to keep the page fast.Load diff
added data/fixtures/whisky-hammer/lot-page.json +81 −0
Large diff (83 lines) is not loaded by default to keep the page fast.Load diff
Menu

Theme