import { z } from 'zod'; import { BaseConnector, type ConnectorMeta, type CrawlContext, type RawRecordInput, type RawRecordLike } from '@rareindex/connectors'; import { parseGradeFromTitle } from '@rareindex/taxonomy'; import type { NormalizedRecord, NormalizedSale } from '@rareindex/shared'; import { dateMDY, lotAttributes, makeSale, md, money, splitMarkdownItems } from '../_carlib/index.js'; const BASE = 'https://pbagalleries.com'; const PARSER_VERSION = '1.0.0'; export const AuctionSchema = z.object({ catalogId: z.string(), saleNumber: z.string().nullable(), title: z.string(), dateText: z.string().nullable(), lots: z.number().nullable(), closed: z.boolean() }); export const LotSchema = z.object({ lotNumber: z.string().nullable(), lotId: z.string(), title: z.string(), url: z.string(), soldText: z.string().nullable(), status: z.string().nullable(), estimateText: z.string().nullable(), fields: z.record(z.string(), z.string()), image: z.string().nullable(), }); export const CatalogPayloadSchema = z.object({ kind: z.literal('catalog_page'), auction: AuctionSchema, page: z.number(), lots: z.array(LotSchema) }); export type CatalogPayload = z.infer; export function parseAuctionList(markdown: string): z.infer[] { const out: z.infer[] = []; const chunks = splitMarkdownItems(markdown, /^- \[!\[/m); for (const c of chunks) { const info = c.match(/\((https:\/\/pbagalleries\.com\/auctions\/info\/id\/(\d+))\)/); if (!info) continue; const catalogId = info[2]!; const head = c.match(/######\s*\[(?:(\d+)\s+)?([^\]]+)\]/); const title = md.clean(head?.[2] ?? ''); if (!title) continue; const dateText = c.match(/\[(\d{2}\/\d{2}\/\d{4})[^\]]*\]/)?.[1] ?? null; const lots = c.match(/Lots:\s*(\d+)/)?.[1]; const closed = /Sale closed/i.test(c); if (!out.some((a) => a.catalogId === catalogId)) out.push({ catalogId, saleNumber: head?.[1] ?? null, title, dateText, lots: lots ? Number(lots) : null, closed }); } return out; } export function parseCatalogPage(markdown: string, auction: z.infer, page: number): CatalogPayload { const chunks = splitMarkdownItems(markdown, /^- \[!\[/m); const lots: z.infer[] = []; for (const c of chunks) { const url = c.match(/\((https:\/\/pbagalleries\.com\/lot-details\/index\/catalog\/\d+\/lot\/(\d+)\/[^)?\s]+)/); if (!url) continue; const title = c.match(/##\s*\[([^\]]+)\]/)?.[1]; if (!title) continue; const fields: Record = {}; const fre = /^\s*-\s+([A-Z][A-Za-z /]+)\n\n\s+(.+)$/gm; let fm: RegExpExecArray | null; while ((fm = fre.exec(c))) fields[fm[1]!.trim()] = md.clean(fm[2]!); lots.push({ lotNumber: c.match(/\[Lot #(\d+)\]/)?.[1] ?? null, lotId: url[2]!, title: md.clean(title), url: url[1]!, soldText: c.match(/Sold for\s*(\$[\d,]+(?:\.\d+)?)/)?.[1] ?? null, status: c.match(/Status\s*([A-Za-z ]+)/)?.[1]?.trim() ?? null, estimateText: md.clean(c.match(/Estimate\s*(\$[^\n]+)/)?.[1] ?? '') || null, fields, image: md.image(c), }); } return { kind: 'catalog_page', auction, page, lots }; } export function pbaCategory(saleTitle: string, lot: { title: string; fields: Record }): string { const s = `${saleTitle}`.toLowerCase(); const t = `${lot.title} ${Object.values(lot.fields).join(' ')}`.toLowerCase(); if (/comic|pre-code|ec,|mad,|graphic novel/.test(s) || /cgc|cbcs|no\. \d+ \*|comic/.test(t)) { const pub = (lot.fields.Publisher ?? '').toLowerCase(); if (/marvel|timely|atlas/.test(pub)) return 'marvel_comics'; if (/\bdc\b|national|vertigo/.test(pub)) return 'dc_comics'; return 'independent_comics'; } if (/photograph/.test(s) && !/book/.test(t)) return 'photography'; if (/map|atlas|cartograph/.test(s)) return 'maps'; if (/poster/.test(s) || /poster/.test(t)) return 'movie_posters'; if (/\b(autograph letter|letter signed|typed letter|manuscript (?:leaf|page|document)|signed document|archive of|telegram|deed|land grant)\b/.test(t)) return 'historical_documents'; if (/fine art|print|painting/.test(s) && !/book/.test(t)) return 'art'; return 'books'; } export class PbaGalleriesConnector extends BaseConnector { readonly version = '1.0.0'; readonly parserVersion = PARSER_VERSION; protected override minIntervalMs = 2000; async *crawl(ctx: CrawlContext): AsyncIterable { const auctionsPerRun = Number(this.meta.config.auctionsPerRun ?? 2); const pagesPerAuction = Number(this.meta.config.catalogPagesPerAuction ?? 5); const done = new Set(Array.isArray(ctx.options.cursor?.doneCatalogs) ? (ctx.options.cursor!.doneCatalogs as string[]) : []); const listPage = ctx.options.mode === 'backfill' ? Number(ctx.options.cursor?.listPage ?? 1) : 1; const listUrl = `${BASE}/auctions/${listPage > 1 ? `?page=${listPage}` : ''}`; await this.throttle(); const list = await ctx.fetch(listUrl, { expect: ['title', 'date'], parse: (r) => (r.markdown ? { title: parseAuctionList(r.markdown)[0]?.title ?? null, date: parseAuctionList(r.markdown)[0]?.dateText ?? null } : null) }); if (!list.success || !list.markdown) { ctx.anomaly('page_fetch_failed', `${listUrl}: ${list.error ?? list.httpStatus}`); return; } const auctions = parseAuctionList(list.markdown).filter((a) => a.closed && !done.has(a.catalogId)); let count = 0; let processed = 0; for (const auction of auctions) { if (processed >= auctionsPerRun || ctx.signal?.aborted) break; for (let page = 1; page <= pagesPerAuction; page++) { if (ctx.signal?.aborted || this.reached(ctx, count)) break; const url = `${BASE}/auctions/catalog/id/${auction.catalogId}${page > 1 ? `?page=${page}` : ''}`; await this.throttle(); const res = await ctx.fetch(url, { expect: ['title', 'price', 'date', 'status'], parse: (r) => { const f = r.markdown ? parseCatalogPage(r.markdown, auction, page).lots.find((l) => l.soldText) : null; return f ? { title: f.title, price: money(f.soldText, 'USD')?.amount ?? null, date: auction.dateText, status: f.status } : null; }, }); if (!res.success || !res.markdown) { ctx.anomaly('page_fetch_failed', `${url}: ${res.error ?? res.httpStatus}`); break; } const payload = parseCatalogPage(res.markdown, auction, page); if (payload.lots.length === 0) break; count++; yield { url, externalId: `catalog:${auction.catalogId}:page:${page}`, kind: 'sale', engine: res.engine, httpStatus: res.httpStatus, payload, fetchedAt: res.fetchedAt }; if (!res.markdown.includes(`catalog/id/${auction.catalogId}?page=${page + 1}`)) break; } processed++; done.add(auction.catalogId); await ctx.setCursor({ doneCatalogs: [...done].slice(-300), listPage: ctx.options.mode === 'backfill' && auctions.every((a) => done.has(a.catalogId)) ? listPage + 1 : listPage, updatedAt: new Date().toISOString() }); } } async normalize(raw: RawRecordLike): Promise { const p = CatalogPayloadSchema.parse(raw.payload); const saleDate = dateMDY(p.auction.dateText); if (!saleDate) return []; const out: NormalizedSale[] = []; for (const lot of p.lots) { if (!lot.soldText || (lot.status && !/sold/i.test(lot.status))) continue; const m = money(lot.soldText, 'USD'); if (!m) continue; const categorySlug = pbaCategory(p.auction.title, lot); const g = parseGradeFromTitle(lot.title); const issue = lot.title.match(/#\s?(\d+[A-Za-z]?)/)?.[1] ?? lot.fields.Title?.match(/No\.\s*(\d+)/)?.[1] ?? null; const yearField = Object.entries(lot.fields).find(([k]) => /date|year/i.test(k))?.[1] ?? null; const year = yearField?.match(/\b(1[6-9]\d{2}|20\d{2})\b/)?.[1]; const attributes = lotAttributes({ categorySlug, name: lot.title, brand: lot.fields.Publisher ?? lot.fields.Author ?? null, series: categorySlug.endsWith('_comics') ? (lot.fields.Title?.replace(/\s*No\.\s*\d+.*$/i, '') ?? null) : null, set: categorySlug.endsWith('_comics') ? (lot.fields.Title?.replace(/\s*No\.\s*\d+.*$/i, '') ?? null) : null, number: categorySlug.endsWith('_comics') ? issue : null, year: year ? Number(year) : null, identifiers: { pba_lot: lot.lotId }, metadata: { sale_number: p.auction.saleNumber, sale_title: p.auction.title, estimate: lot.estimateText, fields: lot.fields }, }); out.push(makeSale({ meta: this.meta, sourceUrl: lot.url, externalId: lot.lotId, rawTitle: lot.title, attributes, price: m.amount, currency: 'USD', saleDate, buyerPremiumIncluded: true, auctionHouse: 'PBA Galleries', lotNumber: lot.lotNumber, imageUrls: lot.image ? [lot.image] : [], observedAt: raw.fetchedAt, parserVersion: PARSER_VERSION, grader: g.grader && g.grader !== 'raw' ? g.grader : null, grade: g.grader && g.grader !== 'raw' ? g.grade : null, location: 'US' })); } return out; } } export default (meta: ConnectorMeta) => new PbaGalleriesConnector(meta);