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 { UA_HEADERS, dayOf, isoDay, splitNumber, tcgplayerImage, variantFromSubType } from '../_lib/tcg-shared.js'; /** * TCGCSV connector — public daily mirror of TCGplayer's catalog + prices for ~90 card games. * One raw record per TCGplayer product (with its price rows); normalize emits one catalog_item per * printing subtype and price_observations (market/low/…) dated with the mirror's last-updated stamp. */ const API = 'https://tcgcsv.com/tcgplayer'; const PARSER_VERSION = '1.0.0'; const GroupSchema = z.object({ groupId: z.number(), name: z.string(), abbreviation: z.string().nullable().optional(), isSupplemental: z.boolean().optional(), publishedOn: z.string().nullable().optional(), categoryId: z.number().optional() }); const ExtSchema = z.object({ name: z.string(), displayName: z.string().optional(), value: z.string() }); const ProductSchema = z.object({ productId: z.number(), name: z.string(), cleanName: z.string().optional(), imageUrl: z.string().nullable().optional(), url: z.string().optional(), modifiedOn: z.string().optional(), extendedData: z.array(ExtSchema).default([]), }); const PriceSchema = z.object({ productId: z.number(), lowPrice: z.number().nullable().optional(), midPrice: z.number().nullable().optional(), highPrice: z.number().nullable().optional(), marketPrice: z.number().nullable().optional(), directLowPrice: z.number().nullable().optional(), subTypeName: z.string().nullable().optional(), }); const CategoryCfgSchema = z.object({ slug: z.string(), franchise: z.string().nullable(), brand: z.string().nullable(), language: z.string().optional() }); const RawPayloadSchema = z.object({ categoryId: z.number(), category: CategoryCfgSchema, group: GroupSchema, product: ProductSchema, prices: z.array(PriceSchema), updatedAt: z.string().nullable(), }); export type TcgcsvPayload = z.infer; export function trimProduct(p: Record) { const keep = ['productId', 'name', 'cleanName', 'imageUrl', 'url', 'modifiedOn', 'extendedData']; const out: Record = {}; for (const k of keep) if (p[k] !== undefined) out[k] = p[k]; return ProductSchema.parse(out); } export class TcgcsvConnector extends BaseConnector { readonly version = '1.0.0'; readonly parserVersion = PARSER_VERSION; protected override minIntervalMs = 260; private categories(): Array<[number, z.infer]> { const cfg = (this.meta.config.categories ?? {}) as Record; return Object.entries(cfg).map(([id, c]) => [Number(id), CategoryCfgSchema.parse(c)] as [number, z.infer]); } private async get(ctx: CrawlContext, url: string) { await this.throttle(); return withRetries(() => ctx.fetch(url, { engines: ['api'], headers: UA_HEADERS }), (r) => r.success && (r.json !== null || Boolean(r.html)), 4, 1500); } async *crawl(ctx: CrawlContext): AsyncIterable { const stamp = await this.get(ctx, 'https://tcgcsv.com/last-updated.txt'); const updatedAt = typeof stamp.html === 'string' ? stamp.html.trim() : typeof stamp.json === 'string' ? String(stamp.json).trim() : null; const cats = this.categories().filter(([id]) => !ctx.options.seeds?.length || ctx.options.seeds.includes(String(id)) || ctx.options.seeds.includes(String(id))); const maxGroups = ctx.options.mode === 'backfill' ? Infinity : Number(this.meta.config.maxGroupsPerCategory ?? 40); let catIdx = Number(ctx.options.cursor?.catIdx ?? 0); let groupIdx = Number(ctx.options.cursor?.groupIdx ?? 0); let count = 0; for (; catIdx < cats.length; catIdx++, groupIdx = 0) { const [categoryId, category] = cats[catIdx]!; const gRes = await this.get(ctx, `${API}/${categoryId}/groups`); const gJson = gRes.json as { results?: unknown[] } | null; if (!gRes.success || !Array.isArray(gJson?.results)) { ctx.anomaly('page_fetch_failed', `groups ${categoryId}: ${gRes.error ?? gRes.httpStatus}`); continue; } const groups = z .array(GroupSchema.loose()) .parse(gJson.results) .sort((a, b) => (b.publishedOn ?? '').localeCompare(a.publishedOn ?? '')) .slice(0, maxGroups); for (; groupIdx < groups.length; groupIdx++) { if (ctx.signal?.aborted) return; const group = GroupSchema.parse(groups[groupIdx]); const pRes = await this.get(ctx, `${API}/${categoryId}/${group.groupId}/products`); const products = (pRes.json as { results?: unknown[] } | null)?.results; if (!pRes.success || !Array.isArray(products)) { ctx.anomaly('page_fetch_failed', `products ${categoryId}/${group.groupId}: ${pRes.error ?? pRes.httpStatus}`); continue; } const prRes = await this.get(ctx, `${API}/${categoryId}/${group.groupId}/prices`); const priceRows = (prRes.json as { results?: unknown[] } | null)?.results; const byProduct = new Map[]>(); if (Array.isArray(priceRows)) { for (const row of priceRows) { const parsed = PriceSchema.safeParse(row); if (!parsed.success) continue; const list = byProduct.get(parsed.data.productId) ?? []; list.push(parsed.data); byProduct.set(parsed.data.productId, list); } } else { ctx.anomaly('page_fetch_failed', `prices ${categoryId}/${group.groupId}: ${prRes.error ?? prRes.httpStatus}`); } for (const raw of products) { let product: z.infer; try { product = trimProduct(raw as Record); } catch (err) { ctx.anomaly('parse_failure_product', `${group.groupId}: ${err instanceof Error ? err.message : String(err)}`); continue; } if (this.reached(ctx, count)) return; count++; const payload: TcgcsvPayload = { categoryId, category, group, product, prices: byProduct.get(product.productId) ?? [], updatedAt }; yield { url: product.url ?? `https://www.tcgplayer.com/product/${product.productId}`, externalId: String(product.productId), kind: 'catalog_item', engine: 'api', httpStatus: pRes.httpStatus, payload, fetchedAt: pRes.fetchedAt }; } await ctx.setCursor({ catIdx, groupIdx: groupIdx + 1, updatedAt }); } } await ctx.setCursor({ catIdx: 0, groupIdx: 0, updatedAt, completedAt: new Date().toISOString() }); } async normalize(raw: RawRecordLike): Promise { const { category, group, product, prices, updatedAt } = RawPayloadSchema.parse(raw.payload); const ext = new Map(product.extendedData.map((e) => [e.name.toLowerCase(), e.value])); const { number, total } = splitNumber(ext.get('number') ?? null); const rarity = ext.get('rarity') ?? null; const isSealed = !number && !rarity; const year = group.publishedOn ? Number(group.publishedOn.slice(0, 4)) || null : null; const images = tcgplayerImage(product.imageUrl); // "Charizard - 4/102" style names carry the number; keep the clean name. const name = (product.cleanName ?? product.name).replace(/\s+-\s+[A-Za-z0-9]+\/[A-Za-z0-9]+$/, '').trim() || product.name; const observedAt = raw.fetchedAt; const obsDate = isoDay(updatedAt) ?? dayOf(observedAt); const priceKinds = new Set(((this.meta.config.priceKinds as string[] | undefined) ?? ['market', 'low'])); const build = (variant: string | null) => attrs({ categorySlug: category.slug, franchise: category.franchise, brand: category.brand, set: group.name, setCode: group.abbreviation ?? null, name, number, year, variant, language: category.language ?? 'English', rarity, identifiers: { tcgplayer_id: String(product.productId), tcgplayer_group_id: String(group.groupId) }, metadata: { sealed: isSealed, total, card_type: ext.get('card type') ?? ext.get('cardtype') ?? null, supplemental: group.isSupplemental ?? false, tcgplayer_url: product.url ?? null }, }); const out: NormalizedRecord[] = []; const subTypes = prices.length ? [...new Set(prices.map((p) => p.subTypeName ?? 'Normal'))] : ['Normal']; const seenVariants = new Set(); for (const subType of subTypes) { const variant = variantFromSubType(subType); const key = variant ?? ''; const a = build(variant); const rawTitle = makeTitle({ name, set: group.name, number, total, year, variant }); if (!seenVariants.has(key)) { seenVariants.add(key); out.push(catalogItem({ kind: 'catalog_item', connectorId: this.meta.id, sourceId: this.meta.sourceId, sourceUrl: raw.url, externalId: `${product.productId}${variant ? `:${variant}` : ''}`, rawTitle, imageUrls: images, attributes: a, observedAt, confidence: 0.85, parserVersion: PARSER_VERSION, releaseDate: group.publishedOn ? new Date(group.publishedOn) : null })); } const row = prices.find((p) => (p.subTypeName ?? 'Normal') === subType); if (!row) continue; const pairs: Array<['market' | 'low' | 'mid' | 'high', number | null]> = [ ['market', num(row.marketPrice)], ['low', num(row.lowPrice)], ['mid', num(row.midPrice)], ['high', num(row.highPrice)], ]; for (const [kind, price] of pairs) { if (!price || !priceKinds.has(kind)) continue; out.push(priceObservation({ kind: 'price_observation', connectorId: this.meta.id, sourceId: this.meta.sourceId, sourceUrl: raw.url, externalId: `${product.productId}:${subType}:${kind}`, rawTitle, imageUrls: images, attributes: a, observedAt, confidence: 0.75, parserVersion: PARSER_VERSION, priceKind: kind, price, currency: 'USD', observationDate: obsDate, sampleSize: null })); } } return out; } } export default (meta: ConnectorMeta) => new TcgcsvConnector(meta);