Connectors: TCG data wave (tcgcsv, Digimon, SWU, Grand Archive, Sorcery, MTGGoldfish, pokemonprice; agent K); registry 61 connectors; more deterministic identifiers
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
42 changed files +7,711 −356
added
connectors/api/_lib/capture-k.ts
+89 −0
@@ -0,0 +1,89 @@ | ||
| 1 | +/** | |
| 2 | + * Live capture + smoke for the wave-3 TCG connectors. Runs each connector in probe mode through the real | |
| 3 | + * router/crawl context, saves small fixtures and prints sample normalized records. | |
| 4 | + * Usage: pnpm tsx connectors/api/_lib/capture-k.ts [ids…] (default: all seven) | |
| 5 | + */ | |
| 6 | +import { createCrawlContext, createRouter, type RareIndexConnector, type RawRecordInput } from '@rareindex/connectors'; | |
| 7 | +import { localMeta } from './local-meta.js'; | |
| 8 | +import { saveFixture } from '@rareindex/connectors/testing'; | |
| 9 | +import { childLogger } from '@rareindex/shared'; | |
| 10 | + | |
| 11 | +interface Plan { | |
| 12 | + limit: number; | |
| 13 | + seeds?: string[]; | |
| 14 | + /** pick which raw records become fixtures: returns a fixture name or null */ | |
| 15 | + pick: (raw: RawRecordInput, i: number) => string | null; | |
| 16 | + kinds: string[]; | |
| 17 | + requiredFields: string[]; | |
| 18 | +} | |
| 19 | + | |
| 20 | +const PLANS: Record<string, Plan> = { | |
| 21 | + tcgcsv: { | |
| 22 | + limit: 40, | |
| 23 | + seeds: ['3'], | |
| 24 | + pick: (raw, i) => { | |
| 25 | + const p = raw.payload as { product: { name: string }; prices: unknown[] }; | |
| 26 | + if (i === 0) return 'pokemon-first-product'; | |
| 27 | + if (p.prices.length > 1 && /charizard|blastoise|venusaur/i.test(p.product.name)) return 'pokemon-multi-subtype'; | |
| 28 | + return null; | |
| 29 | + }, | |
| 30 | + kinds: ['catalog_item', 'price_observation'], | |
| 31 | + requiredFields: ['attributes.identifiers.tcgplayer_id', 'attributes.set'], | |
| 32 | + }, | |
| 33 | + digimoncard: { limit: 12, seeds: ['BT5'], pick: (_r, i) => (i === 0 ? 'bt5-first' : i === 5 ? 'bt5-sixth' : null), kinds: ['catalog_item'], requiredFields: ['attributes.identifiers.digimoncard_id', 'attributes.number'] }, | |
| 34 | + 'swu-db': { limit: 8, seeds: ['SOR'], pick: (_r, i) => (i === 0 ? 'sor-first' : i === 3 ? 'sor-fourth' : null), kinds: ['catalog_item', 'price_observation'], requiredFields: ['attributes.setCode', 'attributes.number'] }, | |
| 35 | + 'grand-archive': { limit: 6, pick: (_r, i) => (i === 0 ? 'page1-first' : i === 2 ? 'page1-third' : null), kinds: ['catalog_item'], requiredFields: ['attributes.identifiers.gatcg_edition_id'] }, | |
| 36 | + 'sorcery-tcg': { limit: 6, pick: (_r, i) => (i === 0 ? 'first' : i === 5 ? 'sixth' : null), kinds: ['catalog_item'], requiredFields: ['attributes.identifiers.sorcery_printing_id', 'attributes.set'] }, | |
| 37 | + mtggoldfish: { | |
| 38 | + limit: 400, | |
| 39 | + seeds: ['Limited+Edition+Alpha'], | |
| 40 | + pick: (raw) => { | |
| 41 | + const p = raw.payload as { kind: string; card: { display_name: string; foil?: boolean } }; | |
| 42 | + if (p.kind === 'card' && p.card.display_name === 'Black Lotus' && !p.card.foil) return 'lea-black-lotus'; | |
| 43 | + if (p.kind === 'history' && p.card.display_name === 'Black Lotus') return 'lea-black-lotus-history'; | |
| 44 | + if (p.kind === 'card' && p.card.display_name === 'Air Elemental') return 'lea-air-elemental'; | |
| 45 | + return null; | |
| 46 | + }, | |
| 47 | + kinds: ['catalog_item', 'price_observation'], | |
| 48 | + requiredFields: ['attributes.setCode', 'attributes.number'], | |
| 49 | + }, | |
| 50 | + pokemonprice: { limit: 3, seeds: ['base-set-1st-edition'], pick: (raw, i) => (i === 0 ? 'base-set-1st-edition-first' : i === 2 ? 'base-set-1st-edition-third' : null), kinds: ['catalog_item', 'price_observation'], requiredFields: ['attributes.set', 'attributes.number'] }, | |
| 51 | +}; | |
| 52 | + | |
| 53 | +const ids = process.argv.slice(2).length ? process.argv.slice(2) : Object.keys(PLANS); | |
| 54 | +const router = createRouter({ firecrawlApiKey: process.env.FIRECRAWL_API_KEY, scrapflyApiKey: process.env.SCRAPFLY_API_KEY }); | |
| 55 | +for (const id of ids) { | |
| 56 | + const plan = PLANS[id]; | |
| 57 | + if (!plan) { | |
| 58 | + console.log(`[${id}] no plan`); | |
| 59 | + continue; | |
| 60 | + } | |
| 61 | + const started = Date.now(); | |
| 62 | + const mod = (await import(`../${id}/index.ts`)) as { default: (m: ReturnType<typeof localMeta>) => RareIndexConnector }; | |
| 63 | + const metaJson = (await import(`../${id}/meta.json`, { with: { type: 'json' } })) as { default: unknown }; | |
| 64 | + const connector = mod.default(localMeta(metaJson.default)); | |
| 65 | + const ctx = createCrawlContext({ router, meta: connector.meta, options: { mode: 'probe', limit: plan.limit, ...(plan.seeds ? { seeds: plan.seeds } : {}) }, log: childLogger({ connector: id, level: 'warn' }) }); | |
| 66 | + let raws = 0; | |
| 67 | + let normalized = 0; | |
| 68 | + let printed = 0; | |
| 69 | + let saved = 0; | |
| 70 | + for await (const raw of connector.crawl(ctx)) { | |
| 71 | + const rawLike = { ...raw, externalId: raw.externalId ?? null, fetchedAt: raw.fetchedAt ?? new Date() }; | |
| 72 | + const out = await connector.normalize(rawLike); | |
| 73 | + normalized += out.length; | |
| 74 | + const name = plan.pick(raw, raws); | |
| 75 | + raws++; | |
| 76 | + if (name) { | |
| 77 | + saveFixture(id, name, { raw: rawLike, expect: { minCount: 1, kinds: plan.kinds, requiredFields: plan.requiredFields }, note: `Live capture ${new Date().toISOString().slice(0, 10)} from ${raw.url}` }); | |
| 78 | + saved++; | |
| 79 | + } | |
| 80 | + if (printed < 3) { | |
| 81 | + for (const r of out.slice(0, 3)) { | |
| 82 | + const summary = r.kind === 'price_observation' ? `${r.priceKind} ${r.price} ${r.currency} @ ${r.observationDate.toISOString().slice(0, 10)}${r.grade.grader ? ` [${r.grade.grader} ${r.grade.grade ?? ''}]` : ''}` : r.kind === 'catalog_item' ? `variant=${r.attributes.variant ?? '-'} set=${r.attributes.set ?? '-'} #${r.attributes.number ?? '-'} ids=${JSON.stringify(r.attributes.identifiers)}` : ''; | |
| 83 | + console.log(` [${id}] ${r.kind}: ${'rawTitle' in r ? r.rawTitle : ''} → ${summary}`); | |
| 84 | + } | |
| 85 | + printed++; | |
| 86 | + } | |
| 87 | + } | |
| 88 | + console.log(`[${id}] raw=${raws} normalized=${normalized} fixtures=${saved} engineStats=${JSON.stringify(ctx.engineStats)} anomalies=${ctx.anomalies.slice(0, 3).join(' | ')} ms=${Date.now() - started}`); | |
| 89 | +} | |
added
connectors/api/_lib/local-meta.ts
+6 −0
@@ -0,0 +1,6 @@ | ||
| 1 | +import { ConnectorMetaSchema, type ConnectorMeta } from '@rareindex/connectors'; | |
| 2 | + | |
| 3 | +/** Parse a connector's own meta.json (lets tests/smoke run before the registry is rebuilt). */ | |
| 4 | +export function localMeta(meta: unknown): ConnectorMeta { | |
| 5 | + return ConnectorMetaSchema.parse(meta); | |
| 6 | +} | |
added
connectors/api/_lib/tcg-shared.ts
+68 −0
@@ -0,0 +1,68 @@ | ||
| 1 | +/** | |
| 2 | + * Helpers shared by the TCG catalog/price connectors added in wave 3 (tcgcsv, digimoncard, swu-db, | |
| 3 | + * grand-archive, sorcery-tcg, mtggoldfish, pokemonprice). Kept inside connectors/api (not the framework). | |
| 4 | + */ | |
| 5 | + | |
| 6 | +export const UA_HEADERS = { 'user-agent': 'RareIndexBot/0.1 (+https://www.rareindex.io/about/data)', accept: 'application/json, text/html;q=0.9, */*;q=0.8' }; | |
| 7 | + | |
| 8 | +/** UTC midnight of a Date (observation dates are day-precise). */ | |
| 9 | +export function dayOf(d: Date): Date { | |
| 10 | + return new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate())); | |
| 11 | +} | |
| 12 | + | |
| 13 | +/** "2026-09-06T20:05:30+0000" | "2026-09-06" → UTC midnight Date, else null. */ | |
| 14 | +export function isoDay(s: string | null | undefined): Date | null { | |
| 15 | + if (!s) return null; | |
| 16 | + const m = s.match(/^(\d{4})-(\d{2})-(\d{2})/); | |
| 17 | + if (!m) return null; | |
| 18 | + const d = new Date(Date.UTC(Number(m[1]), Number(m[2]) - 1, Number(m[3]))); | |
| 19 | + return Number.isNaN(d.getTime()) ? null : d; | |
| 20 | +} | |
| 21 | + | |
| 22 | +/** | |
| 23 | + * TCGplayer "subTypeName" / finish label → RareIndex variant vocabulary shared with the API catalogs | |
| 24 | + * (pokemontcg: 'Holo', 'Reverse Holo', '1st Edition', '1st Edition Holo'; scryfall: 'Foil', 'Etched Foil'). | |
| 25 | + * Returns null for the base printing. | |
| 26 | + */ | |
| 27 | +export function variantFromSubType(subType: string | null | undefined): string | null { | |
| 28 | + if (!subType) return null; | |
| 29 | + const s = subType.trim().toLowerCase(); | |
| 30 | + if (!s || s === 'normal' || s === 'regular' || s === 'standard' || s === 'non-foil' || s === 'nonfoil' || s === 'unlimited') return null; | |
| 31 | + if (s === 'holofoil' || s === 'holo') return 'Holo'; | |
| 32 | + if (s === 'reverse holofoil' || s === 'reverse holo') return 'Reverse Holo'; | |
| 33 | + if (s === '1st edition holofoil' || s === '1st edition holo') return '1st Edition Holo'; | |
| 34 | + if (s === '1st edition normal' || s === '1st edition') return '1st Edition'; | |
| 35 | + if (s === 'unlimited holofoil') return 'Holo'; | |
| 36 | + if (s === 'foil') return 'Foil'; | |
| 37 | + if (s === 'etched foil' || s === 'foil etched') return 'Etched Foil'; | |
| 38 | + // Title-case anything else ("Hyperspace Foil", "Showcase", "Textured Foil") | |
| 39 | + return subType.trim().replace(/\s+/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase()); | |
| 40 | +} | |
| 41 | + | |
| 42 | +/** "PSA9" | "PSA 10" | "BGS9.5" | "CGC 9.8" | "Raw" → grader/grade. */ | |
| 43 | +export function parseCompactGrade(label: string | null | undefined): { grader: string | null; grade: string | null } { | |
| 44 | + if (!label) return { grader: null, grade: null }; | |
| 45 | + const s = label.trim(); | |
| 46 | + if (/^(raw|ungraded)$/i.test(s)) return { grader: 'raw', grade: null }; | |
| 47 | + const m = s.match(/^(PSA|BGS|CGC|SGC|TAG|ACE|CBCS)\s*(\d{1,2}(?:\.\d)?)$/i); | |
| 48 | + if (!m) return { grader: null, grade: null }; | |
| 49 | + return { grader: m[1]!.toLowerCase(), grade: m[2]! }; | |
| 50 | +} | |
| 51 | + | |
| 52 | +/** "101/102" → { number: "101", total: 102 }; "SV049" → { number: "SV049", total: null } */ | |
| 53 | +export function splitNumber(s: string | null | undefined): { number: string | null; total: number | null } { | |
| 54 | + if (!s) return { number: null, total: null }; | |
| 55 | + const t = s.trim(); | |
| 56 | + const m = t.match(/^([A-Za-z0-9-]+)\s*\/\s*([A-Za-z0-9-]+)$/); | |
| 57 | + if (m) { | |
| 58 | + const total = Number(m[2]); | |
| 59 | + return { number: m[1]!, total: Number.isFinite(total) ? total : null }; | |
| 60 | + } | |
| 61 | + return { number: t || null, total: null }; | |
| 62 | +} | |
| 63 | + | |
| 64 | +/** Upscale a TCGplayer CDN thumbnail (…_200w.jpg → …_400w.jpg). */ | |
| 65 | +export function tcgplayerImage(url: string | null | undefined): string[] { | |
| 66 | + if (!url) return []; | |
| 67 | + return [url.replace(/_200w\.jpg$/, '_400w.jpg')]; | |
| 68 | +} | |
added
connectors/api/digimoncard/index.test.ts
+29 −0
@@ -0,0 +1,29 @@ | ||
| 1 | +import { describe, expect, it } from 'vitest'; | |
| 2 | +import meta from './meta.json' with { type: 'json' }; | |
| 3 | +import { localMeta } from '../_lib/local-meta.js'; | |
| 4 | +import { listFixtures, loadFixture, runFixtureSuite } from '@rareindex/connectors/testing'; | |
| 5 | +import createConnector, { primarySet, setPrefix } from './index.js'; | |
| 6 | + | |
| 7 | +const connector = createConnector(localMeta(meta)); | |
| 8 | + | |
| 9 | +describe('digimoncard', () => { | |
| 10 | + runFixtureSuite(connector, it, expect); | |
| 11 | + | |
| 12 | + it('derives the primary set from the card number prefix', () => { | |
| 13 | + expect(setPrefix('BT5-103')).toEqual({ prefix: 'BT5', code: 'BT-05' }); | |
| 14 | + expect(setPrefix('ST1-03').code).toBe('ST-1'); | |
| 15 | + expect(primarySet('BT5-103', ['Promo', 'BT-05: Battle of Omni'])).toBe('BT-05: Battle of Omni'); | |
| 16 | + expect(primarySet('ST1-03', ['ST-1: Starter Deck Gaia Red', 'Trial Deck'])).toBe('ST-1: Starter Deck Gaia Red'); | |
| 17 | + }); | |
| 18 | + | |
| 19 | + it('emits digimon_tcg catalog items with tcgplayer ids when present', async () => { | |
| 20 | + const [name] = listFixtures('digimoncard'); | |
| 21 | + const out = await connector.normalize(loadFixture('digimoncard', name!).raw); | |
| 22 | + expect(out).toHaveLength(1); | |
| 23 | + const c = out[0]!; | |
| 24 | + if (c.kind !== 'catalog_item') throw new Error('expected catalog item'); | |
| 25 | + expect(c.attributes.categorySlug).toBe('digimon_tcg'); | |
| 26 | + expect(c.attributes.identifiers.digimoncard_id).toMatch(/^[A-Z]+\d*-\d+$/); | |
| 27 | + expect(c.imageUrls[0]).toContain('images.digimoncard.io'); | |
| 28 | + }); | |
| 29 | +}); | |
added
connectors/api/digimoncard/index.ts
+147 −0
@@ -0,0 +1,147 @@ | ||
| 1 | +import { z } from 'zod'; | |
| 2 | +import { BaseConnector, type ConnectorMeta, type CrawlContext, type RawRecordInput, type RawRecordLike } from '@rareindex/connectors'; | |
| 3 | +import { type NormalizedRecord } from '@rareindex/shared'; | |
| 4 | +import { attrs, catalogItem, makeTitle, withRetries } from '../_lib/shared.js'; | |
| 5 | +import { UA_HEADERS } from '../_lib/tcg-shared.js'; | |
| 6 | + | |
| 7 | +/** DigimonCard.io — full Digimon Card Game catalog in one public JSON call (no prices). */ | |
| 8 | +const API = 'https://digimoncard.io/api-public'; | |
| 9 | +const PARSER_VERSION = '1.0.0'; | |
| 10 | + | |
| 11 | +const CardSchema = z | |
| 12 | + .object({ | |
| 13 | + name: z.string(), | |
| 14 | + type: z.string().nullable().optional(), | |
| 15 | + id: z.string(), | |
| 16 | + color: z.string().nullable().optional(), | |
| 17 | + color2: z.string().nullable().optional(), | |
| 18 | + rarity: z.string().nullable().optional(), | |
| 19 | + stage: z.string().nullable().optional(), | |
| 20 | + attribute: z.string().nullable().optional(), | |
| 21 | + level: z.number().nullable().optional(), | |
| 22 | + dp: z.number().nullable().optional(), | |
| 23 | + artist: z.string().nullable().optional(), | |
| 24 | + series: z.string().nullable().optional(), | |
| 25 | + pretty_url: z.string().nullable().optional(), | |
| 26 | + date_added: z.string().nullable().optional(), | |
| 27 | + tcgplayer_name: z.string().nullable().optional(), | |
| 28 | + tcgplayer_id: z.number().nullable().optional(), | |
| 29 | + set_name: z.array(z.string()).nullable().optional(), | |
| 30 | + }) | |
| 31 | + .loose(); | |
| 32 | +export type DigimonCard = z.infer<typeof CardSchema>; | |
| 33 | + | |
| 34 | +export function trimCard(raw: Record<string, unknown>): DigimonCard { | |
| 35 | + const keep = ['name', 'type', 'id', 'color', 'color2', 'rarity', 'stage', 'attribute', 'level', 'dp', 'artist', 'series', 'pretty_url', 'date_added', 'tcgplayer_name', 'tcgplayer_id', 'set_name']; | |
| 36 | + const out: Record<string, unknown> = {}; | |
| 37 | + for (const k of keep) if (raw[k] !== undefined) out[k] = raw[k]; | |
| 38 | + return CardSchema.parse(out); | |
| 39 | +} | |
| 40 | + | |
| 41 | +/** "BT5-103" → { prefix: "BT5", code: "BT-05" }; "ST1-03" → { prefix "ST1", code "ST-1" }; "P-021" → { prefix "P", code "P" } */ | |
| 42 | +export function setPrefix(id: string): { prefix: string; code: string } { | |
| 43 | + const m = id.match(/^([A-Za-z]+)(\d*)-/); | |
| 44 | + if (!m) return { prefix: id, code: id }; | |
| 45 | + const letters = m[1]!.toUpperCase(); | |
| 46 | + const digits = m[2] ?? ''; | |
| 47 | + const code = digits ? `${letters}-${letters === 'BT' || letters === 'EX' ? digits.padStart(2, '0') : digits}` : letters; | |
| 48 | + return { prefix: `${letters}${digits}`, code }; | |
| 49 | +} | |
| 50 | + | |
| 51 | +/** Pick the primary set name for a card from its set list using the number prefix. */ | |
| 52 | +export function primarySet(id: string, sets: string[] | null | undefined): string | null { | |
| 53 | + if (!sets?.length) return null; | |
| 54 | + const { code, prefix } = setPrefix(id); | |
| 55 | + const letters = prefix.replace(/\d+$/, ''); | |
| 56 | + const digits = prefix.slice(letters.length); | |
| 57 | + const candidates = [code, `${letters}-${digits}`, `${letters}-${digits.padStart(2, '0')}`, `${letters}${digits}`]; | |
| 58 | + for (const s of sets) { | |
| 59 | + const head = s.split(':')[0]!.trim().toUpperCase(); | |
| 60 | + if (candidates.includes(head)) return s; | |
| 61 | + } | |
| 62 | + return sets[0] ?? null; | |
| 63 | +} | |
| 64 | + | |
| 65 | +export class DigimonCardConnector extends BaseConnector { | |
| 66 | + readonly version = '1.0.0'; | |
| 67 | + readonly parserVersion = PARSER_VERSION; | |
| 68 | + protected override minIntervalMs = 1000; | |
| 69 | + override readonly urlPatterns = [/digimoncard\.io\/card\/[a-z0-9-]+/i]; | |
| 70 | + | |
| 71 | + async *crawl(ctx: CrawlContext): AsyncIterable<RawRecordInput> { | |
| 72 | + await this.throttle(); | |
| 73 | + const res = await withRetries(() => ctx.fetch(`${API}/search?series=${encodeURIComponent('Digimon Card Game')}`, { engines: ['api'], headers: UA_HEADERS, timeoutMs: 120_000 }), (r) => r.success && Array.isArray(r.json), 3, 3000); | |
| 74 | + if (!res.success || !Array.isArray(res.json)) throw new Error(`digimoncard: catalog unavailable (${res.error ?? res.httpStatus})`); | |
| 75 | + const seen = new Map<string, number>(); | |
| 76 | + let count = 0; | |
| 77 | + for (const raw of res.json as Record<string, unknown>[]) { | |
| 78 | + let card: DigimonCard; | |
| 79 | + try { | |
| 80 | + card = trimCard(raw); | |
| 81 | + } catch (err) { | |
| 82 | + ctx.anomaly('parse_failure_card', err instanceof Error ? err.message : String(err)); | |
| 83 | + continue; | |
| 84 | + } | |
| 85 | + if (ctx.options.seeds?.length && !ctx.options.seeds.includes(setPrefix(card.id).prefix)) continue; | |
| 86 | + const n = (seen.get(card.id) ?? 0) + 1; | |
| 87 | + seen.set(card.id, n); | |
| 88 | + if (this.reached(ctx, count)) return; | |
| 89 | + count++; | |
| 90 | + yield { url: `https://digimoncard.io/card/${card.pretty_url ?? card.id.toLowerCase()}`, externalId: n > 1 ? `${card.id}#${n}` : card.id, kind: 'catalog_item', engine: 'api', httpStatus: res.httpStatus, payload: { card, alt: n }, fetchedAt: res.fetchedAt }; | |
| 91 | + } | |
| 92 | + await ctx.setCursor({ updatedAt: new Date().toISOString(), cards: count }); | |
| 93 | + } | |
| 94 | + | |
| 95 | + async lookup(url: string, ctx: CrawlContext): Promise<RawRecordInput[]> { | |
| 96 | + const m = url.match(/digimoncard\.io\/card\/([a-z0-9-]+)/i); | |
| 97 | + if (!m) return []; | |
| 98 | + const id = m[1]!.split('-').slice(-2).join('-').toUpperCase(); // pretty_url ends with the card number | |
| 99 | + const res = await ctx.fetch(`${API}/search?card=${encodeURIComponent(id)}`, { engines: ['api'], headers: UA_HEADERS }); | |
| 100 | + if (!res.success || !Array.isArray(res.json) || !res.json.length) return []; | |
| 101 | + const card = trimCard(res.json[0] as Record<string, unknown>); | |
| 102 | + return [{ url, externalId: card.id, kind: 'catalog_item', engine: 'api', httpStatus: res.httpStatus, payload: { card, alt: 1 }, fetchedAt: res.fetchedAt }]; | |
| 103 | + } | |
| 104 | + | |
| 105 | + async normalize(raw: RawRecordLike): Promise<NormalizedRecord[]> { | |
| 106 | + const { card, alt } = z.object({ card: CardSchema, alt: z.number().default(1) }).parse(raw.payload); | |
| 107 | + const set = primarySet(card.id, card.set_name); | |
| 108 | + const { code } = setPrefix(card.id); | |
| 109 | + const identifiers: Record<string, string> = { digimoncard_id: card.id }; | |
| 110 | + if (card.tcgplayer_id) identifiers.tcgplayer_id = String(card.tcgplayer_id); | |
| 111 | + const variant = alt > 1 ? `Alternate Art ${alt - 1}` : null; | |
| 112 | + const a = attrs({ | |
| 113 | + categorySlug: 'digimon_tcg', | |
| 114 | + franchise: 'Digimon', | |
| 115 | + brand: 'Bandai', | |
| 116 | + set, | |
| 117 | + setCode: code, | |
| 118 | + name: card.name, | |
| 119 | + number: card.id, | |
| 120 | + year: null, | |
| 121 | + variant, | |
| 122 | + language: 'English', | |
| 123 | + rarity: card.rarity ?? null, | |
| 124 | + identifiers, | |
| 125 | + metadata: { type: card.type, color: card.color, color2: card.color2, stage: card.stage, attribute: card.attribute, level: card.level, dp: card.dp, artist: card.artist, sets: card.set_name ?? [] }, | |
| 126 | + }); | |
| 127 | + const rawTitle = makeTitle({ name: card.name, set, number: card.id, variant }); | |
| 128 | + return [ | |
| 129 | + catalogItem({ | |
| 130 | + kind: 'catalog_item', | |
| 131 | + connectorId: this.meta.id, | |
| 132 | + sourceId: this.meta.sourceId, | |
| 133 | + sourceUrl: raw.url, | |
| 134 | + externalId: raw.externalId ?? card.id, | |
| 135 | + rawTitle, | |
| 136 | + imageUrls: [`https://images.digimoncard.io/images/cards/${card.id}.jpg`], | |
| 137 | + attributes: a, | |
| 138 | + observedAt: raw.fetchedAt, | |
| 139 | + confidence: 0.85, | |
| 140 | + parserVersion: PARSER_VERSION, | |
| 141 | + releaseDate: null, | |
| 142 | + }), | |
| 143 | + ]; | |
| 144 | + } | |
| 145 | +} | |
| 146 | + | |
| 147 | +export default (meta: ConnectorMeta) => new DigimonCardConnector(meta); | |
added
connectors/api/digimoncard/meta.json
+30 −0
@@ -0,0 +1,30 @@ | ||
| 1 | +{ | |
| 2 | + "id": "digimoncard", | |
| 3 | + "displayName": "DigimonCard.io (Digimon Card Game)", | |
| 4 | + "sourceId": "digimoncard", | |
| 5 | + "sourceName": "DigimonCard.io", | |
| 6 | + "sourceType": "catalog", | |
| 7 | + "sourceUrl": "https://digimoncard.io", | |
| 8 | + "module": "api/digimoncard", | |
| 9 | + "enginePriority": ["api"], | |
| 10 | + "categories": ["digimon_tcg"], | |
| 11 | + "regions": ["US", "JP"], | |
| 12 | + "languages": ["en"], | |
| 13 | + "currency": [], | |
| 14 | + "supportsListings": false, | |
| 15 | + "supportsSold": false, | |
| 16 | + "supportsAuctions": false, | |
| 17 | + "supportsImages": true, | |
| 18 | + "supportsCatalog": true, | |
| 19 | + "supportsPopulation": false, | |
| 20 | + "supportsLookup": true, | |
| 21 | + "refreshFrequencyMinutes": 10080, | |
| 22 | + "priority": "low", | |
| 23 | + "trustScore": 0.7, | |
| 24 | + "attributionRequired": true, | |
| 25 | + "termsUrl": "https://digimoncard.io/api-public/", | |
| 26 | + "accessNotes": "Free public JSON API (https://digimoncard.io/api-public/search?series=Digimon%20Card%20Game returns every card in one call; the documented '.php' paths 301 to the extensionless ones). Catalog only — no prices; TCGplayer product ids are included and used as the deterministic identifier (tcgplayer_id) so TCGCSV prices attach to the same assets. Images from images.digimoncard.io/images/cards/<id>.jpg. Primary set derived from the card number prefix (BT5 → 'BT-05' set entry).", | |
| 27 | + "enabled": true, | |
| 28 | + "schemaVersion": "1.0", | |
| 29 | + "config": {} | |
| 30 | +} | |
added
connectors/api/grand-archive/index.test.ts
+25 −0
@@ -0,0 +1,25 @@ | ||
| 1 | +import { describe, expect, it } from 'vitest'; | |
| 2 | +import meta from './meta.json' with { type: 'json' }; | |
| 3 | +import { localMeta } from '../_lib/local-meta.js'; | |
| 4 | +import { listFixtures, loadFixture, runFixtureSuite } from '@rareindex/connectors/testing'; | |
| 5 | +import createConnector from './index.js'; | |
| 6 | + | |
| 7 | +const connector = createConnector(localMeta(meta)); | |
| 8 | + | |
| 9 | +describe('grand-archive', () => { | |
| 10 | + runFixtureSuite(connector, it, expect); | |
| 11 | + | |
| 12 | + it('emits one catalog item per edition/finish with publisher populations', async () => { | |
| 13 | + const [name] = listFixtures('grand-archive'); | |
| 14 | + const out = await connector.normalize(loadFixture('grand-archive', name!).raw); | |
| 15 | + expect(out.length).toBeGreaterThanOrEqual(1); | |
| 16 | + for (const c of out) { | |
| 17 | + if (c.kind !== 'catalog_item') throw new Error('expected catalog item'); | |
| 18 | + expect(c.attributes.categorySlug).toBe('other_tcg'); | |
| 19 | + expect(c.attributes.franchise).toBe('Grand Archive'); | |
| 20 | + expect(c.attributes.identifiers.gatcg_edition_id).toBeTruthy(); | |
| 21 | + // production quantity only when the publisher states an exact count | |
| 22 | + if (c.attributes.productionQuantity !== null) expect(c.attributes.metadata.population_exact).toBe(true); | |
| 23 | + } | |
| 24 | + }); | |
| 25 | +}); | |
added
connectors/api/grand-archive/index.ts
+123 −0
@@ -0,0 +1,123 @@ | ||
| 1 | +import { z } from 'zod'; | |
| 2 | +import { BaseConnector, type ConnectorMeta, type CrawlContext, type RawRecordInput, type RawRecordLike } from '@rareindex/connectors'; | |
| 3 | +import { type NormalizedRecord } from '@rareindex/shared'; | |
| 4 | +import { attrs, catalogItem, makeTitle, withRetries } from '../_lib/shared.js'; | |
| 5 | +import { UA_HEADERS } from '../_lib/tcg-shared.js'; | |
| 6 | + | |
| 7 | +/** Grand Archive official index — editions with publisher-declared circulations (print runs). */ | |
| 8 | +const API = 'https://api.gatcg.com'; | |
| 9 | +const PARSER_VERSION = '1.0.0'; | |
| 10 | + | |
| 11 | +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() }); | |
| 12 | +const EditionSchema = z | |
| 13 | + .object({ | |
| 14 | + uuid: z.string(), | |
| 15 | + slug: z.string().nullable().optional(), | |
| 16 | + collector_number: z.string().nullable().optional(), | |
| 17 | + rarity: z.number().nullable().optional(), | |
| 18 | + illustrator: z.string().nullable().optional(), | |
| 19 | + image: z.string().nullable().optional(), | |
| 20 | + configuration: z.string().nullable().optional(), | |
| 21 | + 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() }), | |
| 22 | + circulations: z.array(CirculationSchema).default([]), | |
| 23 | + }) | |
| 24 | + .loose(); | |
| 25 | +const CardSchema = z | |
| 26 | + .object({ | |
| 27 | + uuid: z.string(), | |
| 28 | + name: z.string(), | |
| 29 | + slug: z.string().nullable().optional(), | |
| 30 | + element: z.string().nullable().optional(), | |
| 31 | + types: z.array(z.string()).optional(), | |
| 32 | + classes: z.array(z.string()).optional(), | |
| 33 | + editions: z.array(EditionSchema).default([]), | |
| 34 | + }) | |
| 35 | + .loose(); | |
| 36 | +export type GaCard = z.infer<typeof CardSchema>; | |
| 37 | + | |
| 38 | +const RARITY: Record<number, string> = { 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' }; | |
| 39 | + | |
| 40 | +export function trimCard(raw: Record<string, unknown>): GaCard { | |
| 41 | + const editions = Array.isArray(raw.editions) ? (raw.editions as Record<string, unknown>[]).map((e) => ({ | |
| 42 | + uuid: e.uuid, slug: e.slug, collector_number: e.collector_number, rarity: e.rarity, illustrator: e.illustrator, image: e.image, configuration: e.configuration, | |
| 43 | + set: e.set && typeof e.set === 'object' ? { id: (e.set as Record<string, unknown>).id, name: (e.set as Record<string, unknown>).name, prefix: (e.set as Record<string, unknown>).prefix, release_date: (e.set as Record<string, unknown>).release_date, language: (e.set as Record<string, unknown>).language } : undefined, | |
| 44 | + circulations: Array.isArray(e.circulations) ? (e.circulations as Record<string, unknown>[]).map((c) => ({ uuid: c.uuid, kind: c.kind, foil: c.foil, population: c.population, population_operator: c.population_operator, printing: c.printing })) : [], | |
| 45 | + })) : []; | |
| 46 | + return CardSchema.parse({ uuid: raw.uuid, name: raw.name, slug: raw.slug, element: raw.element, types: raw.types, classes: raw.classes, editions }); | |
| 47 | +} | |
| 48 | + | |
| 49 | +export class GrandArchiveConnector extends BaseConnector { | |
| 50 | + readonly version = '1.0.0'; | |
| 51 | + readonly parserVersion = PARSER_VERSION; | |
| 52 | + protected override minIntervalMs = 600; | |
| 53 | + | |
| 54 | + async *crawl(ctx: CrawlContext): AsyncIterable<RawRecordInput> { | |
| 55 | + const pageSize = Number(this.meta.config.pageSize ?? 50); | |
| 56 | + let page = Number(ctx.options.cursor?.page ?? 1); | |
| 57 | + let count = 0; | |
| 58 | + for (; page < 400; page++) { | |
| 59 | + if (ctx.signal?.aborted) return; | |
| 60 | + await this.throttle(); | |
| 61 | + 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); | |
| 62 | + const body = res.json as { data?: unknown[]; has_more?: boolean } | null; | |
| 63 | + if (!res.success || !Array.isArray(body?.data)) { | |
| 64 | + ctx.anomaly('page_fetch_failed', `page ${page}: ${res.error ?? res.httpStatus}`); | |
| 65 | + break; | |
| 66 | + } | |
| 67 | + for (const raw of body.data) { | |
| 68 | + let card: GaCard; | |
| 69 | + try { | |
| 70 | + card = trimCard(raw as Record<string, unknown>); | |
| 71 | + } catch (err) { | |
| 72 | + ctx.anomaly('parse_failure_card', `page ${page}: ${err instanceof Error ? err.message : String(err)}`); | |
| 73 | + continue; | |
| 74 | + } | |
| 75 | + if (this.reached(ctx, count)) return; | |
| 76 | + count++; | |
| 77 | + 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 }; | |
| 78 | + } | |
| 79 | + await ctx.setCursor({ page: page + 1, updatedAt: new Date().toISOString() }); | |
| 80 | + if (!body.has_more) break; | |
| 81 | + } | |
| 82 | + await ctx.setCursor({ page: 1, updatedAt: new Date().toISOString() }); | |
| 83 | + } | |
| 84 | + | |
| 85 | + async normalize(raw: RawRecordLike): Promise<NormalizedRecord[]> { | |
| 86 | + const { card } = z.object({ card: CardSchema }).parse(raw.payload); | |
| 87 | + const out: NormalizedRecord[] = []; | |
| 88 | + for (const ed of card.editions) { | |
| 89 | + const y = ed.set.release_date ? Number(ed.set.release_date.slice(0, 4)) : NaN; | |
| 90 | + const year = Number.isFinite(y) && y >= 1990 ? y : null; // epoch placeholders (1970) are not release years | |
| 91 | + const images = ed.image ? [`${API}${ed.image.startsWith('/') ? '' : '/'}${ed.image}`] : []; | |
| 92 | + const circulations = ed.circulations.length ? ed.circulations : [{ kind: 'NONFOIL', foil: false, population: null, population_operator: null }]; | |
| 93 | + const seen = new Set<string>(); | |
| 94 | + for (const c of circulations) { | |
| 95 | + const variant = c.foil || c.kind === 'FOIL' ? 'Foil' : null; | |
| 96 | + const key = variant ?? ''; | |
| 97 | + if (seen.has(key)) continue; | |
| 98 | + seen.add(key); | |
| 99 | + const exact = c.population_operator === '=' || c.population_operator === 'EXACT'; | |
| 100 | + const a = attrs({ | |
| 101 | + categorySlug: 'other_tcg', | |
| 102 | + franchise: 'Grand Archive', | |
| 103 | + brand: 'Weebs of the Shore', | |
| 104 | + set: ed.set.name, | |
| 105 | + setCode: ed.set.prefix ?? null, | |
| 106 | + name: card.name, | |
| 107 | + number: ed.collector_number ?? null, | |
| 108 | + year, | |
| 109 | + variant, | |
| 110 | + language: ed.set.language === 'EN' || !ed.set.language ? 'English' : ed.set.language, | |
| 111 | + rarity: ed.rarity ? (RARITY[ed.rarity] ?? String(ed.rarity)) : null, | |
| 112 | + productionQuantity: exact && c.population ? c.population : null, | |
| 113 | + identifiers: { gatcg_edition_id: ed.uuid, gatcg_card_id: card.uuid, ...(ed.slug ? { gatcg_slug: ed.slug } : {}) }, | |
| 114 | + 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 }, | |
| 115 | + }); | |
| 116 | + 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 })); | |
| 117 | + } | |
| 118 | + } | |
| 119 | + return out; | |
| 120 | + } | |
| 121 | +} | |
| 122 | + | |
| 123 | +export default (meta: ConnectorMeta) => new GrandArchiveConnector(meta); | |
added
connectors/api/grand-archive/meta.json
+30 −0
@@ -0,0 +1,30 @@ | ||
| 1 | +{ | |
| 2 | + "id": "grand-archive", | |
| 3 | + "displayName": "Grand Archive Index (official card API)", | |
| 4 | + "sourceId": "grand-archive", | |
| 5 | + "sourceName": "Grand Archive TCG Index", | |
| 6 | + "sourceType": "manufacturer", | |
| 7 | + "sourceUrl": "https://index.gatcg.com", | |
| 8 | + "module": "api/grand-archive", | |
| 9 | + "enginePriority": ["api"], | |
| 10 | + "categories": ["other_tcg"], | |
| 11 | + "regions": ["US"], | |
| 12 | + "languages": ["en"], | |
| 13 | + "currency": [], | |
| 14 | + "supportsListings": false, | |
| 15 | + "supportsSold": false, | |
| 16 | + "supportsAuctions": false, | |
| 17 | + "supportsImages": true, | |
| 18 | + "supportsCatalog": true, | |
| 19 | + "supportsPopulation": true, | |
| 20 | + "supportsLookup": false, | |
| 21 | + "refreshFrequencyMinutes": 10080, | |
| 22 | + "priority": "low", | |
| 23 | + "trustScore": 0.85, | |
| 24 | + "attributionRequired": true, | |
| 25 | + "termsUrl": "https://index.gatcg.com", | |
| 26 | + "accessNotes": "Official Grand Archive card index API (https://api.gatcg.com/cards/search, paginated, no key). Emits one catalog_item per edition × circulation (non-foil / foil) with the publisher's declared print run in `productionQuantity` when the operator is exact ('=') and in metadata when approximate ('≈') — a rare case of manufacturer-published production counts. No prices (TCGCSV supplies Grand Archive prices via tcgplayer_id when available).", | |
| 27 | + "enabled": true, | |
| 28 | + "schemaVersion": "1.0", | |
| 29 | + "config": { "pageSize": 50 } | |
| 30 | +} | |
added
connectors/api/mtggoldfish/index.test.ts
+39 −0
@@ -0,0 +1,39 @@ | ||
| 1 | +import { describe, expect, it } from 'vitest'; | |
| 2 | +import meta from './meta.json' with { type: 'json' }; | |
| 3 | +import { localMeta } from '../_lib/local-meta.js'; | |
| 4 | +import { listFixtures, loadFixture, runFixtureSuite } from '@rareindex/connectors/testing'; | |
| 5 | +import createConnector, { parseHistory, parseSetPage } from './index.js'; | |
| 6 | + | |
| 7 | +const connector = createConnector(localMeta(meta)); | |
| 8 | + | |
| 9 | +describe('mtggoldfish', () => { | |
| 10 | + runFixtureSuite(connector, it, expect); | |
| 11 | + | |
| 12 | + it('parses the embedded set JSON and history series', () => { | |
| 13 | + const html = `<title>Limited Edition Alpha (LEA) Price Guide | MTGGoldfish</title><div data-x="{"cards":[{"name":"Black Lotus [LEA]","set":"LEA","display_name":"Black Lotus","rarity":"Rare","foil":false,"card_num":232,"finish":"regular","card_uuid":"u1","prices":{"paper":{"current_price":"12345.5"},"online":{"current_price":null}},"links":{"default":"/price/limited-edition-alpha/232/black-lotus"},"source_image_variants":{"672x938":"https://img/x.webp"}}],"view_type":"grid"}"></div>`; | |
| 14 | + const { setName, cards } = parseSetPage(html); | |
| 15 | + expect(setName).toBe('Limited Edition Alpha'); | |
| 16 | + expect(cards).toHaveLength(1); | |
| 17 | + expect(cards[0]).toMatchObject({ set: 'LEA', display_name: 'Black Lotus', card_num: 232, paper: 12345.5, card_uuid: 'u1' }); | |
| 18 | + expect(parseHistory('var d = "";\nd += "2010-11-02, 3102.99\\n";\nd += "2010-11-03, 3110\\n";')).toEqual([['2010-11-02', 3102.99], ['2010-11-03', 3110]]); | |
| 19 | + }); | |
| 20 | + | |
| 21 | + it('emits magic catalog items keyed like Scryfall (set code + collector number) with USD observations', async () => { | |
| 22 | + const names = listFixtures('mtggoldfish'); | |
| 23 | + const card = names.find((n) => !n.includes('history')) ?? names[0]!; | |
| 24 | + const out = await connector.normalize(loadFixture('mtggoldfish', card).raw); | |
| 25 | + const c = out.find((r) => r.kind === 'catalog_item'); | |
| 26 | + if (!c || c.kind !== 'catalog_item') throw new Error('expected catalog item'); | |
| 27 | + expect(c.attributes.categorySlug).toBe('magic_the_gathering'); | |
| 28 | + expect(c.attributes.setCode).toMatch(/^[A-Z0-9]{2,6}$/); | |
| 29 | + expect(c.attributes.number).toBeTruthy(); | |
| 30 | + for (const o of out) if (o.kind === 'price_observation') expect(o.currency).toBe('USD'); | |
| 31 | + const history = names.find((n) => n.includes('history')); | |
| 32 | + if (history) { | |
| 33 | + const h = await connector.normalize(loadFixture('mtggoldfish', history).raw); | |
| 34 | + expect(h.length).toBeGreaterThan(10); | |
| 35 | + const dates = h.map((r) => (r.kind === 'price_observation' ? r.observationDate.toISOString().slice(0, 10) : '')); | |
| 36 | + expect(new Set(dates).size).toBe(dates.length); | |
| 37 | + } | |
| 38 | + }); | |
| 39 | +}); | |
added
connectors/api/mtggoldfish/index.ts
+214 −0
@@ -0,0 +1,214 @@ | ||
| 1 | +import { z } from 'zod'; | |
| 2 | +import { BaseConnector, html as htmlUtil, type ConnectorMeta, type CrawlContext, type RawRecordInput, type RawRecordLike } from '@rareindex/connectors'; | |
| 3 | +import { type NormalizedRecord } from '@rareindex/shared'; | |
| 4 | +import { attrs, catalogItem, makeTitle, num, priceObservation, withRetries } from '../_lib/shared.js'; | |
| 5 | +import { dayOf, isoDay } from '../_lib/tcg-shared.js'; | |
| 6 | + | |
| 7 | +/** | |
| 8 | + * MTGGoldfish — set pages embed a JSON card list with current paper/online prices; the public | |
| 9 | + * price-history component returns a daily paper price series (2010 →) per printing. | |
| 10 | + */ | |
| 11 | +const BASE = 'https://www.mtggoldfish.com'; | |
| 12 | +const PARSER_VERSION = '1.0.0'; | |
| 13 | +const HEADERS = { 'user-agent': 'RareIndexBot/0.1 (+https://www.rareindex.io/about/data)', accept: 'text/html,application/xhtml+xml' }; | |
| 14 | + | |
| 15 | +const CardSchema = z.object({ | |
| 16 | + name: z.string(), // "Black Lotus [LEA]" — MTGGoldfish card id | |
| 17 | + set: z.string(), | |
| 18 | + display_name: z.string(), | |
| 19 | + rarity: z.string().nullable().optional(), | |
| 20 | + foil: z.boolean().optional(), | |
| 21 | + card_num: z.union([z.number(), z.string()]).nullable().optional(), | |
| 22 | + finish: z.string().nullable().optional(), | |
| 23 | + card_uuid: z.string().nullable().optional(), | |
| 24 | + paper: z.number().nullable(), | |
| 25 | + online: z.number().nullable(), | |
| 26 | + link: z.string().nullable(), | |
| 27 | + image: z.string().nullable(), | |
| 28 | +}); | |
| 29 | +type Card = z.infer<typeof CardSchema>; | |
| 30 | + | |
| 31 | +const SetPayloadSchema = z.object({ kind: z.literal('card'), setName: z.string().nullable(), setSlug: z.string(), card: CardSchema }); | |
| 32 | +const HistoryPayloadSchema = z.object({ kind: z.literal('history'), setName: z.string().nullable(), card: CardSchema, series: z.array(z.tuple([z.string(), z.number()])) }); | |
| 33 | + | |
| 34 | +/** Parse the escaped JSON card list embedded in a set page. */ | |
| 35 | +export function parseSetPage(html: string): { setName: string | null; cards: Card[] } { | |
| 36 | + const idx = html.indexOf('"cards":['); | |
| 37 | + if (idx < 0) return { setName: titleOf(html), cards: [] }; | |
| 38 | + const start = html.lastIndexOf('="', idx) + 2; | |
| 39 | + const end = html.indexOf('"', start); | |
| 40 | + const raw = html.slice(start, end); | |
| 41 | + const decoded = raw.replace(/"/g, '"').replace(/'/g, "'").replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>'); | |
| 42 | + let json: { cards?: Array<Record<string, unknown>> }; | |
| 43 | + try { | |
| 44 | + json = JSON.parse(decoded) as { cards?: Array<Record<string, unknown>> }; | |
| 45 | + } catch { | |
| 46 | + return { setName: titleOf(html), cards: [] }; | |
| 47 | + } | |
| 48 | + const cards: Card[] = []; | |
| 49 | + for (const c of json.cards ?? []) { | |
| 50 | + const prices = (c.prices ?? {}) as Record<string, { current_price?: string | null } | null>; | |
| 51 | + const images = (c.source_image_variants ?? {}) as Record<string, string>; | |
| 52 | + const links = (c.links ?? {}) as Record<string, string>; | |
| 53 | + const parsed = CardSchema.safeParse({ | |
| 54 | + name: c.name, | |
| 55 | + set: c.set, | |
| 56 | + display_name: c.display_name, | |
| 57 | + rarity: c.rarity, | |
| 58 | + foil: c.foil, | |
| 59 | + card_num: c.card_num, | |
| 60 | + finish: c.finish, | |
| 61 | + card_uuid: c.card_uuid, | |
| 62 | + paper: num(prices.paper?.current_price), | |
| 63 | + online: num(prices.online?.current_price), | |
| 64 | + link: links.default ?? null, | |
| 65 | + image: images['672x938'] ?? images['265x370'] ?? null, | |
| 66 | + }); | |
| 67 | + if (parsed.success) cards.push(parsed.data); | |
| 68 | + } | |
| 69 | + return { setName: titleOf(html), cards }; | |
| 70 | +} | |
| 71 | + | |
| 72 | +function titleOf(html: string): string | null { | |
| 73 | + const m = html.match(/<title>([^<]+)<\/title>/); | |
| 74 | + if (!m) return null; | |
| 75 | + return m[1]!.replace(/\s*[|·-]\s*MTGGoldfish.*$/i, '').replace(/\s*Price(s| Guide).*$/i, '').replace(/\s*\([A-Z0-9]{2,6}\)\s*$/, '').trim() || null; | |
| 76 | +} | |
| 77 | + | |
| 78 | +/** "d += '2010-11-02, 3102.99\n'" lines → [[date, price], …] */ | |
| 79 | +export function parseHistory(js: string): Array<[string, number]> { | |
| 80 | + const out: Array<[string, number]> = []; | |
| 81 | + for (const m of js.matchAll(/(\d{4}-\d{2}-\d{2}),\s*([0-9.]+)/g)) { | |
| 82 | + const price = Number(m[2]); | |
| 83 | + if (Number.isFinite(price) && price > 0) out.push([m[1]!, price]); | |
| 84 | + } | |
| 85 | + return out; | |
| 86 | +} | |
| 87 | + | |
| 88 | +export class MtgGoldfishConnector extends BaseConnector { | |
| 89 | + readonly version = '1.0.0'; | |
| 90 | + readonly parserVersion = PARSER_VERSION; | |
| 91 | + protected override minIntervalMs = 1500; | |
| 92 | + override readonly urlPatterns = [/mtggoldfish\.com\/price\/[^/]+\/\d+\//i, /mtggoldfish\.com\/sets\/[^/?#]+/i]; | |
| 93 | + | |
| 94 | + private async page(ctx: CrawlContext, url: string) { | |
| 95 | + await this.throttle(); | |
| 96 | + return withRetries(() => ctx.fetch(url, { engines: ['api', 'firecrawl'], headers: HEADERS, responseType: 'text', minQuality: 0.3 }), (r) => r.success && Boolean(r.html || r.markdown), 3, 2500); | |
| 97 | + } | |
| 98 | + | |
| 99 | + private async discoverSets(ctx: CrawlContext): Promise<string[]> { | |
| 100 | + const seeds = (ctx.options.seeds?.length ? ctx.options.seeds : (this.meta.config.seeds as string[] | undefined)) ?? []; | |
| 101 | + if (seeds.length) return seeds; | |
| 102 | + const res = await this.page(ctx, `${BASE}/sets`); | |
| 103 | + const text = res.html ?? ''; | |
| 104 | + const slugs = [...new Set([...text.matchAll(/href="\/sets\/([^"#?]+)"/g)].map((m) => m[1]!))]; | |
| 105 | + return slugs; | |
| 106 | + } | |
| 107 | + | |
| 108 | + async *crawl(ctx: CrawlContext): AsyncIterable<RawRecordInput> { | |
| 109 | + const sets = await this.discoverSets(ctx); | |
| 110 | + const maxSets = ctx.options.mode === 'backfill' ? Infinity : Number(this.meta.config.maxSetsPerRun ?? 60); | |
| 111 | + const hist = (this.meta.config.history ?? {}) as { enabled?: boolean; minPrice?: number; maxPerRun?: number; days?: number }; | |
| 112 | + let setIdx = Number(ctx.options.cursor?.setIdx ?? 0); | |
| 113 | + if (setIdx >= sets.length) setIdx = 0; | |
| 114 | + let done = 0; | |
| 115 | + let count = 0; | |
| 116 | + let historyFetched = 0; | |
| 117 | + for (; setIdx < sets.length && done < maxSets; setIdx++, done++) { | |
| 118 | + const slug = sets[setIdx]!; | |
| 119 | + const res = await this.page(ctx, `${BASE}/sets/${slug}`); | |
| 120 | + if (!res.success || !res.html) { | |
| 121 | + ctx.anomaly('page_fetch_failed', `${slug}: ${res.error ?? res.httpStatus}`); | |
| 122 | + continue; | |
| 123 | + } | |
| 124 | + const { setName, cards } = parseSetPage(res.html); | |
| 125 | + if (!cards.length) ctx.anomaly('empty_page', `set ${slug}`); | |
| 126 | + for (const card of cards) { | |
| 127 | + if (this.reached(ctx, count)) return; | |
| 128 | + count++; | |
| 129 | + const url = card.link ? `${BASE}${card.link}` : `${BASE}/sets/${slug}`; | |
| 130 | + yield { url, externalId: card.card_uuid ?? card.name, kind: 'catalog_item', engine: res.engine, httpStatus: res.httpStatus, payload: { kind: 'card', setName, setSlug: slug, card }, fetchedAt: res.fetchedAt }; | |
| 131 | + if (hist.enabled !== false && card.paper !== null && card.paper >= Number(hist.minPrice ?? 100) && historyFetched < Number(hist.maxPerRun ?? 150) && !card.foil) { | |
| 132 | + historyFetched++; | |
| 133 | + const q = new URLSearchParams({ card_id: card.name, selector: '#tab-paper', type: 'paper', price_type: '' }); | |
| 134 | + const h = await this.page(ctx, `${BASE}/price_history_component?${q.toString()}`); | |
| 135 | + if (h.success && h.html) { | |
| 136 | + const cutoff = Date.now() - Number(hist.days ?? 1095) * 86_400_000; | |
| 137 | + const series = parseHistory(h.html).filter(([d]) => new Date(`${d}T00:00:00Z`).getTime() >= cutoff); | |
| 138 | + if (series.length) yield { url, externalId: `${card.card_uuid ?? card.name}:history`, kind: 'price_observation', engine: h.engine, httpStatus: h.httpStatus, payload: { kind: 'history', setName, card, series }, fetchedAt: h.fetchedAt }; | |
| 139 | + } else ctx.anomaly('page_fetch_failed', `history ${card.name}: ${h.error ?? h.httpStatus}`); | |
| 140 | + } | |
| 141 | + } | |
| 142 | + await ctx.setCursor({ setIdx: setIdx + 1, updatedAt: new Date().toISOString() }); | |
| 143 | + } | |
| 144 | + if (setIdx >= sets.length) await ctx.setCursor({ setIdx: 0, updatedAt: new Date().toISOString() }); | |
| 145 | + } | |
| 146 | + | |
| 147 | + async lookup(url: string, ctx: CrawlContext): Promise<RawRecordInput[]> { | |
| 148 | + const m = url.match(/mtggoldfish\.com\/price\/([^/]+)\/(\d+)\//i); | |
| 149 | + if (!m) return []; | |
| 150 | + // Card pages do not embed the JSON list; resolve through the set page. | |
| 151 | + const res = await this.page(ctx, `${BASE}/sets/${m[1]}`); | |
| 152 | + if (!res.success || !res.html) return []; | |
| 153 | + const { setName, cards } = parseSetPage(res.html); | |
| 154 | + const card = cards.find((c) => String(c.card_num) === m[2] && !c.foil) ?? cards.find((c) => String(c.card_num) === m[2]); | |
| 155 | + if (!card) return []; | |
| 156 | + return [{ url, externalId: card.card_uuid ?? card.name, kind: 'catalog_item', engine: res.engine, httpStatus: res.httpStatus, payload: { kind: 'card', setName, setSlug: m[1]!, card }, fetchedAt: res.fetchedAt }]; | |
| 157 | + } | |
| 158 | + | |
| 159 | + private attrsFor(card: Card, setName: string | null) { | |
| 160 | + const variant = card.foil || /foil/i.test(card.finish ?? '') ? (/etched/i.test(card.finish ?? '') ? 'Etched Foil' : 'Foil') : null; | |
| 161 | + return { | |
| 162 | + variant, | |
| 163 | + a: attrs({ | |
| 164 | + categorySlug: 'magic_the_gathering', | |
| 165 | + franchise: 'Magic: The Gathering', | |
| 166 | + brand: 'Wizards of the Coast', | |
| 167 | + set: setName ?? card.set, | |
| 168 | + setCode: card.set.toUpperCase(), | |
| 169 | + name: card.display_name, | |
| 170 | + number: card.card_num !== null && card.card_num !== undefined ? String(card.card_num) : null, | |
| 171 | + year: null, | |
| 172 | + variant, | |
| 173 | + language: 'English', | |
| 174 | + rarity: card.rarity ?? null, | |
| 175 | + identifiers: { mtggoldfish_card_id: card.name, ...(card.card_uuid ? { mtggoldfish_uuid: card.card_uuid } : {}) }, | |
| 176 | + metadata: { finish: card.finish ?? null, online_price: card.online }, | |
| 177 | + }), | |
| 178 | + }; | |
| 179 | + } | |
| 180 | + | |
| 181 | + async normalize(raw: RawRecordLike): Promise<NormalizedRecord[]> { | |
| 182 | + const payload = raw.payload as { kind?: string }; | |
| 183 | + if (payload.kind === 'history') { | |
| 184 | + const { card, setName, series } = HistoryPayloadSchema.parse(raw.payload); | |
| 185 | + const { a, variant } = this.attrsFor(card, setName); | |
| 186 | + const rawTitle = makeTitle({ name: card.display_name, set: setName ?? card.set, number: card.card_num !== null && card.card_num !== undefined ? String(card.card_num) : null, variant }); | |
| 187 | + const out: NormalizedRecord[] = []; | |
| 188 | + const seen = new Set<string>(); | |
| 189 | + for (const [date, price] of series) { | |
| 190 | + if (seen.has(date)) continue; | |
| 191 | + seen.add(date); | |
| 192 | + const day = isoDay(date); | |
| 193 | + if (!day) continue; | |
| 194 | + out.push(priceObservation({ kind: 'price_observation', connectorId: this.meta.id, sourceId: this.meta.sourceId, sourceUrl: raw.url, externalId: `${card.card_uuid ?? card.name}:paper:${date}`, rawTitle, imageUrls: card.image ? [card.image] : [], attributes: a, observedAt: raw.fetchedAt, confidence: 0.7, parserVersion: PARSER_VERSION, priceKind: 'market', price, currency: 'USD', observationDate: day, sampleSize: null })); | |
| 195 | + } | |
| 196 | + return out; | |
| 197 | + } | |
| 198 | + const { card, setName } = SetPayloadSchema.parse(raw.payload); | |
| 199 | + const { a, variant } = this.attrsFor(card, setName); | |
| 200 | + const number = card.card_num !== null && card.card_num !== undefined ? String(card.card_num) : null; | |
| 201 | + const rawTitle = makeTitle({ name: card.display_name, set: setName ?? card.set, number, variant }); | |
| 202 | + const images = card.image ? [card.image] : []; | |
| 203 | + const out: NormalizedRecord[] = [ | |
| 204 | + catalogItem({ kind: 'catalog_item', connectorId: this.meta.id, sourceId: this.meta.sourceId, sourceUrl: raw.url, externalId: card.card_uuid ?? card.name, rawTitle, imageUrls: images, attributes: a, observedAt: raw.fetchedAt, confidence: 0.85, parserVersion: PARSER_VERSION, releaseDate: null }), | |
| 205 | + ]; | |
| 206 | + if (card.paper) { | |
| 207 | + out.push(priceObservation({ kind: 'price_observation', connectorId: this.meta.id, sourceId: this.meta.sourceId, sourceUrl: raw.url, externalId: `${card.card_uuid ?? card.name}:paper:current`, rawTitle, imageUrls: images, attributes: a, observedAt: raw.fetchedAt, confidence: 0.75, parserVersion: PARSER_VERSION, priceKind: 'market', price: card.paper, currency: 'USD', observationDate: dayOf(raw.fetchedAt), sampleSize: null })); | |
| 208 | + } | |
| 209 | + return out; | |
| 210 | + } | |
| 211 | +} | |
| 212 | + | |
| 213 | +export default (meta: ConnectorMeta) => new MtgGoldfishConnector(meta); | |
| 214 | +export { htmlUtil as _html }; | |
added
connectors/api/mtggoldfish/meta.json
+34 −0
@@ -0,0 +1,34 @@ | ||
| 1 | +{ | |
| 2 | + "id": "mtggoldfish", | |
| 3 | + "displayName": "MTGGoldfish (paper prices & history)", | |
| 4 | + "sourceId": "mtggoldfish", | |
| 5 | + "sourceName": "MTGGoldfish", | |
| 6 | + "sourceType": "pricing_guide", | |
| 7 | + "sourceUrl": "https://www.mtggoldfish.com", | |
| 8 | + "module": "api/mtggoldfish", | |
| 9 | + "enginePriority": ["api", "firecrawl"], | |
| 10 | + "categories": ["magic_the_gathering"], | |
| 11 | + "regions": ["US"], | |
| 12 | + "languages": ["en"], | |
| 13 | + "currency": ["USD"], | |
| 14 | + "supportsListings": false, | |
| 15 | + "supportsSold": false, | |
| 16 | + "supportsAuctions": false, | |
| 17 | + "supportsImages": true, | |
| 18 | + "supportsCatalog": true, | |
| 19 | + "supportsPopulation": false, | |
| 20 | + "supportsLookup": true, | |
| 21 | + "refreshFrequencyMinutes": 1440, | |
| 22 | + "priority": "medium", | |
| 23 | + "trustScore": 0.75, | |
| 24 | + "attributionRequired": true, | |
| 25 | + "termsUrl": "https://www.mtggoldfish.com/robots.txt", | |
| 26 | + "accessNotes": "Public set pages (https://www.mtggoldfish.com/sets/<Set+Name>) embed a JSON payload with every printing (card_uuid, set code, collector number, finish, current paper/online price, images). robots.txt allows '/' for generic agents (only widgets/embeds are disallowed; Content-Signal ai-train=no is respected — data is used as market reference, not for training). Paper price = TCGplayer-derived market price → price_observation 'market' dated by fetch day (confidence 0.75). For cards above `history.minPrice` the public price-history component (/price_history_component, daily series since 2010) is fetched, capped per run → dated observations that give assets a real multi-year guide-price history. Politeness 1.5 s/page; ~340 sets.", | |
| 27 | + "enabled": true, | |
| 28 | + "schemaVersion": "1.0", | |
| 29 | + "config": { | |
| 30 | + "seeds": [], | |
| 31 | + "maxSetsPerRun": 60, | |
| 32 | + "history": { "enabled": true, "minPrice": 100, "maxPerRun": 150, "days": 1095 } | |
| 33 | + } | |
| 34 | +} | |
added
connectors/api/pokemonprice/index.test.ts
+41 −0
@@ -0,0 +1,41 @@ | ||
| 1 | +import { describe, expect, it } from 'vitest'; | |
| 2 | +import meta from './meta.json' with { type: 'json' }; | |
| 3 | +import { localMeta } from '../_lib/local-meta.js'; | |
| 4 | +import { listFixtures, loadFixture, runFixtureSuite } from '@rareindex/connectors/testing'; | |
| 5 | +import createConnector, { extractJson, setAndVariant } from './index.js'; | |
| 6 | +import { parseCompactGrade } from '../_lib/tcg-shared.js'; | |
| 7 | + | |
| 8 | +const connector = createConnector(localMeta(meta)); | |
| 9 | + | |
| 10 | +describe('pokemonprice', () => { | |
| 11 | + runFixtureSuite(connector, it, expect); | |
| 12 | + | |
| 13 | + it('aligns set names and variants with the pokemontcg vocabulary', () => { | |
| 14 | + expect(setAndVariant('Base Set 1st Edition', 'Holo 1st edition')).toEqual({ set: 'Base Set', variant: '1st Edition Holo' }); | |
| 15 | + expect(setAndVariant('Base Set Unlimited', 'Holo')).toEqual({ set: 'Base Set', variant: 'Holo' }); | |
| 16 | + expect(setAndVariant('Base Set Shadowless', null)).toEqual({ set: 'Base Set', variant: 'Shadowless' }); | |
| 17 | + expect(setAndVariant('Evolving Skies', null)).toEqual({ set: 'Evolving Skies', variant: null }); | |
| 18 | + expect(parseCompactGrade('PSA9')).toEqual({ grader: 'psa', grade: '9' }); | |
| 19 | + expect(parseCompactGrade('Raw')).toEqual({ grader: 'raw', grade: null }); | |
| 20 | + expect(extractJson('x "rows":[{"a":[1,2]},{"b":"]"}] y', 'rows')).toEqual([{ a: [1, 2] }, { b: ']' }]); | |
| 21 | + }); | |
| 22 | + | |
| 23 | + it('emits per-grade guide values with grader/grade and transaction counts', async () => { | |
| 24 | + const [name] = listFixtures('pokemonprice'); | |
| 25 | + const out = await connector.normalize(loadFixture('pokemonprice', name!).raw); | |
| 26 | + const cat = out.find((r) => r.kind === 'catalog_item'); | |
| 27 | + if (!cat || cat.kind !== 'catalog_item') throw new Error('expected catalog item'); | |
| 28 | + expect(cat.attributes.categorySlug).toBe('pokemon'); | |
| 29 | + expect(cat.attributes.set).toBeTruthy(); | |
| 30 | + expect(cat.attributes.number).toMatch(/^\d+/); | |
| 31 | + const obs = out.filter((r) => r.kind === 'price_observation'); | |
| 32 | + expect(obs.length).toBeGreaterThan(3); | |
| 33 | + const graded = obs.filter((o) => o.kind === 'price_observation' && o.grade.grader && o.grade.grader !== 'raw'); | |
| 34 | + expect(graded.length).toBeGreaterThan(0); | |
| 35 | + for (const o of obs) { | |
| 36 | + if (o.kind !== 'price_observation') continue; | |
| 37 | + expect(o.currency).toBe('USD'); | |
| 38 | + expect(o.confidence).toBeLessThanOrEqual(0.8); | |
| 39 | + } | |
| 40 | + }); | |
| 41 | +}); | |
added
connectors/api/pokemonprice/index.ts
+262 −0
@@ -0,0 +1,262 @@ | ||
| 1 | +import { z } from 'zod'; | |
| 2 | +import { BaseConnector, type ConnectorMeta, type CrawlContext, type RawRecordInput, type RawRecordLike } from '@rareindex/connectors'; | |
| 3 | +import { type NormalizedRecord } from '@rareindex/shared'; | |
| 4 | +import { attrs, catalogItem, makeTitle, num, priceObservation, withRetries } from '../_lib/shared.js'; | |
| 5 | +import { dayOf, isoDay, parseCompactGrade } from '../_lib/tcg-shared.js'; | |
| 6 | + | |
| 7 | +/** PokemonPrice.com — per-grade fair-value model for Pokémon cards, embedded in server-rendered pages. */ | |
| 8 | +const BASE = 'https://www.pokemonprice.com'; | |
| 9 | +const PARSER_VERSION = '1.0.0'; | |
| 10 | +const HEADERS = { 'user-agent': 'RareIndexBot/0.1 (+https://www.rareindex.io/about/data)', accept: 'text/html,application/xhtml+xml' }; | |
| 11 | + | |
| 12 | +const GradeRowSchema = z.object({ | |
| 13 | + grade: z.string(), | |
| 14 | + fair_price: z.union([z.string(), z.number()]).nullable().optional(), | |
| 15 | + low_price: z.union([z.string(), z.number()]).nullable().optional(), | |
| 16 | + high_price: z.union([z.string(), z.number()]).nullable().optional(), | |
| 17 | + confidence: z.union([z.string(), z.number()]).nullable().optional(), | |
| 18 | + last_sale_date: z.string().nullable().optional(), | |
| 19 | +}); | |
| 20 | +const TxSchema = z.object({ month: z.string(), grade: z.string(), count: z.number() }); | |
| 21 | +const PayloadSchema = z.object({ | |
| 22 | + slug: z.string(), | |
| 23 | + setSlug: z.string(), | |
| 24 | + name: z.string(), | |
| 25 | + tag: z.string().nullable(), | |
| 26 | + number: z.string().nullable(), | |
| 27 | + total: z.string().nullable(), | |
| 28 | + setName: z.string().nullable(), | |
| 29 | + image: z.string().nullable(), | |
| 30 | + rows: z.array(GradeRowSchema), | |
| 31 | + transactions: z.array(TxSchema), | |
| 32 | +}); | |
| 33 | +export type PokemonPricePayload = z.infer<typeof PayloadSchema>; | |
| 34 | + | |
| 35 | +/** Decode the RSC flight strings of a Next.js app-router page into one searchable string. */ | |
| 36 | +export function flightText(html: string): string { | |
| 37 | + const parts: string[] = []; | |
| 38 | + for (const m of html.matchAll(/self\.__next_f\.push\(\[1,"((?:[^"\\]|\\.)*)"\]\)/g)) { | |
| 39 | + try { | |
| 40 | + parts.push(JSON.parse(`"${m[1]}"`) as string); | |
| 41 | + } catch { | |
| 42 | + /* skip undecodable chunk */ | |
| 43 | + } | |
| 44 | + } | |
| 45 | + return parts.join(''); | |
| 46 | +} | |
| 47 | + | |
| 48 | +/** Extract a balanced JSON array/object that starts right after `key":`. */ | |
| 49 | +export function extractJson(text: string, key: string): unknown | null { | |
| 50 | + const idx = text.indexOf(`"${key}":`); | |
| 51 | + if (idx < 0) return null; | |
| 52 | + let i = text.indexOf(':', idx) + 1; | |
| 53 | + while (text[i] === ' ') i++; | |
| 54 | + const open = text[i]; | |
| 55 | + if (open !== '[' && open !== '{') return null; | |
| 56 | + const close = open === '[' ? ']' : '}'; | |
| 57 | + let depth = 0; | |
| 58 | + let inStr = false; | |
| 59 | + for (let j = i; j < text.length; j++) { | |
| 60 | + const ch = text[j]!; | |
| 61 | + if (inStr) { | |
| 62 | + if (ch === '\\') j++; | |
| 63 | + else if (ch === '"') inStr = false; | |
| 64 | + continue; | |
| 65 | + } | |
| 66 | + if (ch === '"') inStr = true; | |
| 67 | + else if (ch === open) depth++; | |
| 68 | + else if (ch === close) { | |
| 69 | + depth--; | |
| 70 | + if (depth === 0) { | |
| 71 | + try { | |
| 72 | + return JSON.parse(text.slice(i, j + 1)); | |
| 73 | + } catch { | |
| 74 | + return null; | |
| 75 | + } | |
| 76 | + } | |
| 77 | + } | |
| 78 | + } | |
| 79 | + return null; | |
| 80 | +} | |
| 81 | + | |
| 82 | +export function parseCardPage(html: string, url: string): PokemonPricePayload | null { | |
| 83 | + const h1 = html.match(/<h1[^>]*>([\s\S]*?)<\/h1>/); | |
| 84 | + const spans = h1 ? [...h1[1]!.matchAll(/<span[^>]*>([^<]*)<\/span>/g)].map((m) => m[1]!.trim()).filter(Boolean) : []; | |
| 85 | + const titleSpan = spans[0] ?? ''; | |
| 86 | + const nm = titleSpan.match(/^(.*?)\s*(?:\[(.+?)\])?$/); | |
| 87 | + const name = (nm?.[1] ?? titleSpan).trim(); | |
| 88 | + const tag = nm?.[2]?.trim() ?? null; | |
| 89 | + const numSpan = spans.find((s) => /^\d+[A-Za-z]?\s*\/\s*\w+$/.test(s) || /^[A-Za-z0-9]+\s*\/\s*[A-Za-z0-9]+$/.test(s)); | |
| 90 | + const [number, total] = numSpan ? numSpan.split('/').map((s) => s.trim()) : [null, null]; | |
| 91 | + const setName = spans.filter((s) => s !== titleSpan && s !== numSpan).pop() ?? null; | |
| 92 | + const image = html.match(/https:\/\/cdn\.pokemonprice\.com\/cards\/[^"\\\s]+/)?.[0] ?? null; | |
| 93 | + const text = flightText(html); | |
| 94 | + const rowsRaw = extractJson(text, 'rows'); | |
| 95 | + const rows = Array.isArray(rowsRaw) ? rowsRaw.filter((r) => r && typeof r === 'object' && 'grade' in (r as object) && 'fair_price' in (r as object)).map((r) => { | |
| 96 | + const o = r as Record<string, unknown>; | |
| 97 | + return { grade: String(o.grade), fair_price: o.fair_price as string, low_price: o.low_price as string, high_price: o.high_price as string, confidence: o.confidence as string, last_sale_date: (o.last_sale_date as string) ?? null }; | |
| 98 | + }) : []; | |
| 99 | + const txRaw = extractJson(text, 'transactions'); | |
| 100 | + const transactions = Array.isArray(txRaw) ? txRaw.filter((t) => t && typeof t === 'object' && 'month' in (t as object)).map((t) => ({ month: String((t as Record<string, unknown>).month), grade: String((t as Record<string, unknown>).grade), count: Number((t as Record<string, unknown>).count) || 0 })) : []; | |
| 101 | + if (!name) return null; | |
| 102 | + const parts = new URL(url).pathname.split('/').filter(Boolean); | |
| 103 | + const parsed = PayloadSchema.safeParse({ slug: parts.slice(-1)[0] ?? '', setSlug: parts[0] ?? '', name, tag, number: number ?? null, total: total ?? null, setName, image, rows, transactions }); | |
| 104 | + return parsed.success ? parsed.data : null; | |
| 105 | +} | |
| 106 | + | |
| 107 | +/** "Base Set 1st Edition" + tag "Holo 1st edition" → { set: "Base Set", variant: "1st Edition Holo" } */ | |
| 108 | +export function setAndVariant(setName: string | null, tag: string | null): { set: string | null; variant: string | null } { | |
| 109 | + let set = setName?.trim() ?? null; | |
| 110 | + const flags = new Set<string>(); | |
| 111 | + if (set) { | |
| 112 | + const m = set.match(/^(.*?)\s+(1st Edition|Shadowless|Unlimited)$/i); | |
| 113 | + if (m) { | |
| 114 | + set = m[1]!.trim(); | |
| 115 | + const f = m[2]!.toLowerCase(); | |
| 116 | + if (f === '1st edition') flags.add('1st Edition'); | |
| 117 | + if (f === 'shadowless') flags.add('Shadowless'); | |
| 118 | + } | |
| 119 | + } | |
| 120 | + for (const t of (tag ?? '').split(/[,·]/).map((s) => s.trim().toLowerCase()).filter(Boolean)) { | |
| 121 | + if (t.includes('reverse')) flags.add('Reverse Holo'); | |
| 122 | + else if (t.includes('1st') && t.includes('holo')) { | |
| 123 | + flags.add('1st Edition'); | |
| 124 | + flags.add('Holo'); | |
| 125 | + } else if (t.includes('1st')) flags.add('1st Edition'); | |
| 126 | + else if (t.includes('holo')) flags.add('Holo'); | |
| 127 | + else if (t.includes('shadowless')) flags.add('Shadowless'); | |
| 128 | + else if (t !== 'unlimited') flags.add(t.replace(/\b\w/g, (c) => c.toUpperCase())); | |
| 129 | + } | |
| 130 | + const ordered: string[] = []; | |
| 131 | + if (flags.has('1st Edition') && flags.has('Holo')) ordered.push('1st Edition Holo'); | |
| 132 | + else { | |
| 133 | + if (flags.has('1st Edition')) ordered.push('1st Edition'); | |
| 134 | + if (flags.has('Holo')) ordered.push('Holo'); | |
| 135 | + } | |
| 136 | + if (flags.has('Shadowless')) ordered.push('Shadowless'); | |
| 137 | + if (flags.has('Reverse Holo')) ordered.push('Reverse Holo'); | |
| 138 | + for (const f of flags) if (!['1st Edition', 'Holo', 'Shadowless', 'Reverse Holo'].includes(f)) ordered.push(f); | |
| 139 | + return { set, variant: ordered.length ? ordered.join(' · ') : null }; | |
| 140 | +} | |
| 141 | + | |
| 142 | +export class PokemonPriceConnector extends BaseConnector { | |
| 143 | + readonly version = '1.0.0'; | |
| 144 | + readonly parserVersion = PARSER_VERSION; | |
| 145 | + protected override minIntervalMs = 1500; | |
| 146 | + override readonly urlPatterns = [/pokemonprice\.com\/[a-z0-9-]+\/[a-z0-9-]+$/i]; | |
| 147 | + | |
| 148 | + private async page(ctx: CrawlContext, url: string) { | |
| 149 | + await this.throttle(); | |
| 150 | + return withRetries(() => ctx.fetch(url, { engines: ['api', 'firecrawl'], headers: HEADERS, responseType: 'text', minQuality: 0.3 }), (r) => r.success && Boolean(r.html), 3, 2500); | |
| 151 | + } | |
| 152 | + | |
| 153 | + async *crawl(ctx: CrawlContext): AsyncIterable<RawRecordInput> { | |
| 154 | + const seeds = (ctx.options.seeds?.length ? ctx.options.seeds : (this.meta.config.seeds as string[] | undefined)) ?? []; | |
| 155 | + let sets = seeds; | |
| 156 | + if (!sets.length) { | |
| 157 | + const idx = await this.page(ctx, `${BASE}/card-lists`); | |
| 158 | + sets = [...new Set([...(idx.html ?? '').matchAll(/href="\/card-lists\/([a-z0-9-]+)"/g)].map((m) => m[1]!))]; | |
| 159 | + } | |
| 160 | + const maxCards = ctx.options.mode === 'backfill' ? Infinity : Number(this.meta.config.maxCardsPerRun ?? 300); | |
| 161 | + let setIdx = Number(ctx.options.cursor?.setIdx ?? 0); | |
| 162 | + if (setIdx >= sets.length) setIdx = 0; | |
| 163 | + let count = 0; | |
| 164 | + for (; setIdx < sets.length; setIdx++) { | |
| 165 | + const setSlug = sets[setIdx]!; | |
| 166 | + const cardSlugs: string[] = []; | |
| 167 | + for (let offset = 0; offset < 1200; offset += 30) { | |
| 168 | + const list = await this.page(ctx, `${BASE}/card-lists/${setSlug}${offset ? `?offset=${offset}` : ''}`); | |
| 169 | + if (!list.success || !list.html) { | |
| 170 | + ctx.anomaly('page_fetch_failed', `list ${setSlug}@${offset}: ${list.error ?? list.httpStatus}`); | |
| 171 | + break; | |
| 172 | + } | |
| 173 | + const found = [...new Set([...list.html.matchAll(new RegExp(`href="/${setSlug}/([a-z0-9-]+)"`, 'g'))].map((m) => m[1]!))]; | |
| 174 | + const fresh = found.filter((s) => !cardSlugs.includes(s)); | |
| 175 | + if (!fresh.length) break; | |
| 176 | + cardSlugs.push(...fresh); | |
| 177 | + if (!list.html.includes(`offset=${offset + 30}`)) break; | |
| 178 | + } | |
| 179 | + for (const cardSlug of cardSlugs) { | |
| 180 | + if (ctx.signal?.aborted) return; | |
| 181 | + if (this.reached(ctx, count) || count >= maxCards) { | |
| 182 | + await ctx.setCursor({ setIdx, updatedAt: new Date().toISOString() }); | |
| 183 | + return; | |
| 184 | + } | |
| 185 | + const url = `${BASE}/${setSlug}/${cardSlug}`; | |
| 186 | + const res = await this.page(ctx, url); | |
| 187 | + if (!res.success || !res.html) { | |
| 188 | + ctx.anomaly('page_fetch_failed', `${url}: ${res.error ?? res.httpStatus}`); | |
| 189 | + continue; | |
| 190 | + } | |
| 191 | + const payload = parseCardPage(res.html, url); | |
| 192 | + if (!payload) { | |
| 193 | + ctx.anomaly('parse_failure_card', url); | |
| 194 | + continue; | |
| 195 | + } | |
| 196 | + count++; | |
| 197 | + yield { url, externalId: `${setSlug}/${cardSlug}`, kind: 'catalog_item', engine: res.engine, httpStatus: res.httpStatus, payload, fetchedAt: res.fetchedAt }; | |
| 198 | + } | |
| 199 | + await ctx.setCursor({ setIdx: setIdx + 1, updatedAt: new Date().toISOString() }); | |
| 200 | + } | |
| 201 | + await ctx.setCursor({ setIdx: 0, updatedAt: new Date().toISOString() }); | |
| 202 | + } | |
| 203 | + | |
| 204 | + async lookup(url: string, ctx: CrawlContext): Promise<RawRecordInput[]> { | |
| 205 | + if (!this.urlPatterns[0]!.test(url)) return []; | |
| 206 | + const res = await this.page(ctx, url); | |
| 207 | + if (!res.success || !res.html) return []; | |
| 208 | + const payload = parseCardPage(res.html, url); | |
| 209 | + if (!payload) return []; | |
| 210 | + return [{ url, externalId: `${payload.setSlug}/${payload.slug}`, kind: 'catalog_item', engine: res.engine, httpStatus: res.httpStatus, payload, fetchedAt: res.fetchedAt }]; | |
| 211 | + } | |
| 212 | + | |
| 213 | + async normalize(raw: RawRecordLike): Promise<NormalizedRecord[]> { | |
| 214 | + const p = PayloadSchema.parse(raw.payload); | |
| 215 | + const { set, variant } = setAndVariant(p.setName, p.tag); | |
| 216 | + const a = attrs({ | |
| 217 | + categorySlug: 'pokemon', | |
| 218 | + franchise: 'Pokémon', | |
| 219 | + brand: 'The Pokémon Company', | |
| 220 | + set, | |
| 221 | + name: p.name, | |
| 222 | + number: p.number, | |
| 223 | + year: null, | |
| 224 | + variant, | |
| 225 | + language: 'English', | |
| 226 | + identifiers: { pokemonprice_slug: `${p.setSlug}/${p.slug}` }, | |
| 227 | + metadata: { total: p.total, set_label: p.setName, tag: p.tag }, | |
| 228 | + }); | |
| 229 | + const images = p.image ? [p.image] : []; | |
| 230 | + const rawTitle = makeTitle({ name: p.name, set, number: p.number, total: p.total, variant }); | |
| 231 | + const observedAt = raw.fetchedAt; | |
| 232 | + const out: NormalizedRecord[] = [ | |
| 233 | + catalogItem({ kind: 'catalog_item', connectorId: this.meta.id, sourceId: this.meta.sourceId, sourceUrl: raw.url, externalId: `${p.setSlug}/${p.slug}`, rawTitle, imageUrls: images, attributes: a, observedAt, confidence: 0.8, parserVersion: PARSER_VERSION, releaseDate: null }), | |
| 234 | + ]; | |
| 235 | + const kinds = new Set(((this.meta.config.priceKinds as string[] | undefined) ?? ['guide_value', 'low', 'high'])); | |
| 236 | + const txByGrade = new Map<string, number>(); | |
| 237 | + for (const t of p.transactions) txByGrade.set(t.grade, (txByGrade.get(t.grade) ?? 0) + t.count); | |
| 238 | + for (const row of p.rows) { | |
| 239 | + const { grader, grade } = parseCompactGrade(row.grade); | |
| 240 | + if (!grader && !/^raw$/i.test(row.grade)) continue; | |
| 241 | + const fair = num(row.fair_price); | |
| 242 | + if (!fair) continue; | |
| 243 | + const conf = Math.min(0.8, Math.max(0.2, (Number(row.confidence) || 40) / 100)); | |
| 244 | + // The fair price is the model's current estimate → dated by fetch day; the last sale date is kept in the title context only. | |
| 245 | + const obsDate = dayOf(observedAt); | |
| 246 | + const lastSale = isoDay(row.last_sale_date); | |
| 247 | + const sample = txByGrade.get(row.grade) ?? null; | |
| 248 | + const gradeLabel = grader === 'raw' ? 'Raw' : `${grader!.toUpperCase()} ${grade}`; | |
| 249 | + const title = `${rawTitle} — ${gradeLabel}${lastSale ? ` (last sale ${lastSale.toISOString().slice(0, 10)})` : ''}`; | |
| 250 | + const emit = (kind: 'guide_value' | 'low' | 'high', price: number | null) => { | |
| 251 | + if (!price || !kinds.has(kind)) return; | |
| 252 | + out.push(priceObservation({ kind: 'price_observation', connectorId: this.meta.id, sourceId: this.meta.sourceId, sourceUrl: raw.url, externalId: `${p.setSlug}/${p.slug}:${row.grade}:${kind}`, rawTitle: title, imageUrls: images, attributes: a, grade: { grader: grader === 'raw' ? 'raw' : grader, grade, qualifier: null, certificationNumber: null }, observedAt, confidence: conf, parserVersion: PARSER_VERSION, priceKind: kind, price, currency: 'USD', observationDate: obsDate, sampleSize: sample })); | |
| 253 | + }; | |
| 254 | + emit('guide_value', fair); | |
| 255 | + emit('low', num(row.low_price)); | |
| 256 | + emit('high', num(row.high_price)); | |
| 257 | + } | |
| 258 | + return out; | |
| 259 | + } | |
| 260 | +} | |
| 261 | + | |
| 262 | +export default (meta: ConnectorMeta) => new PokemonPriceConnector(meta); | |
added
connectors/api/pokemonprice/meta.json
+30 −0
@@ -0,0 +1,30 @@ | ||
| 1 | +{ | |
| 2 | + "id": "pokemonprice", | |
| 3 | + "displayName": "PokemonPrice (graded Pokémon price guide)", | |
| 4 | + "sourceId": "pokemonprice", | |
| 5 | + "sourceName": "PokemonPrice.com", | |
| 6 | + "sourceType": "pricing_guide", | |
| 7 | + "sourceUrl": "https://www.pokemonprice.com", | |
| 8 | + "module": "api/pokemonprice", | |
| 9 | + "enginePriority": ["api", "firecrawl"], | |
| 10 | + "categories": ["pokemon"], | |
| 11 | + "regions": ["US"], | |
| 12 | + "languages": ["en"], | |
| 13 | + "currency": ["USD"], | |
| 14 | + "supportsListings": false, | |
| 15 | + "supportsSold": false, | |
| 16 | + "supportsAuctions": false, | |
| 17 | + "supportsImages": true, | |
| 18 | + "supportsCatalog": true, | |
| 19 | + "supportsPopulation": false, | |
| 20 | + "supportsLookup": true, | |
| 21 | + "refreshFrequencyMinutes": 2880, | |
| 22 | + "priority": "medium", | |
| 23 | + "trustScore": 0.7, | |
| 24 | + "attributionRequired": true, | |
| 25 | + "termsUrl": "https://www.pokemonprice.com/about-us", | |
| 26 | + "accessNotes": "Public server-rendered card pages (robots.txt allows '/' for generic agents; Content-Signal ai-train=no respected). Each card page embeds, in its React Server Components payload, a per-grade price model (Raw, PSA 1–10, BGS, CGC: fair/low/high price, model confidence, last sale date) derived from eBay and other public sold listings, plus monthly transaction counts. Emitted as price_observations 'guide_value' (+ low/high) per grade with the model confidence (capped 0.8) and the monthly transaction count as sampleSize — not transactions. Set naming follows pokemontcg ('Base Set', 'Jungle'…); edition suffixes (1st Edition, Shadowless) become the variant so assets merge with the API catalogs. ~167 set lists, ~25k card pages; politeness 1.5 s, `maxCardsPerRun` caps a run and the cursor rotates through sets.", | |
| 27 | + "enabled": true, | |
| 28 | + "schemaVersion": "1.0", | |
| 29 | + "config": { "seeds": [], "maxCardsPerRun": 300, "priceKinds": ["guide_value", "low", "high"] } | |
| 30 | +} | |
added
connectors/api/sorcery-tcg/index.test.ts
+21 −0
@@ -0,0 +1,21 @@ | ||
| 1 | +import { describe, expect, it } from 'vitest'; | |
| 2 | +import meta from './meta.json' with { type: 'json' }; | |
| 3 | +import { localMeta } from '../_lib/local-meta.js'; | |
| 4 | +import { listFixtures, loadFixture, runFixtureSuite } from '@rareindex/connectors/testing'; | |
| 5 | +import createConnector from './index.js'; | |
| 6 | + | |
| 7 | +const connector = createConnector(localMeta(meta)); | |
| 8 | + | |
| 9 | +describe('sorcery-tcg', () => { | |
| 10 | + runFixtureSuite(connector, it, expect); | |
| 11 | + | |
| 12 | + it('emits one catalog item per printing with finish-derived variants', async () => { | |
| 13 | + const [name] = listFixtures('sorcery-tcg'); | |
| 14 | + const out = await connector.normalize(loadFixture('sorcery-tcg', name!).raw); | |
| 15 | + expect(out.length).toBeGreaterThanOrEqual(2); | |
| 16 | + const variants = out.map((c) => (c.kind === 'catalog_item' ? c.attributes.variant : undefined)); | |
| 17 | + expect(variants).toContain(null); | |
| 18 | + expect(variants.some((v) => v && v.includes('Foil'))).toBe(true); | |
| 19 | + for (const c of out) if (c.kind === 'catalog_item') expect(c.attributes.identifiers.sorcery_printing_id).toBeTruthy(); | |
| 20 | + }); | |
| 21 | +}); | |
added
connectors/api/sorcery-tcg/index.ts
+94 −0
@@ -0,0 +1,94 @@ | ||
| 1 | +import { z } from 'zod'; | |
| 2 | +import { BaseConnector, type ConnectorMeta, type CrawlContext, type RawRecordInput, type RawRecordLike } from '@rareindex/connectors'; | |
| 3 | +import { type NormalizedRecord } from '@rareindex/shared'; | |
| 4 | +import { attrs, catalogItem, makeTitle, withRetries } from '../_lib/shared.js'; | |
| 5 | +import { UA_HEADERS } from '../_lib/tcg-shared.js'; | |
| 6 | + | |
| 7 | +/** Sorcery: Contested Realm — official catalog with per-printing finish/product/set data. */ | |
| 8 | +const API = 'https://api.sorcerytcg.com/api'; | |
| 9 | +const PARSER_VERSION = '1.0.0'; | |
| 10 | + | |
| 11 | +const 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 | +}); | |
| 18 | +const 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 | +}); | |
| 25 | +export type SorceryCard = z.infer<typeof CardSchema>; | |
| 26 | + | |
| 27 | +export 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 | +} | |
| 35 | + | |
| 36 | +export class SorceryConnector extends BaseConnector { | |
| 37 | + readonly version = '1.0.0'; | |
| 38 | + readonly parserVersion = PARSER_VERSION; | |
| 39 | + protected override minIntervalMs = 1000; | |
| 40 | + | |
| 41 | + 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 | + } | |
| 61 | + | |
| 62 | + 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 | +} | |
| 93 | + | |
| 94 | +export default (meta: ConnectorMeta) => new SorceryConnector(meta); | |
added
connectors/api/sorcery-tcg/meta.json
+30 −0
@@ -0,0 +1,30 @@ | ||
| 1 | +{ | |
| 2 | + "id": "sorcery-tcg", | |
| 3 | + "displayName": "Sorcery: Contested Realm (official card API)", | |
| 4 | + "sourceId": "sorcery-tcg", | |
| 5 | + "sourceName": "Sorcery TCG (Erik's Curiosa)", | |
| 6 | + "sourceType": "manufacturer", | |
| 7 | + "sourceUrl": "https://sorcerytcg.com", | |
| 8 | + "module": "api/sorcery-tcg", | |
| 9 | + "enginePriority": ["api"], | |
| 10 | + "categories": ["other_tcg"], | |
| 11 | + "regions": ["US", "EU"], | |
| 12 | + "languages": ["en"], | |
| 13 | + "currency": [], | |
| 14 | + "supportsListings": false, | |
| 15 | + "supportsSold": false, | |
| 16 | + "supportsAuctions": false, | |
| 17 | + "supportsImages": false, | |
| 18 | + "supportsCatalog": true, | |
| 19 | + "supportsPopulation": false, | |
| 20 | + "supportsLookup": false, | |
| 21 | + "refreshFrequencyMinutes": 10080, | |
| 22 | + "priority": "low", | |
| 23 | + "trustScore": 0.85, | |
| 24 | + "attributionRequired": true, | |
| 25 | + "termsUrl": "https://sorcerytcg.com", | |
| 26 | + "accessNotes": "Official public JSON (https://api.sorcerytcg.com/api/cards, one call ≈1,100 cards with every printing: set code/name, finish Standard/Foil, product Booster/Precon/Promo, print date). Catalog only, no prices or images; TCGCSV supplies Sorcery prices via TCGplayer product names. One catalog_item per printing (finish → 'Foil' variant; non-booster product noted in metadata).", | |
| 27 | + "enabled": true, | |
| 28 | + "schemaVersion": "1.0", | |
| 29 | + "config": {} | |
| 30 | +} | |
added
connectors/api/swu-db/index.test.ts
+25 −0
@@ -0,0 +1,25 @@ | ||
| 1 | +import { describe, expect, it } from 'vitest'; | |
| 2 | +import meta from './meta.json' with { type: 'json' }; | |
| 3 | +import { localMeta } from '../_lib/local-meta.js'; | |
| 4 | +import { listFixtures, loadFixture, runFixtureSuite } from '@rareindex/connectors/testing'; | |
| 5 | +import createConnector from './index.js'; | |
| 6 | + | |
| 7 | +const connector = createConnector(localMeta(meta)); | |
| 8 | + | |
| 9 | +describe('swu-db', () => { | |
| 10 | + runFixtureSuite(connector, it, expect); | |
| 11 | + | |
| 12 | + it('emits star_wars_tcg catalog items with tcgplayer ids and USD observations', async () => { | |
| 13 | + const [name] = listFixtures('swu-db'); | |
| 14 | + const out = await connector.normalize(loadFixture('swu-db', name!).raw); | |
| 15 | + const cats = out.filter((r) => r.kind === 'catalog_item'); | |
| 16 | + expect(cats.length).toBeGreaterThanOrEqual(1); | |
| 17 | + for (const c of cats) { | |
| 18 | + if (c.kind !== 'catalog_item') continue; | |
| 19 | + expect(c.attributes.categorySlug).toBe('star_wars_tcg'); | |
| 20 | + expect(c.attributes.setCode).toMatch(/^[A-Z]{3}$/); | |
| 21 | + expect(c.attributes.number).toMatch(/^\d+$/); | |
| 22 | + } | |
| 23 | + for (const o of out) if (o.kind === 'price_observation') expect(o.currency).toBe('USD'); | |
| 24 | + }); | |
| 25 | +}); | |
added
connectors/api/swu-db/index.ts
+138 −0
@@ -0,0 +1,138 @@ | ||
| 1 | +import { z } from 'zod'; | |
| 2 | +import { BaseConnector, type ConnectorMeta, type CrawlContext, type RawRecordInput, type RawRecordLike } from '@rareindex/connectors'; | |
| 3 | +import { type NormalizedRecord } from '@rareindex/shared'; | |
| 4 | +import { attrs, catalogItem, makeTitle, num, priceObservation, withRetries } from '../_lib/shared.js'; | |
| 5 | +import { UA_HEADERS, dayOf } from '../_lib/tcg-shared.js'; | |
| 6 | + | |
| 7 | +/** SWU-DB — Star Wars: Unlimited catalog with relayed TCGplayer market prices. */ | |
| 8 | +const API = 'https://api.swu-db.com'; | |
| 9 | +const PARSER_VERSION = '1.0.0'; | |
| 10 | + | |
| 11 | +const CardSchema = z | |
| 12 | + .object({ | |
| 13 | + Set: z.string(), | |
| 14 | + Number: z.string(), | |
| 15 | + Name: z.string(), | |
| 16 | + Subtitle: z.string().nullable().optional(), | |
| 17 | + Type: z.string().nullable().optional(), | |
| 18 | + Rarity: z.string().nullable().optional(), | |
| 19 | + VariantType: z.string().nullable().optional(), | |
| 20 | + Unique: z.boolean().optional(), | |
| 21 | + Artist: z.string().nullable().optional(), | |
| 22 | + cid: z.string().nullable().optional(), | |
| 23 | + tcgplayerId: z.string().nullable().optional(), | |
| 24 | + MarketPrice: z.string().nullable().optional(), | |
| 25 | + LowPrice: z.string().nullable().optional(), | |
| 26 | + FoilPrice: z.string().nullable().optional(), | |
| 27 | + LowFoilPrice: z.string().nullable().optional(), | |
| 28 | + FrontArt: z.string().nullable().optional(), | |
| 29 | + Aspects: z.array(z.union([z.string(), z.record(z.string(), z.string())])).optional(), | |
| 30 | + }) | |
| 31 | + .loose(); | |
| 32 | +export type SwuCard = z.infer<typeof CardSchema>; | |
| 33 | + | |
| 34 | +export function trimCard(raw: Record<string, unknown>): SwuCard { | |
| 35 | + const keep = ['Set', 'Number', 'Name', 'Subtitle', 'Type', 'Rarity', 'VariantType', 'Unique', 'Artist', 'cid', 'tcgplayerId', 'MarketPrice', 'LowPrice', 'FoilPrice', 'LowFoilPrice', 'FrontArt', 'Aspects']; | |
| 36 | + const out: Record<string, unknown> = {}; | |
| 37 | + for (const k of keep) if (raw[k] !== undefined) out[k] = raw[k]; | |
| 38 | + return CardSchema.parse(out); | |
| 39 | +} | |
| 40 | + | |
| 41 | +const RawPayloadSchema = z.object({ card: CardSchema, setName: z.string().nullable() }); | |
| 42 | + | |
| 43 | +export class SwuDbConnector extends BaseConnector { | |
| 44 | + readonly version = '1.0.0'; | |
| 45 | + readonly parserVersion = PARSER_VERSION; | |
| 46 | + protected override minIntervalMs = 500; | |
| 47 | + override readonly urlPatterns = [/swu-db\.com\/cards?\/[a-z]{3}\/\d+/i]; | |
| 48 | + | |
| 49 | + private sets(): Record<string, string> { | |
| 50 | + return (this.meta.config.sets ?? {}) as Record<string, string>; | |
| 51 | + } | |
| 52 | + | |
| 53 | + async *crawl(ctx: CrawlContext): AsyncIterable<RawRecordInput> { | |
| 54 | + const sets = Object.entries(this.sets()).filter(([code]) => !ctx.options.seeds?.length || ctx.options.seeds.map((s) => s.toUpperCase()).includes(code)); | |
| 55 | + let setIdx = Number(ctx.options.cursor?.setIdx ?? 0); | |
| 56 | + let count = 0; | |
| 57 | + for (; setIdx < sets.length; setIdx++) { | |
| 58 | + const [code, setName] = sets[setIdx]!; | |
| 59 | + await this.throttle(); | |
| 60 | + const res = await withRetries(() => ctx.fetch(`${API}/cards/${code.toLowerCase()}`, { engines: ['api'], headers: UA_HEADERS, timeoutMs: 60_000 }), (r) => r.success && r.json !== null, 3, 2000); | |
| 61 | + const data = (res.json as { data?: unknown[] } | null)?.data; | |
| 62 | + if (!res.success || !Array.isArray(data)) { | |
| 63 | + ctx.anomaly('page_fetch_failed', `${code}: ${res.error ?? res.httpStatus}`); | |
| 64 | + continue; | |
| 65 | + } | |
| 66 | + for (const raw of data) { | |
| 67 | + let card: SwuCard; | |
| 68 | + try { | |
| 69 | + card = trimCard(raw as Record<string, unknown>); | |
| 70 | + } catch (err) { | |
| 71 | + ctx.anomaly('parse_failure_card', `${code}: ${err instanceof Error ? err.message : String(err)}`); | |
| 72 | + continue; | |
| 73 | + } | |
| 74 | + if (this.reached(ctx, count)) return; | |
| 75 | + count++; | |
| 76 | + yield { url: `https://www.swu-db.com/card/${card.Set}/${card.Number}`, externalId: `${card.Set}-${card.Number}`, kind: 'catalog_item', engine: 'api', httpStatus: res.httpStatus, payload: { card, setName }, fetchedAt: res.fetchedAt }; | |
| 77 | + } | |
| 78 | + await ctx.setCursor({ setIdx: setIdx + 1, updatedAt: new Date().toISOString() }); | |
| 79 | + } | |
| 80 | + await ctx.setCursor({ setIdx: 0, updatedAt: new Date().toISOString() }); | |
| 81 | + } | |
| 82 | + | |
| 83 | + async lookup(url: string, ctx: CrawlContext): Promise<RawRecordInput[]> { | |
| 84 | + const m = url.match(/swu-db\.com\/cards?\/([a-z]{3})\/(\d+)/i); | |
| 85 | + if (!m) return []; | |
| 86 | + const res = await ctx.fetch(`${API}/cards/${m[1]!.toLowerCase()}/${m[2]}`, { engines: ['api'], headers: UA_HEADERS }); | |
| 87 | + if (!res.success || !res.json) return []; | |
| 88 | + const card = trimCard(res.json as Record<string, unknown>); | |
| 89 | + return [{ url, externalId: `${card.Set}-${card.Number}`, kind: 'catalog_item', engine: 'api', httpStatus: res.httpStatus, payload: { card, setName: this.sets()[card.Set] ?? null }, fetchedAt: res.fetchedAt }]; | |
| 90 | + } | |
| 91 | + | |
| 92 | + async normalize(raw: RawRecordLike): Promise<NormalizedRecord[]> { | |
| 93 | + const { card, setName } = RawPayloadSchema.parse(raw.payload); | |
| 94 | + const identifiers: Record<string, string> = { swudb_id: `${card.Set}-${card.Number}` }; | |
| 95 | + if (card.cid) identifiers.swudb_cid = card.cid; | |
| 96 | + if (card.tcgplayerId) identifiers.tcgplayer_id = card.tcgplayerId; | |
| 97 | + const name = card.Subtitle ? `${card.Name} - ${card.Subtitle}` : card.Name; | |
| 98 | + const foilEntry = /F$/i.test(card.Number); | |
| 99 | + const number = foilEntry ? card.Number.replace(/F$/i, '') : card.Number; | |
| 100 | + const typeVariant = card.VariantType && card.VariantType !== 'Normal' && card.VariantType !== 'Foil' ? card.VariantType : null; | |
| 101 | + const baseVariant = foilEntry || card.VariantType === 'Foil' ? (typeVariant ? `${typeVariant} Foil` : 'Foil') : typeVariant; | |
| 102 | + const images = card.FrontArt ? [card.FrontArt] : []; | |
| 103 | + const observedAt = raw.fetchedAt; | |
| 104 | + const obsDate = dayOf(observedAt); | |
| 105 | + const build = (variant: string | null) => | |
| 106 | + attrs({ | |
| 107 | + categorySlug: 'star_wars_tcg', | |
| 108 | + franchise: 'Star Wars: Unlimited', | |
| 109 | + brand: 'Fantasy Flight Games', | |
| 110 | + set: setName ?? card.Set, | |
| 111 | + setCode: card.Set, | |
| 112 | + name, | |
| 113 | + number, | |
| 114 | + year: null, | |
| 115 | + variant, | |
| 116 | + language: 'English', | |
| 117 | + rarity: card.Rarity ?? null, | |
| 118 | + identifiers, | |
| 119 | + metadata: { type: card.Type, unique: card.Unique ?? null, artist: card.Artist, variant_type: card.VariantType ?? 'Normal', foil_price_hint: num(card.FoilPrice) }, | |
| 120 | + }); | |
| 121 | + const out: NormalizedRecord[] = []; | |
| 122 | + const emit = (variant: string | null, market: number | null, low: number | null) => { | |
| 123 | + const a = build(variant); | |
| 124 | + const rawTitle = makeTitle({ name, set: setName ?? card.Set, number, variant }); | |
| 125 | + out.push(catalogItem({ kind: 'catalog_item', connectorId: this.meta.id, sourceId: this.meta.sourceId, sourceUrl: raw.url, externalId: `${card.Set}-${card.Number}${variant ? `:${variant}` : ''}`, rawTitle, imageUrls: images, attributes: a, observedAt, confidence: 0.85, parserVersion: PARSER_VERSION, releaseDate: null })); | |
| 126 | + for (const [kind, price] of [['market', market], ['low', low]] as const) { | |
| 127 | + if (!price) continue; | |
| 128 | + // Prices are relayed from TCGplayer without a timestamp → fetch-day observation, moderate confidence. | |
| 129 | + out.push(priceObservation({ kind: 'price_observation', connectorId: this.meta.id, sourceId: this.meta.sourceId, sourceUrl: raw.url, externalId: `${card.Set}-${card.Number}:${variant ?? 'normal'}:${kind}`, rawTitle, imageUrls: images, attributes: a, observedAt, confidence: 0.7, parserVersion: PARSER_VERSION, priceKind: kind, price, currency: 'USD', observationDate: obsDate, sampleSize: null })); | |
| 130 | + } | |
| 131 | + }; | |
| 132 | + // Foil printings are separate API entries (Number "059F"); FoilPrice on the base entry is informational only. | |
| 133 | + emit(baseVariant, num(card.MarketPrice), num(card.LowPrice)); | |
| 134 | + return out; | |
| 135 | + } | |
| 136 | +} | |
| 137 | + | |
| 138 | +export default (meta: ConnectorMeta) => new SwuDbConnector(meta); | |
added
connectors/api/swu-db/meta.json
+32 −0
@@ -0,0 +1,32 @@ | ||
| 1 | +{ | |
| 2 | + "id": "swu-db", | |
| 3 | + "displayName": "SWU-DB (Star Wars: Unlimited)", | |
| 4 | + "sourceId": "swu-db", | |
| 5 | + "sourceName": "SWU-DB", | |
| 6 | + "sourceType": "catalog", | |
| 7 | + "sourceUrl": "https://www.swu-db.com", | |
| 8 | + "module": "api/swu-db", | |
| 9 | + "enginePriority": ["api"], | |
| 10 | + "categories": ["star_wars_tcg"], | |
| 11 | + "regions": ["US"], | |
| 12 | + "languages": ["en"], | |
| 13 | + "currency": ["USD"], | |
| 14 | + "supportsListings": false, | |
| 15 | + "supportsSold": false, | |
| 16 | + "supportsAuctions": false, | |
| 17 | + "supportsImages": true, | |
| 18 | + "supportsCatalog": true, | |
| 19 | + "supportsPopulation": false, | |
| 20 | + "supportsLookup": true, | |
| 21 | + "refreshFrequencyMinutes": 1440, | |
| 22 | + "priority": "medium", | |
| 23 | + "trustScore": 0.7, | |
| 24 | + "attributionRequired": true, | |
| 25 | + "termsUrl": "https://www.swu-db.com/api", | |
| 26 | + "accessNotes": "Free community API (https://api.swu-db.com/cards/<set>, no key). Each card carries TCGplayer ids and USD MarketPrice/LowPrice (+ FoilPrice/LowFoilPrice) relayed from TCGplayer without a timestamp → observations dated by the fetch day, confidence 0.7. Set codes are configured (the /catalog/sets endpoint needs an API key); VariantType (Normal, Hyperspace, Showcase…) becomes the variant, foil prices create a 'Foil' variant.", | |
| 27 | + "enabled": true, | |
| 28 | + "schemaVersion": "1.0", | |
| 29 | + "config": { | |
| 30 | + "sets": { "SOR": "Spark of Rebellion", "SHD": "Shadows of the Galaxy", "TWI": "Twilight of the Republic", "JTL": "Jump to Lightspeed", "LOF": "Legends of the Force", "SEC": "Secrets of Power" } | |
| 31 | + } | |
| 32 | +} | |
added
connectors/api/tcgcsv/_capture.ts
+22 −0
Large diff (22 lines) is not loaded by default to keep the page fast.Load diff
added
connectors/api/tcgcsv/index.test.ts
+44 −0
Large diff (44 lines) is not loaded by default to keep the page fast.Load diff
added
connectors/api/tcgcsv/index.ts
+189 −0
Large diff (189 lines) is not loaded by default to keep the page fast.Load diff
added
connectors/api/tcgcsv/meta.json
+60 −0
Large diff (60 lines) is not loaded by default to keep the page fast.Load diff
modified
connectors/registry.json
+2762 −355
Large diff (3,407 lines) is not loaded by default to keep the page fast.Load diff
added
data/fixtures/digimoncard/bt5-first.json
+46 −0
Large diff (47 lines) is not loaded by default to keep the page fast.Load diff
added
data/fixtures/digimoncard/bt5-sixth.json
+49 −0
Large diff (50 lines) is not loaded by default to keep the page fast.Load diff
added
data/fixtures/grand-archive/page1-first.json
+64 −0
Large diff (65 lines) is not loaded by default to keep the page fast.Load diff
added
data/fixtures/grand-archive/page1-third.json
+64 −0
Large diff (65 lines) is not loaded by default to keep the page fast.Load diff
added
data/fixtures/mtggoldfish/lea-air-elemental.json
+42 −0
Large diff (43 lines) is not loaded by default to keep the page fast.Load diff
added
data/fixtures/mtggoldfish/lea-black-lotus.json
+42 −0
Large diff (43 lines) is not loaded by default to keep the page fast.Load diff
added
data/fixtures/pokemonprice/base-set-1st-edition-first.json
+1360 −0
Large diff (1,361 lines) is not loaded by default to keep the page fast.Load diff
added
data/fixtures/pokemonprice/base-set-1st-edition-third.json
+915 −0
Large diff (916 lines) is not loaded by default to keep the page fast.Load diff
added
data/fixtures/sorcery-tcg/first.json
+78 −0
Large diff (79 lines) is not loaded by default to keep the page fast.Load diff
added
data/fixtures/sorcery-tcg/sixth.json
+114 −0
Large diff (115 lines) is not loaded by default to keep the page fast.Load diff
added
data/fixtures/swu-db/sor-first.json
+48 −0
Large diff (49 lines) is not loaded by default to keep the page fast.Load diff
added
data/fixtures/swu-db/sor-fourth.json
+47 −0
Large diff (48 lines) is not loaded by default to keep the page fast.Load diff
added
data/fixtures/tcgcsv/pokemon-base-charizard.json
+112 −0
Large diff (113 lines) is not loaded by default to keep the page fast.Load diff
added
data/fixtures/tcgcsv/pokemon-base-energy.json
+77 −0
Large diff (78 lines) is not loaded by default to keep the page fast.Load diff
added
data/fixtures/tcgcsv/pokemon-first-product.json
+67 −0
Large diff (68 lines) is not loaded by default to keep the page fast.Load diff
modified
workers/entity-resolution/canonical-key.ts
+2 −1
Large diff (10 lines) is not loaded by default to keep the page fast.Load diff