import { z } from 'zod'; import { BaseConnector, html as H, type ConnectorMeta, type CrawlContext, type RawRecordInput, type RawRecordLike } from '@rareindex/connectors'; import type { NormalizedRecord } from '@rareindex/shared'; import { hintFromLabel, slugFromTitle } from '../_auction-lib/categories.js'; import { amount, decodeEntities, houseCategory, isBundleTitle, lotAttributes, makeSale, parseUsDate, safeYear, saleGrade } from '../_g7-auctions-na-lib/index.js'; const SITE = 'https://www.doyle.com'; const HOUSE = 'Doyle'; const PARSER_VERSION = '1.0.0'; const PAGE_SIZE = 96; export const AuctionSchema = z.object({ auId: z.string(), code: z.string().nullable(), name: z.string(), dateText: z.string().nullable(), url: z.string() }); export const LotSchema = z.object({ lotId: z.string(), lotNumber: z.string().nullable(), title: z.string(), url: z.string(), image: z.string().nullable(), soldText: z.string().nullable(), soldPrice: z.number().nullable(), estimateText: z.string().nullable(), estimateLow: z.number().nullable(), estimateHigh: z.number().nullable(), categoryId: z.string().nullable(), }); export const PayloadSchema = z.object({ kind: z.literal('doyle_results_page'), auction: AuctionSchema, page: z.number(), hasNext: z.boolean(), lots: z.array(LotSchema) }); export type Payload = z.infer; export type DoyleAuction = z.infer; /** /past-auctions/ → calendar items (newest first, as displayed). */ export function parsePastAuctions(htmlText: string): DoyleAuction[] { const $ = H.load(htmlText); const out: DoyleAuction[] = []; const seen = new Set(); $('.auction-calendar-item').each((_, el) => { const item = $(el); const href = item.find('a[href*="/auction/"][href*="au="]').first().attr('href'); const auId = href?.match(/[?&]au=(\d+)/)?.[1]; if (!href || !auId || seen.has(auId)) return; seen.add(auId); const name = H.text(item.find('h3, H3').first()) ?? ''; // Live sales print "Date: …"; online-only sales print "Ends: …" (the close date = sale date). const dateText = H.text(item.find('strong').filter((_, s) => /^\s*(Date|Ends?):/i.test($(s).text())).first())?.replace(/^(Date|Ends?):\s*/i, '') ?? null; const code = href.match(/\/auction\/([0-9a-z]+)-/i)?.[1]?.toUpperCase() ?? null; out.push({ auId, code, name: decodeEntities(name), dateText, url: `${SITE}${href.split('#')[0]}` }); }); return out; } /** "Mon d, yyyy hh:mm EST" header on an auction page (fallback when the calendar date is missing, e.g. seeds). */ export function parseAuctionHeaderDate(htmlText: string): string | null { const text = htmlText.replace(/|/g, ' ').replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' '); return text.match(/\b((?:Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)[a-z]* \d{1,2}, 20\d{2}(?: \d{1,2}:\d{2} [A-Z]{2,4})?)\b/)?.[1] ?? null; } function lotUrl(href: string): string { const u = new URL(href.replace(/&/g, '&'), SITE); const lot = u.searchParams.get('lot'); const au = u.searchParams.get('au'); const out = new URL(u.pathname, SITE); if (lot) out.searchParams.set('lot', lot); if (au) out.searchParams.set('au', au); return out.toString(); } /** One results page (/auction/search/?au=…&pp=96&pn=N&g=1) → lot cards. */ export function parseResultsPage(htmlText: string, auction: DoyleAuction, page: number): Payload { const $ = H.load(htmlText); const lots: Payload['lots'] = []; const seen = new Set(); $('div.auction-lot').each((_, el) => { const card = $(el); const link = card.find('p.auction-lot-title a').first(); const href = link.attr('href') ?? card.find('a[href*="/auction/lot/"]').first().attr('href'); const lotId = href?.match(/[?&]lot=(\d+)/)?.[1]; if (!href || !lotId || seen.has(lotId)) return; seen.add(lotId); const span = card.find('span.lot-title').first(); const parts = (span.html() ?? '').split(//i); const lotNumber = H.text(H.load(parts[0] ?? '')('body'))?.replace(/^Lot\s*/i, '').trim() || null; const title = decodeEntities(H.text(H.load(parts.slice(1).join(' '))('body')) ?? '') || decodeEntities(H.text(span) ?? ''); if (!title) return; const categoryId = (span.attr('class') ?? '').match(/\bcat-(\d+)/)?.[1] ?? null; let soldText: string | null = null; let estimateText: string | null = null; card.find('strong').each((_, s) => { const t = H.text($(s)) ?? ''; if (/^Sold for/i.test(t)) soldText = t; else if (/^Estimate/i.test(t)) estimateText = t; }); const est = (estimateText ?? '').match(/\$([\d,]+)\s*-\s*\$([\d,]+)/); const img = card.find('img[src*="/stock/"]').first().attr('src') ?? null; lots.push({ lotId, lotNumber, title, url: lotUrl(href), image: img, soldText, soldPrice: soldText ? amount((soldText as string).replace(/^Sold for/i, '')) : null, estimateText, estimateLow: est ? amount(est[1]) : null, estimateHigh: est ? amount(est[2]) : null, categoryId, }); }); const hasNext = new RegExp(`[?&](?:amp;)?pn=${page + 1}(?:&|'|"|$)`).test(htmlText); return { kind: 'doyle_results_page', auction, page, hasNext, lots }; } /** Doyle sale name + lot title → taxonomy slug (null when nothing confident). */ export function doyleCategory(saleName: string, title: string): string | null { const s = saleName.toLowerCase(); // Natural history lots appear inside book/decorative sales; decide them before department fallbacks // (and before the handbag keyword "clutch" can misfire on "clutch of eggs"). if (/\b(meteorite|pallasite|chondrite)\b/i.test(title)) return 'meteorites'; if (/\b(fossil|ammonite|trilobite|dinosaur|sauropod|megalodon|mammoth|mosasaur|petrified|fossilized|coprolite)\b/i.test(title)) return 'fossils'; if (/\b(mineral specimen|geode|quartz cluster|amethyst|tourmaline|fluorite|azurite|malachite|crystal cluster|agate slice)\b/i.test(title)) return 'minerals'; if (/couture|handbag|fashion|luxury accessor/.test(s)) return slugFromTitle(title, 'fashion') ?? 'fashion_streetwear'; if (/book|autograph|map|manuscript|bibliophil|librar|print(ed)? & manuscript/.test(s)) return slugFromTitle(title, 'books') ?? 'books'; if (/photograph/.test(s)) return slugFromTitle(title, 'photographs') ?? 'photography'; if (/jewel|gem/.test(s) && !/watch/.test(s)) return slugFromTitle(title, 'jewelry') ?? 'jewelry'; if (/watch/.test(s)) return slugFromTitle(title, /\b(watch|wristwatch|chronograph|pocket watch)\b/i.test(title) ? 'watches' : 'jewelry') ?? 'jewelry'; if (/coin|bank ?note|stamp|currency|numismat/.test(s)) return slugFromTitle(title, 'coins') ?? 'coins'; // Silver-only sales; mixed sales ("… Furniture, Old Master Paintings, Silver") fall through to the title sweep below. if (/silver|vertu/.test(s) && !/furniture|painting|decorative|works of art/.test(s)) return slugFromTitle(title, 'silver') ?? 'silver'; // Mixed sales ("English & Continental Furniture, Old Master Paintings, Silver"): let the title decide, default antiques. if (/furniture|decorative|works of art|at home|estate|collects/.test(s) && /painting|silver|art\b/.test(s)) { if (/\b(oil on|acrylic|watercolou?r|gouache|lithograph|etching|engraving|screenprint|woodcut|drawing|pastel|bronze|sculpture|mixed media)\b/i.test(title)) return slugFromTitle(title, 'art') ?? 'art'; return slugFromTitle(title, 'furniture') ?? 'antiques'; } if (/contemporary|post-war|modern art/.test(s)) return slugFromTitle(title, 'contemporary') ?? 'contemporary_art'; if (/painting|prints|drawing|american art|european art|impressionist|old master|fine art|sculpture|works on paper|artist/.test(s)) return slugFromTitle(title, 'art') ?? 'art'; if (/design|mid-century|20th century decorative/.test(s)) return slugFromTitle(title, 'design') ?? 'design_furniture'; if (/asian|chinese|japanese|russian|furniture|decorative|english|continental|at home|estate|collects|interior|american story|americana|works of art|antique|rug|carpet/.test(s)) return slugFromTitle(title, 'furniture') ?? 'antiques'; const generic = houseCategory(saleName, title, null); if (generic) return generic; return hintFromLabel(saleName) === 'unknown' ? null : 'antiques'; } /** * Doyle (New York) — auction results. Public server-rendered pages over plain HTTPS: /past-auctions/ (calendar of * closed sales with date) and /auction/search/?au=&pp=96&pn=N&g=1 (result cards "Sold for $X", estimate). * robots.txt asks for crawl-delay 10, honoured. See meta.json accessNotes. */ export class DoyleConnector extends BaseConnector { readonly version = '1.0.0'; readonly parserVersion = PARSER_VERSION; protected override minIntervalMs = 10_000; // robots.txt crawl-delay: 10 private async html(ctx: CrawlContext, url: string): Promise<{ html: string | null; status: number | null; fetchedAt: Date }> { await this.throttle(url); const res = await ctx.fetch(url, { engines: ['api'], responseType: 'text', minQuality: 0, timeoutMs: 60_000 }); if (!res.success || !res.html) { ctx.anomaly('page_fetch_failed', `${url}: ${res.error ?? res.httpStatus}`); return { html: null, status: res.httpStatus, fetchedAt: res.fetchedAt }; } return { html: res.html, status: res.httpStatus, fetchedAt: res.fetchedAt }; } private async listAuctions(ctx: CrawlContext): Promise { if (ctx.options.seeds?.length) { const out: DoyleAuction[] = []; for (const s of ctx.options.seeds) { const auId = s.match(/[?&]au=(\d+)/)?.[1]; if (!auId) continue; const code = s.match(/\/auction\/([0-9a-z]+)-/i)?.[1]?.toUpperCase() ?? null; out.push({ auId, code, name: '', dateText: null, url: s.startsWith('http') ? s : `${SITE}${s}` }); } return out; } const r = await this.html(ctx, `${SITE}/past-auctions/`); if (!r.html) return []; const list = parsePastAuctions(r.html); if (!list.length) ctx.anomaly('selector_missing', 'past-auctions: no .auction-calendar-item found'); return list; } async *crawl(ctx: CrawlContext): AsyncIterable { const auctionsPerRun = ctx.options.mode === 'probe' ? 1 : Number(this.meta.config.auctionsPerRun ?? 2); const maxPages = ctx.options.mode === 'probe' ? 1 : Number(this.meta.config.pagesPerAuction ?? 15); const done = new Set(Array.isArray(ctx.options.cursor?.doneAuctions) ? (ctx.options.cursor!.doneAuctions as string[]) : []); const all = await this.listAuctions(ctx); // Incremental: newest first (list order). Backfill: oldest first so progress walks the archive forward. const ordered = ctx.options.mode === 'backfill' ? [...all].reverse() : all; const pending = ordered.filter((a) => !done.has(a.auId)); let processed = 0; let count = 0; let items = 0; for (const auction of pending) { if (ctx.signal?.aborted || processed >= auctionsPerRun || this.reached(ctx, count)) break; if (!auction.dateText) { // Seeds carry no calendar date: read the auction page header once. const page = await this.html(ctx, auction.url); if (page.html) { auction.dateText = parseAuctionHeaderDate(page.html); if (!auction.name) auction.name = decodeEntities(H.text(H.load(page.html)('h1').first()) ?? H.text(H.load(page.html)('title')) ?? ''); } if (!auction.dateText) ctx.anomaly('missing_auction_date', auction.url); } let complete = true; for (let page = 1; page <= maxPages; page++) { if (ctx.signal?.aborted || this.reached(ctx, count)) { complete = false; break; } const url = `${SITE}/auction/search/?au=${auction.auId}&pp=${PAGE_SIZE}${page > 1 ? `&pn=${page}` : ''}&g=1`; const r = await this.html(ctx, url); if (!r.html) { complete = false; break; } const payload = parseResultsPage(r.html, auction, page); if (payload.lots.length === 0) { if (page === 1) ctx.anomaly('parse_failure_page', `${url}: no lot cards`); break; } count++; items += payload.lots.length; yield { url, externalId: `auction:${auction.auId}:page:${page}`, kind: 'sale', engine: 'api', httpStatus: r.status, payload, fetchedAt: r.fetchedAt }; if (!payload.hasNext) break; if (page === maxPages) complete = false; } processed++; if (complete) done.add(auction.auId); const doneList = [...done].slice(-500); await ctx.setCursor({ doneAuctions: doneList, updatedAt: new Date().toISOString() }); await ctx.progress({ page: all.filter((a) => done.has(a.auId)).length, totalPages: all.length, itemsProcessed: items, cursor: { doneAuctions: doneList } }); } if (ctx.options.mode === 'backfill' && all.length && all.every((a) => done.has(a.auId))) await ctx.setCursor({ doneAuctions: [...done].slice(-500), done: true, updatedAt: new Date().toISOString() }); } async normalize(raw: RawRecordLike): Promise { const p = PayloadSchema.parse(raw.payload); const saleDate = parseUsDate(p.auction.dateText); if (!saleDate || saleDate.getTime() > Date.now() + 86_400_000) return []; const out: NormalizedRecord[] = []; for (const l of p.lots) { if (!l.soldPrice) continue; // unsold / withdrawn / passed: no realised price const slug = doyleCategory(p.auction.name, l.title); if (!slug) continue; const g = saleGrade(l.title); const attributes = lotAttributes({ categorySlug: slug, name: l.title, year: safeYear(l.title), identifiers: { doyle_lot_id: l.lotId }, metadata: { auction_id: p.auction.auId, auction_code: p.auction.code, auction_name: p.auction.name, estimate_low: l.estimateLow, estimate_high: l.estimateHigh, doyle_category_id: l.categoryId }, }); out.push( makeSale({ meta: this.meta, sourceUrl: l.url, externalId: l.lotId, rawTitle: l.title, attributes, price: l.soldPrice, currency: 'USD', saleDate, // Doyle lot pages print "Includes Buyer's Premium" under "Sold for". buyerPremiumIncluded: true, auctionHouse: HOUSE, lotNumber: l.lotNumber, imageUrls: l.image ? [l.image] : [], location: 'US', observedAt: raw.fetchedAt, parserVersion: PARSER_VERSION, grader: g.grader, grade: g.grade, isBundle: isBundleTitle(l.title), confidence: 0.85, }), ); } return out; } } export default (meta: ConnectorMeta) => new DoyleConnector(meta);