import { z } from 'zod'; import { BaseConnector, html as H, type ConnectorMeta, type CrawlContext, type RawRecordInput, type RawRecordLike } from '@rareindex/connectors'; import { extractYear, parsePrice, parseSourceDate, type NormalizedCatalogItem, type NormalizedPriceObservation, type NormalizedRecord, type NormalizedSale, type AssetAttributes } from '@rareindex/shared'; /** * PriceCharting connector — video games, LEGO sets, Funko Pops and comic books. * * A product page carries (a) PriceCharting's own guide value per condition column and * (b) a table of recently completed eBay sales per condition tab (date · title · price · eBay id). * We store the parsed page compactly as one raw record and normalise it into: * - one catalog_item (canonical identity + identifiers), * - one price_observation per non-empty guide column ('guide_value'), * - one sale per completed-sale row (sale_type unknown: eBay auction/BIN not distinguished). */ const BASE = 'https://www.pricecharting.com'; const PARSER_VERSION = '1.0.0'; export const PriceCellSchema = z.object({ key: z.string(), value: z.number().nullable(), raw: z.string() }); export const SaleRowSchema = z.object({ tab: z.string(), date: z.string(), title: z.string(), price: z.number(), ebayId: z.string().nullable(), listedPrice: z.number().nullable() }); export const ProductPayloadSchema = z.object({ kind: z.literal('product'), url: z.string(), productId: z.string().nullable(), consoleUri: z.string(), consoleName: z.string(), title: z.string(), flags: z.object({ isComic: z.boolean(), isLegoSet: z.boolean(), isFunkoPop: z.boolean(), isCard: z.boolean(), isCoin: z.boolean(), isSystem: z.boolean() }), columnLabels: z.array(z.string()), prices: z.array(PriceCellSchema), sales: z.array(SaleRowSchema), details: z.record(z.string(), z.string()), images: z.array(z.string()), }); export type ProductPayload = z.infer; type Family = 'video_games' | 'lego' | 'funko' | 'comics'; interface ColumnMeaning { condition: string | null; completeness: string | null; conditionRaw: string; grade?: string | null; } const PRICE_KEYS = ['used_price', 'complete_price', 'new_price', 'graded_price', 'box_only_price', 'manual_only_price'] as const; const TAB_TO_KEY: Record = { used: 'used_price', cib: 'complete_price', new: 'new_price', graded: 'graded_price', 'box-only': 'box_only_price', 'manual-only': 'manual_only_price', 'loose-and-manual': 'graded_price', }; /** Column semantics per family (PriceCharting reuses the same six cells with different labels). */ const COLUMN_MEANING: Record>> = { video_games: { used_price: { condition: 'loose', completeness: 'loose', conditionRaw: 'Loose' }, complete_price: { condition: 'cib', completeness: 'cib', conditionRaw: 'Complete in box' }, new_price: { condition: 'sealed', completeness: 'sealed', conditionRaw: 'New / sealed' }, graded_price: { condition: null, completeness: null, conditionRaw: 'Graded (any grader)' }, box_only_price: { condition: null, completeness: 'box_only', conditionRaw: 'Box only' }, manual_only_price: { condition: null, completeness: 'manual_only', conditionRaw: 'Manual only' }, }, lego: { used_price: { condition: 'used_complete', completeness: 'used_complete', conditionRaw: 'Pieces only (used, complete pieces)' }, complete_price: { condition: 'opened_complete', completeness: 'opened_complete', conditionRaw: 'Complete (pieces, box, manual)' }, new_price: { condition: 'sealed', completeness: 'sealed', conditionRaw: 'New / sealed' }, manual_only_price: { condition: null, completeness: 'instructions_only', conditionRaw: 'Manual only' }, }, funko: { used_price: { condition: 'loose', completeness: 'loose', conditionRaw: 'Out of box' }, complete_price: { condition: 'boxed', completeness: 'boxed', conditionRaw: 'In damaged box' }, new_price: { condition: 'mint_in_box', completeness: 'boxed', conditionRaw: 'New (mint in box)' }, }, comics: { used_price: { condition: null, completeness: null, conditionRaw: 'Ungraded (raw)', grade: null }, complete_price: { condition: 'very_good', completeness: null, conditionRaw: 'Graded 4.0 / VG (any grader)', grade: '4.0' }, new_price: { condition: 'fine', completeness: null, conditionRaw: 'Graded 6.0 / Fine (any grader)', grade: '6.0' }, graded_price: { condition: 'very_fine', completeness: null, conditionRaw: 'Graded 8.0 / VF (any grader)', grade: '8.0' }, box_only_price: { condition: 'near_mint', completeness: null, conditionRaw: 'Graded 9.2 / NM- (any grader)', grade: '9.2' }, manual_only_price: { condition: 'near_mint', completeness: null, conditionRaw: 'Graded 9.8 (any grader)', grade: '9.8' }, }, }; const NINTENDO = /^(nes|famicom|super-nintendo|super-famicom|nintendo-64|gamecube|wii|wii-u|nintendo-switch|gameboy|gameboy-color|gameboy-advance|nintendo-ds|nintendo-3ds|virtual-boy|jp-|pal-)/; const SEGA = /^(sega-|pal-sega|jp-sega)/; const PLAYSTATION = /^(playstation|psp|playstation-vita|jp-playstation|pal-playstation)/; const XBOX = /^xbox/; const RETRO = /^(atari|intellivision|colecovision|neo-geo|turbografx|pc-engine|commodore|amiga|vectrex|3do|jaguar|philips-cd-i|magnavox|odyssey|msx|sharp|wonderswan|n-gage|game-com|tiger|evercade|super-cassette|fairchild|bally|arcadia|action-max|amiga-cd32)/; function familyOf(p: ProductPayload): Family | null { if (p.flags.isComic || p.consoleUri.startsWith('comic-books')) return 'comics'; if (p.flags.isLegoSet || p.consoleUri.startsWith('lego')) return 'lego'; if (p.flags.isFunkoPop || p.consoleUri.startsWith('funko')) return 'funko'; if (p.flags.isCard || p.flags.isCoin) return null; // cards & coins are handled by dedicated connectors return 'video_games'; } function categorySlug(p: ProductPayload, family: Family): string | null { const c = p.consoleUri; switch (family) { case 'lego': return 'lego_sets'; case 'funko': return 'funko'; case 'comics': { const pub = (p.details['Publisher'] ?? '').toLowerCase(); if (/marvel/.test(pub)) return 'marvel_comics'; if (/\bdc\b|dc comics|vertigo|wildstorm/.test(pub)) return 'dc_comics'; return 'independent_comics'; } case 'video_games': if (c === 'pc-games') return 'pc_games'; if (NINTENDO.test(c)) return 'nintendo_games'; if (SEGA.test(c)) return 'sega_games'; if (PLAYSTATION.test(c)) return 'playstation_games'; if (XBOX.test(c)) return 'xbox_games'; if (RETRO.test(c)) return 'atari_retro_games'; return 'video_games'; } } /** "Super Mario 64 [Player's Choice]" → { name, variant } ; "Cloud City #10123" → { name, number } */ function splitTitle(title: string): { name: string; variant: string | null; number: string | null } { let t = title.trim(); const variants: string[] = []; t = t.replace(/\[([^\]]+)\]/g, (_m, v: string) => { variants.push(v.trim()); return ' '; }); let number: string | null = null; const num = t.match(/#\s*([A-Za-z0-9.\-/]+)/); if (num) { number = num[1]!; t = t.replace(num[0], ' '); } return { name: t.replace(/\s+/g, ' ').trim(), variant: variants.length ? variants.join(' · ') : null, number }; } function parseProductPage(htmlText: string, url: string): ProductPayload { const $ = H.load(htmlText); const h1 = $('h1#product_name'); const consoleName = H.text(h1.find('a')) ?? ''; const consoleUri = (h1.find('a').attr('href') ?? '').replace(/^\/console\//, '') || (url.match(/\/game\/([^/]+)\//)?.[1] ?? ''); const title = h1 .clone() .children('a') .remove() .end() .text() .replace(/\s+/g, ' ') .trim(); const flagsBlock = htmlText.match(/VGPC\.product\s*=\s*\{([\s\S]*?)\}/)?.[1] ?? ''; const flag = (k: string) => new RegExp(`${k}\\s*:\\s*true`).test(flagsBlock); const productId = flagsBlock.match(/id\s*:\s*(\d+)/)?.[1] ?? null; const prices: z.infer[] = []; const seen = new Set(); for (const key of PRICE_KEYS) { if (seen.has(key)) continue; const cell = $(`td#${key}`).first(); if (!cell.length) continue; seen.add(key); const raw = H.text(cell.find('.price').first()) ?? H.text(cell) ?? ''; const parsed = parsePrice(raw, 'USD'); prices.push({ key, value: parsed && parsed.amount > 0 ? parsed.amount : null, raw }); } const columnLabels: string[] = []; $('#price_data thead th, table.price_data thead th').each((_, el) => { const t = H.text($(el)); if (t) columnLabels.push(t); }); const sales: z.infer[] = []; $('div[class^="completed-auctions-"]').each((_, sec) => { const cls = ($(sec).attr('class') ?? '').split(/\s+/).find((c) => c.startsWith('completed-auctions-')) ?? ''; const tab = cls.replace('completed-auctions-', ''); if (!tab || tab === 'condition' || !$(sec).find('table').length) return; $(sec) .find('tbody tr') .each((__, tr) => { const $tr = $(tr); const date = H.text($tr.find('td.date')) ?? ''; const titleEl = $tr.find('td.title a').first(); const rowTitle = H.text(titleEl) ?? H.text($tr.find('td.title')) ?? ''; const priceTxt = H.text($tr.find('td.numeric .js-price').first()) ?? H.text($tr.find('td.numeric').first()) ?? ''; const price = parsePrice(priceTxt, 'USD'); const listedTxt = H.text($tr.find('td.listed-price')); const listed = listedTxt ? parsePrice(listedTxt, 'USD') : null; const ebayId = ($tr.attr('id') ?? '').match(/ebay-(\d+)/)?.[1] ?? titleEl.attr('href')?.match(/\/itm\/(\d+)/)?.[1] ?? null; if (!date || !rowTitle || !price || price.amount <= 0) return; sales.push({ tab, date, title: rowTitle, price: price.amount, ebayId, listedPrice: listed && listed.amount > 0 ? listed.amount : null }); }); }); const details: Record = {}; $('td.title').each((_, el) => { const k = (H.text($(el)) ?? '').replace(/:$/, '').trim(); const v = H.text($(el).next('td.details')); if (k && v && v !== 'none' && v !== 'n/a') details[k] = v; }); const images: string[] = []; $('img[src*="images.pricecharting.com"]').each((_, el) => { const src = $(el).attr('src'); if (src && /\/(240|1600|400)\.jpg$/.test(src) && !images.includes(src)) images.push(src); }); return { kind: 'product', url, productId, consoleUri, consoleName, title, flags: { isComic: flag('is_comic'), isLegoSet: flag('is_lego_set'), isFunkoPop: flag('is_funko_pop'), isCard: flag('is_card'), isCoin: flag('is_coin'), isSystem: flag('is_system') }, columnLabels, prices, sales, details, images: images.slice(0, 4), }; } interface ConsoleProduct { id: string; productUri: string; productName: string; consoleUri: string; } export class PriceChartingConnector extends BaseConnector { readonly version = '1.0.0'; readonly parserVersion = PARSER_VERSION; override readonly urlPatterns = [/^https?:\/\/(www\.)?pricecharting\.com\/game\/[^/]+\/[^/?#]+/i]; protected override minIntervalMs = 1500; private get seeds(): string[] { const s = this.meta.config.seeds; return Array.isArray(s) ? (s as string[]) : []; } async *crawl(ctx: CrawlContext): AsyncIterable { const perConsole = Number(this.meta.config.productsPerConsole ?? 200); const sort = String(this.meta.config.sort ?? 'popularity'); const seeds = ctx.options.seeds?.length ? ctx.options.seeds : this.seeds; const cursor = ctx.options.cursor ?? {}; const doneConsoles = new Set((cursor.doneConsoles as string[] | undefined) ?? []); let count = 0; for (const consoleUri of seeds) { if (doneConsoles.has(consoleUri) && ctx.options.mode !== 'backfill') continue; if (ctx.signal?.aborted) return; const products = await this.listConsole(ctx, consoleUri, perConsole, sort); if (products.length === 0) ctx.anomaly('empty_console', consoleUri); for (const p of products) { if (this.reached(ctx, count)) return; const url = `${BASE}/game/${p.consoleUri}/${p.productUri}`; if (!(await ctx.shouldFetch(url))) continue; await this.throttle(); const rec = await this.fetchProduct(ctx, url); if (rec) { count++; yield rec; } } doneConsoles.add(consoleUri); await ctx.setCursor({ doneConsoles: [...doneConsoles], updatedAt: new Date().toISOString() }); } // Full pass complete: reset so the next incremental run starts again. await ctx.setCursor({ doneConsoles: [], updatedAt: new Date().toISOString() }); } private async listConsole(ctx: CrawlContext, consoleUri: string, max: number, sort: string): Promise { const out: ConsoleProduct[] = []; let cursorPos = 0; while (out.length < max) { await this.throttle(); const url = `${BASE}/console/${consoleUri}?sort=${encodeURIComponent(sort)}&cursor=${cursorPos}&format=json`; const res = await ctx.fetch(url, { engines: ['api'], responseType: 'json', minQuality: 0 }); let json = res.json as { products?: Array>; cursor?: string } | null; if (!json && res.html) { try { json = JSON.parse(res.html) as typeof json; } catch { json = null; } } if (!res.success || !json?.products) { if (cursorPos === 0) ctx.anomaly('console_list_failed', `${consoleUri}: ${res.error ?? 'no products'}`); break; } for (const p of json.products) { if (typeof p.productUri === 'string' && typeof p.consoleUri === 'string') { out.push({ id: String(p.id ?? ''), productUri: p.productUri, productName: String(p.productName ?? ''), consoleUri: p.consoleUri }); } } const next = Number(json.cursor); if (!Number.isFinite(next) || next <= cursorPos || json.products.length === 0) break; cursorPos = next; } return out.slice(0, max); } private async fetchProduct(ctx: CrawlContext, url: string): Promise { const res = await ctx.fetch(url, { responseType: 'text', expect: ['title', 'price', 'identifiers', 'category'], parse: (r) => { if (!r.html) return null; const p = parseProductPage(r.html, url); return { title: p.title, price: p.prices.find((x) => x.value !== null)?.value ?? null, identifiers: p.productId ? { id: p.productId } : null, category: p.consoleUri }; }, }); if (!res.success || !res.html) { ctx.anomaly('product_fetch_failed', `${url}: ${res.error ?? res.httpStatus}`); return null; } const payload = parseProductPage(res.html, url); if (!payload.title) { ctx.anomaly('parse_failure_title', url); return null; } return { url, externalId: payload.productId, kind: 'catalog_item', engine: res.engine, httpStatus: res.httpStatus, payload, fetchedAt: res.fetchedAt }; } async lookup(url: string, ctx: CrawlContext): Promise { const clean = url.split('?')[0]!; const rec = await this.fetchProduct(ctx, clean); return rec ? [rec] : []; } async normalize(raw: RawRecordLike): Promise { const p = ProductPayloadSchema.parse(raw.payload); const family = familyOf(p); if (!family) return []; const slug = categorySlug(p, family); if (!slug) return []; const { name, variant, number } = splitTitle(p.title); const year = extractYear(p.details['Release Date'] ?? '') ?? extractYear(p.title) ?? null; const identifiers: Record = {}; if (p.productId) identifiers.pricecharting_id = p.productId; if (p.details['UPC']) identifiers.upc = p.details['UPC'].replace(/\s+/g, ''); if (p.details['ASIN (Amazon)']) identifiers.asin = p.details['ASIN (Amazon)']; if (p.details['ePID (eBay)']) identifiers.ebay_epid = p.details['ePID (eBay)']; if (p.details['Comic.org ID']) identifiers.comics_org_id = p.details['Comic.org ID']; if (family === 'lego' && number) identifiers.lego_set_number = number; if (p.details['Model Number']) identifiers.model_number = p.details['Model Number']; if (family === 'funko' && p.details['Box Number']) identifiers.funko_box_number = p.details['Box Number']; const attributes: AssetAttributes = { categorySlug: slug, subcategorySlug: null, franchise: family === 'funko' || family === 'lego' ? p.consoleName.replace(/^(Funko POP|LEGO)\s*/i, '') || null : null, brand: family === 'lego' ? 'LEGO' : family === 'funko' ? 'Funko' : p.details['Publisher'] ?? null, series: family === 'funko' ? p.details['Series'] ?? p.consoleName : null, set: family === 'comics' ? p.consoleName : family === 'lego' ? p.consoleName.replace(/^LEGO\s*/i, '') : p.consoleName, setCode: null, name: family === 'comics' ? p.consoleName : name, model: null, reference: null, number: number ?? (family === 'funko' ? p.details['Box Number'] ?? null : null), year, edition: null, variant, language: 'English', region: /^(jp|pal)-/.test(p.consoleUri) ? p.consoleUri.split('-')[0]!.toUpperCase() : 'NTSC-U', country: null, material: null, size: null, color: null, rarity: null, productionQuantity: null, originalMsrp: null, originalMsrpCurrency: null, identifiers, metadata: { pricecharting_console: p.consoleUri, key_issue: p.details['Is Key Issue'] === 'Yes' ? true : undefined, genre: p.details['Genre'], notes: p.details['Notes'] }, }; if (family === 'comics') { // "Amazing Spider-Man #1 (1963)" → name = series (from console), number = issue, year from title attributes.name = p.consoleName; attributes.year = extractYear(p.title) ?? year; } const observedAt = raw.fetchedAt; const base = { connectorId: this.meta.id, sourceId: this.meta.sourceId, sourceUrl: p.url, externalId: p.productId, rawTitle: family === 'comics' ? p.title : `${p.title} (${p.consoleName})`, description: p.details['Description'] ?? null, imageUrls: p.images, attributes, observedAt, confidence: 0.9, parserVersion: PARSER_VERSION, }; const meanings = COLUMN_MEANING[family]; const out: NormalizedRecord[] = []; const catalog: NormalizedCatalogItem = { kind: 'catalog_item', ...base, grade: { grader: null, grade: null, qualifier: null, certificationNumber: null }, condition: { condition: null, conditionRaw: null, completeness: null }, releaseDate: parseSourceDate(p.details['Release Date']) ?? null }; out.push(catalog); const obsDate = new Date(Date.UTC(observedAt.getUTCFullYear(), observedAt.getUTCMonth(), observedAt.getUTCDate())); for (const cell of p.prices) { const m = meanings[cell.key as (typeof PRICE_KEYS)[number]]; if (!m || cell.value === null) continue; const obs: NormalizedPriceObservation = { kind: 'price_observation', ...base, grade: { grader: null, grade: m.grade ?? null, qualifier: null, certificationNumber: null }, condition: { condition: m.condition, conditionRaw: m.conditionRaw, completeness: m.completeness }, priceKind: 'guide_value', price: cell.value, currency: 'USD', observationDate: obsDate, sampleSize: null, confidence: 0.85, }; out.push(obs); } const seenSale = new Set(); for (const row of p.sales) { const key = TAB_TO_KEY[row.tab]; const m = key ? meanings[key] : undefined; if (!m) continue; const saleDate = parseSourceDate(row.date); if (!saleDate) continue; const dedupe = `${row.ebayId ?? row.title}|${row.date}|${row.price}`; if (seenSale.has(dedupe)) continue; seenSale.add(dedupe); const sale: NormalizedSale = { kind: 'sale', ...base, externalId: row.ebayId ? `ebay:${row.ebayId}` : `${p.productId ?? p.url}:${row.date}:${row.price}`, rawTitle: row.title, description: null, grade: { grader: null, grade: m.grade ?? null, qualifier: null, certificationNumber: null }, condition: { condition: m.condition, conditionRaw: m.conditionRaw, completeness: m.completeness }, saleType: 'unknown', saleDate, price: row.price, currency: 'USD', buyerPremiumIncluded: false, quantity: 1, isBundle: /\b(lot|bundle)\b/i.test(row.title), location: null, auctionHouse: null, lotNumber: null, confidence: 0.75, }; out.push(sale); } return out; } } export default (meta: ConnectorMeta) => new PriceChartingConnector(meta); export { parseProductPage };