TypeScript 61.9%
HTML 37.2%
SQL 0.7%
1import type { ConnectorMeta } from '@rareindex/connectors';2import { CertLookupConnector, type PageStatus, type ParsedCert } from '../_g2-grading-lib/cert-base.js';3import { cardCategoryFromText, clean, parseDate, titleCase, toInt } from '../_g2-grading-lib/text.js';45/**6 * TAG Grading cert lookup — https://my.taggrading.com/card/<cert> (DIG report; public, robots.txt7 * explicitly allows /card/, ~3M card URLs listed in the public dig*.xml sitemaps). Client-rendered React8 * app → Firecrawl markdown (the DOM uses hashed MUI class names, so the markdown line sequence is the9 * stable surface). Complements connectors/firecrawl/tag-pop (set-level population tables).10 */11const PARSER_VERSION = '1.0.0';12const PATTERNS = [/^https?:\/\/my\.taggrading\.com\/card\/([A-Z]\d{7})\/?(?:[?#].*)?$/i, /^https?:\/\/tagd\.co\/([A-Z]\d{7})\/?(?:[?#].*)?$/i];1314export function certUrl(cert: string): string {15 return `https://my.taggrading.com/card/${clean(cert).toUpperCase()}`;16}1718export function certFromUrl(url: string): string | null {19 for (const re of PATTERNS) {20 const m = url.match(re);21 if (m) return m[1]!.toUpperCase();22 }23 return null;24}2526export interface TagPage {27 cert: string | null;28 name: string;29 setLine: string;30 extraLines: string[];31 tagScore: number | null;32 grade: string | null;33 gradeLabel: string | null;34 asOf: Date | null;35 atGrade: number | null;36 totalGraded: number | null;37 gradedDate: Date | null;38 rankByGrade: string | null;39 rankOverall: string | null;40 images: string[];41}4243/** Pure parser over the trimmed markdown. Exported for tests. */44export function parseTagMarkdown(md: string): TagPage | null {45 const rawLines = md.split('\n').map((l) => clean(l)).filter(Boolean);46 const lines = rawLines.filter((l) => !/^!\[/.test(l));47 const i = lines.findIndex((l) => /^TAG Score$/i.test(l));48 if (i < 1) return null;49 const tagScore = toInt(lines[i - 1]);50 // Header block = the lines between the nav ("Submit") and the score.51 let start = i - 1;52 while (start > 0 && !/^(Submit|HomeAbout|Home)/i.test(lines[start - 1]!) && i - start < 6) start--;53 const header = lines.slice(start, i - 1);54 const name = header[0] ?? '';55 const setLine = header[1] ?? '';56 const extraLines = header.slice(2);57 if (!name) return null;58 const gradeTok = lines[i + 1] ?? '';59 const grade = /^(10|[1-9](?:\.5)?)$/.test(gradeTok) ? gradeTok : /^auth/i.test(gradeTok) ? 'authentic' : null;60 const gradeLabel = grade ? (lines[i + 2] ?? null) : gradeTok || null;61 const cert = md.match(/cert\s*#\s*([A-Z]\d{7})/i)?.[1]?.toUpperCase() ?? null;62 const p = lines.findIndex((l) => /^population$/i.test(l));63 let asOf: Date | null = null;64 let atGrade: number | null = null;65 let totalGraded: number | null = null;66 if (p >= 0) {67 asOf = parseDate((lines[p + 1] ?? '').replace(/^AS OF\s*/i, ''));68 for (let k = p + 1; k < Math.min(lines.length, p + 8); k++) {69 const l = lines[k]!;70 if (/^total graded$/i.test(l)) totalGraded = toInt(lines[k - 1]);71 else if (/ graded$/i.test(l) && !/^total/i.test(l)) atGrade = toInt(lines[k - 1]);72 }73 }74 const r = lines.findIndex((l) => /^card rank$/i.test(l));75 let rankByGrade: string | null = null;76 let rankOverall: string | null = null;77 if (r >= 0) {78 for (let k = r + 1; k < Math.min(lines.length, r + 8); k++) {79 const l = lines[k]!;80 if (/^highest .* \(T\)$|^highest [A-Z ]+$/i.test(l) && !/overall/i.test(l)) rankByGrade = lines[k - 1] ?? null;81 if (/^highest overall/i.test(l)) rankOverall = lines[k - 1] ?? null;82 }83 }84 const gradedDate = parseDate(md.match(/graded\s+(\d{1,2}\/\d{1,2}\/\d{4})/i)?.[1]);85 const images = [...new Set([...md.matchAll(/!\[[^\]]*\]\((https?:\/\/[^)\s]+_(?:FRONT|BACK)_MAIN\.jpg)\)/gi)].map((m) => m[1]!))];86 return { cert, name, setLine, extraLines, tagScore, grade, gradeLabel, asOf, atGrade, totalGraded, gradedDate, rankByGrade, rankOverall, images };87}8889/** "2021 BOWMAN CHROME #BCP-238" → year 2021, set "Bowman Chrome", number "BCP-238". */90export function parseSetLine(line: string): { year: number | null; set: string | null; number: string | null } {91 const t = clean(line);92 const year = t.match(/^(1[89]\d{2}|20\d{2})(?:-\d{2})?\b/)?.[1];93 const number = t.match(/#\s*([A-Za-z0-9/.-]+)\s*$/)?.[1] ?? null;94 let set = t.replace(/#\s*[A-Za-z0-9/.-]+\s*$/, '').trim();95 if (year) set = set.replace(/^(1[89]\d{2}|20\d{2})(?:-\d{2})?\s*/, '').trim();96 return { year: year ? Number(year) : null, set: set ? titleCase(set) : null, number };97}9899export class TagCertConnector extends CertLookupConnector {100 readonly version = '1.0.0';101 readonly parserVersion = PARSER_VERSION;102 readonly grader = 'tag';103 readonly idKey = 'tag_cert';104 readonly format = 'markdown' as const;105 override readonly urlPatterns = PATTERNS;106 protected override waitForMs = 8000;107108 certUrl(cert: string): string {109 return certUrl(cert);110 }111 certFromUrl(url: string): string | null {112 return certFromUrl(url);113 }114115 classify(doc: string): PageStatus {116 if (/cert\s*#\s*[A-Z]\d{7}/i.test(doc) && /TAG Score/i.test(doc)) return 'found';117 if (/not found|no card|does not exist|invalid cert|could not be found|unable to find|no results|something went wrong/i.test(doc)) return 'not_found';118 // The portal shell rendered without any card content = TAG has nothing for this cert.119 if (/Pop Report/i.test(doc) && !/TAG Score/i.test(doc) && !/Surface Defect/i.test(doc)) return 'not_found';120 return 'unknown';121 }122123 trim(doc: string): string {124 const lines = doc.split('\n');125 const end = lines.findIndex((l) => /TAG grading summary|Surface details|Corner Details/i.test(l));126 const cut = end > 0 ? lines.slice(0, end) : lines;127 return cut128 .map((l) => l.replace(/ /g, ' ').trimEnd())129 .filter((l, idx, arr) => l.trim() || (idx > 0 && arr[idx - 1]!.trim()))130 .join('\n')131 .slice(0, 12_000);132 }133134 parse(snapshot: string, info: { cert: string; url: string }): ParsedCert | null {135 const page = parseTagMarkdown(snapshot);136 if (!page) return null;137 const cert = page.cert ?? info.cert;138 const s = parseSetLine(page.setLine);139 const variant = page.extraLines.filter((l) => !/^\d+$/.test(l)).join(' ') || null;140 const text = `${page.setLine} ${variant ?? ''} ${page.name}`;141 const cat = cardCategoryFromText(text);142 const isPokemon = cat.slug === 'pokemon';143 return {144 title: `${page.setLine} ${page.name}${variant ? ` ${variant}` : ''} TAG ${page.grade ?? ''} ${page.gradeLabel ?? ''}`.replace(/\s+/g, ' ').trim(),145 attributes: {146 categorySlug: cat.slug,147 franchise: isPokemon ? 'Pokémon' : null,148 brand: isPokemon ? 'The Pokémon Company' : null,149 set: s.set,150 name: titleCase(page.name),151 number: s.number,152 year: s.year,153 variant,154 language: /japanese/i.test(text) ? 'Japanese' : null,155 identifiers: {},156 metadata: {157 tag_cert: cert,158 tag_score: page.tagScore,159 graded_date: page.gradedDate?.toISOString().slice(0, 10) ?? null,160 rank_by_grade: page.rankByGrade,161 rank_overall: page.rankOverall,162 population_as_of: page.asOf?.toISOString().slice(0, 10) ?? null,163 },164 },165 grade: page.grade,166 qualifier: null,167 gradeLabel: page.gradeLabel,168 images: page.images,169 population: page.atGrade !== null ? { gradeKey: page.grade ?? page.gradeLabel ?? 'unknown', atGrade: page.atGrade, higher: null, total: page.totalGraded, url: info.url, asOf: page.asOf } : null,170 confidence: cat.confident ? 0.9 : 0.75,171 };172 }173}174175export default (meta: ConnectorMeta) => new TagCertConnector(meta);176