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, priceObservation } from '../_lib/shared.js'; import { JSON_HEADERS, dayOf, isoDay } from '../_g1-cards-eu-jp-lib/index.js'; /** * Cardmarket public bulk files: daily price guide (EUR) joined with the product catalog per game. * One raw record per product; normalize emits a catalog item (plus a foil variant when foil prices * exist) and one price observation per Cardmarket price field, dated by the file's own createdAt. */ const BASE = 'https://downloads.s3.cardmarket.com/productCatalog'; const PARSER_VERSION = '1.0.0'; const GameCfgSchema = z.object({ slug: z.string(), name: z.string(), franchise: z.string().nullable().optional(), brand: z.string().nullable().optional(), foilVariant: z.string().default('Foil') }); export type GameCfg = z.infer; export const ProductSchema = z.object({ idProduct: z.number().int(), name: z.string(), idCategory: z.number().int(), categoryName: z.string(), idExpansion: z.number().int().nullable().optional(), idMetacard: z.number().int().nullable().optional(), dateAdded: z.string().nullable().optional(), }); export type CardmarketProduct = z.infer; const price = z.number().nullable().optional(); export const PriceRowSchema = z.object({ idProduct: z.number().int(), idCategory: z.number().int().optional(), avg: price, low: price, trend: price, avg1: price, avg7: price, avg30: price, 'avg-foil': price, 'low-foil': price, 'trend-foil': price, 'avg1-foil': price, 'avg7-foil': price, 'avg30-foil': price, }); export type PriceRow = z.infer; const RawPayloadSchema = z.object({ gameId: z.number().int(), game: GameCfgSchema, product: ProductSchema, prices: PriceRowSchema.nullable(), single: z.boolean(), priceGuideCreatedAt: z.string().nullable(), productListCreatedAt: z.string().nullable(), }); export type CardmarketPayload = z.infer; const PriceGuideFileSchema = z.object({ version: z.number().optional(), createdAt: z.string().nullable().optional(), priceGuides: z.array(z.unknown()) }); const ProductFileSchema = z.object({ version: z.number().optional(), createdAt: z.string().nullable().optional(), products: z.array(z.unknown()) }); /** Cardmarket price field → RareIndex priceKind. */ export const PRICE_FIELDS: Array<[keyof PriceRow, 'market' | 'low' | 'mid' | 'trend' | 'average_7d' | 'average_30d']> = [ ['avg', 'mid'], ['low', 'low'], ['trend', 'trend'], ['avg1', 'market'], ['avg7', 'average_7d'], ['avg30', 'average_30d'], ]; export function hasAnyPrice(row: PriceRow | null | undefined, foil = false): boolean { if (!row) return false; return PRICE_FIELDS.some(([f]) => { const v = row[foil ? (`${f}-foil` as keyof PriceRow) : f]; return typeof v === 'number' && v > 0; }); } /** * Cardmarket product names embed disambiguators: "Kakuna [Bug Bite | Primal Clash]", "Roronoa Zoro (OP01-001)", * "Auron (1-001)", "Forest (V.1)". Returns the clean name plus the parts we can use. */ export function parseProductName(raw: string): { name: string; number: string | null; version: string | null; disambiguation: string | null } { let name = raw.replace(/'/g, "'").replace(/&/g, '&').replace(/\s+/g, ' ').trim(); let disambiguation: string | null = null; let number: string | null = null; let version: string | null = null; const br = name.match(/\s*\[([^\]]+)\]\s*$/); if (br) { disambiguation = br[1]!.trim(); name = name.slice(0, br.index).trim(); } for (;;) { const m = name.match(/\s*\(([^()]+)\)\s*$/); if (!m) break; const inner = m[1]!.trim(); if (/^V\.\s?\d+$/i.test(inner)) version = version ?? inner.replace(/\s/g, ''); else if (/^(?:[A-Z]{1,6}\d{0,3}[A-Z]?-\d{1,4}[A-Za-z]{0,3}|\d{1,2}-\d{3}[A-Z]?|[A-Z]{1,3}\d{1,3}-\d{1,3})$/.test(inner)) number = number ?? inner; else break; name = name.slice(0, m.index).trim(); } return { name: name || raw.trim(), number, version, disambiguation }; } const SEALED_RE = /booster|box|display|bundle|deck|case|collection|tin|pack|kit|set$|starter|bundle|toolkit|fat pack|gift/i; export class CardmarketPriceGuideConnector extends BaseConnector { readonly version = '1.0.0'; readonly parserVersion = PARSER_VERSION; protected override minIntervalMs = 2000; private games(): Array<[number, GameCfg]> { const cfg = (this.meta.config.games ?? {}) as Record; return Object.entries(cfg) .map(([id, c]) => [Number(id), GameCfgSchema.parse(c)] as [number, GameCfg]) .sort((a, b) => a[0] - b[0]); } private async file(ctx: CrawlContext, url: string, schema: z.ZodType): Promise<{ data: T | null; status: number | null; fetchedAt: Date; error: string | null }> { await this.throttle(url); const res = await ctx.fetch(url, { engines: ['api'], headers: JSON_HEADERS, timeoutMs: 240_000, minQuality: 0 }); if (!res.success || res.json === null || res.json === undefined) return { data: null, status: res.httpStatus ?? null, fetchedAt: res.fetchedAt, error: res.error ?? `HTTP ${res.httpStatus}` }; const parsed = schema.safeParse(res.json); if (!parsed.success) return { data: null, status: res.httpStatus ?? null, fetchedAt: res.fetchedAt, error: `schema: ${parsed.error.issues[0]?.path.join('.')} ${parsed.error.issues[0]?.message}` }; return { data: parsed.data, status: res.httpStatus ?? null, fetchedAt: res.fetchedAt, error: null }; } async *crawl(ctx: CrawlContext): AsyncIterable { let games = this.games(); if (ctx.options.seeds?.length) games = games.filter(([id]) => ctx.options.seeds!.includes(String(id))); const includeNonSingles = this.meta.config.includeNonSingles !== false; const onlyPriced = this.meta.config.onlyPriced !== false; let gameIdx = Number(ctx.options.cursor?.gameIdx ?? 0); let offset = Number(ctx.options.cursor?.offset ?? 0); let count = 0; let processed = 0; for (; gameIdx < games.length; gameIdx++, offset = 0) { if (ctx.signal?.aborted) return; const [gameId, game] = games[gameIdx]!; const guideUrl = `${BASE}/priceGuide/price_guide_${gameId}.json`; const guide = await this.file(ctx, guideUrl, PriceGuideFileSchema); if (!guide.data) { ctx.anomaly('page_fetch_failed', `${guideUrl}: ${guide.error}`); continue; } const prices = new Map(); let badRows = 0; for (const row of guide.data.priceGuides) { const p = PriceRowSchema.safeParse(row); if (p.success) prices.set(p.data.idProduct, p.data); else badRows++; } if (badRows) ctx.anomaly('schema_drift', `price_guide_${gameId}: ${badRows} unparseable rows`); const priceGuideCreatedAt = guide.data.createdAt ?? null; const lists: Array<{ product: CardmarketProduct; single: boolean }> = []; let productListCreatedAt: string | null = null; const kinds: Array<['singles' | 'nonsingles', boolean]> = includeNonSingles ? [['singles', true], ['nonsingles', false]] : [['singles', true]]; for (const [kind, single] of kinds) { const url = `${BASE}/productList/products_${kind}_${gameId}.json`; const f = await this.file(ctx, url, ProductFileSchema); if (!f.data) { ctx.anomaly('page_fetch_failed', `${url}: ${f.error}`); continue; } productListCreatedAt = productListCreatedAt ?? f.data.createdAt ?? null; let bad = 0; for (const raw of f.data.products) { const p = ProductSchema.safeParse(raw); if (!p.success) { bad++; continue; } lists.push({ product: p.data, single }); } if (bad) ctx.anomaly('schema_drift', `products_${kind}_${gameId}: ${bad} unparseable products`); } const items = onlyPriced ? lists.filter((x) => hasAnyPrice(prices.get(x.product.idProduct)) || hasAnyPrice(prices.get(x.product.idProduct), true)) : lists; ctx.log.info({ gameId, products: lists.length, priced: items.length, createdAt: priceGuideCreatedAt }, 'cardmarket bulk files loaded'); for (; offset < items.length; offset++) { if (ctx.signal?.aborted) return; if (this.reached(ctx, count)) { await ctx.setCursor({ gameIdx, offset }); return; } const { product, single } = items[offset]!; count++; processed++; const payload: CardmarketPayload = { gameId, game, product, prices: prices.get(product.idProduct) ?? null, single, priceGuideCreatedAt, productListCreatedAt }; yield { url: `${guideUrl}#idProduct=${product.idProduct}`, externalId: String(product.idProduct), kind: 'catalog_item', engine: 'api', httpStatus: guide.status, payload, fetchedAt: guide.fetchedAt }; if (offset > 0 && offset % 2000 === 0) await ctx.setCursor({ gameIdx, offset: offset + 1 }); } await ctx.setCursor({ gameIdx: gameIdx + 1, offset: 0, priceGuideCreatedAt }); await ctx.progress({ page: gameIdx + 1, totalPages: games.length, itemsProcessed: processed }); } await ctx.setCursor({ gameIdx: 0, offset: 0, completedAt: new Date().toISOString(), done: true }); } async normalize(raw: RawRecordLike): Promise { const { gameId, game, product, prices, single, priceGuideCreatedAt } = RawPayloadSchema.parse(raw.payload); const parsed = parseProductName(product.name); const observedAt = raw.fetchedAt; const obsDate = isoDay(priceGuideCreatedAt) ?? dayOf(observedAt); const sealed = !single && SEALED_RE.test(product.categoryName); const priceKinds = new Set((this.meta.config.priceKinds as string[] | undefined) ?? PRICE_FIELDS.map(([f]) => f)); const ids: Record = { cardmarket_id: String(product.idProduct) }; if (single && product.idMetacard) ids.cardmarket_metacard_id = String(product.idMetacard); if (product.idExpansion) ids.cardmarket_expansion_id = String(product.idExpansion); const build = (variant: string | null) => attrs({ categorySlug: game.slug, franchise: game.franchise ?? null, brand: game.brand ?? null, name: parsed.name, number: parsed.number, variant, language: null, identifiers: ids, metadata: { cardmarket_game_id: gameId, cardmarket_game: game.name, cardmarket_category: product.categoryName, cardmarket_category_id: product.idCategory, cardmarket_expansion_id: product.idExpansion ?? null, single, sealed, version: parsed.version, disambiguation: parsed.disambiguation, date_added: product.dateAdded && !product.dateAdded.startsWith('0000') ? product.dateAdded : null, }, }); const out: NormalizedRecord[] = []; const variants: Array<{ variant: string | null; foil: boolean }> = [{ variant: null, foil: false }]; if (single && hasAnyPrice(prices, true)) variants.push({ variant: game.foilVariant, foil: true }); for (const { variant, foil } of variants) { const a = build(variant); const rawTitle = makeTitle({ name: parsed.name, number: parsed.number, variant }) + (parsed.disambiguation ? ` [${parsed.disambiguation}]` : '') + (single ? '' : ` — ${product.categoryName}`); out.push( catalogItem({ kind: 'catalog_item', connectorId: this.meta.id, sourceId: this.meta.sourceId, sourceUrl: raw.url, externalId: `${product.idProduct}${foil ? ':foil' : ''}`, rawTitle, imageUrls: [], attributes: a, condition: { completeness: sealed ? 'sealed' : null }, observedAt, confidence: 0.8, parserVersion: PARSER_VERSION, releaseDate: null, }), ); if (!prices) continue; for (const [field, priceKind] of PRICE_FIELDS) { if (!priceKinds.has(field)) continue; const key = foil ? (`${field}-foil` as keyof PriceRow) : field; const v = prices[key]; if (typeof v !== 'number' || !(v > 0)) continue; out.push( priceObservation({ kind: 'price_observation', connectorId: this.meta.id, sourceId: this.meta.sourceId, sourceUrl: raw.url, externalId: `${product.idProduct}:${foil ? 'foil' : 'base'}:${field}`, rawTitle, imageUrls: [], attributes: { ...a, metadata: { ...a.metadata, cardmarket_field: key, foil } }, condition: { completeness: sealed ? 'sealed' : null }, observedAt, confidence: 0.75, parserVersion: PARSER_VERSION, priceKind, price: v, currency: 'EUR', observationDate: obsDate, sampleSize: null, }), ); } } return out; } } export default (meta: ConnectorMeta) => new CardmarketPriceGuideConnector(meta);