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, withRetries } from '../_lib/shared.js'; import { UA_HEADERS } from '../_lib/tcg-shared.js'; /** Grand Archive official index — editions with publisher-declared circulations (print runs). */ const API = 'https://api.gatcg.com'; const PARSER_VERSION = '1.0.0'; const CirculationSchema = z.object({ uuid: z.string().optional(), kind: z.string().nullable().optional(), foil: z.boolean().optional(), population: z.number().nullable().optional(), population_operator: z.string().nullable().optional(), printing: z.boolean().optional() }); const EditionSchema = z .object({ uuid: z.string(), slug: z.string().nullable().optional(), collector_number: z.string().nullable().optional(), rarity: z.number().nullable().optional(), illustrator: z.string().nullable().optional(), image: z.string().nullable().optional(), configuration: z.string().nullable().optional(), set: z.object({ id: z.string().optional(), name: z.string(), prefix: z.string().nullable().optional(), release_date: z.string().nullable().optional(), language: z.string().nullable().optional() }), circulations: z.array(CirculationSchema).default([]), }) .loose(); const CardSchema = z .object({ uuid: z.string(), name: z.string(), slug: z.string().nullable().optional(), element: z.string().nullable().optional(), types: z.array(z.string()).optional(), classes: z.array(z.string()).optional(), editions: z.array(EditionSchema).default([]), }) .loose(); export type GaCard = z.infer; const RARITY: Record = { 1: 'Common', 2: 'Uncommon', 3: 'Rare', 4: 'Super Rare', 5: 'Ultra Rare', 6: 'Promotional', 7: 'Collector Super Rare', 8: 'Collector Ultra Rare', 9: 'Collector Promo' }; export function trimCard(raw: Record): GaCard { const editions = Array.isArray(raw.editions) ? (raw.editions as Record[]).map((e) => ({ uuid: e.uuid, slug: e.slug, collector_number: e.collector_number, rarity: e.rarity, illustrator: e.illustrator, image: e.image, configuration: e.configuration, set: e.set && typeof e.set === 'object' ? { id: (e.set as Record).id, name: (e.set as Record).name, prefix: (e.set as Record).prefix, release_date: (e.set as Record).release_date, language: (e.set as Record).language } : undefined, circulations: Array.isArray(e.circulations) ? (e.circulations as Record[]).map((c) => ({ uuid: c.uuid, kind: c.kind, foil: c.foil, population: c.population, population_operator: c.population_operator, printing: c.printing })) : [], })) : []; return CardSchema.parse({ uuid: raw.uuid, name: raw.name, slug: raw.slug, element: raw.element, types: raw.types, classes: raw.classes, editions }); } export class GrandArchiveConnector extends BaseConnector { readonly version = '1.0.0'; readonly parserVersion = PARSER_VERSION; protected override minIntervalMs = 600; async *crawl(ctx: CrawlContext): AsyncIterable { const pageSize = Number(this.meta.config.pageSize ?? 50); let page = Number(ctx.options.cursor?.page ?? 1); let count = 0; for (; page < 400; page++) { if (ctx.signal?.aborted) return; await this.throttle(); const res = await withRetries(() => ctx.fetch(`${API}/cards/search?page=${page}&page_size=${pageSize}`, { engines: ['api'], headers: UA_HEADERS }), (r) => r.success && r.json !== null, 3, 2000); const body = res.json as { data?: unknown[]; has_more?: boolean } | null; if (!res.success || !Array.isArray(body?.data)) { ctx.anomaly('page_fetch_failed', `page ${page}: ${res.error ?? res.httpStatus}`); break; } for (const raw of body.data) { let card: GaCard; try { card = trimCard(raw as Record); } catch (err) { ctx.anomaly('parse_failure_card', `page ${page}: ${err instanceof Error ? err.message : String(err)}`); continue; } if (this.reached(ctx, count)) return; count++; yield { url: `https://index.gatcg.com/card/${card.slug ?? card.uuid}`, externalId: card.uuid, kind: 'catalog_item', engine: 'api', httpStatus: res.httpStatus, payload: { card }, fetchedAt: res.fetchedAt }; } await ctx.setCursor({ page: page + 1, updatedAt: new Date().toISOString() }); if (!body.has_more) break; } await ctx.setCursor({ page: 1, updatedAt: new Date().toISOString() }); } async normalize(raw: RawRecordLike): Promise { const { card } = z.object({ card: CardSchema }).parse(raw.payload); const out: NormalizedRecord[] = []; for (const ed of card.editions) { const y = ed.set.release_date ? Number(ed.set.release_date.slice(0, 4)) : NaN; const year = Number.isFinite(y) && y >= 1990 ? y : null; // epoch placeholders (1970) are not release years const images = ed.image ? [`${API}${ed.image.startsWith('/') ? '' : '/'}${ed.image}`] : []; const circulations = ed.circulations.length ? ed.circulations : [{ kind: 'NONFOIL', foil: false, population: null, population_operator: null }]; const seen = new Set(); for (const c of circulations) { const variant = c.foil || c.kind === 'FOIL' ? 'Foil' : null; const key = variant ?? ''; if (seen.has(key)) continue; seen.add(key); const exact = c.population_operator === '=' || c.population_operator === 'EXACT'; const a = attrs({ categorySlug: 'other_tcg', franchise: 'Grand Archive', brand: 'Weebs of the Shore', set: ed.set.name, setCode: ed.set.prefix ?? null, name: card.name, number: ed.collector_number ?? null, year, variant, language: ed.set.language === 'EN' || !ed.set.language ? 'English' : ed.set.language, rarity: ed.rarity ? (RARITY[ed.rarity] ?? String(ed.rarity)) : null, productionQuantity: exact && c.population ? c.population : null, identifiers: { gatcg_edition_id: ed.uuid, gatcg_card_id: card.uuid, ...(ed.slug ? { gatcg_slug: ed.slug } : {}) }, metadata: { element: card.element, types: card.types ?? [], classes: card.classes ?? [], illustrator: ed.illustrator, configuration: ed.configuration, population: c.population ?? null, population_operator: c.population_operator ?? null, population_exact: exact }, }); out.push(catalogItem({ kind: 'catalog_item', connectorId: this.meta.id, sourceId: this.meta.sourceId, sourceUrl: raw.url, externalId: `${ed.uuid}${variant ? `:${variant}` : ''}`, rawTitle: makeTitle({ name: card.name, set: ed.set.name, number: ed.collector_number ?? null, year, variant }), imageUrls: images, attributes: a, observedAt: raw.fetchedAt, confidence: 0.9, parserVersion: PARSER_VERSION, releaseDate: year && ed.set.release_date ? new Date(ed.set.release_date) : null })); } } return out; } } export default (meta: ConnectorMeta) => new GrandArchiveConnector(meta);