import { z } from 'zod'; import { BaseConnector, html as H, type ConnectorMeta, type CrawlContext, type RawRecordInput, type RawRecordLike } from '@rareindex/connectors'; import { AssetAttributesSchema, NormalizedCatalogItemSchema, NormalizedPriceObservationSchema, type NormalizedRecord } from '@rareindex/shared'; /** * PCGS Price Guide — US coin series pages: one row per coin (PCGS #, description, designation) * with guide values per grade column. Raw payload = parsed table; normalise → catalog item + * one guide_value observation per (grade, plus-grade). */ const BASE = 'https://www.pcgs.com'; const PARSER_VERSION = '1.0.0'; export const RowSchema = z.object({ pcgsNumber: z.string(), description: z.string(), designation: z.string().nullable(), /** grade column label (e.g. "65") → { value, plus } */ prices: z.record(z.string(), z.object({ value: z.number().nullable(), plus: z.number().nullable() })), }); export const PagePayloadSchema = z.object({ kind: z.literal('price_guide_page'), url: z.string(), seriesName: z.string(), seriesSlug: z.string(), seriesId: z.string(), lastUpdate: z.string().nullable(), grades: z.array(z.string()), rows: z.array(RowSchema), }); export type PagePayload = z.infer; function money(s: string | null): number | null { if (!s) return null; const n = Number(s.replace(/[^\d.]/g, '')); return Number.isFinite(n) && n > 0 ? n : null; } export function parsePriceGuidePage(htmlText: string, url: string): PagePayload | null { const $ = H.load(htmlText); const h1 = H.text($('h1').first()) ?? ''; const seriesName = h1.replace(/\s*Price Guide$/i, '').trim(); const m = url.match(/\/prices\/detail\/([^/]+)\/(\d+)/); if (!m || !seriesName) return null; const lastUpdate = $('main').text().match(/Last Update:\s*([0-9]{2}-[0-9]{2}\s+[0-9:]+\s*[AP]M\s*[A-Z]{3})/)?.[1] ?? null; // header: find the row containing "PCGS #" and numeric grade cells let grades: string[] = []; let table: ReturnType | null = null; $('table').each((_, t) => { const txt = $(t).text(); if (/PCGS\s*#/.test(txt) && /Desig/.test(txt)) { table = $(t); return false; } return undefined; }); if (!table) return null; const tbl = table as ReturnType; tbl.find('tr').each((_, tr) => { const cells = $(tr).find('th,td').map((__, c) => H.text($(c)) ?? '').get(); if (cells.some((c) => /PCGS\s*#/.test(c))) { grades = cells.filter((c) => /^\d{1,2}$/.test(c)); return false; } return undefined; }); if (!grades.length) return null; const rows: z.infer[] = []; tbl.find('tr').each((_, tr) => { const $tr = $(tr); const numLink = $tr.find('a[href*="/coinfacts/coin/detail/"]').first(); const pcgsNumber = (H.text(numLink) ?? '').trim(); if (!/^\d+$/.test(pcgsNumber)) return; const tds = $tr.children('td'); const descCell = tds.eq(1).clone(); descCell.find('.hidden-print, a[data-func]').remove(); const description = H.text(descCell) ?? ''; const desigCell = tds.eq(2).clone(); desigCell.find('br').replaceWith(' '); const designation = (H.text(desigCell) ?? '').replace(/\s*\+\s*$/, '').trim() || null; const prices: Record = {}; const priceCells = tds.slice(3); grades.forEach((g, i) => { const cell = priceCells.eq(i); if (!cell.length) return; const anchors = cell.find('a'); const value = money(H.text(anchors.eq(0))); const plus = anchors.length > 1 ? money(H.text(anchors.eq(1))) : null; if (value !== null || plus !== null) prices[g] = { value, plus }; }); if (description && Object.keys(prices).length) rows.push({ pcgsNumber, description, designation, prices }); }); return { kind: 'price_guide_page', url, seriesName, seriesSlug: m[1]!, seriesId: m[2]!, lastUpdate, grades, rows }; } /** "09-06 10:59 PM EST" + fetch date → Date (year inferred from the fetch date, never after it). */ export function lastUpdateDate(stamp: string | null, fetchedAt: Date): Date { const m = stamp?.match(/^(\d{2})-(\d{2})/); if (!m) return fetchedAt; let year = fetchedAt.getUTCFullYear(); let d = new Date(Date.UTC(year, Number(m[1]) - 1, Number(m[2]))); if (d.getTime() > fetchedAt.getTime() + 86_400_000) d = new Date(Date.UTC(--year, Number(m[1]) - 1, Number(m[2]))); return d; } /** "1878 8TF" → { year: 1878, mintMark: null, variety: '8TF' }; "1893-S" → { year: 1893, mintMark: 'S' } */ export function parseDescription(desc: string): { year: number | null; mintMark: string | null; variety: string | null } { const m = desc.match(/^(\d{4})(?:-([A-Z]{1,2}))?\s*(.*)$/); if (!m) return { year: null, mintMark: null, variety: desc || null }; return { year: Number(m[1]), mintMark: m[2] ?? null, variety: m[3]?.trim() || null }; } export class PcgsPriceGuideConnector extends BaseConnector { readonly version = '1.0.0'; readonly parserVersion = PARSER_VERSION; protected override minIntervalMs = 2000; async *crawl(ctx: CrawlContext): AsyncIterable { const seeds = (ctx.options.seeds?.length ? ctx.options.seeds : (this.meta.config.seeds as string[] | undefined)) ?? []; const desigs = (this.meta.config.designations as string[] | undefined) ?? ['ms']; let count = 0; for (const seed of seeds) { for (const d of desigs) { if (ctx.signal?.aborted || this.reached(ctx, count)) return; const url = `${BASE}/prices/detail/${seed}/most-active/${d}`; if (ctx.options.mode !== 'backfill' && !(await ctx.shouldFetch(url))) continue; await this.throttle(); const res = await ctx.fetch(url, { engines: ['firecrawl', 'scrapfly'], expect: ['title', 'price', 'identifiers'], parse: (r) => { const p = r.html ? parsePriceGuidePage(r.html, url) : null; const row = p?.rows[0]; return p && row ? { title: p.seriesName, price: Object.values(row.prices)[0]?.value ?? null, identifiers: { pcgs: row.pcgsNumber } } : null; }, }); const payload = res.success && res.html ? parsePriceGuidePage(res.html, url) : null; if (!payload || payload.rows.length === 0) { ctx.anomaly(payload ? 'empty_page' : 'page_parse_failed', `${url}: ${res.error ?? res.httpStatus}`); continue; } count++; yield { url, externalId: `series:${payload.seriesId}:${d}`, kind: 'price_observation', engine: res.engine, httpStatus: res.httpStatus, payload, fetchedAt: res.fetchedAt }; } } } async normalize(raw: RawRecordLike): Promise { const p = PagePayloadSchema.parse(raw.payload); const obsDate = lastUpdateDate(p.lastUpdate, raw.fetchedAt); const out: NormalizedRecord[] = []; for (const row of p.rows) { const { year, mintMark, variety } = parseDescription(row.description); const desig = row.designation?.split(/\s+/)[0]?.toUpperCase() ?? null; // MS | PR | SP const name = `${row.description} ${p.seriesName}`.trim(); const rawTitle = `${row.description} ${p.seriesName}${desig ? ` (${desig})` : ''} · PCGS #${row.pcgsNumber}`; const attributes = AssetAttributesSchema.parse({ categorySlug: 'coins', brand: 'United States Mint', series: p.seriesName, set: p.seriesName, name, number: row.pcgsNumber, year, variant: [mintMark ? `${mintMark} mint` : null, variety, desig && desig !== 'MS' ? desig : null].filter(Boolean).join(' · ') || null, country: 'US', identifiers: { pcgs_number: row.pcgsNumber }, metadata: { mint_mark: mintMark, variety, designation: desig, pcgs_series_id: p.seriesId, pcgs_series_slug: p.seriesSlug }, }); const base = { connectorId: this.meta.id, sourceId: this.meta.sourceId, sourceUrl: `${BASE}/coinfacts/coin/detail/${row.pcgsNumber}`, rawTitle, imageUrls: [], attributes, observedAt: raw.fetchedAt, parserVersion: PARSER_VERSION }; out.push(NormalizedCatalogItemSchema.parse({ kind: 'catalog_item', ...base, externalId: `coin:${row.pcgsNumber}`, confidence: 0.95 })); for (const [g, pr] of Object.entries(row.prices)) { for (const [suffix, value] of [['', pr.value], ['+', pr.plus]] as const) { if (!value) continue; const grade = `${desig ?? 'MS'}${g}${suffix}`; out.push( NormalizedPriceObservationSchema.parse({ kind: 'price_observation', ...base, externalId: `coin:${row.pcgsNumber}:${grade}`, confidence: 0.85, grade: { grader: 'pcgs', grade, qualifier: null, certificationNumber: null }, priceKind: 'guide_value', price: value, currency: 'USD', observationDate: obsDate, sampleSize: null, }), ); } } } return out; } } export default function createConnector(meta: ConnectorMeta) { return new PcgsPriceGuideConnector(meta); }