SPB Git forge

spb/rareindex

Public
54commits 1branches 0releases
7.1 MBsize
maindefault branch
10 days agolast push
TypeScript 61.9% HTML 37.2% SQL 0.7%
5.3 KB · 95 lines typescript
Raw Blame History
1import { z } from 'zod';2import { BaseConnector, type ConnectorMeta, type CrawlContext, type RawRecordInput, type RawRecordLike } from '@rareindex/connectors';3import { type NormalizedRecord } from '@rareindex/shared';4import { attrs, catalogItem, makeTitle, withRetries } from '../_lib/shared.js';5import { UA_HEADERS } from '../_lib/tcg-shared.js';67/** Sorcery: Contested Realm — official catalog with per-printing finish/product/set data. */8const API = 'https://api.sorcerytcg.com/api';9const PARSER_VERSION = '1.0.0';1011const PrintingSchema = z.object({12  id: z.string(),13  slug: z.string(),14  printedAt: z.string().nullable().optional(),15  set: z.object({ name: z.string(), code: z.string().nullable().optional(), releasedAt: z.string().nullable().optional() }),16  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(),17});18const CardSchema = z.object({19  id: z.string(),20  name: z.string(),21  slug: z.string().nullable().optional(),22  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(),23  printings: z.array(PrintingSchema).default([]),24});25export type SorceryCard = z.infer<typeof CardSchema>;2627export function trimCard(raw: Record<string, unknown>): SorceryCard {28  const engine = (raw.engine ?? {}) as Record<string, unknown>;29  const printings = Array.isArray(raw.printings) ? (raw.printings as Record<string, unknown>[]).map((p) => {30    const meta = (p.meta ?? {}) as Record<string, unknown>;31    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 } };32  }) : [];33  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 });34}3536export class SorceryConnector extends BaseConnector {37  readonly version = '1.0.0';38  readonly parserVersion = PARSER_VERSION;39  protected override minIntervalMs = 1000;4041  async *crawl(ctx: CrawlContext): AsyncIterable<RawRecordInput> {42    await this.throttle();43    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);44    if (!res.success || !Array.isArray(res.json)) throw new Error(`sorcery-tcg: catalog unavailable (${res.error ?? res.httpStatus})`);45    let count = 0;46    for (const raw of res.json as Record<string, unknown>[]) {47      let card: SorceryCard;48      try {49        card = trimCard(raw);50      } catch (err) {51        ctx.anomaly('parse_failure_card', err instanceof Error ? err.message : String(err));52        continue;53      }54      if (ctx.options.seeds?.length && !card.printings.some((p) => ctx.options.seeds!.includes(p.set.code ?? p.set.name))) continue;55      if (this.reached(ctx, count)) return;56      count++;57      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 };58    }59    await ctx.setCursor({ updatedAt: new Date().toISOString(), cards: count });60  }6162  async normalize(raw: RawRecordLike): Promise<NormalizedRecord[]> {63    const { card } = z.object({ card: CardSchema }).parse(raw.payload);64    const out: NormalizedRecord[] = [];65    for (const p of card.printings) {66      const finish = (p.meta.finish ?? 'Standard').trim();67      const product = (p.meta.product ?? 'Booster').trim();68      const variantParts: string[] = [];69      if (/foil/i.test(finish)) variantParts.push('Foil');70      if (product && !/^booster$/i.test(product)) variantParts.push(product);71      const variant = variantParts.length ? variantParts.join(' · ') : null;72      const year = p.printedAt ? Number(p.printedAt.slice(0, 4)) || null : null;73      const a = attrs({74        categorySlug: 'other_tcg',75        franchise: 'Sorcery: Contested Realm',76        brand: "Erik's Curiosa",77        set: p.set.name,78        setCode: p.set.code ?? null,79        name: card.name,80        number: null,81        year,82        variant,83        language: 'English',84        rarity: card.engine?.rarity ?? null,85        identifiers: { sorcery_printing_id: p.id, sorcery_card_id: card.id, sorcery_printing_slug: p.slug },86        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 },87      });88      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 }));89    }90    return out;91  }92}9394export default (meta: ConnectorMeta) => new SorceryConnector(meta);95