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'; /** Sorcery: Contested Realm — official catalog with per-printing finish/product/set data. */ const API = 'https://api.sorcerytcg.com/api'; const PARSER_VERSION = '1.0.0'; const PrintingSchema = z.object({ id: z.string(), slug: z.string(), printedAt: z.string().nullable().optional(), set: z.object({ name: z.string(), code: z.string().nullable().optional(), releasedAt: z.string().nullable().optional() }), meta: z.object({ finish: z.string().nullable().optional(), product: z.string().nullable().optional(), artist: z.object({ name: z.string().nullable().optional() }).nullable().optional(), typeline: z.string().nullable().optional() }).loose(), }); const CardSchema = z.object({ id: z.string(), name: z.string(), slug: z.string().nullable().optional(), engine: z.object({ type: z.string().nullable().optional(), category: z.string().nullable().optional(), rarity: z.string().nullable().optional(), elements: z.array(z.string()).optional(), subtypes: z.array(z.string()).optional() }).loose().optional(), printings: z.array(PrintingSchema).default([]), }); export type SorceryCard = z.infer; export function trimCard(raw: Record): SorceryCard { const engine = (raw.engine ?? {}) as Record; const printings = Array.isArray(raw.printings) ? (raw.printings as Record[]).map((p) => { const meta = (p.meta ?? {}) as Record; return { id: p.id, slug: p.slug, printedAt: p.printedAt, set: p.set, meta: { finish: meta.finish, product: meta.product, artist: meta.artist, typeline: meta.typeline } }; }) : []; return CardSchema.parse({ id: raw.id, name: raw.name, slug: raw.slug, engine: { type: engine.type, category: engine.category, rarity: engine.rarity, elements: engine.elements, subtypes: engine.subtypes }, printings }); } export class SorceryConnector extends BaseConnector { readonly version = '1.0.0'; readonly parserVersion = PARSER_VERSION; protected override minIntervalMs = 1000; async *crawl(ctx: CrawlContext): AsyncIterable { await this.throttle(); const res = await withRetries(() => ctx.fetch(`${API}/cards`, { engines: ['api'], headers: UA_HEADERS, timeoutMs: 90_000 }), (r) => r.success && Array.isArray(r.json), 3, 3000); if (!res.success || !Array.isArray(res.json)) throw new Error(`sorcery-tcg: catalog unavailable (${res.error ?? res.httpStatus})`); let count = 0; for (const raw of res.json as Record[]) { let card: SorceryCard; try { card = trimCard(raw); } catch (err) { ctx.anomaly('parse_failure_card', err instanceof Error ? err.message : String(err)); continue; } if (ctx.options.seeds?.length && !card.printings.some((p) => ctx.options.seeds!.includes(p.set.code ?? p.set.name))) continue; if (this.reached(ctx, count)) return; count++; yield { url: `https://curiosa.io/cards/${card.slug ?? card.id}`, externalId: card.id, kind: 'catalog_item', engine: 'api', httpStatus: res.httpStatus, payload: { card }, fetchedAt: res.fetchedAt }; } await ctx.setCursor({ updatedAt: new Date().toISOString(), cards: count }); } async normalize(raw: RawRecordLike): Promise { const { card } = z.object({ card: CardSchema }).parse(raw.payload); const out: NormalizedRecord[] = []; for (const p of card.printings) { const finish = (p.meta.finish ?? 'Standard').trim(); const product = (p.meta.product ?? 'Booster').trim(); const variantParts: string[] = []; if (/foil/i.test(finish)) variantParts.push('Foil'); if (product && !/^booster$/i.test(product)) variantParts.push(product); const variant = variantParts.length ? variantParts.join(' · ') : null; const year = p.printedAt ? Number(p.printedAt.slice(0, 4)) || null : null; const a = attrs({ categorySlug: 'other_tcg', franchise: 'Sorcery: Contested Realm', brand: "Erik's Curiosa", set: p.set.name, setCode: p.set.code ?? null, name: card.name, number: null, year, variant, language: 'English', rarity: card.engine?.rarity ?? null, identifiers: { sorcery_printing_id: p.id, sorcery_card_id: card.id, sorcery_printing_slug: p.slug }, metadata: { type: card.engine?.type ?? null, category: card.engine?.category ?? null, elements: card.engine?.elements ?? [], subtypes: card.engine?.subtypes ?? [], finish, product, artist: p.meta.artist?.name ?? null, typeline: p.meta.typeline ?? null }, }); out.push(catalogItem({ kind: 'catalog_item', connectorId: this.meta.id, sourceId: this.meta.sourceId, sourceUrl: raw.url, externalId: p.id, rawTitle: makeTitle({ name: card.name, set: p.set.name, year, variant }), imageUrls: [], attributes: a, observedAt: raw.fetchedAt, confidence: 0.9, parserVersion: PARSER_VERSION, releaseDate: p.printedAt ? new Date(p.printedAt) : null })); } return out; } } export default (meta: ConnectorMeta) => new SorceryConnector(meta);