import { html as H, type ConnectorMeta } from '@rareindex/connectors'; import { CertLookupConnector, type PageStatus, type ParsedCert } from '../_g2-grading-lib/cert-base.js'; import { clean, money, parseCardGrade, psaCategorySlug, titleCase, toInt, yearOf } from '../_g2-grading-lib/text.js'; /** * PSA certificate verification — https://www.psacard.com/cert/ (public; no login). * The page is a Next.js app-router page behind a Cloudflare managed challenge: plain HTTP gets a * 403 "Just a moment…" page, Firecrawl (and Scrapfly as fallback) render the same public page a * visitor sees. PSA APR and the population report proper require a Collectors login and are never * fetched; the per-grade "PSA Population / Pop Higher" figures shown on the cert page itself are used. */ const PARSER_VERSION = '1.0.0'; const PATTERNS = [/^https?:\/\/(?:www\.)?psacard\.com\/(?:[a-z]{2}-[a-z]{2}\/)?cert\/(\d{5,12})(?:\/[a-z]+)?\/?(?:[?#].*)?$/i]; /** Canonical public verify URL (matches data/taxonomy/graders.json verifyUrl). */ export function certUrl(cert: string): string { return `https://www.psacard.com/cert/${clean(cert).replace(/\D/g, '')}`; } export function certFromUrl(url: string): string | null { for (const re of PATTERNS) { const m = url.match(re); if (m) return m[1]!; } return null; } export interface PsaPage { title: string; fields: Record; population: number | null; popHigher: number | null; populationUrl: string | null; estimate: number | null; estimateText: string | null; specId: string | null; images: string[]; } /** Pure parser over the trimmed `
` fragment. Exported for tests. */ export function parsePsaPage(snapshot: string): PsaPage | null { const $ = H.load(snapshot); const title = clean($('h1').first().text()); const fields: Record = {}; $('dl dt').each((_, dt) => { const label = clean($(dt).text()); const dd = $(dt).next('dd'); if (label && dd.length) fields[label] = clean(dd.text()); }); if (!fields['Cert Number'] && !title) return null; const stat = (label: string): { text: string; href: string | null } | null => { const p = $('p') .toArray() .find((e) => clean($(e).text()).toLowerCase() === label.toLowerCase()); if (!p) return null; const next = $(p).next(); return { text: clean(next.text()), href: next.attr('href') ?? null }; }; const pop = stat('PSA Population'); const higher = stat('PSA Pop Higher'); const est = stat('PSA Estimate'); const specHref = $('a[href*="/spec/psa/"]').first().attr('href') ?? null; const specId = specHref?.match(/\/spec\/psa\/(\d+)/)?.[1] ?? null; const images = [...new Set($('img[alt^="Cert image"]').toArray().map((e) => $(e).attr('src') ?? '').filter((u) => /^https?:\/\//.test(u)))]; return { title, fields, population: toInt(pop?.text), popHigher: toInt(higher?.text), populationUrl: pop?.href ?? null, estimate: money(est?.text), estimateText: est?.text ?? null, specId, images }; } export class PsaCertConnector extends CertLookupConnector { readonly version = '1.0.0'; readonly parserVersion = PARSER_VERSION; readonly grader = 'psa'; readonly idKey = 'psa_cert'; readonly format = 'html' as const; override readonly urlPatterns = PATTERNS; protected override waitForMs = 4000; certUrl(cert: string): string { return certUrl(cert); } certFromUrl(url: string): string | null { return certFromUrl(url); } classify(doc: string): PageStatus { const $ = H.load(doc); const hasCert = $('dl dt') .toArray() .some((dt) => /^cert number$/i.test(clean($(dt).text()))); if (hasCert && $('h1').length) return 'found'; const text = clean($('main').text() || $('body').text()); if (/(could not|couldn.t|cannot|unable to) (find|locate)|not (a )?valid|no (results|record)|does not exist|not found|invalid cert|private set registry/i.test(text)) return 'not_found'; return 'unknown'; } trim(doc: string): string { const $ = H.load(doc); $('script, style, noscript, iframe, svg, table, [role="dialog"], header, footer, nav').remove(); const main = $('main').first(); return main.length ? $.html(main) : $.html($('body')); } parse(snapshot: string, info: { cert: string; url: string }): ParsedCert | null { const page = parsePsaPage(snapshot); if (!page) return null; const f = page.fields; const cert = f['Cert Number'] ?? info.cert; const g = parseCardGrade(f['Item Grade']); const brand = f['Brand/Title'] ?? null; const subject = f['Subject'] ?? null; const cat = psaCategorySlug(f['Category'], brand, subject); const text = `${brand ?? ''} ${subject ?? ''} ${page.title}`; const isPokemon = cat.slug === 'pokemon'; const identifiers: Record = {}; if (page.specId) identifiers.psa_spec_id = page.specId; const metadata: Record = { psa_category: f['Category'] ?? null, label_type: f['Label Type'] ?? null, reverse_cert_barcode: f['Reverse Cert/Barcode'] ?? null, psa_estimate_usd: page.estimate, psa_estimate_text: page.estimateText, population_url: page.populationUrl, fields: f, }; return { title: page.title || [f['Year'], brand, subject].filter(Boolean).join(' '), attributes: { categorySlug: cat.slug, franchise: isPokemon ? 'Pokémon' : null, brand: isPokemon ? 'The Pokémon Company' : brand ? titleCase(brand) : null, set: brand ? titleCase(brand) : null, name: subject ? titleCase(subject) : page.title, number: f['Card Number'] ?? null, year: yearOf(f['Year']), variant: f['Variety/Pedigree'] ?? f['Variety'] ?? null, language: /japanese/i.test(text) ? 'Japanese' : isPokemon ? 'English' : null, identifiers, metadata, }, grade: g.grade, qualifier: g.qualifier, gradeLabel: g.label || null, images: page.images, population: page.population !== null ? { gradeKey: g.grade ?? g.label, atGrade: page.population, higher: page.popHigher, url: page.populationUrl } : null, confidence: cat.confident ? 0.92 : 0.8, }; } } export default (meta: ConnectorMeta) => new PsaCertConnector(meta);