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%
7.6 KB · 145 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, num, priceObservation, withRetries } from '../_lib/shared.js';56/**7 * Lorcast connector — Disney Lorcana catalog (all sets, promos) with TCGplayer market prices8 * (usd / usd_foil). Open API, no key: https://lorcast.com/docs/api9 */10const API = 'https://api.lorcast.com/v0';11const HEADERS = { 'user-agent': 'RareIndexBot/0.1 (+https://www.rareindex.io/about/data)', accept: 'application/json' };12const PARSER_VERSION = '1.0.0';1314const SetSchema = z.object({ id: z.string(), name: z.string(), code: z.string(), released_at: z.string().nullable().optional(), prereleased_at: z.string().nullable().optional() });15const CardSchema = z16  .object({17    id: z.string(),18    name: z.string(),19    version: z.string().nullable().optional(),20    layout: z.string().optional(),21    released_at: z.string().nullable().optional(),22    image_uris: z.record(z.string(), z.record(z.string(), z.string())).optional(),23    ink: z.string().nullable().optional(),24    type: z.array(z.string()).optional(),25    rarity: z.string().optional(),26    illustrators: z.array(z.string()).optional(),27    collector_number: z.string(),28    lang: z.string().optional(),29    set: z.object({ id: z.string(), code: z.string(), name: z.string() }),30    tcgplayer_id: z.number().nullable().optional(),31    prices: z.object({ usd: z.number().nullable().optional(), usd_foil: z.number().nullable().optional() }).partial().nullable().optional(),32  })33  .loose();34type LorCard = z.infer<typeof CardSchema>;3536export function trimCard(raw: Record<string, unknown>): LorCard {37  const keep = ['id', 'name', 'version', 'layout', 'released_at', 'image_uris', 'ink', 'type', 'rarity', 'illustrators', 'collector_number', 'lang', 'set', 'tcgplayer_id', 'prices'];38  const out: Record<string, unknown> = {};39  for (const k of keep) if (raw[k] !== undefined) out[k] = raw[k];40  return CardSchema.parse(out);41}4243const RawPayloadSchema = z.object({ card: CardSchema, set: SetSchema });4445export class LorcastConnector extends BaseConnector {46  readonly version = '1.0.0';47  readonly parserVersion = PARSER_VERSION;48  protected override minIntervalMs = 250;4950  private async get(ctx: CrawlContext, url: string) {51    await this.throttle();52    return withRetries(() => ctx.fetch(url, { engines: ['api'], headers: HEADERS }), (r) => r.success && r.json !== null, 4, 1500);53  }5455  async *crawl(ctx: CrawlContext): AsyncIterable<RawRecordInput> {56    const setsRes = await this.get(ctx, `${API}/sets`);57    const setsJson = (setsRes.json as { results?: unknown[] } | null)?.results;58    if (!setsRes.success || !Array.isArray(setsJson)) throw new Error(`lorcast: sets unavailable (${setsRes.error ?? setsRes.httpStatus})`);59    const sets = z.array(SetSchema.loose()).parse(setsJson).filter((s) => !ctx.options.seeds?.length || ctx.options.seeds.includes(s.code));60    let setIndex = Number(ctx.options.cursor?.setIndex ?? 0);61    let count = 0;62    for (; setIndex < sets.length; setIndex++) {63      const set = sets[setIndex]!;64      if (ctx.signal?.aborted) return;65      const seenIds = new Set<string>();66      for (let page = 1; page < 50; page++) {67        const res = await this.get(ctx, `${API}/cards/search?q=${encodeURIComponent(`set:${set.code}`)}&page=${page}`);68        const results = (res.json as { results?: unknown[] } | null)?.results;69        if (!res.success || !Array.isArray(results) || results.length === 0) break;70        let fresh = 0;71        for (const raw of results) {72          let card: LorCard;73          try {74            card = trimCard(raw as Record<string, unknown>);75          } catch (err) {76            ctx.anomaly('parse_failure_card', `${set.code}: ${err instanceof Error ? err.message : String(err)}`);77            continue;78          }79          if (seenIds.has(card.id)) continue;80          seenIds.add(card.id);81          fresh++;82          if (this.reached(ctx, count)) return;83          count++;84          yield { url: `https://lorcast.com/cards/${card.id}`, externalId: card.id, kind: 'catalog_item', engine: 'api', httpStatus: res.httpStatus, payload: { card, set }, fetchedAt: res.fetchedAt };85        }86        if (fresh === 0) break; // API returned the same page again → no pagination87      }88      await ctx.setCursor({ setIndex: setIndex + 1, updatedAt: new Date().toISOString() });89    }90    await ctx.setCursor({ setIndex: 0, updatedAt: new Date().toISOString() });91  }9293  async normalize(raw: RawRecordLike): Promise<NormalizedRecord[]> {94    const { card, set } = RawPayloadSchema.parse(raw.payload);95    const year = set.released_at ? Number(set.released_at.slice(0, 4)) || null : card.released_at ? Number(card.released_at.slice(0, 4)) || null : null;96    const identifiers: Record<string, string> = { lorcast_id: card.id };97    if (card.tcgplayer_id) identifiers.tcgplayer_id = String(card.tcgplayer_id);98    const digital = card.image_uris?.digital;99    const images = digital ? [digital.large ?? digital.normal ?? digital.small].filter((x): x is string => Boolean(x)) : [];100    const rarity = card.rarity ? card.rarity.replace(/_/g, ' ') : null;101    const name = card.version ? `${card.name} - ${card.version}` : card.name;102    const build = (variant: string | null) =>103      attrs({104        categorySlug: 'disney_lorcana',105        franchise: 'Disney Lorcana',106        brand: 'Ravensburger',107        set: set.name,108        setCode: set.code,109        name,110        number: card.collector_number,111        year,112        variant,113        language: card.lang === 'en' || !card.lang ? 'English' : card.lang,114        rarity,115        identifiers,116        metadata: { ink: card.ink, type: card.type, illustrators: card.illustrators, layout: card.layout, lorcast_set: set.id },117      });118    const out: NormalizedRecord[] = [];119    const observedAt = raw.fetchedAt;120    const obsDate = new Date(Date.UTC(observedAt.getUTCFullYear(), observedAt.getUTCMonth(), observedAt.getUTCDate()));121    const isEnchanted = /enchanted/i.test(card.rarity ?? '');122    const variantsToEmit: Array<[string | null, number | null]> = [123      [isEnchanted ? 'Enchanted' : null, num(card.prices?.usd)],124      [isEnchanted ? 'Enchanted' : 'Foil', num(card.prices?.usd_foil)],125    ];126    const seen = new Set<string>();127    for (const [variant, price] of variantsToEmit) {128      const key = variant ?? '';129      const a = build(variant);130      const rawTitle = makeTitle({ name, set: set.name, number: card.collector_number, year, variant });131      if (!seen.has(key)) {132        seen.add(key);133        out.push(catalogItem({ kind: 'catalog_item', connectorId: this.meta.id, sourceId: this.meta.sourceId, sourceUrl: raw.url, externalId: `${card.id}${variant ? `:${variant}` : ''}`, rawTitle, imageUrls: images, attributes: a, observedAt, confidence: 0.9, parserVersion: PARSER_VERSION, releaseDate: set.released_at ? new Date(set.released_at) : null }));134      }135      if (price) {136        // Lorcast relays TCGplayer market prices without a timestamp → dated by fetch day, moderate confidence.137        out.push(priceObservation({ kind: 'price_observation', connectorId: this.meta.id, sourceId: this.meta.sourceId, sourceUrl: raw.url, externalId: `${card.id}:${variant ?? 'normal'}:market`, rawTitle, imageUrls: images, attributes: a, observedAt, confidence: 0.7, parserVersion: PARSER_VERSION, priceKind: 'market', price, currency: 'USD', observationDate: obsDate, sampleSize: null }));138      }139    }140    return out;141  }142}143144export default (meta: ConnectorMeta) => new LorcastConnector(meta);145