TypeScript 61.9%
HTML 37.2%
SQL 0.7%
1import { html as H, type ConnectorMeta } from '@rareindex/connectors';2import { CertLookupConnector, type PageStatus, type ParsedCert } from '../_g2-grading-lib/cert-base.js';3import { clean, money, parseCardGrade, psaCategorySlug, titleCase, toInt, yearOf } from '../_g2-grading-lib/text.js';45/**6 * PSA certificate verification — https://www.psacard.com/cert/<cert> (public; no login).7 * The page is a Next.js app-router page behind a Cloudflare managed challenge: plain HTTP gets a8 * 403 "Just a moment…" page, Firecrawl (and Scrapfly as fallback) render the same public page a9 * visitor sees. PSA APR and the population report proper require a Collectors login and are never10 * fetched; the per-grade "PSA Population / Pop Higher" figures shown on the cert page itself are used.11 */12const PARSER_VERSION = '1.0.0';13const PATTERNS = [/^https?:\/\/(?:www\.)?psacard\.com\/(?:[a-z]{2}-[a-z]{2}\/)?cert\/(\d{5,12})(?:\/[a-z]+)?\/?(?:[?#].*)?$/i];1415/** Canonical public verify URL (matches data/taxonomy/graders.json verifyUrl). */16export function certUrl(cert: string): string {17 return `https://www.psacard.com/cert/${clean(cert).replace(/\D/g, '')}`;18}1920export function certFromUrl(url: string): string | null {21 for (const re of PATTERNS) {22 const m = url.match(re);23 if (m) return m[1]!;24 }25 return null;26}2728export interface PsaPage {29 title: string;30 fields: Record<string, string>;31 population: number | null;32 popHigher: number | null;33 populationUrl: string | null;34 estimate: number | null;35 estimateText: string | null;36 specId: string | null;37 images: string[];38}3940/** Pure parser over the trimmed `<main>` fragment. Exported for tests. */41export function parsePsaPage(snapshot: string): PsaPage | null {42 const $ = H.load(snapshot);43 const title = clean($('h1').first().text());44 const fields: Record<string, string> = {};45 $('dl dt').each((_, dt) => {46 const label = clean($(dt).text());47 const dd = $(dt).next('dd');48 if (label && dd.length) fields[label] = clean(dd.text());49 });50 if (!fields['Cert Number'] && !title) return null;51 const stat = (label: string): { text: string; href: string | null } | null => {52 const p = $('p')53 .toArray()54 .find((e) => clean($(e).text()).toLowerCase() === label.toLowerCase());55 if (!p) return null;56 const next = $(p).next();57 return { text: clean(next.text()), href: next.attr('href') ?? null };58 };59 const pop = stat('PSA Population');60 const higher = stat('PSA Pop Higher');61 const est = stat('PSA Estimate');62 const specHref = $('a[href*="/spec/psa/"]').first().attr('href') ?? null;63 const specId = specHref?.match(/\/spec\/psa\/(\d+)/)?.[1] ?? null;64 const images = [...new Set($('img[alt^="Cert image"]').toArray().map((e) => $(e).attr('src') ?? '').filter((u) => /^https?:\/\//.test(u)))];65 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 };66}6768export class PsaCertConnector extends CertLookupConnector {69 readonly version = '1.0.0';70 readonly parserVersion = PARSER_VERSION;71 readonly grader = 'psa';72 readonly idKey = 'psa_cert';73 readonly format = 'html' as const;74 override readonly urlPatterns = PATTERNS;75 protected override waitForMs = 4000;7677 certUrl(cert: string): string {78 return certUrl(cert);79 }80 certFromUrl(url: string): string | null {81 return certFromUrl(url);82 }8384 classify(doc: string): PageStatus {85 const $ = H.load(doc);86 const hasCert = $('dl dt')87 .toArray()88 .some((dt) => /^cert number$/i.test(clean($(dt).text())));89 if (hasCert && $('h1').length) return 'found';90 const text = clean($('main').text() || $('body').text());91 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';92 return 'unknown';93 }9495 trim(doc: string): string {96 const $ = H.load(doc);97 $('script, style, noscript, iframe, svg, table, [role="dialog"], header, footer, nav').remove();98 const main = $('main').first();99 return main.length ? $.html(main) : $.html($('body'));100 }101102 parse(snapshot: string, info: { cert: string; url: string }): ParsedCert | null {103 const page = parsePsaPage(snapshot);104 if (!page) return null;105 const f = page.fields;106 const cert = f['Cert Number'] ?? info.cert;107 const g = parseCardGrade(f['Item Grade']);108 const brand = f['Brand/Title'] ?? null;109 const subject = f['Subject'] ?? null;110 const cat = psaCategorySlug(f['Category'], brand, subject);111 const text = `${brand ?? ''} ${subject ?? ''} ${page.title}`;112 const isPokemon = cat.slug === 'pokemon';113 const identifiers: Record<string, string> = {};114 if (page.specId) identifiers.psa_spec_id = page.specId;115 const metadata: Record<string, unknown> = {116 psa_category: f['Category'] ?? null,117 label_type: f['Label Type'] ?? null,118 reverse_cert_barcode: f['Reverse Cert/Barcode'] ?? null,119 psa_estimate_usd: page.estimate,120 psa_estimate_text: page.estimateText,121 population_url: page.populationUrl,122 fields: f,123 };124 return {125 title: page.title || [f['Year'], brand, subject].filter(Boolean).join(' '),126 attributes: {127 categorySlug: cat.slug,128 franchise: isPokemon ? 'Pokémon' : null,129 brand: isPokemon ? 'The Pokémon Company' : brand ? titleCase(brand) : null,130 set: brand ? titleCase(brand) : null,131 name: subject ? titleCase(subject) : page.title,132 number: f['Card Number'] ?? null,133 year: yearOf(f['Year']),134 variant: f['Variety/Pedigree'] ?? f['Variety'] ?? null,135 language: /japanese/i.test(text) ? 'Japanese' : isPokemon ? 'English' : null,136 identifiers,137 metadata,138 },139 grade: g.grade,140 qualifier: g.qualifier,141 gradeLabel: g.label || null,142 images: page.images,143 population: page.population !== null ? { gradeKey: g.grade ?? g.label, atGrade: page.population, higher: page.popHigher, url: page.populationUrl } : null,144 confidence: cat.confident ? 0.92 : 0.8,145 };146 }147}148149export default (meta: ConnectorMeta) => new PsaCertConnector(meta);150