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%
7.5 KB · 176 lines typescript
Raw Blame History
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