Connectors: MTGJSON, Card Kingdom, MySlabs, TAG population, COMC, Alt (agent S)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
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(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/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 | +[\\ | ||||
| 38 | +\\ | ||||
| 39 | +AuctionAug 6, 2026\\ | ||||
| 40 | +\\ | ||||
| 41 | +$15](https://www.ebay.com/itm/278232527458) | ||||
| 42 | + | ||||
| 43 | +[\\ | ||||
| 44 | +\\ | ||||
| 45 | +Best offerSep 3, 2024\\ | ||||
| 46 | +\\ | ||||
| 47 | +$20](https://www.ebay.com/itm/405178094963) | ||||
| 48 | + | ||||
| 49 | +View all | ||||
| 50 | + | ||||
| 51 | +Listings | ||||
| 52 | + | ||||
| 53 | +[\\ | ||||
| 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": "<p>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.</p>", | ||||
| 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": "<p>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.</p>", | ||||
| 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": "<p>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.</p>", | ||||
| 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 | |||||