import { z } from 'zod'; import { BaseConnector, type ConnectorMeta, type CrawlContext, type RawRecordInput, type RawRecordLike } from '@rareindex/connectors'; import { type NormalizedRecord } from '@rareindex/shared'; import { attrs, catalogItem, makeTitle, num, priceObservation, withRetries } from '../_lib/shared.js'; import { dayOf, isoDay, parseCompactGrade } from '../_lib/tcg-shared.js'; /** PokemonPrice.com — per-grade fair-value model for Pokémon cards, embedded in server-rendered pages. */ const BASE = 'https://www.pokemonprice.com'; const PARSER_VERSION = '1.0.0'; const HEADERS = { 'user-agent': 'RareIndexBot/0.1 (+https://www.rareindex.io/about/data)', accept: 'text/html,application/xhtml+xml' }; const GradeRowSchema = z.object({ grade: z.string(), fair_price: z.union([z.string(), z.number()]).nullable().optional(), low_price: z.union([z.string(), z.number()]).nullable().optional(), high_price: z.union([z.string(), z.number()]).nullable().optional(), confidence: z.union([z.string(), z.number()]).nullable().optional(), last_sale_date: z.string().nullable().optional(), }); const TxSchema = z.object({ month: z.string(), grade: z.string(), count: z.number() }); const PayloadSchema = z.object({ slug: z.string(), setSlug: z.string(), name: z.string(), tag: z.string().nullable(), number: z.string().nullable(), total: z.string().nullable(), setName: z.string().nullable(), image: z.string().nullable(), rows: z.array(GradeRowSchema), transactions: z.array(TxSchema), }); export type PokemonPricePayload = z.infer; /** Decode the RSC flight strings of a Next.js app-router page into one searchable string. */ export function flightText(html: string): string { const parts: string[] = []; for (const m of html.matchAll(/self\.__next_f\.push\(\[1,"((?:[^"\\]|\\.)*)"\]\)/g)) { try { parts.push(JSON.parse(`"${m[1]}"`) as string); } catch { /* skip undecodable chunk */ } } return parts.join(''); } /** Extract a balanced JSON array/object that starts right after `key":`. */ export function extractJson(text: string, key: string): unknown | null { const idx = text.indexOf(`"${key}":`); if (idx < 0) return null; let i = text.indexOf(':', idx) + 1; while (text[i] === ' ') i++; const open = text[i]; if (open !== '[' && open !== '{') return null; const close = open === '[' ? ']' : '}'; let depth = 0; let inStr = false; for (let j = i; j < text.length; j++) { const ch = text[j]!; if (inStr) { if (ch === '\\') j++; else if (ch === '"') inStr = false; continue; } if (ch === '"') inStr = true; else if (ch === open) depth++; else if (ch === close) { depth--; if (depth === 0) { try { return JSON.parse(text.slice(i, j + 1)); } catch { return null; } } } } return null; } export function parseCardPage(html: string, url: string): PokemonPricePayload | null { const h1 = html.match(/]*>([\s\S]*?)<\/h1>/); const spans = h1 ? [...h1[1]!.matchAll(/]*>([^<]*)<\/span>/g)].map((m) => m[1]!.trim()).filter(Boolean) : []; const titleSpan = spans[0] ?? ''; const nm = titleSpan.match(/^(.*?)\s*(?:\[(.+?)\])?$/); const name = (nm?.[1] ?? titleSpan).trim(); const tag = nm?.[2]?.trim() ?? null; const numSpan = spans.find((s) => /^\d+[A-Za-z]?\s*\/\s*\w+$/.test(s) || /^[A-Za-z0-9]+\s*\/\s*[A-Za-z0-9]+$/.test(s)); const [number, total] = numSpan ? numSpan.split('/').map((s) => s.trim()) : [null, null]; const setName = spans.filter((s) => s !== titleSpan && s !== numSpan).pop() ?? null; const image = html.match(/https:\/\/cdn\.pokemonprice\.com\/cards\/[^"\\\s]+/)?.[0] ?? null; const text = flightText(html); const rowsRaw = extractJson(text, 'rows'); const rows = Array.isArray(rowsRaw) ? rowsRaw.filter((r) => r && typeof r === 'object' && 'grade' in (r as object) && 'fair_price' in (r as object)).map((r) => { const o = r as Record; return { grade: String(o.grade), fair_price: o.fair_price as string, low_price: o.low_price as string, high_price: o.high_price as string, confidence: o.confidence as string, last_sale_date: (o.last_sale_date as string) ?? null }; }) : []; const txRaw = extractJson(text, 'transactions'); const transactions = Array.isArray(txRaw) ? txRaw.filter((t) => t && typeof t === 'object' && 'month' in (t as object)).map((t) => ({ month: String((t as Record).month), grade: String((t as Record).grade), count: Number((t as Record).count) || 0 })) : []; if (!name) return null; const parts = new URL(url).pathname.split('/').filter(Boolean); const parsed = PayloadSchema.safeParse({ slug: parts.slice(-1)[0] ?? '', setSlug: parts[0] ?? '', name, tag, number: number ?? null, total: total ?? null, setName, image, rows, transactions }); return parsed.success ? parsed.data : null; } /** "Base Set 1st Edition" + tag "Holo 1st edition" → { set: "Base Set", variant: "1st Edition Holo" } */ export function setAndVariant(setName: string | null, tag: string | null): { set: string | null; variant: string | null } { let set = setName?.trim() ?? null; const flags = new Set(); if (set) { const m = set.match(/^(.*?)\s+(1st Edition|Shadowless|Unlimited)$/i); if (m) { set = m[1]!.trim(); const f = m[2]!.toLowerCase(); if (f === '1st edition') flags.add('1st Edition'); if (f === 'shadowless') flags.add('Shadowless'); } } for (const t of (tag ?? '').split(/[,·]/).map((s) => s.trim().toLowerCase()).filter(Boolean)) { if (t.includes('reverse')) flags.add('Reverse Holo'); else if (t.includes('1st') && t.includes('holo')) { flags.add('1st Edition'); flags.add('Holo'); } else if (t.includes('1st')) flags.add('1st Edition'); else if (t.includes('holo')) flags.add('Holo'); else if (t.includes('shadowless')) flags.add('Shadowless'); else if (t !== 'unlimited') flags.add(t.replace(/\b\w/g, (c) => c.toUpperCase())); } const ordered: string[] = []; if (flags.has('1st Edition') && flags.has('Holo')) ordered.push('1st Edition Holo'); else { if (flags.has('1st Edition')) ordered.push('1st Edition'); if (flags.has('Holo')) ordered.push('Holo'); } if (flags.has('Shadowless')) ordered.push('Shadowless'); if (flags.has('Reverse Holo')) ordered.push('Reverse Holo'); for (const f of flags) if (!['1st Edition', 'Holo', 'Shadowless', 'Reverse Holo'].includes(f)) ordered.push(f); return { set, variant: ordered.length ? ordered.join(' · ') : null }; } export class PokemonPriceConnector extends BaseConnector { readonly version = '1.0.0'; readonly parserVersion = PARSER_VERSION; protected override minIntervalMs = 1500; override readonly urlPatterns = [/pokemonprice\.com\/[a-z0-9-]+\/[a-z0-9-]+$/i]; private async page(ctx: CrawlContext, url: string) { await this.throttle(); return withRetries(() => ctx.fetch(url, { engines: ['api', 'firecrawl'], headers: HEADERS, responseType: 'text', minQuality: 0.3 }), (r) => r.success && Boolean(r.html), 3, 2500); } async *crawl(ctx: CrawlContext): AsyncIterable { const seeds = (ctx.options.seeds?.length ? ctx.options.seeds : (this.meta.config.seeds as string[] | undefined)) ?? []; let sets = seeds; if (!sets.length) { const idx = await this.page(ctx, `${BASE}/card-lists`); sets = [...new Set([...(idx.html ?? '').matchAll(/href="\/card-lists\/([a-z0-9-]+)"/g)].map((m) => m[1]!))]; } const maxCards = ctx.options.mode === 'backfill' ? Infinity : Number(this.meta.config.maxCardsPerRun ?? 300); let setIdx = Number(ctx.options.cursor?.setIdx ?? 0); if (setIdx >= sets.length) setIdx = 0; let count = 0; for (; setIdx < sets.length; setIdx++) { const setSlug = sets[setIdx]!; const cardSlugs: string[] = []; for (let offset = 0; offset < 1200; offset += 30) { const list = await this.page(ctx, `${BASE}/card-lists/${setSlug}${offset ? `?offset=${offset}` : ''}`); if (!list.success || !list.html) { ctx.anomaly('page_fetch_failed', `list ${setSlug}@${offset}: ${list.error ?? list.httpStatus}`); break; } const found = [...new Set([...list.html.matchAll(new RegExp(`href="/${setSlug}/([a-z0-9-]+)"`, 'g'))].map((m) => m[1]!))]; const fresh = found.filter((s) => !cardSlugs.includes(s)); if (!fresh.length) break; cardSlugs.push(...fresh); if (!list.html.includes(`offset=${offset + 30}`)) break; } for (const cardSlug of cardSlugs) { if (ctx.signal?.aborted) return; if (this.reached(ctx, count) || count >= maxCards) { await ctx.setCursor({ setIdx, updatedAt: new Date().toISOString() }); return; } const url = `${BASE}/${setSlug}/${cardSlug}`; const res = await this.page(ctx, url); if (!res.success || !res.html) { ctx.anomaly('page_fetch_failed', `${url}: ${res.error ?? res.httpStatus}`); continue; } const payload = parseCardPage(res.html, url); if (!payload) { ctx.anomaly('parse_failure_card', url); continue; } count++; yield { url, externalId: `${setSlug}/${cardSlug}`, kind: 'catalog_item', engine: res.engine, httpStatus: res.httpStatus, payload, fetchedAt: res.fetchedAt }; } await ctx.setCursor({ setIdx: setIdx + 1, updatedAt: new Date().toISOString() }); } await ctx.setCursor({ setIdx: 0, updatedAt: new Date().toISOString() }); } async lookup(url: string, ctx: CrawlContext): Promise { if (!this.urlPatterns[0]!.test(url)) return []; const res = await this.page(ctx, url); if (!res.success || !res.html) return []; const payload = parseCardPage(res.html, url); if (!payload) return []; return [{ url, externalId: `${payload.setSlug}/${payload.slug}`, kind: 'catalog_item', engine: res.engine, httpStatus: res.httpStatus, payload, fetchedAt: res.fetchedAt }]; } async normalize(raw: RawRecordLike): Promise { const p = PayloadSchema.parse(raw.payload); const { set, variant } = setAndVariant(p.setName, p.tag); const a = attrs({ categorySlug: 'pokemon', franchise: 'Pokémon', brand: 'The Pokémon Company', set, name: p.name, number: p.number, year: null, variant, language: 'English', identifiers: { pokemonprice_slug: `${p.setSlug}/${p.slug}` }, metadata: { total: p.total, set_label: p.setName, tag: p.tag }, }); const images = p.image ? [p.image] : []; const rawTitle = makeTitle({ name: p.name, set, number: p.number, total: p.total, variant }); const observedAt = raw.fetchedAt; const out: NormalizedRecord[] = [ catalogItem({ kind: 'catalog_item', connectorId: this.meta.id, sourceId: this.meta.sourceId, sourceUrl: raw.url, externalId: `${p.setSlug}/${p.slug}`, rawTitle, imageUrls: images, attributes: a, observedAt, confidence: 0.8, parserVersion: PARSER_VERSION, releaseDate: null }), ]; const kinds = new Set(((this.meta.config.priceKinds as string[] | undefined) ?? ['guide_value', 'low', 'high'])); const txByGrade = new Map(); for (const t of p.transactions) txByGrade.set(t.grade, (txByGrade.get(t.grade) ?? 0) + t.count); for (const row of p.rows) { const { grader, grade } = parseCompactGrade(row.grade); if (!grader && !/^raw$/i.test(row.grade)) continue; const fair = num(row.fair_price); if (!fair) continue; const conf = Math.min(0.8, Math.max(0.2, (Number(row.confidence) || 40) / 100)); // The fair price is the model's current estimate → dated by fetch day; the last sale date is kept in the title context only. const obsDate = dayOf(observedAt); const lastSale = isoDay(row.last_sale_date); const sample = txByGrade.get(row.grade) ?? null; const gradeLabel = grader === 'raw' ? 'Raw' : `${grader!.toUpperCase()} ${grade}`; const title = `${rawTitle} — ${gradeLabel}${lastSale ? ` (last sale ${lastSale.toISOString().slice(0, 10)})` : ''}`; const emit = (kind: 'guide_value' | 'low' | 'high', price: number | null) => { if (!price || !kinds.has(kind)) return; out.push(priceObservation({ kind: 'price_observation', connectorId: this.meta.id, sourceId: this.meta.sourceId, sourceUrl: raw.url, externalId: `${p.setSlug}/${p.slug}:${row.grade}:${kind}`, rawTitle: title, imageUrls: images, attributes: a, grade: { grader: grader === 'raw' ? 'raw' : grader, grade, qualifier: null, certificationNumber: null }, observedAt, confidence: conf, parserVersion: PARSER_VERSION, priceKind: kind, price, currency: 'USD', observationDate: obsDate, sampleSize: sample })); }; emit('guide_value', fair); emit('low', num(row.low_price)); emit('high', num(row.high_price)); } return out; } } export default (meta: ConnectorMeta) => new PokemonPriceConnector(meta);