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);