import { z } from 'zod'; import { BaseConnector, type ConnectorMeta, type CrawlContext, type RawRecordInput, type RawRecordLike } from '@rareindex/connectors'; import { NormalizedPopulationReportSchema, type NormalizedRecord } from '@rareindex/shared'; import { attrs } from '../../api/_lib/shared.js'; import { BOT_HEADERS, cardCategorySlug, dayOf, markdownTables, mdLink, mdText, parseSitemap, toInt } from '../../api/_lib/wave4.js'; /** TAG Grading population report — set pages rendered by Firecrawl, parsed from the markdown table. */ const PARSER_VERSION = '1.0.0'; const SITE = 'https://my.taggrading.com'; const RowSchema = z.object({ number: z.string().nullable(), name: z.string(), variation: z.string().nullable(), url: z.string().nullable(), counts: z.record(z.string(), z.number()), total: z.number().nullable() }); const RawPayloadSchema = z.object({ category: z.string(), year: z.string().nullable(), company: z.string().nullable(), setName: z.string().nullable(), grades: z.array(z.string()), rows: z.array(RowSchema) }); export type TagPopPayload = z.infer; /** Parse the set-page markdown into rows. Exported for tests. */ export function parseSetMarkdown(md: string): { grades: string[]; rows: z.infer[] } { const tables = markdownTables(md); for (const t of tables) { const headerIdx = t.findIndex((r) => r[0]?.replace(/\s/g, '').toLowerCase() === 'card#'); if (headerIdx < 0) continue; const header = t[headerIdx]!; const gradeCols: Array<{ idx: number; label: string }> = []; let totalIdx = -1; header.forEach((h, i) => { const label = mdText(h); if (i < 2) return; if (/^total$/i.test(label)) totalIdx = i; else if (label) gradeCols.push({ idx: i, label: label === 'VA' ? 'authentic' : label }); }); const rows: z.infer[] = []; for (const r of t.slice(headerIdx + 1)) { if (r.length < 3) continue; const number = mdText(r[0] ?? '') || null; const nameCell = r[1] ?? ''; const link = mdLink(nameCell); const nameText = mdText(nameCell.replace(//gi, ' ¦ ')); const [namePart, ...rest] = nameText.split('¦').map((s) => s.trim()); const name = namePart ?? nameText; if (!name || /^totals?$/i.test(name)) continue; const variation = rest.filter(Boolean).join(' ') || null; const counts: Record = {}; for (const g of gradeCols) { const n = toInt(mdText(r[g.idx] ?? '')); if (n !== null && n > 0) counts[g.label] = n; } const total = totalIdx >= 0 ? toInt(mdText(r[totalIdx] ?? '')) : Object.values(counts).reduce((a, b) => a + b, 0); rows.push({ number, name, variation, url: link, counts, total }); } return { grades: gradeCols.map((g) => g.label), rows }; } return { grades: [], rows: [] }; } function parseSetUrl(u: string): { category: string; year: string | null; company: string | null; setName: string | null } | null { try { const url = new URL(u); const parts = url.pathname.split('/').filter(Boolean).map((p) => decodeURIComponent(p).trim()); if (parts[0] !== 'pop-report' || parts.length < 4) return null; return { category: parts[1]!, year: parts[2] ?? null, company: parts[3] ?? null, setName: url.searchParams.get('setName') }; } catch { return null; } } export class TagPopConnector extends BaseConnector { readonly version = '1.0.0'; readonly parserVersion = PARSER_VERSION; protected override minIntervalMs = 1500; async *crawl(ctx: CrawlContext): AsyncIterable { const seeds = (ctx.options.seeds?.length ? ctx.options.seeds : (this.meta.config.seeds as string[] | undefined) ?? ['Pokemon']).map((s) => s.toLowerCase()); const maxPages = ctx.options.mode === 'backfill' ? Infinity : Number(this.meta.config.maxPagesPerRun ?? 40); let urls: string[] = []; if (ctx.options.seeds?.some((s) => s.startsWith('http'))) urls = ctx.options.seeds.filter((s) => s.startsWith('http')); else { const sm = await ctx.fetch(`${SITE}/pop.xml`, { engines: ['api'], headers: { ...BOT_HEADERS, accept: 'application/xml' }, responseType: 'text', timeoutMs: 120_000 }); if (!sm.success || !sm.html) throw new Error(`tag pop sitemap failed: ${sm.error ?? sm.httpStatus}`); urls = parseSitemap(sm.html) .map((e) => e.loc) .filter((u) => u.includes('setName=') && !/\t|%09/.test(u)) .filter((u) => { const p = parseSetUrl(u); return p && seeds.some((s) => p.category.toLowerCase() === s || p.category.toLowerCase().startsWith(s)); }); } let idx = Number(ctx.options.cursor?.idx ?? 0); if (idx >= urls.length) idx = 0; let count = 0; let pages = 0; for (; idx < urls.length && pages < maxPages; idx++) { if (ctx.signal?.aborted) return; if (this.reached(ctx, count)) break; const u = urls[idx]!; const info = parseSetUrl(u); if (!info) continue; await this.throttle(); const res = await ctx.fetch(u, { engines: ['firecrawl'], waitForMs: 8000, timeoutMs: 90_000, expect: ['title'], parse: (r) => ({ title: r.markdown && /Card #/i.test(r.markdown) ? 'ok' : null }) }); pages++; if (!res.success || !res.markdown) { ctx.anomaly('page_fetch_failed', `${u}: ${res.error ?? res.httpStatus}`); continue; } const { grades, rows } = parseSetMarkdown(res.markdown); if (!rows.length) { ctx.anomaly('parse_failure_table', u); continue; } count++; const payload: TagPopPayload = { ...info, grades, rows }; yield { url: u, externalId: `${info.category}|${info.year ?? ''}|${info.company ?? ''}|${info.setName ?? ''}`, kind: 'population_report', engine: 'firecrawl', httpStatus: res.httpStatus, payload, fetchedAt: res.fetchedAt }; await ctx.setCursor({ idx: idx + 1, total: urls.length }); } if (idx >= urls.length) await ctx.setCursor({ idx: 0, total: urls.length, completedAt: new Date().toISOString() }); } async normalize(raw: RawRecordLike): Promise { const p = RawPayloadSchema.parse(raw.payload); const categorySlug = cardCategorySlug(p.category); if (!categorySlug) return []; const year = p.year && /^\d{4}$/.test(p.year) ? Number(p.year) : null; const reportDate = dayOf(raw.fetchedAt); const isPokemon = categorySlug === 'pokemon'; const out: NormalizedRecord[] = []; for (const r of p.rows) { const total = r.total ?? Object.values(r.counts).reduce((a, b) => a + b, 0); if (!total) continue; const number = r.number ? r.number.split('/')[0]!.trim() : null; const totalInSet = r.number?.includes('/') ? toInt(r.number.split('/')[1]!) : null; const a = attrs({ categorySlug, franchise: isPokemon ? 'Pokémon' : null, brand: isPokemon ? 'The Pokémon Company' : p.company, set: p.setName, name: r.name, number, year, variant: r.variation, language: isPokemon && /japanese/i.test(p.company ?? '') ? 'Japanese' : isPokemon ? 'English' : null, identifiers: {}, metadata: { total_in_set: totalInSet, company: p.company }, }); out.push(NormalizedPopulationReportSchema.parse({ kind: 'population_report', connectorId: this.meta.id, sourceId: this.meta.sourceId, sourceUrl: r.url ?? raw.url, grader: 'tag', attributes: a, reportDate, total, byGrade: r.counts, parserVersion: PARSER_VERSION, confidence: 0.95 })); } return out; } } export default (meta: ConnectorMeta) => new TagPopConnector(meta);