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