TypeScript 61.9%
HTML 37.2%
SQL 0.7%
1/**2 * Parser for the Certified Collectibles Group cert-verification pages (CGC Comics, CGC Cards, NGC, PMG):3 * all four sites share one AngularJS template — a `.results-pane` holding `<dl><dt>label</dt><dd>value</dd></dl>`4 * pairs, `.certlookup-stats-item-value` population lines ("In 9.0: 2", "In Higher Grades: 0") and a5 * `.certlookup-images` gallery. Not-found pages render the lookup form with "This item cannot be found".6 */7import { html as H } from '@rareindex/connectors';8import { clean, toInt } from './text.js';9import type { PageStatus } from './cert-base.js';1011export interface CcgPage {12 /** label → value (labels normalised: trailing colon removed, whitespace collapsed) */13 fields: Record<string, string>;14 /** `<br>`-separated multi-line values, kept as arrays */15 lines: Record<string, string[]>;16 stats: string[];17 populationUrl: string | null;18 /** population at the cert's grade ("In 9.0: 2" → 2); null when absent or "Coming Soon!" */19 atGrade: number | null;20 higher: number | null;21 /** grade key as printed in the stats line ("9.0", "MS 61", "67EPQ") */22 popGradeKey: string | null;23 images: string[];24}2526/** Trim a CCG document to its results pane (fixtures store this). */27export function ccgTrim(doc: string): string {28 const $ = H.load(doc);29 $('script, style, noscript, iframe, svg').remove();30 const pane = $('.results-pane').first();31 if (pane.length) return $.html(pane);32 const form = $('form[name="form"], .certlookup-form').first().closest('div');33 const err = $('.error, .error-message').first();34 return `<div class="results-pane g2-trimmed">${form.length ? $.html(form) : ''}${err.length ? $.html(err) : ''}</div>`;35}3637export function ccgClassify(doc: string): PageStatus {38 const $ = H.load(doc);39 const hasCert = $('dl dt')40 .toArray()41 .some((dt) => /cert/i.test($(dt).text()));42 if (hasCert && $('dl dd').length) return 'found';43 const text = clean($('body').text() || $.root().text());44 if (/cannot be found|could not be found|not be found|no results/i.test(text)) return 'not_found';45 // Rendered lookup form without results (e.g. NGC/PMG URL missing the grade) is a definitive "nothing here".46 if ($('form[name="form"], .certlookup-form, input[name="CertNum"], .certlookup-search-box').length) return 'not_found';47 return 'unknown';48}4950export function ccgParse(snapshot: string): CcgPage {51 const $ = H.load(snapshot);52 const fields: Record<string, string> = {};53 const lines: Record<string, string[]> = {};54 $('dl').each((_, dl) => {55 const dt = clean($(dl).find('dt').first().text()).replace(/\s*:$/, '');56 const dd = $(dl).find('dd').first();57 if (!dt || !dd.length) return;58 const html = dd.html() ?? '';59 const parts = html60 .split(/<br\s*\/?>/i)61 .map((h) => clean(H.load(`<div>${h}</div>`)('div').text()))62 .filter(Boolean);63 lines[dt] = parts;64 fields[dt] = parts.join(' | ');65 });66 const stats = $('.certlookup-stats-item-value')67 .toArray()68 .map((e) => clean($(e).text()))69 .filter(Boolean);70 let atGrade: number | null = null;71 let higher: number | null = null;72 let popGradeKey: string | null = null;73 for (const s of stats) {74 const h = s.match(/^In Higher Grades?:\s*(.+)$/i);75 if (h) {76 higher = toInt(h[1]);77 continue;78 }79 const g = s.match(/^In\s+(.+?)(?:\s+Grade)?:\s*(.+)$/i);80 if (g) {81 popGradeKey = clean(g[1]);82 atGrade = toInt(g[2]);83 }84 }85 const populationUrl = $('.certlookup-stats a[href*="population-report"]').first().attr('href') ?? null;86 const images = [87 ...new Set(88 $('.certlookup-images a[href]')89 .toArray()90 .map((a) => $(a).attr('href') ?? '')91 .filter((u) => /^https?:\/\//.test(u)),92 ),93 ];94 if (!images.length) {95 for (const img of $('.certlookup-images img').toArray()) {96 const src = $(img).attr('src');97 if (src && /^https?:\/\//.test(src)) images.push(src);98 }99 }100 return { fields, lines, stats, populationUrl, atGrade, higher, popGradeKey, images };101}102103/** First present value among several label spellings. */104export function pick(fields: Record<string, string>, ...labels: string[]): string | null {105 for (const l of labels) {106 const hit = Object.keys(fields).find((k) => k.toLowerCase() === l.toLowerCase());107 if (hit && fields[hit]) return fields[hit]!;108 }109 return null;110}111