SPB Git forge

spb/rareindex

Public
54commits 1branches 0releases
7.1 MBsize
maindefault branch
10 days agolast push
TypeScript 61.9% HTML 37.2% SQL 0.7%
6.2 KB · 150 lines typescript
Raw Blame History
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