Connectors: cars, whisky, wine auction results (agent V); registry 93 connectors
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
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&so=0&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&so=0&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&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(/&/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