SPB Git forge

spb/rareindex

Public
54commits 1branches 0releases
7.1 MBsize
maindefault branch
10 days agolast push
TypeScript 61.9% HTML 37.2% SQL 0.7%

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>
Simon-Pierre Boucher committed 17 days ago (Sep 7, 2026) parent 5270c73

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="{&quot;cards&quot;:[{&quot;name&quot;:&quot;Black Lotus [LEA]&quot;,&quot;set&quot;:&quot;LEA&quot;,&quot;display_name&quot;:&quot;Black Lotus&quot;,&quot;rarity&quot;:&quot;Rare&quot;,&quot;foil&quot;:false,&quot;card_num&quot;:232,&quot;finish&quot;:&quot;regular&quot;,&quot;card_uuid&quot;:&quot;u1&quot;,&quot;prices&quot;:{&quot;paper&quot;:{&quot;current_price&quot;:&quot;12345.5&quot;},&quot;online&quot;:{&quot;current_price&quot;:null}},&quot;links&quot;:{&quot;default&quot;:&quot;/price/limited-edition-alpha/232/black-lotus&quot;},&quot;source_image_variants&quot;:{&quot;672x938&quot;:&quot;https://img/x.webp&quot;}}],&quot;view_type&quot;:&quot;grid&quot;}"></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('&quot;cards&quot;:[');
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(/&quot;/g, '"').replace(/&#39;/g, "'").replace(/&amp;/g, '&').replace(/&lt;/g, '<').replace(/&gt;/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
@@ -0,0 +1,22 @@
1 +/** Targeted live fixture: Pokémon Base Set (group 604) cards with multiple price subtypes. */
2 +import { saveFixture } from '@rareindex/connectors/testing';
3 +import { trimProduct } from './index.js';
4 +const UA = { 'user-agent': 'RareIndexBot/0.1 (+https://www.rareindex.io/about/data)' };
5 +const get = async (u: string) => (await (await fetch(u, { headers: UA })).json()) as { results: Record<string, unknown>[] };
6 +const stamp = (await (await fetch('https://tcgcsv.com/last-updated.txt', { headers: UA })).text()).trim();
7 +const groups = await get('https://tcgcsv.com/tcgplayer/3/groups');
8 +const group = groups.results.find((g) => g.groupId === 604)!;
9 +const products = await get('https://tcgcsv.com/tcgplayer/3/604/products');
10 +const prices = await get('https://tcgcsv.com/tcgplayer/3/604/prices');
11 +const category = { slug: 'pokemon', franchise: 'Pokémon', brand: 'The Pokémon Company', language: 'English' };
12 +for (const [name, match] of [['pokemon-base-charizard', /^Charizard/], ['pokemon-base-energy', /^Psychic Energy/]] as const) {
13 + const p = products.results.find((x) => match.test(String(x.name)))!;
14 + const product = trimProduct(p);
15 + const rows = prices.results.filter((r) => r.productId === product.productId);
16 + saveFixture('tcgcsv', name, {
17 + raw: { url: String(p.url), externalId: String(product.productId), kind: 'catalog_item', engine: 'api', fetchedAt: new Date(), payload: { categoryId: 3, category, group: { groupId: group.groupId, name: group.name, abbreviation: group.abbreviation, isSupplemental: group.isSupplemental, publishedOn: group.publishedOn, categoryId: 3 }, product, prices: rows, updatedAt: stamp } },
18 + expect: { minCount: 2, kinds: ['catalog_item', 'price_observation'], requiredFields: ['attributes.identifiers.tcgplayer_id', 'attributes.set', 'attributes.number'] },
19 + note: `Live capture ${new Date().toISOString().slice(0, 10)} — tcgcsv.com/tcgplayer/3/604 (Base Set), subtypes ${[...new Set(rows.map((r) => r.subTypeName))].join('/')}`,
20 + });
21 + console.log(name, product.productId, rows.map((r) => `${r.subTypeName}:${r.marketPrice}`).join(' '));
22 +}
added connectors/api/tcgcsv/index.test.ts +44 −0
@@ -0,0 +1,44 @@
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 +import { variantFromSubType, splitNumber } from '../_lib/tcg-shared.js';
7 +
8 +const connector = createConnector(localMeta(meta));
9 +
10 +describe('tcgcsv', () => {
11 + runFixtureSuite(connector, it, expect);
12 +
13 + it('maps TCGplayer subtypes to the shared variant vocabulary', () => {
14 + expect(variantFromSubType('Normal')).toBeNull();
15 + expect(variantFromSubType('Holofoil')).toBe('Holo');
16 + expect(variantFromSubType('Reverse Holofoil')).toBe('Reverse Holo');
17 + expect(variantFromSubType('1st Edition Holofoil')).toBe('1st Edition Holo');
18 + expect(variantFromSubType('Foil')).toBe('Foil');
19 + expect(splitNumber('4/102')).toEqual({ number: '4', total: 102 });
20 + });
21 +
22 + it('emits a catalog item per printing subtype and dated price observations', async () => {
23 + const names = listFixtures('tcgcsv');
24 + const pokemon = names.find((n) => n.startsWith('pokemon')) ?? names[0]!;
25 + const fx = loadFixture('tcgcsv', pokemon);
26 + const out = await connector.normalize(fx.raw);
27 + const cats = out.filter((r) => r.kind === 'catalog_item');
28 + const obs = out.filter((r) => r.kind === 'price_observation');
29 + expect(cats.length).toBeGreaterThanOrEqual(1);
30 + expect(obs.length).toBeGreaterThanOrEqual(1);
31 + for (const c of cats) {
32 + if (c.kind !== 'catalog_item') continue;
33 + expect(c.attributes.identifiers.tcgplayer_id).toMatch(/^\d+$/);
34 + expect(c.attributes.set).toBeTruthy();
35 + }
36 + for (const o of obs) {
37 + if (o.kind !== 'price_observation') continue;
38 + expect(o.currency).toBe('USD');
39 + expect(['market', 'low', 'mid', 'high']).toContain(o.priceKind);
40 + // dated by the mirror's last-updated stamp, never in the future
41 + expect(o.observationDate.getTime()).toBeLessThanOrEqual(Date.now());
42 + }
43 + });
44 +});
added connectors/api/tcgcsv/index.ts +189 −0
@@ -0,0 +1,189 @@
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, isoDay, splitNumber, tcgplayerImage, variantFromSubType } from '../_lib/tcg-shared.js';
6 +
7 +/**
8 + * TCGCSV connector — public daily mirror of TCGplayer's catalog + prices for ~90 card games.
9 + * One raw record per TCGplayer product (with its price rows); normalize emits one catalog_item per
10 + * printing subtype and price_observations (market/low/…) dated with the mirror's last-updated stamp.
11 + */
12 +const API = 'https://tcgcsv.com/tcgplayer';
13 +const PARSER_VERSION = '1.0.0';
14 +
15 +const GroupSchema = z.object({ groupId: z.number(), name: z.string(), abbreviation: z.string().nullable().optional(), isSupplemental: z.boolean().optional(), publishedOn: z.string().nullable().optional(), categoryId: z.number().optional() });
16 +const ExtSchema = z.object({ name: z.string(), displayName: z.string().optional(), value: z.string() });
17 +const ProductSchema = z.object({
18 + productId: z.number(),
19 + name: z.string(),
20 + cleanName: z.string().optional(),
21 + imageUrl: z.string().nullable().optional(),
22 + url: z.string().optional(),
23 + modifiedOn: z.string().optional(),
24 + extendedData: z.array(ExtSchema).default([]),
25 +});
26 +const PriceSchema = z.object({
27 + productId: z.number(),
28 + lowPrice: z.number().nullable().optional(),
29 + midPrice: z.number().nullable().optional(),
30 + highPrice: z.number().nullable().optional(),
31 + marketPrice: z.number().nullable().optional(),
32 + directLowPrice: z.number().nullable().optional(),
33 + subTypeName: z.string().nullable().optional(),
34 +});
35 +const CategoryCfgSchema = z.object({ slug: z.string(), franchise: z.string().nullable(), brand: z.string().nullable(), language: z.string().optional() });
36 +const RawPayloadSchema = z.object({
37 + categoryId: z.number(),
38 + category: CategoryCfgSchema,
39 + group: GroupSchema,
40 + product: ProductSchema,
41 + prices: z.array(PriceSchema),
42 + updatedAt: z.string().nullable(),
43 +});
44 +export type TcgcsvPayload = z.infer<typeof RawPayloadSchema>;
45 +
46 +export function trimProduct(p: Record<string, unknown>) {
47 + const keep = ['productId', 'name', 'cleanName', 'imageUrl', 'url', 'modifiedOn', 'extendedData'];
48 + const out: Record<string, unknown> = {};
49 + for (const k of keep) if (p[k] !== undefined) out[k] = p[k];
50 + return ProductSchema.parse(out);
51 +}
52 +
53 +export class TcgcsvConnector extends BaseConnector {
54 + readonly version = '1.0.0';
55 + readonly parserVersion = PARSER_VERSION;
56 + protected override minIntervalMs = 260;
57 +
58 + private categories(): Array<[number, z.infer<typeof CategoryCfgSchema>]> {
59 + const cfg = (this.meta.config.categories ?? {}) as Record<string, unknown>;
60 + return Object.entries(cfg).map(([id, c]) => [Number(id), CategoryCfgSchema.parse(c)] as [number, z.infer<typeof CategoryCfgSchema>]);
61 + }
62 +
63 + private async get(ctx: CrawlContext, url: string) {
64 + await this.throttle();
65 + return withRetries(() => ctx.fetch(url, { engines: ['api'], headers: UA_HEADERS }), (r) => r.success && (r.json !== null || Boolean(r.html)), 4, 1500);
66 + }
67 +
68 + async *crawl(ctx: CrawlContext): AsyncIterable<RawRecordInput> {
69 + const stamp = await this.get(ctx, 'https://tcgcsv.com/last-updated.txt');
70 + const updatedAt = typeof stamp.html === 'string' ? stamp.html.trim() : typeof stamp.json === 'string' ? String(stamp.json).trim() : null;
71 + const cats = this.categories().filter(([id]) => !ctx.options.seeds?.length || ctx.options.seeds.includes(String(id)) || ctx.options.seeds.includes(String(id)));
72 + const maxGroups = ctx.options.mode === 'backfill' ? Infinity : Number(this.meta.config.maxGroupsPerCategory ?? 40);
73 + let catIdx = Number(ctx.options.cursor?.catIdx ?? 0);
74 + let groupIdx = Number(ctx.options.cursor?.groupIdx ?? 0);
75 + let count = 0;
76 + for (; catIdx < cats.length; catIdx++, groupIdx = 0) {
77 + const [categoryId, category] = cats[catIdx]!;
78 + const gRes = await this.get(ctx, `${API}/${categoryId}/groups`);
79 + const gJson = gRes.json as { results?: unknown[] } | null;
80 + if (!gRes.success || !Array.isArray(gJson?.results)) {
81 + ctx.anomaly('page_fetch_failed', `groups ${categoryId}: ${gRes.error ?? gRes.httpStatus}`);
82 + continue;
83 + }
84 + const groups = z
85 + .array(GroupSchema.loose())
86 + .parse(gJson.results)
87 + .sort((a, b) => (b.publishedOn ?? '').localeCompare(a.publishedOn ?? ''))
88 + .slice(0, maxGroups);
89 + for (; groupIdx < groups.length; groupIdx++) {
90 + if (ctx.signal?.aborted) return;
91 + const group = GroupSchema.parse(groups[groupIdx]);
92 + const pRes = await this.get(ctx, `${API}/${categoryId}/${group.groupId}/products`);
93 + const products = (pRes.json as { results?: unknown[] } | null)?.results;
94 + if (!pRes.success || !Array.isArray(products)) {
95 + ctx.anomaly('page_fetch_failed', `products ${categoryId}/${group.groupId}: ${pRes.error ?? pRes.httpStatus}`);
96 + continue;
97 + }
98 + const prRes = await this.get(ctx, `${API}/${categoryId}/${group.groupId}/prices`);
99 + const priceRows = (prRes.json as { results?: unknown[] } | null)?.results;
100 + const byProduct = new Map<number, z.infer<typeof PriceSchema>[]>();
101 + if (Array.isArray(priceRows)) {
102 + for (const row of priceRows) {
103 + const parsed = PriceSchema.safeParse(row);
104 + if (!parsed.success) continue;
105 + const list = byProduct.get(parsed.data.productId) ?? [];
106 + list.push(parsed.data);
107 + byProduct.set(parsed.data.productId, list);
108 + }
109 + } else {
110 + ctx.anomaly('page_fetch_failed', `prices ${categoryId}/${group.groupId}: ${prRes.error ?? prRes.httpStatus}`);
111 + }
112 + for (const raw of products) {
113 + let product: z.infer<typeof ProductSchema>;
114 + try {
115 + product = trimProduct(raw as Record<string, unknown>);
116 + } catch (err) {
117 + ctx.anomaly('parse_failure_product', `${group.groupId}: ${err instanceof Error ? err.message : String(err)}`);
118 + continue;
119 + }
120 + if (this.reached(ctx, count)) return;
121 + count++;
122 + const payload: TcgcsvPayload = { categoryId, category, group, product, prices: byProduct.get(product.productId) ?? [], updatedAt };
123 + yield { url: product.url ?? `https://www.tcgplayer.com/product/${product.productId}`, externalId: String(product.productId), kind: 'catalog_item', engine: 'api', httpStatus: pRes.httpStatus, payload, fetchedAt: pRes.fetchedAt };
124 + }
125 + await ctx.setCursor({ catIdx, groupIdx: groupIdx + 1, updatedAt });
126 + }
127 + }
128 + await ctx.setCursor({ catIdx: 0, groupIdx: 0, updatedAt, completedAt: new Date().toISOString() });
129 + }
130 +
131 + async normalize(raw: RawRecordLike): Promise<NormalizedRecord[]> {
132 + const { category, group, product, prices, updatedAt } = RawPayloadSchema.parse(raw.payload);
133 + const ext = new Map(product.extendedData.map((e) => [e.name.toLowerCase(), e.value]));
134 + const { number, total } = splitNumber(ext.get('number') ?? null);
135 + const rarity = ext.get('rarity') ?? null;
136 + const isSealed = !number && !rarity;
137 + const year = group.publishedOn ? Number(group.publishedOn.slice(0, 4)) || null : null;
138 + const images = tcgplayerImage(product.imageUrl);
139 + // "Charizard - 4/102" style names carry the number; keep the clean name.
140 + const name = (product.cleanName ?? product.name).replace(/\s+-\s+[A-Za-z0-9]+\/[A-Za-z0-9]+$/, '').trim() || product.name;
141 + const observedAt = raw.fetchedAt;
142 + const obsDate = isoDay(updatedAt) ?? dayOf(observedAt);
143 + const priceKinds = new Set(((this.meta.config.priceKinds as string[] | undefined) ?? ['market', 'low']));
144 + const build = (variant: string | null) =>
145 + attrs({
146 + categorySlug: category.slug,
147 + franchise: category.franchise,
148 + brand: category.brand,
149 + set: group.name,
150 + setCode: group.abbreviation ?? null,
151 + name,
152 + number,
153 + year,
154 + variant,
155 + language: category.language ?? 'English',
156 + rarity,
157 + identifiers: { tcgplayer_id: String(product.productId), tcgplayer_group_id: String(group.groupId) },
158 + metadata: { sealed: isSealed, total, card_type: ext.get('card type') ?? ext.get('cardtype') ?? null, supplemental: group.isSupplemental ?? false, tcgplayer_url: product.url ?? null },
159 + });
160 + const out: NormalizedRecord[] = [];
161 + const subTypes = prices.length ? [...new Set(prices.map((p) => p.subTypeName ?? 'Normal'))] : ['Normal'];
162 + const seenVariants = new Set<string>();
163 + for (const subType of subTypes) {
164 + const variant = variantFromSubType(subType);
165 + const key = variant ?? '';
166 + const a = build(variant);
167 + const rawTitle = makeTitle({ name, set: group.name, number, total, year, variant });
168 + if (!seenVariants.has(key)) {
169 + seenVariants.add(key);
170 + out.push(catalogItem({ kind: 'catalog_item', connectorId: this.meta.id, sourceId: this.meta.sourceId, sourceUrl: raw.url, externalId: `${product.productId}${variant ? `:${variant}` : ''}`, rawTitle, imageUrls: images, attributes: a, observedAt, confidence: 0.85, parserVersion: PARSER_VERSION, releaseDate: group.publishedOn ? new Date(group.publishedOn) : null }));
171 + }
172 + const row = prices.find((p) => (p.subTypeName ?? 'Normal') === subType);
173 + if (!row) continue;
174 + const pairs: Array<['market' | 'low' | 'mid' | 'high', number | null]> = [
175 + ['market', num(row.marketPrice)],
176 + ['low', num(row.lowPrice)],
177 + ['mid', num(row.midPrice)],
178 + ['high', num(row.highPrice)],
179 + ];
180 + for (const [kind, price] of pairs) {
181 + if (!price || !priceKinds.has(kind)) continue;
182 + out.push(priceObservation({ kind: 'price_observation', connectorId: this.meta.id, sourceId: this.meta.sourceId, sourceUrl: raw.url, externalId: `${product.productId}:${subType}:${kind}`, rawTitle, imageUrls: images, attributes: a, observedAt, confidence: 0.75, parserVersion: PARSER_VERSION, priceKind: kind, price, currency: 'USD', observationDate: obsDate, sampleSize: null }));
183 + }
184 + }
185 + return out;
186 + }
187 +}
188 +
189 +export default (meta: ConnectorMeta) => new TcgcsvConnector(meta);
added connectors/api/tcgcsv/meta.json +60 −0
@@ -0,0 +1,60 @@
1 +{
2 + "id": "tcgcsv",
3 + "displayName": "TCGCSV (TCGplayer catalog & prices)",
4 + "sourceId": "tcgcsv",
5 + "sourceName": "TCGCSV",
6 + "sourceType": "pricing_guide",
7 + "sourceUrl": "https://tcgcsv.com",
8 + "module": "api/tcgcsv",
9 + "enginePriority": ["api"],
10 + "categories": ["trading_cards", "pokemon", "magic_the_gathering", "yugioh", "disney_lorcana", "one_piece_card_game", "digimon_tcg", "flesh_and_blood", "star_wars_tcg", "dragon_ball_tcg", "weiss_schwarz", "final_fantasy_tcg", "other_tcg", "funko"],
11 + "regions": ["US"],
12 + "languages": ["en", "ja"],
13 + "currency": ["USD"],
14 + "supportsListings": false,
15 + "supportsSold": false,
16 + "supportsAuctions": false,
17 + "supportsImages": true,
18 + "supportsCatalog": true,
19 + "supportsPopulation": false,
20 + "supportsLookup": false,
21 + "refreshFrequencyMinutes": 1440,
22 + "priority": "high",
23 + "trustScore": 0.8,
24 + "attributionRequired": true,
25 + "termsUrl": "https://tcgcsv.com/faq",
26 + "accessNotes": "Public daily mirror of TCGplayer's catalog and price API (JSON endpoints /tcgplayer/{categoryId}/groups, /{groupId}/products, /{groupId}/prices; robots.txt allows everything and the FAQ explicitly invites programmatic access with an identifying User-Agent and ~4 req/s). Prices (low/mid/high/market per printing subtype) are TCGplayer market data, not transactions: stored as price_observations dated with the mirror's last-updated timestamp (/last-updated.txt), confidence 0.75. Categories are mapped to taxonomy slugs in config; incremental runs cover the newest `maxGroupsPerCategory` sets per category, backfill covers all. Identifiers: tcgplayer_id (product) + tcgplayer_group_id. Historical price archives (2024-02-08 →) exist as daily 7z files and can be backfilled later.",
27 + "enabled": true,
28 + "schemaVersion": "1.0",
29 + "config": {
30 + "categories": {
31 + "3": { "slug": "pokemon", "franchise": "Pokémon", "brand": "The Pokémon Company", "language": "English" },
32 + "85": { "slug": "pokemon", "franchise": "Pokémon", "brand": "The Pokémon Company", "language": "Japanese" },
33 + "1": { "slug": "magic_the_gathering", "franchise": "Magic: The Gathering", "brand": "Wizards of the Coast" },
34 + "2": { "slug": "yugioh", "franchise": "Yu-Gi-Oh!", "brand": "Konami" },
35 + "71": { "slug": "disney_lorcana", "franchise": "Disney Lorcana", "brand": "Ravensburger" },
36 + "68": { "slug": "one_piece_card_game", "franchise": "One Piece", "brand": "Bandai" },
37 + "63": { "slug": "digimon_tcg", "franchise": "Digimon", "brand": "Bandai" },
38 + "62": { "slug": "flesh_and_blood", "franchise": "Flesh and Blood", "brand": "Legend Story Studios" },
39 + "79": { "slug": "star_wars_tcg", "franchise": "Star Wars: Unlimited", "brand": "Fantasy Flight Games" },
40 + "80": { "slug": "dragon_ball_tcg", "franchise": "Dragon Ball Super Fusion World", "brand": "Bandai" },
41 + "27": { "slug": "dragon_ball_tcg", "franchise": "Dragon Ball Super Card Game", "brand": "Bandai" },
42 + "20": { "slug": "weiss_schwarz", "franchise": "Weiß Schwarz", "brand": "Bushiroad" },
43 + "24": { "slug": "final_fantasy_tcg", "franchise": "Final Fantasy TCG", "brand": "Square Enix" },
44 + "74": { "slug": "other_tcg", "franchise": "Grand Archive", "brand": "Weebs of the Shore" },
45 + "77": { "slug": "other_tcg", "franchise": "Sorcery: Contested Realm", "brand": "Erik's Curiosa" },
46 + "81": { "slug": "other_tcg", "franchise": "Union Arena", "brand": "Bandai" },
47 + "86": { "slug": "other_tcg", "franchise": "Gundam Card Game", "brand": "Bandai" },
48 + "89": { "slug": "other_tcg", "franchise": "Riftbound", "brand": "Riot Games" },
49 + "16": { "slug": "other_tcg", "franchise": "Cardfight!! Vanguard", "brand": "Bushiroad" },
50 + "17": { "slug": "other_tcg", "franchise": "Force of Will", "brand": "Force of Will Co." },
51 + "59": { "slug": "other_tcg", "franchise": "KeyForge", "brand": "Ghost Galaxy" },
52 + "66": { "slug": "other_tcg", "franchise": "MetaZoo", "brand": "MetaZoo Games" },
53 + "72": { "slug": "other_tcg", "franchise": "Battle Spirits Saga", "brand": "Bandai" },
54 + "25": { "slug": "other_tcg", "franchise": "UniVersus", "brand": "UVS Games" },
55 + "29": { "slug": "funko", "franchise": null, "brand": "Funko" }
56 + },
57 + "maxGroupsPerCategory": 40,
58 + "priceKinds": ["market", "low"]
59 + }
60 +}
modified connectors/registry.json +2762 −355
@@ -1,6 +1,263 @@
1 1 {
2 2 "version": "1.0",
3 3 "connectors": [
4 + {
5 + "id": "amiami",
6 + "displayName": "AmiAmi (figures & hobby — new + pre-owned, JPY)",
7 + "sourceId": "amiami",
8 + "sourceName": "AmiAmi",
9 + "sourceType": "marketplace",
10 + "sourceUrl": "https://www.amiami.com",
11 + "module": "scrapfly/amiami",
12 + "enginePriority": [
13 + "scrapfly"
14 + ],
15 + "categories": [
16 + "action_figures",
17 + "gundam",
18 + "plush",
19 + "designer_toys"
20 + ],
21 + "regions": [
22 + "JP"
23 + ],
24 + "languages": [
25 + "en",
26 + "ja"
27 + ],
28 + "currency": [
29 + "JPY"
30 + ],
31 + "supportsListings": true,
32 + "supportsSold": false,
33 + "supportsAuctions": false,
34 + "supportsImages": true,
35 + "supportsCatalog": true,
36 + "supportsPopulation": false,
37 + "supportsLookup": true,
38 + "refreshFrequencyMinutes": 720,
39 + "priority": "medium",
40 + "trustScore": 0.8,
41 + "attributionRequired": true,
42 + "termsUrl": "https://www.amiami.com/eng/guide/",
43 + "accessNotes": "Reads the public JSON API that AmiAmi's own storefront calls (api.amiami.com/api/v1.0/items with the storefront's X-User-Key header, no account or login). Plain requests from data-centre IPs get a Cloudflare 'Attention Required' page, so the request is routed through Scrapfly (asp, no JS, country jp; 1 credit per 50-item page). Data: item code, JAN, maker, release date, list price (tax-in), current sell price (pre-owned range min/max or new price), stock and pre-owned flags — all JPY. Emits catalog items + fixed-price listings; pre-owned condition comes from the storefront's ITEM/BOX grades when present in the title. No purchase, cart or account endpoints are touched.",
44 + "enabled": true,
45 + "schemaVersion": "1.0",
46 + "config": {
47 + "seeds": [
48 + {
49 + "params": "s_cate_tag=14&s_st_condition_flg=1",
50 + "categorySlug": "action_figures",
51 + "label": "Figures — pre-owned"
52 + },
53 + {
54 + "params": "s_cate_tag=14&s_st_condition_flg=0",
55 + "categorySlug": "action_figures",
56 + "label": "Figures — new"
57 + },
58 + {
59 + "params": "s_keywords=Gundam&s_st_condition_flg=1",
60 + "categorySlug": "gundam",
61 + "label": "Gundam — pre-owned"
62 + },
63 + {
64 + "params": "s_keywords=Nendoroid",
65 + "categorySlug": "action_figures",
66 + "label": "Nendoroid"
67 + }
68 + ],
69 + "pagesPerSeed": 2,
70 + "pageSize": 50
71 + }
72 + },
73 + {
74 + "id": "antiquorum",
75 + "displayName": "Antiquorum (watch auction results & upcoming lots)",
76 + "sourceId": "antiquorum",
77 + "sourceName": "Antiquorum",
78 + "sourceType": "auction_house",
79 + "sourceUrl": "https://catalog.antiquorum.swiss",
80 + "module": "api/antiquorum",
81 + "enginePriority": [
82 + "api",
83 + "firecrawl"
84 + ],
85 + "categories": [
86 + "watches",
87 + "rolex",
88 + "patek_philippe",
89 + "audemars_piguet",
90 + "omega",
91 + "other_watches",
92 + "jewelry"
93 + ],
94 + "regions": [
95 + "CH",
96 + "HK",
97 + "MC"
98 + ],
99 + "languages": [
100 + "en"
101 + ],
102 + "currency": [
103 + "CHF",
104 + "HKD",
105 + "EUR",
106 + "USD"
107 + ],
108 + "supportsListings": false,
109 + "supportsSold": true,
110 + "supportsAuctions": true,
111 + "supportsImages": true,
112 + "supportsCatalog": false,
113 + "supportsPopulation": false,
114 + "supportsLookup": true,
115 + "refreshFrequencyMinutes": 1440,
116 + "priority": "high",
117 + "trustScore": 0.9,
118 + "attributionRequired": true,
119 + "termsUrl": "https://www.antiquorum.swiss/conditions-of-sale/",
120 + "accessNotes": "Public auction catalogue (catalog.antiquorum.swiss/en → /en/auctions/<slug>/lots?page=N) fetched over plain HTTPS with the RareIndex user agent; robots.txt only disallows /wp-admin/. Each lot carries schema.org RDFa (name, sku, brand, price, currency, image) and a spec table (Brand, Model, Reference, Year, Material). Sold lots show 'Sold: <CCY> <amount>' which Antiquorum's own price lists confirm is hammer + buyer's premium (e.g. lot 387-1: hammer 230,000 → sold 287,500 HKD), so buyerPremiumIncluded=true. Sale date = the auction date on the catalogue home. Unsold/upcoming lots become auction lots with estimates. 2 s between pages.",
121 + "enabled": true,
122 + "schemaVersion": "1.0",
123 + "config": {
124 + "auctionsPerRun": 3,
125 + "maxPagesPerAuction": 25
126 + }
127 + },
128 + {
129 + "id": "apmex",
130 + "displayName": "APMEX (coins & bullion dealer, USD asks)",
131 + "sourceId": "apmex",
132 + "sourceName": "APMEX",
133 + "sourceType": "dealer",
134 + "sourceUrl": "https://www.apmex.com",
135 + "module": "firecrawl/apmex",
136 + "enginePriority": [
137 + "firecrawl",
138 + "scrapfly"
139 + ],
140 + "categories": [
141 + "coins"
142 + ],
143 + "regions": [
144 + "US"
145 + ],
146 + "languages": [
147 + "en"
148 + ],
149 + "currency": [
150 + "USD"
151 + ],
152 + "supportsListings": true,
153 + "supportsSold": false,
154 + "supportsAuctions": false,
155 + "supportsImages": true,
156 + "supportsCatalog": true,
157 + "supportsPopulation": false,
158 + "supportsLookup": false,
159 + "refreshFrequencyMinutes": 1440,
160 + "priority": "low",
161 + "trustScore": 0.85,
162 + "attributionRequired": true,
163 + "termsUrl": "https://www.apmex.com/terms-and-conditions",
164 + "accessNotes": "Public category grids (apmex.com/category/<id>/<slug>) of one of the largest US coin dealers; robots.txt disallows /catalog/, /cart/, /account/, /spotprice/ etc., none of which are used. Plain HTTPS gets an Akamai 403 for non-browser clients, so pages are fetched with Firecrawl (1 credit per ~80-product page). Parsed per product card: APMEX product id, title, 'As Low As' price (USD, cash/wire tier), stock message, image. Year, mint mark, grade and grading service (PCGS/NGC/ANACS/CAC) are parsed from the title; graded coins become grader/grade variants, raw/BU coins stay raw. Emits catalog items + dealer fixed-price listings (asks, not transactions).",
165 + "enabled": true,
166 + "schemaVersion": "1.0",
167 + "config": {
168 + "seeds": [
169 + {
170 + "url": "https://www.apmex.com/category/11903/10-liberty-eagle-coins-1795-1907",
171 + "series": "$10 Liberty Gold Eagle",
172 + "country": "US"
173 + },
174 + {
175 + "url": "https://www.apmex.com/category/11904/20-liberty-double-eagle-coins-1849-1907",
176 + "series": "$20 Liberty Double Eagle",
177 + "country": "US"
178 + },
179 + {
180 + "url": "https://www.apmex.com/category/11905/20-saint-gaudens-double-eagle-coins-1907-1933",
181 + "series": "$20 Saint-Gaudens Double Eagle",
182 + "country": "US"
183 + },
184 + {
185 + "url": "https://www.apmex.com/category/25310/morgan-dollars-1878-1921",
186 + "series": "Morgan Dollar",
187 + "country": "US"
188 + },
189 + {
190 + "url": "https://www.apmex.com/category/25320/peace-dollars-1921-1935",
191 + "series": "Peace Dollar",
192 + "country": "US"
193 + },
194 + {
195 + "url": "https://www.apmex.com/category/11440/american-gold-eagles",
196 + "series": "American Gold Eagle",
197 + "country": "US"
198 + }
199 + ],
200 + "pagesPerSeed": 1
201 + }
202 + },
203 + {
204 + "id": "artcurial",
205 + "displayName": "Artcurial (sale results)",
206 + "sourceId": "artcurial",
207 + "sourceName": "Artcurial",
208 + "sourceType": "auction_house",
209 + "sourceUrl": "https://www.artcurial.com",
210 + "module": "firecrawl/artcurial",
211 + "enginePriority": [
212 + "firecrawl",
213 + "scrapfly"
214 + ],
215 + "categories": [
216 + "art",
217 + "contemporary_art",
218 + "photography",
219 + "design_furniture",
220 + "jewelry",
221 + "other_watches",
222 + "luxury_handbags",
223 + "wine",
224 + "comics",
225 + "books",
226 + "automobiles",
227 + "automotive_memorabilia",
228 + "antiques"
229 + ],
230 + "regions": [
231 + "FR",
232 + "MC"
233 + ],
234 + "languages": [
235 + "en",
236 + "fr"
237 + ],
238 + "currency": [
239 + "EUR"
240 + ],
241 + "supportsListings": false,
242 + "supportsSold": true,
243 + "supportsAuctions": true,
244 + "supportsImages": true,
245 + "supportsCatalog": false,
246 + "supportsPopulation": false,
247 + "supportsLookup": false,
248 + "refreshFrequencyMinutes": 1440,
249 + "priority": "medium",
250 + "trustScore": 0.9,
251 + "attributionRequired": true,
252 + "termsUrl": "https://www.artcurial.com/en/conditions-of-sale",
253 + "accessNotes": "Paris/Monaco auction house (Nuxt app). The public results index (/en/results-auctions-sales) and each sale page (/en/sales/<n>) are rendered through Firecrawl (JS wait ~5 s, 1 credit each); the sale page lists every lot with lot number, title, sub-title, estimate and 'Sold €X' plus the public lot URL. robots.txt disallows press, tracking and /motorcars paths only (car sales are listed under /en/sales/<n>, which is allowed). Artcurial does not state on the results list whether the published price includes buyer's premium, so buyer_premium_included is left null (unknown, never assumed). Sale date = the sale date on the results index. 3 s politeness delay; salesPerRun caps each run.",
254 + "enabled": true,
255 + "schemaVersion": "1.0",
256 + "config": {
257 + "resultsUrl": "https://www.artcurial.com/en/results-auctions-sales",
258 + "salesPerRun": 3
259 + }
260 + },
4 261 {
5 262 "id": "aucfree",
6 263 "displayName": "aucfree (Yahoo! Auctions Japan closed lots)",
@@ -92,6 +349,66 @@
92 349 "pagesPerSeed": 2
93 350 }
94 351 },
352 + {
353 + "id": "bobs-watches",
354 + "displayName": "Bob's Watches (pre-owned Rolex listings)",
355 + "sourceId": "bobs-watches",
356 + "sourceName": "Bob's Watches",
357 + "sourceType": "dealer",
358 + "sourceUrl": "https://www.bobswatches.com",
359 + "module": "api/bobs-watches",
360 + "enginePriority": [
361 + "api",
362 + "firecrawl"
363 + ],
364 + "categories": [
365 + "rolex",
366 + "watches",
367 + "other_watches"
368 + ],
369 + "regions": [
370 + "US"
371 + ],
372 + "languages": [
373 + "en"
374 + ],
375 + "currency": [
376 + "USD"
377 + ],
378 + "supportsListings": true,
379 + "supportsSold": false,
380 + "supportsAuctions": false,
381 + "supportsImages": true,
382 + "supportsCatalog": false,
383 + "supportsPopulation": false,
384 + "supportsLookup": true,
385 + "refreshFrequencyMinutes": 720,
386 + "priority": "medium",
387 + "trustScore": 0.8,
388 + "attributionRequired": true,
389 + "termsUrl": "https://www.bobswatches.com/terms-and-conditions",
390 + "accessNotes": "Public model-family pages (bobswatches.com/rolex-<model>) fetched over plain HTTPS with the RareIndex user agent; robots.txt allows them (sort/query/search URLs are disallowed and never used). Each page embeds schema.org Product JSON-LD per watch (name, mpn = reference, sku = stock id, colour/material, USD price, availability). Listings only; Bob's does not publish realised prices. 1.5 s between pages.",
391 + "enabled": true,
392 + "schemaVersion": "1.0",
393 + "config": {
394 + "seeds": [
395 + "rolex-submariner",
396 + "rolex-daytona",
397 + "rolex-gmt-master-ii",
398 + "rolex-datejust",
399 + "rolex-day-date",
400 + "rolex-explorer",
401 + "rolex-sea-dweller",
402 + "rolex-yacht-master",
403 + "rolex-oyster-perpetual",
404 + "rolex-sky-dweller",
405 + "rolex-milgauss",
406 + "rolex-air-king",
407 + "vintage-rolex",
408 + "used-rolex-watches"
409 + ]
410 + }
411 + },
95 412 {
96 413 "id": "bonhams",
97 414 "displayName": "Bonhams (auction results & upcoming lots)",
@@ -333,44 +650,138 @@
333 650 }
334 651 },
335 652 {
336 − "id": "catawiki",
337 − "displayName": "Catawiki (closed-lot results & live lots)",
338 − "sourceId": "catawiki",
339 − "sourceName": "Catawiki",
653 + "id": "bring-a-trailer",
654 + "displayName": "Bring a Trailer (auction results)",
655 + "sourceId": "bring-a-trailer",
656 + "sourceName": "Bring a Trailer",
340 657 "sourceType": "auction_house",
341 − "sourceUrl": "https://www.catawiki.com",
342 − "module": "scrapfly/catawiki",
658 + "sourceUrl": "https://bringatrailer.com",
659 + "module": "api/bring-a-trailer",
343 660 "enginePriority": [
344 − "scrapfly"
661 + "api"
345 662 ],
346 663 "categories": [
347 − "pokemon",
348 − "magic_the_gathering",
349 − "yugioh",
350 − "one_piece_card_game",
351 − "disney_lorcana",
352 − "other_tcg",
353 − "basketball_cards",
354 − "baseball_cards",
355 − "football_cards",
356 − "hockey_cards",
357 − "soccer_cards",
358 − "f1_cards",
359 − "non_sport_cards",
360 − "rolex",
361 − "patek_philippe",
362 − "audemars_piguet",
363 − "omega",
364 − "other_watches",
365 − "pens",
366 − "lighters",
367 − "marvel_comics",
368 − "dc_comics",
369 − "independent_comics",
370 − "manga",
371 − "animation_art",
372 − "lego_sets",
373 − "funko",
664 + "automobiles",
665 + "motorcycles"
666 + ],
667 + "regions": [
668 + "US",
669 + "CA",
670 + "GB",
671 + "EU"
672 + ],
673 + "languages": [
674 + "en"
675 + ],
676 + "currency": [
677 + "USD",
678 + "CAD",
679 + "GBP",
680 + "EUR"
681 + ],
682 + "supportsListings": false,
683 + "supportsSold": true,
684 + "supportsAuctions": false,
685 + "supportsImages": true,
686 + "supportsCatalog": false,
687 + "supportsPopulation": false,
688 + "supportsLookup": true,
689 + "refreshFrequencyMinutes": 360,
690 + "priority": "high",
691 + "trustScore": 0.9,
692 + "attributionRequired": true,
693 + "termsUrl": "https://bringatrailer.com/terms-of-use/",
694 + "accessNotes": "Completed auctions read from the same public JSON the /auctions/results/ page uses (POST /wp-json/bringatrailer/1.0/data/listings-filter, 36 items/page, ~262k results, newest first) over plain HTTPS with the RareIndex user agent; no login, no bidder data. robots.txt allows the results pages (search and member areas are disallowed and not used). 'Sold for <CUR> <amount> on <date>' entries become sales; 'Bid to' (reserve not met) entries are skipped. Prices are the winning bid: BaT's buyer fee (5%, capped) is NOT included → buyer_premium_included=false. Lookup of a listing URL parses the public listing page (title, sold line, chassis/VIN, mileage). 1.5 s politeness delay.",
695 + "enabled": true,
696 + "schemaVersion": "1.0",
697 + "config": {
698 + "pagesPerRun": 20,
699 + "perPage": 36
700 + }
701 + },
702 + {
703 + "id": "cars-and-bids",
704 + "displayName": "Cars & Bids (past auctions)",
705 + "sourceId": "cars-and-bids",
706 + "sourceName": "Cars & Bids",
707 + "sourceType": "auction_house",
708 + "sourceUrl": "https://carsandbids.com",
709 + "module": "firecrawl/cars-and-bids",
710 + "enginePriority": [
711 + "firecrawl"
712 + ],
713 + "categories": [
714 + "automobiles"
715 + ],
716 + "regions": [
717 + "US",
718 + "CA"
719 + ],
720 + "languages": [
721 + "en"
722 + ],
723 + "currency": [
724 + "USD",
725 + "CAD"
726 + ],
727 + "supportsListings": false,
728 + "supportsSold": true,
729 + "supportsAuctions": false,
730 + "supportsImages": true,
731 + "supportsCatalog": false,
732 + "supportsPopulation": false,
733 + "supportsLookup": false,
734 + "refreshFrequencyMinutes": 360,
735 + "priority": "high",
736 + "trustScore": 0.9,
737 + "attributionRequired": true,
738 + "termsUrl": "https://carsandbids.com/terms-of-use/",
739 + "accessNotes": "Public 'Past Results' list (/past-auctions/?page=N, ~50 auctions per page, newest first) rendered through Firecrawl (plain HTTPS returns a Cloudflare interstitial; we do not bypass it — Firecrawl fetches the public page like a browser). robots.txt allows the page (only /sell-car/, /widgets/, /dealers/ are disallowed). 'Sold for $X' + 'Ended m/d/yy' rows become sales; 'Bid to' rows (reserve not met) are skipped. Price is the winning bid — the 4.5% buyer fee is NOT included → buyer_premium_included=false. 1 Firecrawl credit per page, 2 s politeness delay.",
740 + "enabled": true,
741 + "schemaVersion": "1.0",
742 + "config": {
743 + "pagesPerRun": 10
744 + }
745 + },
746 + {
747 + "id": "catawiki",
748 + "displayName": "Catawiki (closed-lot results & live lots)",
749 + "sourceId": "catawiki",
750 + "sourceName": "Catawiki",
751 + "sourceType": "auction_house",
752 + "sourceUrl": "https://www.catawiki.com",
753 + "module": "scrapfly/catawiki",
754 + "enginePriority": [
755 + "scrapfly"
756 + ],
757 + "categories": [
758 + "pokemon",
759 + "magic_the_gathering",
760 + "yugioh",
761 + "one_piece_card_game",
762 + "disney_lorcana",
763 + "other_tcg",
764 + "basketball_cards",
765 + "baseball_cards",
766 + "football_cards",
767 + "hockey_cards",
768 + "soccer_cards",
769 + "f1_cards",
770 + "non_sport_cards",
771 + "rolex",
772 + "patek_philippe",
773 + "audemars_piguet",
774 + "omega",
775 + "other_watches",
776 + "pens",
777 + "lighters",
778 + "marvel_comics",
779 + "dc_comics",
780 + "independent_comics",
781 + "manga",
782 + "animation_art",
783 + "lego_sets",
784 + "funko",
374 785 "model_cars",
375 786 "model_trains",
376 787 "action_figures",
@@ -677,6 +1088,58 @@
677 1088 "pageSize": 120
678 1089 }
679 1090 },
1091 + {
1092 + "id": "collecting-cars",
1093 + "displayName": "Collecting Cars (results)",
1094 + "sourceId": "collecting-cars",
1095 + "sourceName": "Collecting Cars",
1096 + "sourceType": "auction_house",
1097 + "sourceUrl": "https://collectingcars.com",
1098 + "module": "firecrawl/collecting-cars",
1099 + "enginePriority": [
1100 + "firecrawl"
1101 + ],
1102 + "categories": [
1103 + "automobiles",
1104 + "motorcycles",
1105 + "automotive_memorabilia"
1106 + ],
1107 + "regions": [
1108 + "GB",
1109 + "EU",
1110 + "AU",
1111 + "US",
1112 + "AE"
1113 + ],
1114 + "languages": [
1115 + "en"
1116 + ],
1117 + "currency": [
1118 + "GBP",
1119 + "EUR",
1120 + "AUD",
1121 + "USD",
1122 + "AED"
1123 + ],
1124 + "supportsListings": false,
1125 + "supportsSold": true,
1126 + "supportsAuctions": false,
1127 + "supportsImages": true,
1128 + "supportsCatalog": false,
1129 + "supportsPopulation": false,
1130 + "supportsLookup": false,
1131 + "refreshFrequencyMinutes": 720,
1132 + "priority": "low",
1133 + "trustScore": 0.9,
1134 + "attributionRequired": true,
1135 + "termsUrl": "https://collectingcars.com/terms-and-conditions",
1136 + "accessNotes": "Public results list (/sold?page=N, 48 lots per page, ~26k sold lots, newest first) rendered through Firecrawl (plain HTTPS gets a Cloudflare interstitial; not bypassed). robots.txt: Allow / for generic agents (named AI-training crawlers are disallowed; RareIndexBot is not one — we only record public sale facts and link back). Most sold prices are shown only to signed-in users ('Sign in to view sold price'); we NEVER log in, so only lots whose price is displayed publicly (a minority, typically the most recent/featured results) become sales — expect a few sales per page. Prices are hammer prices — the buyer's premium is charged on top → buyer_premium_included=false. 1 Firecrawl credit per page, 2 s politeness delay.",
1137 + "enabled": true,
1138 + "schemaVersion": "1.0",
1139 + "config": {
1140 + "pagesPerRun": 10
1141 + }
1142 + },
680 1143 {
681 1144 "id": "comicconnect",
682 1145 "displayName": "ComicConnect (sold archive)",
@@ -726,6 +1189,110 @@
726 1189 "sortType": "ended_desc"
727 1190 }
728 1191 },
1192 + {
1193 + "id": "crown-caliber",
1194 + "displayName": "European Watch Company (crownandcaliber.com) — pre-owned watch listings",
1195 + "sourceId": "europeanwatch",
1196 + "sourceName": "European Watch Company",
1197 + "sourceType": "dealer",
1198 + "sourceUrl": "https://www.europeanwatch.com",
1199 + "module": "api/crown-caliber",
1200 + "enginePriority": [
1201 + "api",
1202 + "firecrawl"
1203 + ],
1204 + "categories": [
1205 + "watches",
1206 + "rolex",
1207 + "patek_philippe",
1208 + "audemars_piguet",
1209 + "omega",
1210 + "other_watches"
1211 + ],
1212 + "regions": [
1213 + "US"
1214 + ],
1215 + "languages": [
1216 + "en"
1217 + ],
1218 + "currency": [
1219 + "USD"
1220 + ],
1221 + "supportsListings": true,
1222 + "supportsSold": false,
1223 + "supportsAuctions": false,
1224 + "supportsImages": true,
1225 + "supportsCatalog": false,
1226 + "supportsPopulation": false,
1227 + "supportsLookup": true,
1228 + "refreshFrequencyMinutes": 720,
1229 + "priority": "medium",
1230 + "trustScore": 0.8,
1231 + "attributionRequired": true,
1232 + "termsUrl": "https://www.europeanwatch.com/terms",
1233 + "accessNotes": "crownandcaliber.com now redirects to europeanwatch.com (Boston dealer). Public brand pages (/brand/<slug>?page=N) embed a schema.org ItemList of Products (name, sku, USD price, availability, condition) — fetched over plain HTTPS with the RareIndex user agent; robots.txt only disallows /login. References and circa years are parsed from the product name; box/papers/condition details live on product pages and are only fetched by lookup(). 1.5 s between pages, the whole brand inventory is served on one page (~130 watches for Rolex; pagination parameters are ignored, which the crawler detects).",
1234 + "enabled": true,
1235 + "schemaVersion": "1.0",
1236 + "config": {
1237 + "seeds": [
1238 + "rolex",
1239 + "patek-philippe",
1240 + "audemars-piguet",
1241 + "omega",
1242 + "cartier",
1243 + "tudor",
1244 + "vacheron-constantin",
1245 + "a-lange-and-sohne",
1246 + "iwc",
1247 + "jaeger-lecoultre",
1248 + "breitling",
1249 + "panerai",
1250 + "grand-seiko",
1251 + "f-p-journe",
1252 + "zenith"
1253 + ],
1254 + "pagesPerSeed": 1
1255 + }
1256 + },
1257 + {
1258 + "id": "digimoncard",
1259 + "displayName": "DigimonCard.io (Digimon Card Game)",
1260 + "sourceId": "digimoncard",
1261 + "sourceName": "DigimonCard.io",
1262 + "sourceType": "catalog",
1263 + "sourceUrl": "https://digimoncard.io",
1264 + "module": "api/digimoncard",
1265 + "enginePriority": [
1266 + "api"
1267 + ],
1268 + "categories": [
1269 + "digimon_tcg"
1270 + ],
1271 + "regions": [
1272 + "US",
1273 + "JP"
1274 + ],
1275 + "languages": [
1276 + "en"
1277 + ],
1278 + "currency": [],
1279 + "supportsListings": false,
1280 + "supportsSold": false,
1281 + "supportsAuctions": false,
1282 + "supportsImages": true,
1283 + "supportsCatalog": true,
1284 + "supportsPopulation": false,
1285 + "supportsLookup": true,
1286 + "refreshFrequencyMinutes": 10080,
1287 + "priority": "low",
1288 + "trustScore": 0.7,
1289 + "attributionRequired": true,
1290 + "termsUrl": "https://digimoncard.io/api-public/",
1291 + "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).",
1292 + "enabled": true,
1293 + "schemaVersion": "1.0",
1294 + "config": {}
1295 + },
729 1296 {
730 1297 "id": "discogs",
731 1298 "displayName": "Discogs (releases + marketplace lows)",
@@ -805,31 +1372,23 @@
805 1372 }
806 1373 },
807 1374 {
808 − "id": "goldin",
809 − "displayName": "Goldin (sold auction results)",
810 − "sourceId": "goldin",
811 − "sourceName": "Goldin",
812 − "sourceType": "auction_house",
813 − "sourceUrl": "https://goldin.co",
814 − "module": "scrapfly/goldin",
1375 + "id": "fashionphile",
1376 + "displayName": "FASHIONPHILE (pre-owned luxury listings & sold items)",
1377 + "sourceId": "fashionphile",
1378 + "sourceName": "FASHIONPHILE",
1379 + "sourceType": "marketplace",
1380 + "sourceUrl": "https://www.fashionphile.com",
1381 + "module": "api/fashionphile",
815 1382 "enginePriority": [
816 − "scrapfly"
1383 + "api"
817 1384 ],
818 1385 "categories": [
819 − "pokemon",
820 − "magic_the_gathering",
821 − "yugioh",
822 − "one_piece_card_game",
823 − "basketball_cards",
824 − "baseball_cards",
825 − "football_cards",
826 − "hockey_cards",
827 − "soccer_cards",
828 − "f1_cards",
829 − "sports_memorabilia",
830 − "marvel_comics",
831 − "nintendo_games",
832 − "non_sport_cards"
1386 + "luxury_handbags",
1387 + "watches",
1388 + "other_watches",
1389 + "rolex",
1390 + "jewelry",
1391 + "fashion_streetwear"
833 1392 ],
834 1393 "regions": [
835 1394 "US"
@@ -840,27 +1399,224 @@
840 1399 "currency": [
841 1400 "USD"
842 1401 ],
843 − "supportsListings": false,
844 − "supportsSold": true,
1402 + "supportsListings": true,
1403 + "supportsSold": false,
845 1404 "supportsAuctions": false,
846 1405 "supportsImages": true,
847 1406 "supportsCatalog": false,
848 1407 "supportsPopulation": false,
849 1408 "supportsLookup": true,
850 − "refreshFrequencyMinutes": 360,
851 − "priority": "high",
852 − "trustScore": 0.9,
1409 + "refreshFrequencyMinutes": 720,
1410 + "priority": "medium",
1411 + "trustScore": 0.8,
853 1412 "attributionRequired": true,
854 − "termsUrl": "https://goldin.co/useragreement",
855 − "accessNotes": "Public 'Sold Items' result grids (https://goldin.co/buy/sc/<subcategory>?show_only=Sold%20Items&sort=Most_Recent_Bids&number_of_lots=240) rendered through Scrapfly (asp + JS rendering, ~6 credits per page of 240 sold lots). goldin.co/robots.txt allows /buy/ and /item/ and disallows /api/; we never call goldin.co/api ourselves — the lot data is read from the XHR (lots_v2 on their CloudFront search endpoint) that the public page performs during rendering, exactly what a browser does. Plain HTTP and Firecrawl return the empty SPA shell / an Akamai challenge. Prices: the grid shows the final price INCLUDING buyer's premium (current_price × (1 + buyer_premium%)); we store that as the sale price with buyerPremiumIncluded=true and keep the hammer price and BP % in metadata. Sale date = lot end_timestamp; lots whose end_timestamp is in the future (placeholder private-sale dates like 2235-06-04) are skipped and counted as anomalies. Cert numbers appear only in item-page descriptions (lookup).",
1413 + "termsUrl": "https://www.fashionphile.com/terms-of-use",
1414 + "accessNotes": "Public Shopify storefront feed (www.fashionphile.com/collections/<brand>/products.json) fetched over plain HTTPS with the RareIndex user agent; robots.txt allows /collections/ (only sort/filter permutations, cart, account and checkout paths are disallowed). Each product is one authenticated pre-owned item priced in USD; `available:false` on a still-published product means the item shows SOLD on the site, which is recorded as a listing with availability=sold (the last asking price is not asserted as the transaction price). Condition ratings are only on HTML product pages and are not fetched. 1.5 s between requests, 250 products per page.",
856 1415 "enabled": true,
857 1416 "schemaVersion": "1.0",
858 1417 "config": {
859 1418 "seeds": [
860 − {
861 − "path": "/buy/sc/pokemon",
862 − "categorySlug": "pokemon"
863 − },
1419 + "hermes",
1420 + "chanel",
1421 + "louis-vuitton",
1422 + "dior",
1423 + "gucci",
1424 + "goyard",
1425 + "fendi",
1426 + "bottega-veneta",
1427 + "prada",
1428 + "celine",
1429 + "loewe",
1430 + "saint-laurent",
1431 + "rolex",
1432 + "cartier",
1433 + "van-cleef-arpels"
1434 + ],
1435 + "pagesPerSeed": 2
1436 + }
1437 + },
1438 + {
1439 + "id": "flight-club",
1440 + "displayName": "Flight Club (sneaker lowest asks & retail)",
1441 + "sourceId": "flight-club",
1442 + "sourceName": "Flight Club",
1443 + "sourceType": "marketplace",
1444 + "sourceUrl": "https://www.flightclub.com",
1445 + "module": "firecrawl/flight-club",
1446 + "enginePriority": [
1447 + "firecrawl",
1448 + "scrapfly"
1449 + ],
1450 + "categories": [
1451 + "sneakers",
1452 + "nike_jordan",
1453 + "adidas_yeezy",
1454 + "new_balance_asics_other"
1455 + ],
1456 + "regions": [
1457 + "US"
1458 + ],
1459 + "languages": [
1460 + "en"
1461 + ],
1462 + "currency": [
1463 + "USD"
1464 + ],
1465 + "supportsListings": true,
1466 + "supportsSold": false,
1467 + "supportsAuctions": false,
1468 + "supportsImages": true,
1469 + "supportsCatalog": true,
1470 + "supportsPopulation": false,
1471 + "supportsLookup": false,
1472 + "refreshFrequencyMinutes": 1440,
1473 + "priority": "medium",
1474 + "trustScore": 0.75,
1475 + "attributionRequired": true,
1476 + "termsUrl": "https://www.flightclub.com/terms",
1477 + "accessNotes": "Plain HTTPS requests receive 403 (robots.txt itself is not served to non-browser agents); Firecrawl's standard fetch returns the public category pages (flightclub.com/air-jordans?page=N …) whose __NEXT_DATA__ holds the search grid: product id, name, brand, image, lowest price, retail price and a slug ending with the style code. 1 Firecrawl credit per page of 30 products; 2 s between pages. Listings/asks only — Flight Club does not publish sale history publicly.",
1478 + "enabled": true,
1479 + "schemaVersion": "1.0",
1480 + "config": {
1481 + "seeds": [
1482 + "air-jordans",
1483 + "nike",
1484 + "adidas/adidas-yeezy",
1485 + "adidas",
1486 + "new-balance",
1487 + "asics"
1488 + ],
1489 + "pagesPerSeed": 2
1490 + }
1491 + },
1492 + {
1493 + "id": "fossilera",
1494 + "displayName": "FossilEra (fossils, minerals & meteorites dealer, USD)",
1495 + "sourceId": "fossilera",
1496 + "sourceName": "FossilEra",
1497 + "sourceType": "dealer",
1498 + "sourceUrl": "https://www.fossilera.com",
1499 + "module": "api/fossilera",
1500 + "enginePriority": [
1501 + "api",
1502 + "firecrawl"
1503 + ],
1504 + "categories": [
1505 + "fossils",
1506 + "minerals",
1507 + "meteorites"
1508 + ],
1509 + "regions": [
1510 + "US"
1511 + ],
1512 + "languages": [
1513 + "en"
1514 + ],
1515 + "currency": [
1516 + "USD"
1517 + ],
1518 + "supportsListings": true,
1519 + "supportsSold": false,
1520 + "supportsAuctions": false,
1521 + "supportsImages": true,
1522 + "supportsCatalog": false,
1523 + "supportsPopulation": false,
1524 + "supportsLookup": true,
1525 + "refreshFrequencyMinutes": 1440,
1526 + "priority": "low",
1527 + "trustScore": 0.85,
1528 + "attributionRequired": true,
1529 + "termsUrl": "https://www.fossilera.com/pages/terms-of-service",
1530 + "accessNotes": "Public category pages (fossilera.com/fossils-for-sale/<category>?page=N) and specimen pages fetched over plain HTTPS with the RareIndex user agent (robots.txt only disallows gift-card/checkout helpers). Each card gives the specimen title, price (and previous price when reduced), image and the FossilEra item number; specimen pages add species, geological age, location, formation, size and category. Every specimen is unique, so each becomes its own asset with one fixed-price listing (available or, when the page states it, sold — no sale date is published, so sold items are listings with availability=sold, never transactions). Compliance: fossilera states all items are legally collected; locality is kept in attributes.country/region so jurisdiction flags (taxonomy compliance 'jurisdiction_check') can be applied downstream.",
1531 + "enabled": true,
1532 + "schemaVersion": "1.0",
1533 + "config": {
1534 + "seeds": [
1535 + {
1536 + "path": "/fossils-for-sale/dinosaur-teeth",
1537 + "categorySlug": "fossils"
1538 + },
1539 + {
1540 + "path": "/fossils-for-sale/megalodon-teeth",
1541 + "categorySlug": "fossils"
1542 + },
1543 + {
1544 + "path": "/fossils-for-sale/ammonites",
1545 + "categorySlug": "fossils"
1546 + },
1547 + {
1548 + "path": "/fossils-for-sale/trilobites",
1549 + "categorySlug": "fossils"
1550 + },
1551 + {
1552 + "path": "/minerals-for-sale",
1553 + "categorySlug": "minerals"
1554 + },
1555 + {
1556 + "path": "/meteorites-for-sale",
1557 + "categorySlug": "meteorites"
1558 + }
1559 + ],
1560 + "pagesPerSeed": 1
1561 + }
1562 + },
1563 + {
1564 + "id": "goldin",
1565 + "displayName": "Goldin (sold auction results)",
1566 + "sourceId": "goldin",
1567 + "sourceName": "Goldin",
1568 + "sourceType": "auction_house",
1569 + "sourceUrl": "https://goldin.co",
1570 + "module": "scrapfly/goldin",
1571 + "enginePriority": [
1572 + "scrapfly"
1573 + ],
1574 + "categories": [
1575 + "pokemon",
1576 + "magic_the_gathering",
1577 + "yugioh",
1578 + "one_piece_card_game",
1579 + "basketball_cards",
1580 + "baseball_cards",
1581 + "football_cards",
1582 + "hockey_cards",
1583 + "soccer_cards",
1584 + "f1_cards",
1585 + "sports_memorabilia",
1586 + "marvel_comics",
1587 + "nintendo_games",
1588 + "non_sport_cards"
1589 + ],
1590 + "regions": [
1591 + "US"
1592 + ],
1593 + "languages": [
1594 + "en"
1595 + ],
1596 + "currency": [
1597 + "USD"
1598 + ],
1599 + "supportsListings": false,
1600 + "supportsSold": true,
1601 + "supportsAuctions": false,
1602 + "supportsImages": true,
1603 + "supportsCatalog": false,
1604 + "supportsPopulation": false,
1605 + "supportsLookup": true,
1606 + "refreshFrequencyMinutes": 360,
1607 + "priority": "high",
1608 + "trustScore": 0.9,
1609 + "attributionRequired": true,
1610 + "termsUrl": "https://goldin.co/useragreement",
1611 + "accessNotes": "Public 'Sold Items' result grids (https://goldin.co/buy/sc/<subcategory>?show_only=Sold%20Items&sort=Most_Recent_Bids&number_of_lots=240) rendered through Scrapfly (asp + JS rendering, ~6 credits per page of 240 sold lots). goldin.co/robots.txt allows /buy/ and /item/ and disallows /api/; we never call goldin.co/api ourselves — the lot data is read from the XHR (lots_v2 on their CloudFront search endpoint) that the public page performs during rendering, exactly what a browser does. Plain HTTP and Firecrawl return the empty SPA shell / an Akamai challenge. Prices: the grid shows the final price INCLUDING buyer's premium (current_price × (1 + buyer_premium%)); we store that as the sale price with buyerPremiumIncluded=true and keep the hammer price and BP % in metadata. Sale date = lot end_timestamp; lots whose end_timestamp is in the future (placeholder private-sale dates like 2235-06-04) are skipped and counted as anomalies. Cert numbers appear only in item-page descriptions (lookup).",
1612 + "enabled": true,
1613 + "schemaVersion": "1.0",
1614 + "config": {
1615 + "seeds": [
1616 + {
1617 + "path": "/buy/sc/pokemon",
1618 + "categorySlug": "pokemon"
1619 + },
864 1620 {
865 1621 "path": "/buy/sc/Magic_The_Gathering",
866 1622 "categorySlug": "magic_the_gathering"
@@ -918,80 +1674,71 @@
918 1674 }
919 1675 },
920 1676 {
921 − "id": "lorcast",
922 − "displayName": "Lorcast (Disney Lorcana)",
923 − "sourceId": "lorcast",
924 − "sourceName": "Lorcast",
925 − "sourceType": "catalog",
926 − "sourceUrl": "https://lorcast.com",
927 − "module": "api/lorcast",
1677 + "id": "grand-archive",
1678 + "displayName": "Grand Archive Index (official card API)",
1679 + "sourceId": "grand-archive",
1680 + "sourceName": "Grand Archive TCG Index",
1681 + "sourceType": "manufacturer",
1682 + "sourceUrl": "https://index.gatcg.com",
1683 + "module": "api/grand-archive",
928 1684 "enginePriority": [
929 1685 "api"
930 1686 ],
931 1687 "categories": [
932 − "disney_lorcana"
1688 + "other_tcg"
933 1689 ],
934 1690 "regions": [
935 − "US",
936 − "EU"
1691 + "US"
937 1692 ],
938 1693 "languages": [
939 1694 "en"
940 1695 ],
941 − "currency": [
942 − "USD"
943 − ],
1696 + "currency": [],
944 1697 "supportsListings": false,
945 1698 "supportsSold": false,
946 1699 "supportsAuctions": false,
947 1700 "supportsImages": true,
948 1701 "supportsCatalog": true,
949 − "supportsPopulation": false,
1702 + "supportsPopulation": true,
950 1703 "supportsLookup": false,
951 − "refreshFrequencyMinutes": 1440,
952 − "priority": "medium",
953 − "trustScore": 0.75,
1704 + "refreshFrequencyMinutes": 10080,
1705 + "priority": "low",
1706 + "trustScore": 0.85,
954 1707 "attributionRequired": true,
955 − "termsUrl": "https://lorcast.com/docs/api",
956 − "accessNotes": "Open Lorcast REST API (v0, no key): /sets then /cards/search?q=set:<code>&page=N. Cards carry TCGplayer ids and USD market prices (normal / foil) without a timestamp, so observations are dated by the fetch day with confidence 0.7. Enchanted cards are emitted as a single 'Enchanted' variant. ~4 req/s self-throttled.",
1708 + "termsUrl": "https://index.gatcg.com",
1709 + "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).",
957 1710 "enabled": true,
958 1711 "schemaVersion": "1.0",
959 − "config": {}
1712 + "config": {
1713 + "pageSize": 50
1714 + }
960 1715 },
961 1716 {
962 − "id": "novelship",
963 − "displayName": "Novelship (sneaker catalog, last sale & lowest ask)",
964 − "sourceId": "novelship",
965 − "sourceName": "Novelship",
966 − "sourceType": "marketplace",
967 − "sourceUrl": "https://novelship.com",
968 − "module": "api/novelship",
1717 + "id": "hobbysearch",
1718 + "displayName": "HobbySearch 1999.co.jp (Gunpla & model kits, JPY)",
1719 + "sourceId": "hobbysearch",
1720 + "sourceName": "HobbySearch",
1721 + "sourceType": "dealer",
1722 + "sourceUrl": "https://www.1999.co.jp",
1723 + "module": "firecrawl/hobbysearch",
969 1724 "enginePriority": [
970 − "api",
971 1725 "firecrawl",
972 1726 "scrapfly"
973 1727 ],
974 1728 "categories": [
975 − "sneakers",
976 − "nike_jordan",
977 − "adidas_yeezy",
978 − "new_balance_asics_other"
1729 + "gundam",
1730 + "action_figures",
1731 + "model_cars"
979 1732 ],
980 1733 "regions": [
981 − "SG",
982 − "AU",
983 − "NZ",
984 − "TW",
985 − "HK",
986 − "MY",
987 − "JP",
988 − "US"
1734 + "JP"
989 1735 ],
990 1736 "languages": [
991 − "en"
1737 + "en",
1738 + "ja"
992 1739 ],
993 1740 "currency": [
994 − "USD"
1741 + "JPY"
995 1742 ],
996 1743 "supportsListings": true,
997 1744 "supportsSold": false,
@@ -1000,200 +1747,281 @@
1000 1747 "supportsCatalog": true,
1001 1748 "supportsPopulation": false,
1002 1749 "supportsLookup": true,
1003 − "refreshFrequencyMinutes": 720,
1004 − "priority": "medium",
1005 − "trustScore": 0.7,
1750 + "refreshFrequencyMinutes": 1440,
1751 + "priority": "low",
1752 + "trustScore": 0.8,
1006 1753 "attributionRequired": true,
1007 − "termsUrl": "https://novelship.com/terms",
1008 − "accessNotes": "Public Novelship browse pages (novelship.com/sneakers/<brand>?page=N) and product pages fetched over plain HTTPS with the RareIndex user agent; robots.txt only disallows sell/auth/dashboard/pay paths. The server-rendered payload embeds each product's SKU (style code), colorway, retail cost, release date, last sale price, lowest listing price and 180-day sales count — no login or private endpoint is used. Prices are read as USD (the anonymous international storefront prices in US$; cost_retail matches US retail) with confidence 0.7; per-size asks are not exposed on listing pages. Sizes/stock are not fetched. 1.5 s politeness delay.",
1754 + "termsUrl": "https://www.1999.co.jp/eng/guide/",
1755 + "accessNotes": "Public English search/list pages (1999.co.jp/eng/search?...; robots.txt allows all for generic agents) and item pages. Direct HTTPS returns 403 to non-browser clients, so pages go through Firecrawl (1 credit per page of 60 items). Parsed per card: HobbySearch item id, title, street price and list price (JPY), discount, stock state (In Stock / Sold Out / Pre-Order / Back-order), image. Emits catalog items (list price = manufacturer MSRP) and the retailer's fixed-price listing. Gundam categorisation is keyword-based on the seed + title; other kits fall back to the seed's category.",
1009 1756 "enabled": true,
1010 1757 "schemaVersion": "1.0",
1011 1758 "config": {
1012 1759 "seeds": [
1013 − "jordan",
1014 − "nike",
1015 − "adidas",
1016 − "new-balance",
1017 − "asics",
1018 − "yeezy"
1760 + {
1761 + "key": "Gundam MG",
1762 + "categorySlug": "gundam"
1763 + },
1764 + {
1765 + "key": "Gundam RG",
1766 + "categorySlug": "gundam"
1767 + },
1768 + {
1769 + "key": "Gundam PG",
1770 + "categorySlug": "gundam"
1771 + },
1772 + {
1773 + "key": "HGUC",
1774 + "categorySlug": "gundam"
1775 + },
1776 + {
1777 + "key": "Nendoroid",
1778 + "categorySlug": "action_figures"
1779 + },
1780 + {
1781 + "key": "figma",
1782 + "categorySlug": "action_figures"
1783 + }
1019 1784 ],
1020 − "pagesPerSeed": 3
1785 + "pagesPerSeed": 1
1021 1786 }
1022 1787 },
1023 1788 {
1024 − "id": "optcg",
1025 − "displayName": "OPTCG API (One Piece Card Game)",
1026 − "sourceId": "optcgapi",
1027 − "sourceName": "OPTCG API",
1028 − "sourceType": "catalog",
1029 − "sourceUrl": "https://optcgapi.com",
1030 − "module": "api/optcg",
1789 + "id": "hypeboost",
1790 + "displayName": "Hypeboost (EU sneaker marketplace — lowest asks)",
1791 + "sourceId": "hypeboost",
1792 + "sourceName": "Hypeboost",
1793 + "sourceType": "marketplace",
1794 + "sourceUrl": "https://hypeboost.com",
1795 + "module": "firecrawl/hypeboost",
1031 1796 "enginePriority": [
1032 − "api"
1797 + "firecrawl",
1798 + "scrapfly"
1033 1799 ],
1034 1800 "categories": [
1035 − "one_piece_card_game"
1801 + "sneakers",
1802 + "nike_jordan",
1803 + "adidas_yeezy",
1804 + "new_balance_asics_other"
1036 1805 ],
1037 1806 "regions": [
1038 − "US"
1807 + "NL",
1808 + "EU"
1039 1809 ],
1040 1810 "languages": [
1041 1811 "en"
1042 1812 ],
1043 1813 "currency": [
1044 − "USD"
1814 + "EUR"
1045 1815 ],
1046 − "supportsListings": false,
1816 + "supportsListings": true,
1047 1817 "supportsSold": false,
1048 1818 "supportsAuctions": false,
1049 1819 "supportsImages": true,
1050 1820 "supportsCatalog": true,
1051 1821 "supportsPopulation": false,
1052 − "supportsLookup": false,
1822 + "supportsLookup": true,
1053 1823 "refreshFrequencyMinutes": 1440,
1054 − "priority": "medium",
1055 − "trustScore": 0.65,
1824 + "priority": "low",
1825 + "trustScore": 0.7,
1056 1826 "attributionRequired": true,
1057 − "termsUrl": "https://optcgapi.com/",
1058 − "accessNotes": "Open community API (no key): /api/allSets/ then /api/sets/<set_id>/ returning every card of the set with TCGplayer-derived market_price / inventory_price and a date_scraped, used as the observation date (confidence 0.7 / 0.65). Alternate arts share the card code and are distinguished by image id. One request per set, 0.5 s politeness.",
1827 + "termsUrl": "https://hypeboost.com/en/terms-and-conditions",
1828 + "accessNotes": "Plain HTTPS gets 403 (robots.txt not served to non-browser agents); Firecrawl's standard fetch returns the public category grid (hypeboost.com/en/category/sneakers/<brand>[?page=N]) with product name, EUR lowest price, brand/category data attributes and product id (36 per page, 1 credit). Product pages (lookup) expose the style code in schema.org Product sku. 2 s between pages.",
1059 1829 "enabled": true,
1060 1830 "schemaVersion": "1.0",
1061 − "config": {}
1831 + "config": {
1832 + "seeds": [
1833 + "air-jordan",
1834 + "nike",
1835 + "adidas",
1836 + "yeezy",
1837 + "new-balance",
1838 + "asics"
1839 + ],
1840 + "pagesPerSeed": 2
1841 + }
1062 1842 },
1063 1843 {
1064 − "id": "pcgs-priceguide",
1065 − "displayName": "PCGS Price Guide (US coins)",
1066 − "sourceId": "pcgs",
1067 − "sourceName": "PCGS",
1068 − "sourceType": "grading_company",
1069 − "sourceUrl": "https://www.pcgs.com",
1070 − "module": "firecrawl/pcgs-priceguide",
1844 + "id": "idealwine",
1845 + "displayName": "iDealwine Price Estimate (wine price index)",
1846 + "sourceId": "idealwine",
1847 + "sourceName": "iDealwine",
1848 + "sourceType": "pricing_guide",
1849 + "sourceUrl": "https://www.idealwine.com",
1850 + "module": "api/idealwine",
1071 1851 "enginePriority": [
1852 + "api",
1072 1853 "firecrawl",
1073 1854 "scrapfly"
1074 1855 ],
1075 1856 "categories": [
1076 − "coins"
1857 + "wine"
1077 1858 ],
1078 1859 "regions": [
1079 − "US"
1860 + "FR",
1861 + "EU"
1080 1862 ],
1081 1863 "languages": [
1082 − "en"
1864 + "en",
1865 + "fr"
1083 1866 ],
1084 1867 "currency": [
1085 − "USD"
1868 + "EUR"
1086 1869 ],
1087 1870 "supportsListings": false,
1088 1871 "supportsSold": false,
1089 1872 "supportsAuctions": false,
1090 − "supportsImages": false,
1873 + "supportsImages": true,
1091 1874 "supportsCatalog": true,
1092 1875 "supportsPopulation": false,
1093 1876 "supportsLookup": false,
1094 1877 "refreshFrequencyMinutes": 10080,
1095 − "priority": "low",
1096 − "trustScore": 0.9,
1878 + "priority": "medium",
1879 + "trustScore": 0.85,
1097 1880 "attributionRequired": true,
1098 − "termsUrl": "https://www.pcgs.com/legal",
1099 − "accessNotes": "Public PCGS Price Guide category pages (pcgs.com/prices/detail/<series>/<id>/most-active) — no login required; robots.txt does not restrict /prices. Plain HTTPS is refused by the CDN (403) so pages are rendered through Firecrawl (1 credit per category page ≈ 100 coins × 10 grades). Values are PCGS retail guide values in USD per grade (columns 4…70 and '+' grades), dated with the page's 'Last Update' stamp. Stored as guide_value observations with grader 'pcgs' and grade like MS65 / PR65 / MS65+; PCGS coin numbers are kept as identifiers (pcgs_number). Auction Prices Realized and the Population Report on pcgs.com require a Collectors account and are not fetched.",
1881 + "termsUrl": "https://www.idealwine.com/en/cgv",
1882 + "accessNotes": "Public price-index pages (/en/cote/<region>?page=N) of iDealwine, the leading French wine auction house: each card lists a wine (region > appellation, colour, producer picture) with the iDealwine Price Estimate per vintage in EUR per 75 cl bottle. The estimate is computed by iDealwine from auction results (hammer price INCLUDING buyer's premium, updated weekly from 3M+ French auction prices since 1992) — we store it as a guide_value price observation, never as a transaction. Plain HTTPS with the RareIndex user agent; robots.txt disallows only /api/, account and login paths. No timestamp is published per estimate, so observationDate = fetch date (confidence 0.75). 2 s politeness delay; regions and pages per run are configurable.",
1883 + "enabled": true,
1884 + "schemaVersion": "1.0",
1885 + "config": {
1886 + "regions": [
1887 + "bordeaux",
1888 + "bourgogne",
1889 + "rhone",
1890 + "champagne",
1891 + "loire",
1892 + "italie",
1893 + "alsace",
1894 + "languedoc",
1895 + "provence",
1896 + "jura"
1897 + ],
1898 + "pagesPerRegion": 3
1899 + }
1900 + },
1901 + {
1902 + "id": "laced",
1903 + "displayName": "Laced (UK sneaker marketplace — asks per size)",
1904 + "sourceId": "laced",
1905 + "sourceName": "Laced",
1906 + "sourceType": "marketplace",
1907 + "sourceUrl": "https://www.laced.com",
1908 + "module": "api/laced",
1909 + "enginePriority": [
1910 + "api",
1911 + "firecrawl"
1912 + ],
1913 + "categories": [
1914 + "sneakers",
1915 + "nike_jordan",
1916 + "adidas_yeezy",
1917 + "new_balance_asics_other"
1918 + ],
1919 + "regions": [
1920 + "GB",
1921 + "EU"
1922 + ],
1923 + "languages": [
1924 + "en"
1925 + ],
1926 + "currency": [
1927 + "GBP"
1928 + ],
1929 + "supportsListings": true,
1930 + "supportsSold": false,
1931 + "supportsAuctions": false,
1932 + "supportsImages": true,
1933 + "supportsCatalog": true,
1934 + "supportsPopulation": false,
1935 + "supportsLookup": true,
1936 + "refreshFrequencyMinutes": 720,
1937 + "priority": "medium",
1938 + "trustScore": 0.75,
1939 + "attributionRequired": true,
1940 + "termsUrl": "https://www.laced.com/pages/terms-and-conditions",
1941 + "accessNotes": "Public brand pages (laced.com/<brand>) list product slugs; each product page embeds a schema.org ProductGroup with the style code (sku), brand and one Product per size carrying the current lowest ask in GBP. Plain HTTPS with the RareIndex user agent; robots.txt allows everything except account/admin. New-condition marketplace (deadstock). 1.5 s between pages; productsPerSeed caps product-page fetches per run.",
1100 1942 "enabled": true,
1101 1943 "schemaVersion": "1.0",
1102 1944 "config": {
1103 1945 "seeds": [
1104 − "morgan-dollar/744",
1105 − "peace-dollar/26",
1106 − "lincoln-cent-wheat-reverse/46",
1107 − "indian-cent/44",
1108 − "buffalo-nickel/83",
1109 − "mercury-dime/703",
1110 − "walking-liberty-half-dollar/733",
1111 − "standing-liberty-quarter/111",
1112 − "franklin-half-dollar/734",
1113 − "kennedy-half-dollar/125",
1114 − "washington-quarter/112",
1115 − "trade-dollar/743",
1116 − "flying-eagle-cent/664",
1117 − "barber-half-dollar/732",
1118 − "silver-eagles/939",
1119 − "liberty-seated-dollar/29"
1946 + "air-jordan",
1947 + "nike",
1948 + "adidas",
1949 + "yeezy",
1950 + "new-balance",
1951 + "asics"
1120 1952 ],
1121 − "designations": [
1122 − "ms"
1123 − ]
1953 + "productsPerSeed": 24
1124 1954 }
1125 1955 },
1126 1956 {
1127 − "id": "phillips-watches",
1128 − "displayName": "Phillips Watches (auction results)",
1129 − "sourceId": "phillips",
1130 − "sourceName": "Phillips",
1131 − "sourceType": "auction_house",
1132 − "sourceUrl": "https://www.phillips.com",
1133 − "module": "firecrawl/phillips-watches",
1957 + "id": "lego-shop",
1958 + "displayName": "LEGO.com Shop (official retail price & availability)",
1959 + "sourceId": "lego-shop",
1960 + "sourceName": "LEGO Shop (official)",
1961 + "sourceType": "manufacturer",
1962 + "sourceUrl": "https://www.lego.com",
1963 + "module": "firecrawl/lego-shop",
1134 1964 "enginePriority": [
1135 1965 "firecrawl",
1136 1966 "scrapfly"
1137 1967 ],
1138 1968 "categories": [
1139 − "watches",
1140 − "rolex",
1141 − "patek_philippe",
1142 − "audemars_piguet",
1143 − "omega",
1144 − "other_watches"
1969 + "lego_sets",
1970 + "lego"
1145 1971 ],
1146 1972 "regions": [
1147 − "CH",
1148 − "US",
1149 − "HK",
1150 − "GB"
1973 + "US"
1151 1974 ],
1152 1975 "languages": [
1153 1976 "en"
1154 1977 ],
1155 1978 "currency": [
1156 − "CHF",
1157 − "USD",
1158 − "HKD",
1159 − "GBP",
1160 − "EUR"
1979 + "USD"
1161 1980 ],
1162 − "supportsListings": false,
1163 − "supportsSold": true,
1164 − "supportsAuctions": true,
1981 + "supportsListings": true,
1982 + "supportsSold": false,
1983 + "supportsAuctions": false,
1165 1984 "supportsImages": true,
1166 − "supportsCatalog": false,
1985 + "supportsCatalog": true,
1167 1986 "supportsPopulation": false,
1168 − "supportsLookup": false,
1169 − "refreshFrequencyMinutes": 10080,
1170 − "priority": "high",
1987 + "supportsLookup": true,
1988 + "refreshFrequencyMinutes": 1440,
1989 + "priority": "medium",
1171 1990 "trustScore": 0.95,
1172 1991 "attributionRequired": true,
1173 − "termsUrl": "https://www.phillips.com/about/terms",
1174 − "accessNotes": "Past watch sales are discovered from the public /auctions/past page (plain HTTPS; embedded JSON with saleNumber, title, end date, location). Each sale page is rendered through Firecrawl (JS wait ~6 s, 1 credit) to read the public lot list: lot number, maker, reference, model, estimate and 'Sold For' (Phillips publishes prices realised including buyer's premium). robots.txt disallows only /search and filter paths. No login, no bidder data. Large sales may lazy-load beyond the first ~70 lots — coverage is recorded per run.",
1992 + "termsUrl": "https://www.lego.com/en-us/legal/notices-and-policies/terms-of-use",
1993 + "accessNotes": "Official manufacturer store. Public theme pages (lego.com/en-us/themes/<theme>?page=N) and product pages are rendered client-side and return 403 to plain non-browser clients, so they are fetched with Firecrawl (1 credit per page; ~22 products per theme page). Parsed per product leaf: set number (from the product URL), name, US retail price, badges (New / Retiring soon / Coming Soon / Exclusive / Hard to find / Sold out), image; product pages add availability, age and piece count. Emits catalog items (MSRP USD, identifiers.lego_set_number shared with PriceCharting/Brickset/BrickEconomy) and the official-store fixed-price listing. robots.txt only disallows account/checkout/identity paths.",
1175 1994 "enabled": true,
1176 1995 "schemaVersion": "1.0",
1177 1996 "config": {
1178 − "pastAuctionsUrl": "https://www.phillips.com/auctions/past",
1179 − "salesPerRun": 6,
1180 − "titleFilter": "watch"
1997 + "themes": [
1998 + "star-wars",
1999 + "icons",
2000 + "technic",
2001 + "ideas",
2002 + "harry-potter",
2003 + "marvel",
2004 + "architecture",
2005 + "ninjago",
2006 + "creator-expert",
2007 + "botanical-collection"
2008 + ],
2009 + "pagesPerTheme": 1
1181 2010 }
1182 2011 },
1183 2012 {
1184 − "id": "pokemontcg",
1185 − "displayName": "Pokémon TCG API (pokemontcg.io)",
1186 − "sourceId": "pokemontcg",
1187 − "sourceName": "Pokémon TCG API",
2013 + "id": "lorcast",
2014 + "displayName": "Lorcast (Disney Lorcana)",
2015 + "sourceId": "lorcast",
2016 + "sourceName": "Lorcast",
1188 2017 "sourceType": "catalog",
1189 − "sourceUrl": "https://pokemontcg.io",
1190 − "module": "api/pokemontcg",
2018 + "sourceUrl": "https://lorcast.com",
2019 + "module": "api/lorcast",
1191 2020 "enginePriority": [
1192 − "api",
1193 − "feed"
2021 + "api"
1194 2022 ],
1195 2023 "categories": [
1196 − "pokemon"
2024 + "disney_lorcana"
1197 2025 ],
1198 2026 "regions": [
1199 2027 "US",
@@ -1203,8 +2031,7 @@
1203 2031 "en"
1204 2032 ],
1205 2033 "currency": [
1206 − "USD",
1207 − "EUR"
2034 + "USD"
1208 2035 ],
1209 2036 "supportsListings": false,
1210 2037 "supportsSold": false,
@@ -1214,92 +2041,781 @@
1214 2041 "supportsPopulation": false,
1215 2042 "supportsLookup": false,
1216 2043 "refreshFrequencyMinutes": 1440,
1217 − "priority": "high",
1218 − "trustScore": 0.8,
2044 + "priority": "medium",
2045 + "trustScore": 0.75,
1219 2046 "attributionRequired": true,
1220 − "termsUrl": "https://docs.pokemontcg.io/",
1221 − "accessNotes": "Public REST API v2 (https://api.pokemontcg.io/v2). Optional X-Api-Key (POKEMONTCG_API_KEY) raises limits (20k/day) — without a key ~1k/day and 30/min. Crawl = sets, then cards per set (pageSize 250, cursor {setIndex,page}). The API is intermittently unavailable (5xx); the connector retries with backoff and falls back to the maintainers' public GitHub mirror (PokemonTCG/pokemon-tcg-data, same card schema, no prices) for the catalog when the API keeps failing. Prices are TCGplayer (USD, per printing variant: normal/holofoil/reverseHolofoil/1stEdition…) and Cardmarket (EUR) daily aggregates with their own updatedAt → stored as price_observations, never as sales. Pokémon © Nintendo/Creatures/GAME FREAK; data attributed to pokemontcg.io.",
2047 + "termsUrl": "https://lorcast.com/docs/api",
2048 + "accessNotes": "Open Lorcast REST API (v0, no key): /sets then /cards/search?q=set:<code>&page=N. Cards carry TCGplayer ids and USD market prices (normal / foil) without a timestamp, so observations are dated by the fetch day with confidence 0.7. Enchanted cards are emitted as a single 'Enchanted' variant. ~4 req/s self-throttled.",
1222 2049 "enabled": true,
1223 2050 "schemaVersion": "1.0",
1224 − "config": {
1225 − "pageSize": 250,
1226 − "requestIntervalMs": 2100,
1227 − "mirrorBase": "https://raw.githubusercontent.com/PokemonTCG/pokemon-tcg-data/master"
1228 − }
2051 + "config": {}
1229 2052 },
1230 2053 {
1231 − "id": "pricecharting",
1232 − "displayName": "PriceCharting",
1233 − "sourceId": "pricecharting",
1234 − "sourceName": "PriceCharting",
1235 − "sourceType": "pricing_guide",
1236 − "sourceUrl": "https://www.pricecharting.com",
1237 − "module": "api/pricecharting",
2054 + "id": "lyon-turnbull",
2055 + "displayName": "Lyon & Turnbull (auction results)",
2056 + "sourceId": "lyon-turnbull",
2057 + "sourceName": "Lyon & Turnbull",
2058 + "sourceType": "auction_house",
2059 + "sourceUrl": "https://www.lyonandturnbull.com",
2060 + "module": "api/lyon-turnbull",
1238 2061 "enginePriority": [
1239 2062 "api",
1240 2063 "firecrawl",
1241 2064 "scrapfly"
1242 2065 ],
1243 2066 "categories": [
1244 − "video_games",
1245 − "nintendo_games",
1246 − "sega_games",
1247 − "playstation_games",
1248 − "xbox_games",
1249 − "atari_retro_games",
1250 − "pc_games",
1251 − "lego_sets",
1252 − "funko",
1253 − "comics",
1254 − "marvel_comics",
1255 − "dc_comics",
1256 − "independent_comics",
1257 − "trading_cards",
1258 − "pokemon",
1259 − "magic_the_gathering",
1260 − "yugioh"
2067 + "art",
2068 + "contemporary_art",
2069 + "photography",
2070 + "design_furniture",
2071 + "antiques",
2072 + "jewelry",
2073 + "silver",
2074 + "watches",
2075 + "other_watches",
2076 + "books",
2077 + "whisky",
2078 + "porcelain",
2079 + "glass_crystal",
2080 + "clocks",
2081 + "scientific_instruments"
1261 2082 ],
1262 2083 "regions": [
1263 − "US"
2084 + "GB"
1264 2085 ],
1265 2086 "languages": [
1266 2087 "en"
1267 2088 ],
1268 2089 "currency": [
1269 − "USD"
2090 + "GBP"
1270 2091 ],
1271 2092 "supportsListings": false,
1272 2093 "supportsSold": true,
1273 − "supportsAuctions": false,
2094 + "supportsAuctions": true,
1274 2095 "supportsImages": true,
1275 − "supportsCatalog": true,
2096 + "supportsCatalog": false,
1276 2097 "supportsPopulation": false,
1277 − "supportsLookup": true,
2098 + "supportsLookup": false,
1278 2099 "refreshFrequencyMinutes": 1440,
1279 − "priority": "high",
1280 − "trustScore": 0.7,
2100 + "priority": "medium",
2101 + "trustScore": 0.9,
1281 2102 "attributionRequired": true,
1282 − "termsUrl": "https://www.pricecharting.com/page/terms-of-service",
1283 − "accessNotes": "Public product pages fetched over plain HTTPS with the RareIndex user agent (robots.txt only disallows /buy, /publish-offer, /stripe-connect). Each product page exposes PriceCharting guide values per condition and a table of recently completed eBay sales (date, title, price, eBay item id) — we store those as sales with the PriceCharting page as source URL. No account, no API key, no photos (TimeWarp is paid and not used). Console listings paginate through the public ?format=json cursor endpoint. ~1 request per product, 1.5 s politeness delay. Trading-card consoles (Pokémon, Magic, Yu-Gi-Oh!) are discovered from the public /category/<game>-cards pages; card pages expose guide values and eBay sales per grade (Ungraded, Grade 1–9.5, TAG/ACE/SGC/CGC/PSA/BGS 10, BGS 10 Black, CGC 10 Pristine). Graded rows without a named company use the generic grader 'graded' unless the eBay title names it. Pokémon sets are mapped to pokemontcg ids/codes through the maintainers' GitHub mirror and Magic sets/cards to Scryfall (one exact-name API lookup per Magic product, ≤ 10 req/s) so sales attach to the API catalogs' canonical assets.",
2103 + "termsUrl": "https://www.lyonandturnbull.com/auctions/buy-sell/buy",
2104 + "accessNotes": "Edinburgh/London/Glasgow auction house. Past auctions are listed in the public /auctions/past-auctions page (Next.js __NEXT_DATA__: title, sale number, date, link); each auction page embeds the full lot list as JSON (lot number, title, sub-title, estimate, hammer_price, status sold/unsold, images, buyer's premium tiers). Plain HTTPS with the RareIndex user agent; robots.txt disallows only /account, /auth, /search and collection_* paths. We store the HAMMER price (buyer_premium_included=false) and record the published premium tiers in metadata; sale date = auction session date. No login, no bidder data. 2 s politeness delay.",
1284 2105 "enabled": true,
1285 − "schemaVersion": "2.0",
2106 + "schemaVersion": "1.0",
1286 2107 "config": {
1287 − "seeds": [
1288 − "nintendo-64",
1289 − "nes",
1290 − "super-nintendo",
1291 − "gamecube",
1292 − "gameboy",
1293 − "gameboy-color",
1294 − "gameboy-advance",
1295 − "nintendo-ds",
1296 − "nintendo-switch",
1297 − "wii",
1298 − "playstation",
1299 − "playstation-2",
1300 − "playstation-3",
1301 − "playstation-4",
1302 − "playstation-5",
2108 + "pastAuctionsUrl": "https://www.lyonandturnbull.com/auctions/past-auctions",
2109 + "auctionsPerRun": 3
2110 + }
2111 + },
2112 + {
2113 + "id": "ma-shops",
2114 + "displayName": "MA-Shops (European coin, banknote & militaria dealer network)",
2115 + "sourceId": "ma-shops",
2116 + "sourceName": "MA-Shops",
2117 + "sourceType": "marketplace",
2118 + "sourceUrl": "https://www.ma-shops.com",
2119 + "module": "api/ma-shops",
2120 + "enginePriority": [
2121 + "api",
2122 + "firecrawl"
2123 + ],
2124 + "categories": [
2125 + "coins",
2126 + "banknotes",
2127 + "medals",
2128 + "militaria"
2129 + ],
2130 + "regions": [
2131 + "DE",
2132 + "EU"
2133 + ],
2134 + "languages": [
2135 + "en",
2136 + "de"
2137 + ],
2138 + "currency": [
2139 + "EUR",
2140 + "USD",
2141 + "CAD"
2142 + ],
2143 + "supportsListings": true,
2144 + "supportsSold": false,
2145 + "supportsAuctions": false,
2146 + "supportsImages": true,
2147 + "supportsCatalog": false,
2148 + "supportsPopulation": false,
2149 + "supportsLookup": false,
2150 + "refreshFrequencyMinutes": 1440,
2151 + "priority": "low",
2152 + "trustScore": 0.75,
2153 + "attributionRequired": true,
2154 + "termsUrl": "https://www.ma-shops.com/shops/help.php?id=1",
2155 + "accessNotes": "Public search gallery pages (ma-shops.com/search.php?keywords=…, 45 items per page; category landing pages only show a handful of featured items) fetched over plain HTTPS with the RareIndex user agent; robots.txt is fully permissive. Each gallery cell gives the dealer's item URL, title, price with the displayed currency (the site picks a currency per visitor — CAN$, US$ or EUR — which is parsed and stored as-is), the dealer name and the primary image. Emits dealer asks (fixed-price listings). Militaria items carry the taxonomy compliance flags (jurisdiction_check, restricted_symbols_review) and are presented neutrally.",
2156 + "enabled": true,
2157 + "schemaVersion": "1.0",
2158 + "config": {
2159 + "seeds": [
2160 + {
2161 + "path": "/search.php?keywords=denarius",
2162 + "categorySlug": "coins",
2163 + "label": "Roman denarius"
2164 + },
2165 + {
2166 + "path": "/search.php?keywords=thaler",
2167 + "categorySlug": "coins",
2168 + "label": "Thaler"
2169 + },
2170 + {
2171 + "path": "/search.php?keywords=tetradrachm",
2172 + "categorySlug": "coins",
2173 + "label": "Greek tetradrachm"
2174 + },
2175 + {
2176 + "path": "/search.php?keywords=20+mark+gold",
2177 + "categorySlug": "coins",
2178 + "label": "German 20 Mark gold"
2179 + },
2180 + {
2181 + "path": "/search.php?keywords=sovereign",
2182 + "categorySlug": "coins",
2183 + "label": "Sovereign"
2184 + },
2185 + {
2186 + "path": "/search.php?keywords=banknote",
2187 + "categorySlug": "banknotes",
2188 + "label": "Banknotes"
2189 + },
2190 + {
2191 + "path": "/search.php?keywords=medaille",
2192 + "categorySlug": "medals",
2193 + "label": "Medals"
2194 + }
2195 + ],
2196 + "pagesPerSeed": 1
2197 + }
2198 + },
2199 + {
2200 + "id": "mecum",
2201 + "displayName": "Mecum Auctions (results)",
2202 + "sourceId": "mecum",
2203 + "sourceName": "Mecum Auctions",
2204 + "sourceType": "auction_house",
2205 + "sourceUrl": "https://www.mecum.com",
2206 + "module": "firecrawl/mecum",
2207 + "enginePriority": [
2208 + "firecrawl"
2209 + ],
2210 + "categories": [
2211 + "automobiles",
2212 + "motorcycles",
2213 + "automotive_memorabilia"
2214 + ],
2215 + "regions": [
2216 + "US"
2217 + ],
2218 + "languages": [
2219 + "en"
2220 + ],
2221 + "currency": [
2222 + "USD"
2223 + ],
2224 + "supportsListings": false,
2225 + "supportsSold": true,
2226 + "supportsAuctions": false,
2227 + "supportsImages": true,
2228 + "supportsCatalog": false,
2229 + "supportsPopulation": false,
2230 + "supportsLookup": false,
2231 + "refreshFrequencyMinutes": 720,
2232 + "priority": "high",
2233 + "trustScore": 0.9,
2234 + "attributionRequired": true,
2235 + "termsUrl": "https://www.mecum.com/terms-and-conditions/",
2236 + "accessNotes": "Public results: the /results/ page lists completed auctions (plain HTTPS), each auction's lot list (/auctions/<slug>/lots/?page=N, 24 lots per page) is rendered through Firecrawl because prices are client-rendered. robots.txt allows the pages (only /search/ is disallowed). Lots carrying the 'sold' badge with a price become sales; 'bid goes on'/unsold lots are skipped. Sale date = first day of the auction's date range (range kept in metadata). Mecum does not state on the results page whether displayed prices include the buyer's premium → buyer_premium_included=null (flagged for review). 1 credit per page, 2 s politeness delay.",
2237 + "enabled": true,
2238 + "schemaVersion": "1.0",
2239 + "config": {
2240 + "auctionsPerRun": 1,
2241 + "lotPagesPerAuction": 10
2242 + }
2243 + },
2244 + {
2245 + "id": "mtggoldfish",
2246 + "displayName": "MTGGoldfish (paper prices & history)",
2247 + "sourceId": "mtggoldfish",
2248 + "sourceName": "MTGGoldfish",
2249 + "sourceType": "pricing_guide",
2250 + "sourceUrl": "https://www.mtggoldfish.com",
2251 + "module": "api/mtggoldfish",
2252 + "enginePriority": [
2253 + "api",
2254 + "firecrawl"
2255 + ],
2256 + "categories": [
2257 + "magic_the_gathering"
2258 + ],
2259 + "regions": [
2260 + "US"
2261 + ],
2262 + "languages": [
2263 + "en"
2264 + ],
2265 + "currency": [
2266 + "USD"
2267 + ],
2268 + "supportsListings": false,
2269 + "supportsSold": false,
2270 + "supportsAuctions": false,
2271 + "supportsImages": true,
2272 + "supportsCatalog": true,
2273 + "supportsPopulation": false,
2274 + "supportsLookup": true,
2275 + "refreshFrequencyMinutes": 1440,
2276 + "priority": "medium",
2277 + "trustScore": 0.75,
2278 + "attributionRequired": true,
2279 + "termsUrl": "https://www.mtggoldfish.com/robots.txt",
2280 + "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.",
2281 + "enabled": true,
2282 + "schemaVersion": "1.0",
2283 + "config": {
2284 + "seeds": [],
2285 + "maxSetsPerRun": 60,
2286 + "history": {
2287 + "enabled": true,
2288 + "minPrice": 100,
2289 + "maxPerRun": 150,
2290 + "days": 1095
2291 + }
2292 + }
2293 + },
2294 + {
2295 + "id": "novelship",
2296 + "displayName": "Novelship (sneaker catalog, last sale & lowest ask)",
2297 + "sourceId": "novelship",
2298 + "sourceName": "Novelship",
2299 + "sourceType": "marketplace",
2300 + "sourceUrl": "https://novelship.com",
2301 + "module": "api/novelship",
2302 + "enginePriority": [
2303 + "api",
2304 + "firecrawl",
2305 + "scrapfly"
2306 + ],
2307 + "categories": [
2308 + "sneakers",
2309 + "nike_jordan",
2310 + "adidas_yeezy",
2311 + "new_balance_asics_other"
2312 + ],
2313 + "regions": [
2314 + "SG",
2315 + "AU",
2316 + "NZ",
2317 + "TW",
2318 + "HK",
2319 + "MY",
2320 + "JP",
2321 + "US"
2322 + ],
2323 + "languages": [
2324 + "en"
2325 + ],
2326 + "currency": [
2327 + "USD"
2328 + ],
2329 + "supportsListings": true,
2330 + "supportsSold": false,
2331 + "supportsAuctions": false,
2332 + "supportsImages": true,
2333 + "supportsCatalog": true,
2334 + "supportsPopulation": false,
2335 + "supportsLookup": true,
2336 + "refreshFrequencyMinutes": 720,
2337 + "priority": "medium",
2338 + "trustScore": 0.7,
2339 + "attributionRequired": true,
2340 + "termsUrl": "https://novelship.com/terms",
2341 + "accessNotes": "Public Novelship browse pages (novelship.com/sneakers/<brand>?page=N) and product pages fetched over plain HTTPS with the RareIndex user agent; robots.txt only disallows sell/auth/dashboard/pay paths. The server-rendered payload embeds each product's SKU (style code), colorway, retail cost, release date, last sale price, lowest listing price and 180-day sales count — no login or private endpoint is used. Prices are read as USD (the anonymous international storefront prices in US$; cost_retail matches US retail) with confidence 0.7; per-size asks are not exposed on listing pages. Sizes/stock are not fetched. 1.5 s politeness delay.",
2342 + "enabled": true,
2343 + "schemaVersion": "1.0",
2344 + "config": {
2345 + "seeds": [
2346 + "jordan",
2347 + "nike",
2348 + "adidas",
2349 + "new-balance",
2350 + "asics",
2351 + "yeezy"
2352 + ],
2353 + "pagesPerSeed": 3
2354 + }
2355 + },
2356 + {
2357 + "id": "optcg",
2358 + "displayName": "OPTCG API (One Piece Card Game)",
2359 + "sourceId": "optcgapi",
2360 + "sourceName": "OPTCG API",
2361 + "sourceType": "catalog",
2362 + "sourceUrl": "https://optcgapi.com",
2363 + "module": "api/optcg",
2364 + "enginePriority": [
2365 + "api"
2366 + ],
2367 + "categories": [
2368 + "one_piece_card_game"
2369 + ],
2370 + "regions": [
2371 + "US"
2372 + ],
2373 + "languages": [
2374 + "en"
2375 + ],
2376 + "currency": [
2377 + "USD"
2378 + ],
2379 + "supportsListings": false,
2380 + "supportsSold": false,
2381 + "supportsAuctions": false,
2382 + "supportsImages": true,
2383 + "supportsCatalog": true,
2384 + "supportsPopulation": false,
2385 + "supportsLookup": false,
2386 + "refreshFrequencyMinutes": 1440,
2387 + "priority": "medium",
2388 + "trustScore": 0.65,
2389 + "attributionRequired": true,
2390 + "termsUrl": "https://optcgapi.com/",
2391 + "accessNotes": "Open community API (no key): /api/allSets/ then /api/sets/<set_id>/ returning every card of the set with TCGplayer-derived market_price / inventory_price and a date_scraped, used as the observation date (confidence 0.7 / 0.65). Alternate arts share the card code and are distinguished by image id. One request per set, 0.5 s politeness.",
2392 + "enabled": true,
2393 + "schemaVersion": "1.0",
2394 + "config": {}
2395 + },
2396 + {
2397 + "id": "pba-galleries",
2398 + "displayName": "PBA Galleries (prices realized)",
2399 + "sourceId": "pba-galleries",
2400 + "sourceName": "PBA Galleries",
2401 + "sourceType": "auction_house",
2402 + "sourceUrl": "https://www.pbagalleries.com",
2403 + "module": "firecrawl/pba-galleries",
2404 + "enginePriority": [
2405 + "firecrawl"
2406 + ],
2407 + "categories": [
2408 + "books",
2409 + "comics",
2410 + "marvel_comics",
2411 + "dc_comics",
2412 + "independent_comics",
2413 + "photography",
2414 + "maps",
2415 + "movie_posters",
2416 + "art",
2417 + "historical_documents"
2418 + ],
2419 + "regions": [
2420 + "US"
2421 + ],
2422 + "languages": [
2423 + "en"
2424 + ],
2425 + "currency": [
2426 + "USD"
2427 + ],
2428 + "supportsListings": false,
2429 + "supportsSold": true,
2430 + "supportsAuctions": false,
2431 + "supportsImages": true,
2432 + "supportsCatalog": false,
2433 + "supportsPopulation": false,
2434 + "supportsLookup": false,
2435 + "refreshFrequencyMinutes": 1440,
2436 + "priority": "medium",
2437 + "trustScore": 0.85,
2438 + "attributionRequired": true,
2439 + "termsUrl": "https://www.pbagalleries.com/terms-conditions/",
2440 + "accessNotes": "Public auction list (pbagalleries.com/auctions/?page=N: sale number, title, date, catalog id) and closed catalogs (/auctions/catalog/id/<id>?page=N, 'Sold for $X' + 'Status Sold' per lot with title/publisher/date fields) rendered through Firecrawl (plain HTTPS gets a Cloudflare interstitial; not bypassed; robots.txt allows the site). PBA's realized prices include the 25% buyer's premium (verified: every price is a hammer bid × 1.25) → buyer_premium_included=true. Category from the sale title (comics/books/photographs/maps/posters). 1 credit per page, 2 s politeness delay.",
2441 + "enabled": true,
2442 + "schemaVersion": "1.0",
2443 + "config": {
2444 + "auctionsPerRun": 2,
2445 + "catalogPagesPerAuction": 5
2446 + }
2447 + },
2448 + {
2449 + "id": "pcgs-priceguide",
2450 + "displayName": "PCGS Price Guide (US coins)",
2451 + "sourceId": "pcgs",
2452 + "sourceName": "PCGS",
2453 + "sourceType": "grading_company",
2454 + "sourceUrl": "https://www.pcgs.com",
2455 + "module": "firecrawl/pcgs-priceguide",
2456 + "enginePriority": [
2457 + "firecrawl",
2458 + "scrapfly"
2459 + ],
2460 + "categories": [
2461 + "coins"
2462 + ],
2463 + "regions": [
2464 + "US"
2465 + ],
2466 + "languages": [
2467 + "en"
2468 + ],
2469 + "currency": [
2470 + "USD"
2471 + ],
2472 + "supportsListings": false,
2473 + "supportsSold": false,
2474 + "supportsAuctions": false,
2475 + "supportsImages": false,
2476 + "supportsCatalog": true,
2477 + "supportsPopulation": false,
2478 + "supportsLookup": false,
2479 + "refreshFrequencyMinutes": 10080,
2480 + "priority": "low",
2481 + "trustScore": 0.9,
2482 + "attributionRequired": true,
2483 + "termsUrl": "https://www.pcgs.com/legal",
2484 + "accessNotes": "Public PCGS Price Guide category pages (pcgs.com/prices/detail/<series>/<id>/most-active) — no login required; robots.txt does not restrict /prices. Plain HTTPS is refused by the CDN (403) so pages are rendered through Firecrawl (1 credit per category page ≈ 100 coins × 10 grades). Values are PCGS retail guide values in USD per grade (columns 4…70 and '+' grades), dated with the page's 'Last Update' stamp. Stored as guide_value observations with grader 'pcgs' and grade like MS65 / PR65 / MS65+; PCGS coin numbers are kept as identifiers (pcgs_number). Auction Prices Realized and the Population Report on pcgs.com require a Collectors account and are not fetched.",
2485 + "enabled": true,
2486 + "schemaVersion": "1.0",
2487 + "config": {
2488 + "seeds": [
2489 + "morgan-dollar/744",
2490 + "peace-dollar/26",
2491 + "lincoln-cent-wheat-reverse/46",
2492 + "indian-cent/44",
2493 + "buffalo-nickel/83",
2494 + "mercury-dime/703",
2495 + "walking-liberty-half-dollar/733",
2496 + "standing-liberty-quarter/111",
2497 + "franklin-half-dollar/734",
2498 + "kennedy-half-dollar/125",
2499 + "washington-quarter/112",
2500 + "trade-dollar/743",
2501 + "flying-eagle-cent/664",
2502 + "barber-half-dollar/732",
2503 + "silver-eagles/939",
2504 + "liberty-seated-dollar/29"
2505 + ],
2506 + "designations": [
2507 + "ms"
2508 + ]
2509 + }
2510 + },
2511 + {
2512 + "id": "phillips-art",
2513 + "displayName": "Phillips Art, Editions, Design & Photographs (auction results)",
2514 + "sourceId": "phillips",
2515 + "sourceName": "Phillips",
2516 + "sourceType": "auction_house",
2517 + "sourceUrl": "https://www.phillips.com",
2518 + "module": "firecrawl/phillips-art",
2519 + "enginePriority": [
2520 + "firecrawl",
2521 + "scrapfly"
2522 + ],
2523 + "categories": [
2524 + "art",
2525 + "contemporary_art",
2526 + "photography",
2527 + "design_furniture",
2528 + "jewelry",
2529 + "luxury_handbags"
2530 + ],
2531 + "regions": [
2532 + "US",
2533 + "GB",
2534 + "HK",
2535 + "CH"
2536 + ],
2537 + "languages": [
2538 + "en"
2539 + ],
2540 + "currency": [
2541 + "USD",
2542 + "GBP",
2543 + "HKD",
2544 + "CHF",
2545 + "EUR"
2546 + ],
2547 + "supportsListings": false,
2548 + "supportsSold": true,
2549 + "supportsAuctions": true,
2550 + "supportsImages": true,
2551 + "supportsCatalog": false,
2552 + "supportsPopulation": false,
2553 + "supportsLookup": false,
2554 + "refreshFrequencyMinutes": 10080,
2555 + "priority": "medium",
2556 + "trustScore": 0.95,
2557 + "attributionRequired": true,
2558 + "termsUrl": "https://www.phillips.com/about/terms",
2559 + "accessNotes": "Same public mechanism as phillips-watches (past-sales JSON on /auctions/past over plain HTTPS, then one Firecrawl render per sale page, 1 credit) applied to the Editions, Design, 20th Century & Contemporary Art, Photographs, Jewels and Handbags departments. Prices realised are published by Phillips including buyer's premium (buyer_premium_included=true). Sale date = sale end date. robots.txt disallows only /search and filter paths; no login, no bidder data. 3 s politeness delay; salesPerRun caps each run.",
2560 + "enabled": true,
2561 + "schemaVersion": "1.0",
2562 + "config": {
2563 + "pastAuctionsUrl": "https://www.phillips.com/auctions/past",
2564 + "salesPerRun": 4,
2565 + "departments": [
2566 + {
2567 + "filter": "editions",
2568 + "category": "contemporary_art"
2569 + },
2570 + {
2571 + "filter": "design",
2572 + "category": "design_furniture"
2573 + },
2574 + {
2575 + "filter": "photograph",
2576 + "category": "photography"
2577 + },
2578 + {
2579 + "filter": "contemporary art",
2580 + "category": "contemporary_art"
2581 + },
2582 + {
2583 + "filter": "jewels",
2584 + "category": "jewelry"
2585 + },
2586 + {
2587 + "filter": "handbags",
2588 + "category": "luxury_handbags"
2589 + }
2590 + ]
2591 + }
2592 + },
2593 + {
2594 + "id": "phillips-watches",
2595 + "displayName": "Phillips Watches (auction results)",
2596 + "sourceId": "phillips",
2597 + "sourceName": "Phillips",
2598 + "sourceType": "auction_house",
2599 + "sourceUrl": "https://www.phillips.com",
2600 + "module": "firecrawl/phillips-watches",
2601 + "enginePriority": [
2602 + "firecrawl",
2603 + "scrapfly"
2604 + ],
2605 + "categories": [
2606 + "watches",
2607 + "rolex",
2608 + "patek_philippe",
2609 + "audemars_piguet",
2610 + "omega",
2611 + "other_watches"
2612 + ],
2613 + "regions": [
2614 + "CH",
2615 + "US",
2616 + "HK",
2617 + "GB"
2618 + ],
2619 + "languages": [
2620 + "en"
2621 + ],
2622 + "currency": [
2623 + "CHF",
2624 + "USD",
2625 + "HKD",
2626 + "GBP",
2627 + "EUR"
2628 + ],
2629 + "supportsListings": false,
2630 + "supportsSold": true,
2631 + "supportsAuctions": true,
2632 + "supportsImages": true,
2633 + "supportsCatalog": false,
2634 + "supportsPopulation": false,
2635 + "supportsLookup": false,
2636 + "refreshFrequencyMinutes": 10080,
2637 + "priority": "high",
2638 + "trustScore": 0.95,
2639 + "attributionRequired": true,
2640 + "termsUrl": "https://www.phillips.com/about/terms",
2641 + "accessNotes": "Past watch sales are discovered from the public /auctions/past page (plain HTTPS; embedded JSON with saleNumber, title, end date, location). Each sale page is rendered through Firecrawl (JS wait ~6 s, 1 credit) to read the public lot list: lot number, maker, reference, model, estimate and 'Sold For' (Phillips publishes prices realised including buyer's premium). robots.txt disallows only /search and filter paths. No login, no bidder data. Large sales may lazy-load beyond the first ~70 lots — coverage is recorded per run.",
2642 + "enabled": true,
2643 + "schemaVersion": "1.0",
2644 + "config": {
2645 + "pastAuctionsUrl": "https://www.phillips.com/auctions/past",
2646 + "salesPerRun": 6,
2647 + "titleFilter": "watch"
2648 + }
2649 + },
2650 + {
2651 + "id": "pokemonprice",
2652 + "displayName": "PokemonPrice (graded Pokémon price guide)",
2653 + "sourceId": "pokemonprice",
2654 + "sourceName": "PokemonPrice.com",
2655 + "sourceType": "pricing_guide",
2656 + "sourceUrl": "https://www.pokemonprice.com",
2657 + "module": "api/pokemonprice",
2658 + "enginePriority": [
2659 + "api",
2660 + "firecrawl"
2661 + ],
2662 + "categories": [
2663 + "pokemon"
2664 + ],
2665 + "regions": [
2666 + "US"
2667 + ],
2668 + "languages": [
2669 + "en"
2670 + ],
2671 + "currency": [
2672 + "USD"
2673 + ],
2674 + "supportsListings": false,
2675 + "supportsSold": false,
2676 + "supportsAuctions": false,
2677 + "supportsImages": true,
2678 + "supportsCatalog": true,
2679 + "supportsPopulation": false,
2680 + "supportsLookup": true,
2681 + "refreshFrequencyMinutes": 2880,
2682 + "priority": "medium",
2683 + "trustScore": 0.7,
2684 + "attributionRequired": true,
2685 + "termsUrl": "https://www.pokemonprice.com/about-us",
2686 + "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.",
2687 + "enabled": true,
2688 + "schemaVersion": "1.0",
2689 + "config": {
2690 + "seeds": [],
2691 + "maxCardsPerRun": 300,
2692 + "priceKinds": [
2693 + "guide_value",
2694 + "low",
2695 + "high"
2696 + ]
2697 + }
2698 + },
2699 + {
2700 + "id": "pokemontcg",
2701 + "displayName": "Pokémon TCG API (pokemontcg.io)",
2702 + "sourceId": "pokemontcg",
2703 + "sourceName": "Pokémon TCG API",
2704 + "sourceType": "catalog",
2705 + "sourceUrl": "https://pokemontcg.io",
2706 + "module": "api/pokemontcg",
2707 + "enginePriority": [
2708 + "api",
2709 + "feed"
2710 + ],
2711 + "categories": [
2712 + "pokemon"
2713 + ],
2714 + "regions": [
2715 + "US",
2716 + "EU"
2717 + ],
2718 + "languages": [
2719 + "en"
2720 + ],
2721 + "currency": [
2722 + "USD",
2723 + "EUR"
2724 + ],
2725 + "supportsListings": false,
2726 + "supportsSold": false,
2727 + "supportsAuctions": false,
2728 + "supportsImages": true,
2729 + "supportsCatalog": true,
2730 + "supportsPopulation": false,
2731 + "supportsLookup": false,
2732 + "refreshFrequencyMinutes": 1440,
2733 + "priority": "high",
2734 + "trustScore": 0.8,
2735 + "attributionRequired": true,
2736 + "termsUrl": "https://docs.pokemontcg.io/",
2737 + "accessNotes": "Public REST API v2 (https://api.pokemontcg.io/v2). Optional X-Api-Key (POKEMONTCG_API_KEY) raises limits (20k/day) — without a key ~1k/day and 30/min. Crawl = sets, then cards per set (pageSize 250, cursor {setIndex,page}). The API is intermittently unavailable (5xx); the connector retries with backoff and falls back to the maintainers' public GitHub mirror (PokemonTCG/pokemon-tcg-data, same card schema, no prices) for the catalog when the API keeps failing. Prices are TCGplayer (USD, per printing variant: normal/holofoil/reverseHolofoil/1stEdition…) and Cardmarket (EUR) daily aggregates with their own updatedAt → stored as price_observations, never as sales. Pokémon © Nintendo/Creatures/GAME FREAK; data attributed to pokemontcg.io.",
2738 + "enabled": true,
2739 + "schemaVersion": "1.0",
2740 + "config": {
2741 + "pageSize": 250,
2742 + "requestIntervalMs": 2100,
2743 + "mirrorBase": "https://raw.githubusercontent.com/PokemonTCG/pokemon-tcg-data/master"
2744 + }
2745 + },
2746 + {
2747 + "id": "pricecharting",
2748 + "displayName": "PriceCharting",
2749 + "sourceId": "pricecharting",
2750 + "sourceName": "PriceCharting",
2751 + "sourceType": "pricing_guide",
2752 + "sourceUrl": "https://www.pricecharting.com",
2753 + "module": "api/pricecharting",
2754 + "enginePriority": [
2755 + "api",
2756 + "firecrawl",
2757 + "scrapfly"
2758 + ],
2759 + "categories": [
2760 + "video_games",
2761 + "nintendo_games",
2762 + "sega_games",
2763 + "playstation_games",
2764 + "xbox_games",
2765 + "atari_retro_games",
2766 + "pc_games",
2767 + "lego_sets",
2768 + "funko",
2769 + "comics",
2770 + "marvel_comics",
2771 + "dc_comics",
2772 + "independent_comics",
2773 + "trading_cards",
2774 + "pokemon",
2775 + "magic_the_gathering",
2776 + "yugioh"
2777 + ],
2778 + "regions": [
2779 + "US"
2780 + ],
2781 + "languages": [
2782 + "en"
2783 + ],
2784 + "currency": [
2785 + "USD"
2786 + ],
2787 + "supportsListings": false,
2788 + "supportsSold": true,
2789 + "supportsAuctions": false,
2790 + "supportsImages": true,
2791 + "supportsCatalog": true,
2792 + "supportsPopulation": false,
2793 + "supportsLookup": true,
2794 + "refreshFrequencyMinutes": 1440,
2795 + "priority": "high",
2796 + "trustScore": 0.7,
2797 + "attributionRequired": true,
2798 + "termsUrl": "https://www.pricecharting.com/page/terms-of-service",
2799 + "accessNotes": "Public product pages fetched over plain HTTPS with the RareIndex user agent (robots.txt only disallows /buy, /publish-offer, /stripe-connect). Each product page exposes PriceCharting guide values per condition and a table of recently completed eBay sales (date, title, price, eBay item id) — we store those as sales with the PriceCharting page as source URL. No account, no API key, no photos (TimeWarp is paid and not used). Console listings paginate through the public ?format=json cursor endpoint. ~1 request per product, 1.5 s politeness delay. Trading-card consoles (Pokémon, Magic, Yu-Gi-Oh!) are discovered from the public /category/<game>-cards pages; card pages expose guide values and eBay sales per grade (Ungraded, Grade 1–9.5, TAG/ACE/SGC/CGC/PSA/BGS 10, BGS 10 Black, CGC 10 Pristine). Graded rows without a named company use the generic grader 'graded' unless the eBay title names it. Pokémon sets are mapped to pokemontcg ids/codes through the maintainers' GitHub mirror and Magic sets/cards to Scryfall (one exact-name API lookup per Magic product, ≤ 10 req/s) so sales attach to the API catalogs' canonical assets.",
2800 + "enabled": true,
2801 + "schemaVersion": "2.0",
2802 + "config": {
2803 + "seeds": [
2804 + "nintendo-64",
2805 + "nes",
2806 + "super-nintendo",
2807 + "gamecube",
2808 + "gameboy",
2809 + "gameboy-color",
2810 + "gameboy-advance",
2811 + "nintendo-ds",
2812 + "nintendo-switch",
2813 + "wii",
2814 + "playstation",
2815 + "playstation-2",
2816 + "playstation-3",
2817 + "playstation-4",
2818 + "playstation-5",
1303 2819 "psp",
1304 2820 "xbox",
1305 2821 "xbox-360",
@@ -1374,6 +2890,300 @@
1374 2890 "maxConsolesPerCategory": 30
1375 2891 }
1376 2892 },
2893 + {
2894 + "id": "rebag",
2895 + "displayName": "Rebag (pre-owned luxury bags, watches & jewelry listings)",
2896 + "sourceId": "rebag",
2897 + "sourceName": "Rebag",
2898 + "sourceType": "marketplace",
2899 + "sourceUrl": "https://shop.rebag.com",
2900 + "module": "api/rebag",
2901 + "enginePriority": [
2902 + "api"
2903 + ],
2904 + "categories": [
2905 + "luxury_handbags",
2906 + "watches",
2907 + "rolex",
2908 + "omega",
2909 + "other_watches",
2910 + "jewelry",
2911 + "fashion_streetwear"
2912 + ],
2913 + "regions": [
2914 + "US"
2915 + ],
2916 + "languages": [
2917 + "en"
2918 + ],
2919 + "currency": [
2920 + "USD"
2921 + ],
2922 + "supportsListings": true,
2923 + "supportsSold": false,
2924 + "supportsAuctions": false,
2925 + "supportsImages": true,
2926 + "supportsCatalog": false,
2927 + "supportsPopulation": false,
2928 + "supportsLookup": true,
2929 + "refreshFrequencyMinutes": 720,
2930 + "priority": "medium",
2931 + "trustScore": 0.8,
2932 + "attributionRequired": true,
2933 + "termsUrl": "https://www.rebag.com/terms-of-service/",
2934 + "accessNotes": "Public Shopify storefront feed (shop.rebag.com/collections/<handle>/products.json) over plain HTTPS with the RareIndex user agent; rebag.com robots.txt disallows nothing relevant (only /digital_certificate/). Each product is one authenticated pre-owned item in USD; Rebag's condition grade (Pristine/Excellent/Great/Good/Fair) is the first token of the variant title and the estimated retail price comes from the description. 1.5 s between requests.",
2935 + "enabled": true,
2936 + "schemaVersion": "1.0",
2937 + "config": {
2938 + "seeds": [
2939 + "hermes",
2940 + "chanel",
2941 + "louis-vuitton",
2942 + "dior",
2943 + "gucci",
2944 + "goyard",
2945 + "fendi",
2946 + "bottega-veneta",
2947 + "prada",
2948 + "celine",
2949 + "loewe",
2950 + "saint-laurent",
2951 + "all-watches",
2952 + "rolex",
2953 + "omega",
2954 + "cartier",
2955 + "van-cleef-arpels"
2956 + ],
2957 + "pagesPerSeed": 2
2958 + }
2959 + },
2960 + {
2961 + "id": "rm-sothebys",
2962 + "displayName": "RM Sotheby's (results)",
2963 + "sourceId": "rm-sothebys",
2964 + "sourceName": "RM Sotheby's",
2965 + "sourceType": "auction_house",
2966 + "sourceUrl": "https://rmsothebys.com",
2967 + "module": "firecrawl/rm-sothebys",
2968 + "enginePriority": [
2969 + "firecrawl"
2970 + ],
2971 + "categories": [
2972 + "automobiles",
2973 + "motorcycles",
2974 + "automotive_memorabilia"
2975 + ],
2976 + "regions": [
2977 + "US",
2978 + "EU",
2979 + "GB",
2980 + "AE"
2981 + ],
2982 + "languages": [
2983 + "en"
2984 + ],
2985 + "currency": [
2986 + "USD",
2987 + "EUR",
2988 + "GBP",
2989 + "CHF",
2990 + "AED"
2991 + ],
2992 + "supportsListings": false,
2993 + "supportsSold": true,
2994 + "supportsAuctions": false,
2995 + "supportsImages": true,
2996 + "supportsCatalog": false,
2997 + "supportsPopulation": false,
2998 + "supportsLookup": false,
2999 + "refreshFrequencyMinutes": 1440,
3000 + "priority": "medium",
3001 + "trustScore": 0.9,
3002 + "attributionRequired": true,
3003 + "termsUrl": "https://rmsothebys.com/terms-and-conditions/",
3004 + "accessNotes": "Public results: /results/ (auction list per year with date ranges) and each auction's lot grid (/auctions/<code>/lots/) rendered through Firecrawl because RM's site is an Angular app (no robots.txt is served; nothing is login-gated). Limitation: the lot grid paginates client-side (40 lots per view) and does not expose a page URL, so only the first 40 lots of each auction are captured per run — partial coverage, stated in the data. Lots marked 'Sold' with a price become sales; 'Not Sold' lots are skipped. RM publishes results inclusive of buyer's premium → buyer_premium_included=true. Sale date = first day of the auction (range in metadata). 1 credit per page, 2 s politeness delay.",
3005 + "enabled": true,
3006 + "schemaVersion": "1.0",
3007 + "config": {
3008 + "auctionsPerRun": 3
3009 + }
3010 + },
3011 + {
3012 + "id": "rr-auction",
3013 + "displayName": "RR Auction (past auctions)",
3014 + "sourceId": "rr-auction",
3015 + "sourceName": "RR Auction",
3016 + "sourceType": "auction_house",
3017 + "sourceUrl": "https://www.rrauction.com",
3018 + "module": "firecrawl/rr-auction",
3019 + "enginePriority": [
3020 + "firecrawl"
3021 + ],
3022 + "categories": [
3023 + "space",
3024 + "autographs",
3025 + "historical_documents",
3026 + "apple_collectibles",
3027 + "music_memorabilia",
3028 + "sports_memorabilia",
3029 + "movie_memorabilia",
3030 + "meteorites",
3031 + "vintage_computers",
3032 + "animation_art",
3033 + "aviation"
3034 + ],
3035 + "regions": [
3036 + "US"
3037 + ],
3038 + "languages": [
3039 + "en"
3040 + ],
3041 + "currency": [
3042 + "USD"
3043 + ],
3044 + "supportsListings": false,
3045 + "supportsSold": true,
3046 + "supportsAuctions": false,
3047 + "supportsImages": true,
3048 + "supportsCatalog": false,
3049 + "supportsPopulation": false,
3050 + "supportsLookup": false,
3051 + "refreshFrequencyMinutes": 720,
3052 + "priority": "high",
3053 + "trustScore": 0.9,
3054 + "attributionRequired": true,
3055 + "termsUrl": "https://www.rrauction.com/terms-and-conditions",
3056 + "accessNotes": "Two public pages rendered through Firecrawl (plain HTTPS gets a Cloudflare interstitial; not bypassed; robots.txt allows everything except /admin): the past-auction calendar (/auctions/auction-calendar/cron/past/?page=N: auction id, title, date, realized total) and each auction's lot gallery (/auctions/auction-details/<id>?page=N&itemQty=96&view=gallery&sort=time&cat=0) whose cards show 'Sold For: $X (w/BP)', estimate and the auction date. RR's published prices explicitly include the buyer's premium → buyer_premium_included=true. Categories come from the auction title and lot title keywords. 1 credit per page (96 lots), 2 s politeness delay.",
3057 + "enabled": true,
3058 + "schemaVersion": "1.0",
3059 + "config": {
3060 + "auctionsPerRun": 2,
3061 + "lotPagesPerAuction": 6,
3062 + "calendarPagesPerRun": 1
3063 + }
3064 + },
3065 + {
3066 + "id": "saatchi-art",
3067 + "displayName": "Saatchi Art (original artworks for sale)",
3068 + "sourceId": "saatchi-art",
3069 + "sourceName": "Saatchi Art",
3070 + "sourceType": "marketplace",
3071 + "sourceUrl": "https://www.saatchiart.com",
3072 + "module": "api/saatchi-art",
3073 + "enginePriority": [
3074 + "api",
3075 + "firecrawl",
3076 + "scrapfly"
3077 + ],
3078 + "categories": [
3079 + "art",
3080 + "contemporary_art",
3081 + "photography"
3082 + ],
3083 + "regions": [
3084 + "US",
3085 + "GB",
3086 + "EU"
3087 + ],
3088 + "languages": [
3089 + "en"
3090 + ],
3091 + "currency": [
3092 + "USD"
3093 + ],
3094 + "supportsListings": true,
3095 + "supportsSold": false,
3096 + "supportsAuctions": false,
3097 + "supportsImages": true,
3098 + "supportsCatalog": true,
3099 + "supportsPopulation": false,
3100 + "supportsLookup": true,
3101 + "refreshFrequencyMinutes": 1440,
3102 + "priority": "medium",
3103 + "trustScore": 0.7,
3104 + "attributionRequired": true,
3105 + "termsUrl": "https://www.saatchiart.com/termsofservice",
3106 + "accessNotes": "Public category browse pages (/paintings, /photography, /sculpture, /prints … with ?page=N) served as Next.js pages whose __NEXT_DATA__ carries 25 artworks per page: title, artist, category, mediums, materials, dimensions (cm), list price (USD cents), availability status (avail/sold), SKU and image. Plain HTTPS with the RareIndex user agent. robots.txt only disallows filter/sort query parameters (?category=, ?sort= …) which we do not use — pagination via ?page= is allowed. Listing asks are NOT transactions; sold works keep their last list price as a listing marked sold (no sale date is published, so no sale record is created). 2 s politeness delay.",
3107 + "enabled": true,
3108 + "schemaVersion": "1.0",
3109 + "config": {
3110 + "seeds": [
3111 + {
3112 + "path": "/paintings",
3113 + "category": "contemporary_art"
3114 + },
3115 + {
3116 + "path": "/photography",
3117 + "category": "photography"
3118 + },
3119 + {
3120 + "path": "/sculpture",
3121 + "category": "contemporary_art"
3122 + },
3123 + {
3124 + "path": "/prints",
3125 + "category": "contemporary_art"
3126 + },
3127 + {
3128 + "path": "/drawings",
3129 + "category": "contemporary_art"
3130 + },
3131 + {
3132 + "path": "/collage",
3133 + "category": "contemporary_art"
3134 + }
3135 + ],
3136 + "pagesPerSeed": 4
3137 + }
3138 + },
3139 + {
3140 + "id": "scotch-whisky-auctions",
3141 + "displayName": "Scotch Whisky Auctions (results)",
3142 + "sourceId": "scotch-whisky-auctions",
3143 + "sourceName": "Scotch Whisky Auctions",
3144 + "sourceType": "auction_house",
3145 + "sourceUrl": "https://www.scotchwhiskyauctions.com",
3146 + "module": "api/scotch-whisky-auctions",
3147 + "enginePriority": [
3148 + "api",
3149 + "firecrawl",
3150 + "scrapfly"
3151 + ],
3152 + "categories": [
3153 + "whisky",
3154 + "rum",
3155 + "cognac"
3156 + ],
3157 + "regions": [
3158 + "GB"
3159 + ],
3160 + "languages": [
3161 + "en"
3162 + ],
3163 + "currency": [
3164 + "GBP"
3165 + ],
3166 + "supportsListings": false,
3167 + "supportsSold": true,
3168 + "supportsAuctions": true,
3169 + "supportsImages": true,
3170 + "supportsCatalog": false,
3171 + "supportsPopulation": false,
3172 + "supportsLookup": true,
3173 + "refreshFrequencyMinutes": 1440,
3174 + "priority": "high",
3175 + "trustScore": 0.9,
3176 + "attributionRequired": true,
3177 + "termsUrl": "https://www.scotchwhiskyauctions.com/terms/",
3178 + "accessNotes": "Public monthly auction archive (Glasgow; ~4,500–5,000 lots per auction, 20 lots per page, plain HTTPS with the RareIndex user agent, robots.txt disallows only /cmsplus/). Each lot card exposes title, lot number and 'Sold for £X in <month year>' — this is the hammer price; the buyer's commission (SWA charges a separate buyer's premium plus VAT) is NOT included, so buyer_premium_included=false. Sale date = the auction end date shown on the auction page. Unsold lots are skipped. 2 s politeness delay; pagesPerRun caps each incremental run (backfill mode walks older auctions).",
3179 + "enabled": true,
3180 + "schemaVersion": "1.0",
3181 + "config": {
3182 + "auctionsUrl": "https://www.scotchwhiskyauctions.com/auctions/",
3183 + "auctionsPerRun": 2,
3184 + "pagesPerRun": 12
3185 + }
3186 + },
1377 3187 {
1378 3188 "id": "scryfall",
1379 3189 "displayName": "Scryfall (Magic: The Gathering)",
@@ -1419,6 +3229,45 @@
1419 3229 "requestIntervalMs": 100
1420 3230 }
1421 3231 },
3232 + {
3233 + "id": "sorcery-tcg",
3234 + "displayName": "Sorcery: Contested Realm (official card API)",
3235 + "sourceId": "sorcery-tcg",
3236 + "sourceName": "Sorcery TCG (Erik's Curiosa)",
3237 + "sourceType": "manufacturer",
3238 + "sourceUrl": "https://sorcerytcg.com",
3239 + "module": "api/sorcery-tcg",
3240 + "enginePriority": [
3241 + "api"
3242 + ],
3243 + "categories": [
3244 + "other_tcg"
3245 + ],
3246 + "regions": [
3247 + "US",
3248 + "EU"
3249 + ],
3250 + "languages": [
3251 + "en"
3252 + ],
3253 + "currency": [],
3254 + "supportsListings": false,
3255 + "supportsSold": false,
3256 + "supportsAuctions": false,
3257 + "supportsImages": false,
3258 + "supportsCatalog": true,
3259 + "supportsPopulation": false,
3260 + "supportsLookup": false,
3261 + "refreshFrequencyMinutes": 10080,
3262 + "priority": "low",
3263 + "trustScore": 0.85,
3264 + "attributionRequired": true,
3265 + "termsUrl": "https://sorcerytcg.com",
3266 + "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).",
3267 + "enabled": true,
3268 + "schemaVersion": "1.0",
3269 + "config": {}
3270 + },
1422 3271 {
1423 3272 "id": "sothebys",
1424 3273 "displayName": "Sotheby's (auction results & upcoming lots)",
@@ -1476,69 +3325,324 @@
1476 3325 "vintage_toys"
1477 3326 ],
1478 3327 "regions": [
1479 − "US",
1480 − "GB",
1481 − "HK",
1482 − "FR",
1483 − "CH"
3328 + "US",
3329 + "GB",
3330 + "HK",
3331 + "FR",
3332 + "CH"
3333 + ],
3334 + "languages": [
3335 + "en"
3336 + ],
3337 + "currency": [
3338 + "USD",
3339 + "GBP",
3340 + "HKD",
3341 + "EUR",
3342 + "CHF"
3343 + ],
3344 + "supportsListings": false,
3345 + "supportsSold": true,
3346 + "supportsAuctions": true,
3347 + "supportsImages": true,
3348 + "supportsCatalog": false,
3349 + "supportsPopulation": false,
3350 + "supportsLookup": true,
3351 + "refreshFrequencyMinutes": 360,
3352 + "priority": "high",
3353 + "trustScore": 0.9,
3354 + "attributionRequired": true,
3355 + "termsUrl": "https://www.sothebys.com/en/terms-conditions",
3356 + "accessNotes": "Auction pages https://www.sothebys.com/en/buy/auction/<year>/<slug> are server-rendered (__NEXT_DATA__ Apollo cache) with auction metadata, department names, currency, dates and the first 48 lot cards including estimates and, for closed sales, the visible result (BidState.sold.premiums.finalPriceV2 = price including buyer's premium, currentBidV2 = hammer). Remaining lots are paged with the same public GraphQL endpoint the page uses (clientapi.prod.sothelabs.com/graphql, query LotCardsFilterByPaginated, no authentication; we request only public lot-card fields). Auction discovery: links on the public /en/results and /en/calendar pages (rendered through Firecrawl, 1 credit each), plus config.seeds auction URLs; auctions seen while open are re-checked after they close so their results are captured. robots.txt disallows /bsp-api/* and PDFs — not used. Prices: finalPriceV2 (buyer's premium included) → buyerPremiumIncluded=true, hammer kept in metadata; sale date = lot closingTime or the auction's closed timestamp. Condition reports are behind login and are not fetched.",
3357 + "enabled": true,
3358 + "schemaVersion": "1.0",
3359 + "config": {
3360 + "seeds": [],
3361 + "discoveryPages": [
3362 + "https://www.sothebys.com/en/results",
3363 + "https://www.sothebys.com/en/calendar"
3364 + ],
3365 + "departments": [],
3366 + "maxAuctionsPerRun": 25,
3367 + "pageSize": 48
3368 + }
3369 + },
3370 + {
3371 + "id": "sportscardspro",
3372 + "displayName": "SportsCardsPro",
3373 + "sourceId": "sportscardspro",
3374 + "sourceName": "SportsCardsPro (PriceCharting)",
3375 + "sourceType": "pricing_guide",
3376 + "sourceUrl": "https://www.sportscardspro.com",
3377 + "module": "api/sportscardspro",
3378 + "enginePriority": [
3379 + "firecrawl",
3380 + "scrapfly"
3381 + ],
3382 + "categories": [
3383 + "sports_cards",
3384 + "baseball_cards",
3385 + "basketball_cards",
3386 + "football_cards",
3387 + "hockey_cards",
3388 + "soccer_cards",
3389 + "f1_cards",
3390 + "other_sports_cards"
3391 + ],
3392 + "regions": [
3393 + "US"
3394 + ],
3395 + "languages": [
3396 + "en"
3397 + ],
3398 + "currency": [
3399 + "USD"
3400 + ],
3401 + "supportsListings": false,
3402 + "supportsSold": true,
3403 + "supportsAuctions": false,
3404 + "supportsImages": true,
3405 + "supportsCatalog": true,
3406 + "supportsPopulation": false,
3407 + "supportsLookup": true,
3408 + "refreshFrequencyMinutes": 1440,
3409 + "priority": "high",
3410 + "trustScore": 0.7,
3411 + "attributionRequired": true,
3412 + "termsUrl": "https://www.sportscardspro.com/page/terms-of-service",
3413 + "accessNotes": "Public product pages of PriceCharting's sports-card site. robots.txt only disallows /buy, /publish-offer, /stripe-connect. Plain HTTP with our user agent gets a Cloudflare interstitial (HTTP 403), so pages are fetched through Firecrawl (1 credit per page, ~1 MB each) — no login, no CAPTCHA solving, no account. Each page exposes guide values per grade (Ungraded, Grade 1–9.5, TAG/ACE/SGC/CGC/PSA/BGS 10, BGS 10 Black, CGC 10 Pristine) and recently completed eBay sales per grade tab; graded rows without a named grading company are stored with the generic grader 'graded' unless the eBay title names the company. Set lists come from the public /category/<sport>-cards pages; console listings use the ?format=json cursor endpoint.",
3414 + "enabled": true,
3415 + "schemaVersion": "2.0",
3416 + "config": {
3417 + "seeds": [
3418 + "basketball-cards-1986-fleer",
3419 + "basketball-cards-2003-topps-chrome",
3420 + "basketball-cards-2018-panini-prizm",
3421 + "basketball-cards-2019-panini-prizm",
3422 + "basketball-cards-1996-topps-chrome",
3423 + "baseball-cards-1952-topps",
3424 + "baseball-cards-1989-upper-deck",
3425 + "baseball-cards-2011-topps-update",
3426 + "baseball-cards-2018-topps-update",
3427 + "baseball-cards-1993-sp",
3428 + "football-cards-2000-playoff-contenders",
3429 + "football-cards-2017-panini-prizm",
3430 + "football-cards-2020-panini-prizm",
3431 + "football-cards-1957-topps",
3432 + "hockey-cards-1979-o-pee-chee",
3433 + "hockey-cards-2005-upper-deck",
3434 + "hockey-cards-2015-upper-deck",
3435 + "soccer-cards-2018-panini-prizm-world-cup",
3436 + "soccer-cards-2004-panini-mega-cracks"
3437 + ],
3438 + "cardCategories": [
3439 + "basketball-cards",
3440 + "baseball-cards",
3441 + "football-cards",
3442 + "hockey-cards",
3443 + "soccer-cards"
3444 + ],
3445 + "maxConsolesPerCategory": 25,
3446 + "productsPerConsole": 120,
3447 + "sort": "popularity"
3448 + }
3449 + },
3450 + {
3451 + "id": "subdial",
3452 + "displayName": "Subdial (UK pre-owned watch listings)",
3453 + "sourceId": "subdial",
3454 + "sourceName": "Subdial",
3455 + "sourceType": "marketplace",
3456 + "sourceUrl": "https://subdial.com",
3457 + "module": "api/subdial",
3458 + "enginePriority": [
3459 + "api",
3460 + "firecrawl"
3461 + ],
3462 + "categories": [
3463 + "watches",
3464 + "rolex",
3465 + "patek_philippe",
3466 + "audemars_piguet",
3467 + "omega",
3468 + "other_watches"
3469 + ],
3470 + "regions": [
3471 + "GB"
3472 + ],
3473 + "languages": [
3474 + "en"
3475 + ],
3476 + "currency": [
3477 + "GBP"
3478 + ],
3479 + "supportsListings": true,
3480 + "supportsSold": false,
3481 + "supportsAuctions": false,
3482 + "supportsImages": true,
3483 + "supportsCatalog": false,
3484 + "supportsPopulation": false,
3485 + "supportsLookup": true,
3486 + "refreshFrequencyMinutes": 720,
3487 + "priority": "medium",
3488 + "trustScore": 0.8,
3489 + "attributionRequired": true,
3490 + "termsUrl": "https://subdial.com/terms",
3491 + "accessNotes": "Listing URLs come from the public sitemap (subdial.com/sitemap-listing.xml); each listing page embeds a schema.org Product (brand, mpn = reference, sku = Subdial id, GBP price, availability) plus a specification table (reference, year, condition, box, papers). robots.txt allows / for generic agents (it blocks a list of named AI crawlers, which we are not). Plain HTTPS with the RareIndex user agent, 1.5 s between pages, capped per run; the crawl budget skips URLs already fetched recently.",
3492 + "enabled": true,
3493 + "schemaVersion": "1.0",
3494 + "config": {
3495 + "maxListingsPerRun": 250
3496 + }
3497 + },
3498 + {
3499 + "id": "surugaya",
3500 + "displayName": "Suruga-ya (Japanese second-hand hobby retailer, JPY)",
3501 + "sourceId": "surugaya",
3502 + "sourceName": "Suruga-ya",
3503 + "sourceType": "marketplace",
3504 + "sourceUrl": "https://www.suruga-ya.jp",
3505 + "module": "firecrawl/surugaya",
3506 + "enginePriority": [
3507 + "firecrawl"
3508 + ],
3509 + "categories": [
3510 + "pokemon",
3511 + "yugioh",
3512 + "one_piece_card_game",
3513 + "gundam",
3514 + "action_figures",
3515 + "nintendo_games",
3516 + "sega_games",
3517 + "playstation_games",
3518 + "designer_toys"
3519 + ],
3520 + "regions": [
3521 + "JP"
3522 + ],
3523 + "languages": [
3524 + "ja"
3525 + ],
3526 + "currency": [
3527 + "JPY"
3528 + ],
3529 + "supportsListings": true,
3530 + "supportsSold": false,
3531 + "supportsAuctions": false,
3532 + "supportsImages": true,
3533 + "supportsCatalog": true,
3534 + "supportsPopulation": false,
3535 + "supportsLookup": false,
3536 + "refreshFrequencyMinutes": 720,
3537 + "priority": "medium",
3538 + "trustScore": 0.75,
3539 + "attributionRequired": true,
3540 + "termsUrl": "https://www.suruga-ya.jp/man/index.html",
3541 + "accessNotes": "Public search result pages (suruga-ya.jp/search?...; robots.txt: 'Allow: /' for generic agents with Content-Signal search=yes, ai-train=no, use=reference — we only index prices and never train on content). Plain HTTPS returns 403 from data-centre IPs, so pages are fetched through Firecrawl (1 credit per page, ~24 items). Parsed per card: product id (shinaban), title, condition/type label, release date, brand, list price (定価), Suruga-ya price or 品切れ (sold out), and the marketplace (マケプレ) lowest price when Suruga-ya itself is out of stock. JPY. Emits catalog items + fixed-price listings; no sold history is available publicly.",
3542 + "enabled": true,
3543 + "schemaVersion": "1.0",
3544 + "config": {
3545 + "seeds": [
3546 + {
3547 + "query": "category=5&search_word=%E3%83%9D%E3%82%B1%E3%83%A2%E3%83%B3%E3%82%AB%E3%83%BC%E3%83%89%20PSA",
3548 + "categorySlug": "pokemon",
3549 + "label": "Pokémon cards PSA"
3550 + },
3551 + {
3552 + "query": "category=5&search_word=%E3%83%9D%E3%82%B1%E3%83%A2%E3%83%B3%E3%82%AB%E3%83%BC%E3%83%89%20SAR",
3553 + "categorySlug": "pokemon",
3554 + "label": "Pokémon SAR"
3555 + },
3556 + {
3557 + "query": "category=5&search_word=%E9%81%8A%E6%88%AF%E7%8E%8B%20%E3%83%97%E3%83%AA%E3%82%BA%E3%83%9E",
3558 + "categorySlug": "yugioh",
3559 + "label": "Yu-Gi-Oh! prismatic"
3560 + },
3561 + {
3562 + "query": "category=5&search_word=%E3%82%AC%E3%83%B3%E3%83%97%E3%83%A9%20MG",
3563 + "categorySlug": "gundam",
3564 + "label": "Gunpla MG"
3565 + },
3566 + {
3567 + "query": "category=5&search_word=figma",
3568 + "categorySlug": "action_figures",
3569 + "label": "figma"
3570 + },
3571 + {
3572 + "query": "category=5&search_word=%E3%82%B9%E3%83%BC%E3%83%91%E3%83%BC%E3%83%95%E3%82%A1%E3%83%9F%E3%82%B3%E3%83%B3%20%E3%82%BD%E3%83%95%E3%83%88",
3573 + "categorySlug": "nintendo_games",
3574 + "label": "Super Famicom software"
3575 + }
3576 + ],
3577 + "pagesPerSeed": 1
3578 + }
3579 + },
3580 + {
3581 + "id": "swann",
3582 + "displayName": "Swann Auction Galleries (results)",
3583 + "sourceId": "swann",
3584 + "sourceName": "Swann Auction Galleries",
3585 + "sourceType": "auction_house",
3586 + "sourceUrl": "https://www.swanngalleries.com",
3587 + "module": "api/swann",
3588 + "enginePriority": [
3589 + "api",
3590 + "firecrawl"
3591 + ],
3592 + "categories": [
3593 + "autographs",
3594 + "photography",
3595 + "movie_posters",
3596 + "maps",
3597 + "books",
3598 + "art",
3599 + "contemporary_art",
3600 + "historical_documents",
3601 + "comics",
3602 + "animation_art"
3603 + ],
3604 + "regions": [
3605 + "US"
1484 3606 ],
1485 3607 "languages": [
1486 3608 "en"
1487 3609 ],
1488 3610 "currency": [
1489 − "USD",
1490 − "GBP",
1491 − "HKD",
1492 − "EUR",
1493 − "CHF"
3611 + "USD"
1494 3612 ],
1495 3613 "supportsListings": false,
1496 3614 "supportsSold": true,
1497 − "supportsAuctions": true,
3615 + "supportsAuctions": false,
1498 3616 "supportsImages": true,
1499 3617 "supportsCatalog": false,
1500 3618 "supportsPopulation": false,
1501 − "supportsLookup": true,
1502 − "refreshFrequencyMinutes": 360,
1503 − "priority": "high",
3619 + "supportsLookup": false,
3620 + "refreshFrequencyMinutes": 1440,
3621 + "priority": "medium",
1504 3622 "trustScore": 0.9,
1505 3623 "attributionRequired": true,
1506 − "termsUrl": "https://www.sothebys.com/en/terms-conditions",
1507 − "accessNotes": "Auction pages https://www.sothebys.com/en/buy/auction/<year>/<slug> are server-rendered (__NEXT_DATA__ Apollo cache) with auction metadata, department names, currency, dates and the first 48 lot cards including estimates and, for closed sales, the visible result (BidState.sold.premiums.finalPriceV2 = price including buyer's premium, currentBidV2 = hammer). Remaining lots are paged with the same public GraphQL endpoint the page uses (clientapi.prod.sothelabs.com/graphql, query LotCardsFilterByPaginated, no authentication; we request only public lot-card fields). Auction discovery: links on the public /en/results and /en/calendar pages (rendered through Firecrawl, 1 credit each), plus config.seeds auction URLs; auctions seen while open are re-checked after they close so their results are captured. robots.txt disallows /bsp-api/* and PDFs — not used. Prices: finalPriceV2 (buyer's premium included) → buyerPremiumIncluded=true, hammer kept in metadata; sale date = lot closingTime or the auction's closed timestamp. Condition reports are behind login and are not fetched.",
3624 + "termsUrl": "https://www.swanngalleries.com/conditions-of-sale/",
3625 + "accessNotes": "Public pages over plain HTTPS with the RareIndex user agent (robots.txt: Disallow empty): the past-auctions list (/auctions/past-auctions/: date, sale number, department, catalog link) and archived catalogs (/auction-catalog/<slug>?algoliaParam=archive_lotNumber_asc_prod[page]=N, 20 lots per page) whose server-rendered cards show lot number, title, estimate and 'Sold: $X' with the note 'Sold price includes buyer's premium' → buyer_premium_included=true; 'Passed' lots are skipped. Category from the sale's department. 1.5 s politeness delay.",
1508 3626 "enabled": true,
1509 3627 "schemaVersion": "1.0",
1510 3628 "config": {
1511 − "seeds": [],
1512 − "discoveryPages": [
1513 − "https://www.sothebys.com/en/results",
1514 − "https://www.sothebys.com/en/calendar"
1515 − ],
1516 − "departments": [],
1517 − "maxAuctionsPerRun": 25,
1518 − "pageSize": 48
3629 + "auctionsPerRun": 2,
3630 + "pagesPerAuction": 15
1519 3631 }
1520 3632 },
1521 3633 {
1522 − "id": "sportscardspro",
1523 − "displayName": "SportsCardsPro",
1524 − "sourceId": "sportscardspro",
1525 − "sourceName": "SportsCardsPro (PriceCharting)",
1526 − "sourceType": "pricing_guide",
1527 − "sourceUrl": "https://www.sportscardspro.com",
1528 − "module": "api/sportscardspro",
3634 + "id": "swu-db",
3635 + "displayName": "SWU-DB (Star Wars: Unlimited)",
3636 + "sourceId": "swu-db",
3637 + "sourceName": "SWU-DB",
3638 + "sourceType": "catalog",
3639 + "sourceUrl": "https://www.swu-db.com",
3640 + "module": "api/swu-db",
1529 3641 "enginePriority": [
1530 − "firecrawl",
1531 − "scrapfly"
3642 + "api"
1532 3643 ],
1533 3644 "categories": [
1534 − "sports_cards",
1535 − "baseball_cards",
1536 − "basketball_cards",
1537 − "football_cards",
1538 − "hockey_cards",
1539 − "soccer_cards",
1540 − "f1_cards",
1541 − "other_sports_cards"
3645 + "star_wars_tcg"
1542 3646 ],
1543 3647 "regions": [
1544 3648 "US"
@@ -1550,52 +3654,218 @@
1550 3654 "USD"
1551 3655 ],
1552 3656 "supportsListings": false,
1553 − "supportsSold": true,
3657 + "supportsSold": false,
1554 3658 "supportsAuctions": false,
1555 3659 "supportsImages": true,
1556 3660 "supportsCatalog": true,
1557 3661 "supportsPopulation": false,
1558 3662 "supportsLookup": true,
1559 3663 "refreshFrequencyMinutes": 1440,
1560 − "priority": "high",
3664 + "priority": "medium",
1561 3665 "trustScore": 0.7,
1562 3666 "attributionRequired": true,
1563 − "termsUrl": "https://www.sportscardspro.com/page/terms-of-service",
1564 − "accessNotes": "Public product pages of PriceCharting's sports-card site. robots.txt only disallows /buy, /publish-offer, /stripe-connect. Plain HTTP with our user agent gets a Cloudflare interstitial (HTTP 403), so pages are fetched through Firecrawl (1 credit per page, ~1 MB each) — no login, no CAPTCHA solving, no account. Each page exposes guide values per grade (Ungraded, Grade 1–9.5, TAG/ACE/SGC/CGC/PSA/BGS 10, BGS 10 Black, CGC 10 Pristine) and recently completed eBay sales per grade tab; graded rows without a named grading company are stored with the generic grader 'graded' unless the eBay title names the company. Set lists come from the public /category/<sport>-cards pages; console listings use the ?format=json cursor endpoint.",
3667 + "termsUrl": "https://www.swu-db.com/api",
3668 + "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.",
1565 3669 "enabled": true,
1566 − "schemaVersion": "2.0",
3670 + "schemaVersion": "1.0",
1567 3671 "config": {
1568 − "seeds": [
1569 − "basketball-cards-1986-fleer",
1570 − "basketball-cards-2003-topps-chrome",
1571 − "basketball-cards-2018-panini-prizm",
1572 − "basketball-cards-2019-panini-prizm",
1573 − "basketball-cards-1996-topps-chrome",
1574 − "baseball-cards-1952-topps",
1575 − "baseball-cards-1989-upper-deck",
1576 − "baseball-cards-2011-topps-update",
1577 − "baseball-cards-2018-topps-update",
1578 − "baseball-cards-1993-sp",
1579 − "football-cards-2000-playoff-contenders",
1580 − "football-cards-2017-panini-prizm",
1581 − "football-cards-2020-panini-prizm",
1582 − "football-cards-1957-topps",
1583 − "hockey-cards-1979-o-pee-chee",
1584 − "hockey-cards-2005-upper-deck",
1585 − "hockey-cards-2015-upper-deck",
1586 − "soccer-cards-2018-panini-prizm-world-cup",
1587 − "soccer-cards-2004-panini-mega-cracks"
1588 − ],
1589 − "cardCategories": [
1590 − "basketball-cards",
1591 − "baseball-cards",
1592 − "football-cards",
1593 − "hockey-cards",
1594 − "soccer-cards"
1595 − ],
1596 − "maxConsolesPerCategory": 25,
1597 − "productsPerConsole": 120,
1598 − "sort": "popularity"
3672 + "sets": {
3673 + "SOR": "Spark of Rebellion",
3674 + "SHD": "Shadows of the Galaxy",
3675 + "TWI": "Twilight of the Republic",
3676 + "JTL": "Jump to Lightspeed",
3677 + "LOF": "Legends of the Force",
3678 + "SEC": "Secrets of Power"
3679 + }
3680 + }
3681 + },
3682 + {
3683 + "id": "tcgcsv",
3684 + "displayName": "TCGCSV (TCGplayer catalog & prices)",
3685 + "sourceId": "tcgcsv",
3686 + "sourceName": "TCGCSV",
3687 + "sourceType": "pricing_guide",
3688 + "sourceUrl": "https://tcgcsv.com",
3689 + "module": "api/tcgcsv",
3690 + "enginePriority": [
3691 + "api"
3692 + ],
3693 + "categories": [
3694 + "trading_cards",
3695 + "pokemon",
3696 + "magic_the_gathering",
3697 + "yugioh",
3698 + "disney_lorcana",
3699 + "one_piece_card_game",
3700 + "digimon_tcg",
3701 + "flesh_and_blood",
3702 + "star_wars_tcg",
3703 + "dragon_ball_tcg",
3704 + "weiss_schwarz",
3705 + "final_fantasy_tcg",
3706 + "other_tcg",
3707 + "funko"
3708 + ],
3709 + "regions": [
3710 + "US"
3711 + ],
3712 + "languages": [
3713 + "en",
3714 + "ja"
3715 + ],
3716 + "currency": [
3717 + "USD"
3718 + ],
3719 + "supportsListings": false,
3720 + "supportsSold": false,
3721 + "supportsAuctions": false,
3722 + "supportsImages": true,
3723 + "supportsCatalog": true,
3724 + "supportsPopulation": false,
3725 + "supportsLookup": false,
3726 + "refreshFrequencyMinutes": 1440,
3727 + "priority": "high",
3728 + "trustScore": 0.8,
3729 + "attributionRequired": true,
3730 + "termsUrl": "https://tcgcsv.com/faq",
3731 + "accessNotes": "Public daily mirror of TCGplayer's catalog and price API (JSON endpoints /tcgplayer/{categoryId}/groups, /{groupId}/products, /{groupId}/prices; robots.txt allows everything and the FAQ explicitly invites programmatic access with an identifying User-Agent and ~4 req/s). Prices (low/mid/high/market per printing subtype) are TCGplayer market data, not transactions: stored as price_observations dated with the mirror's last-updated timestamp (/last-updated.txt), confidence 0.75. Categories are mapped to taxonomy slugs in config; incremental runs cover the newest `maxGroupsPerCategory` sets per category, backfill covers all. Identifiers: tcgplayer_id (product) + tcgplayer_group_id. Historical price archives (2024-02-08 →) exist as daily 7z files and can be backfilled later.",
3732 + "enabled": true,
3733 + "schemaVersion": "1.0",
3734 + "config": {
3735 + "categories": {
3736 + "1": {
3737 + "slug": "magic_the_gathering",
3738 + "franchise": "Magic: The Gathering",
3739 + "brand": "Wizards of the Coast"
3740 + },
3741 + "2": {
3742 + "slug": "yugioh",
3743 + "franchise": "Yu-Gi-Oh!",
3744 + "brand": "Konami"
3745 + },
3746 + "3": {
3747 + "slug": "pokemon",
3748 + "franchise": "Pokémon",
3749 + "brand": "The Pokémon Company",
3750 + "language": "English"
3751 + },
3752 + "16": {
3753 + "slug": "other_tcg",
3754 + "franchise": "Cardfight!! Vanguard",
3755 + "brand": "Bushiroad"
3756 + },
3757 + "17": {
3758 + "slug": "other_tcg",
3759 + "franchise": "Force of Will",
3760 + "brand": "Force of Will Co."
3761 + },
3762 + "20": {
3763 + "slug": "weiss_schwarz",
3764 + "franchise": "Weiß Schwarz",
3765 + "brand": "Bushiroad"
3766 + },
3767 + "24": {
3768 + "slug": "final_fantasy_tcg",
3769 + "franchise": "Final Fantasy TCG",
3770 + "brand": "Square Enix"
3771 + },
3772 + "25": {
3773 + "slug": "other_tcg",
3774 + "franchise": "UniVersus",
3775 + "brand": "UVS Games"
3776 + },
3777 + "27": {
3778 + "slug": "dragon_ball_tcg",
3779 + "franchise": "Dragon Ball Super Card Game",
3780 + "brand": "Bandai"
3781 + },
3782 + "29": {
3783 + "slug": "funko",
3784 + "franchise": null,
3785 + "brand": "Funko"
3786 + },
3787 + "59": {
3788 + "slug": "other_tcg",
3789 + "franchise": "KeyForge",
3790 + "brand": "Ghost Galaxy"
3791 + },
3792 + "62": {
3793 + "slug": "flesh_and_blood",
3794 + "franchise": "Flesh and Blood",
3795 + "brand": "Legend Story Studios"
3796 + },
3797 + "63": {
3798 + "slug": "digimon_tcg",
3799 + "franchise": "Digimon",
3800 + "brand": "Bandai"
3801 + },
3802 + "66": {
3803 + "slug": "other_tcg",
3804 + "franchise": "MetaZoo",
3805 + "brand": "MetaZoo Games"
3806 + },
3807 + "68": {
3808 + "slug": "one_piece_card_game",
3809 + "franchise": "One Piece",
3810 + "brand": "Bandai"
3811 + },
3812 + "71": {
3813 + "slug": "disney_lorcana",
3814 + "franchise": "Disney Lorcana",
3815 + "brand": "Ravensburger"
3816 + },
3817 + "72": {
3818 + "slug": "other_tcg",
3819 + "franchise": "Battle Spirits Saga",
3820 + "brand": "Bandai"
3821 + },
3822 + "74": {
3823 + "slug": "other_tcg",
3824 + "franchise": "Grand Archive",
3825 + "brand": "Weebs of the Shore"
3826 + },
3827 + "77": {
3828 + "slug": "other_tcg",
3829 + "franchise": "Sorcery: Contested Realm",
3830 + "brand": "Erik's Curiosa"
3831 + },
3832 + "79": {
3833 + "slug": "star_wars_tcg",
3834 + "franchise": "Star Wars: Unlimited",
3835 + "brand": "Fantasy Flight Games"
3836 + },
3837 + "80": {
3838 + "slug": "dragon_ball_tcg",
3839 + "franchise": "Dragon Ball Super Fusion World",
3840 + "brand": "Bandai"
3841 + },
3842 + "81": {
3843 + "slug": "other_tcg",
3844 + "franchise": "Union Arena",
3845 + "brand": "Bandai"
3846 + },
3847 + "85": {
3848 + "slug": "pokemon",
3849 + "franchise": "Pokémon",
3850 + "brand": "The Pokémon Company",
3851 + "language": "Japanese"
3852 + },
3853 + "86": {
3854 + "slug": "other_tcg",
3855 + "franchise": "Gundam Card Game",
3856 + "brand": "Bandai"
3857 + },
3858 + "89": {
3859 + "slug": "other_tcg",
3860 + "franchise": "Riftbound",
3861 + "brand": "Riot Games"
3862 + }
3863 + },
3864 + "maxGroupsPerCategory": 40,
3865 + "priceKinds": [
3866 + "market",
3867 + "low"
3868 + ]
1599 3869 }
1600 3870 },
1601 3871 {
@@ -1652,6 +3922,143 @@
1652 3922 ]
1653 3923 }
1654 3924 },
3925 + {
3926 + "id": "watchfinder",
3927 + "displayName": "Watchfinder & Co. (UK pre-owned watch listings)",
3928 + "sourceId": "watchfinder",
3929 + "sourceName": "Watchfinder & Co.",
3930 + "sourceType": "dealer",
3931 + "sourceUrl": "https://www.watchfinder.co.uk",
3932 + "module": "firecrawl/watchfinder",
3933 + "enginePriority": [
3934 + "firecrawl",
3935 + "scrapfly"
3936 + ],
3937 + "categories": [
3938 + "watches",
3939 + "rolex",
3940 + "patek_philippe",
3941 + "audemars_piguet",
3942 + "omega",
3943 + "other_watches"
3944 + ],
3945 + "regions": [
3946 + "GB"
3947 + ],
3948 + "languages": [
3949 + "en"
3950 + ],
3951 + "currency": [
3952 + "GBP"
3953 + ],
3954 + "supportsListings": true,
3955 + "supportsSold": false,
3956 + "supportsAuctions": false,
3957 + "supportsImages": true,
3958 + "supportsCatalog": false,
3959 + "supportsPopulation": false,
3960 + "supportsLookup": false,
3961 + "refreshFrequencyMinutes": 720,
3962 + "priority": "medium",
3963 + "trustScore": 0.85,
3964 + "attributionRequired": true,
3965 + "termsUrl": "https://www.watchfinder.co.uk/terms-and-conditions",
3966 + "accessNotes": "Plain HTTPS returns 403 to non-browser agents (robots.txt is not served either); Firecrawl's standard fetch returns the public model pages (watchfinder.co.uk/watches/<brand>/<model>?p=N). Each product card carries data attributes (sku, brand, series, model reference, image), box/papers icons, year and the GBP price (data-price-amount). 1 Firecrawl credit per page (~24 watches); 2 s between pages. Listings only.",
3967 + "enabled": true,
3968 + "schemaVersion": "1.0",
3969 + "config": {
3970 + "seeds": [
3971 + "rolex/daytona",
3972 + "rolex/submariner",
3973 + "rolex/gmt-master-ii",
3974 + "rolex/datejust",
3975 + "rolex/day-date",
3976 + "rolex/explorer",
3977 + "patek-philippe/nautilus",
3978 + "patek-philippe/aquanaut",
3979 + "audemars-piguet/royal-oak",
3980 + "audemars-piguet/royal-oak-offshore",
3981 + "omega/speedmaster",
3982 + "omega/seamaster",
3983 + "cartier/santos",
3984 + "tudor/black-bay",
3985 + "iwc/portugieser",
3986 + "jaeger-lecoultre/reverso"
3987 + ],
3988 + "pagesPerSeed": 2
3989 + }
3990 + },
3991 + {
3992 + "id": "winebid",
3993 + "displayName": "WineBid (recent sales per wine)",
3994 + "sourceId": "winebid",
3995 + "sourceName": "WineBid",
3996 + "sourceType": "marketplace",
3997 + "sourceUrl": "https://www.winebid.com",
3998 + "module": "api/winebid",
3999 + "enginePriority": [
4000 + "api",
4001 + "firecrawl",
4002 + "scrapfly"
4003 + ],
4004 + "categories": [
4005 + "wine",
4006 + "whisky"
4007 + ],
4008 + "regions": [
4009 + "US"
4010 + ],
4011 + "languages": [
4012 + "en"
4013 + ],
4014 + "currency": [
4015 + "USD"
4016 + ],
4017 + "supportsListings": true,
4018 + "supportsSold": true,
4019 + "supportsAuctions": true,
4020 + "supportsImages": true,
4021 + "supportsCatalog": false,
4022 + "supportsPopulation": false,
4023 + "supportsLookup": true,
4024 + "refreshFrequencyMinutes": 1440,
4025 + "priority": "medium",
4026 + "trustScore": 0.85,
4027 + "attributionRequired": true,
4028 + "termsUrl": "https://www.winebid.com/Help/TermsAndConditions",
4029 + "accessNotes": "US online wine auction (weekly auctions). Category browse pages (/BuyWine/Items/<region>/<id>) are rendered through Firecrawl (1 credit, plain HTTP returns 403 for list pages) to discover item URLs; each public item page (/BuyWine/Item/<id>/<slug>, plain HTTPS) shows the wine, the current auction lot (estimate / current bid) and a 'Recent sales' table of past winning bids for the same wine: item id, quantity sold, per-bottle amount (USD) and sale date. robots.txt lists rules for named search engines only (no generic group); we still avoid /Sales, /AuctionClosed and account paths. Winning bids exclude WineBid's 17% buyer's premium (buyer_premium_included=false; premium % in metadata). Sale dates come from the table (PDT). 2 s politeness delay; itemsPerRun caps each run.",
4030 + "enabled": true,
4031 + "schemaVersion": "1.0",
4032 + "config": {
4033 + "categories": [
4034 + {
4035 + "path": "/BuyWine/Items/Bordeaux/18403",
4036 + "label": "Bordeaux"
4037 + },
4038 + {
4039 + "path": "/BuyWine/Items/Burgundy/15363",
4040 + "label": "Burgundy"
4041 + },
4042 + {
4043 + "path": "/BuyWine/Items/Napa-Valley/2910",
4044 + "label": "Napa Valley"
4045 + },
4046 + {
4047 + "path": "/BuyWine/Items/Barolo/16367",
4048 + "label": "Barolo"
4049 + },
4050 + {
4051 + "path": "/BuyWine/Items/Brunello-di-Montalcino/7725165",
4052 + "label": "Brunello di Montalcino"
4053 + },
4054 + {
4055 + "path": "/BuyWine/Items/Big-Bottles/7902856",
4056 + "label": "Large formats"
4057 + }
4058 + ],
4059 + "itemsPerRun": 24
4060 + }
4061 + },
1655 4062 {
1656 4063 "id": "ygoprodeck",
1657 4064 "displayName": "YGOPRODeck (Yu-Gi-Oh!)",
added data/fixtures/digimoncard/bt5-first.json +46 −0
@@ -0,0 +1,46 @@
1 +{
2 + "raw": {
3 + "url": "https://digimoncard.io/card/a-blazing-storm-of-metal-bt5-103",
4 + "externalId": "BT5-103",
5 + "kind": "catalog_item",
6 + "engine": "api",
7 + "httpStatus": 200,
8 + "payload": {
9 + "card": {
10 + "name": "A Blazing Storm of Metal!",
11 + "type": "Option",
12 + "id": "BT5-103",
13 + "color": "Black",
14 + "color2": null,
15 + "rarity": "U",
16 + "stage": null,
17 + "attribute": null,
18 + "level": null,
19 + "dp": null,
20 + "artist": null,
21 + "series": "Digimon Card Game",
22 + "pretty_url": "a-blazing-storm-of-metal-bt5-103",
23 + "date_added": "2025-11-25 15:16:35",
24 + "tcgplayer_name": "A Blazing Storm of Metal!",
25 + "tcgplayer_id": 245181,
26 + "set_name": [
27 + "BT-05: Booster Battle Of Omni"
28 + ]
29 + },
30 + "alt": 1
31 + },
32 + "fetchedAt": "2026-09-07T06:26:44.856Z"
33 + },
34 + "expect": {
35 + "minCount": 1,
36 + "kinds": [
37 + "catalog_item"
38 + ],
39 + "requiredFields": [
40 + "attributes.identifiers.digimoncard_id",
41 + "attributes.number"
42 + ]
43 + },
44 + "note": "Live capture 2026-09-07 from https://digimoncard.io/card/a-blazing-storm-of-metal-bt5-103",
45 + "capturedAt": "2026-09-07T06:26:44.857Z"
46 +}
\ No newline at end of file
added data/fixtures/digimoncard/bt5-sixth.json +49 −0
@@ -0,0 +1,49 @@
1 +{
2 + "raw": {
3 + "url": "https://digimoncard.io/card/agumon-bt5-007",
4 + "externalId": "BT5-007",
5 + "kind": "catalog_item",
6 + "engine": "api",
7 + "httpStatus": 200,
8 + "payload": {
9 + "card": {
10 + "name": "Agumon",
11 + "type": "Digimon",
12 + "id": "BT5-007",
13 + "color": "Red",
14 + "color2": null,
15 + "rarity": "C",
16 + "stage": "Rookie",
17 + "attribute": "Vaccine",
18 + "level": 3,
19 + "dp": 2000,
20 + "artist": null,
21 + "series": "Digimon Card Game",
22 + "pretty_url": "agumon-bt5-007",
23 + "date_added": "2025-11-25 15:16:37",
24 + "tcgplayer_name": "Agumon",
25 + "tcgplayer_id": 243533,
26 + "set_name": [
27 + "BT-05: Booster Battle Of Omni",
28 + "PB-13: Digimon Card Game Royal Knights Binder Set",
29 + "Tamer Battle Pack 7",
30 + "Tamer Party Vol.4"
31 + ]
32 + },
33 + "alt": 1
34 + },
35 + "fetchedAt": "2026-09-07T06:26:44.856Z"
36 + },
37 + "expect": {
38 + "minCount": 1,
39 + "kinds": [
40 + "catalog_item"
41 + ],
42 + "requiredFields": [
43 + "attributes.identifiers.digimoncard_id",
44 + "attributes.number"
45 + ]
46 + },
47 + "note": "Live capture 2026-09-07 from https://digimoncard.io/card/agumon-bt5-007",
48 + "capturedAt": "2026-09-07T06:26:44.859Z"
49 +}
\ No newline at end of file
added data/fixtures/grand-archive/page1-first.json +64 −0
@@ -0,0 +1,64 @@
1 +{
2 + "raw": {
3 + "url": "https://index.gatcg.com/card/apotheosis-rite",
4 + "externalId": "df594Qoszn",
5 + "kind": "catalog_item",
6 + "engine": "api",
7 + "httpStatus": 200,
8 + "payload": {
9 + "card": {
10 + "uuid": "df594Qoszn",
11 + "name": "Apotheosis Rite",
12 + "slug": "apotheosis-rite",
13 + "element": "NORM",
14 + "types": [
15 + "REGALIA",
16 + "ITEM"
17 + ],
18 + "classes": [
19 + "WARRIOR"
20 + ],
21 + "editions": [
22 + {
23 + "uuid": "2zw7a98f7b",
24 + "slug": "apotheosis-rite-p24-cpr",
25 + "collector_number": "000",
26 + "rarity": 9,
27 + "illustrator": "十尾",
28 + "image": "/cards/images/2zw7a98f7b.jpg",
29 + "configuration": "default",
30 + "set": {
31 + "id": "muw6lmtzwg",
32 + "name": "Promotional 2024",
33 + "prefix": "P24",
34 + "release_date": "2024-01-24T00:00:00",
35 + "language": "EN"
36 + },
37 + "circulations": [
38 + {
39 + "uuid": "c29f57afoP",
40 + "kind": "FOIL",
41 + "foil": true,
42 + "population": 1,
43 + "population_operator": "=",
44 + "printing": false
45 + }
46 + ]
47 + }
48 + ]
49 + }
50 + },
51 + "fetchedAt": "2026-09-07T06:26:55.815Z"
52 + },
53 + "expect": {
54 + "minCount": 1,
55 + "kinds": [
56 + "catalog_item"
57 + ],
58 + "requiredFields": [
59 + "attributes.identifiers.gatcg_edition_id"
60 + ]
61 + },
62 + "note": "Live capture 2026-09-07 from https://index.gatcg.com/card/apotheosis-rite",
63 + "capturedAt": "2026-09-07T06:26:55.818Z"
64 +}
\ No newline at end of file
added data/fixtures/grand-archive/page1-third.json +64 −0
@@ -0,0 +1,64 @@
1 +{
2 + "raw": {
3 + "url": "https://index.gatcg.com/card/transcendental-rite",
4 + "externalId": "tAiiMGZJXp",
5 + "kind": "catalog_item",
6 + "engine": "api",
7 + "httpStatus": 200,
8 + "payload": {
9 + "card": {
10 + "uuid": "tAiiMGZJXp",
11 + "name": "Transcendental Rite",
12 + "slug": "transcendental-rite",
13 + "element": "NORM",
14 + "types": [
15 + "REGALIA",
16 + "ITEM"
17 + ],
18 + "classes": [
19 + "MAGE"
20 + ],
21 + "editions": [
22 + {
23 + "uuid": "tlTsnFNlat",
24 + "slug": "transcendental-rite-p26-cpr",
25 + "collector_number": "000",
26 + "rarity": 9,
27 + "illustrator": "十尾",
28 + "image": "/cards/images/tlTsnFNlat.jpg",
29 + "configuration": "default",
30 + "set": {
31 + "id": "8a5a6c2b25",
32 + "name": "Promotional 2026",
33 + "prefix": "P26",
34 + "release_date": "1970-01-01T00:00:00",
35 + "language": "EN"
36 + },
37 + "circulations": [
38 + {
39 + "uuid": "lFW23Ww57k",
40 + "kind": "FOIL",
41 + "foil": true,
42 + "population": 1,
43 + "population_operator": "=",
44 + "printing": false
45 + }
46 + ]
47 + }
48 + ]
49 + }
50 + },
51 + "fetchedAt": "2026-09-07T06:26:55.815Z"
52 + },
53 + "expect": {
54 + "minCount": 1,
55 + "kinds": [
56 + "catalog_item"
57 + ],
58 + "requiredFields": [
59 + "attributes.identifiers.gatcg_edition_id"
60 + ]
61 + },
62 + "note": "Live capture 2026-09-07 from https://index.gatcg.com/card/transcendental-rite",
63 + "capturedAt": "2026-09-07T06:26:55.819Z"
64 +}
\ No newline at end of file
added data/fixtures/mtggoldfish/lea-air-elemental.json +42 −0
@@ -0,0 +1,42 @@
1 +{
2 + "raw": {
3 + "url": "https://www.mtggoldfish.com/price/limited-edition-alpha/46/air-elemental",
4 + "externalId": "019ce2ef-3ce1-7f1e-9e00-484543650f1a",
5 + "kind": "catalog_item",
6 + "engine": "api",
7 + "httpStatus": 200,
8 + "payload": {
9 + "kind": "card",
10 + "setName": "Limited Edition Alpha Set Information",
11 + "setSlug": "Limited+Edition+Alpha",
12 + "card": {
13 + "name": "Air Elemental [LEA]",
14 + "set": "LEA",
15 + "display_name": "Air Elemental",
16 + "rarity": "Uncommon",
17 + "foil": false,
18 + "card_num": 46,
19 + "finish": "regular",
20 + "card_uuid": "019ce2ef-3ce1-7f1e-9e00-484543650f1a",
21 + "paper": 156,
22 + "online": null,
23 + "link": "/price/limited-edition-alpha/46/air-elemental",
24 + "image": "https://cards.mtggoldfish.com/images/019ce2ef-3ce1-7f1e-9e00-484543650f1a/variants/672/938/card_image.webp"
25 + }
26 + },
27 + "fetchedAt": "2026-09-07T06:27:11.229Z"
28 + },
29 + "expect": {
30 + "minCount": 1,
31 + "kinds": [
32 + "catalog_item",
33 + "price_observation"
34 + ],
35 + "requiredFields": [
36 + "attributes.setCode",
37 + "attributes.number"
38 + ]
39 + },
40 + "note": "Live capture 2026-09-07 from https://www.mtggoldfish.com/price/limited-edition-alpha/46/air-elemental",
41 + "capturedAt": "2026-09-07T06:27:11.234Z"
42 +}
\ No newline at end of file
added data/fixtures/mtggoldfish/lea-black-lotus.json +42 −0
@@ -0,0 +1,42 @@
1 +{
2 + "raw": {
3 + "url": "https://www.mtggoldfish.com/price/limited-edition-alpha/232/black-lotus",
4 + "externalId": "019ce2ef-3ce1-7edb-b3c2-64761ad06557",
5 + "kind": "catalog_item",
6 + "engine": "api",
7 + "httpStatus": 200,
8 + "payload": {
9 + "kind": "card",
10 + "setName": "Limited Edition Alpha Set Information",
11 + "setSlug": "Limited+Edition+Alpha",
12 + "card": {
13 + "name": "Black Lotus [LEA]",
14 + "set": "LEA",
15 + "display_name": "Black Lotus",
16 + "rarity": "Rare",
17 + "foil": false,
18 + "card_num": 232,
19 + "finish": "regular",
20 + "card_uuid": "019ce2ef-3ce1-7edb-b3c2-64761ad06557",
21 + "paper": null,
22 + "online": 491.06,
23 + "link": "/price/limited-edition-alpha/232/black-lotus",
24 + "image": "https://cards.mtggoldfish.com/images/019ce2ef-3ce1-7edb-b3c2-64761ad06557/variants/672/938/card_image.webp"
25 + }
26 + },
27 + "fetchedAt": "2026-09-07T06:27:11.229Z"
28 + },
29 + "expect": {
30 + "minCount": 1,
31 + "kinds": [
32 + "catalog_item",
33 + "price_observation"
34 + ],
35 + "requiredFields": [
36 + "attributes.setCode",
37 + "attributes.number"
38 + ]
39 + },
40 + "note": "Live capture 2026-09-07 from https://www.mtggoldfish.com/price/limited-edition-alpha/232/black-lotus",
41 + "capturedAt": "2026-09-07T06:31:35.665Z"
42 +}
\ No newline at end of file
added data/fixtures/pokemonprice/base-set-1st-edition-first.json +1360 −0
@@ -0,0 +1,1360 @@
1 +{
2 + "raw": {
3 + "url": "https://www.pokemonprice.com/base-set-1st-edition/alakazam-1-holo-1st-edition",
4 + "externalId": "base-set-1st-edition/alakazam-1-holo-1st-edition",
5 + "kind": "catalog_item",
6 + "engine": "api",
7 + "httpStatus": 200,
8 + "payload": {
9 + "slug": "alakazam-1-holo-1st-edition",
10 + "setSlug": "base-set-1st-edition",
11 + "name": "Alakazam",
12 + "tag": "Holo 1st edition",
13 + "number": "1",
14 + "total": "130",
15 + "setName": "Base Set 1st Edition",
16 + "image": "https://cdn.pokemonprice.com/cards/base4-1-alakazam.webp",
17 + "rows": [
18 + {
19 + "grade": "PSA1",
20 + "fair_price": "30.00",
21 + "low_price": "30.00",
22 + "high_price": "30.00",
23 + "confidence": "52.72",
24 + "last_sale_date": "2020-05-30"
25 + },
26 + {
27 + "grade": "PSA10",
28 + "fair_price": "12999.99",
29 + "low_price": "12999.99",
30 + "high_price": "12999.99",
31 + "confidence": "52.72",
32 + "last_sale_date": "2024-11-03"
33 + },
34 + {
35 + "grade": "PSA2",
36 + "fair_price": "36.00",
37 + "low_price": "36.00",
38 + "high_price": "36.00",
39 + "confidence": "52.72",
40 + "last_sale_date": "2023-07-03"
41 + },
42 + {
43 + "grade": "PSA3",
44 + "fair_price": "30.00",
45 + "low_price": "30.00",
46 + "high_price": "30.00",
47 + "confidence": "52.72",
48 + "last_sale_date": "2023-07-10"
49 + },
50 + {
51 + "grade": "PSA4",
52 + "fair_price": "28.00",
53 + "low_price": "28.00",
54 + "high_price": "28.00",
55 + "confidence": "52.72",
56 + "last_sale_date": "2023-08-31"
57 + },
58 + {
59 + "grade": "PSA5",
60 + "fair_price": "51.00",
61 + "low_price": "51.00",
62 + "high_price": "51.00",
63 + "confidence": "52.72",
64 + "last_sale_date": "2023-09-25"
65 + },
66 + {
67 + "grade": "PSA6",
68 + "fair_price": "71.00",
69 + "low_price": "71.00",
70 + "high_price": "71.00",
71 + "confidence": "52.72",
72 + "last_sale_date": "2023-10-01"
73 + },
74 + {
75 + "grade": "PSA7",
76 + "fair_price": "94.54",
77 + "low_price": "94.54",
78 + "high_price": "94.54",
79 + "confidence": "52.72",
80 + "last_sale_date": "2024-05-24"
81 + },
82 + {
83 + "grade": "PSA8",
84 + "fair_price": "242.50",
85 + "low_price": "242.50",
86 + "high_price": "242.50",
87 + "confidence": "52.72",
88 + "last_sale_date": "2024-06-10"
89 + },
90 + {
91 + "grade": "PSA9",
92 + "fair_price": "2059.80",
93 + "low_price": "2059.80",
94 + "high_price": "2059.80",
95 + "confidence": "52.72",
96 + "last_sale_date": "2024-09-14"
97 + },
98 + {
99 + "grade": "Raw",
100 + "fair_price": "448.75",
101 + "low_price": "397.47",
102 + "high_price": "500.03",
103 + "confidence": "47.78",
104 + "last_sale_date": "2024-07-18"
105 + }
106 + ],
107 + "transactions": [
108 + {
109 + "month": "Sep, 2016",
110 + "grade": "PSA9",
111 + "count": 1
112 + },
113 + {
114 + "month": "Nov, 2016",
115 + "grade": "PSA9",
116 + "count": 1
117 + },
118 + {
119 + "month": "Dec, 2016",
120 + "grade": "PSA6",
121 + "count": 1
122 + },
123 + {
124 + "month": "Jan, 2017",
125 + "grade": "PSA8",
126 + "count": 1
127 + },
128 + {
129 + "month": "Jan, 2017",
130 + "grade": "PSA7",
131 + "count": 1
132 + },
133 + {
134 + "month": "May, 2017",
135 + "grade": "PSA8",
136 + "count": 2
137 + },
138 + {
139 + "month": "Jun, 2017",
140 + "grade": "PSA8",
141 + "count": 1
142 + },
143 + {
144 + "month": "Jul, 2017",
145 + "grade": "PSA7",
146 + "count": 4
147 + },
148 + {
149 + "month": "Jul, 2017",
150 + "grade": "PSA8",
151 + "count": 2
152 + },
153 + {
154 + "month": "Jul, 2017",
155 + "grade": "PSA9",
156 + "count": 1
157 + },
158 + {
159 + "month": "Aug, 2017",
160 + "grade": "PSA8",
161 + "count": 1
162 + },
163 + {
164 + "month": "Aug, 2017",
165 + "grade": "PSA9",
166 + "count": 2
167 + },
168 + {
169 + "month": "Aug, 2017",
170 + "grade": "PSA6",
171 + "count": 1
172 + },
173 + {
174 + "month": "Sep, 2017",
175 + "grade": "PSA6",
176 + "count": 1
177 + },
178 + {
179 + "month": "Sep, 2017",
180 + "grade": "PSA9",
181 + "count": 1
182 + },
183 + {
184 + "month": "Oct, 2017",
185 + "grade": "PSA7",
186 + "count": 1
187 + },
188 + {
189 + "month": "Oct, 2017",
190 + "grade": "PSA8",
191 + "count": 2
192 + },
193 + {
194 + "month": "Nov, 2017",
195 + "grade": "PSA7",
196 + "count": 1
197 + },
198 + {
199 + "month": "Nov, 2017",
200 + "grade": "PSA8",
201 + "count": 2
202 + },
203 + {
204 + "month": "Dec, 2017",
205 + "grade": "PSA7",
206 + "count": 1
207 + },
208 + {
209 + "month": "Jan, 2018",
210 + "grade": "PSA8",
211 + "count": 4
212 + },
213 + {
214 + "month": "Jan, 2018",
215 + "grade": "PSA9",
216 + "count": 1
217 + },
218 + {
219 + "month": "Feb, 2018",
220 + "grade": "PSA8",
221 + "count": 2
222 + },
223 + {
224 + "month": "Feb, 2018",
225 + "grade": "PSA6",
226 + "count": 2
227 + },
228 + {
229 + "month": "Feb, 2018",
230 + "grade": "PSA9",
231 + "count": 2
232 + },
233 + {
234 + "month": "Mar, 2018",
235 + "grade": "PSA8",
236 + "count": 6
237 + },
238 + {
239 + "month": "Mar, 2018",
240 + "grade": "PSA6",
241 + "count": 1
242 + },
243 + {
244 + "month": "Mar, 2018",
245 + "grade": "PSA9",
246 + "count": 2
247 + },
248 + {
249 + "month": "Mar, 2018",
250 + "grade": "PSA5",
251 + "count": 1
252 + },
253 + {
254 + "month": "Apr, 2018",
255 + "grade": "PSA7",
256 + "count": 5
257 + },
258 + {
259 + "month": "Apr, 2018",
260 + "grade": "PSA9",
261 + "count": 6
262 + },
263 + {
264 + "month": "Apr, 2018",
265 + "grade": "PSA8",
266 + "count": 2
267 + },
268 + {
269 + "month": "Apr, 2018",
270 + "grade": "PSA5",
271 + "count": 1
272 + },
273 + {
274 + "month": "Apr, 2018",
275 + "grade": "PSA1",
276 + "count": 1
277 + },
278 + {
279 + "month": "Apr, 2018",
280 + "grade": "PSA3",
281 + "count": 1
282 + },
283 + {
284 + "month": "May, 2018",
285 + "grade": "PSA8",
286 + "count": 4
287 + },
288 + {
289 + "month": "May, 2018",
290 + "grade": "PSA7",
291 + "count": 1
292 + },
293 + {
294 + "month": "May, 2018",
295 + "grade": "PSA10",
296 + "count": 2
297 + },
298 + {
299 + "month": "May, 2018",
300 + "grade": "PSA5",
301 + "count": 1
302 + },
303 + {
304 + "month": "May, 2018",
305 + "grade": "PSA6",
306 + "count": 1
307 + },
308 + {
309 + "month": "May, 2018",
310 + "grade": "PSA9",
311 + "count": 2
312 + },
313 + {
314 + "month": "Jun, 2018",
315 + "grade": "PSA8",
316 + "count": 2
317 + },
318 + {
319 + "month": "Jul, 2018",
320 + "grade": "PSA6",
321 + "count": 1
322 + },
323 + {
324 + "month": "Jul, 2018",
325 + "grade": "PSA8",
326 + "count": 2
327 + },
328 + {
329 + "month": "Jul, 2018",
330 + "grade": "PSA7",
331 + "count": 2
332 + },
333 + {
334 + "month": "Jul, 2018",
335 + "grade": "PSA2",
336 + "count": 1
337 + },
338 + {
339 + "month": "Aug, 2018",
340 + "grade": "PSA9",
341 + "count": 4
342 + },
343 + {
344 + "month": "Aug, 2018",
345 + "grade": "PSA10",
346 + "count": 2
347 + },
348 + {
349 + "month": "Aug, 2018",
350 + "grade": "PSA8",
351 + "count": 2
352 + },
353 + {
354 + "month": "Aug, 2018",
355 + "grade": "PSA7",
356 + "count": 1
357 + },
358 + {
359 + "month": "Sep, 2018",
360 + "grade": "PSA8",
361 + "count": 1
362 + },
363 + {
364 + "month": "Oct, 2018",
365 + "grade": "PSA8",
366 + "count": 1
367 + },
368 + {
369 + "month": "Nov, 2018",
370 + "grade": "PSA9",
371 + "count": 1
372 + },
373 + {
374 + "month": "Nov, 2018",
375 + "grade": "PSA8",
376 + "count": 1
377 + },
378 + {
379 + "month": "Dec, 2018",
380 + "grade": "PSA4",
381 + "count": 1
382 + },
383 + {
384 + "month": "Dec, 2018",
385 + "grade": "PSA8",
386 + "count": 2
387 + },
388 + {
389 + "month": "Jan, 2019",
390 + "grade": "PSA8",
391 + "count": 3
392 + },
393 + {
394 + "month": "Jan, 2019",
395 + "grade": "PSA4",
396 + "count": 1
397 + },
398 + {
399 + "month": "Jan, 2019",
400 + "grade": "PSA9",
401 + "count": 1
402 + },
403 + {
404 + "month": "Feb, 2019",
405 + "grade": "PSA8",
406 + "count": 2
407 + },
408 + {
409 + "month": "Feb, 2019",
410 + "grade": "PSA6",
411 + "count": 1
412 + },
413 + {
414 + "month": "Mar, 2019",
415 + "grade": "PSA10",
416 + "count": 1
417 + },
418 + {
419 + "month": "Mar, 2019",
420 + "grade": "PSA7",
421 + "count": 1
422 + },
423 + {
424 + "month": "Mar, 2019",
425 + "grade": "PSA9",
426 + "count": 3
427 + },
428 + {
429 + "month": "Mar, 2019",
430 + "grade": "PSA6",
431 + "count": 1
432 + },
433 + {
434 + "month": "Apr, 2019",
435 + "grade": "PSA8",
436 + "count": 1
437 + },
438 + {
439 + "month": "Apr, 2019",
440 + "grade": "PSA9",
441 + "count": 1
442 + },
443 + {
444 + "month": "May, 2019",
445 + "grade": "PSA9",
446 + "count": 2
447 + },
448 + {
449 + "month": "May, 2019",
450 + "grade": "PSA8",
451 + "count": 1
452 + },
453 + {
454 + "month": "Jun, 2019",
455 + "grade": "PSA9",
456 + "count": 3
457 + },
458 + {
459 + "month": "Jun, 2019",
460 + "grade": "PSA8",
461 + "count": 1
462 + },
463 + {
464 + "month": "Jul, 2019",
465 + "grade": "PSA9",
466 + "count": 1
467 + },
468 + {
469 + "month": "Jul, 2019",
470 + "grade": "PSA5",
471 + "count": 1
472 + },
473 + {
474 + "month": "Jul, 2019",
475 + "grade": "PSA1",
476 + "count": 1
477 + },
478 + {
479 + "month": "Aug, 2019",
480 + "grade": "PSA7",
481 + "count": 1
482 + },
483 + {
484 + "month": "Aug, 2019",
485 + "grade": "PSA6",
486 + "count": 1
487 + },
488 + {
489 + "month": "Aug, 2019",
490 + "grade": "PSA9",
491 + "count": 1
492 + },
493 + {
494 + "month": "Sep, 2019",
495 + "grade": "PSA8",
496 + "count": 2
497 + },
498 + {
499 + "month": "Oct, 2019",
500 + "grade": "PSA8",
501 + "count": 2
502 + },
503 + {
504 + "month": "Oct, 2019",
505 + "grade": "PSA4",
506 + "count": 1
507 + },
508 + {
509 + "month": "Oct, 2019",
510 + "grade": "PSA3",
511 + "count": 1
512 + },
513 + {
514 + "month": "Nov, 2019",
515 + "grade": "PSA8",
516 + "count": 1
517 + },
518 + {
519 + "month": "Nov, 2019",
520 + "grade": "PSA5",
521 + "count": 1
522 + },
523 + {
524 + "month": "Nov, 2019",
525 + "grade": "PSA9",
526 + "count": 1
527 + },
528 + {
529 + "month": "Nov, 2019",
530 + "grade": "PSA7",
531 + "count": 1
532 + },
533 + {
534 + "month": "Dec, 2019",
535 + "grade": "PSA2",
536 + "count": 2
537 + },
538 + {
539 + "month": "Dec, 2019",
540 + "grade": "PSA9",
541 + "count": 2
542 + },
543 + {
544 + "month": "Dec, 2019",
545 + "grade": "PSA8",
546 + "count": 1
547 + },
548 + {
549 + "month": "Dec, 2019",
550 + "grade": "PSA4",
551 + "count": 1
552 + },
553 + {
554 + "month": "Jan, 2020",
555 + "grade": "PSA8",
556 + "count": 1
557 + },
558 + {
559 + "month": "Feb, 2020",
560 + "grade": "PSA7",
561 + "count": 1
562 + },
563 + {
564 + "month": "Feb, 2020",
565 + "grade": "PSA6",
566 + "count": 1
567 + },
568 + {
569 + "month": "Feb, 2020",
570 + "grade": "PSA9",
571 + "count": 1
572 + },
573 + {
574 + "month": "Mar, 2020",
575 + "grade": "PSA9",
576 + "count": 2
577 + },
578 + {
579 + "month": "Mar, 2020",
580 + "grade": "PSA6",
581 + "count": 1
582 + },
583 + {
584 + "month": "Mar, 2020",
585 + "grade": "PSA4",
586 + "count": 1
587 + },
588 + {
589 + "month": "Mar, 2020",
590 + "grade": "PSA8",
591 + "count": 1
592 + },
593 + {
594 + "month": "Mar, 2020",
595 + "grade": "PSA7",
596 + "count": 1
597 + },
598 + {
599 + "month": "Apr, 2020",
600 + "grade": "PSA9",
601 + "count": 3
602 + },
603 + {
604 + "month": "Apr, 2020",
605 + "grade": "PSA8",
606 + "count": 5
607 + },
608 + {
609 + "month": "Apr, 2020",
610 + "grade": "PSA6",
611 + "count": 1
612 + },
613 + {
614 + "month": "May, 2020",
615 + "grade": "PSA6",
616 + "count": 2
617 + },
618 + {
619 + "month": "May, 2020",
620 + "grade": "PSA5",
621 + "count": 2
622 + },
623 + {
624 + "month": "May, 2020",
625 + "grade": "PSA7",
626 + "count": 1
627 + },
628 + {
629 + "month": "May, 2020",
630 + "grade": "PSA8",
631 + "count": 1
632 + },
633 + {
634 + "month": "May, 2020",
635 + "grade": "PSA1",
636 + "count": 1
637 + },
638 + {
639 + "month": "Jun, 2020",
640 + "grade": "PSA7",
641 + "count": 2
642 + },
643 + {
644 + "month": "Jun, 2020",
645 + "grade": "PSA8",
646 + "count": 3
647 + },
648 + {
649 + "month": "Jun, 2020",
650 + "grade": "PSA9",
651 + "count": 3
652 + },
653 + {
654 + "month": "Jun, 2020",
655 + "grade": "PSA4",
656 + "count": 1
657 + },
658 + {
659 + "month": "Jun, 2020",
660 + "grade": "PSA5",
661 + "count": 1
662 + },
663 + {
664 + "month": "Jul, 2020",
665 + "grade": "PSA6",
666 + "count": 2
667 + },
668 + {
669 + "month": "Jul, 2020",
670 + "grade": "PSA9",
671 + "count": 3
672 + },
673 + {
674 + "month": "Jul, 2020",
675 + "grade": "PSA4",
676 + "count": 1
677 + },
678 + {
679 + "month": "Jul, 2020",
680 + "grade": "PSA8",
681 + "count": 3
682 + },
683 + {
684 + "month": "Aug, 2020",
685 + "grade": "PSA5",
686 + "count": 1
687 + },
688 + {
689 + "month": "Aug, 2020",
690 + "grade": "PSA6",
691 + "count": 2
692 + },
693 + {
694 + "month": "Aug, 2020",
695 + "grade": "PSA8",
696 + "count": 7
697 + },
698 + {
699 + "month": "Aug, 2020",
700 + "grade": "PSA7",
701 + "count": 1
702 + },
703 + {
704 + "month": "Aug, 2020",
705 + "grade": "PSA9",
706 + "count": 1
707 + },
708 + {
709 + "month": "Sep, 2020",
710 + "grade": "PSA9",
711 + "count": 3
712 + },
713 + {
714 + "month": "Sep, 2020",
715 + "grade": "PSA7",
716 + "count": 2
717 + },
718 + {
719 + "month": "Sep, 2020",
720 + "grade": "PSA8",
721 + "count": 2
722 + },
723 + {
724 + "month": "Sep, 2020",
725 + "grade": "PSA6",
726 + "count": 1
727 + },
728 + {
729 + "month": "Sep, 2020",
730 + "grade": "PSA3",
731 + "count": 1
732 + },
733 + {
734 + "month": "Oct, 2020",
735 + "grade": "PSA8",
736 + "count": 6
737 + },
738 + {
739 + "month": "Oct, 2020",
740 + "grade": "PSA4",
741 + "count": 1
742 + },
743 + {
744 + "month": "Oct, 2020",
745 + "grade": "PSA9",
746 + "count": 5
747 + },
748 + {
749 + "month": "Oct, 2020",
750 + "grade": "PSA7",
751 + "count": 7
752 + },
753 + {
754 + "month": "Oct, 2020",
755 + "grade": "PSA6",
756 + "count": 2
757 + },
758 + {
759 + "month": "Oct, 2020",
760 + "grade": "PSA5",
761 + "count": 2
762 + },
763 + {
764 + "month": "Nov, 2020",
765 + "grade": "PSA3",
766 + "count": 1
767 + },
768 + {
769 + "month": "Nov, 2020",
770 + "grade": "PSA8",
771 + "count": 4
772 + },
773 + {
774 + "month": "Nov, 2020",
775 + "grade": "PSA6",
776 + "count": 2
777 + },
778 + {
779 + "month": "Nov, 2020",
780 + "grade": "PSA9",
781 + "count": 5
782 + },
783 + {
784 + "month": "Nov, 2020",
785 + "grade": "PSA7",
786 + "count": 2
787 + },
788 + {
789 + "month": "Dec, 2020",
790 + "grade": "PSA6",
791 + "count": 2
792 + },
793 + {
794 + "month": "Dec, 2020",
795 + "grade": "PSA9",
796 + "count": 11
797 + },
798 + {
799 + "month": "Dec, 2020",
800 + "grade": "PSA8",
801 + "count": 9
802 + },
803 + {
804 + "month": "Dec, 2020",
805 + "grade": "PSA5",
806 + "count": 2
807 + },
808 + {
809 + "month": "Dec, 2020",
810 + "grade": "PSA4",
811 + "count": 1
812 + },
813 + {
814 + "month": "Dec, 2020",
815 + "grade": "PSA7",
816 + "count": 1
817 + },
818 + {
819 + "month": "Jan, 2021",
820 + "grade": "PSA9",
821 + "count": 8
822 + },
823 + {
824 + "month": "Jan, 2021",
825 + "grade": "PSA7",
826 + "count": 3
827 + },
828 + {
829 + "month": "Jan, 2021",
830 + "grade": "PSA8",
831 + "count": 5
832 + },
833 + {
834 + "month": "Jan, 2021",
835 + "grade": "PSA6",
836 + "count": 3
837 + },
838 + {
839 + "month": "Jan, 2021",
840 + "grade": "PSA4",
841 + "count": 1
842 + },
843 + {
844 + "month": "Feb, 2021",
845 + "grade": "PSA5",
846 + "count": 1
847 + },
848 + {
849 + "month": "Feb, 2021",
850 + "grade": "PSA9",
851 + "count": 1
852 + },
853 + {
854 + "month": "Feb, 2021",
855 + "grade": "PSA8",
856 + "count": 7
857 + },
858 + {
859 + "month": "Feb, 2021",
860 + "grade": "PSA7",
861 + "count": 1
862 + },
863 + {
864 + "month": "Feb, 2021",
865 + "grade": "PSA6",
866 + "count": 2
867 + },
868 + {
869 + "month": "Mar, 2021",
870 + "grade": "PSA8",
871 + "count": 6
872 + },
873 + {
874 + "month": "Mar, 2021",
875 + "grade": "PSA6",
876 + "count": 1
877 + },
878 + {
879 + "month": "Mar, 2021",
880 + "grade": "PSA5",
881 + "count": 2
882 + },
883 + {
884 + "month": "Mar, 2021",
885 + "grade": "PSA7",
886 + "count": 1
887 + },
888 + {
889 + "month": "Apr, 2021",
890 + "grade": "PSA6",
891 + "count": 5
892 + },
893 + {
894 + "month": "Apr, 2021",
895 + "grade": "PSA9",
896 + "count": 6
897 + },
898 + {
899 + "month": "Apr, 2021",
900 + "grade": "PSA8",
901 + "count": 4
902 + },
903 + {
904 + "month": "Apr, 2021",
905 + "grade": "PSA3",
906 + "count": 1
907 + },
908 + {
909 + "month": "May, 2021",
910 + "grade": "PSA2",
911 + "count": 1
912 + },
913 + {
914 + "month": "May, 2021",
915 + "grade": "PSA9",
916 + "count": 1
917 + },
918 + {
919 + "month": "May, 2021",
920 + "grade": "PSA6",
921 + "count": 1
922 + },
923 + {
924 + "month": "May, 2021",
925 + "grade": "PSA5",
926 + "count": 1
927 + },
928 + {
929 + "month": "May, 2021",
930 + "grade": "PSA7",
931 + "count": 2
932 + },
933 + {
934 + "month": "Jun, 2021",
935 + "grade": "PSA8",
936 + "count": 1
937 + },
938 + {
939 + "month": "Jul, 2021",
940 + "grade": "PSA8",
941 + "count": 1
942 + },
943 + {
944 + "month": "Jul, 2021",
945 + "grade": "PSA9",
946 + "count": 2
947 + },
948 + {
949 + "month": "Jul, 2021",
950 + "grade": "PSA6",
951 + "count": 1
952 + },
953 + {
954 + "month": "Jul, 2021",
955 + "grade": "PSA7",
956 + "count": 1
957 + },
958 + {
959 + "month": "Jul, 2021",
960 + "grade": "PSA5",
961 + "count": 1
962 + },
963 + {
964 + "month": "Aug, 2021",
965 + "grade": "PSA6",
966 + "count": 1
967 + },
968 + {
969 + "month": "Aug, 2021",
970 + "grade": "PSA9",
971 + "count": 1
972 + },
973 + {
974 + "month": "Aug, 2021",
975 + "grade": "PSA8",
976 + "count": 2
977 + },
978 + {
979 + "month": "Aug, 2021",
980 + "grade": "PSA7",
981 + "count": 1
982 + },
983 + {
984 + "month": "Sep, 2021",
985 + "grade": "PSA4",
986 + "count": 1
987 + },
988 + {
989 + "month": "Sep, 2021",
990 + "grade": "PSA6",
991 + "count": 2
992 + },
993 + {
994 + "month": "Sep, 2021",
995 + "grade": "PSA8",
996 + "count": 1
997 + },
998 + {
999 + "month": "Sep, 2021",
1000 + "grade": "PSA5",
1001 + "count": 1
1002 + },
1003 + {
1004 + "month": "Oct, 2021",
1005 + "grade": "PSA6",
1006 + "count": 3
1007 + },
1008 + {
1009 + "month": "Oct, 2021",
1010 + "grade": "PSA2",
1011 + "count": 1
1012 + },
1013 + {
1014 + "month": "Oct, 2021",
1015 + "grade": "PSA8",
1016 + "count": 1
1017 + },
1018 + {
1019 + "month": "Nov, 2021",
1020 + "grade": "PSA6",
1021 + "count": 2
1022 + },
1023 + {
1024 + "month": "Nov, 2021",
1025 + "grade": "PSA5",
1026 + "count": 2
1027 + },
1028 + {
1029 + "month": "Nov, 2021",
1030 + "grade": "PSA7",
1031 + "count": 1
1032 + },
1033 + {
1034 + "month": "Dec, 2021",
1035 + "grade": "PSA5",
1036 + "count": 2
1037 + },
1038 + {
1039 + "month": "Dec, 2021",
1040 + "grade": "PSA7",
1041 + "count": 1
1042 + },
1043 + {
1044 + "month": "Dec, 2021",
1045 + "grade": "PSA8",
1046 + "count": 2
1047 + },
1048 + {
1049 + "month": "Dec, 2021",
1050 + "grade": "PSA4",
1051 + "count": 1
1052 + },
1053 + {
1054 + "month": "Dec, 2021",
1055 + "grade": "PSA6",
1056 + "count": 1
1057 + },
1058 + {
1059 + "month": "Dec, 2021",
1060 + "grade": "PSA9",
1061 + "count": 1
1062 + },
1063 + {
1064 + "month": "Jan, 2022",
1065 + "grade": "PSA8",
1066 + "count": 1
1067 + },
1068 + {
1069 + "month": "Jan, 2022",
1070 + "grade": "PSA7",
1071 + "count": 1
1072 + },
1073 + {
1074 + "month": "Jan, 2022",
1075 + "grade": "PSA3",
1076 + "count": 1
1077 + },
1078 + {
1079 + "month": "Feb, 2022",
1080 + "grade": "PSA9",
1081 + "count": 1
1082 + },
1083 + {
1084 + "month": "Mar, 2022",
1085 + "grade": "PSA4",
1086 + "count": 1
1087 + },
1088 + {
1089 + "month": "May, 2022",
1090 + "grade": "PSA5",
1091 + "count": 1
1092 + },
1093 + {
1094 + "month": "May, 2022",
1095 + "grade": "PSA4",
1096 + "count": 2
1097 + },
1098 + {
1099 + "month": "Jun, 2022",
1100 + "grade": "PSA5",
1101 + "count": 1
1102 + },
1103 + {
1104 + "month": "Jun, 2022",
1105 + "grade": "PSA8",
1106 + "count": 1
1107 + },
1108 + {
1109 + "month": "Oct, 2022",
1110 + "grade": "PSA7",
1111 + "count": 2
1112 + },
1113 + {
1114 + "month": "Oct, 2022",
1115 + "grade": "PSA4",
1116 + "count": 1
1117 + },
1118 + {
1119 + "month": "Dec, 2022",
1120 + "grade": "PSA5",
1121 + "count": 2
1122 + },
1123 + {
1124 + "month": "Dec, 2022",
1125 + "grade": "PSA9",
1126 + "count": 1
1127 + },
1128 + {
1129 + "month": "Dec, 2022",
1130 + "grade": "PSA4",
1131 + "count": 1
1132 + },
1133 + {
1134 + "month": "Dec, 2022",
1135 + "grade": "PSA7",
1136 + "count": 1
1137 + },
1138 + {
1139 + "month": "Dec, 2022",
1140 + "grade": "PSA6",
1141 + "count": 2
1142 + },
1143 + {
1144 + "month": "Dec, 2022",
1145 + "grade": "PSA8",
1146 + "count": 1
1147 + },
1148 + {
1149 + "month": "Jan, 2023",
1150 + "grade": "PSA6",
1151 + "count": 2
1152 + },
1153 + {
1154 + "month": "Jan, 2023",
1155 + "grade": "PSA8",
1156 + "count": 3
1157 + },
1158 + {
1159 + "month": "Feb, 2023",
1160 + "grade": "PSA7",
1161 + "count": 4
1162 + },
1163 + {
1164 + "month": "Feb, 2023",
1165 + "grade": "PSA8",
1166 + "count": 4
1167 + },
1168 + {
1169 + "month": "Feb, 2023",
1170 + "grade": "PSA5",
1171 + "count": 1
1172 + },
1173 + {
1174 + "month": "Feb, 2023",
1175 + "grade": "PSA6",
1176 + "count": 1
1177 + },
1178 + {
1179 + "month": "Mar, 2023",
1180 + "grade": "PSA8",
1181 + "count": 2
1182 + },
1183 + {
1184 + "month": "Mar, 2023",
1185 + "grade": "PSA6",
1186 + "count": 3
1187 + },
1188 + {
1189 + "month": "Mar, 2023",
1190 + "grade": "PSA7",
1191 + "count": 3
1192 + },
1193 + {
1194 + "month": "Mar, 2023",
1195 + "grade": "PSA5",
1196 + "count": 2
1197 + },
1198 + {
1199 + "month": "Apr, 2023",
1200 + "grade": "PSA8",
1201 + "count": 1
1202 + },
1203 + {
1204 + "month": "May, 2023",
1205 + "grade": "PSA7",
1206 + "count": 1
1207 + },
1208 + {
1209 + "month": "Jun, 2023",
1210 + "grade": "PSA5",
1211 + "count": 1
1212 + },
1213 + {
1214 + "month": "Jul, 2023",
1215 + "grade": "PSA2",
1216 + "count": 1
1217 + },
1218 + {
1219 + "month": "Jul, 2023",
1220 + "grade": "PSA3",
1221 + "count": 1
1222 + },
1223 + {
1224 + "month": "Aug, 2023",
1225 + "grade": "PSA6",
1226 + "count": 3
1227 + },
1228 + {
1229 + "month": "Aug, 2023",
1230 + "grade": "PSA5",
1231 + "count": 1
1232 + },
1233 + {
1234 + "month": "Aug, 2023",
1235 + "grade": "PSA8",
1236 + "count": 1
1237 + },
1238 + {
1239 + "month": "Aug, 2023",
1240 + "grade": "PSA4",
1241 + "count": 1
1242 + },
1243 + {
1244 + "month": "Sep, 2023",
1245 + "grade": "PSA6",
1246 + "count": 2
1247 + },
1248 + {
1249 + "month": "Sep, 2023",
1250 + "grade": "PSA8",
1251 + "count": 1
1252 + },
1253 + {
1254 + "month": "Sep, 2023",
1255 + "grade": "PSA5",
1256 + "count": 1
1257 + },
1258 + {
1259 + "month": "Oct, 2023",
1260 + "grade": "PSA6",
1261 + "count": 1
1262 + },
1263 + {
1264 + "month": "Oct, 2023",
1265 + "grade": "PSA7",
1266 + "count": 1
1267 + },
1268 + {
1269 + "month": "Oct, 2023",
1270 + "grade": "PSA8",
1271 + "count": 1
1272 + },
1273 + {
1274 + "month": "Jan, 2024",
1275 + "grade": "Raw",
1276 + "count": 2
1277 + },
1278 + {
1279 + "month": "Feb, 2024",
1280 + "grade": "Raw",
1281 + "count": 3
1282 + },
1283 + {
1284 + "month": "Mar, 2024",
1285 + "grade": "Raw",
1286 + "count": 5
1287 + },
1288 + {
1289 + "month": "Mar, 2024",
1290 + "grade": "PSA9",
1291 + "count": 1
1292 + },
1293 + {
1294 + "month": "Apr, 2024",
1295 + "grade": "PSA7",
1296 + "count": 1
1297 + },
1298 + {
1299 + "month": "Apr, 2024",
1300 + "grade": "Raw",
1301 + "count": 1
1302 + },
1303 + {
1304 + "month": "May, 2024",
1305 + "grade": "Raw",
1306 + "count": 2
1307 + },
1308 + {
1309 + "month": "May, 2024",
1310 + "grade": "PSA9",
1311 + "count": 3
1312 + },
1313 + {
1314 + "month": "May, 2024",
1315 + "grade": "PSA7",
1316 + "count": 1
1317 + },
1318 + {
1319 + "month": "Jun, 2024",
1320 + "grade": "PSA8",
1321 + "count": 1
1322 + },
1323 + {
1324 + "month": "Jun, 2024",
1325 + "grade": "Raw",
1326 + "count": 1
1327 + },
1328 + {
1329 + "month": "Jul, 2024",
1330 + "grade": "Raw",
1331 + "count": 4
1332 + },
1333 + {
1334 + "month": "Sep, 2024",
1335 + "grade": "PSA9",
1336 + "count": 1
1337 + },
1338 + {
1339 + "month": "Nov, 2024",
1340 + "grade": "PSA10",
1341 + "count": 1
1342 + }
1343 + ]
1344 + },
1345 + "fetchedAt": "2026-09-07T06:27:03.677Z"
1346 + },
1347 + "expect": {
1348 + "minCount": 1,
1349 + "kinds": [
1350 + "catalog_item",
1351 + "price_observation"
1352 + ],
1353 + "requiredFields": [
1354 + "attributes.set",
1355 + "attributes.number"
1356 + ]
1357 + },
1358 + "note": "Live capture 2026-09-07 from https://www.pokemonprice.com/base-set-1st-edition/alakazam-1-holo-1st-edition",
1359 + "capturedAt": "2026-09-07T06:27:03.691Z"
1360 +}
\ No newline at end of file
added data/fixtures/pokemonprice/base-set-1st-edition-third.json +915 −0
@@ -0,0 +1,915 @@
1 +{
2 + "raw": {
3 + "url": "https://www.pokemonprice.com/base-set-1st-edition/lightning-energy-100-1st-edition",
4 + "externalId": "base-set-1st-edition/lightning-energy-100-1st-edition",
5 + "kind": "catalog_item",
6 + "engine": "api",
7 + "httpStatus": 200,
8 + "payload": {
9 + "slug": "lightning-energy-100-1st-edition",
10 + "setSlug": "base-set-1st-edition",
11 + "name": "Lightning Energy",
12 + "tag": "1st edition",
13 + "number": "100",
14 + "total": "102",
15 + "setName": "Base Set 1st Edition",
16 + "image": "https://cdn.pokemonprice.com/cards/base1-100-lightning-energy.webp",
17 + "rows": [
18 + {
19 + "grade": "PSA10",
20 + "fair_price": "102.00",
21 + "low_price": "102.00",
22 + "high_price": "102.00",
23 + "confidence": "52.72",
24 + "last_sale_date": "2024-08-25"
25 + },
26 + {
27 + "grade": "PSA6",
28 + "fair_price": "8.00",
29 + "low_price": "8.00",
30 + "high_price": "8.00",
31 + "confidence": "52.72",
32 + "last_sale_date": "2020-04-30"
33 + },
34 + {
35 + "grade": "PSA7",
36 + "fair_price": "10.50",
37 + "low_price": "10.50",
38 + "high_price": "10.50",
39 + "confidence": "52.72",
40 + "last_sale_date": "2024-06-24"
41 + },
42 + {
43 + "grade": "PSA8",
44 + "fair_price": "15.99",
45 + "low_price": "15.99",
46 + "high_price": "15.99",
47 + "confidence": "52.72",
48 + "last_sale_date": "2024-03-31"
49 + },
50 + {
51 + "grade": "PSA9",
52 + "fair_price": "22.50",
53 + "low_price": "22.50",
54 + "high_price": "22.50",
55 + "confidence": "52.72",
56 + "last_sale_date": "2024-10-17"
57 + },
58 + {
59 + "grade": "Raw",
60 + "fair_price": "7.25",
61 + "low_price": "7.25",
62 + "high_price": "7.25",
63 + "confidence": "52.72",
64 + "last_sale_date": "2024-10-04"
65 + }
66 + ],
67 + "transactions": [
68 + {
69 + "month": "Sep, 2016",
70 + "grade": "PSA10",
71 + "count": 1
72 + },
73 + {
74 + "month": "Sep, 2016",
75 + "grade": "PSA9",
76 + "count": 1
77 + },
78 + {
79 + "month": "Oct, 2016",
80 + "grade": "PSA9",
81 + "count": 1
82 + },
83 + {
84 + "month": "Dec, 2016",
85 + "grade": "PSA9",
86 + "count": 1
87 + },
88 + {
89 + "month": "Jan, 2017",
90 + "grade": "PSA10",
91 + "count": 1
92 + },
93 + {
94 + "month": "Feb, 2017",
95 + "grade": "PSA10",
96 + "count": 1
97 + },
98 + {
99 + "month": "May, 2017",
100 + "grade": "PSA10",
101 + "count": 1
102 + },
103 + {
104 + "month": "May, 2017",
105 + "grade": "PSA9",
106 + "count": 1
107 + },
108 + {
109 + "month": "Jun, 2017",
110 + "grade": "PSA10",
111 + "count": 1
112 + },
113 + {
114 + "month": "Jul, 2017",
115 + "grade": "PSA9",
116 + "count": 3
117 + },
118 + {
119 + "month": "Jul, 2017",
120 + "grade": "PSA10",
121 + "count": 1
122 + },
123 + {
124 + "month": "Sep, 2017",
125 + "grade": "PSA9",
126 + "count": 5
127 + },
128 + {
129 + "month": "Sep, 2017",
130 + "grade": "PSA10",
131 + "count": 2
132 + },
133 + {
134 + "month": "Oct, 2017",
135 + "grade": "PSA9",
136 + "count": 2
137 + },
138 + {
139 + "month": "Oct, 2017",
140 + "grade": "PSA8",
141 + "count": 1
142 + },
143 + {
144 + "month": "Oct, 2017",
145 + "grade": "PSA10",
146 + "count": 3
147 + },
148 + {
149 + "month": "Nov, 2017",
150 + "grade": "PSA10",
151 + "count": 2
152 + },
153 + {
154 + "month": "Nov, 2017",
155 + "grade": "PSA9",
156 + "count": 3
157 + },
158 + {
159 + "month": "Dec, 2017",
160 + "grade": "PSA10",
161 + "count": 4
162 + },
163 + {
164 + "month": "Dec, 2017",
165 + "grade": "PSA9",
166 + "count": 2
167 + },
168 + {
169 + "month": "Jan, 2018",
170 + "grade": "PSA9",
171 + "count": 3
172 + },
173 + {
174 + "month": "Jan, 2018",
175 + "grade": "PSA10",
176 + "count": 3
177 + },
178 + {
179 + "month": "Feb, 2018",
180 + "grade": "PSA10",
181 + "count": 1
182 + },
183 + {
184 + "month": "Mar, 2018",
185 + "grade": "PSA10",
186 + "count": 3
187 + },
188 + {
189 + "month": "Mar, 2018",
190 + "grade": "PSA9",
191 + "count": 3
192 + },
193 + {
194 + "month": "Apr, 2018",
195 + "grade": "PSA10",
196 + "count": 3
197 + },
198 + {
199 + "month": "Apr, 2018",
200 + "grade": "PSA9",
201 + "count": 2
202 + },
203 + {
204 + "month": "May, 2018",
205 + "grade": "PSA10",
206 + "count": 5
207 + },
208 + {
209 + "month": "May, 2018",
210 + "grade": "PSA9",
211 + "count": 1
212 + },
213 + {
214 + "month": "Jun, 2018",
215 + "grade": "PSA9",
216 + "count": 2
217 + },
218 + {
219 + "month": "Jun, 2018",
220 + "grade": "PSA10",
221 + "count": 3
222 + },
223 + {
224 + "month": "Jul, 2018",
225 + "grade": "PSA10",
226 + "count": 3
227 + },
228 + {
229 + "month": "Jul, 2018",
230 + "grade": "PSA9",
231 + "count": 2
232 + },
233 + {
234 + "month": "Aug, 2018",
235 + "grade": "PSA9",
236 + "count": 2
237 + },
238 + {
239 + "month": "Aug, 2018",
240 + "grade": "PSA10",
241 + "count": 2
242 + },
243 + {
244 + "month": "Sep, 2018",
245 + "grade": "PSA10",
246 + "count": 3
247 + },
248 + {
249 + "month": "Sep, 2018",
250 + "grade": "PSA8",
251 + "count": 2
252 + },
253 + {
254 + "month": "Sep, 2018",
255 + "grade": "PSA9",
256 + "count": 2
257 + },
258 + {
259 + "month": "Nov, 2018",
260 + "grade": "PSA8",
261 + "count": 1
262 + },
263 + {
264 + "month": "Dec, 2018",
265 + "grade": "PSA9",
266 + "count": 3
267 + },
268 + {
269 + "month": "Dec, 2018",
270 + "grade": "PSA8",
271 + "count": 2
272 + },
273 + {
274 + "month": "Dec, 2018",
275 + "grade": "PSA10",
276 + "count": 2
277 + },
278 + {
279 + "month": "Jan, 2019",
280 + "grade": "PSA8",
281 + "count": 2
282 + },
283 + {
284 + "month": "Jan, 2019",
285 + "grade": "PSA10",
286 + "count": 10
287 + },
288 + {
289 + "month": "Jan, 2019",
290 + "grade": "PSA7",
291 + "count": 1
292 + },
293 + {
294 + "month": "Jan, 2019",
295 + "grade": "PSA9",
296 + "count": 8
297 + },
298 + {
299 + "month": "Feb, 2019",
300 + "grade": "PSA9",
301 + "count": 1
302 + },
303 + {
304 + "month": "Feb, 2019",
305 + "grade": "PSA10",
306 + "count": 1
307 + },
308 + {
309 + "month": "Mar, 2019",
310 + "grade": "PSA10",
311 + "count": 2
312 + },
313 + {
314 + "month": "Mar, 2019",
315 + "grade": "PSA9",
316 + "count": 6
317 + },
318 + {
319 + "month": "Apr, 2019",
320 + "grade": "PSA9",
321 + "count": 3
322 + },
323 + {
324 + "month": "Apr, 2019",
325 + "grade": "PSA10",
326 + "count": 3
327 + },
328 + {
329 + "month": "Jun, 2019",
330 + "grade": "PSA9",
331 + "count": 2
332 + },
333 + {
334 + "month": "Jul, 2019",
335 + "grade": "PSA9",
336 + "count": 4
337 + },
338 + {
339 + "month": "Jul, 2019",
340 + "grade": "PSA8",
341 + "count": 1
342 + },
343 + {
344 + "month": "Jul, 2019",
345 + "grade": "PSA10",
346 + "count": 4
347 + },
348 + {
349 + "month": "Aug, 2019",
350 + "grade": "PSA9",
351 + "count": 2
352 + },
353 + {
354 + "month": "Aug, 2019",
355 + "grade": "PSA10",
356 + "count": 1
357 + },
358 + {
359 + "month": "Sep, 2019",
360 + "grade": "PSA9",
361 + "count": 1
362 + },
363 + {
364 + "month": "Sep, 2019",
365 + "grade": "PSA10",
366 + "count": 1
367 + },
368 + {
369 + "month": "Nov, 2019",
370 + "grade": "PSA10",
371 + "count": 3
372 + },
373 + {
374 + "month": "Dec, 2019",
375 + "grade": "PSA10",
376 + "count": 1
377 + },
378 + {
379 + "month": "Jan, 2020",
380 + "grade": "PSA8",
381 + "count": 1
382 + },
383 + {
384 + "month": "Jan, 2020",
385 + "grade": "PSA9",
386 + "count": 2
387 + },
388 + {
389 + "month": "Feb, 2020",
390 + "grade": "PSA9",
391 + "count": 1
392 + },
393 + {
394 + "month": "Feb, 2020",
395 + "grade": "PSA10",
396 + "count": 1
397 + },
398 + {
399 + "month": "Feb, 2020",
400 + "grade": "PSA8",
401 + "count": 1
402 + },
403 + {
404 + "month": "Mar, 2020",
405 + "grade": "PSA10",
406 + "count": 2
407 + },
408 + {
409 + "month": "Apr, 2020",
410 + "grade": "PSA9",
411 + "count": 5
412 + },
413 + {
414 + "month": "Apr, 2020",
415 + "grade": "PSA6",
416 + "count": 1
417 + },
418 + {
419 + "month": "May, 2020",
420 + "grade": "PSA10",
421 + "count": 6
422 + },
423 + {
424 + "month": "May, 2020",
425 + "grade": "PSA9",
426 + "count": 5
427 + },
428 + {
429 + "month": "May, 2020",
430 + "grade": "PSA8",
431 + "count": 1
432 + },
433 + {
434 + "month": "May, 2020",
435 + "grade": "PSA7",
436 + "count": 1
437 + },
438 + {
439 + "month": "Jun, 2020",
440 + "grade": "PSA10",
441 + "count": 4
442 + },
443 + {
444 + "month": "Jun, 2020",
445 + "grade": "PSA9",
446 + "count": 9
447 + },
448 + {
449 + "month": "Jul, 2020",
450 + "grade": "PSA9",
451 + "count": 10
452 + },
453 + {
454 + "month": "Jul, 2020",
455 + "grade": "PSA10",
456 + "count": 4
457 + },
458 + {
459 + "month": "Jul, 2020",
460 + "grade": "PSA8",
461 + "count": 1
462 + },
463 + {
464 + "month": "Aug, 2020",
465 + "grade": "PSA10",
466 + "count": 1
467 + },
468 + {
469 + "month": "Aug, 2020",
470 + "grade": "PSA8",
471 + "count": 1
472 + },
473 + {
474 + "month": "Aug, 2020",
475 + "grade": "PSA9",
476 + "count": 5
477 + },
478 + {
479 + "month": "Sep, 2020",
480 + "grade": "PSA9",
481 + "count": 2
482 + },
483 + {
484 + "month": "Sep, 2020",
485 + "grade": "PSA10",
486 + "count": 2
487 + },
488 + {
489 + "month": "Oct, 2020",
490 + "grade": "PSA9",
491 + "count": 2
492 + },
493 + {
494 + "month": "Oct, 2020",
495 + "grade": "PSA10",
496 + "count": 4
497 + },
498 + {
499 + "month": "Oct, 2020",
500 + "grade": "PSA8",
501 + "count": 1
502 + },
503 + {
504 + "month": "Nov, 2020",
505 + "grade": "PSA9",
506 + "count": 4
507 + },
508 + {
509 + "month": "Nov, 2020",
510 + "grade": "PSA10",
511 + "count": 7
512 + },
513 + {
514 + "month": "Dec, 2020",
515 + "grade": "PSA9",
516 + "count": 6
517 + },
518 + {
519 + "month": "Dec, 2020",
520 + "grade": "PSA10",
521 + "count": 4
522 + },
523 + {
524 + "month": "Dec, 2020",
525 + "grade": "PSA8",
526 + "count": 1
527 + },
528 + {
529 + "month": "Jan, 2021",
530 + "grade": "PSA10",
531 + "count": 3
532 + },
533 + {
534 + "month": "Jan, 2021",
535 + "grade": "PSA7",
536 + "count": 1
537 + },
538 + {
539 + "month": "Jan, 2021",
540 + "grade": "PSA9",
541 + "count": 1
542 + },
543 + {
544 + "month": "Feb, 2021",
545 + "grade": "PSA10",
546 + "count": 8
547 + },
548 + {
549 + "month": "Feb, 2021",
550 + "grade": "PSA8",
551 + "count": 1
552 + },
553 + {
554 + "month": "Feb, 2021",
555 + "grade": "PSA9",
556 + "count": 2
557 + },
558 + {
559 + "month": "Mar, 2021",
560 + "grade": "PSA9",
561 + "count": 2
562 + },
563 + {
564 + "month": "Mar, 2021",
565 + "grade": "PSA10",
566 + "count": 1
567 + },
568 + {
569 + "month": "Apr, 2021",
570 + "grade": "PSA9",
571 + "count": 1
572 + },
573 + {
574 + "month": "Apr, 2021",
575 + "grade": "PSA10",
576 + "count": 1
577 + },
578 + {
579 + "month": "May, 2021",
580 + "grade": "PSA9",
581 + "count": 5
582 + },
583 + {
584 + "month": "May, 2021",
585 + "grade": "PSA10",
586 + "count": 3
587 + },
588 + {
589 + "month": "Jun, 2021",
590 + "grade": "PSA9",
591 + "count": 2
592 + },
593 + {
594 + "month": "Jul, 2021",
595 + "grade": "PSA10",
596 + "count": 2
597 + },
598 + {
599 + "month": "Jul, 2021",
600 + "grade": "PSA8",
601 + "count": 1
602 + },
603 + {
604 + "month": "Jul, 2021",
605 + "grade": "PSA9",
606 + "count": 3
607 + },
608 + {
609 + "month": "Aug, 2021",
610 + "grade": "PSA10",
611 + "count": 2
612 + },
613 + {
614 + "month": "Aug, 2021",
615 + "grade": "PSA9",
616 + "count": 1
617 + },
618 + {
619 + "month": "Sep, 2021",
620 + "grade": "PSA8",
621 + "count": 1
622 + },
623 + {
624 + "month": "Sep, 2021",
625 + "grade": "PSA7",
626 + "count": 1
627 + },
628 + {
629 + "month": "Sep, 2021",
630 + "grade": "PSA9",
631 + "count": 3
632 + },
633 + {
634 + "month": "Sep, 2021",
635 + "grade": "PSA10",
636 + "count": 1
637 + },
638 + {
639 + "month": "Oct, 2021",
640 + "grade": "PSA10",
641 + "count": 1
642 + },
643 + {
644 + "month": "Oct, 2021",
645 + "grade": "PSA9",
646 + "count": 6
647 + },
648 + {
649 + "month": "Oct, 2021",
650 + "grade": "PSA8",
651 + "count": 2
652 + },
653 + {
654 + "month": "Nov, 2021",
655 + "grade": "PSA8",
656 + "count": 1
657 + },
658 + {
659 + "month": "Nov, 2021",
660 + "grade": "PSA10",
661 + "count": 2
662 + },
663 + {
664 + "month": "Dec, 2021",
665 + "grade": "PSA10",
666 + "count": 7
667 + },
668 + {
669 + "month": "Dec, 2021",
670 + "grade": "PSA8",
671 + "count": 3
672 + },
673 + {
674 + "month": "Dec, 2021",
675 + "grade": "PSA9",
676 + "count": 7
677 + },
678 + {
679 + "month": "Jan, 2022",
680 + "grade": "PSA9",
681 + "count": 6
682 + },
683 + {
684 + "month": "Jan, 2022",
685 + "grade": "PSA8",
686 + "count": 1
687 + },
688 + {
689 + "month": "Mar, 2022",
690 + "grade": "PSA10",
691 + "count": 1
692 + },
693 + {
694 + "month": "May, 2022",
695 + "grade": "PSA8",
696 + "count": 2
697 + },
698 + {
699 + "month": "May, 2022",
700 + "grade": "PSA9",
701 + "count": 1
702 + },
703 + {
704 + "month": "Sep, 2022",
705 + "grade": "PSA9",
706 + "count": 1
707 + },
708 + {
709 + "month": "Sep, 2022",
710 + "grade": "PSA10",
711 + "count": 2
712 + },
713 + {
714 + "month": "Sep, 2022",
715 + "grade": "PSA8",
716 + "count": 1
717 + },
718 + {
719 + "month": "Oct, 2022",
720 + "grade": "PSA10",
721 + "count": 4
722 + },
723 + {
724 + "month": "Oct, 2022",
725 + "grade": "PSA9",
726 + "count": 1
727 + },
728 + {
729 + "month": "Dec, 2022",
730 + "grade": "PSA8",
731 + "count": 1
732 + },
733 + {
734 + "month": "Dec, 2022",
735 + "grade": "PSA9",
736 + "count": 2
737 + },
738 + {
739 + "month": "Dec, 2022",
740 + "grade": "PSA10",
741 + "count": 1
742 + },
743 + {
744 + "month": "Jan, 2023",
745 + "grade": "PSA8",
746 + "count": 1
747 + },
748 + {
749 + "month": "Jan, 2023",
750 + "grade": "PSA9",
751 + "count": 4
752 + },
753 + {
754 + "month": "Jan, 2023",
755 + "grade": "PSA10",
756 + "count": 1
757 + },
758 + {
759 + "month": "Feb, 2023",
760 + "grade": "PSA10",
761 + "count": 3
762 + },
763 + {
764 + "month": "Feb, 2023",
765 + "grade": "PSA9",
766 + "count": 6
767 + },
768 + {
769 + "month": "Mar, 2023",
770 + "grade": "PSA9",
771 + "count": 6
772 + },
773 + {
774 + "month": "Mar, 2023",
775 + "grade": "PSA10",
776 + "count": 4
777 + },
778 + {
779 + "month": "Mar, 2023",
780 + "grade": "PSA8",
781 + "count": 2
782 + },
783 + {
784 + "month": "Mar, 2023",
785 + "grade": "PSA7",
786 + "count": 4
787 + },
788 + {
789 + "month": "Jul, 2023",
790 + "grade": "PSA10",
791 + "count": 1
792 + },
793 + {
794 + "month": "Aug, 2023",
795 + "grade": "PSA9",
796 + "count": 1
797 + },
798 + {
799 + "month": "Sep, 2023",
800 + "grade": "PSA7",
801 + "count": 1
802 + },
803 + {
804 + "month": "Sep, 2023",
805 + "grade": "PSA8",
806 + "count": 1
807 + },
808 + {
809 + "month": "Oct, 2023",
810 + "grade": "PSA9",
811 + "count": 2
812 + },
813 + {
814 + "month": "Dec, 2023",
815 + "grade": "Raw",
816 + "count": 1
817 + },
818 + {
819 + "month": "Dec, 2023",
820 + "grade": "PSA10",
821 + "count": 1
822 + },
823 + {
824 + "month": "Dec, 2023",
825 + "grade": "PSA8",
826 + "count": 1
827 + },
828 + {
829 + "month": "Jan, 2024",
830 + "grade": "PSA9",
831 + "count": 2
832 + },
833 + {
834 + "month": "Mar, 2024",
835 + "grade": "Raw",
836 + "count": 5
837 + },
838 + {
839 + "month": "Mar, 2024",
840 + "grade": "PSA8",
841 + "count": 1
842 + },
843 + {
844 + "month": "Apr, 2024",
845 + "grade": "Raw",
846 + "count": 3
847 + },
848 + {
849 + "month": "Apr, 2024",
850 + "grade": "PSA10",
851 + "count": 1
852 + },
853 + {
854 + "month": "May, 2024",
855 + "grade": "Raw",
856 + "count": 1
857 + },
858 + {
859 + "month": "Jun, 2024",
860 + "grade": "PSA9",
861 + "count": 1
862 + },
863 + {
864 + "month": "Jun, 2024",
865 + "grade": "PSA7",
866 + "count": 1
867 + },
868 + {
869 + "month": "Aug, 2024",
870 + "grade": "Raw",
871 + "count": 1
872 + },
873 + {
874 + "month": "Aug, 2024",
875 + "grade": "PSA10",
876 + "count": 1
877 + },
878 + {
879 + "month": "Sep, 2024",
880 + "grade": "Raw",
881 + "count": 3
882 + },
883 + {
884 + "month": "Sep, 2024",
885 + "grade": "PSA9",
886 + "count": 1
887 + },
888 + {
889 + "month": "Oct, 2024",
890 + "grade": "Raw",
891 + "count": 1
892 + },
893 + {
894 + "month": "Oct, 2024",
895 + "grade": "PSA9",
896 + "count": 1
897 + }
898 + ]
899 + },
900 + "fetchedAt": "2026-09-07T06:27:06.261Z"
901 + },
902 + "expect": {
903 + "minCount": 1,
904 + "kinds": [
905 + "catalog_item",
906 + "price_observation"
907 + ],
908 + "requiredFields": [
909 + "attributes.set",
910 + "attributes.number"
911 + ]
912 + },
913 + "note": "Live capture 2026-09-07 from https://www.pokemonprice.com/base-set-1st-edition/lightning-energy-100-1st-edition",
914 + "capturedAt": "2026-09-07T06:27:06.264Z"
915 +}
\ No newline at end of file
added data/fixtures/sorcery-tcg/first.json +78 −0
@@ -0,0 +1,78 @@
1 +{
2 + "raw": {
3 + "url": "https://curiosa.io/cards/13_treasures_of_britain",
4 + "externalId": "cmt865vva04mqnwcyrl8c2h8l",
5 + "kind": "catalog_item",
6 + "engine": "api",
7 + "httpStatus": 200,
8 + "payload": {
9 + "card": {
10 + "id": "cmt865vva04mqnwcyrl8c2h8l",
11 + "name": "13 Treasures of Britain",
12 + "slug": "13_treasures_of_britain",
13 + "engine": {
14 + "type": "Artifact",
15 + "category": "Spell",
16 + "rarity": "Unique",
17 + "elements": [
18 + "None"
19 + ],
20 + "subtypes": [
21 + "Relic"
22 + ]
23 + },
24 + "printings": [
25 + {
26 + "id": "cmt865x7l04ninwcyyju3668s",
27 + "slug": "004-13_treasures_of_britain-b-s",
28 + "printedAt": "2024-10-04T07:00:00.000Z",
29 + "set": {
30 + "name": "Arthurian Legends",
31 + "code": "004",
32 + "releasedAt": "2026-08-25T04:32:18.603Z"
33 + },
34 + "meta": {
35 + "finish": "Standard",
36 + "product": "Booster",
37 + "artist": {
38 + "name": "Jeff A. Menges"
39 + },
40 + "typeline": "A Unique collection of priceless Relics"
41 + }
42 + },
43 + {
44 + "id": "cmt865wcd04n1nwcy6l8uye2o",
45 + "slug": "004-13_treasures_of_britain-b-f",
46 + "printedAt": "2024-10-04T07:00:00.000Z",
47 + "set": {
48 + "name": "Arthurian Legends",
49 + "code": "004",
50 + "releasedAt": "2026-08-25T04:32:18.603Z"
51 + },
52 + "meta": {
53 + "finish": "Foil",
54 + "product": "Booster",
55 + "artist": {
56 + "name": "Jeff A. Menges"
57 + },
58 + "typeline": "A Unique collection of priceless Relics"
59 + }
60 + }
61 + ]
62 + }
63 + },
64 + "fetchedAt": "2026-09-07T06:26:56.600Z"
65 + },
66 + "expect": {
67 + "minCount": 1,
68 + "kinds": [
69 + "catalog_item"
70 + ],
71 + "requiredFields": [
72 + "attributes.identifiers.sorcery_printing_id",
73 + "attributes.set"
74 + ]
75 + },
76 + "note": "Live capture 2026-09-07 from https://curiosa.io/cards/13_treasures_of_britain",
77 + "capturedAt": "2026-09-07T06:26:56.603Z"
78 +}
\ No newline at end of file
added data/fixtures/sorcery-tcg/sixth.json +114 −0
@@ -0,0 +1,114 @@
1 +{
2 + "raw": {
3 + "url": "https://curiosa.io/cards/accursed_albatross",
4 + "externalId": "cmt85x1nl0010nwcyghalcg62",
5 + "kind": "catalog_item",
6 + "engine": "api",
7 + "httpStatus": 200,
8 + "payload": {
9 + "card": {
10 + "id": "cmt85x1nl0010nwcyghalcg62",
11 + "name": "Accursed Albatross",
12 + "slug": "accursed_albatross",
13 + "engine": {
14 + "type": "Minion",
15 + "category": "Spell",
16 + "rarity": "Exceptional",
17 + "elements": [
18 + "Water"
19 + ],
20 + "subtypes": [
21 + "Beast"
22 + ]
23 + },
24 + "printings": [
25 + {
26 + "id": "cmt8613ul02a2nwcykgiqva2v",
27 + "slug": "002-accursed_albatross-b-s",
28 + "printedAt": "2023-10-06T07:00:00.000Z",
29 + "set": {
30 + "name": "Beta",
31 + "code": "002",
32 + "releasedAt": "2026-08-25T04:29:12.082Z"
33 + },
34 + "meta": {
35 + "finish": "Standard",
36 + "product": "Booster",
37 + "artist": {
38 + "name": "Vincent Pompetti"
39 + },
40 + "typeline": "An Exceptional Beast of deadly portent"
41 + }
42 + },
43 + {
44 + "id": "cmt8612yp029nnwcy7j41qo56",
45 + "slug": "002-accursed_albatross-b-f",
46 + "printedAt": "2023-10-06T07:00:00.000Z",
47 + "set": {
48 + "name": "Beta",
49 + "code": "002",
50 + "releasedAt": "2026-08-25T04:29:12.082Z"
51 + },
52 + "meta": {
53 + "finish": "Foil",
54 + "product": "Booster",
55 + "artist": {
56 + "name": "Vincent Pompetti"
57 + },
58 + "typeline": "An Exceptional Beast of deadly portent"
59 + }
60 + },
61 + {
62 + "id": "cmt85x2yc001tnwcyn5568x55",
63 + "slug": "001-accursed_albatross-b-s",
64 + "printedAt": "2023-06-22T07:00:00.000Z",
65 + "set": {
66 + "name": "Alpha",
67 + "code": "001",
68 + "releasedAt": "2026-08-25T04:21:27.405Z"
69 + },
70 + "meta": {
71 + "finish": "Standard",
72 + "product": "Booster",
73 + "artist": {
74 + "name": "Vincent Pompetti"
75 + },
76 + "typeline": "An Exceptional Beast of deadly portent"
77 + }
78 + },
79 + {
80 + "id": "cmt85x237001anwcy399fyuld",
81 + "slug": "001-accursed_albatross-b-f",
82 + "printedAt": "2023-06-22T07:00:00.000Z",
83 + "set": {
84 + "name": "Alpha",
85 + "code": "001",
86 + "releasedAt": "2026-08-25T04:21:27.405Z"
87 + },
88 + "meta": {
89 + "finish": "Foil",
90 + "product": "Booster",
91 + "artist": {
92 + "name": "Vincent Pompetti"
93 + },
94 + "typeline": "An Exceptional Beast of deadly portent"
95 + }
96 + }
97 + ]
98 + }
99 + },
100 + "fetchedAt": "2026-09-07T06:26:56.600Z"
101 + },
102 + "expect": {
103 + "minCount": 1,
104 + "kinds": [
105 + "catalog_item"
106 + ],
107 + "requiredFields": [
108 + "attributes.identifiers.sorcery_printing_id",
109 + "attributes.set"
110 + ]
111 + },
112 + "note": "Live capture 2026-09-07 from https://curiosa.io/cards/accursed_albatross",
113 + "capturedAt": "2026-09-07T06:26:56.604Z"
114 +}
\ No newline at end of file
added data/fixtures/swu-db/sor-first.json +48 −0
@@ -0,0 +1,48 @@
1 +{
2 + "raw": {
3 + "url": "https://www.swu-db.com/card/SOR/059",
4 + "externalId": "SOR-059",
5 + "kind": "catalog_item",
6 + "engine": "api",
7 + "httpStatus": 200,
8 + "payload": {
9 + "card": {
10 + "Set": "SOR",
11 + "Number": "059",
12 + "Name": "2-1B Surgical Droid",
13 + "Type": "Unit",
14 + "Rarity": "Common",
15 + "VariantType": "Normal",
16 + "Unique": false,
17 + "Artist": "Hoan Nguyen",
18 + "cid": "5449704164",
19 + "tcgplayerId": "540180",
20 + "MarketPrice": "0.05",
21 + "LowPrice": "0.01",
22 + "FoilPrice": "0.10",
23 + "LowFoilPrice": "0.05",
24 + "FrontArt": "https://cdn.swu-db.com/images/cards/SOR/059.png",
25 + "Aspects": [
26 + {
27 + "S": "Vigilance"
28 + }
29 + ]
30 + },
31 + "setName": "Spark of Rebellion"
32 + },
33 + "fetchedAt": "2026-09-07T06:26:53.964Z"
34 + },
35 + "expect": {
36 + "minCount": 1,
37 + "kinds": [
38 + "catalog_item",
39 + "price_observation"
40 + ],
41 + "requiredFields": [
42 + "attributes.setCode",
43 + "attributes.number"
44 + ]
45 + },
46 + "note": "Live capture 2026-09-07 from https://www.swu-db.com/card/SOR/059",
47 + "capturedAt": "2026-09-07T06:26:53.965Z"
48 +}
\ No newline at end of file
added data/fixtures/swu-db/sor-fourth.json +47 −0
@@ -0,0 +1,47 @@
1 +{
2 + "raw": {
3 + "url": "https://www.swu-db.com/card/SOR/324F",
4 + "externalId": "SOR-324F",
5 + "kind": "catalog_item",
6 + "engine": "api",
7 + "httpStatus": 200,
8 + "payload": {
9 + "card": {
10 + "Set": "SOR",
11 + "Number": "324F",
12 + "Name": "2-1B Surgical Droid",
13 + "Type": "Unit",
14 + "Rarity": "Common",
15 + "VariantType": "Hyperspace Foil",
16 + "Unique": false,
17 + "Artist": "Hoan Nguyen",
18 + "tcgplayerId": "540446",
19 + "MarketPrice": "0.23",
20 + "LowPrice": "0.01",
21 + "FoilPrice": "0.28",
22 + "LowFoilPrice": "0.20",
23 + "FrontArt": "https://cdn.swu-db.com/images/cards/SOR/324F.png",
24 + "Aspects": [
25 + {
26 + "S": "Vigilance"
27 + }
28 + ]
29 + },
30 + "setName": "Spark of Rebellion"
31 + },
32 + "fetchedAt": "2026-09-07T06:26:53.964Z"
33 + },
34 + "expect": {
35 + "minCount": 1,
36 + "kinds": [
37 + "catalog_item",
38 + "price_observation"
39 + ],
40 + "requiredFields": [
41 + "attributes.setCode",
42 + "attributes.number"
43 + ]
44 + },
45 + "note": "Live capture 2026-09-07 from https://www.swu-db.com/card/SOR/324F",
46 + "capturedAt": "2026-09-07T06:26:53.966Z"
47 +}
\ No newline at end of file
added data/fixtures/tcgcsv/pokemon-base-charizard.json +112 −0
@@ -0,0 +1,112 @@
1 +{
2 + "raw": {
3 + "url": "https://www.tcgplayer.com/product/42382/pokemon-base-set-charizard",
4 + "externalId": "42382",
5 + "kind": "catalog_item",
6 + "engine": "api",
7 + "fetchedAt": "2026-09-07T06:38:25.908Z",
8 + "payload": {
9 + "categoryId": 3,
10 + "category": {
11 + "slug": "pokemon",
12 + "franchise": "Pokémon",
13 + "brand": "The Pokémon Company",
14 + "language": "English"
15 + },
16 + "group": {
17 + "groupId": 604,
18 + "name": "Base Set",
19 + "abbreviation": "BS",
20 + "isSupplemental": false,
21 + "publishedOn": "1999-01-09T00:00:00",
22 + "categoryId": 3
23 + },
24 + "product": {
25 + "productId": 42382,
26 + "name": "Charizard",
27 + "cleanName": "Charizard",
28 + "imageUrl": "https://tcgplayer-cdn.tcgplayer.com/product/42382_200w.jpg",
29 + "url": "https://www.tcgplayer.com/product/42382/pokemon-base-set-charizard",
30 + "modifiedOn": "2026-06-30T22:37:51.91",
31 + "extendedData": [
32 + {
33 + "name": "Number",
34 + "displayName": "Card Number",
35 + "value": "004/102"
36 + },
37 + {
38 + "name": "Rarity",
39 + "displayName": "Rarity",
40 + "value": "Holo Rare"
41 + },
42 + {
43 + "name": "Card Type",
44 + "displayName": "Card Type",
45 + "value": "Fire"
46 + },
47 + {
48 + "name": "HP",
49 + "displayName": "HP",
50 + "value": "120"
51 + },
52 + {
53 + "name": "Stage",
54 + "displayName": "Stage",
55 + "value": "Stage 2"
56 + },
57 + {
58 + "name": "CardText",
59 + "displayName": "Card Text",
60 + "value": "<strong>Pokémon Power: Energy Burn</strong> As often as you like during your turn <em>(before your attack)</em>, you may turn all Energy attached to Charizard into Fire Energy for the rest of the turn. This power can't be used if Charizard is Asleep, Confused, or Paralyzed."
61 + },
62 + {
63 + "name": "Attack 1",
64 + "displayName": "Attack 1",
65 + "value": "[RRRR] Fire Spin (100)\r\n<br>Discard 2 Energy cards attached to Charizard in order to use this attack."
66 + },
67 + {
68 + "name": "Weakness",
69 + "displayName": "Weakness",
70 + "value": "W"
71 + },
72 + {
73 + "name": "Resistance",
74 + "displayName": "Resistance",
75 + "value": "F-30"
76 + },
77 + {
78 + "name": "RetreatCost",
79 + "displayName": "Retreat Cost",
80 + "value": "3"
81 + }
82 + ]
83 + },
84 + "prices": [
85 + {
86 + "productId": 42382,
87 + "lowPrice": 449.99,
88 + "midPrice": 999,
89 + "highPrice": 4761.9,
90 + "marketPrice": 897.19,
91 + "directLowPrice": null,
92 + "subTypeName": "Holofoil"
93 + }
94 + ],
95 + "updatedAt": "2026-09-06T20:05:30+0000"
96 + }
97 + },
98 + "expect": {
99 + "minCount": 2,
100 + "kinds": [
101 + "catalog_item",
102 + "price_observation"
103 + ],
104 + "requiredFields": [
105 + "attributes.identifiers.tcgplayer_id",
106 + "attributes.set",
107 + "attributes.number"
108 + ]
109 + },
110 + "note": "Live capture 2026-09-07 — tcgcsv.com/tcgplayer/3/604 (Base Set), subtypes Holofoil",
111 + "capturedAt": "2026-09-07T06:38:25.908Z"
112 +}
\ No newline at end of file
added data/fixtures/tcgcsv/pokemon-base-energy.json +77 −0
@@ -0,0 +1,77 @@
1 +{
2 + "raw": {
3 + "url": "https://www.tcgplayer.com/product/42349/pokemon-base-set-psychic-energy",
4 + "externalId": "42349",
5 + "kind": "catalog_item",
6 + "engine": "api",
7 + "fetchedAt": "2026-09-07T06:38:25.908Z",
8 + "payload": {
9 + "categoryId": 3,
10 + "category": {
11 + "slug": "pokemon",
12 + "franchise": "Pokémon",
13 + "brand": "The Pokémon Company",
14 + "language": "English"
15 + },
16 + "group": {
17 + "groupId": 604,
18 + "name": "Base Set",
19 + "abbreviation": "BS",
20 + "isSupplemental": false,
21 + "publishedOn": "1999-01-09T00:00:00",
22 + "categoryId": 3
23 + },
24 + "product": {
25 + "productId": 42349,
26 + "name": "Psychic Energy",
27 + "cleanName": "Psychic Energy",
28 + "imageUrl": "https://tcgplayer-cdn.tcgplayer.com/product/42349_200w.jpg",
29 + "url": "https://www.tcgplayer.com/product/42349/pokemon-base-set-psychic-energy",
30 + "modifiedOn": "2026-08-05T23:02:16.687",
31 + "extendedData": [
32 + {
33 + "name": "Number",
34 + "displayName": "Card Number",
35 + "value": "101/102"
36 + },
37 + {
38 + "name": "Rarity",
39 + "displayName": "Rarity",
40 + "value": "Common"
41 + },
42 + {
43 + "name": "Card Type",
44 + "displayName": "Card Type",
45 + "value": "Basic Energy"
46 + }
47 + ]
48 + },
49 + "prices": [
50 + {
51 + "productId": 42349,
52 + "lowPrice": 0.1,
53 + "midPrice": 0.4,
54 + "highPrice": 18.9,
55 + "marketPrice": 0.79,
56 + "directLowPrice": 1.25,
57 + "subTypeName": "Normal"
58 + }
59 + ],
60 + "updatedAt": "2026-09-06T20:05:30+0000"
61 + }
62 + },
63 + "expect": {
64 + "minCount": 2,
65 + "kinds": [
66 + "catalog_item",
67 + "price_observation"
68 + ],
69 + "requiredFields": [
70 + "attributes.identifiers.tcgplayer_id",
71 + "attributes.set",
72 + "attributes.number"
73 + ]
74 + },
75 + "note": "Live capture 2026-09-07 — tcgcsv.com/tcgplayer/3/604 (Base Set), subtypes Normal",
76 + "capturedAt": "2026-09-07T06:38:25.908Z"
77 +}
\ No newline at end of file
added data/fixtures/tcgcsv/pokemon-first-product.json +67 −0
@@ -0,0 +1,67 @@
1 +{
2 + "raw": {
3 + "url": "https://www.tcgplayer.com/product/712093/pokemon-me06-delta-reign-delta-reign-3-pack-blister-seel",
4 + "externalId": "712093",
5 + "kind": "catalog_item",
6 + "engine": "api",
7 + "httpStatus": 200,
8 + "payload": {
9 + "categoryId": 3,
10 + "category": {
11 + "slug": "pokemon",
12 + "franchise": "Pokémon",
13 + "brand": "The Pokémon Company",
14 + "language": "English"
15 + },
16 + "group": {
17 + "groupId": 24831,
18 + "name": "ME06: Delta Reign",
19 + "abbreviation": "DLR",
20 + "isSupplemental": false,
21 + "publishedOn": "2026-11-06T00:00:00",
22 + "categoryId": 3
23 + },
24 + "product": {
25 + "productId": 712093,
26 + "name": "Delta Reign 3 Pack Blister [Seel]",
27 + "cleanName": "Delta Reign 3 Pack Blister Seel",
28 + "imageUrl": "https://tcgplayer-cdn.tcgplayer.com/product/712093_200w.jpg",
29 + "url": "https://www.tcgplayer.com/product/712093/pokemon-me06-delta-reign-delta-reign-3-pack-blister-seel",
30 + "modifiedOn": "2026-08-21T19:13:09.38",
31 + "extendedData": [
32 + {
33 + "name": "CardText",
34 + "displayName": "Card Text",
35 + "value": "A Legendary Storm Is Stirring!<br>\r\nA thunderous roar from above heralds the descent of a renowned ruler of the sky! Mega Rayquaza ex brings unparallelled strength to battle, inspiring Mega Golurk ex, Mega Malamar ex, and Mega Golisopod ex to come forth and put their own skills to the test. Prevailing winds lead to legendary places and emerald-colored storms in the Pokémon TCG: Mega Evolution—Delta Reign expansion! <br>\r\nEach blister comes with 3 booster packs and a promotional card."
36 + }
37 + ]
38 + },
39 + "prices": [
40 + {
41 + "productId": 712093,
42 + "lowPrice": 34.99,
43 + "midPrice": 52.24,
44 + "highPrice": 52.58,
45 + "marketPrice": 52.59,
46 + "directLowPrice": null,
47 + "subTypeName": "Normal"
48 + }
49 + ],
50 + "updatedAt": "2026-09-06T20:05:30+0000"
51 + },
52 + "fetchedAt": "2026-09-07T06:26:43.883Z"
53 + },
54 + "expect": {
55 + "minCount": 1,
56 + "kinds": [
57 + "catalog_item",
58 + "price_observation"
59 + ],
60 + "requiredFields": [
61 + "attributes.identifiers.tcgplayer_id",
62 + "attributes.set"
63 + ]
64 + },
65 + "note": "Live capture 2026-09-07 from https://www.tcgplayer.com/product/712093/pokemon-me06-delta-reign-delta-reign-3-pack-blister-seel",
66 + "capturedAt": "2026-09-07T06:26:44.118Z"
67 +}
\ No newline at end of file
modified workers/entity-resolution/canonical-key.ts +2 −1
@@ -13,7 +13,8 @@ const num = (s: string | null | undefined): string => (s ? s.toLowerCase().repla
13 13 /** Identifier keys considered deterministic enough to match on their own. */
14 14 export const DETERMINISTIC_IDS = [
15 15 'scryfall_id', 'oracle_id', 'tcgplayer_id', 'cardmarket_id', 'pokemontcg_id', 'ygo_id', 'ygo_set_code', 'psa_spec_id', 'psa_item_id',
16 − 'style_code', 'sku', 'upc', 'ean', 'isbn', 'lego_set_number', 'bricklink_id', 'brickset_id', 'pricecharting_id', 'reference', 'goldin_item_id', 'stockx_id', 'chrono24_id', 'comic_id', 'gocollect_id', 'hobbydb_id', 'funko_id',
16 + 'style_code', 'sku', 'upc', 'ean', 'isbn', 'jan', 'lego_set_number', 'bricklink_id', 'brickset_id', 'pricecharting_id', 'reference', 'goldin_item_id', 'stockx_id', 'chrono24_id', 'comic_id', 'gocollect_id', 'hobbydb_id', 'funko_id',
17 + 'tcgdex_id', 'lorcast_id', 'mtggoldfish_uuid', 'gatcg_edition_id', 'swudb_id', 'digimoncard_id', 'sorcery_printing_id', 'discogs_release_id', 'pcgs_number', 'mfc_id', 'comics_org_id', 'ebay_epid', 'asin', 'vin', 'bgg_id', 'amiami_gcode', 'fossilera_item', 'ppg_id',
17 18 ] as const;
18 19
19 20 export function deterministicIdentifiers(ids: Record<string, string> | undefined): Record<string, string> {
20 21