import { z } from 'zod'; import { BaseConnector, html as H, type ConnectorMeta, type CrawlContext, type RawRecordInput, type RawRecordLike } from '@rareindex/connectors'; import { NormalizedSaleSchema, extractYear, parsePrice, type NormalizedRecord } from '@rareindex/shared'; import { parseGradeFromTitle } from '@rareindex/taxonomy'; import { classifyGoldinTitle, parseCardAttributes, isBundleTitle } from './classify.js'; import { scrapflyRender, type ScrapflyRenderResult } from './scrapfly-render.js'; /** * Goldin — sold auction results (§110). Primary engine: Scrapfly (JS rendering + ASP). * Data source = the lots_v2 search XHR performed by Goldin's own public "Sold Items" grid. * See meta.json accessNotes for the access/legal rationale. */ const IMAGE_CDN = 'https://d2tt46f3mh26nl.cloudfront.net/public/Lots'; const SITE = 'https://goldin.co'; export const GoldinLotSchema = z.object({ lot_id: z.string(), title: z.string(), meta_slug: z.string(), current_price: z.number().nullable().optional(), buyer_premium: z.number().nullable().optional(), end_timestamp: z.string().nullable().optional(), start_timestamp: z.string().nullable().optional(), status: z.string().nullable().optional(), auction_id: z.string().nullable().optional(), auction_type: z.string().nullable().optional(), lot_number: z.number().nullable().optional(), number_of_bids: z.number().nullable().optional(), primary_image_name: z.string().nullable().optional(), }); export type GoldinLot = z.infer; export const SeedSchema = z.object({ path: z.string(), categorySlug: z.string(), sport: z.string().optional() }); export type Seed = z.infer; /** Raw payload for one rendered "Sold Items" page. */ export const SoldPagePayloadSchema = z.object({ kind: z.literal('sold_page'), seed: SeedSchema, page: z.number().int(), pageUrl: z.string(), request: z.record(z.string(), z.unknown()), total: z.number().nullable(), lots: z.array(GoldinLotSchema), auctions: z.record(z.string(), z.object({ title: z.string().nullable().optional(), auction_type: z.string().nullable().optional(), end_timestamp: z.string().nullable().optional(), buyer_premium: z.number().nullable().optional() })).default({}), }); export type SoldPagePayload = z.infer; /** Raw payload for one rendered item page (lookup). Text is pre-flattened; no HTML stored. */ export const ItemPagePayloadSchema = z.object({ kind: z.literal('item_page'), url: z.string(), title: z.string(), breadcrumb: z.array(z.string()).default([]), totalPriceText: z.string().nullable(), winningBidText: z.string().nullable(), endedText: z.string().nullable(), bidsText: z.string().nullable(), sold: z.boolean(), description: z.string().nullable(), images: z.array(z.string()).default([]), lotNumber: z.string().nullable(), }); export type ItemPagePayload = z.infer; const ConfigSchema = z.object({ seeds: z.array(SeedSchema).default([]), pageSize: z.number().int().min(24).max(240).default(240), maxPagesPerSeed: z.number().int().min(1).default(40), renderingWaitMs: z.number().int().default(6000), }); function parseUtc(s: string | null | undefined): Date | null { if (!s) return null; const iso = /[zZ]$|[+-]\d{2}:?\d{2}$/.test(s) ? s : `${s}Z`; const d = new Date(iso); return Number.isNaN(d.getTime()) ? null : d; } /** * Goldin titles end with " - " (e.g. "- PSA GEM MT 10"). Parse that suffix first so that * words like "Tag Team" inside the card name are not mistaken for TAG grading. */ export function parseGoldinGrade(title: string): ReturnType { const parts = title.split(/\s+-\s+/); for (let i = parts.length - 1; i >= 1; i--) { const g = parseGradeFromTitle(parts[i]!); if (g.grader && g.grader !== 'raw' && g.grade) return g; } const g = parseGradeFromTitle(title); if (g.grader === 'tag' && !/\bTAG\s+\d/i.test(title)) return { grader: null, grade: null, qualifier: null }; return g; } export function soldPageUrl(seedPath: string, page: number, pageSize: number): string { return `${SITE}${seedPath}?page=${page}&number_of_lots=${pageSize}&show_only=Sold%20Items&sort=Most_Recent_Bids`; } /** Pick the lots_v2 XHR that carries the seed filter (the page also fires an unfiltered call). */ export function extractLotsXhr(render: ScrapflyRenderResult): { request: Record; total: number | null; lots: unknown[] } | null { const candidates = render.xhr.filter((x) => x.url.includes('/api/lots_v2') && x.responseBody); let best: { request: Record; total: number | null; lots: unknown[] } | null = null; for (const c of candidates) { try { const req = (JSON.parse(c.requestBody ?? '{}') as { search?: Record }).search ?? {}; const body = JSON.parse(c.responseBody!) as { searchalgolia?: { lots?: unknown[]; total?: string | number } }; const sa = body.searchalgolia; if (!sa?.lots) continue; const filtered = Boolean(req.sub_category || req.category || req.keyword); const total = sa.total === undefined ? null : Number(sa.total); const cand = { request: req, total: Number.isFinite(total as number) ? (total as number) : null, lots: sa.lots }; if (filtered || !best) best = cand; } catch { /* skip malformed */ } } return best; } export function extractAuctionsXhr(render: ScrapflyRenderResult): SoldPagePayload['auctions'] { const out: SoldPagePayload['auctions'] = {}; for (const x of render.xhr) { if (!x.url.endsWith('/api/auctions') || !x.responseBody || x.responseBody.length < 1000) continue; try { const body = JSON.parse(x.responseBody) as { auctions?: Array> }; for (const a of body.auctions ?? []) { const id = a.auction_id as string | undefined; if (!id) continue; out[id] = { title: (a.title as string) ?? null, auction_type: (a.auction_type as string) ?? null, end_timestamp: (a.end_timestamp as string) ?? null, buyer_premium: typeof a.buyer_premium === 'number' ? a.buyer_premium : null }; } } catch { /* ignore */ } } return out; } /** Flatten a rendered item page into a compact payload (no HTML persisted). */ export function parseItemPage(url: string, pageHtml: string): ItemPagePayload { const $ = H.load(pageHtml); $('script, style, noscript').remove(); const text = $('body').text().replace(/\s+/g, ' ').trim(); const title = $('h1').first().text().trim() || ($('title').text().split('|')[0] ?? '').trim(); const winning = text.match(/\$([\d,]+(?:\.\d{2})?)\s*Winning Bid/i); // Live lots also embed a hidden "Lot Sold" template, so only the winning-bid figure proves a sale. const sold = winning !== null; // total price appears right before the winning bid on sold pages, or before "with Buyer’s Premium" on live ones let total: string | null = null; if (winning) { const before = text.slice(Math.max(0, winning.index! - 40), winning.index!); const m = before.match(/\$([\d,]+(?:\.\d{2})?)\s*$/); total = m ? `$${m[1]}` : null; } else { const m = text.match(/\$([\d,]+(?:\.\d{2})?)\s*with Buyer/i); total = m ? `$${m[1]}` : null; } const ended = text.match(/((?:Sun|Mon|Tue|Wed|Thu|Fri|Sat),\s*\d{1,2}\/\d{1,2}\/\d{2,4},\s*\d{1,2}:\d{2}\s*[AP]M\s*(?:P[SD]T|E[SD]T|C[SD]T|M[SD]T|UTC|GMT))/); const bids = text.match(/(?:^|\s|\|)(\d{1,4}) bids?\b/i); const lot = text.match(/Lot #(\d+)/); const descIdx = text.indexOf('Description'); const description = descIdx >= 0 ? text.slice(descIdx + 'Description'.length, descIdx + 3000).split(/You are viewing a lot in|Want to Sell on Goldin/)[0]!.trim() : null; const crumbs: string[] = []; $('[data-testid^="lot-breadcrumb-"]').each((_, el) => { const t = $(el).text().trim(); if (t) crumbs.push(t); }); const images = new Set(); $('img[src*="cloudfront.net/public/Lots/"]').each((_, el) => { const src = $(el).attr('src'); if (src) images.add(src); }); return { kind: 'item_page', url, title, breadcrumb: crumbs, totalPriceText: total, winningBidText: winning ? `$${winning[1]}` : null, endedText: ended ? ended[1]! : null, bidsText: bids ? bids[1]! : null, sold, description, images: [...images].slice(0, 6), lotNumber: lot ? lot[1]! : null }; } /** Parse Goldin's display date "Sat, 11/17/12, 10:16 PM EDT" → UTC Date. */ export function parseGoldinDisplayDate(s: string | null | undefined): Date | null { if (!s) return null; const m = s.match(/(\d{1,2})\/(\d{1,2})\/(\d{2,4}),\s*(\d{1,2}):(\d{2})\s*([AP]M)\s*([A-Z]{2,4})/); if (!m) return null; const month = Number(m[1]); const day = Number(m[2]); let year = Number(m[3]); if (year < 100) year += 2000; let hour = Number(m[4]) % 12; if (m[6] === 'PM') hour += 12; const minute = Number(m[5]); const tzOffsets: Record = { PDT: -7, PST: -8, EDT: -4, EST: -5, CDT: -5, CST: -6, MDT: -6, MST: -7, UTC: 0, GMT: 0 }; const off = tzOffsets[m[7]!] ?? -7; return new Date(Date.UTC(year, month - 1, day, hour - off, minute)); } export default function createConnector(meta: ConnectorMeta) { return new GoldinConnector(meta); } export class GoldinConnector extends BaseConnector { readonly version = '1.0.0'; readonly parserVersion = '1.0.0'; override readonly urlPatterns = [/^https?:\/\/(www\.)?goldin\.co\/item\/[^/?#]+/i]; protected override minIntervalMs = 2000; private readonly config = ConfigSchema.parse(this.meta.config ?? {}); private apiKey(): string { const key = process.env.SCRAPFLY_API_KEY; if (!key) throw new Error('goldin connector requires SCRAPFLY_API_KEY'); return key; } private async render(ctx: CrawlContext, url: string): Promise { await this.throttle(); const stats = (ctx.engineStats.scrapfly ??= { attempts: 0, success: 0, credits: 0, ms: 0 }); stats.attempts++; const res = await scrapflyRender(url, { apiKey: this.apiKey(), renderingWaitMs: this.config.renderingWaitMs, country: 'us' }); stats.credits += res.cost; stats.ms += res.durationMs; if (res.ok) stats.success++; else ctx.anomaly('blocked_request', `${url}: ${res.error ?? `status ${res.status}`}`); return res; } async *crawl(ctx: CrawlContext): AsyncIterable { const seeds = ctx.options.categories?.length ? this.config.seeds.filter((s) => ctx.options.categories!.includes(s.categorySlug)) : this.config.seeds; const cursor = { ...(ctx.options.cursor ?? {}) } as Record; const pageSize = ctx.options.mode === 'probe' ? 24 : this.config.pageSize; let yielded = 0; for (const seed of seeds) { if (ctx.signal?.aborted) return; const lastEnd = ctx.options.mode === 'incremental' ? cursor[seed.path]?.lastEnd : undefined; let newestSeen: string | undefined; const maxPages = ctx.options.mode === 'probe' ? 1 : this.config.maxPagesPerSeed; for (let page = 1; page <= maxPages; page++) { const url = soldPageUrl(seed.path, page, pageSize); const render = await this.render(ctx, url); if (!render.ok) break; const xhr = extractLotsXhr(render); if (!xhr) { ctx.anomaly('empty_page', `${url}: no lots_v2 response captured`); break; } const lots = xhr.lots.map((l) => GoldinLotSchema.safeParse(l)).filter((r) => r.success).map((r) => r.data); if (lots.length !== xhr.lots.length) ctx.anomaly('parse_failure', `${xhr.lots.length - lots.length} lots failed schema on ${url}`); if (lots.length === 0) break; const auctions = extractAuctionsXhr(render); const usedAuctions: SoldPagePayload['auctions'] = {}; for (const l of lots) if (l.auction_id && auctions[l.auction_id]) usedAuctions[l.auction_id] = auctions[l.auction_id]!; const payload: SoldPagePayload = { kind: 'sold_page', seed, page, pageUrl: url, request: xhr.request, total: xhr.total, lots, auctions: usedAuctions }; const realEnds = lots.map((l) => parseUtc(l.end_timestamp)).filter((d): d is Date => d !== null && d.getTime() <= Date.now() + 86_400_000); const newest = realEnds.reduce((a, d) => (!a || d > a ? d : a), null); const oldest = realEnds.reduce((a, d) => (!a || d < a ? d : a), null); if (newest && (!newestSeen || newest.toISOString() > newestSeen)) newestSeen = newest.toISOString(); yield { url, externalId: `${seed.path}#${page}`, kind: 'sale', engine: 'scrapfly', httpStatus: render.status, payload, fetchedAt: new Date() }; yielded += lots.length; if (this.reached(ctx, yielded)) return; if (lastEnd && oldest && oldest.toISOString() <= lastEnd) break; // caught up with previous run if (xhr.total !== null && page * pageSize >= xhr.total) break; } if (newestSeen) { cursor[seed.path] = { lastEnd: newestSeen }; await ctx.setCursor(cursor); } } } async lookup(url: string, ctx: CrawlContext): Promise { const render = await this.render(ctx, url); if (!render.ok || !render.html) return []; const payload = parseItemPage(url, render.html); return [{ url, externalId: url.split('/item/')[1]?.split(/[?#]/)[0] ?? null, kind: 'sale', engine: 'scrapfly', httpStatus: render.status, payload, fetchedAt: new Date() }]; } async normalize(raw: RawRecordLike): Promise { const p = raw.payload as { kind?: string }; if (p?.kind === 'item_page') return this.normalizeItem(raw, ItemPagePayloadSchema.parse(raw.payload)); const page = SoldPagePayloadSchema.parse(raw.payload); const out: NormalizedRecord[] = []; const now = Date.now() + 86_400_000; for (const lot of page.lots) { if (lot.status && lot.status !== 'Completed_Sold') continue; const saleDate = parseUtc(lot.end_timestamp); if (!saleDate || saleDate.getTime() > now || lot.current_price === null || lot.current_price === undefined || lot.current_price <= 0) continue; const bp = lot.buyer_premium ?? page.auctions[lot.auction_id ?? '']?.buyer_premium ?? null; const price = bp !== null ? Math.round(lot.current_price * (1 + bp / 100) * 100) / 100 : lot.current_price; const grade = parseGoldinGrade(lot.title); const categorySlug = classifyGoldinTitle(lot.title, page.seed); const card = parseCardAttributes(lot.title, categorySlug); const auction = lot.auction_id ? page.auctions[lot.auction_id] : undefined; const auctionType = (lot.auction_type ?? auction?.auction_type ?? '').toLowerCase(); const record = NormalizedSaleSchema.parse({ kind: 'sale', connectorId: this.meta.id, sourceId: this.meta.sourceId, sourceUrl: `${SITE}/item/${lot.meta_slug}`, externalId: lot.lot_id, rawTitle: lot.title, description: null, imageUrls: lot.primary_image_name ? [`${IMAGE_CDN}/${lot.lot_id}/${lot.primary_image_name}@1x`] : [], attributes: { categorySlug, name: card.name ?? lot.title, brand: card.brand, set: card.set, number: card.number, year: extractYear(lot.title), variant: card.variant, identifiers: { goldin_lot_id: lot.lot_id, goldin_slug: lot.meta_slug }, metadata: { auction_id: lot.auction_id ?? null, auction_title: auction?.title ?? null, auction_type: lot.auction_type ?? auction?.auction_type ?? null, hammer_price: lot.current_price, buyer_premium_pct: bp, lot_number: lot.lot_number ?? null, bids: lot.number_of_bids ?? null, goldin_seed: page.seed.path }, }, grade: { grader: grade.grader, grade: grade.grade, qualifier: grade.qualifier, certificationNumber: null }, condition: {}, observedAt: raw.fetchedAt, confidence: grade.grader ? 0.9 : 0.8, parserVersion: this.parserVersion, saleType: auctionType.includes('fixed') ? 'fixed_price' : 'auction', saleDate, price, currency: 'USD', buyerPremiumIncluded: bp !== null, quantity: 1, isBundle: isBundleTitle(lot.title), location: 'US', auctionHouse: 'Goldin', lotNumber: lot.lot_number !== null && lot.lot_number !== undefined ? String(lot.lot_number) : null, }); out.push(record); } return out; } private normalizeItem(raw: RawRecordLike, item: ItemPagePayload): NormalizedRecord[] { if (!item.sold) return []; const total = parsePrice(item.totalPriceText, 'USD'); const hammer = parsePrice(item.winningBidText, 'USD'); const saleDate = parseGoldinDisplayDate(item.endedText); const amount = total?.amount ?? hammer?.amount; if (!amount || !saleDate) return []; const grade = parseGoldinGrade(item.title); const cert = item.description?.match(/\b(?:PSA|BGS|CGC|SGC|Beckett)\b[^()]{0,40}\((\d{6,12})\)/i)?.[1] ?? item.description?.match(/\b(?:cert(?:ification)?(?:\s*(?:no|#|number))?)[:\s#]*(\d{6,12})/i)?.[1] ?? null; const seedGuess = { path: 'lookup', categorySlug: item.breadcrumb.map((b) => b.toLowerCase()).includes('pokemon') ? 'pokemon' : 'sports_memorabilia' }; const categorySlug = classifyGoldinTitle(item.title, seedGuess, item.breadcrumb); const card = parseCardAttributes(item.title, categorySlug); return [ NormalizedSaleSchema.parse({ kind: 'sale', connectorId: this.meta.id, sourceId: this.meta.sourceId, sourceUrl: item.url, externalId: raw.externalId ?? item.url, rawTitle: item.title, description: item.description, imageUrls: item.images, attributes: { categorySlug, name: card.name ?? item.title, brand: card.brand, set: card.set, number: card.number, year: extractYear(item.title), variant: card.variant, identifiers: { goldin_slug: raw.externalId ?? '' }, metadata: { hammer_price: hammer?.amount ?? null, breadcrumb: item.breadcrumb, lot_number: item.lotNumber } }, grade: { grader: grade.grader, grade: grade.grade, qualifier: grade.qualifier, certificationNumber: cert }, condition: {}, observedAt: raw.fetchedAt, confidence: 0.85, parserVersion: this.parserVersion, saleType: 'auction', saleDate, price: amount, currency: 'USD', buyerPremiumIncluded: total ? true : null, quantity: 1, isBundle: isBundleTitle(item.title), location: 'US', auctionHouse: 'Goldin', lotNumber: item.lotNumber, }), ]; } }