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
Large diff (48 lines) is not loaded by default to keep the page fast.Load diff
added
connectors/firecrawl/tag-pop/index.test.ts
+43 −0
Large diff (43 lines) is not loaded by default to keep the page fast.Load diff
added
connectors/firecrawl/tag-pop/index.ts
+148 −0
Large diff (148 lines) is not loaded by default to keep the page fast.Load diff
added
connectors/firecrawl/tag-pop/meta.json
+33 −0
Large diff (33 lines) is not loaded by default to keep the page fast.Load diff
added
data/fixtures/alt-xyz/item-priced.json
+42 −0
Large diff (43 lines) is not loaded by default to keep the page fast.Load diff
added
data/fixtures/cardkingdom/single-first.json
+42 −0
Large diff (43 lines) is not loaded by default to keep the page fast.Load diff
added
data/fixtures/cardkingdom/single-fourth.json
+42 −0
Large diff (43 lines) is not loaded by default to keep the page fast.Load diff
added
data/fixtures/comc/basketball-1986-fleer-p1.json
+427 −0
Large diff (428 lines) is not loaded by default to keep the page fast.Load diff
added
data/fixtures/mtgjson/lea-black-lotus.json
+91 −0
Large diff (92 lines) is not loaded by default to keep the page fast.Load diff
added
data/fixtures/mtgjson/lea-first.json
+89 −0
Large diff (90 lines) is not loaded by default to keep the page fast.Load diff
added
data/fixtures/myslabs/slab-first.json
+36 −0
Large diff (37 lines) is not loaded by default to keep the page fast.Load diff
added
data/fixtures/myslabs/slab-sixth.json
+36 −0
Large diff (37 lines) is not loaded by default to keep the page fast.Load diff
added
data/fixtures/myslabs/slab-third.json
+36 −0
Large diff (37 lines) is not loaded by default to keep the page fast.Load diff
added
data/fixtures/tag-pop/pokemon-1999-base-set.json
+1971 −0
Large diff (1,973 lines) is not loaded by default to keep the page fast.Load diff