Connectors: Potter, Heffel, Cherrystone, Kelleher auction results (agent T)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
20 changed files +1,534 −0
added
connectors/api/heffel/_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, Number(process.argv[2] ?? 1)); | |
added
connectors/api/heffel/index.test.ts
+55 −0
@@ -0,0 +1,55 @@ | ||
| 1 | +import { describe, expect, it } from 'vitest'; | |
| 2 | +import { readFileSync } from 'node:fs'; | |
| 3 | +import path from 'node:path'; | |
| 4 | +import { fileURLToPath } from 'node:url'; | |
| 5 | +import { loadFixture, runFixtureSuite } from '@rareindex/connectors/testing'; | |
| 6 | +import { localMeta } from '../_lib/local-meta.js'; | |
| 7 | +import createConnector, { fallbackDate, parseResultsIndex, parseResultsPage } from './index.js'; | |
| 8 | + | |
| 9 | +const dir = path.dirname(fileURLToPath(import.meta.url)); | |
| 10 | +const connector = createConnector(localMeta(JSON.parse(readFileSync(path.join(dir, 'meta.json'), 'utf8')))); | |
| 11 | + | |
| 12 | +const INDEX = `<a href="/Links/Results_E.aspx?Request=AAA">Spring 2026</a> <a href="/Links/Results_E.aspx?Request=BBB">August 2026</a> <a href="/Links/Results_E.aspx?Request=AAA">Spring 2026</a>`; | |
| 13 | +const PAGE = `<body><div>SPRING 2026 LIVE AUCTION RESULTS Sale date: Thursday, May 21 2026 SESSIONS: Post-War & Contemporary Art () Old Master, Impressionist & Modern Art () The following Lots were sold at the prices stated. Prices include Buyer's Premium.</div> | |
| 14 | +<table><tr class="height-adj-md-0x fixed_height_1"> <td><a href='#' class='text ez'>001 <span class='ez'><img src='/Images/Lots/150//PCRE-05022-0014-01.jpg'></span></a></td> | |
| 15 | +<td><span class="text"><a href="/Auction/LotDetails_E.aspx?Request=LOT1">Untitled <br /><i>Molinari, Guido </i></a></span></td><td><span class="text">$73,250 </span></td></tr> | |
| 16 | +<tr class="height-adj-md-0x fixed_height_1"> <td><a href='#' class='text ez'>002 <span class='ez'><img src='/Images/Lots/150//PCRE-04581-0021-01.jpg'></span></a></td> | |
| 17 | +<td><span class="text"><a href="/Auction/LotDetails_E.aspx?Request=LOT2">Snow Light in My House <br /><i>Pratt, Mary Frances </i></a></span></td><td><span class="text">$205,250 </span></td></tr></table></body>`; | |
| 18 | + | |
| 19 | +describe('heffel', () => { | |
| 20 | + runFixtureSuite(connector, it, expect); | |
| 21 | + | |
| 22 | + it('parses the results index (deduplicated) and a results page', () => { | |
| 23 | + const sales = parseResultsIndex(INDEX); | |
| 24 | + expect(sales.map((s) => s.label)).toEqual(['Spring 2026', 'August 2026']); | |
| 25 | + const p = parseResultsPage(PAGE, sales[0]!); | |
| 26 | + expect(p.saleDateText).toBe('May 21 2026'); | |
| 27 | + expect(p.premiumIncluded).toBe(true); | |
| 28 | + expect(p.sessions[0]).toContain('Post-War'); | |
| 29 | + expect(p.lots.length).toBe(2); | |
| 30 | + expect(p.lots[1]).toMatchObject({ lotNumber: '002', title: 'Snow Light in My House', artist: 'Pratt, Mary Frances', priceText: '$205,250', image: 'https://www.heffel.com/Images/Lots/150/PCRE-04581-0021-01.jpg' }); | |
| 31 | + }); | |
| 32 | + | |
| 33 | + it('normalises CAD sales with premium included and the published sale date', async () => { | |
| 34 | + const sale = parseResultsIndex(INDEX)[0]!; | |
| 35 | + const out = await connector.normalize({ url: 'x', externalId: 'a', kind: 'sale', engine: 'api', fetchedAt: new Date('2026-09-07T00:00:00Z'), payload: parseResultsPage(PAGE, sale) }); | |
| 36 | + expect(out.length).toBe(2); | |
| 37 | + const s = out[0]!; | |
| 38 | + if (s.kind !== 'sale') throw new Error('sale'); | |
| 39 | + expect(s).toMatchObject({ price: 73250, currency: 'CAD', buyerPremiumIncluded: true, lotNumber: '001', auctionHouse: 'Heffel', confidence: 0.9 }); | |
| 40 | + expect(s.saleDate.toISOString()).toBe('2026-05-21T00:00:00.000Z'); | |
| 41 | + expect(s.attributes.brand).toBe('Molinari, Guido'); | |
| 42 | + expect(['art', 'contemporary_art']).toContain(s.attributes.categorySlug); | |
| 43 | + }); | |
| 44 | + | |
| 45 | + it('falls back to the month of a monthly online sale', () => { | |
| 46 | + expect(fallbackDate('August 2026')?.toISOString()).toBe('2026-08-01T00:00:00.000Z'); | |
| 47 | + expect(fallbackDate('Spring 2026')).toBeNull(); | |
| 48 | + }); | |
| 49 | + | |
| 50 | + it('fixture lots are CAD with premium', async () => { | |
| 51 | + const out = await connector.normalize(loadFixture('heffel', 'results-page').raw); | |
| 52 | + expect(out.length).toBeGreaterThan(0); | |
| 53 | + for (const r of out) if (r.kind === 'sale') expect(r).toMatchObject({ currency: 'CAD', buyerPremiumIncluded: true }); | |
| 54 | + }); | |
| 55 | +}); | |
added
connectors/api/heffel/index.ts
+182 −0
@@ -0,0 +1,182 @@ | ||
| 1 | +import { z } from 'zod'; | |
| 2 | +import { BaseConnector, type ConnectorMeta, type CrawlContext, type RawRecordInput, type RawRecordLike } from '@rareindex/connectors'; | |
| 3 | +import type { NormalizedRecord, NormalizedSale } from '@rareindex/shared'; | |
| 4 | +import { dateWords, lotAttributes, makeSale, money } from '../../firecrawl/_carlib/index.js'; | |
| 5 | +import { safeYear, slugFromTitle } from '../_auction-lib/categories.js'; | |
| 6 | + | |
| 7 | +/** | |
| 8 | + * Heffel (Canada) — auction results with prices realized (CAD, buyer's premium included). | |
| 9 | + * Public ASP.NET pages, plain HTTPS. See meta.json accessNotes. | |
| 10 | + */ | |
| 11 | + | |
| 12 | +const SITE = 'https://www.heffel.com'; | |
| 13 | +const PARSER_VERSION = '1.0.0'; | |
| 14 | +const MONTHS: Record<string, number> = { january: 0, february: 1, march: 2, april: 3, may: 4, june: 5, july: 6, august: 7, september: 8, october: 9, november: 10, december: 11 }; | |
| 15 | + | |
| 16 | +export const SaleRefSchema = z.object({ label: z.string(), url: z.string() }); | |
| 17 | +export const LotSchema = z.object({ lotNumber: z.string(), title: z.string(), artist: z.string().nullable(), url: z.string(), image: z.string().nullable(), priceText: z.string() }); | |
| 18 | +export const PayloadSchema = z.object({ | |
| 19 | + kind: z.literal('results_page'), | |
| 20 | + sale: SaleRefSchema, | |
| 21 | + saleName: z.string().nullable(), | |
| 22 | + saleDateText: z.string().nullable(), | |
| 23 | + sessions: z.array(z.string()), | |
| 24 | + premiumIncluded: z.boolean(), | |
| 25 | + lots: z.array(LotSchema), | |
| 26 | +}); | |
| 27 | +export type Payload = z.infer<typeof PayloadSchema>; | |
| 28 | + | |
| 29 | +function clean(s: string): string { | |
| 30 | + return s | |
| 31 | + .replace(/&/g, '&') | |
| 32 | + .replace(/�?39;|'/g, "'") | |
| 33 | + .replace(/"/g, '"') | |
| 34 | + .replace(/ /g, ' ') | |
| 35 | + .replace(/<[^>]+>/g, ' ') | |
| 36 | + .replace(/\s+/g, ' ') | |
| 37 | + .trim(); | |
| 38 | +} | |
| 39 | + | |
| 40 | +/** Results index → sale references (label like "Spring 2026" / "August 2026"). */ | |
| 41 | +export function parseResultsIndex(html: string): z.infer<typeof SaleRefSchema>[] { | |
| 42 | + const out: z.infer<typeof SaleRefSchema>[] = []; | |
| 43 | + const re = /<a[^>]+href="(\/Links\/Results_E(?:\.aspx)?\?Request=[^"]+)"[^>]*>([\s\S]*?)<\/a>/g; | |
| 44 | + let m: RegExpExecArray | null; | |
| 45 | + while ((m = re.exec(html))) { | |
| 46 | + const label = clean(m[2]!); | |
| 47 | + const url = `${SITE}${m[1]!.replace(/&/g, '&')}`; | |
| 48 | + if (!label || out.some((s) => s.url === url)) continue; | |
| 49 | + out.push({ label, url }); | |
| 50 | + } | |
| 51 | + return out; | |
| 52 | +} | |
| 53 | + | |
| 54 | +/** One results page → sale header + sold lots. */ | |
| 55 | +export function parseResultsPage(html: string, sale: z.infer<typeof SaleRefSchema>): Payload { | |
| 56 | + const text = clean(html.replace(/<script[\s\S]*?<\/script>|<style[\s\S]*?<\/style>/g, ' ')); | |
| 57 | + const saleName = text.match(/([A-Z][A-Z0-9 &'’,-]{6,80}?)\s+(?:LIVE\s+)?AUCTION RESULTS/)?.[1]?.trim() ?? null; | |
| 58 | + const saleDateText = text.match(/Sale date:\s*([A-Za-z]+,?\s*)?([A-Za-z]+ \d{1,2},? \d{4})/)?.[2] ?? null; | |
| 59 | + const sessions = [...text.matchAll(/SESSIONS?:\s*([^()]+?)\s*\(/g)].map((x) => x[1]!.trim()).slice(0, 6); | |
| 60 | + const premiumIncluded = /Prices include Buyer'?s Premium/i.test(text); | |
| 61 | + const lots: z.infer<typeof LotSchema>[] = []; | |
| 62 | + const row = /<tr class="height-adj[^"]*">([\s\S]*?)<\/tr>/g; | |
| 63 | + let m: RegExpExecArray | null; | |
| 64 | + while ((m = row.exec(html))) { | |
| 65 | + const r = m[1]!; | |
| 66 | + const num = r.match(/class='text ez'>\s*([0-9]+[A-Za-z]?)\s*</)?.[1]; | |
| 67 | + const img = r.match(/<img src='([^']+)'/)?.[1]; | |
| 68 | + const link = r.match(/<a href="(\/Auction\/LotDetails_E(?:\.aspx)?\?Request=[^"]+)">([\s\S]*?)<\/a>/); | |
| 69 | + const price = r.match(/<span class="text">\s*(\$[\d,]+(?:\.\d{2})?)\s*<\/span>/)?.[1]; | |
| 70 | + if (!num || !link || !price) continue; | |
| 71 | + const [titlePart, artistPart] = link[2]!.split(/<br\s*\/?>/i); | |
| 72 | + lots.push({ | |
| 73 | + lotNumber: num, | |
| 74 | + title: clean(titlePart ?? ''), | |
| 75 | + artist: artistPart ? clean(artistPart) || null : null, | |
| 76 | + url: `${SITE}${link[1]!.replace(/&/g, '&')}`, | |
| 77 | + image: img ? `${SITE}${img.replace(/\/{2,}/g, '/').replace(/^\/?/, '/')}` : null, | |
| 78 | + priceText: price, | |
| 79 | + }); | |
| 80 | + } | |
| 81 | + return { kind: 'results_page', sale, saleName, saleDateText, sessions, premiumIncluded, lots }; | |
| 82 | +} | |
| 83 | + | |
| 84 | +/** "Spring 2026" has no day; "August 2026" → first of month (flagged with lower confidence). */ | |
| 85 | +export function fallbackDate(label: string): Date | null { | |
| 86 | + const m = label.match(/([A-Za-z]+)\s+(\d{4})/); | |
| 87 | + if (!m) return null; | |
| 88 | + const mo = MONTHS[m[1]!.toLowerCase()]; | |
| 89 | + if (mo === undefined) return null; | |
| 90 | + return new Date(Date.UTC(Number(m[2]), mo, 1)); | |
| 91 | +} | |
| 92 | + | |
| 93 | +export class HeffelConnector extends BaseConnector { | |
| 94 | + readonly version = '1.0.0'; | |
| 95 | + readonly parserVersion = PARSER_VERSION; | |
| 96 | + protected override minIntervalMs = 2000; | |
| 97 | + | |
| 98 | + async *crawl(ctx: CrawlContext): AsyncIterable<RawRecordInput> { | |
| 99 | + const salesPerRun = Number(this.meta.config.salesPerRun ?? 3); | |
| 100 | + const done = new Set<string>(Array.isArray(ctx.options.cursor?.doneSales) ? (ctx.options.cursor!.doneSales as string[]) : []); | |
| 101 | + const indexUrl = `${SITE}/Links/Results_Choose_E.aspx`; | |
| 102 | + await this.throttle(); | |
| 103 | + const idx = await ctx.fetch(indexUrl, { responseType: 'text', expect: ['title'], parse: (r) => (r.html ? { title: parseResultsIndex(r.html)[0]?.label ?? null } : null) }); | |
| 104 | + if (!idx.success || !idx.html) { | |
| 105 | + ctx.anomaly('page_fetch_failed', `${indexUrl}: ${idx.error ?? idx.httpStatus}`); | |
| 106 | + return; | |
| 107 | + } | |
| 108 | + const sales = parseResultsIndex(idx.html).filter((s) => (ctx.options.mode === 'backfill' ? true : !done.has(s.label))); | |
| 109 | + let count = 0; | |
| 110 | + for (const sale of sales) { | |
| 111 | + if (count >= salesPerRun || ctx.signal?.aborted || this.reached(ctx, count)) break; | |
| 112 | + await this.throttle(); | |
| 113 | + const res = await ctx.fetch(sale.url, { | |
| 114 | + responseType: 'text', | |
| 115 | + expect: ['title', 'price', 'date'], | |
| 116 | + parse: (r) => { | |
| 117 | + if (!r.html) return null; | |
| 118 | + const p = parseResultsPage(r.html, sale); | |
| 119 | + return { title: p.lots[0]?.title ?? null, price: p.lots[0] ? money(p.lots[0].priceText, 'CAD')?.amount ?? null : null, date: p.saleDateText ?? fallbackDate(sale.label) }; | |
| 120 | + }, | |
| 121 | + }); | |
| 122 | + if (!res.success || !res.html) { | |
| 123 | + ctx.anomaly('page_fetch_failed', `${sale.url}: ${res.error ?? res.httpStatus}`); | |
| 124 | + continue; | |
| 125 | + } | |
| 126 | + const payload = parseResultsPage(res.html, sale); | |
| 127 | + if (payload.lots.length === 0) { | |
| 128 | + ctx.anomaly('empty_results_page', sale.label); | |
| 129 | + continue; | |
| 130 | + } | |
| 131 | + count++; | |
| 132 | + yield { url: sale.url, externalId: `results:${sale.label}`, kind: 'sale', engine: res.engine, httpStatus: res.httpStatus, payload, fetchedAt: res.fetchedAt }; | |
| 133 | + done.add(sale.label); | |
| 134 | + await ctx.setCursor({ doneSales: [...done].slice(-100), updatedAt: new Date().toISOString() }); | |
| 135 | + } | |
| 136 | + } | |
| 137 | + | |
| 138 | + async normalize(raw: RawRecordLike): Promise<NormalizedRecord[]> { | |
| 139 | + const p = PayloadSchema.parse(raw.payload); | |
| 140 | + const exact = dateWords(p.saleDateText); | |
| 141 | + const saleDate = exact ?? fallbackDate(p.sale.label); | |
| 142 | + if (!saleDate) return []; | |
| 143 | + const out: NormalizedSale[] = []; | |
| 144 | + for (const lot of p.lots) { | |
| 145 | + const m = money(lot.priceText, 'CAD'); | |
| 146 | + if (!m) continue; | |
| 147 | + const categorySlug = slugFromTitle(`${lot.title} ${lot.artist ?? ''}`, /post-war|contemporary/i.test(p.saleName ?? '') ? 'contemporary' : 'art') ?? 'art'; | |
| 148 | + const attributes = lotAttributes({ | |
| 149 | + categorySlug, | |
| 150 | + name: lot.artist ? `${lot.artist} — ${lot.title}` : lot.title, | |
| 151 | + brand: lot.artist, | |
| 152 | + year: safeYear(lot.title), | |
| 153 | + country: 'CA', | |
| 154 | + identifiers: { heffel_lot: `${p.sale.label.replace(/\s+/g, '-').toLowerCase()}-${lot.lotNumber}` }, | |
| 155 | + metadata: { sale: p.sale.label, sale_name: p.saleName, sessions: p.sessions, artist: lot.artist }, | |
| 156 | + }); | |
| 157 | + out.push( | |
| 158 | + makeSale({ | |
| 159 | + meta: this.meta, | |
| 160 | + sourceUrl: lot.url, | |
| 161 | + externalId: `${p.sale.label}-${lot.lotNumber}`, | |
| 162 | + rawTitle: lot.artist ? `${lot.artist}: ${lot.title}` : lot.title, | |
| 163 | + attributes, | |
| 164 | + price: m.amount, | |
| 165 | + currency: 'CAD', | |
| 166 | + saleDate, | |
| 167 | + buyerPremiumIncluded: p.premiumIncluded ? true : null, | |
| 168 | + auctionHouse: 'Heffel', | |
| 169 | + lotNumber: lot.lotNumber, | |
| 170 | + imageUrls: lot.image ? [lot.image] : [], | |
| 171 | + observedAt: raw.fetchedAt, | |
| 172 | + confidence: exact ? 0.9 : 0.7, | |
| 173 | + parserVersion: PARSER_VERSION, | |
| 174 | + location: 'CA', | |
| 175 | + }), | |
| 176 | + ); | |
| 177 | + } | |
| 178 | + return out; | |
| 179 | + } | |
| 180 | +} | |
| 181 | + | |
| 182 | +export default (meta: ConnectorMeta) => new HeffelConnector(meta); | |
added
connectors/api/heffel/meta.json
+32 −0
@@ -0,0 +1,32 @@ | ||
| 1 | +{ | |
| 2 | + "id": "heffel", | |
| 3 | + "displayName": "Heffel Fine Art Auction House (auction results)", | |
| 4 | + "sourceId": "heffel", | |
| 5 | + "sourceName": "Heffel Fine Art Auction House", | |
| 6 | + "sourceType": "auction_house", | |
| 7 | + "sourceUrl": "https://www.heffel.com", | |
| 8 | + "module": "api/heffel", | |
| 9 | + "enginePriority": ["api", "firecrawl"], | |
| 10 | + "categories": ["art", "contemporary_art", "photography", "design_furniture"], | |
| 11 | + "regions": ["CA"], | |
| 12 | + "languages": ["en", "fr"], | |
| 13 | + "currency": ["CAD"], | |
| 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.heffel.com/Auction/Terms_Defined_E.pdf", | |
| 26 | + "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.", | |
| 27 | + "enabled": true, | |
| 28 | + "schemaVersion": "1.0", | |
| 29 | + "config": { | |
| 30 | + "salesPerRun": 3 | |
| 31 | + } | |
| 32 | +} | |
added
connectors/api/kelleher/_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] ?? 'category-page', Number(process.argv[4] ?? 6)); | |
| 7 | +else await runSmoke(dir, Number(process.argv[2] ?? 2)); | |
added
connectors/api/kelleher/index.test.ts
+60 −0
@@ -0,0 +1,60 @@ | ||
| 1 | +import { describe, expect, it } from 'vitest'; | |
| 2 | +import { readFileSync } from 'node:fs'; | |
| 3 | +import path from 'node:path'; | |
| 4 | +import { fileURLToPath } from 'node:url'; | |
| 5 | +import { loadFixture, runFixtureSuite } from '@rareindex/connectors/testing'; | |
| 6 | +import { localMeta } from '../_lib/local-meta.js'; | |
| 7 | +import createConnector, { closedDate, kelleherCategory, parseCategoryPage, parseFirmPage, parseGroupLinks } from './index.js'; | |
| 8 | + | |
| 9 | +const dir = path.dirname(fileURLToPath(import.meta.url)); | |
| 10 | +const connector = createConnector(localMeta(JSON.parse(readFileSync(path.join(dir, 'meta.json'), 'utf8')))); | |
| 11 | + | |
| 12 | +const FIRM = `<li><strong>Daniel F. Kelleher Auctions, LLC - Sale 5337</strong></font> <A HREF="https://StampAuctionNetwork.com/cCatalog.cfm?SrchFirm=V&SrchSale=5337"> US, British and Worldwide Stamps and Postal History - August 30, 2026 </A> | |
| 13 | +<A NAME = "PricesRealized"></a> <li> Daniel F. Kelleher Auctions, LLC <A HREF="https://StampAuctionNetwork.com/cCatalog.cfm?SrchFirm=V&SrchSale=5303"> US, British and Worldwide Stamps and Postal History - December 28, 2025 </A> <li> Daniel F. Kelleher Auctions, LLC <A HREF="https://StampAuctionNetwork.com/V/V830.cfm"> US & Worldwide Coins & Banknotes - December 22, 2025 </A>`; | |
| 14 | +const GROUPS = `<a href="cCatalog2.cfm?MAJGROUP=United%20States&SrchFirm=V&SrchSale=5303">US</a><a href="cCatalog2.cfm?MAJGROUP=United%20States&SrchFirm=V&SrchSale=5303">dup</a>`; | |
| 15 | +const CATS = `<a href="https://StampAuctionNetwork.com/cCatalog3.cfm?MAJGROUP=United%20States&CATDESCR=1847%20Issue&SrchFirm=V&SrchSale=5303">1847</a>`; | |
| 16 | +const PAGE = `<table id="CatTable" BORDER=1><tr><td><strong><font size="+1"> Sale No: 5303 <br>Lot No: 9001 <br>Symbol: Z <br>Cat No: 1 <P> </font></strong></td><td valign="TOP" width="65%" bgcolor="EEEEEE" align="left"> <A HREF="https://StampAuctionNetwork.com/Photos/V/5303/9001.jpg" target="_blank" class="MagicZoomPlus"> <IMG ALIGN=LEFT SRC="https://StampAuctionNetwork.com/Photos/V/5303/9001.jpg" alt="image" width="150p"> </a> <B>1847, 5¢ red brown (Scott 1),</B> red cancel; creased along 2 crossed letter folds, Fine.<BR>Scott $425. <A HREF="https://StampAuctionNetwork.com/Photos/V/5303/9001.jpg" target="_blank"> <b>(Image)</b></a> <P>Suggested Bid $62 </td><td align="right"> Opening US$ 74.00 <br>Sold...US$ 74.00 <BR><BR> <br>Closed..Dec-28-2025, 21:00:00 EST <br>Sold For 74 </td></tr></table> | |
| 17 | +<table id="CatTable" BORDER=1><tr><td><strong><font size="+1"> Sale No: 5303 <br>Lot No: 9002 <br>Cat No: 2 </font></strong></td><td valign="TOP" width="65%" bgcolor="EEEEEE" align="left"> <B>1847, 10¢ black (Scott 2),</B> used, fine. </td><td align="right"> Opening US$ 300.00 <br>Unsold <BR><br>Closed..Dec-28-2025, 21:00:00 EST </td></tr></table>`; | |
| 18 | + | |
| 19 | +describe('kelleher', () => { | |
| 20 | + runFixtureSuite(connector, it, expect); | |
| 21 | + | |
| 22 | + it('discovers only cCatalog sales after the PricesRealized anchor', () => { | |
| 23 | + const sales = parseFirmPage(FIRM); | |
| 24 | + expect(sales).toEqual([{ sale: '5303', title: 'US, British and Worldwide Stamps and Postal History', dateText: 'December 28, 2025', url: 'https://StampAuctionNetwork.com/cCatalog.cfm?SrchFirm=V&SrchSale=5303' }]); | |
| 25 | + expect(parseGroupLinks(GROUPS, 2)).toEqual(['https://StampAuctionNetwork.com/cCatalog2.cfm?MAJGROUP=United%20States&SrchFirm=V&SrchSale=5303']); | |
| 26 | + expect(parseGroupLinks(CATS, 3).length).toBe(1); | |
| 27 | + }); | |
| 28 | + | |
| 29 | + it('parses lot tables and normalises sold lots as hammer prices dated by Closed', async () => { | |
| 30 | + const sale = parseFirmPage(FIRM)[0]!; | |
| 31 | + const url = 'https://StampAuctionNetwork.com/cCatalog3.cfm?MAJGROUP=United%20States&CATDESCR=1847%20Issue&SrchFirm=V&SrchSale=5303'; | |
| 32 | + const p = parseCategoryPage(PAGE, sale, url); | |
| 33 | + expect(p.majorGroup).toBe('United States'); | |
| 34 | + expect(p.category).toBe('1847 Issue'); | |
| 35 | + expect(p.lots.length).toBe(2); | |
| 36 | + expect(p.lots[0]).toMatchObject({ lotNumber: '9001', catNo: '1', headline: '1847, 5¢ red brown (Scott 1),', soldText: 'US$ 74.00', closedText: 'Dec-28-2025', soldFor: '74', image: 'https://StampAuctionNetwork.com/Photos/V/5303/9001.jpg' }); | |
| 37 | + expect(p.lots[1]!.soldFor).toBeNull(); | |
| 38 | + const out = await connector.normalize({ url, externalId: 'a', kind: 'sale', engine: 'api', fetchedAt: new Date('2026-09-07T00:00:00Z'), payload: p }); | |
| 39 | + expect(out.length).toBe(1); | |
| 40 | + const s = out[0]!; | |
| 41 | + if (s.kind !== 'sale') throw new Error('sale'); | |
| 42 | + expect(s).toMatchObject({ price: 74, currency: 'USD', buyerPremiumIncluded: false, lotNumber: '9001', auctionHouse: 'Daniel F. Kelleher Auctions' }); | |
| 43 | + expect(s.saleDate.toISOString()).toBe('2025-12-28T00:00:00.000Z'); | |
| 44 | + expect(s.attributes).toMatchObject({ categorySlug: 'stamps', set: '1847 Issue', country: 'United States', year: 1847 }); | |
| 45 | + expect(s.attributes.identifiers.san_lot).toBe('V-5303-9001'); | |
| 46 | + }); | |
| 47 | + | |
| 48 | + it('helpers', () => { | |
| 49 | + expect(closedDate('Dec-28-2025')?.toISOString()).toBe('2025-12-28T00:00:00.000Z'); | |
| 50 | + expect(kelleherCategory('United States', '1847 5c red brown')).toBe('stamps'); | |
| 51 | + expect(kelleherCategory('Coins', '1921 Morgan dollar')).toBe('coins'); | |
| 52 | + expect(kelleherCategory('Paper Money', '$5 legal tender note')).toBe('banknotes'); | |
| 53 | + }); | |
| 54 | + | |
| 55 | + it('fixture lots normalise to USD hammer sales', async () => { | |
| 56 | + const out = await connector.normalize(loadFixture('kelleher', 'category-page').raw); | |
| 57 | + expect(out.length).toBeGreaterThan(0); | |
| 58 | + for (const r of out) if (r.kind === 'sale') expect(r).toMatchObject({ currency: 'USD', buyerPremiumIncluded: false }); | |
| 59 | + }); | |
| 60 | +}); | |
added
connectors/api/kelleher/index.ts
+232 −0
@@ -0,0 +1,232 @@ | ||
| 1 | +import { z } from 'zod'; | |
| 2 | +import { BaseConnector, type ConnectorMeta, type CrawlContext, type RawRecordInput, type RawRecordLike } from '@rareindex/connectors'; | |
| 3 | +import type { NormalizedRecord, NormalizedSale } from '@rareindex/shared'; | |
| 4 | +import { dateWords, lotAttributes, makeSale } from '../../firecrawl/_carlib/index.js'; | |
| 5 | +import { safeYear } from '../_auction-lib/categories.js'; | |
| 6 | + | |
| 7 | +/** | |
| 8 | + * Daniel F. Kelleher Auctions — prices realized hosted on Stamp Auction Network (SAN). | |
| 9 | + * Plain HTTPS. See meta.json accessNotes. | |
| 10 | + */ | |
| 11 | + | |
| 12 | +const SAN = 'https://StampAuctionNetwork.com'; | |
| 13 | +const PARSER_VERSION = '1.0.0'; | |
| 14 | +const MON: 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 }; | |
| 15 | + | |
| 16 | +export const SaleSchema = z.object({ sale: z.string(), title: z.string(), dateText: z.string().nullable(), url: z.string() }); | |
| 17 | +export const LotSchema = z.object({ | |
| 18 | + lotNumber: z.string(), | |
| 19 | + catNo: z.string().nullable(), | |
| 20 | + headline: z.string().nullable(), | |
| 21 | + description: z.string(), | |
| 22 | + image: z.string().nullable(), | |
| 23 | + soldText: z.string().nullable(), | |
| 24 | + closedText: z.string().nullable(), | |
| 25 | + soldFor: z.string().nullable(), | |
| 26 | +}); | |
| 27 | +export const PayloadSchema = z.object({ kind: z.literal('category_page'), sale: SaleSchema, majorGroup: z.string(), category: z.string(), url: z.string(), lots: z.array(LotSchema) }); | |
| 28 | +export type Payload = z.infer<typeof PayloadSchema>; | |
| 29 | + | |
| 30 | +function clean(s: string): string { | |
| 31 | + return s.replace(/&/g, '&').replace(/¢/g, '¢').replace(/�?39;|'/g, "'").replace(/"/g, '"').replace(/ /g, ' ').replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim(); | |
| 32 | +} | |
| 33 | + | |
| 34 | +/** | |
| 35 | + * Firm page (Kelleher.cfm) or the SAN archive (pr.cfm) → Kelleher sales in the cCatalog format. | |
| 36 | + * When the page has a "PricesRealized" anchor only the section after it is used (closed sales). | |
| 37 | + */ | |
| 38 | +export function parseFirmPage(html: string): z.infer<typeof SaleSchema>[] { | |
| 39 | + const out: z.infer<typeof SaleSchema>[] = []; | |
| 40 | + const anchor = html.search(/NAME\s*=\s*"PricesRealized"/i); | |
| 41 | + const scope = anchor >= 0 ? html.slice(anchor) : html; | |
| 42 | + const re = /Kelleher Auctions[^<]{0,60}(?:<\/strong>)?\s*(?:<\/font>)?\s*<A HREF="(https?:\/\/StampAuctionNetwork\.com\/cCatalog\.cfm\?SrchFirm=V&(?:amp;)?SrchSale=(\d+))">\s*([\s\S]*?)<\/A>/gi; | |
| 43 | + let m: RegExpExecArray | null; | |
| 44 | + while ((m = re.exec(scope))) { | |
| 45 | + if (out.some((s) => s.sale === m![2])) continue; | |
| 46 | + const label = clean(m[3]!); | |
| 47 | + const dm = label.match(/-\s*([A-Za-z]+ \d{1,2}(?:\s*[-–]\s*\d{1,2})?,\s*\d{4})\s*$/); | |
| 48 | + out.push({ sale: m[2]!, title: dm ? label.slice(0, dm.index).trim() : label, dateText: dm ? dm[1]! : null, url: m[1]!.replace(/&/g, '&') }); | |
| 49 | + } | |
| 50 | + return out; | |
| 51 | +} | |
| 52 | + | |
| 53 | +export function parseGroupLinks(html: string, level: 2 | 3): string[] { | |
| 54 | + const re = level === 2 ? /href="(cCatalog2\.cfm\?[^"]+)"/gi : /href="(https?:\/\/StampAuctionNetwork\.com\/cCatalog3\.cfm\?[^"]+)"/gi; | |
| 55 | + const out: string[] = []; | |
| 56 | + let m: RegExpExecArray | null; | |
| 57 | + while ((m = re.exec(html))) { | |
| 58 | + const u = (m[1]!.startsWith('http') ? m[1]! : `${SAN}/${m[1]!}`).replace(/&/g, '&'); | |
| 59 | + if (!out.includes(u)) out.push(u); | |
| 60 | + } | |
| 61 | + return out; | |
| 62 | +} | |
| 63 | + | |
| 64 | +export function parseCategoryPage(html: string, sale: z.infer<typeof SaleSchema>, url: string): Payload { | |
| 65 | + const q = new URL(url).searchParams; | |
| 66 | + const majorGroup = q.get('MAJGROUP') ?? ''; | |
| 67 | + const category = q.get('CATDESCR') ?? ''; | |
| 68 | + const lots: z.infer<typeof LotSchema>[] = []; | |
| 69 | + const tables = html.split(/<table id="CatTable"/i).slice(1); | |
| 70 | + for (const t of tables) { | |
| 71 | + const lotNumber = t.match(/Lot No:\s*(\d+)/)?.[1]; | |
| 72 | + if (!lotNumber) continue; | |
| 73 | + const descCell = t.match(/bgcolor="EEEEEE"[^>]*>([\s\S]*?)<\/td>/i)?.[1] ?? ''; | |
| 74 | + const headline = descCell.match(/<B>([\s\S]*?)<\/B>/i)?.[1]; | |
| 75 | + const description = clean(descCell.replace(/<A HREF="[^"]*Photos[^"]*"[^>]*>[\s\S]*?<\/a>/gi, ' ').replace(/Suggested Bid.*$/i, '')); | |
| 76 | + lots.push({ | |
| 77 | + lotNumber, | |
| 78 | + catNo: t.match(/Cat No:\s*([^<\n]+)/)?.[1]?.trim() ?? null, | |
| 79 | + headline: headline ? clean(headline) : null, | |
| 80 | + description, | |
| 81 | + image: t.match(/SRC="(https?:\/\/StampAuctionNetwork\.com\/Photos\/[^"]+)"/i)?.[1] ?? null, | |
| 82 | + soldText: t.match(/Sold\.{2,}\s*([A-Z$]+\s?[\d,]+(?:\.\d{2})?)/)?.[1]?.trim() ?? null, | |
| 83 | + closedText: t.match(/Closed\.{2,}\s*([A-Za-z]{3}-\d{1,2}-\d{4})/)?.[1] ?? null, | |
| 84 | + soldFor: t.match(/Sold For\s+([\d,]+(?:\.\d{2})?)/)?.[1] ?? null, | |
| 85 | + }); | |
| 86 | + } | |
| 87 | + return { kind: 'category_page', sale, majorGroup, category, url, lots }; | |
| 88 | +} | |
| 89 | + | |
| 90 | +export function closedDate(s: string | null): Date | null { | |
| 91 | + const m = s?.match(/([A-Za-z]{3})-(\d{1,2})-(\d{4})/); | |
| 92 | + if (!m) return null; | |
| 93 | + const mo = MON[m[1]!.toLowerCase()]; | |
| 94 | + if (mo === undefined) return null; | |
| 95 | + return new Date(Date.UTC(Number(m[3]), mo, Number(m[2]))); | |
| 96 | +} | |
| 97 | + | |
| 98 | +export function kelleherCategory(majorGroup: string, text: string): string { | |
| 99 | + const t = `${majorGroup} ${text}`.toLowerCase(); | |
| 100 | + if (/banknote|bank note|paper money|currency/.test(t)) return 'banknotes'; | |
| 101 | + if (/\bcoins?\b|numismat|medal\b/.test(t) && !/stamp|cover|postal/.test(t)) return 'coins'; | |
| 102 | + return 'stamps'; | |
| 103 | +} | |
| 104 | + | |
| 105 | +export class KelleherConnector extends BaseConnector { | |
| 106 | + readonly version = '1.0.0'; | |
| 107 | + readonly parserVersion = PARSER_VERSION; | |
| 108 | + protected override minIntervalMs = 2000; | |
| 109 | + | |
| 110 | + async *crawl(ctx: CrawlContext): AsyncIterable<RawRecordInput> { | |
| 111 | + const salesPerRun = Number(this.meta.config.salesPerRun ?? 1); | |
| 112 | + const pagesPerRun = Number(this.meta.config.categoryPagesPerRun ?? 30); | |
| 113 | + const cursor = (ctx.options.cursor ?? {}) as { doneSales?: string[]; progress?: Record<string, string[]> }; | |
| 114 | + const done = new Set<string>(cursor.doneSales ?? []); | |
| 115 | + const progress: Record<string, string[]> = cursor.progress ?? {}; | |
| 116 | + const firmUrl = `${SAN}/Kelleher.cfm`; | |
| 117 | + await this.throttle(); | |
| 118 | + const firm = await ctx.fetch(firmUrl, { responseType: 'text', expect: ['title', 'date'], parse: (r) => ({ title: r.html ? parseFirmPage(r.html)[0]?.title ?? null : null, date: r.html ? parseFirmPage(r.html)[0]?.dateText ?? null : null }) }); | |
| 119 | + if (!firm.success || !firm.html) { | |
| 120 | + ctx.anomaly('page_fetch_failed', `${firmUrl}: ${firm.error ?? firm.httpStatus}`); | |
| 121 | + return; | |
| 122 | + } | |
| 123 | + let discovered = parseFirmPage(firm.html); | |
| 124 | + if (ctx.options.mode === 'backfill') { | |
| 125 | + await this.throttle(); | |
| 126 | + const archive = await ctx.fetch(`${SAN}/pr.cfm`, { responseType: 'text', minQuality: 0 }); | |
| 127 | + if (archive.success && archive.html) for (const s of parseFirmPage(archive.html)) if (!discovered.some((d) => d.sale === s.sale)) discovered.push(s); | |
| 128 | + } | |
| 129 | + // Only sales whose published date is in the past can have prices realized. | |
| 130 | + discovered = discovered.filter((s) => !s.dateText || (dateWords(s.dateText)?.getTime() ?? 0) <= Date.now()); | |
| 131 | + const sales = discovered.filter((s) => !done.has(s.sale)); | |
| 132 | + let count = 0; | |
| 133 | + let processed = 0; | |
| 134 | + for (const sale of sales) { | |
| 135 | + if (processed >= salesPerRun || ctx.signal?.aborted) break; | |
| 136 | + await this.throttle(); | |
| 137 | + const top = await ctx.fetch(sale.url, { responseType: 'text', expect: ['title'], parse: (r) => ({ title: r.html && parseGroupLinks(r.html, 2).length ? sale.title : null }) }); | |
| 138 | + if (!top.success || !top.html) { | |
| 139 | + ctx.anomaly('page_fetch_failed', `${sale.url}: ${top.error ?? top.httpStatus}`); | |
| 140 | + break; | |
| 141 | + } | |
| 142 | + const seen = new Set(progress[sale.sale] ?? []); | |
| 143 | + const pages: string[] = []; | |
| 144 | + for (const g of parseGroupLinks(top.html, 2)) { | |
| 145 | + if (ctx.signal?.aborted) break; | |
| 146 | + await this.throttle(); | |
| 147 | + const grp = await ctx.fetch(g, { responseType: 'text', minQuality: 0 }); | |
| 148 | + if (grp.success && grp.html) pages.push(...parseGroupLinks(grp.html, 3)); | |
| 149 | + } | |
| 150 | + const todo = pages.filter((p) => !seen.has(p)); | |
| 151 | + let n = 0; | |
| 152 | + for (const p of todo) { | |
| 153 | + if (n >= pagesPerRun || ctx.signal?.aborted || this.reached(ctx, count)) break; | |
| 154 | + await this.throttle(); | |
| 155 | + const res = await ctx.fetch(p, { | |
| 156 | + responseType: 'text', | |
| 157 | + expect: ['title', 'price', 'date'], | |
| 158 | + parse: (r) => { | |
| 159 | + const f = r.html ? parseCategoryPage(r.html, sale, p).lots.find((l) => l.soldFor) : null; | |
| 160 | + return f ? { title: f.headline ?? f.description, price: Number(f.soldFor!.replace(/,/g, '')), date: f.closedText } : null; | |
| 161 | + }, | |
| 162 | + }); | |
| 163 | + n++; | |
| 164 | + seen.add(p); | |
| 165 | + if (!res.success || !res.html) { | |
| 166 | + ctx.anomaly('page_fetch_failed', `${p}: ${res.error ?? res.httpStatus}`); | |
| 167 | + continue; | |
| 168 | + } | |
| 169 | + const payload = parseCategoryPage(res.html, sale, p); | |
| 170 | + if (payload.lots.length === 0) continue; | |
| 171 | + count++; | |
| 172 | + yield { url: p, externalId: `sale:${sale.sale}:${payload.majorGroup}:${payload.category}`, kind: 'sale', engine: res.engine, httpStatus: res.httpStatus, payload, fetchedAt: res.fetchedAt }; | |
| 173 | + } | |
| 174 | + progress[sale.sale] = [...seen]; | |
| 175 | + if (todo.length - n <= 0) { | |
| 176 | + done.add(sale.sale); | |
| 177 | + delete progress[sale.sale]; | |
| 178 | + processed++; | |
| 179 | + } | |
| 180 | + await ctx.setCursor({ doneSales: [...done].slice(-60), progress, updatedAt: new Date().toISOString() }); | |
| 181 | + if (todo.length - n > 0) break; | |
| 182 | + } | |
| 183 | + } | |
| 184 | + | |
| 185 | + async normalize(raw: RawRecordLike): Promise<NormalizedRecord[]> { | |
| 186 | + const p = PayloadSchema.parse(raw.payload); | |
| 187 | + const out: NormalizedSale[] = []; | |
| 188 | + for (const lot of p.lots) { | |
| 189 | + if (!lot.soldFor) continue; | |
| 190 | + const amount = Number(lot.soldFor.replace(/,/g, '')); | |
| 191 | + const saleDate = closedDate(lot.closedText); | |
| 192 | + if (!Number.isFinite(amount) || amount <= 0 || !saleDate) continue; | |
| 193 | + const currency = /US\$|USD/.test(lot.soldText ?? 'US$') ? 'USD' : null; | |
| 194 | + if (!currency) continue; | |
| 195 | + const title = lot.headline ? `${lot.headline} ${lot.description.replace(lot.headline, '').trim()}`.trim() : lot.description; | |
| 196 | + const url = `${p.url}#lot-${lot.lotNumber}`; | |
| 197 | + const attributes = lotAttributes({ | |
| 198 | + categorySlug: kelleherCategory(p.majorGroup, title), | |
| 199 | + name: lot.headline ?? lot.description.slice(0, 160), | |
| 200 | + set: p.category || null, | |
| 201 | + country: p.majorGroup || null, | |
| 202 | + year: safeYear(lot.headline ?? lot.description), | |
| 203 | + identifiers: { san_lot: `V-${p.sale.sale}-${lot.lotNumber}` }, | |
| 204 | + metadata: { sale: p.sale.sale, sale_title: p.sale.title, major_group: p.majorGroup, category: p.category, cat_no: lot.catNo, price_is_hammer: true }, | |
| 205 | + }); | |
| 206 | + out.push( | |
| 207 | + makeSale({ | |
| 208 | + meta: this.meta, | |
| 209 | + sourceUrl: url, | |
| 210 | + externalId: `${p.sale.sale}-${lot.lotNumber}`, | |
| 211 | + rawTitle: title.slice(0, 500), | |
| 212 | + attributes, | |
| 213 | + price: amount, | |
| 214 | + currency: 'USD', | |
| 215 | + saleDate, | |
| 216 | + buyerPremiumIncluded: false, | |
| 217 | + auctionHouse: 'Daniel F. Kelleher Auctions', | |
| 218 | + lotNumber: lot.lotNumber, | |
| 219 | + imageUrls: lot.image ? [lot.image] : [], | |
| 220 | + description: lot.description, | |
| 221 | + observedAt: raw.fetchedAt, | |
| 222 | + parserVersion: PARSER_VERSION, | |
| 223 | + isBundle: /\b(collection|accumulation|balance|group|lot of|selection|remainder)\b/i.test(title), | |
| 224 | + location: 'US', | |
| 225 | + }), | |
| 226 | + ); | |
| 227 | + } | |
| 228 | + return out; | |
| 229 | + } | |
| 230 | +} | |
| 231 | + | |
| 232 | +export default (meta: ConnectorMeta) => new KelleherConnector(meta); | |
added
connectors/api/kelleher/meta.json
+33 −0
@@ -0,0 +1,33 @@ | ||
| 1 | +{ | |
| 2 | + "id": "kelleher", | |
| 3 | + "displayName": "Daniel F. Kelleher Auctions (prices realized via Stamp Auction Network)", | |
| 4 | + "sourceId": "kelleher", | |
| 5 | + "sourceName": "Daniel F. Kelleher Auctions", | |
| 6 | + "sourceType": "auction_house", | |
| 7 | + "sourceUrl": "https://stampauctionnetwork.com/kelleher.cfm", | |
| 8 | + "module": "api/kelleher", | |
| 9 | + "enginePriority": ["api", "firecrawl"], | |
| 10 | + "categories": ["stamps", "banknotes", "coins"], | |
| 11 | + "regions": ["US"], | |
| 12 | + "languages": ["en"], | |
| 13 | + "currency": ["USD"], | |
| 14 | + "supportsListings": false, | |
| 15 | + "supportsSold": true, | |
| 16 | + "supportsAuctions": false, | |
| 17 | + "supportsImages": true, | |
| 18 | + "supportsCatalog": false, | |
| 19 | + "supportsPopulation": false, | |
| 20 | + "supportsLookup": false, | |
| 21 | + "refreshFrequencyMinutes": 1440, | |
| 22 | + "priority": "medium", | |
| 23 | + "trustScore": 0.85, | |
| 24 | + "attributionRequired": true, | |
| 25 | + "termsUrl": "https://www.kelleherauctions.com/auction-rules/", | |
| 26 | + "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.", | |
| 27 | + "enabled": true, | |
| 28 | + "schemaVersion": "1.0", | |
| 29 | + "config": { | |
| 30 | + "salesPerRun": 1, | |
| 31 | + "categoryPagesPerRun": 30 | |
| 32 | + } | |
| 33 | +} | |
added
connectors/api/potter-auctions/_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] ?? 'catalog-page', Number(process.argv[4] ?? 6)); | |
| 7 | +else await runSmoke(dir, Number(process.argv[2] ?? 2)); | |
added
connectors/api/potter-auctions/index.test.ts
+64 −0
@@ -0,0 +1,64 @@ | ||
| 1 | +import { describe, expect, it } from 'vitest'; | |
| 2 | +import { readFileSync } from 'node:fs'; | |
| 3 | +import path from 'node:path'; | |
| 4 | +import { fileURLToPath } from 'node:url'; | |
| 5 | +import { loadFixture, runFixtureSuite } from '@rareindex/connectors/testing'; | |
| 6 | +import { localMeta } from '../_lib/local-meta.js'; | |
| 7 | +import createConnector, { parseCatalogPage, parsePastList, potterCategory } from './index.js'; | |
| 8 | + | |
| 9 | +const dir = path.dirname(fileURLToPath(import.meta.url)); | |
| 10 | +const connector = createConnector(localMeta(JSON.parse(readFileSync(path.join(dir, 'meta.json'), 'utf8')))); | |
| 11 | + | |
| 12 | +const LIST = `<li id="st-23628"> <div class="row" data-event_id='23628' data-event_status='past' data-event_name='Modern Magic Masters' > <a href="https://auction.potterauctions.com/auctions/potter-potter/modern-magic-masters-23628"><img src="x"/></a> <h1><a href="https://auction.potterauctions.com/auctions/potter-potter/modern-magic-masters-23628">Modern Magic Masters</a></h1> <span>Start: Aug 15, 2026 11:00AM EDT </span></div></li>`; | |
| 13 | + | |
| 14 | +const CATALOG = `<div>499 Lots</div> | |
| 15 | +<img src="https://s1.img.bidsquare.com/item/l/3762/37629123.jpeg?t=1WC8TA" itemprop="image" /> | |
| 16 | +<div class="lot_Num">Lot 1</div> <div class="lot_title"> <a href="https://auction.potterauctions.com/online-auctions/potter-potter/mullica-tom-9520114">MULLICA, Tom (1948 - 2016). TAOM Appearance Poster. </a></div> | |
| 17 | +<div class="estimate_amount dual_conversion" data-exchange='{"low_est":"300.00","high_est":"600.00","from_exch_rate":"1.00","from_symbol":"$"}'> | |
| 18 | +<span id="tbc_9520114_23628" class="num">7 Bids</span><span id="lbl_9520114_23628">Sold for</span><div class="currency_container"><span id="tcb_9520114_23628" class="price ">$420</span></div> | |
| 19 | +<img src="https://s1.img.bidsquare.com/item/l/3762/37629124.jpeg?t=1WC8TB" itemprop="image" /> | |
| 20 | +<div class="lot_Num">Lot 2</div> <div class="lot_title"> <a href="https://auction.potterauctions.com/online-auctions/potter-potter/bicycle-deck-9520115">Bicycle Transformation Playing Cards Deck. </a></div> | |
| 21 | +<div class="estimate_amount dual_conversion" data-exchange='{"low_est":"100.00","high_est":"200.00","from_exch_rate":"1.00","from_symbol":"$"}'> | |
| 22 | +<span id="lbl_9520115_23628">Passed</span> | |
| 23 | +`; | |
| 24 | + | |
| 25 | +describe('potter-auctions', () => { | |
| 26 | + runFixtureSuite(connector, it, expect); | |
| 27 | + | |
| 28 | + it('parses the past list and catalog cards', () => { | |
| 29 | + const auctions = parsePastList(LIST); | |
| 30 | + expect(auctions).toEqual([{ id: '23628', slug: 'modern-magic-masters', title: 'Modern Magic Masters', url: 'https://auction.potterauctions.com/auctions/potter-potter/modern-magic-masters-23628', startText: 'Aug 15, 2026 11:00AM EDT' }]); | |
| 31 | + const p = parseCatalogPage(CATALOG, auctions[0]!, 1); | |
| 32 | + expect(p.totalLots).toBe(499); | |
| 33 | + expect(p.lots.length).toBe(2); | |
| 34 | + expect(p.lots[0]).toMatchObject({ lotNumber: '1', priceText: '$420', statusLabel: 'Sold for', bids: 7, estimateLow: 300, estimateHigh: 600, image: 'https://s1.img.bidsquare.com/item/l/3762/37629123.jpeg?t=1WC8TA' }); | |
| 35 | + expect(p.lots[1]).toMatchObject({ lotNumber: '2', priceText: null, statusLabel: 'Passed' }); | |
| 36 | + }); | |
| 37 | + | |
| 38 | + it('normalises only sold lots as hammer-price sales dated by the auction start', async () => { | |
| 39 | + const auction = parsePastList(LIST)[0]!; | |
| 40 | + const out = await connector.normalize({ url: 'x', externalId: 'a', kind: 'sale', engine: 'api', fetchedAt: new Date('2026-09-07T00:00:00Z'), payload: parseCatalogPage(CATALOG, auction, 1) }); | |
| 41 | + expect(out.length).toBe(1); | |
| 42 | + const s = out[0]!; | |
| 43 | + if (s.kind !== 'sale') throw new Error('sale'); | |
| 44 | + expect(s).toMatchObject({ price: 420, currency: 'USD', buyerPremiumIncluded: false, lotNumber: '1', auctionHouse: 'Potter & Potter Auctions' }); | |
| 45 | + expect(s.saleDate.toISOString()).toBe('2026-08-15T00:00:00.000Z'); | |
| 46 | + expect(s.attributes.categorySlug).toBe('advertising'); | |
| 47 | + expect(s.attributes.identifiers.potter_lot).toBe('23628-1'); | |
| 48 | + }); | |
| 49 | + | |
| 50 | + it('maps departments to taxonomy slugs', () => { | |
| 51 | + expect(potterCategory('Modern Magic Masters', 'Bicycle Transformation Playing Cards Deck.')).toBe('playing_cards'); | |
| 52 | + expect(potterCategory('Gambling Memorabilia', 'Ivory poker chips in case')).toBe('casino_memorabilia'); | |
| 53 | + expect(potterCategory('Fine Books & Manuscripts', 'HOUDINI, Harry. A Magician Among the Spirits. First edition.')).toBe('books'); | |
| 54 | + expect(potterCategory('Hollywood & Pop Culture Collectibles', 'Star Wars one-sheet poster')).toBe('movie_posters'); | |
| 55 | + expect(potterCategory('Modern Magic Masters', 'Okito Coin Box, brass, ca. 1950')).toBe('antiques'); | |
| 56 | + expect(potterCategory('Picture This: Fine Vintage Photography', 'Portrait, gelatin silver print')).toBe('photography'); | |
| 57 | + }); | |
| 58 | + | |
| 59 | + it('fixture sales are hammer prices in USD', async () => { | |
| 60 | + const out = await connector.normalize(loadFixture('potter-auctions', 'catalog-page').raw); | |
| 61 | + expect(out.length).toBeGreaterThan(0); | |
| 62 | + for (const r of out) if (r.kind === 'sale') expect(r).toMatchObject({ currency: 'USD', buyerPremiumIncluded: false }); | |
| 63 | + }); | |
| 64 | +}); | |
added
connectors/api/potter-auctions/index.ts
+217 −0
@@ -0,0 +1,217 @@ | ||
| 1 | +import { z } from 'zod'; | |
| 2 | +import { BaseConnector, type ConnectorMeta, type CrawlContext, type RawRecordInput, type RawRecordLike } from '@rareindex/connectors'; | |
| 3 | +import { parseGradeFromTitle } from '@rareindex/taxonomy'; | |
| 4 | +import type { NormalizedRecord, NormalizedSale } from '@rareindex/shared'; | |
| 5 | +import { dateWords, lotAttributes, makeSale, money } from '../../firecrawl/_carlib/index.js'; | |
| 6 | +import { hintFromLabel, isBundleTitle, safeYear, slugFromTitle } from '../_auction-lib/categories.js'; | |
| 7 | + | |
| 8 | +/** | |
| 9 | + * Potter & Potter Auctions (Chicago) — magic, playing cards, gambling, posters, books, photography, | |
| 10 | + * pop culture. Public Bidsquare-hosted pages, plain HTTPS. See meta.json accessNotes. | |
| 11 | + */ | |
| 12 | + | |
| 13 | +const BASE = 'https://auction.potterauctions.com'; | |
| 14 | +const PARSER_VERSION = '1.0.0'; | |
| 15 | + | |
| 16 | +export const AuctionSchema = z.object({ id: z.string(), slug: z.string(), title: z.string(), url: z.string(), startText: z.string().nullable() }); | |
| 17 | +export type Auction = z.infer<typeof AuctionSchema>; | |
| 18 | + | |
| 19 | +export const LotSchema = z.object({ | |
| 20 | + lotNumber: z.string(), | |
| 21 | + title: z.string(), | |
| 22 | + url: z.string(), | |
| 23 | + statusLabel: z.string().nullable(), | |
| 24 | + priceText: z.string().nullable(), | |
| 25 | + bids: z.number().nullable(), | |
| 26 | + estimateLow: z.number().nullable(), | |
| 27 | + estimateHigh: z.number().nullable(), | |
| 28 | + image: z.string().nullable(), | |
| 29 | +}); | |
| 30 | +export type Lot = z.infer<typeof LotSchema>; | |
| 31 | + | |
| 32 | +export const PayloadSchema = z.object({ kind: z.literal('catalog_page'), auction: AuctionSchema, page: z.number(), totalLots: z.number().nullable(), lots: z.array(LotSchema) }); | |
| 33 | +export type Payload = z.infer<typeof PayloadSchema>; | |
| 34 | + | |
| 35 | +function decode(s: string): string { | |
| 36 | + return s | |
| 37 | + .replace(/&/g, '&') | |
| 38 | + .replace(/�?39;|'/g, "'") | |
| 39 | + .replace(/"/g, '"') | |
| 40 | + .replace(/</g, '<') | |
| 41 | + .replace(/>/g, '>') | |
| 42 | + .replace(/ /g, ' ') | |
| 43 | + .replace(/<[^>]+>/g, ' ') | |
| 44 | + .replace(/\s+/g, ' ') | |
| 45 | + .trim(); | |
| 46 | +} | |
| 47 | + | |
| 48 | +/** Past-auction list page → auctions (newest first as published). */ | |
| 49 | +export function parsePastList(html: string): Auction[] { | |
| 50 | + const out: Auction[] = []; | |
| 51 | + const re = /data-event_id='(\d+)'\s+data-event_status='past'\s+data-event_name='([^']*)'[\s\S]*?href="(https:\/\/auction\.potterauctions\.com\/auctions\/potter-potter\/([a-z0-9-]+)-\1)"[\s\S]*?Start:\s*([^<]+)</g; | |
| 52 | + let m: RegExpExecArray | null; | |
| 53 | + while ((m = re.exec(html))) { | |
| 54 | + if (out.some((a) => a.id === m![1])) continue; | |
| 55 | + out.push({ id: m[1]!, slug: m[4]!, title: decode(m[2]!), url: m[3]!, startText: m[5]!.trim() || null }); | |
| 56 | + } | |
| 57 | + return out; | |
| 58 | +} | |
| 59 | + | |
| 60 | +/** Catalog page → lots. Lot cards are server-rendered; image precedes the lot number block. */ | |
| 61 | +export function parseCatalogPage(html: string, auction: Auction, page: number): Payload { | |
| 62 | + const lots: Lot[] = []; | |
| 63 | + const totalMatch = html.match(/(\d+)\s+Lots?\b/i) ?? html.match(/total[^0-9]{0,40}(\d+)/i); | |
| 64 | + const totalLots = totalMatch ? Number(totalMatch[1]) : null; | |
| 65 | + const idx: number[] = []; | |
| 66 | + const marker = /<div class="lot_Num">Lot\s+/g; | |
| 67 | + let mm: RegExpExecArray | null; | |
| 68 | + while ((mm = marker.exec(html))) idx.push(mm.index); | |
| 69 | + for (let k = 0; k < idx.length; k++) { | |
| 70 | + const start = idx[k]!; | |
| 71 | + const end = idx[k + 1] ?? html.length; | |
| 72 | + const block = html.slice(start, end); | |
| 73 | + const before = html.slice(Math.max(0, start - 2500), start); | |
| 74 | + const num = block.match(/^<div class="lot_Num">Lot\s+([^<]+)</)?.[1]?.trim(); | |
| 75 | + const t = block.match(/lot_title">\s*<a href="([^"]+)">([\s\S]*?)<\/a>/); | |
| 76 | + if (!num || !t) continue; | |
| 77 | + const label = block.match(/id="lbl_\d+_\d+">([^<]*)</)?.[1]?.trim() ?? null; | |
| 78 | + const price = block.match(/id="tcb_\d+_\d+"[^>]*>\s*([^<]+)</)?.[1]?.trim() ?? null; | |
| 79 | + const bids = block.match(/id="tbc_\d+_\d+"[^>]*>\s*(\d+)\s*Bids?/)?.[1]; | |
| 80 | + const est = block.match(/data-exchange='\{"low_est":"([\d.]+)","high_est":"([\d.]+)"/); | |
| 81 | + const img = [...before.matchAll(/<img src="(https:\/\/s1\.img\.bidsquare\.com\/item\/[^"]+)"/g)].pop()?.[1] ?? null; | |
| 82 | + lots.push({ | |
| 83 | + lotNumber: num, | |
| 84 | + title: decode(t[2]!), | |
| 85 | + url: t[1]!, | |
| 86 | + statusLabel: label, | |
| 87 | + priceText: price, | |
| 88 | + bids: bids ? Number(bids) : null, | |
| 89 | + estimateLow: est ? Number(est[1]) : null, | |
| 90 | + estimateHigh: est ? Number(est[2]) : null, | |
| 91 | + image: img, | |
| 92 | + }); | |
| 93 | + } | |
| 94 | + return { kind: 'catalog_page', auction, page, totalLots, lots }; | |
| 95 | +} | |
| 96 | + | |
| 97 | +/** Potter departments → taxonomy slugs (never guesses beyond keywords; magicana falls back to antiques). */ | |
| 98 | +export function potterCategory(auctionTitle: string, lotTitle: string): string { | |
| 99 | + const t = `${auctionTitle} ${lotTitle}`.toLowerCase(); | |
| 100 | + if (/playing card|deck of cards|\bdecks?\b|transformation deck/.test(t)) return 'playing_cards'; | |
| 101 | + if (/gambling|casino|poker chip|roulette|faro|dice\b|slot machine|cheating/.test(t)) return 'casino_memorabilia'; | |
| 102 | + if (/coin-op|coin op|vending|arcade|trade stimulator|mutoscope/.test(t)) return 'vending_machines'; | |
| 103 | + if (/circus|sideshow|carnival|advertising|sign\b|broadside|handbill|trade card/.test(t) && !/book/.test(t)) return 'advertising'; | |
| 104 | + if (/hollywood|film|movie|cinema|screen-used|prop\b/.test(t)) return /poster|lobby card|one[- ]sheet/.test(t) ? 'movie_posters' : 'movie_memorabilia'; | |
| 105 | + if (/poster|lithograph poster|one[- ]sheet|window card/.test(t)) return 'advertising'; | |
| 106 | + if (/photograph|daguerreotype|tintype|ambrotype|cabinet card|cdv|carte de visite|gelatin silver|albumen/.test(t)) return 'photography'; | |
| 107 | + if (/autograph|signed letter|signed photograph|inscribed/.test(t) && /letter|photograph|card\b/.test(t)) return 'autographs'; | |
| 108 | + if (/manuscript|archive|letter\b|diary|document/.test(t) && !/book/.test(t)) return 'historical_documents'; | |
| 109 | + if (/rock|jazz|blues|punk|concert|album|vinyl|guitar/.test(t)) return 'music_memorabilia'; | |
| 110 | + if (/baseball|boxing|basketball|football|wrestling|sport/.test(t)) return 'sports_memorabilia'; | |
| 111 | + if (/\btoy\b|toys|doll|game\b|puzzle|magic set|magic kit/.test(t)) return 'vintage_toys'; | |
| 112 | + if (/book|volume|vols?\.|edition|manuscript|pamphlet|periodical|magazine/.test(t)) return 'books'; | |
| 113 | + // Conjuring apparatus ("Okito Coin Box", "Cups and Balls", "Card Box") is not coins/cards/toys: keep it under antiques. | |
| 114 | + if (/magic|conjur|magicana|houdini|legerdemain|illusion/i.test(auctionTitle)) return 'antiques'; | |
| 115 | + const hinted = slugFromTitle(lotTitle, hintFromLabel(auctionTitle)); | |
| 116 | + if (hinted) return hinted; | |
| 117 | + return 'antiques'; | |
| 118 | +} | |
| 119 | + | |
| 120 | +export class PotterAuctionsConnector extends BaseConnector { | |
| 121 | + readonly version = '1.0.0'; | |
| 122 | + readonly parserVersion = PARSER_VERSION; | |
| 123 | + protected override minIntervalMs = 2000; | |
| 124 | + | |
| 125 | + async *crawl(ctx: CrawlContext): AsyncIterable<RawRecordInput> { | |
| 126 | + const auctionsPerRun = Number(this.meta.config.auctionsPerRun ?? 2); | |
| 127 | + const pagesPerAuction = Number(this.meta.config.pagesPerAuction ?? 12); | |
| 128 | + const backfill = ctx.options.mode === 'backfill'; | |
| 129 | + const done = new Set<string>(Array.isArray(ctx.options.cursor?.doneAuctions) ? (ctx.options.cursor!.doneAuctions as string[]) : []); | |
| 130 | + const listPage = backfill ? Number(ctx.options.cursor?.listPage ?? 1) : 1; | |
| 131 | + const listUrl = `${BASE}/auctions/past?page=${listPage}`; | |
| 132 | + await this.throttle(); | |
| 133 | + const list = await ctx.fetch(listUrl, { responseType: 'text', expect: ['title', 'date'], parse: (r) => (r.html ? { title: parsePastList(r.html)[0]?.title ?? null, date: parsePastList(r.html)[0]?.startText ?? null } : null) }); | |
| 134 | + if (!list.success || !list.html) { | |
| 135 | + ctx.anomaly('page_fetch_failed', `${listUrl}: ${list.error ?? list.httpStatus}`); | |
| 136 | + return; | |
| 137 | + } | |
| 138 | + const auctions = parsePastList(list.html).filter((a) => !done.has(a.id)); | |
| 139 | + let count = 0; | |
| 140 | + let processed = 0; | |
| 141 | + for (const auction of auctions) { | |
| 142 | + if (processed >= auctionsPerRun || ctx.signal?.aborted) break; | |
| 143 | + for (let page = 1; page <= pagesPerAuction; page++) { | |
| 144 | + if (ctx.signal?.aborted || this.reached(ctx, count)) break; | |
| 145 | + const url = `${auction.url}/catalog?page=${page}`; | |
| 146 | + await this.throttle(); | |
| 147 | + const res = await ctx.fetch(url, { | |
| 148 | + responseType: 'text', | |
| 149 | + expect: ['title', 'price', 'status'], | |
| 150 | + parse: (r) => { | |
| 151 | + const f = r.html ? parseCatalogPage(r.html, auction, page).lots.find((l) => l.priceText) : null; | |
| 152 | + return f ? { title: f.title, price: money(f.priceText, 'USD')?.amount ?? null, status: f.statusLabel } : null; | |
| 153 | + }, | |
| 154 | + }); | |
| 155 | + if (!res.success || !res.html) { | |
| 156 | + ctx.anomaly('page_fetch_failed', `${url}: ${res.error ?? res.httpStatus}`); | |
| 157 | + break; | |
| 158 | + } | |
| 159 | + const payload = parseCatalogPage(res.html, auction, page); | |
| 160 | + if (payload.lots.length === 0) break; | |
| 161 | + count++; | |
| 162 | + yield { url, externalId: `auction:${auction.id}:page:${page}`, kind: 'sale', engine: res.engine, httpStatus: res.httpStatus, payload, fetchedAt: res.fetchedAt }; | |
| 163 | + if (payload.totalLots !== null && page * payload.lots.length >= payload.totalLots) break; | |
| 164 | + } | |
| 165 | + processed++; | |
| 166 | + done.add(auction.id); | |
| 167 | + await ctx.setCursor({ doneAuctions: [...done].slice(-300), listPage: backfill && auctions.every((a) => done.has(a.id)) ? listPage + 1 : listPage, updatedAt: new Date().toISOString() }); | |
| 168 | + } | |
| 169 | + } | |
| 170 | + | |
| 171 | + async normalize(raw: RawRecordLike): Promise<NormalizedRecord[]> { | |
| 172 | + const p = PayloadSchema.parse(raw.payload); | |
| 173 | + const saleDate = dateWords(p.auction.startText); | |
| 174 | + if (!saleDate) return []; | |
| 175 | + const out: NormalizedSale[] = []; | |
| 176 | + for (const lot of p.lots) { | |
| 177 | + if (!lot.priceText || !/sold/i.test(lot.statusLabel ?? '')) continue; | |
| 178 | + const m = money(lot.priceText, 'USD'); | |
| 179 | + if (!m) continue; | |
| 180 | + const g = parseGradeFromTitle(lot.title); | |
| 181 | + const categorySlug = potterCategory(p.auction.title, lot.title); | |
| 182 | + const attributes = lotAttributes({ | |
| 183 | + categorySlug, | |
| 184 | + name: lot.title, | |
| 185 | + year: safeYear(lot.title), | |
| 186 | + identifiers: { potter_lot: `${p.auction.id}-${lot.lotNumber}`, bidsquare_item: lot.url.match(/-(\d+)$/)?.[1] ?? '' }, | |
| 187 | + metadata: { auction_id: p.auction.id, department: p.auction.title, estimate_low: lot.estimateLow, estimate_high: lot.estimateHigh, bids: lot.bids, price_is_hammer: true }, | |
| 188 | + }); | |
| 189 | + if (!attributes.identifiers.bidsquare_item) delete attributes.identifiers.bidsquare_item; | |
| 190 | + out.push( | |
| 191 | + makeSale({ | |
| 192 | + meta: this.meta, | |
| 193 | + sourceUrl: lot.url, | |
| 194 | + externalId: `${p.auction.id}-${lot.lotNumber}`, | |
| 195 | + rawTitle: lot.title, | |
| 196 | + attributes, | |
| 197 | + price: m.amount, | |
| 198 | + currency: 'USD', | |
| 199 | + saleDate, | |
| 200 | + buyerPremiumIncluded: false, | |
| 201 | + auctionHouse: 'Potter & Potter Auctions', | |
| 202 | + lotNumber: lot.lotNumber, | |
| 203 | + imageUrls: lot.image ? [lot.image] : [], | |
| 204 | + observedAt: raw.fetchedAt, | |
| 205 | + parserVersion: PARSER_VERSION, | |
| 206 | + grader: g.grader && g.grader !== 'raw' ? g.grader : null, | |
| 207 | + grade: g.grader && g.grader !== 'raw' ? g.grade : null, | |
| 208 | + isBundle: isBundleTitle(lot.title), | |
| 209 | + location: 'US', | |
| 210 | + }), | |
| 211 | + ); | |
| 212 | + } | |
| 213 | + return out; | |
| 214 | + } | |
| 215 | +} | |
| 216 | + | |
| 217 | +export default (meta: ConnectorMeta) => new PotterAuctionsConnector(meta); | |
added
connectors/api/potter-auctions/meta.json
+34 −0
@@ -0,0 +1,34 @@ | ||
| 1 | +{ | |
| 2 | + "id": "potter-auctions", | |
| 3 | + "displayName": "Potter & Potter Auctions (prices realized)", | |
| 4 | + "sourceId": "potter-auctions", | |
| 5 | + "sourceName": "Potter & Potter Auctions", | |
| 6 | + "sourceType": "auction_house", | |
| 7 | + "sourceUrl": "https://auction.potterauctions.com", | |
| 8 | + "module": "api/potter-auctions", | |
| 9 | + "enginePriority": ["api", "firecrawl"], | |
| 10 | + "categories": ["playing_cards", "casino_memorabilia", "advertising", "movie_posters", "movie_memorabilia", "books", "photography", "art", "contemporary_art", "vending_machines", "vintage_toys", "antiques", "historical_documents", "autographs", "music_memorabilia", "sports_memorabilia"], | |
| 11 | + "regions": ["US"], | |
| 12 | + "languages": ["en"], | |
| 13 | + "currency": ["USD"], | |
| 14 | + "supportsListings": false, | |
| 15 | + "supportsSold": true, | |
| 16 | + "supportsAuctions": false, | |
| 17 | + "supportsImages": true, | |
| 18 | + "supportsCatalog": false, | |
| 19 | + "supportsPopulation": false, | |
| 20 | + "supportsLookup": false, | |
| 21 | + "refreshFrequencyMinutes": 720, | |
| 22 | + "priority": "medium", | |
| 23 | + "trustScore": 0.9, | |
| 24 | + "attributionRequired": true, | |
| 25 | + "termsUrl": "https://auction.potterauctions.com/terms-conditions", | |
| 26 | + "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.", | |
| 27 | + "enabled": true, | |
| 28 | + "schemaVersion": "1.0", | |
| 29 | + "config": { | |
| 30 | + "auctionsPerRun": 2, | |
| 31 | + "pagesPerAuction": 12, | |
| 32 | + "listPagesPerRun": 1 | |
| 33 | + } | |
| 34 | +} | |
added
connectors/firecrawl/cherrystone/_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] ?? 'category-page', Number(process.argv[4] ?? 6)); | |
| 7 | +else await runSmoke(dir, Number(process.argv[2] ?? 2)); | |
added
connectors/firecrawl/cherrystone/index.test.ts
+59 −0
@@ -0,0 +1,59 @@ | ||
| 1 | +import { describe, expect, it } from 'vitest'; | |
| 2 | +import { readFileSync } from 'node:fs'; | |
| 3 | +import path from 'node:path'; | |
| 4 | +import { fileURLToPath } from 'node:url'; | |
| 5 | +import { loadFixture, runFixtureSuite } from '@rareindex/connectors/testing'; | |
| 6 | +import { localMeta } from '../../api/_lib/local-meta.js'; | |
| 7 | +import createConnector, { cherrystoneCategory, parseCategoryLinks, parseCategoryPage, parseHome } from './index.js'; | |
| 8 | + | |
| 9 | +const dir = path.dirname(fileURLToPath(import.meta.url)); | |
| 10 | +const connector = createConnector(localMeta(JSON.parse(readFileSync(path.join(dir, 'meta.json'), 'utf8')))); | |
| 11 | + | |
| 12 | +const HOME_MD = `[Click here](https://auctions.cherrystoneauctions.com/catalog.aspx?auctionid=34) for Prices Realized from U.S. and Worldwide Sale - July 28-29, 2026 | |
| 13 | + | |
| 14 | +[Click here](https://auctions.cherrystoneauctions.com/catalog.aspx?auctionid=23) for Prices Realized from The Confederate Stamps and Covers Sale - June 9, 2026`; | |
| 15 | +const HOME_HTML = `<a href="https://auctions.cherrystoneauctions.com/catalog.aspx?auctionid=34">Click here</a> for Prices Realized from U.S. and Worldwide Sale - July 28-29, 2026<br>`; | |
| 16 | +const CATALOG = `<a href="https://auctions.cherrystoneauctions.com/Category/All-1.html?auctionid=34">All</a><a href="https://auctions.cherrystoneauctions.com/Category/1847_Issue-4150.html?auctionid=34">1847 Issue</a><a href="https://auctions.cherrystoneauctions.com/Category/1847_Issue-4150.html?auctionid=34">dup</a><a href="/Category/Confederate_States-99.html?auctionid=35">other</a>`; | |
| 17 | +const PAGE = `<div id="galleryList"><ul><li><div class="lot "><div class="lotInner"><h5><span id="LotLabel">Lot </span><span class="LotNumberSign">#</span><span id="LotNumber">10</span><span id="LotNumberColon">: </span><span id="LotName"><a href="https://auctions.cherrystoneauctions.com/United_States_1847_Issue-LOT34097.aspx">United States 1847 Issue (1P1-2P1) 1847 5c red brown, 10c black, large die proofs, v.f., cat. value $1600 </a></span></h5><div class="imageDiv"><a href="x"><center><img class="lotImage" src="https://auctions.cherrystoneauctions.com/ItemImages/000034/34097A_sm.jpeg"></center></a></div><div class="lotData"><h6><span>Final Price: $750</span></h6><h6><span>Estimate: $450 - $500</span></h6></div></div></div></li> | |
| 18 | +<li><div class="lot "><div class="lotInner"><h5><span id="LotNumber">11</span><span id="LotName"><a href="https://auctions.cherrystoneauctions.com/x-LOT34098.aspx">United States 1847 Issue (2) 10c black, unused, fine</a></span></h5><div class="lotData"><h6><span>Estimate: $1,000 - $1,200</span></h6></div></div></div></li></ul></div>`; | |
| 19 | + | |
| 20 | +describe('cherrystone', () => { | |
| 21 | + runFixtureSuite(connector, it, expect); | |
| 22 | + | |
| 23 | + it('parses home (markdown and HTML), category links and lot cards', () => { | |
| 24 | + expect(parseHome(HOME_MD)).toEqual([ | |
| 25 | + { id: '34', title: 'U.S. and Worldwide Sale', dateText: 'July 28-29, 2026' }, | |
| 26 | + { id: '23', title: 'The Confederate Stamps and Covers Sale', dateText: 'June 9, 2026' }, | |
| 27 | + ]); | |
| 28 | + expect(parseHome(HOME_HTML)[0]).toMatchObject({ id: '34' }); | |
| 29 | + expect(parseCategoryLinks(CATALOG, '34')).toEqual([{ name: '1847 Issue', url: 'https://auctions.cherrystoneauctions.com/Category/1847_Issue-4150.html?auctionid=34' }]); | |
| 30 | + const p = parseCategoryPage(PAGE, { id: '34', title: 'U.S. and Worldwide Sale', dateText: 'July 28-29, 2026' }, '1847 Issue', 'u'); | |
| 31 | + expect(p.lots.length).toBe(2); | |
| 32 | + expect(p.lots[0]).toMatchObject({ lotNumber: '10', finalPriceText: '$750', estimateText: '$450 - $500', image: 'https://auctions.cherrystoneauctions.com/ItemImages/000034/34097A_sm.jpeg' }); | |
| 33 | + expect(p.lots[1]!.finalPriceText).toBeNull(); | |
| 34 | + }); | |
| 35 | + | |
| 36 | + it('normalises sold lots only, dated by the sale date, premium unknown', async () => { | |
| 37 | + const payload = parseCategoryPage(PAGE, { id: '34', title: 'U.S. and Worldwide Sale', dateText: 'July 28-29, 2026' }, '1847 Issue', 'u'); | |
| 38 | + const out = await connector.normalize({ url: 'u', externalId: 'a', kind: 'sale', engine: 'firecrawl', fetchedAt: new Date('2026-09-07T00:00:00Z'), payload }); | |
| 39 | + expect(out.length).toBe(1); | |
| 40 | + const s = out[0]!; | |
| 41 | + if (s.kind !== 'sale') throw new Error('sale'); | |
| 42 | + expect(s).toMatchObject({ price: 750, currency: 'USD', buyerPremiumIncluded: null, lotNumber: '10', auctionHouse: 'Cherrystone Auctions' }); | |
| 43 | + expect(s.saleDate.toISOString()).toBe('2026-07-28T00:00:00.000Z'); | |
| 44 | + expect(s.attributes).toMatchObject({ categorySlug: 'stamps', set: '1847 Issue', year: 1847 }); | |
| 45 | + expect(s.attributes.identifiers.cherrystone_lot).toBe('34097'); | |
| 46 | + }); | |
| 47 | + | |
| 48 | + it('maps banknotes and coins when titled so', () => { | |
| 49 | + expect(cherrystoneCategory('United States 1847 Issue 5c red brown')).toBe('stamps'); | |
| 50 | + expect(cherrystoneCategory('Confederate States $100 note, 1864, fine')).toBe('banknotes'); | |
| 51 | + expect(cherrystoneCategory('1921 Morgan silver dollar coin, uncirculated')).toBe('coins'); | |
| 52 | + }); | |
| 53 | + | |
| 54 | + it('fixture lots normalise to USD sales', async () => { | |
| 55 | + const out = await connector.normalize(loadFixture('cherrystone', 'category-page').raw); | |
| 56 | + expect(out.length).toBeGreaterThan(0); | |
| 57 | + for (const r of out) if (r.kind === 'sale') expect(r.currency).toBe('USD'); | |
| 58 | + }); | |
| 59 | +}); | |
added
connectors/firecrawl/cherrystone/index.ts
+190 −0
@@ -0,0 +1,190 @@ | ||
| 1 | +import { z } from 'zod'; | |
| 2 | +import { BaseConnector, type ConnectorMeta, type CrawlContext, type RawRecordInput, type RawRecordLike } from '@rareindex/connectors'; | |
| 3 | +import type { NormalizedRecord, NormalizedSale } from '@rareindex/shared'; | |
| 4 | +import { dateWords, lotAttributes, makeSale, money } from '../_carlib/index.js'; | |
| 5 | +import { isBundleTitle, safeYear } from '../../api/_auction-lib/categories.js'; | |
| 6 | + | |
| 7 | +/** | |
| 8 | + * Cherrystone Philatelic Auctioneers — prices realized per lot from the public catalog category pages. | |
| 9 | + * Engine: Firecrawl (rawHtml). See meta.json accessNotes. | |
| 10 | + */ | |
| 11 | + | |
| 12 | +const SITE = 'https://auctions.cherrystoneauctions.com'; | |
| 13 | +const PARSER_VERSION = '1.0.0'; | |
| 14 | + | |
| 15 | +export const AuctionSchema = z.object({ id: z.string(), title: z.string(), dateText: z.string().nullable() }); | |
| 16 | +export const LotSchema = z.object({ lotNumber: z.string(), title: z.string(), url: z.string(), image: z.string().nullable(), finalPriceText: z.string().nullable(), estimateText: z.string().nullable() }); | |
| 17 | +export const PayloadSchema = z.object({ kind: z.literal('category_page'), auction: AuctionSchema, category: z.string(), categoryUrl: z.string(), lots: z.array(LotSchema) }); | |
| 18 | +export type Payload = z.infer<typeof PayloadSchema>; | |
| 19 | + | |
| 20 | +function clean(s: string): string { | |
| 21 | + return s.replace(/&/g, '&').replace(/�?39;|'/g, "'").replace(/"/g, '"').replace(/ /g, ' ').replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim(); | |
| 22 | +} | |
| 23 | + | |
| 24 | +/** Home page (HTML or markdown) → closed sales with prices realized. */ | |
| 25 | +export function parseHome(text: string): z.infer<typeof AuctionSchema>[] { | |
| 26 | + const out: z.infer<typeof AuctionSchema>[] = []; | |
| 27 | + const push = (id: string, title: string, dateText: string) => { | |
| 28 | + if (!out.some((a) => a.id === id)) out.push({ id, title: clean(title), dateText }); | |
| 29 | + }; | |
| 30 | + const DATE = String.raw`([A-Za-z]+ \d{1,2}(?:\s*[-–]\s*\d{1,2})?,\s*\d{4})`; | |
| 31 | + // HTML: <a href="…catalog.aspx?auctionid=34">Click here</a> for Prices Realized from <sale> - <date> | |
| 32 | + const reHtml = new RegExp(String.raw`href="[^"]*catalog\.aspx\?auctionid=(\d+)"[^>]*>[^<]*<\/a>\s*for Prices Realized from\s+(.+?)\s+-\s+` + DATE, 'gi'); | |
| 33 | + let m: RegExpExecArray | null; | |
| 34 | + while ((m = reHtml.exec(text))) push(m[1]!, m[2]!, m[3]!); | |
| 35 | + // Markdown: [Click here](…catalog.aspx?auctionid=34) for Prices Realized from <sale> - <date> | |
| 36 | + const reMd = new RegExp(String.raw`\]\([^)]*catalog\.aspx\?auctionid=(\d+)\)\s*for Prices Realized from\s+(.+?)\s+-\s+` + DATE, 'gi'); | |
| 37 | + while ((m = reMd.exec(text))) push(m[1]!, m[2]!, m[3]!); | |
| 38 | + return out; | |
| 39 | +} | |
| 40 | + | |
| 41 | +/** Catalog page → category page URLs for this auction. */ | |
| 42 | +export function parseCategoryLinks(html: string, auctionId: string): Array<{ name: string; url: string }> { | |
| 43 | + const out: Array<{ name: string; url: string }> = []; | |
| 44 | + const re = /href="((?:https:\/\/auctions\.cherrystoneauctions\.com)?\/Category\/([^"?]+)\.html\?auctionid=(\d+))"/g; | |
| 45 | + let m: RegExpExecArray | null; | |
| 46 | + while ((m = re.exec(html))) { | |
| 47 | + if (m[3] !== auctionId) continue; | |
| 48 | + const url = m[1]!.startsWith('http') ? m[1]! : `${SITE}${m[1]!}`; | |
| 49 | + if (out.some((c) => c.url === url)) continue; | |
| 50 | + const name = m[2]!.replace(/-\d+$/, '').replace(/_/g, ' ').trim(); | |
| 51 | + if (/^All$/i.test(name)) continue; | |
| 52 | + out.push({ name, url }); | |
| 53 | + } | |
| 54 | + return out; | |
| 55 | +} | |
| 56 | + | |
| 57 | +/** Category page → lots. */ | |
| 58 | +export function parseCategoryPage(html: string, auction: z.infer<typeof AuctionSchema>, category: string, categoryUrl: string): Payload { | |
| 59 | + const lots: z.infer<typeof LotSchema>[] = []; | |
| 60 | + const blocks = html.split(/<div class="lot\s*">/).slice(1); | |
| 61 | + for (const b of blocks) { | |
| 62 | + const num = b.match(/id="LotNumber">([^<]+)</)?.[1]?.trim(); | |
| 63 | + const link = b.match(/id="LotName">\s*<a href="([^"]+)">([\s\S]*?)<\/a>/); | |
| 64 | + if (!num || !link) continue; | |
| 65 | + lots.push({ | |
| 66 | + lotNumber: num, | |
| 67 | + title: clean(link[2]!), | |
| 68 | + url: link[1]!, | |
| 69 | + image: b.match(/<img class="lotImage" src="([^"]+)"/)?.[1] ?? null, | |
| 70 | + finalPriceText: b.match(/Final Price:\s*([^<]+)</)?.[1]?.trim() ?? null, | |
| 71 | + estimateText: b.match(/Estimate:\s*([^<]+)</)?.[1]?.trim() ?? null, | |
| 72 | + }); | |
| 73 | + } | |
| 74 | + return { kind: 'category_page', auction, category, categoryUrl, lots }; | |
| 75 | +} | |
| 76 | + | |
| 77 | +export function cherrystoneCategory(title: string): string { | |
| 78 | + const t = title.toLowerCase(); | |
| 79 | + if (/(banknote|bank note|currency|paper money|specimen note|\$\d+ note)/.test(t)) return 'banknotes'; | |
| 80 | + if (/\b(coin|medal|token|sovereign|ducat)\b/.test(t) && !/\bcover|stamp|postal/.test(t)) return 'coins'; | |
| 81 | + return 'stamps'; | |
| 82 | +} | |
| 83 | + | |
| 84 | +export class CherrystoneConnector extends BaseConnector { | |
| 85 | + readonly version = '1.0.0'; | |
| 86 | + readonly parserVersion = PARSER_VERSION; | |
| 87 | + protected override minIntervalMs = 2000; | |
| 88 | + | |
| 89 | + async *crawl(ctx: CrawlContext): AsyncIterable<RawRecordInput> { | |
| 90 | + const auctionsPerRun = Number(this.meta.config.auctionsPerRun ?? 1); | |
| 91 | + const categoriesPerRun = Number(this.meta.config.categoriesPerRun ?? 25); | |
| 92 | + const cursor = (ctx.options.cursor ?? {}) as { doneAuctions?: string[]; progress?: Record<string, string[]> }; | |
| 93 | + const done = new Set<string>(cursor.doneAuctions ?? []); | |
| 94 | + const progress: Record<string, string[]> = cursor.progress ?? {}; | |
| 95 | + await this.throttle(); | |
| 96 | + const home = await ctx.fetch(`${SITE}/`, { expect: ['title', 'date'], parse: (r) => ({ title: parseHome(r.html ?? r.markdown ?? '')[0]?.title ?? null, date: parseHome(r.html ?? r.markdown ?? '')[0]?.dateText ?? null }) }); | |
| 97 | + if (!home.success) { | |
| 98 | + ctx.anomaly('page_fetch_failed', `home: ${home.error ?? home.httpStatus}`); | |
| 99 | + return; | |
| 100 | + } | |
| 101 | + const auctions = parseHome(home.html ?? home.markdown ?? '').filter((a) => !done.has(a.id)); | |
| 102 | + let count = 0; | |
| 103 | + let processed = 0; | |
| 104 | + for (const auction of auctions) { | |
| 105 | + if (processed >= auctionsPerRun || ctx.signal?.aborted) break; | |
| 106 | + await this.throttle(); | |
| 107 | + const cat = await ctx.fetch(`${SITE}/catalog.aspx?auctionid=${auction.id}`, { expect: ['title'], parse: (r) => ({ title: r.html ? parseCategoryLinks(r.html, auction.id)[0]?.name ?? null : null }) }); | |
| 108 | + if (!cat.success || !cat.html) { | |
| 109 | + ctx.anomaly('page_fetch_failed', `catalog ${auction.id}: ${cat.error ?? cat.httpStatus}`); | |
| 110 | + break; | |
| 111 | + } | |
| 112 | + const seen = new Set(progress[auction.id] ?? []); | |
| 113 | + const categories = parseCategoryLinks(cat.html, auction.id).filter((c) => !seen.has(c.url)); | |
| 114 | + let n = 0; | |
| 115 | + for (const c of categories) { | |
| 116 | + if (n >= categoriesPerRun || ctx.signal?.aborted || this.reached(ctx, count)) break; | |
| 117 | + await this.throttle(); | |
| 118 | + const res = await ctx.fetch(c.url, { | |
| 119 | + expect: ['title', 'price'], | |
| 120 | + parse: (r) => { | |
| 121 | + const f = r.html ? parseCategoryPage(r.html, auction, c.name, c.url).lots.find((l) => l.finalPriceText) : null; | |
| 122 | + return f ? { title: f.title, price: money(f.finalPriceText, 'USD')?.amount ?? null } : null; | |
| 123 | + }, | |
| 124 | + }); | |
| 125 | + n++; | |
| 126 | + seen.add(c.url); | |
| 127 | + if (!res.success || !res.html) { | |
| 128 | + ctx.anomaly('page_fetch_failed', `${c.url}: ${res.error ?? res.httpStatus}`); | |
| 129 | + continue; | |
| 130 | + } | |
| 131 | + const payload = parseCategoryPage(res.html, auction, c.name, c.url); | |
| 132 | + if (payload.lots.length === 0) continue; | |
| 133 | + count++; | |
| 134 | + yield { url: c.url, externalId: `auction:${auction.id}:${c.name}`, kind: 'sale', engine: res.engine, httpStatus: res.httpStatus, payload, fetchedAt: res.fetchedAt }; | |
| 135 | + } | |
| 136 | + progress[auction.id] = [...seen]; | |
| 137 | + const remaining = categories.length - n; | |
| 138 | + if (remaining <= 0) { | |
| 139 | + done.add(auction.id); | |
| 140 | + delete progress[auction.id]; | |
| 141 | + processed++; | |
| 142 | + } | |
| 143 | + await ctx.setCursor({ doneAuctions: [...done].slice(-50), progress, updatedAt: new Date().toISOString() }); | |
| 144 | + if (remaining > 0) break; // continue this auction next run | |
| 145 | + } | |
| 146 | + } | |
| 147 | + | |
| 148 | + async normalize(raw: RawRecordLike): Promise<NormalizedRecord[]> { | |
| 149 | + const p = PayloadSchema.parse(raw.payload); | |
| 150 | + const saleDate = dateWords(p.auction.dateText); | |
| 151 | + if (!saleDate) return []; | |
| 152 | + const out: NormalizedSale[] = []; | |
| 153 | + for (const lot of p.lots) { | |
| 154 | + const m = money(lot.finalPriceText, 'USD'); | |
| 155 | + if (!m) continue; // $0 / missing = unsold | |
| 156 | + const categorySlug = cherrystoneCategory(lot.title); | |
| 157 | + const attributes = lotAttributes({ | |
| 158 | + categorySlug, | |
| 159 | + name: lot.title, | |
| 160 | + set: p.category, | |
| 161 | + year: safeYear(lot.title), | |
| 162 | + identifiers: { cherrystone_lot: lot.url.match(/LOT(\d+)\.aspx/i)?.[1] ?? `${p.auction.id}-${lot.lotNumber}` }, | |
| 163 | + metadata: { auction_id: p.auction.id, auction_title: p.auction.title, category: p.category, estimate: lot.estimateText, scott_catalogue: lot.title.match(/\(([0-9A-Za-z-]+)\)/)?.[1] ?? null }, | |
| 164 | + }); | |
| 165 | + out.push( | |
| 166 | + makeSale({ | |
| 167 | + meta: this.meta, | |
| 168 | + sourceUrl: lot.url, | |
| 169 | + externalId: `${p.auction.id}-${lot.lotNumber}`, | |
| 170 | + rawTitle: lot.title, | |
| 171 | + attributes, | |
| 172 | + price: m.amount, | |
| 173 | + currency: 'USD', | |
| 174 | + saleDate, | |
| 175 | + buyerPremiumIncluded: null, | |
| 176 | + auctionHouse: 'Cherrystone Auctions', | |
| 177 | + lotNumber: lot.lotNumber, | |
| 178 | + imageUrls: lot.image ? [lot.image] : [], | |
| 179 | + observedAt: raw.fetchedAt, | |
| 180 | + parserVersion: PARSER_VERSION, | |
| 181 | + isBundle: isBundleTitle(lot.title) || /\b(collection|accumulation|balance|group of|lot of)\b/i.test(lot.title), | |
| 182 | + location: 'US', | |
| 183 | + }), | |
| 184 | + ); | |
| 185 | + } | |
| 186 | + return out; | |
| 187 | + } | |
| 188 | +} | |
| 189 | + | |
| 190 | +export default (meta: ConnectorMeta) => new CherrystoneConnector(meta); | |
added
connectors/firecrawl/cherrystone/meta.json
+33 −0
@@ -0,0 +1,33 @@ | ||
| 1 | +{ | |
| 2 | + "id": "cherrystone", | |
| 3 | + "displayName": "Cherrystone Auctions (prices realized)", | |
| 4 | + "sourceId": "cherrystone", | |
| 5 | + "sourceName": "Cherrystone Philatelic Auctioneers", | |
| 6 | + "sourceType": "auction_house", | |
| 7 | + "sourceUrl": "https://auctions.cherrystoneauctions.com", | |
| 8 | + "module": "firecrawl/cherrystone", | |
| 9 | + "enginePriority": ["firecrawl"], | |
| 10 | + "categories": ["stamps", "banknotes", "coins"], | |
| 11 | + "regions": ["US"], | |
| 12 | + "languages": ["en"], | |
| 13 | + "currency": ["USD"], | |
| 14 | + "supportsListings": false, | |
| 15 | + "supportsSold": true, | |
| 16 | + "supportsAuctions": false, | |
| 17 | + "supportsImages": true, | |
| 18 | + "supportsCatalog": false, | |
| 19 | + "supportsPopulation": false, | |
| 20 | + "supportsLookup": false, | |
| 21 | + "refreshFrequencyMinutes": 1440, | |
| 22 | + "priority": "medium", | |
| 23 | + "trustScore": 0.9, | |
| 24 | + "attributionRequired": true, | |
| 25 | + "termsUrl": "https://auctions.cherrystoneauctions.com/viewuserdefinedpage.aspx?pn=terms", | |
| 26 | + "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.", | |
| 27 | + "enabled": true, | |
| 28 | + "schemaVersion": "1.0", | |
| 29 | + "config": { | |
| 30 | + "auctionsPerRun": 1, | |
| 31 | + "categoriesPerRun": 25 | |
| 32 | + } | |
| 33 | +} | |
added
data/fixtures/cherrystone/category-page.json
+77 −0
@@ -0,0 +1,77 @@ | ||
| 1 | +{ | |
| 2 | + "raw": { | |
| 3 | + "url": "https://auctions.cherrystoneauctions.com/Category/All_Items-1.html?auctionid=34", | |
| 4 | + "externalId": "auction:34:All Items", | |
| 5 | + "kind": "sale", | |
| 6 | + "engine": "firecrawl", | |
| 7 | + "fetchedAt": "2026-09-07T07:14:41.888Z", | |
| 8 | + "payload": { | |
| 9 | + "kind": "category_page", | |
| 10 | + "auction": { | |
| 11 | + "id": "34", | |
| 12 | + "title": "U.S. and Worldwide Sale", | |
| 13 | + "dateText": "July 28-29, 2026" | |
| 14 | + }, | |
| 15 | + "category": "All Items", | |
| 16 | + "categoryUrl": "https://auctions.cherrystoneauctions.com/Category/All_Items-1.html?auctionid=34", | |
| 17 | + "lots": [ | |
| 18 | + { | |
| 19 | + "lotNumber": "1", | |
| 20 | + "title": "United States Stampless covers 1793 (9 Sep) FL from Le Havre, France to Philadelphia, with \"27 OC\" and \"Boston\" straight-line arrival handstamp, \"Sh 44\" ms due marking (4c ship plus inland 250-350...", | |
| 21 | + "url": "https://auctions.cherrystoneauctions.com/United_States_Stampless_covers__1793__9_Sep__FL_fr-LOT34088.aspx", | |
| 22 | + "image": "https://auctions.cherrystoneauctions.com/ItemImages/000034/34088A_sm.jpeg", | |
| 23 | + "finalPriceText": "$140", | |
| 24 | + "estimateText": "$140 - $150" | |
| 25 | + }, | |
| 26 | + { | |
| 27 | + "lotNumber": "2", | |
| 28 | + "title": "United States Stampless covers 1802 (12 Mar) FL from Philadelphia to Spain, via Bordeaux, with \"Col. Par Bordeaux\" straight-line hs, due markings, St.Sebastions arrival docketing (8 May), usual...", | |
| 29 | + "url": "https://auctions.cherrystoneauctions.com/United_States_Stampless_covers__1802__12_Mar__FL_f-LOT34089.aspx", | |
| 30 | + "image": "https://auctions.cherrystoneauctions.com/ItemImages/000034/34089A_sm.jpeg", | |
| 31 | + "finalPriceText": "None", | |
| 32 | + "estimateText": "$140 - $150" | |
| 33 | + }, | |
| 34 | + { | |
| 35 | + "lotNumber": "3", | |
| 36 | + "title": "United States Stampless covers 1811 (8 Nov) FL from Bordeaux to New York, with red Baltimore Feb 4 and \"Ship\" handstamped, charged \"53\" (3x17c inland and 2c Ship Letter fee), fine and scarce...", | |
| 37 | + "url": "https://auctions.cherrystoneauctions.com/United_States_Stampless_covers__1811__8_Nov__FL_fr-LOT34090.aspx", | |
| 38 | + "image": "https://auctions.cherrystoneauctions.com/ItemImages/000034/34090A_sm.jpeg", | |
| 39 | + "finalPriceText": "$140", | |
| 40 | + "estimateText": "$140 - $150" | |
| 41 | + }, | |
| 42 | + { | |
| 43 | + "lotNumber": "4", | |
| 44 | + "title": "United States Stampless covers 1814 (25 June) FL from New York to Bordeaux, with \"Col.Par Lorient\" entry hs, \"Deb 32 Bordeaux\" arrival, with \"10\" and \"9\" (decimes) due markings, fine early letter...", | |
| 45 | + "url": "https://auctions.cherrystoneauctions.com/United_States_Stampless_covers__1814__25_June__FL_-LOT34091.aspx", | |
| 46 | + "image": "https://auctions.cherrystoneauctions.com/ItemImages/000034/34091A_sm.jpeg", | |
| 47 | + "finalPriceText": "None", | |
| 48 | + "estimateText": "$140 - $150" | |
| 49 | + }, | |
| 50 | + { | |
| 51 | + "lotNumber": "5", | |
| 52 | + "title": "United States Stampless covers 1837-38 three FLs to Connecticut, one to New Haven, with oval \"Tallahassee FLOa\" Nov 23, 1837 datestamp (territorial), \"Paid\" alongside, charged \"25\", filing fold...", | |
| 53 | + "url": "https://auctions.cherrystoneauctions.com/United_States_Stampless_covers__1837_38_three_FLs_-LOT34092.aspx", | |
| 54 | + "image": "https://auctions.cherrystoneauctions.com/ItemImages/000034/34092A_sm.jpeg", | |
| 55 | + "finalPriceText": "$280", | |
| 56 | + "estimateText": "$180 - $200" | |
| 57 | + }, | |
| 58 | + { | |
| 59 | + "lotNumber": "6", | |
| 60 | + "title": "United States Stampless covers 1848 FL from New Orleans to Paris, endorsed \"By the Steamer from New York to Liverpool\", New Orleans departure pmk, with red framed \"Colonies &c.Art.13\" accountancy...", | |
| 61 | + "url": "https://auctions.cherrystoneauctions.com/United_States_Stampless_covers__1848_FL_from_New_O-LOT34093.aspx", | |
| 62 | + "image": "https://auctions.cherrystoneauctions.com/ItemImages/000034/34093A_sm.jpeg", | |
| 63 | + "finalPriceText": "$350", | |
| 64 | + "estimateText": "$230 - $250" | |
| 65 | + } | |
| 66 | + ] | |
| 67 | + } | |
| 68 | + }, | |
| 69 | + "expect": { | |
| 70 | + "count": 4, | |
| 71 | + "kinds": [ | |
| 72 | + "sale" | |
| 73 | + ] | |
| 74 | + }, | |
| 75 | + "note": "Captured live from https://auctions.cherrystoneauctions.com/Category/All_Items-1.html?auctionid=34 (lists trimmed to 6).", | |
| 76 | + "capturedAt": "2026-09-07T07:14:41.892Z" | |
| 77 | +} | |
| \ No newline at end of file | ||
added
data/fixtures/heffel/results-page.json
+80 −0
@@ -0,0 +1,80 @@ | ||
| 1 | +{ | |
| 2 | + "raw": { | |
| 3 | + "url": "https://www.heffel.com/Links/Results_E.aspx?Request=iZTDNdl2DfJc8jRi4pFzNF4FyYcqe80krsL77dDV7HCFgRnZlE65Sejm0ycgxb2j0Qr6cJ6rSY0ZYuAQ9cntX+Sax1io/xw2uXSHH5uc5XQpE4PCI46OSRRQTHiiQGjQfJYkm9Ein9BR2L9SmCGwwx/VZtRBukCcKykBGRCi51Mtc9NlqgUNblDGyi9l0WhU40B+sXjkJ9/8nfDJaaTB9P32730Ti7zy", | |
| 4 | + "externalId": "results:Spring 2026", | |
| 5 | + "kind": "sale", | |
| 6 | + "engine": "api", | |
| 7 | + "fetchedAt": "2026-09-07T07:10:25.591Z", | |
| 8 | + "payload": { | |
| 9 | + "kind": "results_page", | |
| 10 | + "sale": { | |
| 11 | + "label": "Spring 2026", | |
| 12 | + "url": "https://www.heffel.com/Links/Results_E.aspx?Request=iZTDNdl2DfJc8jRi4pFzNF4FyYcqe80krsL77dDV7HCFgRnZlE65Sejm0ycgxb2j0Qr6cJ6rSY0ZYuAQ9cntX+Sax1io/xw2uXSHH5uc5XQpE4PCI46OSRRQTHiiQGjQfJYkm9Ein9BR2L9SmCGwwx/VZtRBukCcKykBGRCi51Mtc9NlqgUNblDGyi9l0WhU40B+sXjkJ9/8nfDJaaTB9P32730Ti7zy" | |
| 13 | + }, | |
| 14 | + "saleName": "TNERSHIPS ARTISTS OF INTEREST EXPLORE VIRTUAL AUCTION PREVIEW EXCEPTIONAL RESULTS", | |
| 15 | + "saleDateText": "May 21 2026", | |
| 16 | + "sessions": [ | |
| 17 | + "Post-War & Contemporary Art" | |
| 18 | + ], | |
| 19 | + "premiumIncluded": true, | |
| 20 | + "lots": [ | |
| 21 | + { | |
| 22 | + "lotNumber": "001", | |
| 23 | + "title": "Untitled", | |
| 24 | + "artist": "Molinari, Guido", | |
| 25 | + "url": "https://www.heffel.com/Auction/LotDetails_E.aspx?Request=iZTDNdl2DfJc8jRi4pFzNF4FyYcqe80krsL77dDV7HCFgRnZlE65Sejm0ycgxb2jLFf//a9sxqr8XJiJM8G0bSQ6HJtcPFDKAHC7jV+PSzTcZfDT/lZH5XUuKpKunFens1jSYIrT030RZNapkNWimOgNt+DnF9hpzSV3V/ynjRaE4HQqxeJFcTIEfdfzIqnYcgU86K5vIY6y2ihOf3qy2uYrMqSBxiDk1ZZpBW2WvaQ=", | |
| 26 | + "image": "https://www.heffel.com/Images/Lots/150/PCRE-05022-0014-01.jpg", | |
| 27 | + "priceText": "$73,250" | |
| 28 | + }, | |
| 29 | + { | |
| 30 | + "lotNumber": "002", | |
| 31 | + "title": "Snow Light in My House", | |
| 32 | + "artist": "Pratt, Mary Frances", | |
| 33 | + "url": "https://www.heffel.com/Auction/LotDetails_E.aspx?Request=iZTDNdl2DfJc8jRi4pFzNF4FyYcqe80krsL77dDV7HCFgRnZlE65Sejm0ycgxb2jLFf//a9sxqr8XJiJM8G0bcIfuwANuBJKngyXpyWmrV+etgkuVHgT5sjncnrGM/ORsKZQRHQDRmDJgzZ0+hnM70d8LLcHOMIRmf1+Yv93/1sCptG0FJlFX7EvnV1tOrA0aRcFQuLtAbTYnlVDFyEWoJ9A6t/SToZJy+27Tau+jW8=", | |
| 34 | + "image": "https://www.heffel.com/Images/Lots/150/PCRE-04581-0021-01.jpg", | |
| 35 | + "priceText": "$205,250" | |
| 36 | + }, | |
| 37 | + { | |
| 38 | + "lotNumber": "003", | |
| 39 | + "title": "Blue Mist", | |
| 40 | + "artist": "Eyre, Ivan Kenneth", | |
| 41 | + "url": "https://www.heffel.com/Auction/LotDetails_E.aspx?Request=iZTDNdl2DfJc8jRi4pFzNF4FyYcqe80krsL77dDV7HCFgRnZlE65Sejm0ycgxb2jLFf//a9sxqr8XJiJM8G0bTb6oFjJmrhvkVdcylcts9fdFbuMeO/xoKUMHQ1oGDfE4vxHDukd115MncggpZs33MRVpZaY/DLxKk+lN47si4o/hudkG2LQerV2Dojat4+yLBl9SpTUl8/bxBn6YTB7401XoVU+pLCXuZ3295H+PVU=", | |
| 42 | + "image": "https://www.heffel.com/Images/Lots/150/A26S-E26044-001-01.jpg", | |
| 43 | + "priceText": "$217,250" | |
| 44 | + }, | |
| 45 | + { | |
| 46 | + "lotNumber": "004", | |
| 47 | + "title": "Snow #2", | |
| 48 | + "artist": "Smith, Gordon Appelbe", | |
| 49 | + "url": "https://www.heffel.com/Auction/LotDetails_E.aspx?Request=iZTDNdl2DfJc8jRi4pFzNF4FyYcqe80krsL77dDV7HCFgRnZlE65Sejm0ycgxb2jLFf//a9sxqr8XJiJM8G0bclEw0r7RbmgPDQ3ngowRMi+kd4IE6q7h9Ujb8ATT8UduTaBchZvmcXuMljYvOWDPOiL97+jELL53zAIfc2Mw6oCPsE+dkW7Gm6nC4A0o873VtNZgJdAbFnvMdUBE2t7wkD1TSUWOsdQ6ninSL0x+HU=", | |
| 50 | + "image": "https://www.heffel.com/Images/Lots/150/PCRE-11300-0002-01.jpg", | |
| 51 | + "priceText": "$73,250" | |
| 52 | + }, | |
| 53 | + { | |
| 54 | + "lotNumber": "005", | |
| 55 | + "title": "Inside Passage 12/98: In Grenville Channel", | |
| 56 | + "artist": "Tanabe, Takao", | |
| 57 | + "url": "https://www.heffel.com/Auction/LotDetails_E.aspx?Request=iZTDNdl2DfJc8jRi4pFzNF4FyYcqe80krsL77dDV7HCFgRnZlE65Sejm0ycgxb2jLFf//a9sxqr8XJiJM8G0baN6YDXUyaFgJi71yLWmS578vrnAqxtpFfH0OtfS3O0kX+NuTSh3iHME6Muk4cyAv5buoHnvDavzKrb9qQWmHWEzUSWdpvB0PuC3qjgOxQTsQWdf9kceRRw1QT9Lghyii15nV6LlIlX0uHND6qJQH2s=", | |
| 58 | + "image": "https://www.heffel.com/Images/Lots/150/PCRE-12305-0027-01.jpg", | |
| 59 | + "priceText": "$511,250" | |
| 60 | + }, | |
| 61 | + { | |
| 62 | + "lotNumber": "006", | |
| 63 | + "title": "Old Growth Pacific Rim", | |
| 64 | + "artist": "Smith, Gordon Appelbe", | |
| 65 | + "url": "https://www.heffel.com/Auction/LotDetails_E.aspx?Request=iZTDNdl2DfJc8jRi4pFzNF4FyYcqe80krsL77dDV7HCFgRnZlE65Sejm0ycgxb2jLFf//a9sxqr8XJiJM8G0bclEw0r7Rbmg+BcbC898z1WDUKgJ+vy9jaZkmmf7I7H0qPv3SVTVHvZgP/q+6X/oSe8syIHLrKgdwTbYQP1Cn6HDVkCWJiCfhbUICTUtDnfYGCi5g0pMnsj6Qy1J680i6KM+jHnYCQP2qas8zyW3uhM=", | |
| 66 | + "image": "https://www.heffel.com/Images/Lots/150/PCRE-11300-0001-01.jpg", | |
| 67 | + "priceText": "$61,250" | |
| 68 | + } | |
| 69 | + ] | |
| 70 | + } | |
| 71 | + }, | |
| 72 | + "expect": { | |
| 73 | + "count": 6, | |
| 74 | + "kinds": [ | |
| 75 | + "sale" | |
| 76 | + ] | |
| 77 | + }, | |
| 78 | + "note": "Captured live from https://www.heffel.com/Links/Results_E.aspx?Request=iZTDNdl2DfJc8jRi4pFzNF4FyYcqe80krsL77dDV7HCFgRnZlE65Sejm0ycgxb2j0Qr6cJ6rSY0ZYuAQ9cntX+Sax1io/xw2uXSHH5uc5XQpE4PCI46OSRRQTHiiQGjQfJYkm9Ein9BR2L9SmCGwwx/VZtRBukCcKykBGRCi51Mtc9NlqgUNblDGyi9l0WhU40B+sXjkJ9/8nfDJaaTB9P32730Ti7zy (lists trimmed to 6).", | |
| 79 | + "capturedAt": "2026-09-07T07:10:25.602Z" | |
| 80 | +} | |
| \ No newline at end of file | ||
added
data/fixtures/kelleher/category-page.json
+61 −0
@@ -0,0 +1,61 @@ | ||
| 1 | +{ | |
| 2 | + "raw": { | |
| 3 | + "url": "https://StampAuctionNetwork.com/cCatalog3.cfm?MAJGROUP=Confederate%20States&SrchFirm=V&SrchSale=5337", | |
| 4 | + "externalId": "sale:5337:Confederate States:", | |
| 5 | + "kind": "sale", | |
| 6 | + "engine": "api", | |
| 7 | + "fetchedAt": "2026-09-07T07:15:33.138Z", | |
| 8 | + "payload": { | |
| 9 | + "kind": "category_page", | |
| 10 | + "sale": { | |
| 11 | + "sale": "5337", | |
| 12 | + "title": "US, British and Worldwide Stamps and Postal History", | |
| 13 | + "dateText": "August 30, 2026", | |
| 14 | + "url": "https://StampAuctionNetwork.com/cCatalog.cfm?SrchFirm=V&SrchSale=5337" | |
| 15 | + }, | |
| 16 | + "majorGroup": "Confederate States", | |
| 17 | + "category": "", | |
| 18 | + "url": "https://StampAuctionNetwork.com/cCatalog3.cfm?MAJGROUP=Confederate%20States&SrchFirm=V&SrchSale=5337", | |
| 19 | + "lots": [ | |
| 20 | + { | |
| 21 | + "lotNumber": "7471", | |
| 22 | + "catNo": "11c", | |
| 23 | + "headline": "1863, 10¢ greenish blue (Scott 11c),", | |
| 24 | + "description": "1863, 10¢ greenish blue (Scott 11c), pair, nice example with straight cut margins, o.g., Fine to Very Fine. Scott $60. Suggested Bid $30", | |
| 25 | + "image": "https://StampAuctionNetwork.com/Photos/V/5337/7471.jpg", | |
| 26 | + "soldText": "US$ 30.00", | |
| 27 | + "closedText": "Aug-30-2026", | |
| 28 | + "soldFor": "30" | |
| 29 | + }, | |
| 30 | + { | |
| 31 | + "lotNumber": "7472", | |
| 32 | + "catNo": "11d", | |
| 33 | + "headline": "1863, 10¢ green (Scott 11d),", | |
| 34 | + "description": "1863, 10¢ green (Scott 11d), fresh and clean with nice centering, o.g., never hinged (natural gum crease), Very Fine. Scott $75. Suggested Bid $40", | |
| 35 | + "image": "https://StampAuctionNetwork.com/Photos/V/5337/7472.jpg", | |
| 36 | + "soldText": "US$ 46.00", | |
| 37 | + "closedText": "Aug-30-2026", | |
| 38 | + "soldFor": "46" | |
| 39 | + }, | |
| 40 | + { | |
| 41 | + "lotNumber": "7473", | |
| 42 | + "catNo": "13", | |
| 43 | + "headline": "1863, 20¢ green (Scott 13),", | |
| 44 | + "description": "1863, 20¢ green (Scott 13), clean example with a very light cancel, Fine. Scott $400. Suggested Bid $100", | |
| 45 | + "image": "https://StampAuctionNetwork.com/Photos/V/5337/7473.jpg", | |
| 46 | + "soldText": "US$ 62.00", | |
| 47 | + "closedText": "Aug-30-2026", | |
| 48 | + "soldFor": "62" | |
| 49 | + } | |
| 50 | + ] | |
| 51 | + } | |
| 52 | + }, | |
| 53 | + "expect": { | |
| 54 | + "count": 3, | |
| 55 | + "kinds": [ | |
| 56 | + "sale" | |
| 57 | + ] | |
| 58 | + }, | |
| 59 | + "note": "Captured live from https://StampAuctionNetwork.com/cCatalog3.cfm?MAJGROUP=Confederate%20States&SrchFirm=V&SrchSale=5337 (lists trimmed to 6).", | |
| 60 | + "capturedAt": "2026-09-07T07:15:33.143Z" | |
| 61 | +} | |
| \ No newline at end of file | ||
added
data/fixtures/potter-auctions/catalog-page.json
+97 −0
@@ -0,0 +1,97 @@ | ||
| 1 | +{ | |
| 2 | + "raw": { | |
| 3 | + "url": "https://auction.potterauctions.com/auctions/potter-potter/step-right-up-circus-and-sideshow-memorabilia-23625/catalog?page=1", | |
| 4 | + "externalId": "auction:23625:page:1", | |
| 5 | + "kind": "sale", | |
| 6 | + "engine": "api", | |
| 7 | + "fetchedAt": "2026-09-07T07:08:54.800Z", | |
| 8 | + "payload": { | |
| 9 | + "kind": "catalog_page", | |
| 10 | + "auction": { | |
| 11 | + "id": "23625", | |
| 12 | + "slug": "step-right-up-circus-and-sideshow-memorabilia", | |
| 13 | + "title": "Step Right Up: Circus and Sideshow Memorabilia", | |
| 14 | + "url": "https://auction.potterauctions.com/auctions/potter-potter/step-right-up-circus-and-sideshow-memorabilia-23625", | |
| 15 | + "startText": "Sep 03, 2026 11:00AM EDT" | |
| 16 | + }, | |
| 17 | + "page": 1, | |
| 18 | + "totalLots": null, | |
| 19 | + "lots": [ | |
| 20 | + { | |
| 21 | + "lotNumber": "1", | |
| 22 | + "title": "The Barnum & Bailey Greatest Show on Earth / P.T. Barnum - J.A. Bailey.", | |
| 23 | + "url": "https://auction.potterauctions.com/online-auctions/potter-potter/the-barnum-bailey-greatest-show-on-earth-p-t-barnum---j-a-bailey-9659017", | |
| 24 | + "statusLabel": "Sold for", | |
| 25 | + "priceText": "$500", | |
| 26 | + "bids": 6, | |
| 27 | + "estimateLow": 400, | |
| 28 | + "estimateHigh": 600, | |
| 29 | + "image": "https://s1.img.bidsquare.com/item/l/3825/38255151.jpeg?t=1WQWgw" | |
| 30 | + }, | |
| 31 | + { | |
| 32 | + "lotNumber": "2", | |
| 33 | + "title": "Barnum & Bailey / The Biggest and Grandest Street Procession Ever Seen.", | |
| 34 | + "url": "https://auction.potterauctions.com/online-auctions/potter-potter/barnum-bailey-the-biggest-and-grandest-street-procession-ever-seen-9659023", | |
| 35 | + "statusLabel": "Sold for", | |
| 36 | + "priceText": "$2,280", | |
| 37 | + "bids": 24, | |
| 38 | + "estimateLow": 800, | |
| 39 | + "estimateHigh": 1200, | |
| 40 | + "image": "https://s1.img.bidsquare.com/item/l/3825/38255033.jpeg?t=1WQVO1" | |
| 41 | + }, | |
| 42 | + { | |
| 43 | + "lotNumber": "3", | |
| 44 | + "title": "Ringling Bros. and Barnum & Bailey / Congress of World-Famous Horse Acts.", | |
| 45 | + "url": "https://auction.potterauctions.com/online-auctions/potter-potter/ringling-bros-and-barnum-bailey-congress-of-world-famous-horse-acts-9659210", | |
| 46 | + "statusLabel": "Sold for", | |
| 47 | + "priceText": "$2,000", | |
| 48 | + "bids": 23, | |
| 49 | + "estimateLow": 600, | |
| 50 | + "estimateHigh": 900, | |
| 51 | + "image": "https://s1.img.bidsquare.com/item/l/3824/38249207.jpeg?t=1WQJyz" | |
| 52 | + }, | |
| 53 | + { | |
| 54 | + "lotNumber": "4", | |
| 55 | + "title": "Die Barnum & Bailey / Groesste Schaustellung der Welt / Hastiges und aufregendes Wettrennen männlicher Jockeys. Cincinnati: Strobridge Litho., 1900.", | |
| 56 | + "url": "https://auction.potterauctions.com/online-auctions/potter-potter/die-barnum-bailey-groesste-schaustellung-der-welt-hastiges-und-aufregendes-wettrennen-m-nnlicher-jockeys-cincinnati-strobridge-litho-1900-9592177", | |
| 57 | + "statusLabel": "Sold for", | |
| 58 | + "priceText": "$330", | |
| 59 | + "bids": 3, | |
| 60 | + "estimateLow": 400, | |
| 61 | + "estimateHigh": 600, | |
| 62 | + "image": "https://s1.img.bidsquare.com/item/l/3809/38095268.jpeg?t=1WMDGP" | |
| 63 | + }, | |
| 64 | + { | |
| 65 | + "lotNumber": "5", | |
| 66 | + "title": "Ringling Bros. and Barnum & Bailey Circus / Unique and Astounding Performing Animals. Cincinnati & New York: Strobridge & Co., 1900.", | |
| 67 | + "url": "https://auction.potterauctions.com/online-auctions/potter-potter/ringling-bros-and-barnum-bailey-circus-unique-and-astounding-performing-animals-cincinnati-new-york-strobridge-co-1900-9610144", | |
| 68 | + "statusLabel": "Sold for", | |
| 69 | + "priceText": "$960", | |
| 70 | + "bids": 10, | |
| 71 | + "estimateLow": 500, | |
| 72 | + "estimateHigh": 1000, | |
| 73 | + "image": "https://s1.img.bidsquare.com/item/l/3810/38107640.jpeg?t=1WMZfC" | |
| 74 | + }, | |
| 75 | + { | |
| 76 | + "lotNumber": "6", | |
| 77 | + "title": "Ringling Bros. and Barnum & Bailey Circus / Rare Beasts and Birds. Cincinnati & New York: Strobridge & Co., 1901.", | |
| 78 | + "url": "https://auction.potterauctions.com/online-auctions/potter-potter/ringling-bros-and-barnum-bailey-circus-rare-beasts-and-birds-cincinnati-new-york-strobridge-co-1901-9610161", | |
| 79 | + "statusLabel": "Sold for", | |
| 80 | + "priceText": "$840", | |
| 81 | + "bids": 11, | |
| 82 | + "estimateLow": 500, | |
| 83 | + "estimateHigh": 1000, | |
| 84 | + "image": "https://s1.img.bidsquare.com/item/l/3810/38107641.jpeg?t=1WMZfC" | |
| 85 | + } | |
| 86 | + ] | |
| 87 | + } | |
| 88 | + }, | |
| 89 | + "expect": { | |
| 90 | + "count": 6, | |
| 91 | + "kinds": [ | |
| 92 | + "sale" | |
| 93 | + ] | |
| 94 | + }, | |
| 95 | + "note": "Captured live from https://auction.potterauctions.com/auctions/potter-potter/step-right-up-circus-and-sideshow-memorabilia-23625/catalog?page=1 (lists trimmed to 6).", | |
| 96 | + "capturedAt": "2026-09-07T07:08:54.805Z" | |
| 97 | +} | |
| \ No newline at end of file | ||
| 98 | ||