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: MTGJSON, Card Kingdom, MySlabs, TAG population, COMC, Alt (agent S)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Simon-Pierre Boucher committed 17 days ago (Sep 7, 2026) parent 45b94aa

30 changed files +4,660 −0

added connectors/api/_lib/capture-s.ts +99 −0
@@ -0,0 +1,99 @@
1 +/**
2 + * Live capture + smoke for the wave-4 connectors (mtgjson, cardkingdom, myslabs, tag-pop, comc, alt-xyz).
3 + * Usage: pnpm tsx connectors/api/_lib/capture-s.ts [ids…] [--no-save]
4 + */
5 +import { createCrawlContext, createRouter, type RareIndexConnector, type RawRecordInput } from '@rareindex/connectors';
6 +import { localMeta } from './local-meta.js';
7 +import { saveFixture } from '@rareindex/connectors/testing';
8 +import { childLogger } from '@rareindex/shared';
9 +
10 +interface Plan {
11 + dir: string;
12 + limit: number;
13 + seeds?: string[];
14 + pick: (raw: RawRecordInput, i: number) => string | null;
15 + kinds: string[];
16 + requiredFields: string[];
17 +}
18 +
19 +const picked = new Set<string>();
20 +const PLANS: Record<string, Plan> = {
21 + mtgjson: {
22 + dir: 'api',
23 + limit: 320,
24 + seeds: ['LEA'],
25 + pick: (raw, i) => {
26 + const p = raw.payload as { card: { name: string }; prices: unknown };
27 + if (p.card.name === 'Black Lotus') return 'lea-black-lotus';
28 + if (i === 0) return 'lea-first';
29 + return null;
30 + },
31 + kinds: ['catalog_item', 'price_observation'],
32 + requiredFields: ['attributes.identifiers.scryfall_id', 'attributes.setCode'],
33 + },
34 + cardkingdom: { dir: 'api', limit: 6, pick: (raw, i) => (i === 0 ? 'single-first' : i === 3 ? 'single-fourth' : null), kinds: ['catalog_item', 'price_observation'], requiredFields: ['attributes.identifiers.cardkingdom_id', 'attributes.set'] },
35 + myslabs: { dir: 'api', limit: 6, pick: (raw, i) => (i === 0 ? 'slab-first' : i === 2 ? 'slab-third' : i === 5 ? 'slab-sixth' : null), kinds: ['listing'], requiredFields: ['attributes.identifiers.myslabs_id', 'price'] },
36 + 'tag-pop': { dir: 'firecrawl', limit: 1, seeds: ['https://my.taggrading.com/pop-report/Pokemon/1999/WOTC%20Pok%C3%A9mon?setName=Base%20Set'], pick: () => 'pokemon-1999-base-set', kinds: ['population_report'], requiredFields: ['attributes.set', 'attributes.number'] },
37 + comc: { dir: 'firecrawl', limit: 1, seeds: ['Cards/Basketball/1986/Fleer'], pick: () => 'basketball-1986-fleer-p1', kinds: ['listing'], requiredFields: ['attributes.set', 'price'] },
38 + 'alt-xyz': {
39 + dir: 'firecrawl',
40 + limit: 8,
41 + pick: (raw) => {
42 + const p = raw.payload as { transactions: unknown[]; altValue: number | null; listPrice: number | null };
43 + if (p.transactions.length && !picked.has('item-with-transactions')) return 'item-with-transactions';
44 + if ((p.altValue || p.listPrice) && !picked.has('item-priced')) return 'item-priced';
45 + return null;
46 + },
47 + kinds: ['sale', 'price_observation', 'listing'],
48 + requiredFields: ['attributes.identifiers.alt_item_id'],
49 + },
50 +};
51 +
52 +const args = process.argv.slice(2);
53 +const noSave = args.includes('--no-save');
54 +const ids = args.filter((a) => !a.startsWith('--'));
55 +const targets = ids.length ? ids : Object.keys(PLANS);
56 +const router = createRouter({ firecrawlApiKey: process.env.FIRECRAWL_API_KEY, scrapflyApiKey: process.env.SCRAPFLY_API_KEY });
57 +for (const id of targets) {
58 + const plan = PLANS[id];
59 + if (!plan) {
60 + console.log(`[${id}] no plan`);
61 + continue;
62 + }
63 + const started = Date.now();
64 + const mod = (await import(`../../${plan.dir}/${id}/index.ts`)) as { default: (m: ReturnType<typeof localMeta>) => RareIndexConnector };
65 + const metaJson = (await import(`../../${plan.dir}/${id}/meta.json`, { with: { type: 'json' } })) as { default: unknown };
66 + const connector = mod.default(localMeta(metaJson.default));
67 + const ctx = createCrawlContext({ router, meta: connector.meta, options: { mode: 'probe', limit: plan.limit, ...(plan.seeds ? { seeds: plan.seeds } : {}) }, log: childLogger({ connector: id, level: 'warn' }) });
68 + let raws = 0;
69 + let normalized = 0;
70 + let printed = 0;
71 + let saved = 0;
72 + const kinds: Record<string, number> = {};
73 + try {
74 + for await (const raw of connector.crawl(ctx)) {
75 + const rawLike = { ...raw, externalId: raw.externalId ?? null, fetchedAt: raw.fetchedAt ?? new Date() };
76 + const out = await connector.normalize(rawLike);
77 + normalized += out.length;
78 + for (const r of out) kinds[r.kind] = (kinds[r.kind] ?? 0) + 1;
79 + const name = plan.pick(raw, raws);
80 + raws++;
81 + if (name && !noSave) {
82 + picked.add(name);
83 + 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}` });
84 + saved++;
85 + }
86 + if (printed < 3 && out.length) {
87 + printed++;
88 + const r = out.find((x) => x.kind === 'sale') ?? out.find((x) => x.kind === 'price_observation') ?? out.find((x) => x.kind === 'population_report') ?? out[0]!;
89 + const s = r as unknown as Record<string, unknown>;
90 + const attrs = (s.attributes ?? {}) as Record<string, unknown>;
91 + console.log(`[${id}] ${r.kind} · ${String(s.rawTitle ?? attrs.name ?? '')} · ${String(attrs.categorySlug)} · set=${String(attrs.set)} #${String(attrs.number)} · grade=${JSON.stringify(s.grade ?? null)} · price=${String(s.price ?? s.total ?? '')} ${String(s.currency ?? '')} · date=${String(s.saleDate ?? s.observationDate ?? s.reportDate ?? '')}`);
92 + }
93 + }
94 + } catch (err) {
95 + console.log(`[${id}] crawl error: ${err instanceof Error ? err.message : String(err)}`);
96 + }
97 + console.log(`[${id}] raw=${raws} normalized=${normalized} kinds=${JSON.stringify(kinds)} saved=${saved} anomalies=${ctx.anomalies.length} engines=${JSON.stringify(ctx.engineStats)} ms=${Date.now() - started}`);
98 + if (ctx.anomalies.length) console.log(`[${id}] anomalies: ${ctx.anomalies.slice(0, 5).join(' ; ')}`);
99 +}
added connectors/api/_lib/wave4.ts +273 −0
@@ -0,0 +1,273 @@
1 +/**
2 + * Helpers shared by the wave-4 card/comic connectors (mtgjson, cardkingdom, myslabs, tag-pop, comc,
3 + * alt-xyz). Kept inside connectors/api (not the framework).
4 + */
5 +import { createGunzip, gunzipSync } from 'node:zlib';
6 +import { Readable } from 'node:stream';
7 +
8 +export const BOT_UA = 'RareIndexBot/0.1 (+https://www.rareindex.io/about/data; contact data@rareindex.io)';
9 +export const BOT_HEADERS = { 'user-agent': BOT_UA, accept: 'application/json, text/html;q=0.9, */*;q=0.8' };
10 +
11 +/** UTC midnight. */
12 +export function dayOf(d: Date): Date {
13 + return new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate()));
14 +}
15 +
16 +/** "2026-09-06" → UTC midnight Date. */
17 +export function isoDay(s: string | null | undefined): Date | null {
18 + if (!s) return null;
19 + const m = s.match(/^(\d{4})-(\d{2})-(\d{2})/);
20 + if (!m) return null;
21 + const d = new Date(Date.UTC(Number(m[1]), Number(m[2]) - 1, Number(m[3])));
22 + return Number.isNaN(d.getTime()) ? null : d;
23 +}
24 +
25 +/** "$8,830.00" | "8000.00" → number or null (never ≤ 0). */
26 +export function money(s: string | number | null | undefined): number | null {
27 + if (s === null || s === undefined) return null;
28 + const n = typeof s === 'number' ? s : Number.parseFloat(String(s).replace(/[^0-9.]/g, ''));
29 + return Number.isFinite(n) && n > 0 ? n : null;
30 +}
31 +
32 +/** Download a gzip JSON document fully (small files only, e.g. AllPricesToday ≈ 5 MB gz). */
33 +/** Download a (possibly gzip) document; handles servers that already transparently decode Content-Encoding. */
34 +export async function fetchMaybeGzip(url: string, signal?: AbortSignal): Promise<Buffer> {
35 + const res = await fetch(url, { headers: BOT_HEADERS, signal });
36 + if (!res.ok) throw new Error(`${url}: HTTP ${res.status}`);
37 + const buf = Buffer.from(await res.arrayBuffer());
38 + if (buf.length >= 2 && buf[0] === 0x1f && buf[1] === 0x8b) return gunzipSync(buf);
39 + return buf;
40 +}
41 +
42 +export async function fetchGzipJson<T = unknown>(url: string, signal?: AbortSignal): Promise<T> {
43 + return JSON.parse((await fetchMaybeGzip(url, signal)).toString('utf8')) as T;
44 +}
45 +
46 +/**
47 + * Stream a large gzip JSON document shaped like {"meta":{…},"data":{"<key>":<value>,…}} and invoke
48 + * `onEntry(key, value)` for each top-level entry of `data` without materialising the whole file
49 + * (MTGJSON AllPrices ≈ 1.5 GB uncompressed). Returns the number of entries seen.
50 + */
51 +export async function streamGzipDataEntries(url: string, onEntry: (key: string, value: unknown) => Promise<void> | void, opts: { signal?: AbortSignal; limit?: number } = {}): Promise<number> {
52 + const res = await fetch(url, { headers: BOT_HEADERS, signal: opts.signal });
53 + if (!res.ok || !res.body) throw new Error(`${url}: HTTP ${res.status}`);
54 + const encoded = (res.headers.get('content-type') ?? '').includes('gzip') || /\.gz(\?|$)/.test(url);
55 + const raw = Readable.fromWeb(res.body as never);
56 + // Some CDNs serve .gz files with Content-Encoding: gzip, which fetch already decodes → sniff the magic bytes.
57 + const first = await new Promise<Buffer | null>((resolve, reject) => {
58 + raw.once('readable', () => resolve((raw.read(2) as Buffer | null) ?? null));
59 + raw.once('error', reject);
60 + raw.once('end', () => resolve(null));
61 + });
62 + if (first) raw.unshift(first);
63 + const isGz = first ? first[0] === 0x1f && first[1] === 0x8b : encoded;
64 + const stream = isGz ? raw.pipe(createGunzip()) : raw;
65 + let buf = '';
66 + let inData = false;
67 + let count = 0;
68 + let done = false;
69 + for await (const chunk of stream) {
70 + if (done) break;
71 + buf += (chunk as Buffer).toString('utf8');
72 + if (!inData) {
73 + const i = buf.indexOf('"data"');
74 + if (i < 0) {
75 + buf = buf.slice(-16);
76 + continue;
77 + }
78 + const brace = buf.indexOf('{', i);
79 + if (brace < 0) continue;
80 + buf = buf.slice(brace + 1);
81 + inData = true;
82 + }
83 + // Parse as many complete `"key": {…},` entries as the buffer holds.
84 + for (;;) {
85 + const k = buf.indexOf('"');
86 + if (k < 0) break;
87 + const kEnd = buf.indexOf('"', k + 1);
88 + if (kEnd < 0) break;
89 + const colon = buf.indexOf(':', kEnd);
90 + if (colon < 0) break;
91 + const start = colon + 1;
92 + const end = scanJsonValue(buf, start);
93 + if (end < 0) break; // incomplete value, wait for more data
94 + const key = buf.slice(k + 1, kEnd);
95 + const raw = buf.slice(start, end).trim();
96 + try {
97 + await onEntry(key, JSON.parse(raw));
98 + } catch (err) {
99 + if (err instanceof SyntaxError) {
100 + /* skip malformed slice */
101 + } else throw err;
102 + }
103 + count++;
104 + buf = buf.slice(end);
105 + const comma = buf.indexOf(',');
106 + const close = buf.indexOf('}');
107 + if (close >= 0 && (comma < 0 || close < comma)) {
108 + done = true;
109 + break;
110 + }
111 + buf = comma >= 0 ? buf.slice(comma + 1) : buf;
112 + if (opts.limit && count >= opts.limit) {
113 + done = true;
114 + break;
115 + }
116 + }
117 + }
118 + return count;
119 +}
120 +
121 +/** Index just past a complete JSON value starting at `start` (whitespace allowed); -1 if incomplete. */
122 +function scanJsonValue(s: string, start: number): number {
123 + let i = start;
124 + while (i < s.length && /\s/.test(s[i]!)) i++;
125 + if (i >= s.length) return -1;
126 + const c = s[i]!;
127 + if (c === '{' || c === '[') {
128 + let depth = 0;
129 + let inStr = false;
130 + for (let j = i; j < s.length; j++) {
131 + const ch = s[j]!;
132 + if (inStr) {
133 + if (ch === '\\') j++;
134 + else if (ch === '"') inStr = false;
135 + continue;
136 + }
137 + if (ch === '"') inStr = true;
138 + else if (ch === '{' || ch === '[') depth++;
139 + else if (ch === '}' || ch === ']') {
140 + depth--;
141 + if (depth === 0) return j + 1;
142 + }
143 + }
144 + return -1;
145 + }
146 + if (c === '"') {
147 + for (let j = i + 1; j < s.length; j++) {
148 + if (s[j] === '\\') j++;
149 + else if (s[j] === '"') return j + 1;
150 + }
151 + return -1;
152 + }
153 + const m = s.slice(i).match(/^(true|false|null|-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?)/);
154 + if (!m) return -1;
155 + const end = i + m[0].length;
156 + return end < s.length ? end : -1;
157 +}
158 +
159 +/** Parse <url><loc>…</loc><lastmod>…</lastmod></url> entries from a sitemap. */
160 +export function parseSitemap(xml: string): Array<{ loc: string; lastmod: string | null }> {
161 + const out: Array<{ loc: string; lastmod: string | null }> = [];
162 + const re = /<url>([\s\S]*?)<\/url>/g;
163 + let m: RegExpExecArray | null;
164 + while ((m = re.exec(xml))) {
165 + const loc = m[1]!.match(/<loc>\s*([^<\s]+)\s*<\/loc>/)?.[1];
166 + if (!loc) continue;
167 + const lastmod = m[1]!.match(/<lastmod>\s*([^<\s]+)\s*<\/lastmod>/)?.[1] ?? null;
168 + out.push({ loc: decodeXml(loc), lastmod });
169 + }
170 + return out;
171 +}
172 +
173 +export function decodeXml(s: string): string {
174 + return s.replace(/&amp;/g, '&').replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&quot;/g, '"').replace(/&#39;/g, "'");
175 +}
176 +
177 +/** Parse GitHub-flavoured markdown tables (as emitted by Firecrawl) into rows of cells. */
178 +export function markdownTables(md: string): string[][][] {
179 + const tables: string[][][] = [];
180 + let cur: string[][] | null = null;
181 + for (const line of md.split('\n')) {
182 + const t = line.trim();
183 + if (t.startsWith('|') && t.endsWith('|')) {
184 + const cells = t.slice(1, -1).split('|').map((c) => c.trim());
185 + if (cells.every((c) => /^:?-{2,}:?$/.test(c))) continue; // separator row
186 + if (!cur) cur = [];
187 + cur.push(cells);
188 + } else if (cur) {
189 + tables.push(cur);
190 + cur = null;
191 + }
192 + }
193 + if (cur) tables.push(cur);
194 + return tables;
195 +}
196 +
197 +/** Strip markdown link syntax: "[2](https://…)" → "2"; "[Alakazam](url) <br>Holo" → "Alakazam Holo". */
198 +export function mdText(cell: string): string {
199 + return cell
200 + .replace(/!\[[^\]]*\]\([^)]*\)/g, '')
201 + .replace(/\[([^\]]*)\]\([^)]*\)/g, '$1')
202 + .replace(/<br\s*\/?>/gi, ' ')
203 + .replace(/\\([\\`*_{}\[\]()#+\-.!|])/g, '$1')
204 + .replace(/\s+/g, ' ')
205 + .trim();
206 +}
207 +
208 +export function mdLink(cell: string): string | null {
209 + return cell.match(/\]\((https?:[^)\s]+)\)/)?.[1] ?? null;
210 +}
211 +
212 +export function toInt(s: string): number | null {
213 + const n = Number.parseInt(s.replace(/[^0-9]/g, ''), 10);
214 + return Number.isFinite(n) ? n : null;
215 +}
216 +
217 +/** TAG / COMC / Alt category labels → taxonomy slugs (never invented; null when unknown). */
218 +export function cardCategorySlug(label: string | null | undefined): string | null {
219 + if (!label) return null;
220 + const s = label.toLowerCase();
221 + if (/pok[eé]mon/.test(s)) return 'pokemon';
222 + if (/magic/.test(s)) return 'magic_the_gathering';
223 + if (/yu-?gi-?oh/.test(s)) return 'yugioh';
224 + if (/lorcana/.test(s)) return 'disney_lorcana';
225 + if (/one piece/.test(s)) return 'one_piece_card_game';
226 + if (/digimon/.test(s)) return 'digimon_tcg';
227 + if (/flesh/.test(s)) return 'flesh_and_blood';
228 + if (/star wars/.test(s)) return 'star_wars_tcg';
229 + if (/dragon ?ball/.test(s)) return 'dragon_ball_tcg';
230 + if (/baseball/.test(s)) return 'baseball_cards';
231 + if (/basketball/.test(s)) return 'basketball_cards';
232 + if (/football/.test(s)) return 'football_cards';
233 + if (/hockey/.test(s)) return 'hockey_cards';
234 + if (/soccer|futbol/.test(s)) return 'soccer_cards';
235 + if (/formula|f1\b|racing/.test(s)) return 'f1_cards';
236 + if (/boxing|golf|tennis|wrestling|wwe|ufc|mma|olympic|lacrosse|rugby|cricket|nascar|multi-?sport/.test(s)) return 'other_sports_cards';
237 + if (/comic/.test(s)) return 'comics';
238 + if (/non-?sport|entertainment|garbage pail|marvel|dc /.test(s)) return 'non_sport_cards';
239 + if (/video game|nintendo|playstation/.test(s)) return 'video_games';
240 + if (/tcg|trading card|gaming/.test(s)) return 'trading_cards';
241 + return null;
242 +}
243 +
244 +/** "Aug 6, 2026" | "Sep 3, 2024" → UTC date. */
245 +export function parseMonthDay(s: string): Date | null {
246 + const m = s.match(/([A-Za-z]{3,9})\.?\s+(\d{1,2}),?\s+(\d{4})/);
247 + if (!m) return null;
248 + const months = ['jan', 'feb', 'mar', 'apr', 'may', 'jun', 'jul', 'aug', 'sep', 'oct', 'nov', 'dec'];
249 + const mo = months.indexOf(m[1]!.slice(0, 3).toLowerCase());
250 + if (mo < 0) return null;
251 + return new Date(Date.UTC(Number(m[3]), mo, Number(m[2])));
252 +}
253 +
254 +// ---- Magic printing treatments (mirrors connectors/api/scryfall so identifier matches agree on `variant`) ----
255 +const VARIANT_FRAME_EFFECTS = new Set(['showcase', 'extendedart', 'inverted', 'shatteredglass', 'etched', 'textured']);
256 +const VARIANT_PROMO_TYPES = new Set(['serialized', 'prerelease', 'promopack', 'judgegift', 'buyabox', 'gameday', 'textured', 'galaxyfoil', 'surgefoil', 'stepandcompleat', 'confettifoil', 'oilslick', 'halofoil', 'neonink', 'ripplefoil', 'fracturefoil', 'rainbowfoil', 'raisedfoil', 'invisibleink', 'doublerainbow', 'manafoil', 'firstplacefoil', 'dragonscalefoil', 'silverfoil', 'gilded', 'embossed', 'startercollection', 'schinesealtart', 'datestamped', 'playerrewards', 'arenaleague', 'fnm', 'release', 'launch', 'convention', 'mediainsert', 'wizardsplaynetwork', 'thick', 'poster']);
257 +const LABELS: Record<string, string> = { extendedart: 'Extended Art', promopack: 'Promo Pack', judgegift: 'Judge Promo', buyabox: 'Buy-a-Box', gameday: 'Game Day', galaxyfoil: 'Galaxy Foil', surgefoil: 'Surge Foil', stepandcompleat: 'Step-and-Compleat', confettifoil: 'Confetti Foil', oilslick: 'Oil Slick', halofoil: 'Halo Foil', neonink: 'Neon Ink', ripplefoil: 'Ripple Foil', fracturefoil: 'Fracture Foil', rainbowfoil: 'Rainbow Foil', raisedfoil: 'Raised Foil', invisibleink: 'Invisible Ink', doublerainbow: 'Double Rainbow', manafoil: 'Mana Foil', firstplacefoil: 'First Place Foil', dragonscalefoil: 'Dragon Scale Foil', silverfoil: 'Silver Foil', startercollection: 'Starter Collection', schinesealtart: 'Chinese Alt Art', datestamped: 'Date Stamped', playerrewards: 'Player Rewards', arenaleague: 'Arena League', fnm: 'FNM', mediainsert: 'Media Insert', wizardsplaynetwork: 'WPN', shatteredglass: 'Shattered Glass' };
258 +
259 +export function magicTreatments(card: { frameEffects?: string[]; promoTypes?: string[]; isFullArt?: boolean; borderColor?: string; frameVersion?: string; setType?: string | null; releaseDate?: string | null }): string[] {
260 + const t: string[] = [];
261 + for (const fe of card.frameEffects ?? []) if (VARIANT_FRAME_EFFECTS.has(fe)) t.push(fe);
262 + for (const pt of card.promoTypes ?? []) if (VARIANT_PROMO_TYPES.has(pt)) t.push(pt);
263 + if (card.isFullArt) t.push('fullart');
264 + if (card.borderColor === 'borderless') t.push('borderless');
265 + if (card.frameVersion === '1997' && card.setType !== 'core' && card.setType !== 'expansion' && (card.releaseDate ?? '') > '2010') t.push('retro');
266 + return [...new Set(t)].map((k) => LABELS[k] ?? (k === 'fullart' ? 'Full Art' : k.charAt(0).toUpperCase() + k.slice(1)));
267 +}
268 +
269 +export function magicFinishVariant(finish: string): string | null {
270 + if (finish === 'foil') return 'Foil';
271 + if (finish === 'etched') return 'Etched Foil';
272 + return null;
273 +}
added connectors/api/cardkingdom/index.test.ts +27 −0
@@ -0,0 +1,27 @@
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, { isFoil } from './index.js';
6 +
7 +const connector = createConnector(localMeta(meta));
8 +
9 +describe('cardkingdom', () => {
10 + runFixtureSuite(connector, it, expect);
11 +
12 + it('parses foil flags', () => {
13 + expect(isFoil('true')).toBe(true);
14 + expect(isFoil('false')).toBe(false);
15 + expect(isFoil(undefined)).toBe(false);
16 + });
17 +
18 + it('keys singles by scryfall_id and dates observations by the price list stamp', async () => {
19 + const single = listFixtures('cardkingdom').find((n) => n.startsWith('single'))!;
20 + const out = await connector.normalize(loadFixture('cardkingdom', single).raw);
21 + const cat = out.find((r) => r.kind === 'catalog_item');
22 + const obs = out.find((r) => r.kind === 'price_observation');
23 + expect(cat && cat.kind === 'catalog_item' ? cat.attributes.identifiers.scryfall_id : null).toMatch(/^[0-9a-f-]{36}$/);
24 + expect(obs && obs.kind === 'price_observation' ? obs.currency : null).toBe('USD');
25 + expect(obs && obs.kind === 'price_observation' ? (obs.attributes.metadata as { provider: string }).provider : null).toBe('cardkingdom');
26 + });
27 +});
added connectors/api/cardkingdom/index.ts +111 −0
@@ -0,0 +1,111 @@
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 } from '../_lib/shared.js';
5 +import { BOT_HEADERS, dayOf } from '../_lib/wave4.js';
6 +
7 +/** Card Kingdom public price lists → Magic price observations keyed by scryfall_id. */
8 +const PARSER_VERSION = '1.0.0';
9 +const SINGLES = 'https://api.cardkingdom.com/api/pricelist';
10 +const SEALED = 'https://api.cardkingdom.com/api/sealed_pricelist';
11 +
12 +const SingleSchema = z.object({
13 + id: z.number(),
14 + sku: z.string().nullable().optional(),
15 + scryfall_id: z.string().nullable().optional(),
16 + url: z.string().nullable().optional(),
17 + name: z.string(),
18 + variation: z.string().nullable().optional(),
19 + edition: z.string(),
20 + is_foil: z.union([z.string(), z.boolean()]).optional(),
21 + price_retail: z.union([z.string(), z.number()]).nullable().optional(),
22 + qty_retail: z.number().nullable().optional(),
23 + price_buy: z.union([z.string(), z.number()]).nullable().optional(),
24 + qty_buying: z.number().nullable().optional(),
25 +});
26 +const SealedSchema = z.object({ id: z.number(), url: z.string().nullable().optional(), name: z.string(), edition: z.string().nullable().optional(), price_retail: z.union([z.string(), z.number()]).nullable().optional(), qty_retail: z.number().nullable().optional(), price_buy: z.union([z.string(), z.number()]).nullable().optional(), qty_buying: z.number().nullable().optional() });
27 +const RawPayloadSchema = z.discriminatedUnion('kind', [
28 + z.object({ kind: z.literal('single'), row: SingleSchema, createdAt: z.string().nullable(), baseUrl: z.string() }),
29 + z.object({ kind: z.literal('sealed'), row: SealedSchema, createdAt: z.string().nullable(), baseUrl: z.string() }),
30 +]);
31 +export type CardKingdomPayload = z.infer<typeof RawPayloadSchema>;
32 +
33 +export function isFoil(v: string | boolean | undefined): boolean {
34 + return v === true || String(v).toLowerCase() === 'true';
35 +}
36 +
37 +export class CardKingdomConnector extends BaseConnector {
38 + readonly version = '1.0.0';
39 + readonly parserVersion = PARSER_VERSION;
40 +
41 + async *crawl(ctx: CrawlContext): AsyncIterable<RawRecordInput> {
42 + const minRetail = Number(this.meta.config.minRetailUsd ?? 0);
43 + let count = 0;
44 + const singles = await ctx.fetch(SINGLES, { engines: ['api'], headers: BOT_HEADERS, timeoutMs: 180_000 });
45 + let doc = singles.json as { meta?: { created_at?: string; base_url?: string }; data?: unknown[] } | null;
46 + if ((!doc || !Array.isArray(doc.data)) && typeof singles.html === 'string' && singles.html.startsWith('{')) doc = JSON.parse(singles.html) as typeof doc; // served as text/html
47 + if (!singles.success || !doc || !Array.isArray(doc.data)) throw new Error(`cardkingdom pricelist failed: ${singles.error ?? singles.httpStatus}`);
48 + const createdAt = doc.meta?.created_at ?? null;
49 + const baseUrl = doc.meta?.base_url ?? 'https://www.cardkingdom.com/';
50 + for (const r of doc.data) {
51 + const parsed = SingleSchema.safeParse(r);
52 + if (!parsed.success) {
53 + ctx.anomaly('parse_failure_row', parsed.error.issues[0]?.message);
54 + continue;
55 + }
56 + const retail = num(parsed.data.price_retail);
57 + if (retail === null || retail < minRetail) continue;
58 + if (this.reached(ctx, count)) return;
59 + count++;
60 + yield { url: `${baseUrl}${parsed.data.url ?? `mtg/${parsed.data.id}`}`, externalId: `s${parsed.data.id}`, kind: 'catalog_item', engine: 'api', httpStatus: singles.httpStatus, payload: { kind: 'single', row: parsed.data, createdAt, baseUrl } satisfies CardKingdomPayload, fetchedAt: singles.fetchedAt };
61 + }
62 + if (this.meta.config.includeSealed !== false) {
63 + const sealed = await ctx.fetch(SEALED, { engines: ['api'], headers: BOT_HEADERS, timeoutMs: 120_000 });
64 + let sdoc = sealed.json as { meta?: { created_at?: string; base_url?: string }; data?: unknown[] } | null;
65 + if ((!sdoc || !Array.isArray(sdoc.data)) && typeof sealed.html === 'string' && sealed.html.startsWith('{')) sdoc = JSON.parse(sealed.html) as typeof sdoc;
66 + if (sealed.success && sdoc && Array.isArray(sdoc.data)) {
67 + for (const r of sdoc.data) {
68 + const parsed = SealedSchema.safeParse(r);
69 + if (!parsed.success || num(parsed.data.price_retail) === null) continue;
70 + if (this.reached(ctx, count)) return;
71 + count++;
72 + yield { url: `${baseUrl}${parsed.data.url ?? `mtg-sealed/${parsed.data.id}`}`, externalId: `x${parsed.data.id}`, kind: 'catalog_item', engine: 'api', httpStatus: sealed.httpStatus, payload: { kind: 'sealed', row: parsed.data, createdAt: sdoc.meta?.created_at ?? null, baseUrl } satisfies CardKingdomPayload, fetchedAt: sealed.fetchedAt };
73 + }
74 + } else ctx.anomaly('page_fetch_failed', `sealed_pricelist: ${sealed.error ?? sealed.httpStatus}`);
75 + }
76 + await ctx.setCursor({ completedAt: new Date().toISOString() });
77 + }
78 +
79 + async normalize(raw: RawRecordLike): Promise<NormalizedRecord[]> {
80 + const p = RawPayloadSchema.parse(raw.payload);
81 + const observedAt = raw.fetchedAt;
82 + const created = p.createdAt ? new Date(p.createdAt.replace(' ', 'T') + 'Z') : null;
83 + const observationDate = created && !Number.isNaN(created.getTime()) ? dayOf(created) : dayOf(observedAt);
84 + const out: NormalizedRecord[] = [];
85 + if (p.kind === 'single') {
86 + const r = p.row;
87 + const foil = isFoil(r.is_foil);
88 + const variation = (r.variation ?? '').trim();
89 + const variant = [variation || null, foil ? 'Foil' : null].filter(Boolean).join(' ') || null;
90 + const number = r.sku?.match(/-([A-Za-z0-9]+)$/)?.[1] ?? null;
91 + const identifiers: Record<string, string> = { cardkingdom_id: String(r.id) };
92 + if (r.scryfall_id) identifiers.scryfall_id = r.scryfall_id;
93 + if (r.sku) identifiers.cardkingdom_sku = r.sku;
94 + const a = attrs({ categorySlug: 'magic_the_gathering', franchise: 'Magic: The Gathering', brand: 'Wizards of the Coast', set: r.edition, name: r.name, number, variant, language: 'English', identifiers, metadata: { finish: foil ? 'foil' : 'nonfoil', variation: variation || null } });
95 + const rawTitle = makeTitle({ name: r.name, set: r.edition, number, variant });
96 + out.push(catalogItem({ kind: 'catalog_item', connectorId: this.meta.id, sourceId: this.meta.sourceId, sourceUrl: raw.url, externalId: `s${r.id}`, rawTitle, imageUrls: [], attributes: a, observedAt, confidence: 0.8, parserVersion: PARSER_VERSION }));
97 + const retail = num(r.price_retail);
98 + if (retail) out.push(priceObservation({ kind: 'price_observation', connectorId: this.meta.id, sourceId: this.meta.sourceId, sourceUrl: raw.url, externalId: `s${r.id}:retail:${observationDate.toISOString().slice(0, 10)}`, rawTitle, imageUrls: [], attributes: { ...a, metadata: { ...a.metadata, provider: 'cardkingdom', buylist: num(r.price_buy), qty_retail: r.qty_retail ?? null, qty_buying: r.qty_buying ?? null } }, observedAt, confidence: 0.7, parserVersion: PARSER_VERSION, priceKind: 'market', price: retail, currency: 'USD', observationDate, sampleSize: r.qty_retail ?? null }));
99 + return out;
100 + }
101 + const r = p.row;
102 + const a = attrs({ categorySlug: 'magic_the_gathering', franchise: 'Magic: The Gathering', brand: 'Wizards of the Coast', set: r.edition ?? null, name: r.name, identifiers: { cardkingdom_sealed_id: String(r.id) }, metadata: { sealed: true } });
103 + const rawTitle = makeTitle({ name: r.name, set: r.edition ?? null });
104 + out.push(catalogItem({ kind: 'catalog_item', connectorId: this.meta.id, sourceId: this.meta.sourceId, sourceUrl: raw.url, externalId: `x${r.id}`, rawTitle, imageUrls: [], attributes: a, observedAt, confidence: 0.75, parserVersion: PARSER_VERSION }));
105 + const retail = num(r.price_retail);
106 + if (retail) out.push(priceObservation({ kind: 'price_observation', connectorId: this.meta.id, sourceId: this.meta.sourceId, sourceUrl: raw.url, externalId: `x${r.id}:retail:${observationDate.toISOString().slice(0, 10)}`, rawTitle, imageUrls: [], attributes: { ...a, metadata: { ...a.metadata, provider: 'cardkingdom', buylist: num(r.price_buy) } }, observedAt, confidence: 0.7, parserVersion: PARSER_VERSION, priceKind: 'market', price: retail, currency: 'USD', observationDate, sampleSize: r.qty_retail ?? null }));
107 + return out;
108 + }
109 +}
110 +
111 +export default (meta: ConnectorMeta) => new CardKingdomConnector(meta);
added connectors/api/cardkingdom/meta.json +33 −0
@@ -0,0 +1,33 @@
1 +{
2 + "id": "cardkingdom",
3 + "displayName": "Card Kingdom (retail & buylist prices)",
4 + "sourceId": "cardkingdom",
5 + "sourceName": "Card Kingdom",
6 + "sourceType": "dealer",
7 + "sourceUrl": "https://www.cardkingdom.com",
8 + "module": "api/cardkingdom",
9 + "enginePriority": ["api"],
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": false,
18 + "supportsCatalog": true,
19 + "supportsPopulation": false,
20 + "supportsLookup": false,
21 + "refreshFrequencyMinutes": 1440,
22 + "priority": "medium",
23 + "trustScore": 0.8,
24 + "attributionRequired": true,
25 + "termsUrl": "https://www.cardkingdom.com/help/terms",
26 + "accessNotes": "Card Kingdom publishes its full singles price list (https://api.cardkingdom.com/api/pricelist, ~46 MB JSON, refreshed daily) and sealed price list (/api/sealed_pricelist) publicly; each singles row carries the Scryfall id, edition, foil flag, retail price, retail stock, buylist price and buylist demand. Fetched once per run with an identifying User-Agent (two requests). Retail = dealer asking price stored as price_observation kind 'market' with metadata.buylist; not a transaction. Identifiers: cardkingdom_id, scryfall_id. Sealed products get catalog items with metadata.sealed=true.",
27 + "enabled": true,
28 + "schemaVersion": "1.0",
29 + "config": {
30 + "includeSealed": true,
31 + "minRetailUsd": 0.5
32 + }
33 +}
added connectors/api/mtgjson/index.test.ts +58 −0
@@ -0,0 +1,58 @@
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 { magicTreatments, streamGzipDataEntries } from '../_lib/wave4.js';
7 +import { gzipSync } from 'node:zlib';
8 +
9 +const connector = createConnector(localMeta(meta));
10 +
11 +describe('mtgjson', () => {
12 + runFixtureSuite(connector, it, expect);
13 +
14 + it('emits scryfall-aligned variants and provider-dated observations', async () => {
15 + const names = listFixtures('mtgjson');
16 + const fx = loadFixture('mtgjson', names[0]!);
17 + const out = await connector.normalize(fx.raw);
18 + const cats = out.filter((r) => r.kind === 'catalog_item');
19 + const obs = out.filter((r) => r.kind === 'price_observation');
20 + expect(cats.length).toBeGreaterThanOrEqual(1);
21 + for (const c of cats) {
22 + if (c.kind !== 'catalog_item') continue;
23 + expect(c.attributes.identifiers.mtgjson_uuid).toBeTruthy();
24 + expect(c.attributes.identifiers.scryfall_id).toMatch(/^[0-9a-f-]{36}$/);
25 + expect(c.attributes.setCode).toMatch(/^[A-Z0-9]{2,6}$/);
26 + }
27 + for (const o of obs) {
28 + if (o.kind !== 'price_observation') continue;
29 + expect(['USD', 'EUR']).toContain(o.currency);
30 + expect(o.priceKind).toBe('market');
31 + expect((o.attributes.metadata as { provider: string }).provider).toBeTruthy();
32 + expect(o.observationDate.getTime()).toBeLessThanOrEqual(Date.now());
33 + }
34 + });
35 +
36 + it('mirrors the scryfall treatment vocabulary', () => {
37 + expect(magicTreatments({ frameEffects: ['showcase'], promoTypes: [] })).toEqual(['Showcase']);
38 + expect(magicTreatments({ frameEffects: ['extendedart'], borderColor: 'borderless' })).toEqual(['Extended Art', 'Borderless']);
39 + expect(magicTreatments({})).toEqual([]);
40 + });
41 +
42 + it('streams top-level data entries from a gzip JSON document', async () => {
43 + const doc = { meta: { date: '2026-09-06' }, data: { a: { paper: { tcgplayer: { retail: { normal: { '2026-09-06': 1.5 } } } } }, b: { paper: {} }, c: { x: [1, 2, { y: '}' }] } } };
44 + const gz = gzipSync(Buffer.from(JSON.stringify(doc)));
45 + const orig = globalThis.fetch;
46 + globalThis.fetch = (async () => new Response(gz, { status: 200 })) as typeof fetch;
47 + try {
48 + const seen: string[] = [];
49 + const n = await streamGzipDataEntries('https://example.test/x.json.gz', (k) => {
50 + seen.push(k);
51 + });
52 + expect(n).toBe(3);
53 + expect(seen).toEqual(['a', 'b', 'c']);
54 + } finally {
55 + globalThis.fetch = orig;
56 + }
57 + });
58 +});
added connectors/api/mtgjson/index.ts +219 −0
@@ -0,0 +1,219 @@
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, priceObservation } from '../_lib/shared.js';
5 +import { BOT_HEADERS, fetchGzipJson, isoDay, magicFinishVariant, magicTreatments, streamGzipDataEntries } from '../_lib/wave4.js';
6 +
7 +/**
8 + * MTGJSON connector — per-set card files (identifiers across Scryfall/TCGplayer/Cardmarket/Card Kingdom)
9 + * joined with the daily price file. One raw record per printing; normalize emits catalog items per
10 + * finish and dated price observations per provider × finish.
11 + */
12 +const API = 'https://mtgjson.com/api/v5';
13 +const PARSER_VERSION = '1.0.0';
14 +
15 +const SetSchema = z.object({ code: z.string(), name: z.string(), releaseDate: z.string().nullable().optional(), type: z.string().nullable().optional(), tcgplayerGroupId: z.number().nullable().optional(), mcmId: z.number().nullable().optional(), isOnlineOnly: z.boolean().optional(), totalSetSize: z.number().optional() });
16 +const CardSchema = z.object({
17 + uuid: z.string(),
18 + name: z.string(),
19 + number: z.string(),
20 + rarity: z.string().nullable().optional(),
21 + finishes: z.array(z.string()).default([]),
22 + language: z.string().nullable().optional(),
23 + identifiers: z.record(z.string(), z.string()).default({}),
24 + frameEffects: z.array(z.string()).optional(),
25 + promoTypes: z.array(z.string()).optional(),
26 + isFullArt: z.boolean().optional(),
27 + borderColor: z.string().optional(),
28 + frameVersion: z.string().optional(),
29 + isPromo: z.boolean().optional(),
30 + isReserved: z.boolean().optional(),
31 + artist: z.string().nullable().optional(),
32 + type: z.string().nullable().optional(),
33 + manaCost: z.string().nullable().optional(),
34 +});
35 +/** provider → finish → { date: price } */
36 +const ProviderPricesSchema = z.object({ retail: z.record(z.string(), z.record(z.string(), z.number())).optional(), buylist: z.record(z.string(), z.record(z.string(), z.number())).optional(), currency: z.string().optional() });
37 +const PaperPricesSchema = z.record(z.string(), ProviderPricesSchema);
38 +const RawPayloadSchema = z.object({ set: SetSchema, card: CardSchema, prices: PaperPricesSchema.nullable(), priceDate: z.string().nullable() });
39 +export type MtgjsonPayload = z.infer<typeof RawPayloadSchema>;
40 +
41 +type PriceMap = Map<string, z.infer<typeof PaperPricesSchema>>;
42 +
43 +const CARD_KEEP = ['uuid', 'name', 'number', 'rarity', 'finishes', 'language', 'identifiers', 'frameEffects', 'promoTypes', 'isFullArt', 'borderColor', 'frameVersion', 'isPromo', 'isReserved', 'artist', 'type', 'manaCost'] as const;
44 +export function trimCard(c: Record<string, unknown>): z.infer<typeof CardSchema> {
45 + const out: Record<string, unknown> = {};
46 + for (const k of CARD_KEEP) if (c[k] !== undefined) out[k] = c[k];
47 + return CardSchema.parse(out);
48 +}
49 +export function trimSet(s: Record<string, unknown>): z.infer<typeof SetSchema> {
50 + const out: Record<string, unknown> = {};
51 + for (const k of ['code', 'name', 'releaseDate', 'type', 'tcgplayerGroupId', 'mcmId', 'isOnlineOnly', 'totalSetSize']) if (s[k] !== undefined) out[k] = s[k];
52 + return SetSchema.parse(out);
53 +}
54 +
55 +/** Keep only the latest `days` dates per provider/finish (payload compactness). */
56 +function trimPrices(paper: unknown, days: number): z.infer<typeof PaperPricesSchema> | null {
57 + const parsed = PaperPricesSchema.safeParse(paper);
58 + if (!parsed.success) return null;
59 + const out: z.infer<typeof PaperPricesSchema> = {};
60 + for (const [provider, p] of Object.entries(parsed.data)) {
61 + const trimmed: z.infer<typeof ProviderPricesSchema> = { currency: p.currency };
62 + for (const kind of ['retail', 'buylist'] as const) {
63 + const byFinish = p[kind];
64 + if (!byFinish) continue;
65 + const tf: Record<string, Record<string, number>> = {};
66 + for (const [finish, series] of Object.entries(byFinish)) {
67 + const dates = Object.keys(series).sort().slice(-days);
68 + tf[finish] = Object.fromEntries(dates.map((d) => [d, series[d]!]));
69 + }
70 + trimmed[kind] = tf;
71 + }
72 + out[provider] = trimmed;
73 + }
74 + return out;
75 +}
76 +
77 +export class MtgjsonConnector extends BaseConnector {
78 + readonly version = '1.0.0';
79 + readonly parserVersion = PARSER_VERSION;
80 + protected override minIntervalMs = 250;
81 +
82 + async *crawl(ctx: CrawlContext): AsyncIterable<RawRecordInput> {
83 + const backfill = ctx.options.mode === 'backfill';
84 + const maxSets = backfill ? Infinity : Number(this.meta.config.maxSetsPerRun ?? 60);
85 + const listRes = await ctx.fetch(`${API}/SetList.json`, { engines: ['api'], headers: BOT_HEADERS });
86 + const list = (listRes.json as { data?: unknown[] } | null)?.data;
87 + if (!listRes.success || !Array.isArray(list)) throw new Error(`mtgjson SetList failed: ${listRes.error}`);
88 + let sets = list.map((s) => trimSet(s as Record<string, unknown>)).filter((s) => !s.isOnlineOnly);
89 + if (ctx.options.seeds?.length) sets = sets.filter((s) => ctx.options.seeds!.includes(s.code));
90 + sets.sort((a, b) => (b.releaseDate ?? '').localeCompare(a.releaseDate ?? ''));
91 + sets = sets.slice(0, Number.isFinite(maxSets) ? maxSets : sets.length);
92 +
93 + // Daily prices (small) — joined by uuid. Backfill streams the 90-day history instead.
94 + const prices: PriceMap = new Map();
95 + let priceDate: string | null = null;
96 + if (!backfill) {
97 + const today = await fetchGzipJson<{ meta?: { date?: string }; data?: Record<string, { paper?: unknown }> }>(`${API}/AllPricesToday.json.gz`, ctx.signal);
98 + priceDate = today.meta?.date ?? null;
99 + for (const [uuid, v] of Object.entries(today.data ?? {})) {
100 + const t = trimPrices(v.paper, 1);
101 + if (t) prices.set(uuid, t);
102 + }
103 + const s = (ctx.engineStats.api ??= { attempts: 0, success: 0, credits: 0, ms: 0 });
104 + s.attempts++;
105 + s.success++;
106 + }
107 +
108 + let count = 0;
109 + let setIdx = Number(ctx.options.cursor?.setIdx ?? 0);
110 + const cards = new Map<string, { set: z.infer<typeof SetSchema>; card: z.infer<typeof CardSchema>; url: string }>();
111 + for (; setIdx < sets.length; setIdx++) {
112 + if (ctx.signal?.aborted) return;
113 + const set = sets[setIdx]!;
114 + await this.throttle();
115 + const res = await ctx.fetch(`${API}/${set.code}.json`, { engines: ['api'], headers: BOT_HEADERS });
116 + const data = (res.json as { data?: { cards?: unknown[] } } | null)?.data;
117 + if (!res.success || !Array.isArray(data?.cards)) {
118 + ctx.anomaly('page_fetch_failed', `${set.code}: ${res.error ?? res.httpStatus}`);
119 + continue;
120 + }
121 + for (const raw of data.cards) {
122 + let card: z.infer<typeof CardSchema>;
123 + try {
124 + card = trimCard(raw as Record<string, unknown>);
125 + } catch (err) {
126 + ctx.anomaly('parse_failure_card', `${set.code}: ${err instanceof Error ? err.message : String(err)}`);
127 + continue;
128 + }
129 + const url = `https://mtgjson.com/api/v5/${set.code}.json#${card.uuid}`;
130 + if (backfill) {
131 + cards.set(card.uuid, { set, card, url });
132 + continue;
133 + }
134 + if (this.reached(ctx, count)) return;
135 + count++;
136 + const payload: MtgjsonPayload = { set, card, prices: prices.get(card.uuid) ?? null, priceDate };
137 + yield { url, externalId: card.uuid, kind: 'catalog_item', engine: 'api', httpStatus: res.httpStatus, payload, fetchedAt: res.fetchedAt };
138 + }
139 + await ctx.setCursor({ setIdx: setIdx + 1, priceDate });
140 + }
141 + if (backfill) {
142 + // Stream the 90-day history and yield one record per known printing.
143 + const days = Number(this.meta.config.historyDays ?? 90);
144 + const queue: RawRecordInput[] = [];
145 + const seen = await streamGzipDataEntries(
146 + `${API}/AllPrices.json.gz`,
147 + (uuid, value) => {
148 + const c = cards.get(uuid);
149 + if (!c) return;
150 + const paper = (value as { paper?: unknown })?.paper;
151 + const trimmed = trimPrices(paper, days);
152 + const payload: MtgjsonPayload = { set: c.set, card: c.card, prices: trimmed, priceDate: null };
153 + queue.push({ url: c.url, externalId: c.card.uuid, kind: 'catalog_item', engine: 'api', httpStatus: 200, payload, fetchedAt: new Date() });
154 + },
155 + { signal: ctx.signal, limit: ctx.options.limit ? ctx.options.limit * 4 : undefined },
156 + );
157 + ctx.log.info({ seen, matched: queue.length }, 'mtgjson AllPrices streamed');
158 + for (const r of queue) {
159 + if (this.reached(ctx, count)) return;
160 + count++;
161 + yield r;
162 + }
163 + }
164 + await ctx.setCursor({ setIdx: 0, priceDate, completedAt: new Date().toISOString() });
165 + }
166 +
167 + async normalize(raw: RawRecordLike): Promise<NormalizedRecord[]> {
168 + const { set, card, prices } = RawPayloadSchema.parse(raw.payload);
169 + const year = set.releaseDate ? Number(set.releaseDate.slice(0, 4)) || null : null;
170 + const ids: Record<string, string> = { mtgjson_uuid: card.uuid };
171 + const map: Array<[string, string]> = [['scryfallId', 'scryfall_id'], ['scryfallOracleId', 'oracle_id'], ['tcgplayerProductId', 'tcgplayer_id'], ['tcgplayerEtchedProductId', 'tcgplayer_etched_id'], ['mcmId', 'cardmarket_id'], ['cardKingdomId', 'cardkingdom_id'], ['cardKingdomFoilId', 'cardkingdom_foil_id'], ['cardKingdomEtchedId', 'cardkingdom_etched_id'], ['cardsphereId', 'cardsphere_id'], ['multiverseId', 'multiverse_id'], ['mtgoId', 'mtgo_id']];
172 + for (const [from, to] of map) if (card.identifiers[from]) ids[to] = card.identifiers[from]!;
173 + const treat = magicTreatments({ ...card, setType: set.type ?? null, releaseDate: set.releaseDate ?? null });
174 + const baseVariant = treat.length ? treat.join(' ') : null;
175 + const finishes = card.finishes.length ? card.finishes : ['nonfoil'];
176 + const observedAt = raw.fetchedAt;
177 + const providers = new Set((this.meta.config.providers as string[] | undefined) ?? ['tcgplayer', 'cardmarket', 'cardkingdom', 'cardsphere', 'manapool']);
178 + const out: NormalizedRecord[] = [];
179 + for (const finish of finishes) {
180 + const variant = [baseVariant, magicFinishVariant(finish)].filter(Boolean).join(' ') || null;
181 + const a = attrs({
182 + categorySlug: 'magic_the_gathering',
183 + franchise: 'Magic: The Gathering',
184 + brand: 'Wizards of the Coast',
185 + set: set.name,
186 + setCode: set.code.toUpperCase(),
187 + name: card.name,
188 + number: card.number,
189 + year,
190 + variant,
191 + language: card.language ?? 'English',
192 + rarity: card.rarity ?? null,
193 + identifiers: { ...ids, finish },
194 + metadata: { artist: card.artist ?? null, type_line: card.type ?? null, mana_cost: card.manaCost ?? null, promo: card.isPromo ?? false, reserved: card.isReserved ?? false, set_type: set.type ?? null },
195 + });
196 + const rawTitle = makeTitle({ name: card.name, set: set.name, number: card.number, year, variant });
197 + const sourceUrl = `https://scryfall.com/card/${set.code.toLowerCase()}/${encodeURIComponent(card.number)}`;
198 + out.push(catalogItem({ kind: 'catalog_item', connectorId: this.meta.id, sourceId: this.meta.sourceId, sourceUrl, externalId: `${card.uuid}:${finish}`, rawTitle, imageUrls: [], attributes: a, observedAt, confidence: 0.85, parserVersion: PARSER_VERSION, releaseDate: set.releaseDate ? new Date(set.releaseDate) : null }));
199 + if (!prices) continue;
200 + for (const [provider, p] of Object.entries(prices)) {
201 + if (!providers.has(provider)) continue;
202 + // MTGJSON price files key finishes as normal|foil|etched while card.finishes uses nonfoil|foil|etched.
203 + const priceKey = finish === 'nonfoil' ? 'normal' : finish;
204 + const retail = p.retail?.[priceKey];
205 + if (!retail) continue;
206 + const currency = (p.currency ?? (provider === 'cardmarket' ? 'EUR' : 'USD')) as 'USD' | 'EUR';
207 + const buy = p.buylist?.[priceKey];
208 + for (const [date, price] of Object.entries(retail)) {
209 + const observationDate = isoDay(date);
210 + if (!observationDate || !(price > 0)) continue;
211 + out.push(priceObservation({ kind: 'price_observation', connectorId: this.meta.id, sourceId: this.meta.sourceId, sourceUrl, externalId: `${card.uuid}:${finish}:${provider}:${date}`, rawTitle, imageUrls: [], attributes: { ...a, metadata: { ...a.metadata, provider, buylist: buy?.[date] ?? null } }, observedAt, confidence: provider === 'tcgplayer' || provider === 'cardmarket' ? 0.75 : 0.7, parserVersion: PARSER_VERSION, priceKind: 'market', price, currency, observationDate, sampleSize: null }));
212 + }
213 + }
214 + }
215 + return out;
216 + }
217 +}
218 +
219 +export default (meta: ConnectorMeta) => new MtgjsonConnector(meta);
added connectors/api/mtgjson/meta.json +34 −0
@@ -0,0 +1,34 @@
1 +{
2 + "id": "mtgjson",
3 + "displayName": "MTGJSON (prices & identifiers)",
4 + "sourceId": "mtgjson",
5 + "sourceName": "MTGJSON",
6 + "sourceType": "pricing_guide",
7 + "sourceUrl": "https://mtgjson.com",
8 + "module": "api/mtgjson",
9 + "enginePriority": ["api"],
10 + "categories": ["magic_the_gathering"],
11 + "regions": ["US", "EU"],
12 + "languages": ["en"],
13 + "currency": ["USD", "EUR"],
14 + "supportsListings": false,
15 + "supportsSold": false,
16 + "supportsAuctions": false,
17 + "supportsImages": false,
18 + "supportsCatalog": true,
19 + "supportsPopulation": false,
20 + "supportsLookup": false,
21 + "refreshFrequencyMinutes": 1440,
22 + "priority": "high",
23 + "trustScore": 0.85,
24 + "attributionRequired": true,
25 + "termsUrl": "https://mtgjson.com/faq/",
26 + "accessNotes": "Open MTG data project (free bulk files, no key). Incremental runs download SetList.json, the newest `maxSetsPerRun` set files (LEA.json… with per-card identifiers: scryfallId, tcgplayerProductId, cardKingdomId, mcmId, cardsphereId, multiverseId) and AllPricesToday.json.gz (~5 MB gz) which carries the latest daily retail/buylist price per printing from TCGplayer, Cardmarket (EUR), Card Kingdom, Cardsphere and ManaPool. Backfill streams AllPrices.json.gz (~150 MB gz, 90 days of daily history) entry by entry. Retail prices are stored as price_observations (priceKind 'market', metadata.provider), buylist prices only in metadata; every point is dated by MTGJSON's own date keys. Variant vocabulary mirrors the scryfall connector (Foil / Etched Foil + treatments) so identifier matches agree.",
27 + "enabled": true,
28 + "schemaVersion": "1.0",
29 + "config": {
30 + "maxSetsPerRun": 60,
31 + "providers": ["tcgplayer", "cardmarket", "cardkingdom", "cardsphere", "manapool"],
32 + "historyDays": 90
33 + }
34 +}
added connectors/api/myslabs/index.test.ts +32 −0
@@ -0,0 +1,32 @@
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, { slabCategory } from './index.js';
6 +
7 +const connector = createConnector(localMeta(meta));
8 +
9 +describe('myslabs', () => {
10 + runFixtureSuite(connector, it, expect);
11 +
12 + it('classifies slab titles', () => {
13 + expect(slabCategory('2025 Prizm FIFA Manga Kylian Mbappe BGS 9.5', null)).toBe('soccer_cards');
14 + expect(slabCategory('1999 Pokemon Base Set Charizard Holo PSA 9', null)).toBe('pokemon');
15 + expect(slabCategory('Amazing Spider-Man #300 CGC 9.8 1988', null)).toBe('marvel_comics');
16 + expect(slabCategory('2003 Topps Chrome LeBron James Rookie PSA 10', null)).toBe('basketball_cards');
17 + expect(slabCategory('Random vase', null)).toBeNull();
18 + });
19 +
20 + it('records sold-out slabs as sold listings, never as sales', async () => {
21 + for (const name of listFixtures('myslabs')) {
22 + const out = await connector.normalize(loadFixture('myslabs', name).raw);
23 + for (const r of out) {
24 + expect(r.kind).toBe('listing');
25 + if (r.kind === 'listing') {
26 + expect(['available', 'sold', 'unknown']).toContain(r.availability);
27 + expect(r.currency).toBe('USD');
28 + }
29 + }
30 + }
31 + });
32 +});
added connectors/api/myslabs/index.ts +164 −0
@@ -0,0 +1,164 @@
1 +import { z } from 'zod';
2 +import { BaseConnector, html as H, type ConnectorMeta, type CrawlContext, type RawRecordInput, type RawRecordLike } from '@rareindex/connectors';
3 +import { NormalizedListingSchema, extractYear, type NormalizedRecord } from '@rareindex/shared';
4 +import { parseGradeFromTitle } from '@rareindex/taxonomy';
5 +import { attrs } from '../_lib/shared.js';
6 +import { BOT_HEADERS, cardCategorySlug, decodeXml, parseSitemap } from '../_lib/wave4.js';
7 +
8 +/** MySlabs — graded slab marketplace; public slab pages expose a Product JSON-LD. */
9 +const PARSER_VERSION = '1.0.0';
10 +const SITE = 'https://myslabs.com';
11 +
12 +const RawPayloadSchema = z.object({
13 + id: z.string(),
14 + name: z.string(),
15 + description: z.string().nullable(),
16 + images: z.array(z.string()),
17 + price: z.number().nullable(),
18 + currency: z.string().nullable(),
19 + availability: z.string().nullable(),
20 + category: z.string().nullable(),
21 + seller: z.string().nullable(),
22 +});
23 +export type MySlabsPayload = z.infer<typeof RawPayloadSchema>;
24 +
25 +const SPORTS_BRAND = /\b(topps|panini|bowman|prizm|fleer|donruss|upper deck|select|mosaic|optic|chrome|leaf|score|o-pee-chee|hoops|skybox|sp authentic|national treasures|immaculate|flawless|futera|stadium club|goudey|play ball|kellogg)\b/i;
26 +const SPORT_WORDS: Array<[RegExp, string]> = [
27 + [/\b(basketball|nba|wnba|hoops|lebron|jordan|curry|wembanyama|kobe|giannis|luka|jokic)\b/i, 'basketball_cards'],
28 + [/\b(baseball|mlb|mantle|ohtani|trout|jeter|bowman|goudey|play ball)\b/i, 'baseball_cards'],
29 + [/\b(football|nfl|mahomes|brady|rookie qb|panini contenders|donruss optic football)\b/i, 'football_cards'],
30 + [/\b(hockey|nhl|gretzky|mcdavid|crosby|o-pee-chee|young guns|bedard)\b/i, 'hockey_cards'],
31 + [/\b(soccer|fifa|messi|ronaldo|mbappe|mbappé|haaland|yamal|premier league|champions league)\b/i, 'soccer_cards'],
32 + [/\b(f1|formula 1|formula one|verstappen|hamilton|leclerc|norris)\b/i, 'f1_cards'],
33 + [/\b(ufc|wwe|wrestling|boxing|golf|tennis|nascar|olympic|mma)\b/i, 'other_sports_cards'],
34 +];
35 +
36 +/** Title → taxonomy slug for MySlabs inventory (cards/comics); null when unsure. */
37 +export function slabCategory(title: string, categoryHint: string | null): string | null {
38 + const hinted = cardCategorySlug(categoryHint);
39 + if (hinted && hinted !== 'trading_cards') return hinted;
40 + const tcg = cardCategorySlug(title);
41 + if (tcg && ['pokemon', 'magic_the_gathering', 'yugioh', 'disney_lorcana', 'one_piece_card_game', 'digimon_tcg', 'flesh_and_blood', 'star_wars_tcg', 'dragon_ball_tcg'].includes(tcg)) return tcg;
42 + if ((/\b(cgc|cbcs)\b/i.test(title) && /#\s?\d+/.test(title) || /\bcomic|\b#\d+\s*\(?\d{4}\)?/i.test(title)) && !SPORTS_BRAND.test(title)) return /\bmarvel|spider-man|x-men|avengers|hulk|iron man|captain america|wolverine|daredevil|fantastic four\b/i.test(title) ? 'marvel_comics' : /\bdc\b|batman|superman|detective comics|action comics|wonder woman|flash|green lantern|joker/i.test(title) ? 'dc_comics' : 'independent_comics';
43 + for (const [re, slug] of SPORT_WORDS) if (re.test(title)) return slug;
44 + if (SPORTS_BRAND.test(title)) return 'sports_cards';
45 + if (hinted) return hinted;
46 + return null;
47 +}
48 +
49 +function extractPayload(id: string, htmlText: string): MySlabsPayload | null {
50 + const products = H.jsonLd(htmlText, 'Product');
51 + const p = products[0];
52 + if (!p) return null;
53 + const offers = (Array.isArray(p.offers) ? p.offers[0] : p.offers) as Record<string, unknown> | undefined;
54 + const images = (Array.isArray(p.image) ? p.image : p.image ? [p.image] : []).map((u) => decodeXml(String(u)));
55 + const priceRaw = offers?.price;
56 + const price = priceRaw === undefined || priceRaw === null || priceRaw === '' ? null : Number(priceRaw);
57 + const availability = typeof offers?.availability === 'string' ? offers.availability.replace(/^https?:\/\/schema\.org\//, '') : null;
58 + const seller = (offers?.seller as { name?: string } | undefined)?.name ?? null;
59 + const $ = H.load(htmlText);
60 + const crumbs = $('nav[aria-label="breadcrumb"] a, .breadcrumb a, ol.breadcrumb li')
61 + .map((_, el) => $(el).text().trim())
62 + .get()
63 + .filter(Boolean);
64 + const category = crumbs.find((c) => /card|comic|pok|magic|yu-gi|sport/i.test(c)) ?? null;
65 + return { id, name: String(p.name ?? '').trim(), description: typeof p.description === 'string' ? p.description.trim() : null, images, price: Number.isFinite(price as number) ? (price as number) : null, currency: typeof offers?.priceCurrency === 'string' ? offers.priceCurrency : null, availability, category, seller };
66 +}
67 +
68 +export class MySlabsConnector extends BaseConnector {
69 + readonly version = '1.0.0';
70 + readonly parserVersion = PARSER_VERSION;
71 + override readonly urlPatterns = [/^https?:\/\/(www\.)?myslabs\.com\/slab\/view\/\d+/i];
72 + protected override minIntervalMs = 1500;
73 +
74 + private async fetchSlab(ctx: CrawlContext, url: string): Promise<RawRecordInput | null> {
75 + await this.throttle();
76 + const res = await ctx.fetch(url, { engines: ['api'], headers: { ...BOT_HEADERS, accept: 'text/html' }, responseType: 'text' });
77 + if (!res.success || !res.html) {
78 + ctx.anomaly('page_fetch_failed', `${url}: ${res.error ?? res.httpStatus}`);
79 + return null;
80 + }
81 + const id = url.match(/slab\/view\/(\d+)/)?.[1] ?? url;
82 + const payload = extractPayload(id, res.html);
83 + if (!payload || !payload.name) {
84 + ctx.anomaly('parse_failure_slab', url);
85 + return null;
86 + }
87 + return { url, externalId: id, kind: 'listing', engine: 'api', httpStatus: res.httpStatus, payload, fetchedAt: res.fetchedAt };
88 + }
89 +
90 + async *crawl(ctx: CrawlContext): AsyncIterable<RawRecordInput> {
91 + const max = ctx.options.mode === 'backfill' ? Number(this.meta.config.backfillSlabs ?? 2000) : Number(this.meta.config.maxSlabsPerRun ?? 250);
92 + const files = ctx.options.mode === 'backfill' ? ['sitemap-slabs-1.xml', 'sitemap-slabs-2.xml'] : ['sitemap-slabs-1.xml'];
93 + const urls: Array<{ id: number; loc: string }> = [];
94 + for (const f of files) {
95 + const res = await ctx.fetch(`${SITE}/${f}`, { engines: ['api'], headers: { ...BOT_HEADERS, accept: 'application/xml,text/xml' }, responseType: 'text', timeoutMs: 120_000 });
96 + if (!res.success || !res.html) {
97 + ctx.anomaly('page_fetch_failed', `${f}: ${res.error ?? res.httpStatus}`);
98 + continue;
99 + }
100 + for (const e of parseSitemap(res.html)) {
101 + const id = Number(e.loc.match(/slab\/view\/(\d+)/)?.[1]);
102 + if (Number.isFinite(id)) urls.push({ id, loc: e.loc });
103 + }
104 + }
105 + if (ctx.options.seeds?.length) urls.push(...ctx.options.seeds.map((s) => ({ id: Number(s.match(/\d+/)?.[0] ?? 0), loc: s.startsWith('http') ? s : `${SITE}/slab/view/${s}/` })));
106 + urls.sort((a, b) => b.id - a.id);
107 + let count = 0;
108 + for (const u of urls.slice(0, max)) {
109 + if (ctx.signal?.aborted) return;
110 + if (this.reached(ctx, count)) return;
111 + if (!(await ctx.shouldFetch(u.loc))) continue;
112 + const rec = await this.fetchSlab(ctx, u.loc);
113 + if (!rec) continue;
114 + count++;
115 + yield rec;
116 + if (count % 25 === 0) await ctx.setCursor({ lastId: u.id, at: new Date().toISOString() });
117 + }
118 + await ctx.setCursor({ completedAt: new Date().toISOString(), newestId: urls[0]?.id ?? null });
119 + }
120 +
121 + async lookup(url: string, ctx: CrawlContext): Promise<RawRecordInput[]> {
122 + const rec = await this.fetchSlab(ctx, url);
123 + return rec ? [rec] : [];
124 + }
125 +
126 + async normalize(raw: RawRecordLike): Promise<NormalizedRecord[]> {
127 + const p = RawPayloadSchema.parse(raw.payload);
128 + const desc = (p.description ?? '').replace(/<[^>]+>/g, ' ').replace(/&[a-z]+;/g, ' ').slice(0, 400);
129 + const categorySlug = slabCategory(`${p.name} ${desc}`, p.category);
130 + if (!categorySlug) return [];
131 + // Slab pages carry the grade only when the seller typed it (title or description); never guessed.
132 + const g = parseGradeFromTitle(p.name).grader ? parseGradeFromTitle(p.name) : parseGradeFromTitle(desc);
133 + const cert = p.description?.match(/\b(?:cert|certification|serial)\s*(?:#|no\.?|number)?\s*[:\-]?\s*(\d{6,12})\b/i)?.[1] ?? null;
134 + const year = extractYear(p.name);
135 + const number = p.name.match(/#\s?([A-Za-z0-9-]+)/)?.[1] ?? null;
136 + const a = attrs({ categorySlug, name: p.name.replace(/\s*\b(PSA|BGS|CGC|SGC|TAG|CSG|CBCS)\b.*$/i, '').trim() || p.name, year, number, identifiers: { myslabs_id: p.id }, metadata: { seller: p.seller, category_label: p.category } });
137 + const availability = p.availability === 'OutOfStock' || p.availability === 'SoldOut' ? 'sold' : p.availability === 'InStock' || p.availability === 'PreOrder' ? 'available' : 'unknown';
138 + const currency = (p.currency ?? 'USD').toUpperCase();
139 + const listing = NormalizedListingSchema.parse({
140 + kind: 'listing',
141 + connectorId: this.meta.id,
142 + sourceId: this.meta.sourceId,
143 + sourceUrl: raw.url,
144 + externalId: p.id,
145 + rawTitle: p.name,
146 + description: p.description,
147 + imageUrls: p.images,
148 + attributes: a,
149 + grade: { grader: g.grader, grade: g.grade, qualifier: g.qualifier, certificationNumber: cert },
150 + condition: {},
151 + observedAt: raw.fetchedAt,
152 + confidence: 0.75,
153 + parserVersion: PARSER_VERSION,
154 + listingType: 'fixed_price',
155 + price: p.price,
156 + currency: p.price === null ? null : currency,
157 + seller: p.seller,
158 + availability,
159 + });
160 + return [listing];
161 + }
162 +}
163 +
164 +export default (meta: ConnectorMeta) => new MySlabsConnector(meta);
added connectors/api/myslabs/meta.json +33 −0
@@ -0,0 +1,33 @@
1 +{
2 + "id": "myslabs",
3 + "displayName": "MySlabs (graded card & comic marketplace)",
4 + "sourceId": "myslabs",
5 + "sourceName": "MySlabs",
6 + "sourceType": "marketplace",
7 + "sourceUrl": "https://myslabs.com",
8 + "module": "api/myslabs",
9 + "enginePriority": ["api"],
10 + "categories": ["sports_cards", "baseball_cards", "basketball_cards", "football_cards", "hockey_cards", "soccer_cards", "other_sports_cards", "pokemon", "magic_the_gathering", "yugioh", "non_sport_cards", "comics", "marvel_comics", "dc_comics", "independent_comics"],
11 + "regions": ["US"],
12 + "languages": ["en"],
13 + "currency": ["USD"],
14 + "supportsListings": true,
15 + "supportsSold": false,
16 + "supportsAuctions": false,
17 + "supportsImages": true,
18 + "supportsCatalog": false,
19 + "supportsPopulation": false,
20 + "supportsLookup": true,
21 + "refreshFrequencyMinutes": 720,
22 + "priority": "medium",
23 + "trustScore": 0.7,
24 + "attributionRequired": true,
25 + "termsUrl": "https://myslabs.com/static/terms",
26 + "accessNotes": "Public fixed-price marketplace for graded slabs. robots.txt only disallows /account/, /api/, /admin/, /ajax_select/. Discovery uses the public sitemaps (sitemap-slabs-*.xml, newest ids first); slab pages are plain HTML with a schema.org Product JSON-LD (name, images, offers.price USD, availability InStock/OutOfStock). OutOfStock slabs are stored as listings with availability 'sold' at their last asking price — never as confirmed sales. Politeness 1.5 s between pages; ~250 slabs per incremental run (`maxSlabsPerRun`).",
27 + "enabled": true,
28 + "schemaVersion": "1.0",
29 + "config": {
30 + "maxSlabsPerRun": 250,
31 + "backfillSlabs": 2000
32 + }
33 +}
added connectors/firecrawl/alt-xyz/index.test.ts +94 −0
@@ -0,0 +1,94 @@
1 +import { describe, expect, it } from 'vitest';
2 +import meta from './meta.json' with { type: 'json' };
3 +import { localMeta } from '../../api/_lib/local-meta.js';
4 +import { listFixtures, loadFixture, runFixtureSuite } from '@rareindex/connectors/testing';
5 +import createConnector, { parseItemMarkdown } from './index.js';
6 +
7 +const connector = createConnector(localMeta(meta));
8 +
9 +const SAMPLE = `Back
10 +
11 +# 2023 Bowman Chrome Prospect Autograph Purple Refractor Ludwing Espinoza \\#CPALE
12 +
13 +## Baseball Cards
14 +
15 +Serial207/250
16 +
17 +PSA10
18 +
19 +Pop24
20 +
21 +## List price
22 +
23 +### $45
24 +
25 +Buy nowOffer
26 +
27 +## LT Value
28 +
29 +$18
30 +
31 +### $12 - $29
32 +
33 +PSA population
34 +
35 +### Recent transactions
36 +
37 +[![eBay](https://x/e.png)\\
38 +\\
39 +AuctionAug 6, 2026\\
40 +\\
41 +$15](https://www.ebay.com/itm/278232527458)
42 +
43 +[![eBay](https://x/e.png)\\
44 +\\
45 +Best offerSep 3, 2024\\
46 +\\
47 +$20](https://www.ebay.com/itm/405178094963)
48 +
49 +View all
50 +
51 +Listings
52 +
53 +[![eBay](https://x/e.png)\\
54 +\\
55 +Fixed price\\
56 +\\
57 +$60\\
58 +\\
59 +live listing in eBay](https://www.ebay.com/itm/176907976889)
60 +`;
61 +
62 +describe('alt-xyz', () => {
63 + runFixtureSuite(connector, it, expect);
64 +
65 + it('parses item markdown', () => {
66 + const p = parseItemMarkdown(SAMPLE, 'abc')!;
67 + expect(p.title).toContain('Ludwing Espinoza #CPALE');
68 + expect(p.category).toBe('Baseball Cards');
69 + expect(p).toMatchObject({ grader: 'psa', grade: '10', pop: 24, listPrice: 45, listingKind: 'fixed_price', altValue: 18, altLow: 12, altHigh: 29, serial: '207/250' });
70 + expect(p.transactions).toEqual([
71 + { type: 'Auction', date: 'Aug 6, 2026', price: 15, url: 'https://www.ebay.com/itm/278232527458' },
72 + { type: 'Best offer', date: 'Sep 3, 2024', price: 20, url: 'https://www.ebay.com/itm/405178094963' },
73 + ]);
74 + });
75 +
76 + it('normalises into observation + sales + listing', async () => {
77 + const raw = { url: 'https://alt.xyz/itm/abc', externalId: 'abc', kind: 'sale' as const, engine: 'firecrawl' as const, fetchedAt: new Date('2026-09-07T12:00:00Z'), payload: parseItemMarkdown(SAMPLE, 'abc') };
78 + const out = await connector.normalize(raw);
79 + const kinds = out.map((r) => r.kind);
80 + expect(kinds.filter((k) => k === 'sale')).toHaveLength(2);
81 + expect(kinds).toContain('price_observation');
82 + expect(kinds).toContain('listing');
83 + const sale = out.find((r) => r.kind === 'sale');
84 + if (sale && sale.kind === 'sale') {
85 + expect(sale.saleDate.toISOString()).toBe('2026-08-06T00:00:00.000Z');
86 + expect(sale.attributes.categorySlug).toBe('baseball_cards');
87 + expect(sale.grade.grader).toBe('psa');
88 + }
89 + for (const name of listFixtures('alt-xyz')) {
90 + const fx = await connector.normalize(loadFixture('alt-xyz', name).raw);
91 + expect(fx.length).toBeGreaterThan(0);
92 + }
93 + });
94 +});
added connectors/firecrawl/alt-xyz/index.ts +168 −0
@@ -0,0 +1,168 @@
1 +import { z } from 'zod';
2 +import { BaseConnector, type ConnectorMeta, type CrawlContext, type RawRecordInput, type RawRecordLike } from '@rareindex/connectors';
3 +import { NormalizedListingSchema, NormalizedSaleSchema, extractYear, type NormalizedRecord } from '@rareindex/shared';
4 +import { attrs, priceObservation } from '../../api/_lib/shared.js';
5 +import { BOT_HEADERS, cardCategorySlug, dayOf, fetchMaybeGzip, money, parseMonthDay, parseSitemap } from '../../api/_lib/wave4.js';
6 +
7 +/** Alt item pages → Alt Value observations, recent transactions (sales) and Alt list-price listings. */
8 +const PARSER_VERSION = '1.0.0';
9 +const SITE = 'https://alt.xyz';
10 +
11 +const TxSchema = z.object({ type: z.string(), date: z.string(), price: z.number(), url: z.string().nullable() });
12 +const RawPayloadSchema = z.object({
13 + id: z.string(),
14 + title: z.string(),
15 + category: z.string().nullable(),
16 + serial: z.string().nullable(),
17 + grader: z.string().nullable(),
18 + grade: z.string().nullable(),
19 + pop: z.number().nullable(),
20 + listPrice: z.number().nullable(),
21 + listingKind: z.enum(['fixed_price', 'auction']).nullable().default(null),
22 + altValue: z.number().nullable(),
23 + altLow: z.number().nullable(),
24 + altHigh: z.number().nullable(),
25 + transactions: z.array(TxSchema),
26 + images: z.array(z.string()),
27 +});
28 +export type AltPayload = z.infer<typeof RawPayloadSchema>;
29 +
30 +/** Parse the Firecrawl markdown of an item page. Exported for tests. */
31 +export function parseItemMarkdown(md: string, id: string): AltPayload | null {
32 + const text = md.replace(/\\\n/g, '\n').replace(/\\([#$])/g, '$1');
33 + const title = text.match(/^#\s+(.+)$/m)?.[1]?.trim();
34 + if (!title) return null;
35 + const afterTitle = text.slice(text.indexOf(title) + title.length);
36 + const category = afterTitle.match(/^##\s+([A-Za-z' \-]+Cards?)\s*$/m)?.[1]?.trim() ?? afterTitle.match(/^##\s+(Pok[eé]mon|Magic[^\n]*|Yu-Gi-Oh!?)\s*$/m)?.[1]?.trim() ?? null;
37 + const serial = afterTitle.match(/Serial\s*(\d+\s*\/\s*\d+)/)?.[1]?.replace(/\s/g, '') ?? null;
38 + const gm = afterTitle.match(/^(PSA|BGS|SGC|CGC|CSG|TAG|HGA)\s?(\d{1,2}(?:\.\d)?)\s*$/m);
39 + const pop = afterTitle.match(/^Pop\s*([\d,]+)\s*$/m)?.[1];
40 + const fixed = afterTitle.match(/##\s+List price\s*\n+\s*###\s+\$([\d,]+(?:\.\d+)?)/)?.[1] ?? null;
41 + const bid = afterTitle.match(/(?:Starting bid|Current bid|High bid)\s*\$([\d,]+(?:\.\d+)?)/)?.[1] ?? null;
42 + const listPrice = fixed ?? bid;
43 + const listingKind: 'fixed_price' | 'auction' | null = fixed ? 'fixed_price' : bid ? 'auction' : null;
44 + const av = afterTitle.match(/##\s+(?:LT|Alt) Value\s*\n+\s*\$([\d,]+(?:\.\d+)?)\s*\n+\s*###\s+\$([\d,]+(?:\.\d+)?)\s*-\s*\$([\d,]+(?:\.\d+)?)/);
45 + const txStart = afterTitle.search(/Recent transactions/i);
46 + const txEnd = afterTitle.search(/\n##?\s+(Listings|Similar listings)|\nListings\s*\n/);
47 + const txBlock = txStart >= 0 ? afterTitle.slice(txStart, txEnd > txStart ? txEnd : undefined) : '';
48 + const transactions: z.infer<typeof TxSchema>[] = [];
49 + const re = /(Auction|Best offer|Fixed price|Buy now|Sale)\s*([A-Z][a-z]{2,8}\.? \d{1,2}, \d{4})\s*\n+\s*\$([\d,]+(?:\.\d+)?)\]\((https?:[^)\s]+)\)/g;
50 + let m: RegExpExecArray | null;
51 + while ((m = re.exec(txBlock))) {
52 + const price = money(m[3]);
53 + if (!price) continue;
54 + transactions.push({ type: m[1]!, date: m[2]!, price, url: m[4] ?? null });
55 + }
56 + const images = [...text.matchAll(/!\[[^\]]*\]\((https:\/\/alt-images\.b-cdn\.net\/public\/[^)\s]+width=324[^)\s]*)\)/g)].map((x) => x[1]!);
57 + return {
58 + id,
59 + title,
60 + category,
61 + serial,
62 + grader: gm ? gm[1]!.toLowerCase() : null,
63 + grade: gm ? gm[2]! : null,
64 + pop: pop ? Number(pop.replace(/,/g, '')) : null,
65 + listPrice: money(listPrice),
66 + listingKind,
67 + altValue: av ? money(av[1]) : null,
68 + altLow: av ? money(av[2]) : null,
69 + altHigh: av ? money(av[3]) : null,
70 + transactions,
71 + images: [...new Set(images)],
72 + };
73 +}
74 +
75 +async function fetchGzText(url: string, signal?: AbortSignal): Promise<string> {
76 + return (await fetchMaybeGzip(url, signal)).toString('utf8');
77 +}
78 +
79 +export class AltConnector extends BaseConnector {
80 + readonly version = '1.0.0';
81 + readonly parserVersion = PARSER_VERSION;
82 + override readonly urlPatterns = [/^https?:\/\/(www\.)?alt\.xyz\/itm\/[0-9a-f-]{36}/i];
83 + protected override minIntervalMs = 1500;
84 +
85 + private async fetchItem(ctx: CrawlContext, url: string): Promise<RawRecordInput | null> {
86 + await this.throttle();
87 + const res = await ctx.fetch(url, { engines: ['firecrawl'], waitForMs: 9000, timeoutMs: 90_000, expect: ['title', 'price'], parse: (r) => ({ title: r.markdown?.match(/^#\s+.+$/m) ? 'ok' : null, price: r.markdown && /\$\d/.test(r.markdown) ? 1 : null }) });
88 + if (!res.success || !res.markdown) {
89 + ctx.anomaly('page_fetch_failed', `${url}: ${res.error ?? res.httpStatus}`);
90 + return null;
91 + }
92 + const id = url.match(/itm\/([0-9a-f-]{36})/i)?.[1] ?? url;
93 + const payload = parseItemMarkdown(res.markdown, id);
94 + if (!payload) {
95 + ctx.anomaly('parse_failure_item', url);
96 + return null;
97 + }
98 + return { url: `${SITE}/itm/${id}`, externalId: id, kind: 'sale', engine: 'firecrawl', httpStatus: res.httpStatus, payload, fetchedAt: res.fetchedAt };
99 + }
100 +
101 + async *crawl(ctx: CrawlContext): AsyncIterable<RawRecordInput> {
102 + const max = ctx.options.mode === 'backfill' ? Number(this.meta.config.maxItemsPerRun ?? 100) * 5 : Number(this.meta.config.maxItemsPerRun ?? 100);
103 + let urls: Array<{ loc: string; lastmod: string }> = [];
104 + if (ctx.options.seeds?.length) urls = ctx.options.seeds.map((s) => ({ loc: s.startsWith('http') ? s : `${SITE}/itm/${s}`, lastmod: '' }));
105 + else {
106 + const idx = await ctx.fetch(`${SITE}/sitemap.xml`, { engines: ['api'], headers: { ...BOT_HEADERS, accept: 'application/xml' }, responseType: 'text' });
107 + if (!idx.success || !idx.html) throw new Error(`alt sitemap index failed: ${idx.error ?? idx.httpStatus}`);
108 + const wanted = (this.meta.config.sitemaps as string[] | undefined) ?? ['fixed-price', 'auctions'];
109 + const children = [...idx.html.matchAll(/<loc>\s*([^<\s]+)\s*<\/loc>/g)].map((x) => x[1]!).filter((u) => wanted.some((w) => u.includes(`/${w}-`)));
110 + for (const child of children.slice(0, 6)) {
111 + try {
112 + const xml = await fetchGzText(child, ctx.signal);
113 + for (const e of parseSitemap(xml)) urls.push({ loc: e.loc, lastmod: e.lastmod ?? '' });
114 + } catch (err) {
115 + ctx.anomaly('page_fetch_failed', `${child}: ${err instanceof Error ? err.message : String(err)}`);
116 + }
117 + }
118 + const since = String(ctx.options.cursor?.since ?? '');
119 + urls = urls.filter((u) => !since || u.lastmod > since).sort((a, b) => b.lastmod.localeCompare(a.lastmod));
120 + }
121 + let count = 0;
122 + let newest = String(ctx.options.cursor?.since ?? '');
123 + for (const u of urls.slice(0, max)) {
124 + if (ctx.signal?.aborted) return;
125 + if (this.reached(ctx, count)) break;
126 + const rec = await this.fetchItem(ctx, u.loc);
127 + if (!rec) continue;
128 + count++;
129 + if (u.lastmod > newest) newest = u.lastmod;
130 + yield rec;
131 + if (count % 20 === 0) await ctx.setCursor({ since: newest, at: new Date().toISOString() });
132 + }
133 + await ctx.setCursor({ since: newest, completedAt: new Date().toISOString() });
134 + }
135 +
136 + async lookup(url: string, ctx: CrawlContext): Promise<RawRecordInput[]> {
137 + const rec = await this.fetchItem(ctx, url);
138 + return rec ? [rec] : [];
139 + }
140 +
141 + async normalize(raw: RawRecordLike): Promise<NormalizedRecord[]> {
142 + const p = RawPayloadSchema.parse(raw.payload);
143 + const categorySlug = cardCategorySlug(p.category) ?? cardCategorySlug(p.title);
144 + if (!categorySlug) return [];
145 + const year = extractYear(p.title);
146 + const number = p.title.match(/#\s?([A-Za-z0-9-]+)/)?.[1] ?? null;
147 + const name = p.title.replace(/^\d{4}(?:-\d{2})?\s+/, '').replace(/\s*#\S+\s*$/, '').trim() || p.title;
148 + const a = attrs({ categorySlug, name, year, number, identifiers: { alt_item_id: p.id }, metadata: { serial: p.serial, pop: p.pop } });
149 + const grade = { grader: p.grader, grade: p.grade, qualifier: null, certificationNumber: null };
150 + const base = { connectorId: this.meta.id, sourceId: this.meta.sourceId, sourceUrl: raw.url, rawTitle: p.title, imageUrls: p.images, attributes: a, grade, condition: {}, observedAt: raw.fetchedAt, parserVersion: PARSER_VERSION };
151 + const out: NormalizedRecord[] = [];
152 + if (p.altValue) {
153 + out.push(priceObservation({ kind: 'price_observation', ...base, externalId: `${p.id}:altvalue:${dayOf(raw.fetchedAt).toISOString().slice(0, 10)}`, confidence: 0.6, priceKind: 'guide_value', price: p.altValue, currency: 'USD', observationDate: dayOf(raw.fetchedAt), sampleSize: p.transactions.length || null, attributes: { ...a, metadata: { ...a.metadata, low: p.altLow, high: p.altHigh, model: 'Alt Value' } } }));
154 + }
155 + for (const t of p.transactions) {
156 + const saleDate = parseMonthDay(t.date);
157 + if (!saleDate) continue;
158 + const saleType = /auction/i.test(t.type) ? 'auction' : /best offer/i.test(t.type) ? 'best_offer' : 'fixed_price';
159 + out.push(NormalizedSaleSchema.parse({ kind: 'sale', ...base, externalId: `${p.id}:${saleDate.toISOString().slice(0, 10)}:${t.price}`, confidence: 0.65, saleType, saleDate, price: t.price, currency: 'USD', buyerPremiumIncluded: null, quantity: 1, isBundle: false, location: null, auctionHouse: t.url?.includes('ebay.') ? 'eBay (via Alt)' : null, lotNumber: null, attributes: { ...a, metadata: { ...a.metadata, external_url: t.url, via: 'alt.xyz' } } }));
160 + }
161 + if (p.listPrice) {
162 + out.push(NormalizedListingSchema.parse({ kind: 'listing', ...base, externalId: p.id, confidence: 0.75, listingType: p.listingKind ?? 'fixed_price', price: p.listPrice, currency: 'USD', seller: 'Alt marketplace', availability: 'available' }));
163 + }
164 + return out;
165 + }
166 +}
167 +
168 +export default (meta: ConnectorMeta) => new AltConnector(meta);
added connectors/firecrawl/alt-xyz/meta.json +33 −0
@@ -0,0 +1,33 @@
1 +{
2 + "id": "alt-xyz",
3 + "displayName": "Alt (graded card values & transactions)",
4 + "sourceId": "alt",
5 + "sourceName": "Alt",
6 + "sourceType": "analytics_provider",
7 + "sourceUrl": "https://alt.xyz",
8 + "module": "firecrawl/alt-xyz",
9 + "enginePriority": ["firecrawl"],
10 + "categories": ["sports_cards", "baseball_cards", "basketball_cards", "football_cards", "hockey_cards", "soccer_cards", "other_sports_cards", "pokemon", "magic_the_gathering", "yugioh", "non_sport_cards"],
11 + "regions": ["US"],
12 + "languages": ["en"],
13 + "currency": ["USD"],
14 + "supportsListings": true,
15 + "supportsSold": true,
16 + "supportsAuctions": false,
17 + "supportsImages": true,
18 + "supportsCatalog": false,
19 + "supportsPopulation": false,
20 + "supportsLookup": true,
21 + "refreshFrequencyMinutes": 1440,
22 + "priority": "low",
23 + "trustScore": 0.7,
24 + "attributionRequired": true,
25 + "termsUrl": "https://alt.xyz/terms",
26 + "accessNotes": "Alt's public item pages (robots.txt allows everything; discovery through the public sitemaps with lastmod) show the card, grade, PSA/BGS population, Alt's list price, the 'Alt Value' model estimate with its range, and a 'Recent transactions' list of observed sales (mostly eBay auctions/best offers) with date and price. Pages are client-rendered → Firecrawl, 1 credit per item, capped by `maxItemsPerRun`. Alt Value is stored as a guide_value observation (confidence 0.6); recent transactions become sales attributed to Alt with the original listing URL in metadata (confidence 0.65 — second-hand aggregation); the Alt list price is a fixed-price listing.",
27 + "enabled": true,
28 + "schemaVersion": "1.0",
29 + "config": {
30 + "sitemaps": ["fixed-price", "auctions"],
31 + "maxItemsPerRun": 100
32 + }
33 +}
added connectors/firecrawl/comc/index.test.ts +49 −0
@@ -0,0 +1,49 @@
1 +import { describe, expect, it } from 'vitest';
2 +import meta from './meta.json' with { type: 'json' };
3 +import { localMeta } from '../../api/_lib/local-meta.js';
4 +import { listFixtures, loadFixture, runFixtureSuite } from '@rareindex/connectors/testing';
5 +import createConnector, { parseBrowseMarkdown, parseComcTitle, parseSetLine } from './index.js';
6 +
7 +const connector = createConnector(localMeta(meta));
8 +
9 +const SAMPLE = `Listings **1** \\- **100** of **1,292**
10 +
11 +[![Steve Johnson [CSG 10 Gem Mint]](https://img.comc.com/i/Basketball/1986-87/Fleer---Base/55/Steve-Johnson.jpg?id=fc68&size=biggerthumb)](https://www.comc.com/Cards/Basketball/1986-87/Fleer_-_Base/55/Steve_Johnson/1395657/Graded/CSG/10_Gem)
12 +
13 +1986-87 Fleer - \\[Base\\] #55
14 +
15 +
16 +### [Steve Johnson \\[CSG 10 Gem Mint\\]](https://www.comc.com/Cards/Basketball/1986-87/Fleer_-_Base/55/Steve_Johnson/1395657/Graded/CSG/10_Gem)
17 +
18 +$1,791.76
19 +`;
20 +
21 +describe('comc', () => {
22 + runFixtureSuite(connector, it, expect);
23 +
24 + it('parses browse rows, titles and set lines', () => {
25 + const rows = parseBrowseMarkdown(SAMPLE);
26 + expect(rows).toHaveLength(1);
27 + expect(rows[0]).toMatchObject({ price: 1791.76, setLine: '1986-87 Fleer - [Base] #55' });
28 + expect(parseComcTitle('Steve Johnson [CSG 10 Gem Mint]')).toMatchObject({ name: 'Steve Johnson', grader: 'csg', grade: '10' });
29 + expect(parseComcTitle('Michael Jordan [PSA 9 MINT]')).toMatchObject({ grader: 'psa', grade: '9' });
30 + expect(parseComcTitle('Larry Bird')).toMatchObject({ grader: null, grade: null });
31 + expect(parseSetLine('1986-87 Fleer - [Base] #57')).toEqual({ year: 1986, set: '1986 Fleer', variant: null, number: '57' });
32 + expect(parseSetLine('2018-19 Panini Prizm - [Base] - Silver Prizm #280')).toMatchObject({ year: 2018, number: '280' });
33 + });
34 +
35 + it('emits USD listings with card attributes', async () => {
36 + for (const name of listFixtures('comc')) {
37 + const out = await connector.normalize(loadFixture('comc', name).raw);
38 + expect(out.length).toBeGreaterThan(0);
39 + for (const r of out) {
40 + expect(r.kind).toBe('listing');
41 + if (r.kind === 'listing') {
42 + expect(r.currency).toBe('USD');
43 + expect(r.price).toBeGreaterThan(0);
44 + expect(r.sourceUrl).toMatch(/comc\.com\/Cards\//);
45 + }
46 + }
47 + }
48 + });
49 +});
added connectors/firecrawl/comc/index.ts +149 −0
@@ -0,0 +1,149 @@
1 +import { z } from 'zod';
2 +import { BaseConnector, type ConnectorMeta, type CrawlContext, type RawRecordInput, type RawRecordLike } from '@rareindex/connectors';
3 +import { NormalizedListingSchema, type NormalizedRecord } from '@rareindex/shared';
4 +import { attrs } from '../../api/_lib/shared.js';
5 +import { cardCategorySlug, money } from '../../api/_lib/wave4.js';
6 +
7 +/** COMC category browse pages (Firecrawl markdown) → card listings with grades and asking prices. */
8 +const PARSER_VERSION = '1.0.0';
9 +const SITE = 'https://www.comc.com';
10 +
11 +const ListingSchema = z.object({ title: z.string(), url: z.string(), image: z.string().nullable(), setLine: z.string().nullable(), price: z.number() });
12 +const RawPayloadSchema = z.object({ seed: z.string(), page: z.number(), listings: z.array(ListingSchema) });
13 +export type ComcPayload = z.infer<typeof RawPayloadSchema>;
14 +
15 +/** Parse a rendered browse page. Exported for tests. */
16 +export function parseBrowseMarkdown(md: string): z.infer<typeof ListingSchema>[] {
17 + const lines = md.split('\n').map((l) => l.trim());
18 + const out: z.infer<typeof ListingSchema>[] = [];
19 + let setLine: string | null = null;
20 + let image: string | null = null;
21 + for (let i = 0; i < lines.length; i++) {
22 + const l = lines[i]!;
23 + const img = l.match(/^\[!\[[^\]]*\]\((https?:\/\/img\.comc\.com[^)\s]+)\)\]\(https?:\/\/www\.comc\.com\/Cards\/[^)]+\)$/);
24 + if (img) {
25 + image = img[1]!;
26 + continue;
27 + }
28 + if (/^\d{4}(?:-\d{2})?\s.+#\S+$/.test(l.replace(/\\/g, ''))) {
29 + setLine = l.replace(/\\/g, '');
30 + continue;
31 + }
32 + const h = l.match(/^###\s+\[(.+?)\]\((https?:\/\/www\.comc\.com\/Cards\/[^)\s]+)\)\s*$/);
33 + if (!h) continue;
34 + let price: number | null = null;
35 + for (let j = i + 1; j < Math.min(lines.length, i + 5); j++) {
36 + const m = lines[j]!.match(/^\$([\d,]+\.\d{2})$/);
37 + if (m) {
38 + price = money(m[1]);
39 + break;
40 + }
41 + if (lines[j]!.startsWith('###')) break;
42 + }
43 + if (price === null) continue;
44 + out.push({ title: h[1]!.replace(/\\/g, ''), url: h[2]!, image, setLine, price });
45 + image = null;
46 + }
47 + return out;
48 +}
49 +
50 +const GRADE_RE = /\[(PSA|BGS|CSG|CGC|SGC|TAG|HGA|ACE|BVG|GMA|ISA|KSA|MNT|BCCG|PSA\/DNA|BAS|JSA)\s*(\d{1,2}(?:\.\d)?)?\s*([^\]]*)\]/i;
51 +
52 +export function parseComcTitle(title: string): { name: string; grader: string | null; grade: string | null; qualifier: string | null; auto: boolean } {
53 + const m = title.match(GRADE_RE);
54 + const name = title.replace(/\s*\[[^\]]*\]\s*/g, ' ').replace(/\s+/g, ' ').trim();
55 + if (!m) return { name, grader: null, grade: null, qualifier: null, auto: /\bauto/i.test(title) };
56 + const grader = m[1]!.toLowerCase().replace('/', '_');
57 + const grade = m[2] ?? null;
58 + const qualifier = m[3]?.trim() || null;
59 + const isAuth = /dna|bas|jsa/.test(grader);
60 + return { name, grader: isAuth && !grade ? null : grader, grade, qualifier: isAuth ? `${m[1]} ${qualifier ?? ''}`.trim() : qualifier, auto: /\bauto/i.test(title) };
61 +}
62 +
63 +export function parseSetLine(setLine: string | null): { year: number | null; set: string | null; variant: string | null; number: string | null } {
64 + if (!setLine) return { year: null, set: null, variant: null, number: null };
65 + const m = setLine.match(/^(\d{4})(?:-\d{2})?\s+(.+?)(?:\s+-\s+\[(.+?)\])?\s+#(\S+)$/);
66 + if (!m) return { year: null, set: setLine, variant: null, number: null };
67 + const year = Number(m[1]);
68 + const variant = m[3] && !/^base$/i.test(m[3]) ? m[3] : null;
69 + return { year, set: `${m[1]} ${m[2]}`.trim(), variant, number: m[4] ?? null };
70 +}
71 +
72 +function seedCategory(seed: string): string | null {
73 + const sport = seed.split('/')[1]?.replace(/_/g, ' ') ?? '';
74 + return cardCategorySlug(sport) ?? (/^racing$/i.test(sport) ? 'other_sports_cards' : null);
75 +}
76 +
77 +export class ComcConnector extends BaseConnector {
78 + readonly version = '1.0.0';
79 + readonly parserVersion = PARSER_VERSION;
80 + protected override minIntervalMs = 2000;
81 +
82 + async *crawl(ctx: CrawlContext): AsyncIterable<RawRecordInput> {
83 + const seeds = ctx.options.seeds?.length ? ctx.options.seeds : ((this.meta.config.seeds as string[] | undefined) ?? []);
84 + const pages = ctx.options.mode === 'backfill' ? 10 : Number(this.meta.config.pagesPerSeed ?? 2);
85 + let count = 0;
86 + for (const seed of seeds) {
87 + for (let page = 1; page <= pages; page++) {
88 + if (ctx.signal?.aborted) return;
89 + if (this.reached(ctx, count)) return;
90 + const url = `${SITE}/${seed.replace(/^\/+/, '')},sh${page > 1 ? `,p${page}` : ''}`;
91 + await this.throttle();
92 + const res = await ctx.fetch(url, { engines: ['firecrawl'], waitForMs: 3000, timeoutMs: 90_000, expect: ['title', 'price'], parse: (r) => ({ title: r.markdown?.includes('Listings') ? 'ok' : null, price: r.markdown && /\$\d/.test(r.markdown) ? 1 : null }) });
93 + if (!res.success || !res.markdown) {
94 + ctx.anomaly('page_fetch_failed', `${url}: ${res.error ?? res.httpStatus}`);
95 + break;
96 + }
97 + const listings = parseBrowseMarkdown(res.markdown);
98 + if (!listings.length) {
99 + ctx.anomaly('parse_failure_page', url);
100 + break;
101 + }
102 + count++;
103 + const payload: ComcPayload = { seed, page, listings };
104 + yield { url, externalId: `${seed}:p${page}`, kind: 'listing', engine: 'firecrawl', httpStatus: res.httpStatus, payload, fetchedAt: res.fetchedAt };
105 + const total = res.markdown.match(/of\s+\*\*([\d,]+)\*\*/)?.[1];
106 + if (total && Number(total.replace(/,/g, '')) <= page * 100) break;
107 + }
108 + await ctx.setCursor({ seed, at: new Date().toISOString() });
109 + }
110 + }
111 +
112 + async normalize(raw: RawRecordLike): Promise<NormalizedRecord[]> {
113 + const p = RawPayloadSchema.parse(raw.payload);
114 + const categorySlug = seedCategory(p.seed);
115 + if (!categorySlug) return [];
116 + const out: NormalizedRecord[] = [];
117 + for (const l of p.listings) {
118 + const t = parseComcTitle(l.title);
119 + const s = parseSetLine(l.setLine);
120 + const externalId = l.url.replace(`${SITE}/Cards/`, '').replace(/\/+$/, '');
121 + const a = attrs({ categorySlug, name: t.name, set: s.set, year: s.year, number: s.number, variant: s.variant, identifiers: { comc_path: externalId }, metadata: { autograph: t.auto } });
122 + out.push(
123 + NormalizedListingSchema.parse({
124 + kind: 'listing',
125 + connectorId: this.meta.id,
126 + sourceId: this.meta.sourceId,
127 + sourceUrl: l.url,
128 + externalId,
129 + rawTitle: l.setLine ? `${l.setLine} ${l.title}` : l.title,
130 + imageUrls: l.image ? [l.image.replace(/size=biggerthumb/, 'size=large')] : [],
131 + attributes: a,
132 + grade: { grader: t.grader, grade: t.grade, qualifier: t.qualifier },
133 + condition: {},
134 + observedAt: raw.fetchedAt,
135 + confidence: 0.8,
136 + parserVersion: PARSER_VERSION,
137 + listingType: 'fixed_price',
138 + price: l.price,
139 + currency: 'USD',
140 + seller: 'COMC consignment',
141 + availability: 'available',
142 + }),
143 + );
144 + }
145 + return out;
146 + }
147 +}
148 +
149 +export default (meta: ConnectorMeta) => new ComcConnector(meta);
added connectors/firecrawl/comc/meta.json +48 −0
@@ -0,0 +1,48 @@
1 +{
2 + "id": "comc",
3 + "displayName": "COMC (Check Out My Cards)",
4 + "sourceId": "comc",
5 + "sourceName": "COMC",
6 + "sourceType": "marketplace",
7 + "sourceUrl": "https://www.comc.com",
8 + "module": "firecrawl/comc",
9 + "enginePriority": ["firecrawl"],
10 + "categories": ["sports_cards", "baseball_cards", "basketball_cards", "football_cards", "hockey_cards", "soccer_cards", "other_sports_cards", "pokemon", "magic_the_gathering", "yugioh", "non_sport_cards"],
11 + "regions": ["US"],
12 + "languages": ["en"],
13 + "currency": ["USD"],
14 + "supportsListings": true,
15 + "supportsSold": false,
16 + "supportsAuctions": false,
17 + "supportsImages": true,
18 + "supportsCatalog": false,
19 + "supportsPopulation": false,
20 + "supportsLookup": false,
21 + "refreshFrequencyMinutes": 1440,
22 + "priority": "medium",
23 + "trustScore": 0.75,
24 + "attributionRequired": true,
25 + "termsUrl": "https://www.comc.com/Legal",
26 + "accessNotes": "COMC's category browse pages (/Cards/<Sport>/<Year>/<Set>) are public and allowed by robots.txt (only /Search/, /ItemDetails/, /Item/ and account paths are disallowed — item detail pages are therefore never fetched, only linked). Plain HTTP returns 403 to non-browser agents; pages are rendered through Firecrawl (1 credit per 100-listing page). Each listing row gives set/year/number, player, the grade in brackets ([PSA 10 GEM MT], [CSG 10 Gem Mint]) and the asking price (USD); sold-out items are excluded by default. Asking prices are listings, never sales. Seeds and pages per seed are configurable.",
27 + "enabled": true,
28 + "schemaVersion": "1.0",
29 + "config": {
30 + "seeds": [
31 + "Cards/Basketball/1986/Fleer",
32 + "Cards/Basketball/2003/Topps_Chrome",
33 + "Cards/Basketball/2018/Panini_Prizm",
34 + "Cards/Baseball/1952/Topps",
35 + "Cards/Baseball/1989/Upper_Deck",
36 + "Cards/Baseball/2011/Topps_Update",
37 + "Cards/Football/2000/Playoff_Contenders",
38 + "Cards/Football/2017/Panini_Prizm",
39 + "Cards/Hockey/1979/O-Pee-Chee",
40 + "Cards/Hockey/2005/Upper_Deck",
41 + "Cards/Soccer/2018/Panini_Prizm_World_Cup",
42 + "Cards/Pokemon/1999/Base_Set",
43 + "Cards/Magic_The_Gathering",
44 + "Cards/Yu-Gi-Oh"
45 + ],
46 + "pagesPerSeed": 2
47 + }
48 +}
added connectors/firecrawl/tag-pop/index.test.ts +43 −0
@@ -0,0 +1,43 @@
1 +import { describe, expect, it } from 'vitest';
2 +import meta from './meta.json' with { type: 'json' };
3 +import { localMeta } from '../../api/_lib/local-meta.js';
4 +import { listFixtures, loadFixture, runFixtureSuite } from '@rareindex/connectors/testing';
5 +import createConnector, { parseSetMarkdown } from './index.js';
6 +
7 +const connector = createConnector(localMeta(meta));
8 +
9 +const SAMPLE = `## 1999 WOTC Pokémon Base Set
10 +
11 +| Card # | NameGrade | VA | 1 | 9 | 10 | 10P | Total |
12 +| --- | --- | --- | --- | --- | --- | --- | --- |
13 +| 1/102 | [Alakazam](https://my.taggrading.com/pop-report/Pokemon/1999/WOTC%20Pok%C3%A9mon/Alakazam/1%2F102?setName=Base+Set&variation=Holo) <br>Holo | [2](x) | [2](x) | [27](x) | [5](x) | 0 | [36](x) |
14 +| 4/102 | [Charizard](https://my.taggrading.com/x) <br>Holo | 0 | 1 | 40 | 12 | 1 | 54 |
15 +`;
16 +
17 +describe('tag-pop', () => {
18 + runFixtureSuite(connector, it, expect);
19 +
20 + it('parses the population table', () => {
21 + const { grades, rows } = parseSetMarkdown(SAMPLE);
22 + expect(grades).toEqual(['authentic', '1', '9', '10', '10P']);
23 + expect(rows).toHaveLength(2);
24 + expect(rows[0]).toMatchObject({ number: '1/102', name: 'Alakazam', variation: 'Holo', total: 36 });
25 + expect(rows[0]!.counts).toEqual({ authentic: 2, '1': 2, '9': 27, '10': 5 });
26 + expect(rows[1]!.counts['10P']).toBe(1);
27 + });
28 +
29 + it('emits one population report per card row with grader tag', async () => {
30 + for (const name of listFixtures('tag-pop')) {
31 + const out = await connector.normalize(loadFixture('tag-pop', name).raw);
32 + expect(out.length).toBeGreaterThan(0);
33 + for (const r of out) {
34 + expect(r.kind).toBe('population_report');
35 + if (r.kind === 'population_report') {
36 + expect(r.grader).toBe('tag');
37 + expect(r.total).toBeGreaterThan(0);
38 + expect(r.attributes.set).toBeTruthy();
39 + }
40 + }
41 + }
42 + });
43 +});
added connectors/firecrawl/tag-pop/index.ts +148 −0
@@ -0,0 +1,148 @@
1 +import { z } from 'zod';
2 +import { BaseConnector, type ConnectorMeta, type CrawlContext, type RawRecordInput, type RawRecordLike } from '@rareindex/connectors';
3 +import { NormalizedPopulationReportSchema, type NormalizedRecord } from '@rareindex/shared';
4 +import { attrs } from '../../api/_lib/shared.js';
5 +import { BOT_HEADERS, cardCategorySlug, dayOf, markdownTables, mdLink, mdText, parseSitemap, toInt } from '../../api/_lib/wave4.js';
6 +
7 +/** TAG Grading population report — set pages rendered by Firecrawl, parsed from the markdown table. */
8 +const PARSER_VERSION = '1.0.0';
9 +const SITE = 'https://my.taggrading.com';
10 +
11 +const RowSchema = z.object({ number: z.string().nullable(), name: z.string(), variation: z.string().nullable(), url: z.string().nullable(), counts: z.record(z.string(), z.number()), total: z.number().nullable() });
12 +const RawPayloadSchema = z.object({ category: z.string(), year: z.string().nullable(), company: z.string().nullable(), setName: z.string().nullable(), grades: z.array(z.string()), rows: z.array(RowSchema) });
13 +export type TagPopPayload = z.infer<typeof RawPayloadSchema>;
14 +
15 +/** Parse the set-page markdown into rows. Exported for tests. */
16 +export function parseSetMarkdown(md: string): { grades: string[]; rows: z.infer<typeof RowSchema>[] } {
17 + const tables = markdownTables(md);
18 + for (const t of tables) {
19 + const headerIdx = t.findIndex((r) => r[0]?.replace(/\s/g, '').toLowerCase() === 'card#');
20 + if (headerIdx < 0) continue;
21 + const header = t[headerIdx]!;
22 + const gradeCols: Array<{ idx: number; label: string }> = [];
23 + let totalIdx = -1;
24 + header.forEach((h, i) => {
25 + const label = mdText(h);
26 + if (i < 2) return;
27 + if (/^total$/i.test(label)) totalIdx = i;
28 + else if (label) gradeCols.push({ idx: i, label: label === 'VA' ? 'authentic' : label });
29 + });
30 + const rows: z.infer<typeof RowSchema>[] = [];
31 + for (const r of t.slice(headerIdx + 1)) {
32 + if (r.length < 3) continue;
33 + const number = mdText(r[0] ?? '') || null;
34 + const nameCell = r[1] ?? '';
35 + const link = mdLink(nameCell);
36 + const nameText = mdText(nameCell.replace(/<br\s*\/?>/gi, ' ¦ '));
37 + const [namePart, ...rest] = nameText.split('¦').map((s) => s.trim());
38 + const name = namePart ?? nameText;
39 + if (!name || /^totals?$/i.test(name)) continue;
40 + const variation = rest.filter(Boolean).join(' ') || null;
41 + const counts: Record<string, number> = {};
42 + for (const g of gradeCols) {
43 + const n = toInt(mdText(r[g.idx] ?? ''));
44 + if (n !== null && n > 0) counts[g.label] = n;
45 + }
46 + const total = totalIdx >= 0 ? toInt(mdText(r[totalIdx] ?? '')) : Object.values(counts).reduce((a, b) => a + b, 0);
47 + rows.push({ number, name, variation, url: link, counts, total });
48 + }
49 + return { grades: gradeCols.map((g) => g.label), rows };
50 + }
51 + return { grades: [], rows: [] };
52 +}
53 +
54 +function parseSetUrl(u: string): { category: string; year: string | null; company: string | null; setName: string | null } | null {
55 + try {
56 + const url = new URL(u);
57 + const parts = url.pathname.split('/').filter(Boolean).map((p) => decodeURIComponent(p).trim());
58 + if (parts[0] !== 'pop-report' || parts.length < 4) return null;
59 + return { category: parts[1]!, year: parts[2] ?? null, company: parts[3] ?? null, setName: url.searchParams.get('setName') };
60 + } catch {
61 + return null;
62 + }
63 +}
64 +
65 +export class TagPopConnector extends BaseConnector {
66 + readonly version = '1.0.0';
67 + readonly parserVersion = PARSER_VERSION;
68 + protected override minIntervalMs = 1500;
69 +
70 + async *crawl(ctx: CrawlContext): AsyncIterable<RawRecordInput> {
71 + const seeds = (ctx.options.seeds?.length ? ctx.options.seeds : (this.meta.config.seeds as string[] | undefined) ?? ['Pokemon']).map((s) => s.toLowerCase());
72 + const maxPages = ctx.options.mode === 'backfill' ? Infinity : Number(this.meta.config.maxPagesPerRun ?? 40);
73 + let urls: string[] = [];
74 + if (ctx.options.seeds?.some((s) => s.startsWith('http'))) urls = ctx.options.seeds.filter((s) => s.startsWith('http'));
75 + else {
76 + const sm = await ctx.fetch(`${SITE}/pop.xml`, { engines: ['api'], headers: { ...BOT_HEADERS, accept: 'application/xml' }, responseType: 'text', timeoutMs: 120_000 });
77 + if (!sm.success || !sm.html) throw new Error(`tag pop sitemap failed: ${sm.error ?? sm.httpStatus}`);
78 + urls = parseSitemap(sm.html)
79 + .map((e) => e.loc)
80 + .filter((u) => u.includes('setName=') && !/\t|%09/.test(u))
81 + .filter((u) => {
82 + const p = parseSetUrl(u);
83 + return p && seeds.some((s) => p.category.toLowerCase() === s || p.category.toLowerCase().startsWith(s));
84 + });
85 + }
86 + let idx = Number(ctx.options.cursor?.idx ?? 0);
87 + if (idx >= urls.length) idx = 0;
88 + let count = 0;
89 + let pages = 0;
90 + for (; idx < urls.length && pages < maxPages; idx++) {
91 + if (ctx.signal?.aborted) return;
92 + if (this.reached(ctx, count)) break;
93 + const u = urls[idx]!;
94 + const info = parseSetUrl(u);
95 + if (!info) continue;
96 + await this.throttle();
97 + const res = await ctx.fetch(u, { engines: ['firecrawl'], waitForMs: 8000, timeoutMs: 90_000, expect: ['title'], parse: (r) => ({ title: r.markdown && /Card #/i.test(r.markdown) ? 'ok' : null }) });
98 + pages++;
99 + if (!res.success || !res.markdown) {
100 + ctx.anomaly('page_fetch_failed', `${u}: ${res.error ?? res.httpStatus}`);
101 + continue;
102 + }
103 + const { grades, rows } = parseSetMarkdown(res.markdown);
104 + if (!rows.length) {
105 + ctx.anomaly('parse_failure_table', u);
106 + continue;
107 + }
108 + count++;
109 + const payload: TagPopPayload = { ...info, grades, rows };
110 + yield { url: u, externalId: `${info.category}|${info.year ?? ''}|${info.company ?? ''}|${info.setName ?? ''}`, kind: 'population_report', engine: 'firecrawl', httpStatus: res.httpStatus, payload, fetchedAt: res.fetchedAt };
111 + await ctx.setCursor({ idx: idx + 1, total: urls.length });
112 + }
113 + if (idx >= urls.length) await ctx.setCursor({ idx: 0, total: urls.length, completedAt: new Date().toISOString() });
114 + }
115 +
116 + async normalize(raw: RawRecordLike): Promise<NormalizedRecord[]> {
117 + const p = RawPayloadSchema.parse(raw.payload);
118 + const categorySlug = cardCategorySlug(p.category);
119 + if (!categorySlug) return [];
120 + const year = p.year && /^\d{4}$/.test(p.year) ? Number(p.year) : null;
121 + const reportDate = dayOf(raw.fetchedAt);
122 + const isPokemon = categorySlug === 'pokemon';
123 + const out: NormalizedRecord[] = [];
124 + for (const r of p.rows) {
125 + const total = r.total ?? Object.values(r.counts).reduce((a, b) => a + b, 0);
126 + if (!total) continue;
127 + const number = r.number ? r.number.split('/')[0]!.trim() : null;
128 + const totalInSet = r.number?.includes('/') ? toInt(r.number.split('/')[1]!) : null;
129 + const a = attrs({
130 + categorySlug,
131 + franchise: isPokemon ? 'Pokémon' : null,
132 + brand: isPokemon ? 'The Pokémon Company' : p.company,
133 + set: p.setName,
134 + name: r.name,
135 + number,
136 + year,
137 + variant: r.variation,
138 + language: isPokemon && /japanese/i.test(p.company ?? '') ? 'Japanese' : isPokemon ? 'English' : null,
139 + identifiers: {},
140 + metadata: { total_in_set: totalInSet, company: p.company },
141 + });
142 + out.push(NormalizedPopulationReportSchema.parse({ kind: 'population_report', connectorId: this.meta.id, sourceId: this.meta.sourceId, sourceUrl: r.url ?? raw.url, grader: 'tag', attributes: a, reportDate, total, byGrade: r.counts, parserVersion: PARSER_VERSION, confidence: 0.95 }));
143 + }
144 + return out;
145 + }
146 +}
147 +
148 +export default (meta: ConnectorMeta) => new TagPopConnector(meta);
added connectors/firecrawl/tag-pop/meta.json +33 −0
@@ -0,0 +1,33 @@
1 +{
2 + "id": "tag-pop",
3 + "displayName": "TAG Grading population report",
4 + "sourceId": "tag",
5 + "sourceName": "TAG Grading",
6 + "sourceType": "grading_company",
7 + "sourceUrl": "https://my.taggrading.com/pop-report",
8 + "module": "firecrawl/tag-pop",
9 + "enginePriority": ["firecrawl"],
10 + "categories": ["pokemon", "magic_the_gathering", "yugioh", "disney_lorcana", "one_piece_card_game", "baseball_cards", "basketball_cards", "football_cards", "hockey_cards", "soccer_cards", "other_sports_cards", "non_sport_cards"],
11 + "regions": ["US"],
12 + "languages": ["en"],
13 + "currency": ["USD"],
14 + "supportsListings": false,
15 + "supportsSold": false,
16 + "supportsAuctions": false,
17 + "supportsImages": false,
18 + "supportsCatalog": false,
19 + "supportsPopulation": true,
20 + "supportsLookup": false,
21 + "refreshFrequencyMinutes": 10080,
22 + "priority": "low",
23 + "trustScore": 0.95,
24 + "attributionRequired": true,
25 + "termsUrl": "https://www.taggrading.com/terms",
26 + "accessNotes": "TAG's population report is public (robots.txt explicitly allows /pop-report/ and /card/). The pages are a client-rendered app, so set-level pages are rendered with Firecrawl (1 credit per set page, waitFor 8 s) and the markdown table is parsed: one row per card/variation with counts per grade (VA, 1–10, 10P = Pristine) and a total. Discovery is free through the public sitemap (pop.xml, ~26k URLs; set pages carry ?setName=). Category seeds and `maxPagesPerRun` bound the weekly cost. Report date = fetch day (the report is live).",
27 + "enabled": true,
28 + "schemaVersion": "1.0",
29 + "config": {
30 + "seeds": ["Pokemon", "Basketball", "Baseball", "Football", "Hockey", "Soccer", "Magic", "Yu-Gi-Oh"],
31 + "maxPagesPerRun": 40
32 + }
33 +}
added data/fixtures/alt-xyz/item-priced.json +42 −0
@@ -0,0 +1,42 @@
1 +{
2 + "raw": {
3 + "url": "https://alt.xyz/itm/25e7f0af-ef60-4c7d-9ff4-4944311693ea",
4 + "externalId": "25e7f0af-ef60-4c7d-9ff4-4944311693ea",
5 + "kind": "sale",
6 + "engine": "firecrawl",
7 + "httpStatus": 200,
8 + "payload": {
9 + "id": "25e7f0af-ef60-4c7d-9ff4-4944311693ea",
10 + "title": "2000 EX Tom Brady #122",
11 + "category": "Football Cards",
12 + "serial": "1260/1500",
13 + "grader": "bgs",
14 + "grade": "8",
15 + "pop": 29,
16 + "listPrice": 414,
17 + "listingKind": "auction",
18 + "altValue": null,
19 + "altLow": null,
20 + "altHigh": null,
21 + "transactions": [],
22 + "images": [
23 + "https://alt-images.b-cdn.net/public/6f51aa27-ec58-4508-a1b4-edb464a1fdb5_1788566683267_1788595874557_front.png?width=324&height=531&quality=50",
24 + "https://alt-images.b-cdn.net/public/6f51aa27-ec58-4508-a1b4-edb464a1fdb5_1788566683267_1788595874557_back.png?width=324&height=531&quality=50"
25 + ]
26 + },
27 + "fetchedAt": "2026-09-07T07:16:57.031Z"
28 + },
29 + "expect": {
30 + "minCount": 1,
31 + "kinds": [
32 + "sale",
33 + "price_observation",
34 + "listing"
35 + ],
36 + "requiredFields": [
37 + "attributes.identifiers.alt_item_id"
38 + ]
39 + },
40 + "note": "Live capture 2026-09-07 from https://alt.xyz/itm/25e7f0af-ef60-4c7d-9ff4-4944311693ea",
41 + "capturedAt": "2026-09-07T07:16:57.033Z"
42 +}
\ No newline at end of file
added data/fixtures/cardkingdom/single-first.json +42 −0
@@ -0,0 +1,42 @@
1 +{
2 + "raw": {
3 + "url": "https://www.cardkingdom.com/mtg/4th-edition/aladdins-ring",
4 + "externalId": "s10004",
5 + "kind": "catalog_item",
6 + "engine": "api",
7 + "httpStatus": 200,
8 + "payload": {
9 + "kind": "single",
10 + "row": {
11 + "id": 10004,
12 + "sku": "4ED-292",
13 + "scryfall_id": "d9907bbb-12aa-4826-9a65-b2ddda5fc1e2",
14 + "url": "mtg/4th-edition/aladdins-ring",
15 + "name": "Aladdin's Ring",
16 + "variation": "",
17 + "edition": "4th Edition",
18 + "is_foil": "false",
19 + "price_retail": "0.59",
20 + "qty_retail": 5,
21 + "price_buy": "0.09",
22 + "qty_buying": 20
23 + },
24 + "createdAt": "2026-09-07 00:08:45",
25 + "baseUrl": "https://www.cardkingdom.com/"
26 + },
27 + "fetchedAt": "2026-09-07T07:11:39.831Z"
28 + },
29 + "expect": {
30 + "minCount": 1,
31 + "kinds": [
32 + "catalog_item",
33 + "price_observation"
34 + ],
35 + "requiredFields": [
36 + "attributes.identifiers.cardkingdom_id",
37 + "attributes.set"
38 + ]
39 + },
40 + "note": "Live capture 2026-09-07 from https://www.cardkingdom.com/mtg/4th-edition/aladdins-ring",
41 + "capturedAt": "2026-09-07T07:11:39.919Z"
42 +}
\ No newline at end of file
added data/fixtures/cardkingdom/single-fourth.json +42 −0
@@ -0,0 +1,42 @@
1 +{
2 + "raw": {
3 + "url": "https://www.cardkingdom.com/mtg/4th-edition/armageddon",
4 + "externalId": "s10014",
5 + "kind": "catalog_item",
6 + "engine": "api",
7 + "httpStatus": 200,
8 + "payload": {
9 + "kind": "single",
10 + "row": {
11 + "id": 10014,
12 + "sku": "4ED-005",
13 + "scryfall_id": "0794bc35-a8e1-4268-87b2-af5483ca6e5e",
14 + "url": "mtg/4th-edition/armageddon",
15 + "name": "Armageddon",
16 + "variation": "",
17 + "edition": "4th Edition",
18 + "is_foil": "false",
19 + "price_retail": "12.99",
20 + "qty_retail": 0,
21 + "price_buy": "8.00",
22 + "qty_buying": 33
23 + },
24 + "createdAt": "2026-09-07 00:08:45",
25 + "baseUrl": "https://www.cardkingdom.com/"
26 + },
27 + "fetchedAt": "2026-09-07T07:11:39.831Z"
28 + },
29 + "expect": {
30 + "minCount": 1,
31 + "kinds": [
32 + "catalog_item",
33 + "price_observation"
34 + ],
35 + "requiredFields": [
36 + "attributes.identifiers.cardkingdom_id",
37 + "attributes.set"
38 + ]
39 + },
40 + "note": "Live capture 2026-09-07 from https://www.cardkingdom.com/mtg/4th-edition/armageddon",
41 + "capturedAt": "2026-09-07T07:11:39.919Z"
42 +}
\ No newline at end of file
added data/fixtures/comc/basketball-1986-fleer-p1.json +427 −0
@@ -0,0 +1,427 @@
1 +{
2 + "raw": {
3 + "url": "https://www.comc.com/Cards/Basketball/1986/Fleer,sh",
4 + "externalId": "Cards/Basketball/1986/Fleer:p1",
5 + "kind": "listing",
6 + "engine": "firecrawl",
7 + "httpStatus": 200,
8 + "payload": {
9 + "seed": "Cards/Basketball/1986/Fleer",
10 + "page": 1,
11 + "listings": [
12 + {
13 + "title": "Steve Johnson [BAS Beckett Auth Sticker]",
14 + "url": "https://www.comc.com/Cards/Basketball/1986-87/Fleer_-_Base/55/Steve_Johnson/1395657/AftermarketAuto/BAS/Beckett_Auth-Sticker",
15 + "image": null,
16 + "setLine": "1986-87 Fleer - [Base] #55",
17 + "price": 8830
18 + },
19 + {
20 + "title": "Steve Johnson [CSG 10 Gem Mint]",
21 + "url": "https://www.comc.com/Cards/Basketball/1986-87/Fleer_-_Base/55/Steve_Johnson/1395657/Graded/CSG/10_Gem",
22 + "image": null,
23 + "setLine": "1986-87 Fleer - [Base] #55",
24 + "price": 1791.76
25 + },
26 + {
27 + "title": "Larry Smith [PSA/DNA 5 EX]",
28 + "url": "https://www.comc.com/Cards/Basketball/1986-87/Fleer_-_Base/104/Larry_Smith/1395706/AfterAutoGraded/PSA_DNA/5",
29 + "image": null,
30 + "setLine": "1986-87 Fleer - [Base] #104",
31 + "price": 3332.2
32 + },
33 + {
34 + "title": "Vern Fleming [PSA/DNA 8 NM‑MT]",
35 + "url": "https://www.comc.com/Cards/Basketball/1986-87/Fleer_-_Base/33/Vern_Fleming/1395635/AfterAutoGraded/PSA_DNA/8",
36 + "image": null,
37 + "setLine": "1986-87 Fleer - [Base] #33",
38 + "price": 3330
39 + },
40 + {
41 + "title": "Brad Davis [PSA/DNA 7 NM]",
42 + "url": "https://www.comc.com/Cards/Basketball/1986-87/Fleer_-_Base/22/Brad_Davis/1395624/AfterAutoGraded/PSA_DNA/7",
43 + "image": null,
44 + "setLine": "1986-87 Fleer - [Base] #22",
45 + "price": 2782.2
46 + },
47 + {
48 + "title": "Michael Jordan",
49 + "url": "https://www.comc.com/Cards/Basketball/1986-87/Fleer_-_Stickers/8/Michael_Jordan/1395742",
50 + "image": "https://img.comc.com/i/Basketball/1986-87/Fleer---Stickers/8/Michael-Jordan.jpg?id=3f3a71ab-d535-495c-9f25-1631d3257265&size=biggerthumb",
51 + "setLine": "1986-87 Fleer - Stickers #8",
52 + "price": 2451.1
53 + },
54 + {
55 + "title": "Johnny Moore [PSA/DNA 6 EX‑MT]",
56 + "url": "https://www.comc.com/Cards/Basketball/1986-87/Fleer_-_Base/76/Johnny_Moore/1395678/AfterAutoGraded/PSA_DNA/6",
57 + "image": null,
58 + "setLine": "1986-87 Fleer - [Base] #76",
59 + "price": 1682.2
60 + },
61 + {
62 + "title": "Marques Johnson [PSA 6 EX‑MT]",
63 + "url": "https://www.comc.com/Cards/Basketball/1986-87/Fleer_-_Base/54/Marques_Johnson/1395656/Graded/PSA/6",
64 + "image": null,
65 + "setLine": "1986-87 Fleer - [Base] #54",
66 + "price": 1682.2
67 + },
68 + {
69 + "title": "LaSalle Thompson [PSA/DNA 5 EX]",
70 + "url": "https://www.comc.com/Cards/Basketball/1986-87/Fleer_-_Base/110/LaSalle_Thompson/1395712/AfterAutoGraded/PSA_DNA/5",
71 + "image": null,
72 + "setLine": "1986-87 Fleer - [Base] #110",
73 + "price": 1462.2
74 + },
75 + {
76 + "title": "Eddie Johnson [PSA 5 EX]",
77 + "url": "https://www.comc.com/Cards/Basketball/1986-87/Fleer_-_Base/51/Eddie_Johnson/1395653/Graded/PSA/5",
78 + "image": null,
79 + "setLine": "1986-87 Fleer - [Base] #51",
80 + "price": 1462.2
81 + },
82 + {
83 + "title": "Kobe Bryant",
84 + "url": "https://www.comc.com/Cards/Basketball/2006-07/Fleer_-_1986-87_Fleer_Design/58/Kobe_Bryant/3452227",
85 + "image": "https://img.comc.com/i/Basketball/2006-07/Fleer---1986-87-Fleer-Design/58/Kobe-Bryant.jpg?id=752d77c8-b6f0-4e3d-9241-7f8388671b3a&size=biggerthumb",
86 + "setLine": "2006-07 Fleer - 1986-87 Fleer Design #58",
87 + "price": 1350.55
88 + },
89 + {
90 + "title": "Michael Cooper [BGS 9.5 GEM MINT]",
91 + "url": "https://www.comc.com/Cards/Basketball/1986-87/Fleer_-_Base/17/Michael_Cooper/1395619/Graded/BGS/9_5",
92 + "image": null,
93 + "setLine": "1986-87 Fleer - [Base] #17",
94 + "price": 314.15
95 + },
96 + {
97 + "title": "Brad Davis [PSA/DNA Authentic Card & Auto]",
98 + "url": "https://www.comc.com/Cards/Basketball/1986-87/Fleer_-_Base/22/Brad_Davis/1395624/AfterAutoGraded/PSA_DNA/APDC",
99 + "image": null,
100 + "setLine": "1986-87 Fleer - [Base] #22",
101 + "price": 470
102 + },
103 + {
104 + "title": "Akeem Olajuwon [PSA 8.5 NM‑MT+]",
105 + "url": "https://www.comc.com/Cards/Basketball/1986-87/Fleer_-_Base/82/Akeem_Olajuwon/1395684/Graded/PSA/8_5",
106 + "image": null,
107 + "setLine": "1986-87 Fleer - [Base] #82",
108 + "price": 802.2
109 + },
110 + {
111 + "title": "James Worthy [SGC 9.5 Mint+]",
112 + "url": "https://www.comc.com/Cards/Basketball/1986-87/Fleer_-_Base/131/James_Worthy/1395733/Graded/SGC/9_5",
113 + "image": null,
114 + "setLine": "1986-87 Fleer - [Base] #131",
115 + "price": 801.1
116 + },
117 + {
118 + "title": "Robert Reid [BAS BGS Authentic]",
119 + "url": "https://www.comc.com/Cards/Basketball/1986-87/Fleer_-_Base/90/Robert_Reid/1395692/AftermarketAuto/BAS/Encased_by_BGS",
120 + "image": null,
121 + "setLine": "1986-87 Fleer - [Base] #90",
122 + "price": 692.2
123 + },
124 + {
125 + "title": "Adrian Dantley [PSA/DNA 4 VG‑EX]",
126 + "url": "https://www.comc.com/Cards/Basketball/1986-87/Fleer_-_Stickers/3/Adrian_Dantley/1395737/AfterAutoGraded/PSA_DNA/4",
127 + "image": null,
128 + "setLine": "1986-87 Fleer - Stickers #3",
129 + "price": 582.2
130 + },
131 + {
132 + "title": "Al Wood [PSA/DNA 3 VG]",
133 + "url": "https://www.comc.com/Cards/Basketball/1986-87/Fleer_-_Base/128/Al_Wood/1395730/AfterAutoGraded/PSA_DNA/3",
134 + "image": null,
135 + "setLine": "1986-87 Fleer - [Base] #128",
136 + "price": 582.2
137 + },
138 + {
139 + "title": "Lafayette Lever [PSA/DNA 6 EX‑MT]",
140 + "url": "https://www.comc.com/Cards/Basketball/1986-87/Fleer_-_Base/63/Lafayette_Lever/1395665/AfterAutoGraded/PSA_DNA/6",
141 + "image": null,
142 + "setLine": "1986-87 Fleer - [Base] #63",
143 + "price": 582.2
144 + },
145 + {
146 + "title": "Alex English [PSA/DNA 8 NM‑MT]",
147 + "url": "https://www.comc.com/Cards/Basketball/1986-87/Fleer_-_Base/30/Alex_English/1395632/AfterAutoGraded/PSA_DNA/8",
148 + "image": null,
149 + "setLine": "1986-87 Fleer - [Base] #30",
150 + "price": 582.2
151 + },
152 + {
153 + "title": "Charles Barkley [PSA 8 NM‑MT]",
154 + "url": "https://www.comc.com/Cards/Basketball/1986-87/Fleer_-_Base/7/Charles_Barkley/1395609/Graded/PSA/8",
155 + "image": null,
156 + "setLine": "1986-87 Fleer - [Base] #7",
157 + "price": 582.2
158 + },
159 + {
160 + "title": "Karl Malone [PSA 9 MINT]",
161 + "url": "https://www.comc.com/Cards/Basketball/1986-87/Fleer_-_Base/68/Karl_Malone/1395670/Graded/PSA/9",
162 + "image": null,
163 + "setLine": "1986-87 Fleer - [Base] #68",
164 + "price": 492
165 + },
166 + {
167 + "title": "Sam Perkins [BAS BGS Authentic]",
168 + "url": "https://www.comc.com/Cards/Basketball/1986-87/Fleer_-_Base/86/Sam_Perkins/1395688/AftermarketAuto/BAS/Encased_by_BGS",
169 + "image": null,
170 + "setLine": "1986-87 Fleer - [Base] #86",
171 + "price": 472.2
172 + },
173 + {
174 + "title": "Mike Gminski [BAS BGS Authentic]",
175 + "url": "https://www.comc.com/Cards/Basketball/1986-87/Fleer_-_Base/38/Mike_Gminski/1395640/AftermarketAuto/BAS/Encased_by_BGS",
176 + "image": null,
177 + "setLine": "1986-87 Fleer - [Base] #38",
178 + "price": 472.2
179 + },
180 + {
181 + "title": "Robert Parish [BAS BGS Authentic]",
182 + "url": "https://www.comc.com/Cards/Basketball/1986-87/Fleer_-_Base/84/Robert_Parish/1395686/AftermarketAuto/BAS/Encased_by_BGS",
183 + "image": null,
184 + "setLine": "1986-87 Fleer - [Base] #84",
185 + "price": 144.17
186 + },
187 + {
188 + "title": "Larry Bird [PSA/DNA Certified Authentic Auto]",
189 + "url": "https://www.comc.com/Cards/Basketball/1986-87/Fleer_-_Base/9/Larry_Bird/1395611/AftermarketAuto/PSA_DNA/Authentic",
190 + "image": null,
191 + "setLine": "1986-87 Fleer - [Base] #9",
192 + "price": 471.1
193 + },
194 + {
195 + "title": "Allen Iverson",
196 + "url": "https://www.comc.com/Cards/Basketball/2006-07/Fleer_-_1986-87_Fleer_Design/4/Allen_Iverson/3452173",
197 + "image": "https://img.comc.com/i/Basketball/2006-07/Fleer---1986-87-Fleer-Design/4/Allen-Iverson.jpg?id=74a9eae6-ecce-4716-a124-7a0c0cd973c9&size=biggerthumb",
198 + "setLine": "2006-07 Fleer - 1986-87 Fleer Design #4",
199 + "price": 470.55
200 + },
201 + {
202 + "title": "Patrick Ewing [BGS 9 MINT]",
203 + "url": "https://www.comc.com/Cards/Basketball/1986-87/Fleer_-_Base/32/Patrick_Ewing/1395634/Graded/BGS/9",
204 + "image": null,
205 + "setLine": "1986-87 Fleer - [Base] #32",
206 + "price": 446.7
207 + },
208 + {
209 + "title": "Joe Dumars [BGS 9 MINT]",
210 + "url": "https://www.comc.com/Cards/Basketball/1986-87/Fleer_-_Base/27/Joe_Dumars/1395629/Graded/BGS/9",
211 + "image": null,
212 + "setLine": "1986-87 Fleer - [Base] #27",
213 + "price": 178.83
214 + },
215 + {
216 + "title": "Akeem Olajuwon [PSA 8 NM‑MT]",
217 + "url": "https://www.comc.com/Cards/Basketball/1986-87/Fleer_-_Base/82/Akeem_Olajuwon/1395684/Graded/PSA/8",
218 + "image": null,
219 + "setLine": "1986-87 Fleer - [Base] #82",
220 + "price": 1021.1
221 + },
222 + {
223 + "title": "Terry Cummings [SGC 10 GEM]",
224 + "url": "https://www.comc.com/Cards/Basketball/1986-87/Fleer_-_Base/20/Terry_Cummings/1395622/Graded/SGC/10G",
225 + "image": null,
226 + "setLine": "1986-87 Fleer - [Base] #20",
227 + "price": 383.16
228 + },
229 + {
230 + "title": "Dominique Wilkins [PSA/DNA 4 VG‑EX]",
231 + "url": "https://www.comc.com/Cards/Basketball/1986-87/Fleer_-_Base/121/Dominique_Wilkins/1395723/AfterAutoGraded/PSA_DNA/4",
232 + "image": null,
233 + "setLine": "1986-87 Fleer - [Base] #121",
234 + "price": 378.5
235 + },
236 + {
237 + "title": "Kevin Durant [PSA 8 NM‑MT]",
238 + "url": "https://www.comc.com/Cards/Basketball/2007-08/Fleer_-_1986-87_Retro_Rookies_-_Glossy/86R-143/Kevin_Durant/3841999/Graded/PSA/8",
239 + "image": null,
240 + "setLine": "2007-08 Fleer - 1986-87 Retro Rookies - Glossy #86R-143",
241 + "price": 341.1
242 + },
243 + {
244 + "title": "Russell Westbrook [PSA 10 GEM MT]",
245 + "url": "https://www.comc.com/Cards/Basketball/2008-09/Fleer_-_1986-87_Retro_Rookies/86R-166/Russell_Westbrook/4432536/Graded/PSA/10",
246 + "image": null,
247 + "setLine": "2008-09 Fleer - 1986-87 Retro Rookies #86R-166",
248 + "price": 472.2
249 + },
250 + {
251 + "title": "Charles Barkley",
252 + "url": "https://www.comc.com/Cards/Basketball/1986-87/Fleer_-_Base/7/Charles_Barkley/1395609",
253 + "image": "https://img.comc.com/i/Basketball/1986-87/Fleer---Base/7/Charles-Barkley.jpg?id=ebf211ac-522e-47d9-a559-1de87b8a7e16&size=biggerthumb",
254 + "setLine": "1986-87 Fleer - [Base] #7",
255 + "price": 229.45
256 + },
257 + {
258 + "title": "Charles Barkley [EX to NM]",
259 + "url": "https://www.comc.com/Cards/Basketball/1986-87/Fleer_-_Base/7/Charles_Barkley/1395609/Ungraded/COMC/EX-NM",
260 + "image": null,
261 + "setLine": "1986-87 Fleer - [Base] #7",
262 + "price": 203.6
263 + },
264 + {
265 + "title": "Alvan Adams [BAS Seal of Authenticity]",
266 + "url": "https://www.comc.com/Cards/Basketball/1986-87/Fleer_-_Base/2/Alvan_Adams/1395604/AftermarketAuto/BAS/Seal_of_Authenticity",
267 + "image": null,
268 + "setLine": "1986-87 Fleer - [Base] #2",
269 + "price": 71.52
270 + },
271 + {
272 + "title": "Paul Pressey [JSA Certified COA Sticker]",
273 + "url": "https://www.comc.com/Cards/Basketball/1986-87/Fleer_-_Base/88/Paul_Pressey/1395690/AftermarketAuto/JSA_Certified/COA_Sticker",
274 + "image": null,
275 + "setLine": "1986-87 Fleer - [Base] #88",
276 + "price": 231.1
277 + },
278 + {
279 + "title": "John Bagley [PSA/DNA Authentic Card & Auto]",
280 + "url": "https://www.comc.com/Cards/Basketball/1986-87/Fleer_-_Base/5/John_Bagley/1395607/AfterAutoGraded/PSA_DNA/APDC",
281 + "image": null,
282 + "setLine": "1986-87 Fleer - [Base] #5",
283 + "price": 231.1
284 + },
285 + {
286 + "title": "Kurt Rambis [BAS BGS Encased with Relic]",
287 + "url": "https://www.comc.com/Cards/Basketball/1986-87/Fleer_-_Base/89/Kurt_Rambis/1395691/AfterAutoGraded/BAS/Encased_with_Relic",
288 + "image": null,
289 + "setLine": "1986-87 Fleer - [Base] #89",
290 + "price": 231.09
291 + },
292 + {
293 + "title": "Larry Nance [BAS Seal of Authenticity]",
294 + "url": "https://www.comc.com/Cards/Basketball/1986-87/Fleer_-_Base/78/Larry_Nance/1395680/AftermarketAuto/BAS/Seal_of_Authenticity",
295 + "image": null,
296 + "setLine": "1986-87 Fleer - [Base] #78",
297 + "price": 70.37
298 + },
299 + {
300 + "title": "Brad Davis [BAS Beckett Auth Sticker]",
301 + "url": "https://www.comc.com/Cards/Basketball/1986-87/Fleer_-_Base/22/Brad_Davis/1395624/AftermarketAuto/BAS/Beckett_Auth-Sticker",
302 + "image": null,
303 + "setLine": "1986-87 Fleer - [Base] #22",
304 + "price": 70.37
305 + },
306 + {
307 + "title": "Karl Malone [PSA 8 NM‑MT]",
308 + "url": "https://www.comc.com/Cards/Basketball/1986-87/Fleer_-_Base/68/Karl_Malone/1395670/Graded/PSA/8",
309 + "image": null,
310 + "setLine": "1986-87 Fleer - [Base] #68",
311 + "price": 214.6
312 + },
313 + {
314 + "title": "Akeem Olajuwon",
315 + "url": "https://www.comc.com/Cards/Basketball/1986-87/Fleer_-_Base/82/Akeem_Olajuwon/1395684",
316 + "image": "https://img.comc.com/i/Basketball/1986-87/Fleer---Base/82/Akeem-Olajuwon.jpg?id=91f6f374-8eb0-41dc-9ca2-c35aa9db7d2b&size=biggerthumb",
317 + "setLine": "1986-87 Fleer - [Base] #82",
318 + "price": 203.03
319 + },
320 + {
321 + "title": "Derek Harper [PSA 8 NM‑MT]",
322 + "url": "https://www.comc.com/Cards/Basketball/1986-87/Fleer_-_Base/44/Derek_Harper/1395646/Graded/PSA/8",
323 + "image": null,
324 + "setLine": "1986-87 Fleer - [Base] #44",
325 + "price": 168.84
326 + },
327 + {
328 + "title": "Magic Johnson [SGC 8 NM/Mt]",
329 + "url": "https://www.comc.com/Cards/Basketball/1986-87/Fleer_-_Base/53/Magic_Johnson/1395655/Graded/SGC/8",
330 + "image": null,
331 + "setLine": "1986-87 Fleer - [Base] #53",
332 + "price": 229.98
333 + },
334 + {
335 + "title": "Sam Perkins [BRCR 9]",
336 + "url": "https://www.comc.com/Cards/Basketball/1986-87/Fleer_-_Base/86/Sam_Perkins/1395688/Ungraded/BRCR/9",
337 + "image": null,
338 + "setLine": "1986-87 Fleer - [Base] #86",
339 + "price": 221.42
340 + },
341 + {
342 + "title": "Dominique Wilkins [BGS 6.5 EX‑MT+]",
343 + "url": "https://www.comc.com/Cards/Basketball/1986-87/Fleer_-_Base/121/Dominique_Wilkins/1395723/Graded/BGS/6_5",
344 + "image": null,
345 + "setLine": "1986-87 Fleer - [Base] #121",
346 + "price": 220.1
347 + },
348 + {
349 + "title": "Alvan Adams [CSG 9 Mint]",
350 + "url": "https://www.comc.com/Cards/Basketball/1986-87/Fleer_-_Base/2/Alvan_Adams/1395604/Graded/CSG/9",
351 + "image": null,
352 + "setLine": "1986-87 Fleer - [Base] #2",
353 + "price": 214.98
354 + },
355 + {
356 + "title": "Jeff Malone [CSG 9 Mint]",
357 + "url": "https://www.comc.com/Cards/Basketball/1986-87/Fleer_-_Base/67/Jeff_Malone/1395669/Graded/CSG/9",
358 + "image": null,
359 + "setLine": "1986-87 Fleer - [Base] #67",
360 + "price": 208.38
361 + },
362 + {
363 + "title": "Kevin Durant [BCCG 10 Mint or Better]",
364 + "url": "https://www.comc.com/Cards/Basketball/2007-08/Fleer_-_1986-87_Retro_Rookies/86R-143/Kevin_Durant/3832219/Graded/BCCG/MINT",
365 + "image": null,
366 + "setLine": "2007-08 Fleer - 1986-87 Retro Rookies #86R-143",
367 + "price": 146.95
368 + },
369 + {
370 + "title": "Yao Ming",
371 + "url": "https://www.comc.com/Cards/Basketball/2006-07/Fleer_-_1986-87_Fleer_Design/131/Yao_Ming/3452300",
372 + "image": "https://img.comc.com/i/Basketball/2006-07/Fleer---1986-87-Fleer-Design/131/Yao-Ming.jpg?id=935b94bd-9016-431f-9231-63cabb0471d3&size=biggerthumb",
373 + "setLine": "2006-07 Fleer - 1986-87 Fleer Design #131",
374 + "price": 202.77
375 + },
376 + {
377 + "title": "Joe Dumars [PSA 9 MINT]",
378 + "url": "https://www.comc.com/Cards/Basketball/1986-87/Fleer_-_Base/27/Joe_Dumars/1395629/Graded/PSA/9",
379 + "image": null,
380 + "setLine": "1986-87 Fleer - [Base] #27",
381 + "price": 230
382 + },
383 + {
384 + "title": "Phil Hubbard [PSA/DNA Certified Authentic Auto]",
385 + "url": "https://www.comc.com/Cards/Basketball/1986-87/Fleer_-_Base/48/Phil_Hubbard/1395650/AftermarketAuto/PSA_DNA/Authentic",
386 + "image": null,
387 + "setLine": "1986-87 Fleer - [Base] #48",
388 + "price": 186
389 + },
390 + {
391 + "title": "Sidney Moncrief [BAS BGS Encased with Relic]",
392 + "url": "https://www.comc.com/Cards/Basketball/1986-87/Fleer_-_Base/75/Sidney_Moncrief/1395677/AfterAutoGraded/BAS/Encased_with_Relic",
393 + "image": null,
394 + "setLine": "1986-87 Fleer - [Base] #75",
395 + "price": 177.2
396 + },
397 + {
398 + "title": "Alex English [PSA/DNA 6 EX‑MT]",
399 + "url": "https://www.comc.com/Cards/Basketball/1986-87/Fleer_-_Base/30/Alex_English/1395632/AfterAutoGraded/PSA_DNA/6",
400 + "image": null,
401 + "setLine": "1986-87 Fleer - [Base] #30",
402 + "price": 177.2
403 + },
404 + {
405 + "title": "Alex English [PSA/DNA Certified Authentic Auto]",
406 + "url": "https://www.comc.com/Cards/Basketball/1986-87/Fleer_-_Base/30/Alex_English/1395632/AftermarketAuto/PSA_DNA/Authentic",
407 + "image": null,
408 + "setLine": "1986-87 Fleer - [Base] #30",
409 + "price": 177.2
410 + }
411 + ]
412 + },
413 + "fetchedAt": "2026-09-07T07:12:14.540Z"
414 + },
415 + "expect": {
416 + "minCount": 1,
417 + "kinds": [
418 + "listing"
419 + ],
420 + "requiredFields": [
421 + "attributes.set",
422 + "price"
423 + ]
424 + },
425 + "note": "Live capture 2026-09-07 from https://www.comc.com/Cards/Basketball/1986/Fleer,sh",
426 + "capturedAt": "2026-09-07T07:12:14.547Z"
427 +}
\ No newline at end of file
added data/fixtures/mtgjson/lea-black-lotus.json +91 −0
@@ -0,0 +1,91 @@
1 +{
2 + "raw": {
3 + "url": "https://mtgjson.com/api/v5/LEA.json#d4d8c9f9-31ed-53ed-ab67-eba86e2198fe",
4 + "externalId": "d4d8c9f9-31ed-53ed-ab67-eba86e2198fe",
5 + "kind": "catalog_item",
6 + "engine": "api",
7 + "httpStatus": 200,
8 + "payload": {
9 + "set": {
10 + "code": "LEA",
11 + "name": "Limited Edition Alpha",
12 + "releaseDate": "1993-08-05",
13 + "type": "core",
14 + "tcgplayerGroupId": 7,
15 + "mcmId": 1,
16 + "isOnlineOnly": false,
17 + "totalSetSize": 295
18 + },
19 + "card": {
20 + "uuid": "d4d8c9f9-31ed-53ed-ab67-eba86e2198fe",
21 + "name": "Black Lotus",
22 + "number": "232",
23 + "rarity": "rare",
24 + "finishes": [
25 + "nonfoil"
26 + ],
27 + "language": "English",
28 + "identifiers": {
29 + "cardKingdomId": "64017",
30 + "cardsphereId": "24536",
31 + "deckboxId": "13483",
32 + "mcmId": "5465",
33 + "mcmMetaId": "548",
34 + "mtgjsonV4Id": "a8c6ee4c-19e6-5e14-8c73-776f203892f9",
35 + "mtgoId": "347",
36 + "multiverseId": "3",
37 + "scryfallCardBackId": "0aeebaf5-8c7d-4636-9e82-8c27447861f7",
38 + "scryfallId": "b0faa7f2-b547-42c4-a810-839da50dadfe",
39 + "scryfallIllustrationId": "54436824-977b-4dc7-8de1-8498e73e5ef2",
40 + "scryfallOracleId": "5089ec1a-f881-4d55-af14-5d996171203b",
41 + "tcgplayerProductId": "1042"
42 + },
43 + "borderColor": "black",
44 + "frameVersion": "1993",
45 + "isReserved": true,
46 + "artist": "Christopher Rush",
47 + "type": "Artifact",
48 + "manaCost": "{0}"
49 + },
50 + "prices": {
51 + "cardkingdom": {
52 + "currency": "USD",
53 + "retail": {
54 + "normal": {
55 + "2026-09-06": 149999.99
56 + }
57 + },
58 + "buylist": {
59 + "normal": {
60 + "2026-09-06": 68000
61 + }
62 + }
63 + },
64 + "cardmarket": {
65 + "currency": "EUR",
66 + "retail": {
67 + "normal": {
68 + "2026-09-06": 33768.47
69 + }
70 + },
71 + "buylist": {}
72 + }
73 + },
74 + "priceDate": "2026-09-06"
75 + },
76 + "fetchedAt": "2026-09-07T07:14:52.910Z"
77 + },
78 + "expect": {
79 + "minCount": 1,
80 + "kinds": [
81 + "catalog_item",
82 + "price_observation"
83 + ],
84 + "requiredFields": [
85 + "attributes.identifiers.scryfall_id",
86 + "attributes.setCode"
87 + ]
88 + },
89 + "note": "Live capture 2026-09-07 from https://mtgjson.com/api/v5/LEA.json#d4d8c9f9-31ed-53ed-ab67-eba86e2198fe",
90 + "capturedAt": "2026-09-07T07:14:52.943Z"
91 +}
\ No newline at end of file
added data/fixtures/mtgjson/lea-first.json +89 −0
@@ -0,0 +1,89 @@
1 +{
2 + "raw": {
3 + "url": "https://mtgjson.com/api/v5/LEA.json#2b304dc1-8d7d-50a7-a310-2d0e5427935f",
4 + "externalId": "2b304dc1-8d7d-50a7-a310-2d0e5427935f",
5 + "kind": "catalog_item",
6 + "engine": "api",
7 + "httpStatus": 200,
8 + "payload": {
9 + "set": {
10 + "code": "LEA",
11 + "name": "Limited Edition Alpha",
12 + "releaseDate": "1993-08-05",
13 + "type": "core",
14 + "tcgplayerGroupId": 7,
15 + "mcmId": 1,
16 + "isOnlineOnly": false,
17 + "totalSetSize": 295
18 + },
19 + "card": {
20 + "uuid": "2b304dc1-8d7d-50a7-a310-2d0e5427935f",
21 + "name": "Animate Wall",
22 + "number": "1",
23 + "rarity": "rare",
24 + "finishes": [
25 + "nonfoil"
26 + ],
27 + "language": "English",
28 + "identifiers": {
29 + "cardKingdomId": "64004",
30 + "cardsphereId": "24523",
31 + "deckboxId": "4850",
32 + "mcmId": "5418",
33 + "mcmMetaId": "202",
34 + "mtgjsonV4Id": "5b4a162f-c574-5f7e-a883-375aa3ba6642",
35 + "multiverseId": "232",
36 + "scryfallCardBackId": "0aeebaf5-8c7d-4636-9e82-8c27447861f7",
37 + "scryfallId": "d5c83259-9b90-47c2-b48e-c7d78519e792",
38 + "scryfallIllustrationId": "6757e04d-7bfc-4bdc-9dcb-02059a2d4e60",
39 + "scryfallOracleId": "c7a6a165-b709-46e0-ae42-6f69a17c0621",
40 + "tcgplayerProductId": "1029"
41 + },
42 + "borderColor": "black",
43 + "frameVersion": "1993",
44 + "artist": "Dan Frazier",
45 + "type": "Enchantment — Aura",
46 + "manaCost": "{W}"
47 + },
48 + "prices": {
49 + "cardmarket": {
50 + "currency": "EUR",
51 + "retail": {
52 + "normal": {
53 + "2026-09-06": 369.27
54 + }
55 + },
56 + "buylist": {}
57 + },
58 + "cardkingdom": {
59 + "currency": "USD",
60 + "retail": {
61 + "normal": {
62 + "2026-09-06": 379.99
63 + }
64 + },
65 + "buylist": {
66 + "normal": {
67 + "2026-09-06": 228
68 + }
69 + }
70 + }
71 + },
72 + "priceDate": "2026-09-06"
73 + },
74 + "fetchedAt": "2026-09-07T07:14:52.910Z"
75 + },
76 + "expect": {
77 + "minCount": 1,
78 + "kinds": [
79 + "catalog_item",
80 + "price_observation"
81 + ],
82 + "requiredFields": [
83 + "attributes.identifiers.scryfall_id",
84 + "attributes.setCode"
85 + ]
86 + },
87 + "note": "Live capture 2026-09-07 from https://mtgjson.com/api/v5/LEA.json#2b304dc1-8d7d-50a7-a310-2d0e5427935f",
88 + "capturedAt": "2026-09-07T07:14:52.916Z"
89 +}
\ No newline at end of file
added data/fixtures/myslabs/slab-first.json +36 −0
@@ -0,0 +1,36 @@
1 +{
2 + "raw": {
3 + "url": "https://myslabs.com/slab/view/1802432/",
4 + "externalId": "1802432",
5 + "kind": "listing",
6 + "engine": "api",
7 + "httpStatus": 200,
8 + "payload": {
9 + "id": "1802432",
10 + "name": "Stephen Curry 2017 Panini Aficionado Craftwork Golden State Warriors",
11 + "description": "&lt;p&gt;2017 Panini Aficionado Craftwork #45 features Stephen Curry of the Golden State Warriors. Issued by Panini for the NBA, this card comes from the 2017 Aficionado Craftwork set and is identified as card number 45. It is a clean collector piece for fans of Curry, the Warriors, and modern NBA inserts.&lt;/p&gt;",
12 + "images": [
13 + "https://cdn.myslabs.com/myslabs-prod/media/JKASLIE_1788752731_1.png?width=1200&quality=85",
14 + "https://cdn.myslabs.com/myslabs-prod/media/AYMSCHB_1788752731_2.jpg?width=1200&quality=85"
15 + ],
16 + "price": 33.33,
17 + "currency": "USD",
18 + "availability": "OutOfStock",
19 + "category": null,
20 + "seller": null
21 + },
22 + "fetchedAt": "2026-09-07T07:14:53.401Z"
23 + },
24 + "expect": {
25 + "minCount": 1,
26 + "kinds": [
27 + "listing"
28 + ],
29 + "requiredFields": [
30 + "attributes.identifiers.myslabs_id",
31 + "price"
32 + ]
33 + },
34 + "note": "Live capture 2026-09-07 from https://myslabs.com/slab/view/1802432/",
35 + "capturedAt": "2026-09-07T07:14:53.430Z"
36 +}
\ No newline at end of file
added data/fixtures/myslabs/slab-sixth.json +36 −0
@@ -0,0 +1,36 @@
1 +{
2 + "raw": {
3 + "url": "https://myslabs.com/slab/view/1802427/",
4 + "externalId": "1802427",
5 + "kind": "listing",
6 + "engine": "api",
7 + "httpStatus": 200,
8 + "payload": {
9 + "id": "1802427",
10 + "name": "Winterspell Pocahontas Legendary #22",
11 + "description": "&lt;p&gt;Disney Lorcana Winterspell Pocahontas Legendary card #22, manufactured by Ravensburger for the 2026 release, in Near Mint or Better condition. This is a clean example from the Winterspell set featuring Pocahontas, with the Legendary rarity noted on the card. A solid addition for collectors building the Disney Lorcana lineup or focusing on character cards from the Winterspell expansion.&lt;/p&gt;",
12 + "images": [
13 + "https://cdn.myslabs.com/myslabs-prod/media/YXIKMON_1788750936_1.png?width=1200&quality=85",
14 + "https://cdn.myslabs.com/myslabs-prod/media/LURXPXV_1788750936_2.jpg?width=1200&quality=85"
15 + ],
16 + "price": 37.49,
17 + "currency": "USD",
18 + "availability": "OutOfStock",
19 + "category": null,
20 + "seller": null
21 + },
22 + "fetchedAt": "2026-09-07T07:15:00.924Z"
23 + },
24 + "expect": {
25 + "minCount": 1,
26 + "kinds": [
27 + "listing"
28 + ],
29 + "requiredFields": [
30 + "attributes.identifiers.myslabs_id",
31 + "price"
32 + ]
33 + },
34 + "note": "Live capture 2026-09-07 from https://myslabs.com/slab/view/1802427/",
35 + "capturedAt": "2026-09-07T07:15:00.943Z"
36 +}
\ No newline at end of file
added data/fixtures/myslabs/slab-third.json +36 −0
@@ -0,0 +1,36 @@
1 +{
2 + "raw": {
3 + "url": "https://myslabs.com/slab/view/1802430/",
4 + "externalId": "1802430",
5 + "kind": "listing",
6 + "engine": "api",
7 + "httpStatus": 200,
8 + "payload": {
9 + "id": "1802430",
10 + "name": "Sekou Doumbouya 2020 Panini Select Green Pulsar /5 Detroit Pistons",
11 + "description": "&lt;p&gt;2020 Panini Select #71 Sekou Doumbouya Green Pulsar, serial numbered 5/5, features the Detroit Pistons forward in the NBA Select set from Panini. This low-numbered parallel comes from the 2020 release and matches the Green Pulsar variety noted on the card.&lt;/p&gt;",
12 + "images": [
13 + "https://cdn.myslabs.com/myslabs-prod/media/MKWRVOF_1788752725_1.png?width=1200&quality=85",
14 + "https://cdn.myslabs.com/myslabs-prod/media/VMUIRFK_1788752726_2.jpg?width=1200&quality=85"
15 + ],
16 + "price": 33.33,
17 + "currency": "USD",
18 + "availability": "OutOfStock",
19 + "category": null,
20 + "seller": null
21 + },
22 + "fetchedAt": "2026-09-07T07:14:56.500Z"
23 + },
24 + "expect": {
25 + "minCount": 1,
26 + "kinds": [
27 + "listing"
28 + ],
29 + "requiredFields": [
30 + "attributes.identifiers.myslabs_id",
31 + "price"
32 + ]
33 + },
34 + "note": "Live capture 2026-09-07 from https://myslabs.com/slab/view/1802430/",
35 + "capturedAt": "2026-09-07T07:14:56.536Z"
36 +}
\ No newline at end of file
added data/fixtures/tag-pop/pokemon-1999-base-set.json +1971 −0
@@ -0,0 +1,1971 @@
1 +{
2 + "raw": {
3 + "url": "https://my.taggrading.com/pop-report/Pokemon/1999/WOTC%20Pok%C3%A9mon?setName=Base%20Set",
4 + "externalId": "Pokemon|1999|WOTC Pokémon|Base Set",
5 + "kind": "population_report",
6 + "engine": "firecrawl",
7 + "httpStatus": 200,
8 + "payload": {
9 + "category": "Pokemon",
10 + "year": "1999",
11 + "company": "WOTC Pokémon",
12 + "setName": "Base Set",
13 + "grades": [
14 + "authentic",
15 + "1",
16 + "1.5",
17 + "2",
18 + "2.5",
19 + "3",
20 + "3.5",
21 + "4",
22 + "4.5",
23 + "5",
24 + "5.5",
25 + "6",
26 + "6.5",
27 + "7",
28 + "7.5",
29 + "8",
30 + "8.5",
31 + "9",
32 + "10",
33 + "10P"
34 + ],
35 + "rows": [
36 + {
37 + "number": "1/102",
38 + "name": "Alakazam",
39 + "variation": "Holo",
40 + "url": "https://my.taggrading.com/pop-report/Pokemon/1999/WOTC%20Pok%C3%A9mon/Alakazam/1%2F102?setName=Base+Set&variation=Holo",
41 + "counts": {
42 + "1": 2,
43 + "2": 5,
44 + "3": 7,
45 + "4": 13,
46 + "5": 15,
47 + "6": 26,
48 + "7": 18,
49 + "8": 26,
50 + "9": 26,
51 + "10": 2,
52 + "authentic": 2,
53 + "1.5": 1,
54 + "2.5": 2,
55 + "3.5": 4,
56 + "4.5": 12,
57 + "5.5": 13,
58 + "6.5": 20,
59 + "7.5": 27,
60 + "8.5": 30
61 + },
62 + "total": 251
63 + },
64 + {
65 + "number": "2/102",
66 + "name": "Blastoise",
67 + "variation": "Holo - Missing Stage",
68 + "url": "https://my.taggrading.com/pop-report/Pokemon/1999/WOTC%20Pok%C3%A9mon/Blastoise/2%2F102?setName=Base+Set&variation=Holo+-+Missing+Stage",
69 + "counts": {
70 + "8": 2
71 + },
72 + "total": 2
73 + },
74 + {
75 + "number": "2/102",
76 + "name": "Blastoise",
77 + "variation": "Holo",
78 + "url": "https://my.taggrading.com/pop-report/Pokemon/1999/WOTC%20Pok%C3%A9mon/Blastoise/2%2F102?setName=Base+Set&variation=Holo",
79 + "counts": {
80 + "1": 11,
81 + "2": 14,
82 + "3": 48,
83 + "4": 55,
84 + "5": 55,
85 + "6": 81,
86 + "7": 54,
87 + "8": 58,
88 + "9": 27,
89 + "authentic": 6,
90 + "1.5": 5,
91 + "2.5": 17,
92 + "3.5": 21,
93 + "4.5": 38,
94 + "5.5": 35,
95 + "6.5": 39,
96 + "7.5": 49,
97 + "8.5": 44
98 + },
99 + "total": 657
100 + },
101 + {
102 + "number": "3/102",
103 + "name": "Chansey",
104 + "variation": "Holo",
105 + "url": "https://my.taggrading.com/pop-report/Pokemon/1999/WOTC%20Pok%C3%A9mon/Chansey/3%2F102?setName=Base+Set&variation=Holo",
106 + "counts": {
107 + "1": 1,
108 + "2": 1,
109 + "3": 7,
110 + "4": 10,
111 + "5": 11,
112 + "6": 25,
113 + "7": 18,
114 + "8": 23,
115 + "9": 11,
116 + "10": 1,
117 + "authentic": 3,
118 + "1.5": 1,
119 + "3.5": 3,
120 + "4.5": 6,
121 + "5.5": 8,
122 + "6.5": 14,
123 + "7.5": 15,
124 + "8.5": 19
125 + },
126 + "total": 177
127 + },
128 + {
129 + "number": "4/102",
130 + "name": "Charizard",
131 + "variation": "Holo",
132 + "url": "https://my.taggrading.com/pop-report/Pokemon/1999/WOTC%20Pok%C3%A9mon/Charizard/4%2F102?setName=Base+Set&variation=Holo",
133 + "counts": {
134 + "1": 34,
135 + "2": 40,
136 + "3": 101,
137 + "4": 119,
138 + "5": 138,
139 + "6": 150,
140 + "7": 98,
141 + "8": 106,
142 + "9": 45,
143 + "10": 2,
144 + "authentic": 15,
145 + "1.5": 22,
146 + "2.5": 40,
147 + "3.5": 50,
148 + "4.5": 63,
149 + "5.5": 65,
150 + "6.5": 87,
151 + "7.5": 87,
152 + "8.5": 73
153 + },
154 + "total": 1335
155 + },
156 + {
157 + "number": "4/102",
158 + "name": "Charizard",
159 + "variation": "Holo - Black Dot Error",
160 + "url": "https://my.taggrading.com/pop-report/Pokemon/1999/WOTC%20Pok%C3%A9mon/Charizard/4%2F102?setName=Base+Set&variation=Holo+-+Black+Dot+Error",
161 + "counts": {
162 + "7": 1,
163 + "8": 1,
164 + "4.5": 1
165 + },
166 + "total": 3
167 + },
168 + {
169 + "number": "5/102",
170 + "name": "Clefairy",
171 + "variation": "Holo",
172 + "url": "https://my.taggrading.com/pop-report/Pokemon/1999/WOTC%20Pok%C3%A9mon/Clefairy/5%2F102?setName=Base+Set&variation=Holo",
173 + "counts": {
174 + "3": 5,
175 + "4": 4,
176 + "5": 10,
177 + "6": 15,
178 + "7": 13,
179 + "8": 10,
180 + "9": 14,
181 + "authentic": 2,
182 + "2.5": 2,
183 + "3.5": 3,
184 + "4.5": 5,
185 + "5.5": 3,
186 + "6.5": 5,
187 + "7.5": 14,
188 + "8.5": 21
189 + },
190 + "total": 126
191 + },
192 + {
193 + "number": "6/102",
194 + "name": "Gyarados",
195 + "variation": "Holo",
196 + "url": "https://my.taggrading.com/pop-report/Pokemon/1999/WOTC%20Pok%C3%A9mon/Gyarados/6%2F102?setName=Base+Set&variation=Holo",
197 + "counts": {
198 + "1": 4,
199 + "2": 2,
200 + "3": 21,
201 + "4": 16,
202 + "5": 29,
203 + "6": 28,
204 + "7": 27,
205 + "8": 29,
206 + "9": 29,
207 + "10": 1,
208 + "authentic": 1,
209 + "1.5": 1,
210 + "3.5": 14,
211 + "4.5": 11,
212 + "5.5": 15,
213 + "6.5": 17,
214 + "7.5": 28,
215 + "8.5": 34
216 + },
217 + "total": 307
218 + },
219 + {
220 + "number": "7/102",
221 + "name": "Hitmonchan",
222 + "variation": "Holo",
223 + "url": "https://my.taggrading.com/pop-report/Pokemon/1999/WOTC%20Pok%C3%A9mon/Hitmonchan/7%2F102?setName=Base+Set&variation=Holo",
224 + "counts": {
225 + "1": 1,
226 + "2": 1,
227 + "3": 5,
228 + "4": 9,
229 + "5": 16,
230 + "6": 13,
231 + "7": 14,
232 + "8": 19,
233 + "9": 17,
234 + "authentic": 1,
235 + "1.5": 1,
236 + "2.5": 1,
237 + "3.5": 7,
238 + "4.5": 6,
239 + "5.5": 7,
240 + "6.5": 9,
241 + "7.5": 16,
242 + "8.5": 20
243 + },
244 + "total": 163
245 + },
246 + {
247 + "number": "8/102",
248 + "name": "Machamp",
249 + "variation": "Holo",
250 + "url": "https://my.taggrading.com/pop-report/Pokemon/1999/WOTC%20Pok%C3%A9mon/Machamp/8%2F102?setName=Base+Set&variation=Holo",
251 + "counts": {
252 + "2": 1,
253 + "3": 1,
254 + "4": 2,
255 + "5": 1,
256 + "6": 2,
257 + "7": 3,
258 + "8": 1,
259 + "3.5": 2,
260 + "4.5": 1,
261 + "6.5": 2,
262 + "7.5": 1,
263 + "8.5": 1
264 + },
265 + "total": 18
266 + },
267 + {
268 + "number": "9/102",
269 + "name": "Magneton",
270 + "variation": "Holo",
271 + "url": "https://my.taggrading.com/pop-report/Pokemon/1999/WOTC%20Pok%C3%A9mon/Magneton/9%2F102?setName=Base+Set&variation=Holo",
272 + "counts": {
273 + "1": 1,
274 + "2": 2,
275 + "3": 3,
276 + "4": 8,
277 + "5": 9,
278 + "6": 14,
279 + "7": 16,
280 + "8": 17,
281 + "9": 7,
282 + "2.5": 1,
283 + "3.5": 2,
284 + "4.5": 4,
285 + "5.5": 5,
286 + "6.5": 12,
287 + "7.5": 13,
288 + "8.5": 15
289 + },
290 + "total": 129
291 + },
292 + {
293 + "number": "10/102",
294 + "name": "Mewtwo",
295 + "variation": "Holo",
296 + "url": "https://my.taggrading.com/pop-report/Pokemon/1999/WOTC%20Pok%C3%A9mon/Mewtwo/10%2F102?setName=Base+Set&variation=Holo",
297 + "counts": {
298 + "1": 4,
299 + "2": 4,
300 + "3": 20,
301 + "4": 33,
302 + "5": 40,
303 + "6": 46,
304 + "7": 44,
305 + "8": 43,
306 + "9": 39,
307 + "10": 3,
308 + "authentic": 3,
309 + "1.5": 2,
310 + "2.5": 2,
311 + "3.5": 13,
312 + "4.5": 21,
313 + "5.5": 25,
314 + "6.5": 35,
315 + "7.5": 37,
316 + "8.5": 31
317 + },
318 + "total": 445
319 + },
320 + {
321 + "number": "11/102",
322 + "name": "Nidoking",
323 + "variation": "Holo",
324 + "url": "https://my.taggrading.com/pop-report/Pokemon/1999/WOTC%20Pok%C3%A9mon/Nidoking/11%2F102?setName=Base+Set&variation=Holo",
325 + "counts": {
326 + "2": 1,
327 + "3": 6,
328 + "4": 6,
329 + "5": 17,
330 + "6": 17,
331 + "7": 12,
332 + "8": 18,
333 + "9": 14,
334 + "10": 1,
335 + "authentic": 1,
336 + "2.5": 1,
337 + "3.5": 3,
338 + "4.5": 4,
339 + "5.5": 2,
340 + "6.5": 7,
341 + "7.5": 12,
342 + "8.5": 19
343 + },
344 + "total": 141
345 + },
346 + {
347 + "number": "12/102",
348 + "name": "Ninetales",
349 + "variation": "Holo",
350 + "url": "https://my.taggrading.com/pop-report/Pokemon/1999/WOTC%20Pok%C3%A9mon/Ninetales/12%2F102?setName=Base+Set&variation=Holo",
351 + "counts": {
352 + "2": 6,
353 + "3": 8,
354 + "4": 13,
355 + "5": 19,
356 + "6": 28,
357 + "7": 25,
358 + "8": 21,
359 + "9": 28,
360 + "10": 3,
361 + "authentic": 2,
362 + "2.5": 1,
363 + "3.5": 8,
364 + "4.5": 11,
365 + "5.5": 8,
366 + "6.5": 11,
367 + "7.5": 26,
368 + "8.5": 27
369 + },
370 + "total": 245
371 + },
372 + {
373 + "number": "13/102",
374 + "name": "Poliwrath",
375 + "variation": "Holo",
376 + "url": "https://my.taggrading.com/pop-report/Pokemon/1999/WOTC%20Pok%C3%A9mon/Poliwrath/13%2F102?setName=Base+Set&variation=Holo",
377 + "counts": {
378 + "1": 1,
379 + "3": 1,
380 + "4": 3,
381 + "5": 7,
382 + "6": 10,
383 + "7": 10,
384 + "8": 12,
385 + "9": 15,
386 + "10": 1,
387 + "authentic": 1,
388 + "2.5": 2,
389 + "3.5": 4,
390 + "4.5": 2,
391 + "5.5": 6,
392 + "6.5": 9,
393 + "7.5": 17,
394 + "8.5": 17
395 + },
396 + "total": 118
397 + },
398 + {
399 + "number": "14/102",
400 + "name": "Raichu",
401 + "variation": "Holo",
402 + "url": "https://my.taggrading.com/pop-report/Pokemon/1999/WOTC%20Pok%C3%A9mon/Raichu/14%2F102?setName=Base+Set&variation=Holo",
403 + "counts": {
404 + "1": 1,
405 + "2": 1,
406 + "3": 4,
407 + "4": 11,
408 + "5": 21,
409 + "6": 21,
410 + "7": 19,
411 + "8": 25,
412 + "9": 15,
413 + "authentic": 2,
414 + "1.5": 1,
415 + "2.5": 5,
416 + "3.5": 1,
417 + "4.5": 8,
418 + "5.5": 4,
419 + "6.5": 13,
420 + "7.5": 34,
421 + "8.5": 23
422 + },
423 + "total": 209
424 + },
425 + {
426 + "number": "15/102",
427 + "name": "Venusaur",
428 + "variation": "Holo",
429 + "url": "https://my.taggrading.com/pop-report/Pokemon/1999/WOTC%20Pok%C3%A9mon/Venusaur/15%2F102?setName=Base+Set&variation=Holo",
430 + "counts": {
431 + "1": 3,
432 + "2": 10,
433 + "3": 33,
434 + "4": 51,
435 + "5": 51,
436 + "6": 58,
437 + "7": 50,
438 + "8": 48,
439 + "9": 30,
440 + "10": 3,
441 + "authentic": 5,
442 + "1.5": 4,
443 + "2.5": 16,
444 + "3.5": 12,
445 + "4.5": 35,
446 + "5.5": 34,
447 + "6.5": 44,
448 + "7.5": 43,
449 + "8.5": 40
450 + },
451 + "total": 570
452 + },
453 + {
454 + "number": "16/102",
455 + "name": "Zapdos",
456 + "variation": "Holo",
457 + "url": "https://my.taggrading.com/pop-report/Pokemon/1999/WOTC%20Pok%C3%A9mon/Zapdos/16%2F102?setName=Base+Set&variation=Holo",
458 + "counts": {
459 + "1": 2,
460 + "2": 3,
461 + "3": 7,
462 + "4": 13,
463 + "5": 17,
464 + "6": 15,
465 + "7": 28,
466 + "8": 34,
467 + "9": 18,
468 + "10": 1,
469 + "authentic": 1,
470 + "1.5": 1,
471 + "2.5": 2,
472 + "3.5": 6,
473 + "4.5": 11,
474 + "5.5": 13,
475 + "6.5": 23,
476 + "7.5": 21,
477 + "8.5": 18
478 + },
479 + "total": 234
480 + },
481 + {
482 + "number": "17/102",
483 + "name": "Beedrill",
484 + "variation": null,
485 + "url": "https://my.taggrading.com/pop-report/Pokemon/1999/WOTC%20Pok%C3%A9mon/Beedrill/17%2F102?setName=Base+Set",
486 + "counts": {
487 + "4": 1,
488 + "5": 1,
489 + "6": 1,
490 + "9": 6,
491 + "8.5": 2
492 + },
493 + "total": 11
494 + },
495 + {
496 + "number": "18/102",
497 + "name": "Dragonair",
498 + "variation": null,
499 + "url": "https://my.taggrading.com/pop-report/Pokemon/1999/WOTC%20Pok%C3%A9mon/Dragonair/18%2F102?setName=Base+Set",
500 + "counts": {
501 + "4": 2,
502 + "5": 3,
503 + "6": 1,
504 + "7": 4,
505 + "8": 3,
506 + "9": 6,
507 + "1.5": 1,
508 + "4.5": 1,
509 + "5.5": 2,
510 + "6.5": 2,
511 + "7.5": 6,
512 + "8.5": 1
513 + },
514 + "total": 32
515 + },
516 + {
517 + "number": "19/102",
518 + "name": "Dugtrio",
519 + "variation": null,
520 + "url": "https://my.taggrading.com/pop-report/Pokemon/1999/WOTC%20Pok%C3%A9mon/Dugtrio/19%2F102?setName=Base+Set",
521 + "counts": {
522 + "3": 1,
523 + "4": 1,
524 + "6": 1,
525 + "7": 2,
526 + "9": 3,
527 + "2.5": 1,
528 + "5.5": 1,
529 + "6.5": 1,
530 + "8.5": 1
531 + },
532 + "total": 12
533 + },
534 + {
535 + "number": "20/102",
536 + "name": "Electabuzz",
537 + "variation": null,
538 + "url": "https://my.taggrading.com/pop-report/Pokemon/1999/WOTC%20Pok%C3%A9mon/Electabuzz/20%2F102?setName=Base+Set",
539 + "counts": {
540 + "5": 1,
541 + "6": 1,
542 + "8": 4,
543 + "9": 6,
544 + "10": 1,
545 + "6.5": 3,
546 + "8.5": 2
547 + },
548 + "total": 18
549 + },
550 + {
551 + "number": "21/102",
552 + "name": "Electrode",
553 + "variation": null,
554 + "url": "https://my.taggrading.com/pop-report/Pokemon/1999/WOTC%20Pok%C3%A9mon/Electrode/21%2F102?setName=Base+Set",
555 + "counts": {
556 + "3": 1,
557 + "5": 2,
558 + "6": 2,
559 + "8": 2,
560 + "9": 2,
561 + "6.5": 2,
562 + "7.5": 4,
563 + "8.5": 1
564 + },
565 + "total": 16
566 + },
567 + {
568 + "number": "22/102",
569 + "name": "Pidgeotto",
570 + "variation": null,
571 + "url": "https://my.taggrading.com/pop-report/Pokemon/1999/WOTC%20Pok%C3%A9mon/Pidgeotto/22%2F102?setName=Base+Set",
572 + "counts": {
573 + "5": 1,
574 + "8": 3,
575 + "9": 5,
576 + "8.5": 3
577 + },
578 + "total": 12
579 + },
580 + {
581 + "number": "23/102",
582 + "name": "Arcanine",
583 + "variation": null,
584 + "url": "https://my.taggrading.com/pop-report/Pokemon/1999/WOTC%20Pok%C3%A9mon/Arcanine/23%2F102?setName=Base+Set",
585 + "counts": {
586 + "4": 2,
587 + "5": 2,
588 + "8": 11,
589 + "9": 8,
590 + "10": 1,
591 + "2.5": 1,
592 + "6.5": 2,
593 + "7.5": 5,
594 + "8.5": 10
595 + },
596 + "total": 42
597 + },
598 + {
599 + "number": "24/102",
600 + "name": "Charmeleon",
601 + "variation": null,
602 + "url": "https://my.taggrading.com/pop-report/Pokemon/1999/WOTC%20Pok%C3%A9mon/Charmeleon/24%2F102?setName=Base+Set",
603 + "counts": {
604 + "3": 1,
605 + "4": 2,
606 + "5": 5,
607 + "6": 4,
608 + "7": 4,
609 + "8": 8,
610 + "9": 16,
611 + "10": 3,
612 + "2.5": 2,
613 + "4.5": 2,
614 + "6.5": 5,
615 + "7.5": 2,
616 + "8.5": 5
617 + },
618 + "total": 59
619 + },
620 + {
621 + "number": "25/102",
622 + "name": "Dewgong",
623 + "variation": null,
624 + "url": "https://my.taggrading.com/pop-report/Pokemon/1999/WOTC%20Pok%C3%A9mon/Dewgong/25%2F102?setName=Base+Set",
625 + "counts": {
626 + "6": 1,
627 + "7": 1,
628 + "8": 6,
629 + "9": 6,
630 + "7.5": 2,
631 + "8.5": 4
632 + },
633 + "total": 20
634 + },
635 + {
636 + "number": "26/102",
637 + "name": "Dratini",
638 + "variation": null,
639 + "url": "https://my.taggrading.com/pop-report/Pokemon/1999/WOTC%20Pok%C3%A9mon/Dratini/26%2F102?setName=Base+Set",
640 + "counts": {
641 + "3": 1,
642 + "5": 2,
643 + "6": 1,
644 + "7": 1,
645 + "8": 2,
646 + "9": 7,
647 + "10": 2,
648 + "3.5": 1,
649 + "6.5": 3,
650 + "7.5": 4,
651 + "8.5": 3
652 + },
653 + "total": 27
654 + },
655 + {
656 + "number": "27/102",
657 + "name": "Farfetch'd",
658 + "variation": null,
659 + "url": "https://my.taggrading.com/pop-report/Pokemon/1999/WOTC%20Pok%C3%A9mon/Farfetch'd/27%2F102?setName=Base+Set",
660 + "counts": {
661 + "6": 2,
662 + "7": 1,
663 + "8": 6,
664 + "9": 7,
665 + "10": 2,
666 + "4.5": 1,
667 + "6.5": 1,
668 + "7.5": 1,
669 + "8.5": 6
670 + },
671 + "total": 27
672 + },
673 + {
674 + "number": "28/102",
675 + "name": "Growlithe",
676 + "variation": null,
677 + "url": "https://my.taggrading.com/pop-report/Pokemon/1999/WOTC%20Pok%C3%A9mon/Growlithe/28%2F102?setName=Base+Set",
678 + "counts": {
679 + "5": 1,
680 + "6": 3,
681 + "7": 7,
682 + "8": 18,
683 + "9": 18,
684 + "10": 2,
685 + "authentic": 1,
686 + "2.5": 1,
687 + "3.5": 2,
688 + "6.5": 1,
689 + "7.5": 13,
690 + "8.5": 15
691 + },
692 + "total": 82
693 + },
694 + {
695 + "number": "29/102",
696 + "name": "Haunter",
697 + "variation": null,
698 + "url": "https://my.taggrading.com/pop-report/Pokemon/1999/WOTC%20Pok%C3%A9mon/Haunter/29%2F102?setName=Base+Set",
699 + "counts": {
700 + "3": 1,
701 + "6": 1,
702 + "7": 3,
703 + "8": 18,
704 + "9": 13,
705 + "10": 2,
706 + "5.5": 1,
707 + "6.5": 1,
708 + "7.5": 9,
709 + "8.5": 22
710 + },
711 + "total": 71
712 + },
713 + {
714 + "number": "30/102",
715 + "name": "Ivysaur",
716 + "variation": null,
717 + "url": "https://my.taggrading.com/pop-report/Pokemon/1999/WOTC%20Pok%C3%A9mon/Ivysaur/30%2F102?setName=Base+Set",
718 + "counts": {
719 + "4": 1,
720 + "5": 2,
721 + "6": 2,
722 + "7": 7,
723 + "8": 8,
724 + "9": 13,
725 + "10": 2,
726 + "6.5": 4,
727 + "7.5": 9,
728 + "8.5": 11
729 + },
730 + "total": 59
731 + },
732 + {
733 + "number": "31/102",
734 + "name": "Jynx",
735 + "variation": null,
736 + "url": "https://my.taggrading.com/pop-report/Pokemon/1999/WOTC%20Pok%C3%A9mon/Jynx/31%2F102?setName=Base+Set",
737 + "counts": {
738 + "6": 1,
739 + "7": 3,
740 + "8": 4,
741 + "9": 9,
742 + "10": 4,
743 + "2.5": 1,
744 + "4.5": 1,
745 + "6.5": 1,
746 + "7.5": 2,
747 + "8.5": 8
748 + },
749 + "total": 34
750 + },
751 + {
752 + "number": "32/102",
753 + "name": "Kadabra",
754 + "variation": null,
755 + "url": "https://my.taggrading.com/pop-report/Pokemon/1999/WOTC%20Pok%C3%A9mon/Kadabra/32%2F102?setName=Base+Set",
756 + "counts": {
757 + "2": 1,
758 + "3": 1,
759 + "6": 1,
760 + "7": 3,
761 + "8": 12,
762 + "9": 12,
763 + "10": 8,
764 + "6.5": 1,
765 + "7.5": 5,
766 + "8.5": 13
767 + },
768 + "total": 57
769 + },
770 + {
771 + "number": "33/102",
772 + "name": "Kakuna",
773 + "variation": null,
774 + "url": "https://my.taggrading.com/pop-report/Pokemon/1999/WOTC%20Pok%C3%A9mon/Kakuna/33%2F102?setName=Base+Set",
775 + "counts": {
776 + "6": 2,
777 + "7": 5,
778 + "8": 16,
779 + "9": 14,
780 + "10": 1,
781 + "4.5": 1,
782 + "5.5": 1,
783 + "6.5": 1,
784 + "7.5": 4,
785 + "8.5": 25
786 + },
787 + "total": 70
788 + },
789 + {
790 + "number": "34/102",
791 + "name": "Machoke",
792 + "variation": null,
793 + "url": "https://my.taggrading.com/pop-report/Pokemon/1999/WOTC%20Pok%C3%A9mon/Machoke/34%2F102?setName=Base+Set",
794 + "counts": {
795 + "2": 1,
796 + "5": 1,
797 + "6": 1,
798 + "7": 3,
799 + "8": 6,
800 + "9": 15,
801 + "10": 3,
802 + "4.5": 2,
803 + "6.5": 1,
804 + "7.5": 2,
805 + "8.5": 16
806 + },
807 + "total": 51
808 + },
809 + {
810 + "number": "35/102",
811 + "name": "Magikarp",
812 + "variation": null,
813 + "url": "https://my.taggrading.com/pop-report/Pokemon/1999/WOTC%20Pok%C3%A9mon/Magikarp/35%2F102?setName=Base+Set",
814 + "counts": {
815 + "6": 1,
816 + "8": 7,
817 + "9": 18,
818 + "10": 4,
819 + "4.5": 1,
820 + "7.5": 3,
821 + "8.5": 10
822 + },
823 + "total": 44
824 + },
825 + {
826 + "number": "36/102",
827 + "name": "Magmar",
828 + "variation": null,
829 + "url": "https://my.taggrading.com/pop-report/Pokemon/1999/WOTC%20Pok%C3%A9mon/Magmar/36%2F102?setName=Base+Set",
830 + "counts": {
831 + "4": 1,
832 + "7": 4,
833 + "8": 7,
834 + "9": 6,
835 + "10": 1,
836 + "1.5": 1,
837 + "7.5": 10,
838 + "8.5": 13
839 + },
840 + "total": 43
841 + },
842 + {
843 + "number": "37/102",
844 + "name": "Nidorino",
845 + "variation": null,
846 + "url": "https://my.taggrading.com/pop-report/Pokemon/1999/WOTC%20Pok%C3%A9mon/Nidorino/37%2F102?setName=Base+Set",
847 + "counts": {
848 + "5": 1,
849 + "6": 1,
850 + "7": 1,
851 + "8": 24,
852 + "9": 16,
853 + "10": 2,
854 + "5.5": 1,
855 + "6.5": 2,
856 + "7.5": 6,
857 + "8.5": 30
858 + },
859 + "total": 84
860 + },
861 + {
862 + "number": "38/102",
863 + "name": "Poliwhirl",
864 + "variation": null,
865 + "url": "https://my.taggrading.com/pop-report/Pokemon/1999/WOTC%20Pok%C3%A9mon/Poliwhirl/38%2F102?setName=Base+Set",
866 + "counts": {
867 + "7": 1,
868 + "8": 8,
869 + "9": 11,
870 + "10": 1,
871 + "1.5": 1,
872 + "4.5": 1,
873 + "5.5": 2,
874 + "7.5": 8,
875 + "8.5": 12
876 + },
877 + "total": 45
878 + },
879 + {
880 + "number": "39/102",
881 + "name": "Porygon",
882 + "variation": null,
883 + "url": "https://my.taggrading.com/pop-report/Pokemon/1999/WOTC%20Pok%C3%A9mon/Porygon/39%2F102?setName=Base+Set",
884 + "counts": {
885 + "2": 1,
886 + "3": 1,
887 + "5": 2,
888 + "6": 2,
889 + "7": 1,
890 + "8": 4,
891 + "9": 8,
892 + "10": 5,
893 + "3.5": 1,
894 + "8.5": 8
895 + },
896 + "total": 33
897 + },
898 + {
899 + "number": "40/102",
900 + "name": "Raticate",
901 + "variation": null,
902 + "url": "https://my.taggrading.com/pop-report/Pokemon/1999/WOTC%20Pok%C3%A9mon/Raticate/40%2F102?setName=Base+Set",
903 + "counts": {
904 + "5": 1,
905 + "7": 2,
906 + "9": 6,
907 + "10": 2,
908 + "6.5": 3,
909 + "8.5": 8
910 + },
911 + "total": 22
912 + },
913 + {
914 + "number": "41/102",
915 + "name": "Seel",
916 + "variation": null,
917 + "url": "https://my.taggrading.com/pop-report/Pokemon/1999/WOTC%20Pok%C3%A9mon/Seel/41%2F102?setName=Base+Set",
918 + "counts": {
919 + "7": 2,
920 + "8": 6,
921 + "9": 12,
922 + "10": 2,
923 + "6.5": 1,
924 + "7.5": 3,
925 + "8.5": 20
926 + },
927 + "total": 46
928 + },
929 + {
930 + "number": "42/102",
931 + "name": "Wartortle",
932 + "variation": null,
933 + "url": "https://my.taggrading.com/pop-report/Pokemon/1999/WOTC%20Pok%C3%A9mon/Wartortle/42%2F102?setName=Base+Set",
934 + "counts": {
935 + "4": 1,
936 + "5": 2,
937 + "6": 6,
938 + "7": 2,
939 + "8": 5,
940 + "9": 9,
941 + "10": 1,
942 + "2.5": 1,
943 + "4.5": 3,
944 + "5.5": 4,
945 + "6.5": 3,
946 + "7.5": 3,
947 + "8.5": 4
948 + },
949 + "total": 44
950 + },
951 + {
952 + "number": "43/102",
953 + "name": "Abra",
954 + "variation": null,
955 + "url": "https://my.taggrading.com/pop-report/Pokemon/1999/WOTC%20Pok%C3%A9mon/Abra/43%2F102?setName=Base+Set",
956 + "counts": {
957 + "5": 1,
958 + "6": 3,
959 + "7": 2,
960 + "8": 28,
961 + "9": 69,
962 + "10": 12,
963 + "3.5": 1,
964 + "6.5": 2,
965 + "7.5": 10,
966 + "8.5": 52
967 + },
968 + "total": 180
969 + },
970 + {
971 + "number": "44/102",
972 + "name": "Bulbasaur",
973 + "variation": null,
974 + "url": "https://my.taggrading.com/pop-report/Pokemon/1999/WOTC%20Pok%C3%A9mon/Bulbasaur/44%2F102?setName=Base+Set",
975 + "counts": {
976 + "3": 1,
977 + "4": 4,
978 + "5": 9,
979 + "6": 9,
980 + "7": 5,
981 + "8": 23,
982 + "9": 21,
983 + "10": 2,
984 + "2.5": 2,
985 + "3.5": 1,
986 + "4.5": 2,
987 + "6.5": 5,
988 + "7.5": 3,
989 + "8.5": 13
990 + },
991 + "total": 100
992 + },
993 + {
994 + "number": "45/102",
995 + "name": "Caterpie",
996 + "variation": null,
997 + "url": "https://my.taggrading.com/pop-report/Pokemon/1999/WOTC%20Pok%C3%A9mon/Caterpie/45%2F102?setName=Base+Set",
998 + "counts": {
999 + "6": 1,
1000 + "7": 1,
1001 + "8": 20,
1002 + "9": 41,
1003 + "10": 7,
1004 + "3.5": 1,
1005 + "7.5": 12,
1006 + "8.5": 23
1007 + },
1008 + "total": 106
1009 + },
1010 + {
1011 + "number": "46/102",
1012 + "name": "Charmander",
1013 + "variation": null,
1014 + "url": "https://my.taggrading.com/pop-report/Pokemon/1999/WOTC%20Pok%C3%A9mon/Charmander/46%2F102?setName=Base+Set",
1015 + "counts": {
1016 + "2": 2,
1017 + "3": 3,
1018 + "4": 5,
1019 + "5": 4,
1020 + "6": 9,
1021 + "7": 9,
1022 + "8": 17,
1023 + "9": 29,
1024 + "10": 4,
1025 + "1.5": 2,
1026 + "3.5": 1,
1027 + "4.5": 5,
1028 + "5.5": 5,
1029 + "6.5": 4,
1030 + "7.5": 10,
1031 + "8.5": 23
1032 + },
1033 + "total": 132
1034 + },
1035 + {
1036 + "number": "47/102",
1037 + "name": "Diglett",
1038 + "variation": null,
1039 + "url": "https://my.taggrading.com/pop-report/Pokemon/1999/WOTC%20Pok%C3%A9mon/Diglett/47%2F102?setName=Base+Set",
1040 + "counts": {
1041 + "6": 2,
1042 + "7": 7,
1043 + "8": 17,
1044 + "9": 27,
1045 + "10": 3,
1046 + "6.5": 5,
1047 + "7.5": 10,
1048 + "8.5": 33
1049 + },
1050 + "total": 104
1051 + },
1052 + {
1053 + "number": "48/102",
1054 + "name": "Doduo",
1055 + "variation": null,
1056 + "url": "https://my.taggrading.com/pop-report/Pokemon/1999/WOTC%20Pok%C3%A9mon/Doduo/48%2F102?setName=Base+Set",
1057 + "counts": {
1058 + "7": 5,
1059 + "8": 11,
1060 + "9": 18,
1061 + "10": 6,
1062 + "6.5": 4,
1063 + "7.5": 2,
1064 + "8.5": 18
1065 + },
1066 + "total": 64
1067 + },
1068 + {
1069 + "number": "49/102",
1070 + "name": "Drowzee",
1071 + "variation": null,
1072 + "url": "https://my.taggrading.com/pop-report/Pokemon/1999/WOTC%20Pok%C3%A9mon/Drowzee/49%2F102?setName=Base+Set",
1073 + "counts": {
1074 + "4": 2,
1075 + "5": 1,
1076 + "6": 4,
1077 + "7": 2,
1078 + "8": 11,
1079 + "9": 34,
1080 + "10": 9,
1081 + "6.5": 1,
1082 + "7.5": 6,
1083 + "8.5": 36
1084 + },
1085 + "total": 106
1086 + },
1087 + {
1088 + "number": "50/102",
1089 + "name": "Gastly",
1090 + "variation": null,
1091 + "url": "https://my.taggrading.com/pop-report/Pokemon/1999/WOTC%20Pok%C3%A9mon/Gastly/50%2F102?setName=Base+Set",
1092 + "counts": {
1093 + "4": 2,
1094 + "6": 1,
1095 + "7": 2,
1096 + "8": 7,
1097 + "9": 34,
1098 + "10": 6,
1099 + "5.5": 1,
1100 + "6.5": 1,
1101 + "7.5": 3,
1102 + "8.5": 21
1103 + },
1104 + "total": 78
1105 + },
1106 + {
1107 + "number": "51/102",
1108 + "name": "Koffing",
1109 + "variation": null,
1110 + "url": "https://my.taggrading.com/pop-report/Pokemon/1999/WOTC%20Pok%C3%A9mon/Koffing/51%2F102?setName=Base+Set",
1111 + "counts": {
1112 + "7": 6,
1113 + "8": 14,
1114 + "9": 37,
1115 + "10": 26,
1116 + "6.5": 3,
1117 + "7.5": 8,
1118 + "8.5": 32
1119 + },
1120 + "total": 126
1121 + },
1122 + {
1123 + "number": "52/102",
1124 + "name": "Machop",
1125 + "variation": null,
1126 + "url": "https://my.taggrading.com/pop-report/Pokemon/1999/WOTC%20Pok%C3%A9mon/Machop/52%2F102?setName=Base+Set",
1127 + "counts": {
1128 + "5": 1,
1129 + "6": 2,
1130 + "7": 6,
1131 + "8": 21,
1132 + "9": 49,
1133 + "10": 14,
1134 + "5.5": 1,
1135 + "6.5": 5,
1136 + "7.5": 7,
1137 + "8.5": 33
1138 + },
1139 + "total": 139
1140 + },
1141 + {
1142 + "number": "53/102",
1143 + "name": "Magnemite",
1144 + "variation": null,
1145 + "url": "https://my.taggrading.com/pop-report/Pokemon/1999/WOTC%20Pok%C3%A9mon/Magnemite/53%2F102?setName=Base+Set",
1146 + "counts": {
1147 + "4": 1,
1148 + "6": 1,
1149 + "7": 5,
1150 + "8": 7,
1151 + "9": 18,
1152 + "10": 5,
1153 + "6.5": 3,
1154 + "7.5": 1,
1155 + "8.5": 19
1156 + },
1157 + "total": 60
1158 + },
1159 + {
1160 + "number": "54/102",
1161 + "name": "Metapod",
1162 + "variation": null,
1163 + "url": "https://my.taggrading.com/pop-report/Pokemon/1999/WOTC%20Pok%C3%A9mon/Metapod/54%2F102?setName=Base+Set",
1164 + "counts": {
1165 + "7": 1,
1166 + "8": 3,
1167 + "9": 9,
1168 + "10": 1,
1169 + "7.5": 3,
1170 + "8.5": 3
1171 + },
1172 + "total": 20
1173 + },
1174 + {
1175 + "number": "55/102",
1176 + "name": "Nidoran ♂",
1177 + "variation": null,
1178 + "url": "https://my.taggrading.com/pop-report/Pokemon/1999/WOTC%20Pok%C3%A9mon/Nidoran%20%E2%99%82/55%2F102?setName=Base+Set",
1179 + "counts": {
1180 + "4": 1,
1181 + "6": 3,
1182 + "7": 7,
1183 + "8": 23,
1184 + "9": 19,
1185 + "10": 11,
1186 + "3.5": 1,
1187 + "6.5": 1,
1188 + "7.5": 10,
1189 + "8.5": 19
1190 + },
1191 + "total": 95
1192 + },
1193 + {
1194 + "number": "56/102",
1195 + "name": "Onix",
1196 + "variation": null,
1197 + "url": "https://my.taggrading.com/pop-report/Pokemon/1999/WOTC%20Pok%C3%A9mon/Onix/56%2F102?setName=Base+Set",
1198 + "counts": {
1199 + "6": 2,
1200 + "7": 4,
1201 + "8": 19,
1202 + "9": 48,
1203 + "10": 21,
1204 + "5.5": 1,
1205 + "7.5": 7,
1206 + "8.5": 27
1207 + },
1208 + "total": 129
1209 + },
1210 + {
1211 + "number": "57/102",
1212 + "name": "Pidgey",
1213 + "variation": null,
1214 + "url": "https://my.taggrading.com/pop-report/Pokemon/1999/WOTC%20Pok%C3%A9mon/Pidgey/57%2F102?setName=Base+Set",
1215 + "counts": {
1216 + "4": 1,
1217 + "6": 1,
1218 + "7": 1,
1219 + "8": 2,
1220 + "9": 20,
1221 + "10": 1,
1222 + "2.5": 1,
1223 + "6.5": 1,
1224 + "7.5": 1,
1225 + "8.5": 8
1226 + },
1227 + "total": 37
1228 + },
1229 + {
1230 + "number": "58/102",
1231 + "name": "Pikachu",
1232 + "variation": "Red Cheeks",
1233 + "url": "https://my.taggrading.com/pop-report/Pokemon/1999/WOTC%20Pok%C3%A9mon/Pikachu/58%2F102?setName=Base+Set&variation=Red+Cheeks",
1234 + "counts": {
1235 + "6": 1,
1236 + "7": 2,
1237 + "8": 1,
1238 + "4.5": 1,
1239 + "6.5": 1,
1240 + "7.5": 1
1241 + },
1242 + "total": 7
1243 + },
1244 + {
1245 + "number": "58/102",
1246 + "name": "Pikachu",
1247 + "variation": "Yellow Cheeks",
1248 + "url": "https://my.taggrading.com/pop-report/Pokemon/1999/WOTC%20Pok%C3%A9mon/Pikachu/58%2F102?setName=Base+Set&variation=Yellow+Cheeks",
1249 + "counts": {
1250 + "1": 1,
1251 + "2": 3,
1252 + "3": 3,
1253 + "4": 3,
1254 + "5": 6,
1255 + "6": 8,
1256 + "7": 12,
1257 + "8": 24,
1258 + "9": 82,
1259 + "10": 43,
1260 + "2.5": 3,
1261 + "3.5": 1,
1262 + "4.5": 8,
1263 + "5.5": 5,
1264 + "6.5": 8,
1265 + "7.5": 8,
1266 + "8.5": 37
1267 + },
1268 + "total": 255
1269 + },
1270 + {
1271 + "number": "58/102",
1272 + "name": "Pikachu",
1273 + "variation": "Yellow Cheeks - Sarah Natochenny Autograph",
1274 + "url": "https://my.taggrading.com/pop-report/Pokemon/1999/WOTC%20Pok%C3%A9mon/Pikachu/58%2F102?setName=Base+Set&variation=Yellow+Cheeks+-+Sarah+Natochenny+Autograph",
1275 + "counts": {
1276 + "authentic": 12
1277 + },
1278 + "total": 12
1279 + },
1280 + {
1281 + "number": "59/102",
1282 + "name": "Poliwag",
1283 + "variation": null,
1284 + "url": "https://my.taggrading.com/pop-report/Pokemon/1999/WOTC%20Pok%C3%A9mon/Poliwag/59%2F102?setName=Base+Set",
1285 + "counts": {
1286 + "4": 1,
1287 + "6": 2,
1288 + "7": 8,
1289 + "8": 9,
1290 + "9": 35,
1291 + "10": 14,
1292 + "6.5": 5,
1293 + "7.5": 9,
1294 + "8.5": 28
1295 + },
1296 + "total": 111
1297 + },
1298 + {
1299 + "number": "60/102",
1300 + "name": "Ponyta",
1301 + "variation": null,
1302 + "url": "https://my.taggrading.com/pop-report/Pokemon/1999/WOTC%20Pok%C3%A9mon/Ponyta/60%2F102?setName=Base+Set",
1303 + "counts": {
1304 + "2": 1,
1305 + "4": 1,
1306 + "6": 5,
1307 + "7": 12,
1308 + "8": 28,
1309 + "9": 65,
1310 + "10": 15,
1311 + "authentic": 1,
1312 + "5.5": 1,
1313 + "6.5": 4,
1314 + "7.5": 17,
1315 + "8.5": 49
1316 + },
1317 + "total": 199
1318 + },
1319 + {
1320 + "number": "61/102",
1321 + "name": "Rattata",
1322 + "variation": null,
1323 + "url": "https://my.taggrading.com/pop-report/Pokemon/1999/WOTC%20Pok%C3%A9mon/Rattata/61%2F102?setName=Base+Set",
1324 + "counts": {
1325 + "6": 1,
1326 + "7": 3,
1327 + "8": 7,
1328 + "9": 10,
1329 + "10": 4,
1330 + "6.5": 2,
1331 + "7.5": 6,
1332 + "8.5": 8
1333 + },
1334 + "total": 41
1335 + },
1336 + {
1337 + "number": "62/102",
1338 + "name": "Sandshrew",
1339 + "variation": null,
1340 + "url": "https://my.taggrading.com/pop-report/Pokemon/1999/WOTC%20Pok%C3%A9mon/Sandshrew/62%2F102?setName=Base+Set",
1341 + "counts": {
1342 + "6": 8,
1343 + "7": 1,
1344 + "8": 20,
1345 + "9": 40,
1346 + "10": 17,
1347 + "5.5": 1,
1348 + "6.5": 1,
1349 + "7.5": 6,
1350 + "8.5": 31
1351 + },
1352 + "total": 125
1353 + },
1354 + {
1355 + "number": "63/102",
1356 + "name": "Squirtle",
1357 + "variation": null,
1358 + "url": "https://my.taggrading.com/pop-report/Pokemon/1999/WOTC%20Pok%C3%A9mon/Squirtle/63%2F102?setName=Base+Set",
1359 + "counts": {
1360 + "2": 1,
1361 + "3": 1,
1362 + "4": 3,
1363 + "5": 3,
1364 + "6": 8,
1365 + "7": 3,
1366 + "8": 13,
1367 + "9": 39,
1368 + "10": 19,
1369 + "4.5": 3,
1370 + "5.5": 4,
1371 + "6.5": 6,
1372 + "7.5": 7,
1373 + "8.5": 30
1374 + },
1375 + "total": 140
1376 + },
1377 + {
1378 + "number": "64/102",
1379 + "name": "Starmie",
1380 + "variation": null,
1381 + "url": "https://my.taggrading.com/pop-report/Pokemon/1999/WOTC%20Pok%C3%A9mon/Starmie/64%2F102?setName=Base+Set",
1382 + "counts": {
1383 + "5": 1,
1384 + "6": 8,
1385 + "7": 8,
1386 + "8": 15,
1387 + "9": 32,
1388 + "10": 16,
1389 + "3.5": 1,
1390 + "5.5": 3,
1391 + "6.5": 8,
1392 + "7.5": 15,
1393 + "8.5": 28
1394 + },
1395 + "total": 135
1396 + },
1397 + {
1398 + "number": "65/102",
1399 + "name": "Staryu",
1400 + "variation": null,
1401 + "url": "https://my.taggrading.com/pop-report/Pokemon/1999/WOTC%20Pok%C3%A9mon/Staryu/65%2F102?setName=Base+Set",
1402 + "counts": {
1403 + "6": 3,
1404 + "7": 2,
1405 + "8": 14,
1406 + "9": 20,
1407 + "10": 5,
1408 + "6.5": 3,
1409 + "7.5": 2,
1410 + "8.5": 18
1411 + },
1412 + "total": 67
1413 + },
1414 + {
1415 + "number": "66/102",
1416 + "name": "Tangela",
1417 + "variation": null,
1418 + "url": "https://my.taggrading.com/pop-report/Pokemon/1999/WOTC%20Pok%C3%A9mon/Tangela/66%2F102?setName=Base+Set",
1419 + "counts": {
1420 + "3": 1,
1421 + "5": 1,
1422 + "7": 1,
1423 + "8": 14,
1424 + "9": 22,
1425 + "10": 10,
1426 + "1.5": 1,
1427 + "6.5": 1,
1428 + "7.5": 8,
1429 + "8.5": 28
1430 + },
1431 + "total": 87
1432 + },
1433 + {
1434 + "number": "67/102",
1435 + "name": "Voltorb",
1436 + "variation": null,
1437 + "url": "https://my.taggrading.com/pop-report/Pokemon/1999/WOTC%20Pok%C3%A9mon/Voltorb/67%2F102?setName=Base+Set",
1438 + "counts": {
1439 + "5": 1,
1440 + "6": 1,
1441 + "7": 1,
1442 + "8": 4,
1443 + "9": 3,
1444 + "10": 2,
1445 + "6.5": 1,
1446 + "7.5": 3,
1447 + "8.5": 3
1448 + },
1449 + "total": 19
1450 + },
1451 + {
1452 + "number": "68/102",
1453 + "name": "Vulpix",
1454 + "variation": null,
1455 + "url": "https://my.taggrading.com/pop-report/Pokemon/1999/WOTC%20Pok%C3%A9mon/Vulpix/68%2F102?setName=Base+Set",
1456 + "counts": {
1457 + "4": 1,
1458 + "6": 2,
1459 + "7": 3,
1460 + "8": 15,
1461 + "9": 25,
1462 + "10": 5,
1463 + "6.5": 2,
1464 + "7.5": 7,
1465 + "8.5": 29
1466 + },
1467 + "total": 89
1468 + },
1469 + {
1470 + "number": "69/102",
1471 + "name": "Weedle",
1472 + "variation": null,
1473 + "url": "https://my.taggrading.com/pop-report/Pokemon/1999/WOTC%20Pok%C3%A9mon/Weedle/69%2F102?setName=Base+Set",
1474 + "counts": {
1475 + "6": 1,
1476 + "7": 1,
1477 + "8": 5,
1478 + "9": 4,
1479 + "6.5": 1,
1480 + "8.5": 2
1481 + },
1482 + "total": 14
1483 + },
1484 + {
1485 + "number": "70/102",
1486 + "name": "Clefairy Doll",
1487 + "variation": null,
1488 + "url": "https://my.taggrading.com/pop-report/Pokemon/1999/WOTC%20Pok%C3%A9mon/Clefairy%20Doll/70%2F102?setName=Base+Set",
1489 + "counts": {
1490 + "2": 1,
1491 + "6": 1,
1492 + "7": 1,
1493 + "8": 1,
1494 + "10": 1,
1495 + "8.5": 1
1496 + },
1497 + "total": 6
1498 + },
1499 + {
1500 + "number": "71/102",
1501 + "name": "Computer Search",
1502 + "variation": null,
1503 + "url": "https://my.taggrading.com/pop-report/Pokemon/1999/WOTC%20Pok%C3%A9mon/Computer%20Search/71%2F102?setName=Base+Set",
1504 + "counts": {
1505 + "6": 1,
1506 + "9": 1
1507 + },
1508 + "total": 2
1509 + },
1510 + {
1511 + "number": "72/102",
1512 + "name": "Devolution Spray",
1513 + "variation": null,
1514 + "url": "https://my.taggrading.com/pop-report/Pokemon/1999/WOTC%20Pok%C3%A9mon/Devolution%20Spray/72%2F102?setName=Base+Set",
1515 + "counts": {
1516 + "9": 1,
1517 + "6.5": 1
1518 + },
1519 + "total": 2
1520 + },
1521 + {
1522 + "number": "73/102",
1523 + "name": "Imposter Professor Oak",
1524 + "variation": null,
1525 + "url": "https://my.taggrading.com/pop-report/Pokemon/1999/WOTC%20Pok%C3%A9mon/Imposter%20Professor%20Oak/73%2F102?setName=Base+Set",
1526 + "counts": {
1527 + "7": 1,
1528 + "8": 1,
1529 + "9": 1,
1530 + "6.5": 1,
1531 + "7.5": 1
1532 + },
1533 + "total": 5
1534 + },
1535 + {
1536 + "number": "74/102",
1537 + "name": "Item Finder",
1538 + "variation": null,
1539 + "url": "https://my.taggrading.com/pop-report/Pokemon/1999/WOTC%20Pok%C3%A9mon/Item%20Finder/74%2F102?setName=Base+Set",
1540 + "counts": {
1541 + "5": 1,
1542 + "9": 1
1543 + },
1544 + "total": 2
1545 + },
1546 + {
1547 + "number": "75/102",
1548 + "name": "Lass",
1549 + "variation": null,
1550 + "url": "https://my.taggrading.com/pop-report/Pokemon/1999/WOTC%20Pok%C3%A9mon/Lass/75%2F102?setName=Base+Set",
1551 + "counts": {
1552 + "6": 1,
1553 + "8": 2,
1554 + "9": 1,
1555 + "7.5": 2,
1556 + "8.5": 2
1557 + },
1558 + "total": 8
1559 + },
1560 + {
1561 + "number": "76/102",
1562 + "name": "Pokemon Breeder",
1563 + "variation": null,
1564 + "url": "https://my.taggrading.com/pop-report/Pokemon/1999/WOTC%20Pok%C3%A9mon/Pokemon%20Breeder/76%2F102?setName=Base+Set",
1565 + "counts": {
1566 + "7": 1,
1567 + "9": 1,
1568 + "6.5": 2,
1569 + "8.5": 2
1570 + },
1571 + "total": 6
1572 + },
1573 + {
1574 + "number": "77/102",
1575 + "name": "Pokémon Trader",
1576 + "variation": null,
1577 + "url": "https://my.taggrading.com/pop-report/Pokemon/1999/WOTC%20Pok%C3%A9mon/Pok%C3%A9mon%20Trader/77%2F102?setName=Base+Set",
1578 + "counts": {
1579 + "8": 1,
1580 + "9": 1,
1581 + "6.5": 1
1582 + },
1583 + "total": 3
1584 + },
1585 + {
1586 + "number": "78/102",
1587 + "name": "Scoop Up",
1588 + "variation": null,
1589 + "url": "https://my.taggrading.com/pop-report/Pokemon/1999/WOTC%20Pok%C3%A9mon/Scoop%20Up/78%2F102?setName=Base+Set",
1590 + "counts": {
1591 + "8": 1,
1592 + "9": 2
1593 + },
1594 + "total": 3
1595 + },
1596 + {
1597 + "number": "79/102",
1598 + "name": "Super Energy Removal",
1599 + "variation": null,
1600 + "url": "https://my.taggrading.com/pop-report/Pokemon/1999/WOTC%20Pok%C3%A9mon/Super%20Energy%20Removal/79%2F102?setName=Base+Set",
1601 + "counts": {
1602 + "7": 1,
1603 + "9": 2,
1604 + "10": 1,
1605 + "5.5": 1,
1606 + "8.5": 1
1607 + },
1608 + "total": 6
1609 + },
1610 + {
1611 + "number": "80/102",
1612 + "name": "Defender",
1613 + "variation": null,
1614 + "url": "https://my.taggrading.com/pop-report/Pokemon/1999/WOTC%20Pok%C3%A9mon/Defender/80%2F102?setName=Base+Set",
1615 + "counts": {
1616 + "5": 1,
1617 + "7": 1,
1618 + "8": 1,
1619 + "9": 1,
1620 + "10": 1,
1621 + "3.5": 1,
1622 + "4.5": 1,
1623 + "5.5": 1,
1624 + "7.5": 1,
1625 + "8.5": 1
1626 + },
1627 + "total": 10
1628 + },
1629 + {
1630 + "number": "81/102",
1631 + "name": "Energy Retrieval",
1632 + "variation": null,
1633 + "url": "https://my.taggrading.com/pop-report/Pokemon/1999/WOTC%20Pok%C3%A9mon/Energy%20Retrieval/81%2F102?setName=Base+Set",
1634 + "counts": {
1635 + "7": 4,
1636 + "8": 9,
1637 + "9": 34,
1638 + "10": 5,
1639 + "7.5": 1,
1640 + "8.5": 30
1641 + },
1642 + "total": 83
1643 + },
1644 + {
1645 + "number": "82/102",
1646 + "name": "Full Heal",
1647 + "variation": null,
1648 + "url": "https://my.taggrading.com/pop-report/Pokemon/1999/WOTC%20Pok%C3%A9mon/Full%20Heal/82%2F102?setName=Base+Set",
1649 + "counts": {
1650 + "8": 2,
1651 + "9": 8,
1652 + "10": 2,
1653 + "6.5": 1
1654 + },
1655 + "total": 13
1656 + },
1657 + {
1658 + "number": "83/102",
1659 + "name": "Maintenance",
1660 + "variation": null,
1661 + "url": "https://my.taggrading.com/pop-report/Pokemon/1999/WOTC%20Pok%C3%A9mon/Maintenance/83%2F102?setName=Base+Set",
1662 + "counts": {
1663 + "8": 2,
1664 + "9": 3
1665 + },
1666 + "total": 5
1667 + },
1668 + {
1669 + "number": "84/102",
1670 + "name": "PlusPower",
1671 + "variation": null,
1672 + "url": "https://my.taggrading.com/pop-report/Pokemon/1999/WOTC%20Pok%C3%A9mon/PlusPower/84%2F102?setName=Base+Set",
1673 + "counts": {
1674 + "6": 1,
1675 + "7": 4,
1676 + "8": 16,
1677 + "9": 11,
1678 + "10": 4,
1679 + "5.5": 1,
1680 + "7.5": 3,
1681 + "8.5": 21
1682 + },
1683 + "total": 61
1684 + },
1685 + {
1686 + "number": "85/102",
1687 + "name": "Pokemon Center",
1688 + "variation": null,
1689 + "url": "https://my.taggrading.com/pop-report/Pokemon/1999/WOTC%20Pok%C3%A9mon/Pokemon%20Center/85%2F102?setName=Base+Set",
1690 + "counts": {
1691 + "5": 1,
1692 + "8": 1,
1693 + "9": 4,
1694 + "6.5": 3,
1695 + "7.5": 2
1696 + },
1697 + "total": 11
1698 + },
1699 + {
1700 + "number": "86/102",
1701 + "name": "Pokemon Flute",
1702 + "variation": null,
1703 + "url": "https://my.taggrading.com/pop-report/Pokemon/1999/WOTC%20Pok%C3%A9mon/Pokemon%20Flute/86%2F102?setName=Base+Set",
1704 + "counts": {
1705 + "6": 1,
1706 + "10": 2,
1707 + "4.5": 1,
1708 + "7.5": 2,
1709 + "8.5": 1
1710 + },
1711 + "total": 7
1712 + },
1713 + {
1714 + "number": "87/102",
1715 + "name": "Pokédex",
1716 + "variation": null,
1717 + "url": "https://my.taggrading.com/pop-report/Pokemon/1999/WOTC%20Pok%C3%A9mon/Pok%C3%A9dex/87%2F102?setName=Base+Set",
1718 + "counts": {
1719 + "6": 2,
1720 + "7": 2,
1721 + "8": 17,
1722 + "9": 32,
1723 + "10": 5,
1724 + "5.5": 1,
1725 + "7.5": 6,
1726 + "8.5": 33
1727 + },
1728 + "total": 98
1729 + },
1730 + {
1731 + "number": "88/102",
1732 + "name": "Professor Oak",
1733 + "variation": null,
1734 + "url": "https://my.taggrading.com/pop-report/Pokemon/1999/WOTC%20Pok%C3%A9mon/Professor%20Oak/88%2F102?setName=Base+Set",
1735 + "counts": {
1736 + "2": 1,
1737 + "5": 1,
1738 + "8": 1,
1739 + "9": 2,
1740 + "10": 1,
1741 + "7.5": 2,
1742 + "8.5": 2
1743 + },
1744 + "total": 10
1745 + },
1746 + {
1747 + "number": "89/102",
1748 + "name": "Revive",
1749 + "variation": null,
1750 + "url": "https://my.taggrading.com/pop-report/Pokemon/1999/WOTC%20Pok%C3%A9mon/Revive/89%2F102?setName=Base+Set",
1751 + "counts": {
1752 + "6": 1,
1753 + "7": 1,
1754 + "8": 2,
1755 + "9": 2,
1756 + "10": 1,
1757 + "8.5": 1
1758 + },
1759 + "total": 8
1760 + },
1761 + {
1762 + "number": "90/102",
1763 + "name": "Super Potion",
1764 + "variation": null,
1765 + "url": "https://my.taggrading.com/pop-report/Pokemon/1999/WOTC%20Pok%C3%A9mon/Super%20Potion/90%2F102?setName=Base+Set",
1766 + "counts": {
1767 + "5": 1,
1768 + "6": 3,
1769 + "7": 12,
1770 + "8": 28,
1771 + "9": 46,
1772 + "10": 5,
1773 + "4.5": 1,
1774 + "6.5": 2,
1775 + "7.5": 11,
1776 + "8.5": 45
1777 + },
1778 + "total": 154
1779 + },
1780 + {
1781 + "number": "91/102",
1782 + "name": "Bill",
1783 + "variation": null,
1784 + "url": "https://my.taggrading.com/pop-report/Pokemon/1999/WOTC%20Pok%C3%A9mon/Bill/91%2F102?setName=Base+Set",
1785 + "counts": {
1786 + "6": 1,
1787 + "7": 1,
1788 + "8": 3,
1789 + "9": 19,
1790 + "10": 5,
1791 + "6.5": 1,
1792 + "8.5": 10
1793 + },
1794 + "total": 40
1795 + },
1796 + {
1797 + "number": "92/102",
1798 + "name": "Energy Removal",
1799 + "variation": null,
1800 + "url": "https://my.taggrading.com/pop-report/Pokemon/1999/WOTC%20Pok%C3%A9mon/Energy%20Removal/92%2F102?setName=Base+Set",
1801 + "counts": {
1802 + "8": 6,
1803 + "9": 10,
1804 + "10": 2,
1805 + "7.5": 2,
1806 + "8.5": 6
1807 + },
1808 + "total": 26
1809 + },
1810 + {
1811 + "number": "93/102",
1812 + "name": "Gust of Wind",
1813 + "variation": null,
1814 + "url": "https://my.taggrading.com/pop-report/Pokemon/1999/WOTC%20Pok%C3%A9mon/Gust%20of%20Wind/93%2F102?setName=Base+Set",
1815 + "counts": {
1816 + "7": 3,
1817 + "8": 16,
1818 + "9": 25,
1819 + "10": 5,
1820 + "6.5": 2,
1821 + "7.5": 4,
1822 + "8.5": 17
1823 + },
1824 + "total": 72
1825 + },
1826 + {
1827 + "number": "94/102",
1828 + "name": "Potion",
1829 + "variation": null,
1830 + "url": "https://my.taggrading.com/pop-report/Pokemon/1999/WOTC%20Pok%C3%A9mon/Potion/94%2F102?setName=Base+Set",
1831 + "counts": {
1832 + "6": 1,
1833 + "7": 6,
1834 + "8": 7,
1835 + "9": 18,
1836 + "10": 4,
1837 + "5.5": 1,
1838 + "6.5": 3,
1839 + "7.5": 10,
1840 + "8.5": 21
1841 + },
1842 + "total": 71
1843 + },
1844 + {
1845 + "number": "95/102",
1846 + "name": "Switch",
1847 + "variation": null,
1848 + "url": "https://my.taggrading.com/pop-report/Pokemon/1999/WOTC%20Pok%C3%A9mon/Switch/95%2F102?setName=Base+Set",
1849 + "counts": {
1850 + "5": 1,
1851 + "7": 4,
1852 + "8": 15,
1853 + "9": 43,
1854 + "10": 18,
1855 + "6.5": 3,
1856 + "7.5": 8,
1857 + "8.5": 26
1858 + },
1859 + "total": 118
1860 + },
1861 + {
1862 + "number": "96/102",
1863 + "name": "Double Colorless Energy",
1864 + "variation": null,
1865 + "url": "https://my.taggrading.com/pop-report/Pokemon/1999/WOTC%20Pok%C3%A9mon/Double%20Colorless%20Energy/96%2F102?setName=Base+Set",
1866 + "counts": {
1867 + "9": 1,
1868 + "10": 4,
1869 + "7.5": 2
1870 + },
1871 + "total": 7
1872 + },
1873 + {
1874 + "number": "97/102",
1875 + "name": "Fighting Energy",
1876 + "variation": null,
1877 + "url": "https://my.taggrading.com/pop-report/Pokemon/1999/WOTC%20Pok%C3%A9mon/Fighting%20Energy/97%2F102?setName=Base+Set",
1878 + "counts": {
1879 + "7": 1,
1880 + "8": 4,
1881 + "9": 8,
1882 + "10": 1,
1883 + "6.5": 1,
1884 + "8.5": 6
1885 + },
1886 + "total": 21
1887 + },
1888 + {
1889 + "number": "98/102",
1890 + "name": "Fire Energy",
1891 + "variation": null,
1892 + "url": "https://my.taggrading.com/pop-report/Pokemon/1999/WOTC%20Pok%C3%A9mon/Fire%20Energy/98%2F102?setName=Base+Set",
1893 + "counts": {
1894 + "7": 1,
1895 + "9": 1,
1896 + "10": 2,
1897 + "6.5": 1,
1898 + "7.5": 1,
1899 + "8.5": 2
1900 + },
1901 + "total": 8
1902 + },
1903 + {
1904 + "number": "99/102",
1905 + "name": "Grass Energy",
1906 + "variation": null,
1907 + "url": "https://my.taggrading.com/pop-report/Pokemon/1999/WOTC%20Pok%C3%A9mon/Grass%20Energy/99%2F102?setName=Base+Set",
1908 + "counts": {
1909 + "9": 2,
1910 + "10": 1,
1911 + "7.5": 1
1912 + },
1913 + "total": 4
1914 + },
1915 + {
1916 + "number": "100/102",
1917 + "name": "Lightning Energy",
1918 + "variation": null,
1919 + "url": "https://my.taggrading.com/pop-report/Pokemon/1999/WOTC%20Pok%C3%A9mon/Lightning%20Energy/100%2F102?setName=Base+Set",
1920 + "counts": {
1921 + "7": 1,
1922 + "8": 1,
1923 + "9": 5,
1924 + "8.5": 3
1925 + },
1926 + "total": 10
1927 + },
1928 + {
1929 + "number": "101/102",
1930 + "name": "Psychic Energy",
1931 + "variation": null,
1932 + "url": "https://my.taggrading.com/pop-report/Pokemon/1999/WOTC%20Pok%C3%A9mon/Psychic%20Energy/101%2F102?setName=Base+Set",
1933 + "counts": {
1934 + "8": 2,
1935 + "9": 9,
1936 + "7.5": 1,
1937 + "8.5": 4
1938 + },
1939 + "total": 16
1940 + },
1941 + {
1942 + "number": "102/102",
1943 + "name": "Water Energy",
1944 + "variation": null,
1945 + "url": "https://my.taggrading.com/pop-report/Pokemon/1999/WOTC%20Pok%C3%A9mon/Water%20Energy/102%2F102?setName=Base+Set",
1946 + "counts": {
1947 + "6": 1,
1948 + "8": 1,
1949 + "9": 6,
1950 + "10": 1,
1951 + "8.5": 3
1952 + },
1953 + "total": 12
1954 + }
1955 + ]
1956 + },
1957 + "fetchedAt": "2026-09-07T07:11:49.256Z"
1958 + },
1959 + "expect": {
1960 + "minCount": 1,
1961 + "kinds": [
1962 + "population_report"
1963 + ],
1964 + "requiredFields": [
1965 + "attributes.set",
1966 + "attributes.number"
1967 + ]
1968 + },
1969 + "note": "Live capture 2026-09-07 from https://my.taggrading.com/pop-report/Pokemon/1999/WOTC%20Pok%C3%A9mon?setName=Base%20Set",
1970 + "capturedAt": "2026-09-07T07:11:49.264Z"
1971 +}
\ No newline at end of file
1972