SPB Git

spb/rareindex

Public

Connectors: MTGJSON, Card Kingdom, MySlabs, TAG population, COMC, Alt (agent S)

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

30 changed files +4,660 −0

added connectors/api/_lib/capture-s.ts +99 −0
@@ -0,0 +1,99 @@
1+/**
2+ * Live capture + smoke for the wave-4 connectors (mtgjson, cardkingdom, myslabs, tag-pop, comc, alt-xyz).
3+ * Usage: pnpm tsx connectors/api/_lib/capture-s.ts [ids…] [--no-save]
4+ */
5+import { createCrawlContext, createRouter, type RareIndexConnector, type RawRecordInput } from '@rareindex/connectors';
6+import { localMeta } from './local-meta.js';
7+import { saveFixture } from '@rareindex/connectors/testing';
8+import { childLogger } from '@rareindex/shared';
9+
10+interface Plan {
11+ dir: string;
12+ limit: number;
13+ seeds?: string[];
14+ pick: (raw: RawRecordInput, i: number) => string | null;
15+ kinds: string[];
16+ requiredFields: string[];
17+}
18+
19+const picked = new Set<string>();
20+const PLANS: Record<string, Plan> = {
21+ mtgjson: {
22+ dir: 'api',
23+ limit: 320,
24+ seeds: ['LEA'],
25+ pick: (raw, i) => {
26+ const p = raw.payload as { card: { name: string }; prices: unknown };
27+ if (p.card.name === 'Black Lotus') return 'lea-black-lotus';
28+ if (i === 0) return 'lea-first';
29+ return null;
30+ },
31+ kinds: ['catalog_item', 'price_observation'],
32+ requiredFields: ['attributes.identifiers.scryfall_id', 'attributes.setCode'],
33+ },
34+ cardkingdom: { dir: 'api', limit: 6, pick: (raw, i) => (i === 0 ? 'single-first' : i === 3 ? 'single-fourth' : null), kinds: ['catalog_item', 'price_observation'], requiredFields: ['attributes.identifiers.cardkingdom_id', 'attributes.set'] },
35+ myslabs: { dir: 'api', limit: 6, pick: (raw, i) => (i === 0 ? 'slab-first' : i === 2 ? 'slab-third' : i === 5 ? 'slab-sixth' : null), kinds: ['listing'], requiredFields: ['attributes.identifiers.myslabs_id', 'price'] },
36+ 'tag-pop': { dir: 'firecrawl', limit: 1, seeds: ['https://my.taggrading.com/pop-report/Pokemon/1999/WOTC%20Pok%C3%A9mon?setName=Base%20Set'], pick: () => 'pokemon-1999-base-set', kinds: ['population_report'], requiredFields: ['attributes.set', 'attributes.number'] },
37+ comc: { dir: 'firecrawl', limit: 1, seeds: ['Cards/Basketball/1986/Fleer'], pick: () => 'basketball-1986-fleer-p1', kinds: ['listing'], requiredFields: ['attributes.set', 'price'] },
38+ 'alt-xyz': {
39+ dir: 'firecrawl',
40+ limit: 8,
41+ pick: (raw) => {
42+ const p = raw.payload as { transactions: unknown[]; altValue: number | null; listPrice: number | null };
43+ if (p.transactions.length && !picked.has('item-with-transactions')) return 'item-with-transactions';
44+ if ((p.altValue || p.listPrice) && !picked.has('item-priced')) return 'item-priced';
45+ return null;
46+ },
47+ kinds: ['sale', 'price_observation', 'listing'],
48+ requiredFields: ['attributes.identifiers.alt_item_id'],
49+ },
50+};
51+
52+const args = process.argv.slice(2);
53+const noSave = args.includes('--no-save');
54+const ids = args.filter((a) => !a.startsWith('--'));
55+const targets = ids.length ? ids : Object.keys(PLANS);
56+const router = createRouter({ firecrawlApiKey: process.env.FIRECRAWL_API_KEY, scrapflyApiKey: process.env.SCRAPFLY_API_KEY });
57+for (const id of targets) {
58+ const plan = PLANS[id];
59+ if (!plan) {
60+ console.log(`[${id}] no plan`);
61+ continue;
62+ }
63+ const started = Date.now();
64+ const mod = (await import(`../../${plan.dir}/${id}/index.ts`)) as { default: (m: ReturnType<typeof localMeta>) => RareIndexConnector };
65+ const metaJson = (await import(`../../${plan.dir}/${id}/meta.json`, { with: { type: 'json' } })) as { default: unknown };
66+ const connector = mod.default(localMeta(metaJson.default));
67+ const ctx = createCrawlContext({ router, meta: connector.meta, options: { mode: 'probe', limit: plan.limit, ...(plan.seeds ? { seeds: plan.seeds } : {}) }, log: childLogger({ connector: id, level: 'warn' }) });
68+ let raws = 0;
69+ let normalized = 0;
70+ let printed = 0;
71+ let saved = 0;
72+ const kinds: Record<string, number> = {};
73+ try {
74+ for await (const raw of connector.crawl(ctx)) {
75+ const rawLike = { ...raw, externalId: raw.externalId ?? null, fetchedAt: raw.fetchedAt ?? new Date() };
76+ const out = await connector.normalize(rawLike);
77+ normalized += out.length;
78+ for (const r of out) kinds[r.kind] = (kinds[r.kind] ?? 0) + 1;
79+ const name = plan.pick(raw, raws);
80+ raws++;
81+ if (name && !noSave) {
82+ picked.add(name);
83+ saveFixture(id, name, { raw: rawLike, expect: { minCount: 1, kinds: plan.kinds, requiredFields: plan.requiredFields }, note: `Live capture ${new Date().toISOString().slice(0, 10)} from ${raw.url}` });
84+ saved++;
85+ }
86+ if (printed < 3 && out.length) {
87+ printed++;
88+ const r = out.find((x) => x.kind === 'sale') ?? out.find((x) => x.kind === 'price_observation') ?? out.find((x) => x.kind === 'population_report') ?? out[0]!;
89+ const s = r as unknown as Record<string, unknown>;
90+ const attrs = (s.attributes ?? {}) as Record<string, unknown>;
91+ console.log(`[${id}] ${r.kind} · ${String(s.rawTitle ?? attrs.name ?? '')} · ${String(attrs.categorySlug)} · set=${String(attrs.set)} #${String(attrs.number)} · grade=${JSON.stringify(s.grade ?? null)} · price=${String(s.price ?? s.total ?? '')} ${String(s.currency ?? '')} · date=${String(s.saleDate ?? s.observationDate ?? s.reportDate ?? '')}`);
92+ }
93+ }
94+ } catch (err) {
95+ console.log(`[${id}] crawl error: ${err instanceof Error ? err.message : String(err)}`);
96+ }
97+ console.log(`[${id}] raw=${raws} normalized=${normalized} kinds=${JSON.stringify(kinds)} saved=${saved} anomalies=${ctx.anomalies.length} engines=${JSON.stringify(ctx.engineStats)} ms=${Date.now() - started}`);
98+ if (ctx.anomalies.length) console.log(`[${id}] anomalies: ${ctx.anomalies.slice(0, 5).join(' ; ')}`);
99+}
added connectors/api/_lib/wave4.ts +273 −0
@@ -0,0 +1,273 @@
1+/**
2+ * Helpers shared by the wave-4 card/comic connectors (mtgjson, cardkingdom, myslabs, tag-pop, comc,
3+ * alt-xyz). Kept inside connectors/api (not the framework).
4+ */
5+import { createGunzip, gunzipSync } from 'node:zlib';
6+import { Readable } from 'node:stream';
7+
8+export const BOT_UA = 'RareIndexBot/0.1 (+https://www.rareindex.io/about/data; contact data@rareindex.io)';
9+export const BOT_HEADERS = { 'user-agent': BOT_UA, accept: 'application/json, text/html;q=0.9, */*;q=0.8' };
10+
11+/** UTC midnight. */
12+export function dayOf(d: Date): Date {
13+ return new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate()));
14+}
15+
16+/** "2026-09-06" → UTC midnight Date. */
17+export function isoDay(s: string | null | undefined): Date | null {
18+ if (!s) return null;
19+ const m = s.match(/^(\d{4})-(\d{2})-(\d{2})/);
20+ if (!m) return null;
21+ const d = new Date(Date.UTC(Number(m[1]), Number(m[2]) - 1, Number(m[3])));
22+ return Number.isNaN(d.getTime()) ? null : d;
23+}
24+
25+/** "$8,830.00" | "8000.00" → number or null (never ≤ 0). */
26+export function money(s: string | number | null | undefined): number | null {
27+ if (s === null || s === undefined) return null;
28+ const n = typeof s === 'number' ? s : Number.parseFloat(String(s).replace(/[^0-9.]/g, ''));
29+ return Number.isFinite(n) && n > 0 ? n : null;
30+}
31+
32+/** Download a gzip JSON document fully (small files only, e.g. AllPricesToday ≈ 5 MB gz). */
33+/** Download a (possibly gzip) document; handles servers that already transparently decode Content-Encoding. */
34+export async function fetchMaybeGzip(url: string, signal?: AbortSignal): Promise<Buffer> {
35+ const res = await fetch(url, { headers: BOT_HEADERS, signal });
36+ if (!res.ok) throw new Error(`${url}: HTTP ${res.status}`);
37+ const buf = Buffer.from(await res.arrayBuffer());
38+ if (buf.length >= 2 && buf[0] === 0x1f && buf[1] === 0x8b) return gunzipSync(buf);
39+ return buf;
40+}
41+
42+export async function fetchGzipJson<T = unknown>(url: string, signal?: AbortSignal): Promise<T> {
43+ return JSON.parse((await fetchMaybeGzip(url, signal)).toString('utf8')) as T;
44+}
45+
46+/**
47+ * Stream a large gzip JSON document shaped like {"meta":{…},"data":{"<key>":<value>,…}} and invoke
48+ * `onEntry(key, value)` for each top-level entry of `data` without materialising the whole file
49+ * (MTGJSON AllPrices ≈ 1.5 GB uncompressed). Returns the number of entries seen.
50+ */
51+export async function streamGzipDataEntries(url: string, onEntry: (key: string, value: unknown) => Promise<void> | void, opts: { signal?: AbortSignal; limit?: number } = {}): Promise<number> {
52+ const res = await fetch(url, { headers: BOT_HEADERS, signal: opts.signal });
53+ if (!res.ok || !res.body) throw new Error(`${url}: HTTP ${res.status}`);
54+ const encoded = (res.headers.get('content-type') ?? '').includes('gzip') || /\.gz(\?|$)/.test(url);
55+ const raw = Readable.fromWeb(res.body as never);
56+ // Some CDNs serve .gz files with Content-Encoding: gzip, which fetch already decodes → sniff the magic bytes.
57+ const first = await new Promise<Buffer | null>((resolve, reject) => {
58+ raw.once('readable', () => resolve((raw.read(2) as Buffer | null) ?? null));
59+ raw.once('error', reject);
60+ raw.once('end', () => resolve(null));
61+ });
62+ if (first) raw.unshift(first);
63+ const isGz = first ? first[0] === 0x1f && first[1] === 0x8b : encoded;
64+ const stream = isGz ? raw.pipe(createGunzip()) : raw;
65+ let buf = '';
66+ let inData = false;
67+ let count = 0;
68+ let done = false;
69+ for await (const chunk of stream) {
70+ if (done) break;
71+ buf += (chunk as Buffer).toString('utf8');
72+ if (!inData) {
73+ const i = buf.indexOf('"data"');
74+ if (i < 0) {
75+ buf = buf.slice(-16);
76+ continue;
77+ }
78+ const brace = buf.indexOf('{', i);
79+ if (brace < 0) continue;
80+ buf = buf.slice(brace + 1);
81+ inData = true;
82+ }
83+ // Parse as many complete `"key": {…},` entries as the buffer holds.
84+ for (;;) {
85+ const k = buf.indexOf('"');
86+ if (k < 0) break;
87+ const kEnd = buf.indexOf('"', k + 1);
88+ if (kEnd < 0) break;
89+ const colon = buf.indexOf(':', kEnd);
90+ if (colon < 0) break;
91+ const start = colon + 1;
92+ const end = scanJsonValue(buf, start);
93+ if (end < 0) break; // incomplete value, wait for more data
94+ const key = buf.slice(k + 1, kEnd);
95+ const raw = buf.slice(start, end).trim();
96+ try {
97+ await onEntry(key, JSON.parse(raw));
98+ } catch (err) {
99+ if (err instanceof SyntaxError) {
100+ /* skip malformed slice */
101+ } else throw err;
102+ }
103+ count++;
104+ buf = buf.slice(end);
105+ const comma = buf.indexOf(',');
106+ const close = buf.indexOf('}');
107+ if (close >= 0 && (comma < 0 || close < comma)) {
108+ done = true;
109+ break;
110+ }
111+ buf = comma >= 0 ? buf.slice(comma + 1) : buf;
112+ if (opts.limit && count >= opts.limit) {
113+ done = true;
114+ break;
115+ }
116+ }
117+ }
118+ return count;
119+}
120+
121+/** Index just past a complete JSON value starting at `start` (whitespace allowed); -1 if incomplete. */
122+function scanJsonValue(s: string, start: number): number {
123+ let i = start;
124+ while (i < s.length && /\s/.test(s[i]!)) i++;
125+ if (i >= s.length) return -1;
126+ const c = s[i]!;
127+ if (c === '{' || c === '[') {
128+ let depth = 0;
129+ let inStr = false;
130+ for (let j = i; j < s.length; j++) {
131+ const ch = s[j]!;
132+ if (inStr) {
133+ if (ch === '\\') j++;
134+ else if (ch === '"') inStr = false;
135+ continue;
136+ }
137+ if (ch === '"') inStr = true;
138+ else if (ch === '{' || ch === '[') depth++;
139+ else if (ch === '}' || ch === ']') {
140+ depth--;
141+ if (depth === 0) return j + 1;
142+ }
143+ }
144+ return -1;
145+ }
146+ if (c === '"') {
147+ for (let j = i + 1; j < s.length; j++) {
148+ if (s[j] === '\\') j++;
149+ else if (s[j] === '"') return j + 1;
150+ }
151+ return -1;
152+ }
153+ const m = s.slice(i).match(/^(true|false|null|-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?)/);
154+ if (!m) return -1;
155+ const end = i + m[0].length;
156+ return end < s.length ? end : -1;
157+}
158+
159+/** Parse <url><loc>…</loc><lastmod>…</lastmod></url> entries from a sitemap. */
160+export function parseSitemap(xml: string): Array<{ loc: string; lastmod: string | null }> {
161+ const out: Array<{ loc: string; lastmod: string | null }> = [];
162+ const re = /<url>([\s\S]*?)<\/url>/g;
163+ let m: RegExpExecArray | null;
164+ while ((m = re.exec(xml))) {
165+ const loc = m[1]!.match(/<loc>\s*([^<\s]+)\s*<\/loc>/)?.[1];
166+ if (!loc) continue;
167+ const lastmod = m[1]!.match(/<lastmod>\s*([^<\s]+)\s*<\/lastmod>/)?.[1] ?? null;
168+ out.push({ loc: decodeXml(loc), lastmod });
169+ }
170+ return out;
171+}
172+
173+export function decodeXml(s: string): string {
174+ return s.replace(/&amp;/g, '&').replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&quot;/g, '"').replace(/&#39;/g, "'");
175+}
176+
177+/** Parse GitHub-flavoured markdown tables (as emitted by Firecrawl) into rows of cells. */
178+export function markdownTables(md: string): string[][][] {
179+ const tables: string[][][] = [];
180+ let cur: string[][] | null = null;
181+ for (const line of md.split('\n')) {
182+ const t = line.trim();
183+ if (t.startsWith('|') && t.endsWith('|')) {
184+ const cells = t.slice(1, -1).split('|').map((c) => c.trim());
185+ if (cells.every((c) => /^:?-{2,}:?$/.test(c))) continue; // separator row
186+ if (!cur) cur = [];
187+ cur.push(cells);
188+ } else if (cur) {
189+ tables.push(cur);
190+ cur = null;
191+ }
192+ }
193+ if (cur) tables.push(cur);
194+ return tables;
195+}
196+
197+/** Strip markdown link syntax: "[2](https://…)" → "2"; "[Alakazam](url) <br>Holo" → "Alakazam Holo". */
198+export function mdText(cell: string): string {
199+ return cell
200+ .replace(/!\[[^\]]*\]\([^)]*\)/g, '')
201+ .replace(/\[([^\]]*)\]\([^)]*\)/g, '$1')
202+ .replace(/<br\s*\/?>/gi, ' ')
203+ .replace(/\\([\\`*_{}\[\]()#+\-.!|])/g, '$1')
204+ .replace(/\s+/g, ' ')
205+ .trim();
206+}
207+
208+export function mdLink(cell: string): string | null {
209+ return cell.match(/\]\((https?:[^)\s]+)\)/)?.[1] ?? null;
210+}
211+
212+export function toInt(s: string): number | null {
213+ const n = Number.parseInt(s.replace(/[^0-9]/g, ''), 10);
214+ return Number.isFinite(n) ? n : null;
215+}
216+
217+/** TAG / COMC / Alt category labels → taxonomy slugs (never invented; null when unknown). */
218+export function cardCategorySlug(label: string | null | undefined): string | null {
219+ if (!label) return null;
220+ const s = label.toLowerCase();
221+ if (/pok[eé]mon/.test(s)) return 'pokemon';
222+ if (/magic/.test(s)) return 'magic_the_gathering';
223+ if (/yu-?gi-?oh/.test(s)) return 'yugioh';
224+ if (/lorcana/.test(s)) return 'disney_lorcana';
225+ if (/one piece/.test(s)) return 'one_piece_card_game';
226+ if (/digimon/.test(s)) return 'digimon_tcg';
227+ if (/flesh/.test(s)) return 'flesh_and_blood';
228+ if (/star wars/.test(s)) return 'star_wars_tcg';
229+ if (/dragon ?ball/.test(s)) return 'dragon_ball_tcg';
230+ if (/baseball/.test(s)) return 'baseball_cards';
231+ if (/basketball/.test(s)) return 'basketball_cards';
232+ if (/football/.test(s)) return 'football_cards';
233+ if (/hockey/.test(s)) return 'hockey_cards';
234+ if (/soccer|futbol/.test(s)) return 'soccer_cards';
235+ if (/formula|f1\b|racing/.test(s)) return 'f1_cards';
236+ if (/boxing|golf|tennis|wrestling|wwe|ufc|mma|olympic|lacrosse|rugby|cricket|nascar|multi-?sport/.test(s)) return 'other_sports_cards';
237+ if (/comic/.test(s)) return 'comics';
238+ if (/non-?sport|entertainment|garbage pail|marvel|dc /.test(s)) return 'non_sport_cards';
239+ if (/video game|nintendo|playstation/.test(s)) return 'video_games';
240+ if (/tcg|trading card|gaming/.test(s)) return 'trading_cards';
241+ return null;
242+}
243+
244+/** "Aug 6, 2026" | "Sep 3, 2024" → UTC date. */
245+export function parseMonthDay(s: string): Date | null {
246+ const m = s.match(/([A-Za-z]{3,9})\.?\s+(\d{1,2}),?\s+(\d{4})/);
247+ if (!m) return null;
248+ const months = ['jan', 'feb', 'mar', 'apr', 'may', 'jun', 'jul', 'aug', 'sep', 'oct', 'nov', 'dec'];
249+ const mo = months.indexOf(m[1]!.slice(0, 3).toLowerCase());
250+ if (mo < 0) return null;
251+ return new Date(Date.UTC(Number(m[3]), mo, Number(m[2])));
252+}
253+
254+// ---- Magic printing treatments (mirrors connectors/api/scryfall so identifier matches agree on `variant`) ----
255+const VARIANT_FRAME_EFFECTS = new Set(['showcase', 'extendedart', 'inverted', 'shatteredglass', 'etched', 'textured']);
256+const VARIANT_PROMO_TYPES = new Set(['serialized', 'prerelease', 'promopack', 'judgegift', 'buyabox', 'gameday', 'textured', 'galaxyfoil', 'surgefoil', 'stepandcompleat', 'confettifoil', 'oilslick', 'halofoil', 'neonink', 'ripplefoil', 'fracturefoil', 'rainbowfoil', 'raisedfoil', 'invisibleink', 'doublerainbow', 'manafoil', 'firstplacefoil', 'dragonscalefoil', 'silverfoil', 'gilded', 'embossed', 'startercollection', 'schinesealtart', 'datestamped', 'playerrewards', 'arenaleague', 'fnm', 'release', 'launch', 'convention', 'mediainsert', 'wizardsplaynetwork', 'thick', 'poster']);
257+const LABELS: Record<string, string> = { extendedart: 'Extended Art', promopack: 'Promo Pack', judgegift: 'Judge Promo', buyabox: 'Buy-a-Box', gameday: 'Game Day', galaxyfoil: 'Galaxy Foil', surgefoil: 'Surge Foil', stepandcompleat: 'Step-and-Compleat', confettifoil: 'Confetti Foil', oilslick: 'Oil Slick', halofoil: 'Halo Foil', neonink: 'Neon Ink', ripplefoil: 'Ripple Foil', fracturefoil: 'Fracture Foil', rainbowfoil: 'Rainbow Foil', raisedfoil: 'Raised Foil', invisibleink: 'Invisible Ink', doublerainbow: 'Double Rainbow', manafoil: 'Mana Foil', firstplacefoil: 'First Place Foil', dragonscalefoil: 'Dragon Scale Foil', silverfoil: 'Silver Foil', startercollection: 'Starter Collection', schinesealtart: 'Chinese Alt Art', datestamped: 'Date Stamped', playerrewards: 'Player Rewards', arenaleague: 'Arena League', fnm: 'FNM', mediainsert: 'Media Insert', wizardsplaynetwork: 'WPN', shatteredglass: 'Shattered Glass' };
258+
259+export function magicTreatments(card: { frameEffects?: string[]; promoTypes?: string[]; isFullArt?: boolean; borderColor?: string; frameVersion?: string; setType?: string | null; releaseDate?: string | null }): string[] {
260+ const t: string[] = [];
261+ for (const fe of card.frameEffects ?? []) if (VARIANT_FRAME_EFFECTS.has(fe)) t.push(fe);
262+ for (const pt of card.promoTypes ?? []) if (VARIANT_PROMO_TYPES.has(pt)) t.push(pt);
263+ if (card.isFullArt) t.push('fullart');
264+ if (card.borderColor === 'borderless') t.push('borderless');
265+ if (card.frameVersion === '1997' && card.setType !== 'core' && card.setType !== 'expansion' && (card.releaseDate ?? '') > '2010') t.push('retro');
266+ return [...new Set(t)].map((k) => LABELS[k] ?? (k === 'fullart' ? 'Full Art' : k.charAt(0).toUpperCase() + k.slice(1)));
267+}
268+
269+export function magicFinishVariant(finish: string): string | null {
270+ if (finish === 'foil') return 'Foil';
271+ if (finish === 'etched') return 'Etched Foil';
272+ return null;
273+}
added connectors/api/cardkingdom/index.test.ts +27 −0
@@ -0,0 +1,27 @@
1+import { describe, expect, it } from 'vitest';
2+import meta from './meta.json' with { type: 'json' };
3+import { localMeta } from '../_lib/local-meta.js';
4+import { listFixtures, loadFixture, runFixtureSuite } from '@rareindex/connectors/testing';
5+import createConnector, { isFoil } from './index.js';
6+
7+const connector = createConnector(localMeta(meta));
8+
9+describe('cardkingdom', () => {
10+ runFixtureSuite(connector, it, expect);
11+
12+ it('parses foil flags', () => {
13+ expect(isFoil('true')).toBe(true);
14+ expect(isFoil('false')).toBe(false);
15+ expect(isFoil(undefined)).toBe(false);
16+ });
17+
18+ it('keys singles by scryfall_id and dates observations by the price list stamp', async () => {
19+ const single = listFixtures('cardkingdom').find((n) => n.startsWith('single'))!;
20+ const out = await connector.normalize(loadFixture('cardkingdom', single).raw);
21+ const cat = out.find((r) => r.kind === 'catalog_item');
22+ const obs = out.find((r) => r.kind === 'price_observation');
23+ expect(cat && cat.kind === 'catalog_item' ? cat.attributes.identifiers.scryfall_id : null).toMatch(/^[0-9a-f-]{36}$/);
24+ expect(obs && obs.kind === 'price_observation' ? obs.currency : null).toBe('USD');
25+ expect(obs && obs.kind === 'price_observation' ? (obs.attributes.metadata as { provider: string }).provider : null).toBe('cardkingdom');
26+ });
27+});
added connectors/api/cardkingdom/index.ts +111 −0
@@ -0,0 +1,111 @@
1+import { z } from 'zod';
2+import { BaseConnector, type ConnectorMeta, type CrawlContext, type RawRecordInput, type RawRecordLike } from '@rareindex/connectors';
3+import { type NormalizedRecord } from '@rareindex/shared';
4+import { attrs, catalogItem, makeTitle, num, priceObservation } from '../_lib/shared.js';
5+import { BOT_HEADERS, dayOf } from '../_lib/wave4.js';
6+
7+/** Card Kingdom public price lists → Magic price observations keyed by scryfall_id. */
8+const PARSER_VERSION = '1.0.0';
9+const SINGLES = 'https://api.cardkingdom.com/api/pricelist';
10+const SEALED = 'https://api.cardkingdom.com/api/sealed_pricelist';
11+
12+const SingleSchema = z.object({
13+ id: z.number(),
14+ sku: z.string().nullable().optional(),
15+ scryfall_id: z.string().nullable().optional(),
16+ url: z.string().nullable().optional(),
17+ name: z.string(),
18+ variation: z.string().nullable().optional(),
19+ edition: z.string(),
20+ is_foil: z.union([z.string(), z.boolean()]).optional(),
21+ price_retail: z.union([z.string(), z.number()]).nullable().optional(),
22+ qty_retail: z.number().nullable().optional(),
23+ price_buy: z.union([z.string(), z.number()]).nullable().optional(),
24+ qty_buying: z.number().nullable().optional(),
25+});
26+const SealedSchema = z.object({ id: z.number(), url: z.string().nullable().optional(), name: z.string(), edition: z.string().nullable().optional(), price_retail: z.union([z.string(), z.number()]).nullable().optional(), qty_retail: z.number().nullable().optional(), price_buy: z.union([z.string(), z.number()]).nullable().optional(), qty_buying: z.number().nullable().optional() });
27+const RawPayloadSchema = z.discriminatedUnion('kind', [
28+ z.object({ kind: z.literal('single'), row: SingleSchema, createdAt: z.string().nullable(), baseUrl: z.string() }),
29+ z.object({ kind: z.literal('sealed'), row: SealedSchema, createdAt: z.string().nullable(), baseUrl: z.string() }),
30+]);
31+export type CardKingdomPayload = z.infer<typeof RawPayloadSchema>;
32+
33+export function isFoil(v: string | boolean | undefined): boolean {
34+ return v === true || String(v).toLowerCase() === 'true';
35+}
36+
37+export class CardKingdomConnector extends BaseConnector {
38+ readonly version = '1.0.0';
39+ readonly parserVersion = PARSER_VERSION;
40+
41+ async *crawl(ctx: CrawlContext): AsyncIterable<RawRecordInput> {
42+ const minRetail = Number(this.meta.config.minRetailUsd ?? 0);
43+ let count = 0;
44+ const singles = await ctx.fetch(SINGLES, { engines: ['api'], headers: BOT_HEADERS, timeoutMs: 180_000 });
45+ let doc = singles.json as { meta?: { created_at?: string; base_url?: string }; data?: unknown[] } | null;
46+ if ((!doc || !Array.isArray(doc.data)) && typeof singles.html === 'string' && singles.html.startsWith('{')) doc = JSON.parse(singles.html) as typeof doc; // served as text/html
47+ if (!singles.success || !doc || !Array.isArray(doc.data)) throw new Error(`cardkingdom pricelist failed: ${singles.error ?? singles.httpStatus}`);
48+ const createdAt = doc.meta?.created_at ?? null;
49+ const baseUrl = doc.meta?.base_url ?? 'https://www.cardkingdom.com/';
50+ for (const r of doc.data) {
51+ const parsed = SingleSchema.safeParse(r);
52+ if (!parsed.success) {
53+ ctx.anomaly('parse_failure_row', parsed.error.issues[0]?.message);
54+ continue;
55+ }
56+ const retail = num(parsed.data.price_retail);
57+ if (retail === null || retail < minRetail) continue;
58+ if (this.reached(ctx, count)) return;
59+ count++;
60+ yield { url: `${baseUrl}${parsed.data.url ?? `mtg/${parsed.data.id}`}`, externalId: `s${parsed.data.id}`, kind: 'catalog_item', engine: 'api', httpStatus: singles.httpStatus, payload: { kind: 'single', row: parsed.data, createdAt, baseUrl } satisfies CardKingdomPayload, fetchedAt: singles.fetchedAt };
61+ }
62+ if (this.meta.config.includeSealed !== false) {
63+ const sealed = await ctx.fetch(SEALED, { engines: ['api'], headers: BOT_HEADERS, timeoutMs: 120_000 });
64+ let sdoc = sealed.json as { meta?: { created_at?: string; base_url?: string }; data?: unknown[] } | null;
65+ if ((!sdoc || !Array.isArray(sdoc.data)) && typeof sealed.html === 'string' && sealed.html.startsWith('{')) sdoc = JSON.parse(sealed.html) as typeof sdoc;
66+ if (sealed.success && sdoc && Array.isArray(sdoc.data)) {
67+ for (const r of sdoc.data) {
68+ const parsed = SealedSchema.safeParse(r);
69+ if (!parsed.success || num(parsed.data.price_retail) === null) continue;
70+ if (this.reached(ctx, count)) return;
71+ count++;
72+ yield { url: `${baseUrl}${parsed.data.url ?? `mtg-sealed/${parsed.data.id}`}`, externalId: `x${parsed.data.id}`, kind: 'catalog_item', engine: 'api', httpStatus: sealed.httpStatus, payload: { kind: 'sealed', row: parsed.data, createdAt: sdoc.meta?.created_at ?? null, baseUrl } satisfies CardKingdomPayload, fetchedAt: sealed.fetchedAt };
73+ }
74+ } else ctx.anomaly('page_fetch_failed', `sealed_pricelist: ${sealed.error ?? sealed.httpStatus}`);
75+ }
76+ await ctx.setCursor({ completedAt: new Date().toISOString() });
77+ }
78+
79+ async normalize(raw: RawRecordLike): Promise<NormalizedRecord[]> {
80+ const p = RawPayloadSchema.parse(raw.payload);
81+ const observedAt = raw.fetchedAt;
82+ const created = p.createdAt ? new Date(p.createdAt.replace(' ', 'T') + 'Z') : null;
83+ const observationDate = created && !Number.isNaN(created.getTime()) ? dayOf(created) : dayOf(observedAt);
84+ const out: NormalizedRecord[] = [];
85+ if (p.kind === 'single') {
86+ const r = p.row;
87+ const foil = isFoil(r.is_foil);
88+ const variation = (r.variation ?? '').trim();
89+ const variant = [variation || null, foil ? 'Foil' : null].filter(Boolean).join(' ') || null;
90+ const number = r.sku?.match(/-([A-Za-z0-9]+)$/)?.[1] ?? null;
91+ const identifiers: Record<string, string> = { cardkingdom_id: String(r.id) };
92+ if (r.scryfall_id) identifiers.scryfall_id = r.scryfall_id;
93+ if (r.sku) identifiers.cardkingdom_sku = r.sku;
94+ const a = attrs({ categorySlug: 'magic_the_gathering', franchise: 'Magic: The Gathering', brand: 'Wizards of the Coast', set: r.edition, name: r.name, number, variant, language: 'English', identifiers, metadata: { finish: foil ? 'foil' : 'nonfoil', variation: variation || null } });
95+ const rawTitle = makeTitle({ name: r.name, set: r.edition, number, variant });
96+ out.push(catalogItem({ kind: 'catalog_item', connectorId: this.meta.id, sourceId: this.meta.sourceId, sourceUrl: raw.url, externalId: `s${r.id}`, rawTitle, imageUrls: [], attributes: a, observedAt, confidence: 0.8, parserVersion: PARSER_VERSION }));
97+ const retail = num(r.price_retail);
98+ if (retail) out.push(priceObservation({ kind: 'price_observation', connectorId: this.meta.id, sourceId: this.meta.sourceId, sourceUrl: raw.url, externalId: `s${r.id}:retail:${observationDate.toISOString().slice(0, 10)}`, rawTitle, imageUrls: [], attributes: { ...a, metadata: { ...a.metadata, provider: 'cardkingdom', buylist: num(r.price_buy), qty_retail: r.qty_retail ?? null, qty_buying: r.qty_buying ?? null } }, observedAt, confidence: 0.7, parserVersion: PARSER_VERSION, priceKind: 'market', price: retail, currency: 'USD', observationDate, sampleSize: r.qty_retail ?? null }));
99+ return out;
100+ }
101+ const r = p.row;
102+ const a = attrs({ categorySlug: 'magic_the_gathering', franchise: 'Magic: The Gathering', brand: 'Wizards of the Coast', set: r.edition ?? null, name: r.name, identifiers: { cardkingdom_sealed_id: String(r.id) }, metadata: { sealed: true } });
103+ const rawTitle = makeTitle({ name: r.name, set: r.edition ?? null });
104+ out.push(catalogItem({ kind: 'catalog_item', connectorId: this.meta.id, sourceId: this.meta.sourceId, sourceUrl: raw.url, externalId: `x${r.id}`, rawTitle, imageUrls: [], attributes: a, observedAt, confidence: 0.75, parserVersion: PARSER_VERSION }));
105+ const retail = num(r.price_retail);
106+ if (retail) out.push(priceObservation({ kind: 'price_observation', connectorId: this.meta.id, sourceId: this.meta.sourceId, sourceUrl: raw.url, externalId: `x${r.id}:retail:${observationDate.toISOString().slice(0, 10)}`, rawTitle, imageUrls: [], attributes: { ...a, metadata: { ...a.metadata, provider: 'cardkingdom', buylist: num(r.price_buy) } }, observedAt, confidence: 0.7, parserVersion: PARSER_VERSION, priceKind: 'market', price: retail, currency: 'USD', observationDate, sampleSize: r.qty_retail ?? null }));
107+ return out;
108+ }
109+}
110+
111+export default (meta: ConnectorMeta) => new CardKingdomConnector(meta);
added connectors/api/cardkingdom/meta.json +33 −0
@@ -0,0 +1,33 @@
1+{
2+ "id": "cardkingdom",
3+ "displayName": "Card Kingdom (retail & buylist prices)",
4+ "sourceId": "cardkingdom",
5+ "sourceName": "Card Kingdom",
6+ "sourceType": "dealer",
7+ "sourceUrl": "https://www.cardkingdom.com",
8+ "module": "api/cardkingdom",
9+ "enginePriority": ["api"],
10+ "categories": ["magic_the_gathering"],
11+ "regions": ["US"],
12+ "languages": ["en"],
13+ "currency": ["USD"],
14+ "supportsListings": false,
15+ "supportsSold": false,
16+ "supportsAuctions": false,
17+ "supportsImages": false,
18+ "supportsCatalog": true,
19+ "supportsPopulation": false,
20+ "supportsLookup": false,
21+ "refreshFrequencyMinutes": 1440,
22+ "priority": "medium",
23+ "trustScore": 0.8,
24+ "attributionRequired": true,
25+ "termsUrl": "https://www.cardkingdom.com/help/terms",
26+ "accessNotes": "Card Kingdom publishes its full singles price list (https://api.cardkingdom.com/api/pricelist, ~46 MB JSON, refreshed daily) and sealed price list (/api/sealed_pricelist) publicly; each singles row carries the Scryfall id, edition, foil flag, retail price, retail stock, buylist price and buylist demand. Fetched once per run with an identifying User-Agent (two requests). Retail = dealer asking price stored as price_observation kind 'market' with metadata.buylist; not a transaction. Identifiers: cardkingdom_id, scryfall_id. Sealed products get catalog items with metadata.sealed=true.",
27+ "enabled": true,
28+ "schemaVersion": "1.0",
29+ "config": {
30+ "includeSealed": true,
31+ "minRetailUsd": 0.5
32+ }
33+}
added connectors/api/mtgjson/index.test.ts +58 −0
@@ -0,0 +1,58 @@
1+import { describe, expect, it } from 'vitest';
2+import meta from './meta.json' with { type: 'json' };
3+import { localMeta } from '../_lib/local-meta.js';
4+import { listFixtures, loadFixture, runFixtureSuite } from '@rareindex/connectors/testing';
5+import createConnector from './index.js';
6+import { magicTreatments, streamGzipDataEntries } from '../_lib/wave4.js';
7+import { gzipSync } from 'node:zlib';
8+
9+const connector = createConnector(localMeta(meta));
10+
11+describe('mtgjson', () => {
12+ runFixtureSuite(connector, it, expect);
13+
14+ it('emits scryfall-aligned variants and provider-dated observations', async () => {
15+ const names = listFixtures('mtgjson');
16+ const fx = loadFixture('mtgjson', names[0]!);
17+ const out = await connector.normalize(fx.raw);
18+ const cats = out.filter((r) => r.kind === 'catalog_item');
19+ const obs = out.filter((r) => r.kind === 'price_observation');
20+ expect(cats.length).toBeGreaterThanOrEqual(1);
21+ for (const c of cats) {
22+ if (c.kind !== 'catalog_item') continue;
23+ expect(c.attributes.identifiers.mtgjson_uuid).toBeTruthy();
24+ expect(c.attributes.identifiers.scryfall_id).toMatch(/^[0-9a-f-]{36}$/);
25+ expect(c.attributes.setCode).toMatch(/^[A-Z0-9]{2,6}$/);
26+ }
27+ for (const o of obs) {
28+ if (o.kind !== 'price_observation') continue;
29+ expect(['USD', 'EUR']).toContain(o.currency);
30+ expect(o.priceKind).toBe('market');
31+ expect((o.attributes.metadata as { provider: string }).provider).toBeTruthy();
32+ expect(o.observationDate.getTime()).toBeLessThanOrEqual(Date.now());
33+ }
34+ });
35+
36+ it('mirrors the scryfall treatment vocabulary', () => {
37+ expect(magicTreatments({ frameEffects: ['showcase'], promoTypes: [] })).toEqual(['Showcase']);
38+ expect(magicTreatments({ frameEffects: ['extendedart'], borderColor: 'borderless' })).toEqual(['Extended Art', 'Borderless']);
39+ expect(magicTreatments({})).toEqual([]);
40+ });
41+
42+ it('streams top-level data entries from a gzip JSON document', async () => {
43+ const doc = { meta: { date: '2026-09-06' }, data: { a: { paper: { tcgplayer: { retail: { normal: { '2026-09-06': 1.5 } } } } }, b: { paper: {} }, c: { x: [1, 2, { y: '}' }] } } };
44+ const gz = gzipSync(Buffer.from(JSON.stringify(doc)));
45+ const orig = globalThis.fetch;
46+ globalThis.fetch = (async () => new Response(gz, { status: 200 })) as typeof fetch;
47+ try {
48+ const seen: string[] = [];
49+ const n = await streamGzipDataEntries('https://example.test/x.json.gz', (k) => {
50+ seen.push(k);
51+ });
52+ expect(n).toBe(3);
53+ expect(seen).toEqual(['a', 'b', 'c']);
54+ } finally {
55+ globalThis.fetch = orig;
56+ }
57+ });
58+});
added connectors/api/mtgjson/index.ts +219 −0
@@ -0,0 +1,219 @@
1+import { z } from 'zod';
2+import { BaseConnector, type ConnectorMeta, type CrawlContext, type RawRecordInput, type RawRecordLike } from '@rareindex/connectors';
3+import { type NormalizedRecord } from '@rareindex/shared';
4+import { attrs, catalogItem, makeTitle, priceObservation } from '../_lib/shared.js';
5+import { BOT_HEADERS, fetchGzipJson, isoDay, magicFinishVariant, magicTreatments, streamGzipDataEntries } from '../_lib/wave4.js';
6+
7+/**
8+ * MTGJSON connector — per-set card files (identifiers across Scryfall/TCGplayer/Cardmarket/Card Kingdom)
9+ * joined with the daily price file. One raw record per printing; normalize emits catalog items per
10+ * finish and dated price observations per provider × finish.
11+ */
12+const API = 'https://mtgjson.com/api/v5';
13+const PARSER_VERSION = '1.0.0';
14+
15+const SetSchema = z.object({ code: z.string(), name: z.string(), releaseDate: z.string().nullable().optional(), type: z.string().nullable().optional(), tcgplayerGroupId: z.number().nullable().optional(), mcmId: z.number().nullable().optional(), isOnlineOnly: z.boolean().optional(), totalSetSize: z.number().optional() });
16+const CardSchema = z.object({
17+ uuid: z.string(),
18+ name: z.string(),
19+ number: z.string(),
20+ rarity: z.string().nullable().optional(),
21+ finishes: z.array(z.string()).default([]),
22+ language: z.string().nullable().optional(),
23+ identifiers: z.record(z.string(), z.string()).default({}),
24+ frameEffects: z.array(z.string()).optional(),
25+ promoTypes: z.array(z.string()).optional(),
26+ isFullArt: z.boolean().optional(),
27+ borderColor: z.string().optional(),
28+ frameVersion: z.string().optional(),
29+ isPromo: z.boolean().optional(),
30+ isReserved: z.boolean().optional(),
31+ artist: z.string().nullable().optional(),
32+ type: z.string().nullable().optional(),
33+ manaCost: z.string().nullable().optional(),
34+});
35+/** provider → finish → { date: price } */
36+const ProviderPricesSchema = z.object({ retail: z.record(z.string(), z.record(z.string(), z.number())).optional(), buylist: z.record(z.string(), z.record(z.string(), z.number())).optional(), currency: z.string().optional() });
37+const PaperPricesSchema = z.record(z.string(), ProviderPricesSchema);
38+const RawPayloadSchema = z.object({ set: SetSchema, card: CardSchema, prices: PaperPricesSchema.nullable(), priceDate: z.string().nullable() });
39+export type MtgjsonPayload = z.infer<typeof RawPayloadSchema>;
40+
41+type PriceMap = Map<string, z.infer<typeof PaperPricesSchema>>;
42+
43+const CARD_KEEP = ['uuid', 'name', 'number', 'rarity', 'finishes', 'language', 'identifiers', 'frameEffects', 'promoTypes', 'isFullArt', 'borderColor', 'frameVersion', 'isPromo', 'isReserved', 'artist', 'type', 'manaCost'] as const;
44+export function trimCard(c: Record<string, unknown>): z.infer<typeof CardSchema> {
45+ const out: Record<string, unknown> = {};
46+ for (const k of CARD_KEEP) if (c[k] !== undefined) out[k] = c[k];
47+ return CardSchema.parse(out);
48+}
49+export function trimSet(s: Record<string, unknown>): z.infer<typeof SetSchema> {
50+ const out: Record<string, unknown> = {};
51+ for (const k of ['code', 'name', 'releaseDate', 'type', 'tcgplayerGroupId', 'mcmId', 'isOnlineOnly', 'totalSetSize']) if (s[k] !== undefined) out[k] = s[k];
52+ return SetSchema.parse(out);
53+}
54+
55+/** Keep only the latest `days` dates per provider/finish (payload compactness). */
56+function trimPrices(paper: unknown, days: number): z.infer<typeof PaperPricesSchema> | null {
57+ const parsed = PaperPricesSchema.safeParse(paper);
58+ if (!parsed.success) return null;
59+ const out: z.infer<typeof PaperPricesSchema> = {};
60+ for (const [provider, p] of Object.entries(parsed.data)) {
61+ const trimmed: z.infer<typeof ProviderPricesSchema> = { currency: p.currency };
62+ for (const kind of ['retail', 'buylist'] as const) {
63+ const byFinish = p[kind];
64+ if (!byFinish) continue;
65+ const tf: Record<string, Record<string, number>> = {};
66+ for (const [finish, series] of Object.entries(byFinish)) {
67+ const dates = Object.keys(series).sort().slice(-days);
68+ tf[finish] = Object.fromEntries(dates.map((d) => [d, series[d]!]));
69+ }
70+ trimmed[kind] = tf;
71+ }
72+ out[provider] = trimmed;
73+ }
74+ return out;
75+}
76+
77+export class MtgjsonConnector extends BaseConnector {
78+ readonly version = '1.0.0';
79+ readonly parserVersion = PARSER_VERSION;
80+ protected override minIntervalMs = 250;
81+
82+ async *crawl(ctx: CrawlContext): AsyncIterable<RawRecordInput> {
83+ const backfill = ctx.options.mode === 'backfill';
84+ const maxSets = backfill ? Infinity : Number(this.meta.config.maxSetsPerRun ?? 60);
85+ const listRes = await ctx.fetch(`${API}/SetList.json`, { engines: ['api'], headers: BOT_HEADERS });
86+ const list = (listRes.json as { data?: unknown[] } | null)?.data;
87+ if (!listRes.success || !Array.isArray(list)) throw new Error(`mtgjson SetList failed: ${listRes.error}`);
88+ let sets = list.map((s) => trimSet(s as Record<string, unknown>)).filter((s) => !s.isOnlineOnly);
89+ if (ctx.options.seeds?.length) sets = sets.filter((s) => ctx.options.seeds!.includes(s.code));
90+ sets.sort((a, b) => (b.releaseDate ?? '').localeCompare(a.releaseDate ?? ''));
91+ sets = sets.slice(0, Number.isFinite(maxSets) ? maxSets : sets.length);
92+
93+ // Daily prices (small) — joined by uuid. Backfill streams the 90-day history instead.
94+ const prices: PriceMap = new Map();
95+ let priceDate: string | null = null;
96+ if (!backfill) {
97+ const today = await fetchGzipJson<{ meta?: { date?: string }; data?: Record<string, { paper?: unknown }> }>(`${API}/AllPricesToday.json.gz`, ctx.signal);
98+ priceDate = today.meta?.date ?? null;
99+ for (const [uuid, v] of Object.entries(today.data ?? {})) {
100+ const t = trimPrices(v.paper, 1);
101+ if (t) prices.set(uuid, t);
102+ }
103+ const s = (ctx.engineStats.api ??= { attempts: 0, success: 0, credits: 0, ms: 0 });
104+ s.attempts++;
105+ s.success++;
106+ }
107+
108+ let count = 0;
109+ let setIdx = Number(ctx.options.cursor?.setIdx ?? 0);
110+ const cards = new Map<string, { set: z.infer<typeof SetSchema>; card: z.infer<typeof CardSchema>; url: string }>();
111+ for (; setIdx < sets.length; setIdx++) {
112+ if (ctx.signal?.aborted) return;
113+ const set = sets[setIdx]!;
114+ await this.throttle();
115+ const res = await ctx.fetch(`${API}/${set.code}.json`, { engines: ['api'], headers: BOT_HEADERS });
116+ const data = (res.json as { data?: { cards?: unknown[] } } | null)?.data;
117+ if (!res.success || !Array.isArray(data?.cards)) {
118+ ctx.anomaly('page_fetch_failed', `${set.code}: ${res.error ?? res.httpStatus}`);
119+ continue;
120+ }
121+ for (const raw of data.cards) {
122+ let card: z.infer<typeof CardSchema>;
123+ try {
124+ card = trimCard(raw as Record<string, unknown>);
125+ } catch (err) {
126+ ctx.anomaly('parse_failure_card', `${set.code}: ${err instanceof Error ? err.message : String(err)}`);
127+ continue;
128+ }
129+ const url = `https://mtgjson.com/api/v5/${set.code}.json#${card.uuid}`;
130+ if (backfill) {
131+ cards.set(card.uuid, { set, card, url });
132+ continue;
133+ }
134+ if (this.reached(ctx, count)) return;
135+ count++;
136+ const payload: MtgjsonPayload = { set, card, prices: prices.get(card.uuid) ?? null, priceDate };
137+ yield { url, externalId: card.uuid, kind: 'catalog_item', engine: 'api', httpStatus: res.httpStatus, payload, fetchedAt: res.fetchedAt };
138+ }
139+ await ctx.setCursor({ setIdx: setIdx + 1, priceDate });
140+ }
141+ if (backfill) {
142+ // Stream the 90-day history and yield one record per known printing.
143+ const days = Number(this.meta.config.historyDays ?? 90);
144+ const queue: RawRecordInput[] = [];
145+ const seen = await streamGzipDataEntries(
146+ `${API}/AllPrices.json.gz`,
147+ (uuid, value) => {
148+ const c = cards.get(uuid);
149+ if (!c) return;
150+ const paper = (value as { paper?: unknown })?.paper;
151+ const trimmed = trimPrices(paper, days);
152+ const payload: MtgjsonPayload = { set: c.set, card: c.card, prices: trimmed, priceDate: null };
153+ queue.push({ url: c.url, externalId: c.card.uuid, kind: 'catalog_item', engine: 'api', httpStatus: 200, payload, fetchedAt: new Date() });
154+ },
155+ { signal: ctx.signal, limit: ctx.options.limit ? ctx.options.limit * 4 : undefined },
156+ );
157+ ctx.log.info({ seen, matched: queue.length }, 'mtgjson AllPrices streamed');
158+ for (const r of queue) {
159+ if (this.reached(ctx, count)) return;
160+ count++;
161+ yield r;
162+ }
163+ }
164+ await ctx.setCursor({ setIdx: 0, priceDate, completedAt: new Date().toISOString() });
165+ }
166+
167+ async normalize(raw: RawRecordLike): Promise<NormalizedRecord[]> {
168+ const { set, card, prices } = RawPayloadSchema.parse(raw.payload);
169+ const year = set.releaseDate ? Number(set.releaseDate.slice(0, 4)) || null : null;
170+ const ids: Record<string, string> = { mtgjson_uuid: card.uuid };
171+ const map: Array<[string, string]> = [['scryfallId', 'scryfall_id'], ['scryfallOracleId', 'oracle_id'], ['tcgplayerProductId', 'tcgplayer_id'], ['tcgplayerEtchedProductId', 'tcgplayer_etched_id'], ['mcmId', 'cardmarket_id'], ['cardKingdomId', 'cardkingdom_id'], ['cardKingdomFoilId', 'cardkingdom_foil_id'], ['cardKingdomEtchedId', 'cardkingdom_etched_id'], ['cardsphereId', 'cardsphere_id'], ['multiverseId', 'multiverse_id'], ['mtgoId', 'mtgo_id']];
172+ for (const [from, to] of map) if (card.identifiers[from]) ids[to] = card.identifiers[from]!;
173+ const treat = magicTreatments({ ...card, setType: set.type ?? null, releaseDate: set.releaseDate ?? null });
174+ const baseVariant = treat.length ? treat.join(' ') : null;
175+ const finishes = card.finishes.length ? card.finishes : ['nonfoil'];
176+ const observedAt = raw.fetchedAt;
177+ const providers = new Set((this.meta.config.providers as string[] | undefined) ?? ['tcgplayer', 'cardmarket', 'cardkingdom', 'cardsphere', 'manapool']);
178+ const out: NormalizedRecord[] = [];
179+ for (const finish of finishes) {
180+ const variant = [baseVariant, magicFinishVariant(finish)].filter(Boolean).join(' ') || null;
181+ const a = attrs({
182+ categorySlug: 'magic_the_gathering',
183+ franchise: 'Magic: The Gathering',
184+ brand: 'Wizards of the Coast',
185+ set: set.name,
186+ setCode: set.code.toUpperCase(),
187+ name: card.name,
188+ number: card.number,
189+ year,
190+ variant,
191+ language: card.language ?? 'English',
192+ rarity: card.rarity ?? null,
193+ identifiers: { ...ids, finish },
194+ metadata: { artist: card.artist ?? null, type_line: card.type ?? null, mana_cost: card.manaCost ?? null, promo: card.isPromo ?? false, reserved: card.isReserved ?? false, set_type: set.type ?? null },
195+ });
196+ const rawTitle = makeTitle({ name: card.name, set: set.name, number: card.number, year, variant });
197+ const sourceUrl = `https://scryfall.com/card/${set.code.toLowerCase()}/${encodeURIComponent(card.number)}`;
198+ out.push(catalogItem({ kind: 'catalog_item', connectorId: this.meta.id, sourceId: this.meta.sourceId, sourceUrl, externalId: `${card.uuid}:${finish}`, rawTitle, imageUrls: [], attributes: a, observedAt, confidence: 0.85, parserVersion: PARSER_VERSION, releaseDate: set.releaseDate ? new Date(set.releaseDate) : null }));
199+ if (!prices) continue;
200+ for (const [provider, p] of Object.entries(prices)) {
201+ if (!providers.has(provider)) continue;
202+ // MTGJSON price files key finishes as normal|foil|etched while card.finishes uses nonfoil|foil|etched.
203+ const priceKey = finish === 'nonfoil' ? 'normal' : finish;
204+ const retail = p.retail?.[priceKey];
205+ if (!retail) continue;
206+ const currency = (p.currency ?? (provider === 'cardmarket' ? 'EUR' : 'USD')) as 'USD' | 'EUR';
207+ const buy = p.buylist?.[priceKey];
208+ for (const [date, price] of Object.entries(retail)) {
209+ const observationDate = isoDay(date);
210+ if (!observationDate || !(price > 0)) continue;
211+ out.push(priceObservation({ kind: 'price_observation', connectorId: this.meta.id, sourceId: this.meta.sourceId, sourceUrl, externalId: `${card.uuid}:${finish}:${provider}:${date}`, rawTitle, imageUrls: [], attributes: { ...a, metadata: { ...a.metadata, provider, buylist: buy?.[date] ?? null } }, observedAt, confidence: provider === 'tcgplayer' || provider === 'cardmarket' ? 0.75 : 0.7, parserVersion: PARSER_VERSION, priceKind: 'market', price, currency, observationDate, sampleSize: null }));
212+ }
213+ }
214+ }
215+ return out;
216+ }
217+}
218+
219+export default (meta: ConnectorMeta) => new MtgjsonConnector(meta);
added connectors/api/mtgjson/meta.json +34 −0
@@ -0,0 +1,34 @@
1+{
2+ "id": "mtgjson",
3+ "displayName": "MTGJSON (prices & identifiers)",
4+ "sourceId": "mtgjson",
5+ "sourceName": "MTGJSON",
6+ "sourceType": "pricing_guide",
7+ "sourceUrl": "https://mtgjson.com",
8+ "module": "api/mtgjson",
9+ "enginePriority": ["api"],
10+ "categories": ["magic_the_gathering"],
11+ "regions": ["US", "EU"],
12+ "languages": ["en"],
13+ "currency": ["USD", "EUR"],
14+ "supportsListings": false,
15+ "supportsSold": false,
16+ "supportsAuctions": false,
17+ "supportsImages": false,
18+ "supportsCatalog": true,
19+ "supportsPopulation": false,
20+ "supportsLookup": false,
21+ "refreshFrequencyMinutes": 1440,
22+ "priority": "high",
23+ "trustScore": 0.85,
24+ "attributionRequired": true,
25+ "termsUrl": "https://mtgjson.com/faq/",
26+ "accessNotes": "Open MTG data project (free bulk files, no key). Incremental runs download SetList.json, the newest `maxSetsPerRun` set files (LEA.json… with per-card identifiers: scryfallId, tcgplayerProductId, cardKingdomId, mcmId, cardsphereId, multiverseId) and AllPricesToday.json.gz (~5 MB gz) which carries the latest daily retail/buylist price per printing from TCGplayer, Cardmarket (EUR), Card Kingdom, Cardsphere and ManaPool. Backfill streams AllPrices.json.gz (~150 MB gz, 90 days of daily history) entry by entry. Retail prices are stored as price_observations (priceKind 'market', metadata.provider), buylist prices only in metadata; every point is dated by MTGJSON's own date keys. Variant vocabulary mirrors the scryfall connector (Foil / Etched Foil + treatments) so identifier matches agree.",
27+ "enabled": true,
28+ "schemaVersion": "1.0",
29+ "config": {
30+ "maxSetsPerRun": 60,
31+ "providers": ["tcgplayer", "cardmarket", "cardkingdom", "cardsphere", "manapool"],
32+ "historyDays": 90
33+ }
34+}
added connectors/api/myslabs/index.test.ts +32 −0
@@ -0,0 +1,32 @@
1+import { describe, expect, it } from 'vitest';
2+import meta from './meta.json' with { type: 'json' };
3+import { localMeta } from '../_lib/local-meta.js';
4+import { listFixtures, loadFixture, runFixtureSuite } from '@rareindex/connectors/testing';
5+import createConnector, { slabCategory } from './index.js';
6+
7+const connector = createConnector(localMeta(meta));
8+
9+describe('myslabs', () => {
10+ runFixtureSuite(connector, it, expect);
11+
12+ it('classifies slab titles', () => {
13+ expect(slabCategory('2025 Prizm FIFA Manga Kylian Mbappe BGS 9.5', null)).toBe('soccer_cards');
14+ expect(slabCategory('1999 Pokemon Base Set Charizard Holo PSA 9', null)).toBe('pokemon');
15+ expect(slabCategory('Amazing Spider-Man #300 CGC 9.8 1988', null)).toBe('marvel_comics');
16+ expect(slabCategory('2003 Topps Chrome LeBron James Rookie PSA 10', null)).toBe('basketball_cards');
17+ expect(slabCategory('Random vase', null)).toBeNull();
18+ });
19+
20+ it('records sold-out slabs as sold listings, never as sales', async () => {
21+ for (const name of listFixtures('myslabs')) {
22+ const out = await connector.normalize(loadFixture('myslabs', name).raw);
23+ for (const r of out) {
24+ expect(r.kind).toBe('listing');
25+ if (r.kind === 'listing') {
26+ expect(['available', 'sold', 'unknown']).toContain(r.availability);
27+ expect(r.currency).toBe('USD');
28+ }
29+ }
30+ }
31+ });
32+});
added connectors/api/myslabs/index.ts +164 −0
@@ -0,0 +1,164 @@
1+import { z } from 'zod';
2+import { BaseConnector, html as H, type ConnectorMeta, type CrawlContext, type RawRecordInput, type RawRecordLike } from '@rareindex/connectors';
3+import { NormalizedListingSchema, extractYear, type NormalizedRecord } from '@rareindex/shared';
4+import { parseGradeFromTitle } from '@rareindex/taxonomy';
5+import { attrs } from '../_lib/shared.js';
6+import { BOT_HEADERS, cardCategorySlug, decodeXml, parseSitemap } from '../_lib/wave4.js';
7+
8+/** MySlabs — graded slab marketplace; public slab pages expose a Product JSON-LD. */
9+const PARSER_VERSION = '1.0.0';
10+const SITE = 'https://myslabs.com';
11+
12+const RawPayloadSchema = z.object({
13+ id: z.string(),
14+ name: z.string(),
15+ description: z.string().nullable(),
16+ images: z.array(z.string()),
17+ price: z.number().nullable(),
18+ currency: z.string().nullable(),
19+ availability: z.string().nullable(),
20+ category: z.string().nullable(),
21+ seller: z.string().nullable(),
22+});
23+export type MySlabsPayload = z.infer<typeof RawPayloadSchema>;
24+
25+const SPORTS_BRAND = /\b(topps|panini|bowman|prizm|fleer|donruss|upper deck|select|mosaic|optic|chrome|leaf|score|o-pee-chee|hoops|skybox|sp authentic|national treasures|immaculate|flawless|futera|stadium club|goudey|play ball|kellogg)\b/i;
26+const SPORT_WORDS: Array<[RegExp, string]> = [
27+ [/\b(basketball|nba|wnba|hoops|lebron|jordan|curry|wembanyama|kobe|giannis|luka|jokic)\b/i, 'basketball_cards'],
28+ [/\b(baseball|mlb|mantle|ohtani|trout|jeter|bowman|goudey|play ball)\b/i, 'baseball_cards'],
29+ [/\b(football|nfl|mahomes|brady|rookie qb|panini contenders|donruss optic football)\b/i, 'football_cards'],
30+ [/\b(hockey|nhl|gretzky|mcdavid|crosby|o-pee-chee|young guns|bedard)\b/i, 'hockey_cards'],
31+ [/\b(soccer|fifa|messi|ronaldo|mbappe|mbappé|haaland|yamal|premier league|champions league)\b/i, 'soccer_cards'],
32+ [/\b(f1|formula 1|formula one|verstappen|hamilton|leclerc|norris)\b/i, 'f1_cards'],
33+ [/\b(ufc|wwe|wrestling|boxing|golf|tennis|nascar|olympic|mma)\b/i, 'other_sports_cards'],
34+];
35+
36+/** Title → taxonomy slug for MySlabs inventory (cards/comics); null when unsure. */
37+export function slabCategory(title: string, categoryHint: string | null): string | null {
38+ const hinted = cardCategorySlug(categoryHint);
39+ if (hinted && hinted !== 'trading_cards') return hinted;
40+ const tcg = cardCategorySlug(title);
41+ if (tcg && ['pokemon', 'magic_the_gathering', 'yugioh', 'disney_lorcana', 'one_piece_card_game', 'digimon_tcg', 'flesh_and_blood', 'star_wars_tcg', 'dragon_ball_tcg'].includes(tcg)) return tcg;
42+ if ((/\b(cgc|cbcs)\b/i.test(title) && /#\s?\d+/.test(title) || /\bcomic|\b#\d+\s*\(?\d{4}\)?/i.test(title)) && !SPORTS_BRAND.test(title)) return /\bmarvel|spider-man|x-men|avengers|hulk|iron man|captain america|wolverine|daredevil|fantastic four\b/i.test(title) ? 'marvel_comics' : /\bdc\b|batman|superman|detective comics|action comics|wonder woman|flash|green lantern|joker/i.test(title) ? 'dc_comics' : 'independent_comics';
43+ for (const [re, slug] of SPORT_WORDS) if (re.test(title)) return slug;
44+ if (SPORTS_BRAND.test(title)) return 'sports_cards';
45+ if (hinted) return hinted;
46+ return null;
47+}
48+
49+function extractPayload(id: string, htmlText: string): MySlabsPayload | null {
50+ const products = H.jsonLd(htmlText, 'Product');
51+ const p = products[0];
52+ if (!p) return null;
53+ const offers = (Array.isArray(p.offers) ? p.offers[0] : p.offers) as Record<string, unknown> | undefined;
54+ const images = (Array.isArray(p.image) ? p.image : p.image ? [p.image] : []).map((u) => decodeXml(String(u)));
55+ const priceRaw = offers?.price;
56+ const price = priceRaw === undefined || priceRaw === null || priceRaw === '' ? null : Number(priceRaw);
57+ const availability = typeof offers?.availability === 'string' ? offers.availability.replace(/^https?:\/\/schema\.org\//, '') : null;
58+ const seller = (offers?.seller as { name?: string } | undefined)?.name ?? null;
59+ const $ = H.load(htmlText);
60+ const crumbs = $('nav[aria-label="breadcrumb"] a, .breadcrumb a, ol.breadcrumb li')
61+ .map((_, el) => $(el).text().trim())
62+ .get()
63+ .filter(Boolean);
64+ const category = crumbs.find((c) => /card|comic|pok|magic|yu-gi|sport/i.test(c)) ?? null;
65+ return { id, name: String(p.name ?? '').trim(), description: typeof p.description === 'string' ? p.description.trim() : null, images, price: Number.isFinite(price as number) ? (price as number) : null, currency: typeof offers?.priceCurrency === 'string' ? offers.priceCurrency : null, availability, category, seller };
66+}
67+
68+export class MySlabsConnector extends BaseConnector {
69+ readonly version = '1.0.0';
70+ readonly parserVersion = PARSER_VERSION;
71+ override readonly urlPatterns = [/^https?:\/\/(www\.)?myslabs\.com\/slab\/view\/\d+/i];
72+ protected override minIntervalMs = 1500;
73+
74+ private async fetchSlab(ctx: CrawlContext, url: string): Promise<RawRecordInput | null> {
75+ await this.throttle();
76+ const res = await ctx.fetch(url, { engines: ['api'], headers: { ...BOT_HEADERS, accept: 'text/html' }, responseType: 'text' });
77+ if (!res.success || !res.html) {
78+ ctx.anomaly('page_fetch_failed', `${url}: ${res.error ?? res.httpStatus}`);
79+ return null;
80+ }
81+ const id = url.match(/slab\/view\/(\d+)/)?.[1] ?? url;
82+ const payload = extractPayload(id, res.html);
83+ if (!payload || !payload.name) {
84+ ctx.anomaly('parse_failure_slab', url);
85+ return null;
86+ }
87+ return { url, externalId: id, kind: 'listing', engine: 'api', httpStatus: res.httpStatus, payload, fetchedAt: res.fetchedAt };
88+ }
89+
90+ async *crawl(ctx: CrawlContext): AsyncIterable<RawRecordInput> {
91+ const max = ctx.options.mode === 'backfill' ? Number(this.meta.config.backfillSlabs ?? 2000) : Number(this.meta.config.maxSlabsPerRun ?? 250);
92+ const files = ctx.options.mode === 'backfill' ? ['sitemap-slabs-1.xml', 'sitemap-slabs-2.xml'] : ['sitemap-slabs-1.xml'];
93+ const urls: Array<{ id: number; loc: string }> = [];
94+ for (const f of files) {
95+ const res = await ctx.fetch(`${SITE}/${f}`, { engines: ['api'], headers: { ...BOT_HEADERS, accept: 'application/xml,text/xml' }, responseType: 'text', timeoutMs: 120_000 });
96+ if (!res.success || !res.html) {
97+ ctx.anomaly('page_fetch_failed', `${f}: ${res.error ?? res.httpStatus}`);
98+ continue;
99+ }
100+ for (const e of parseSitemap(res.html)) {
101+ const id = Number(e.loc.match(/slab\/view\/(\d+)/)?.[1]);
102+ if (Number.isFinite(id)) urls.push({ id, loc: e.loc });
103+ }
104+ }
105+ if (ctx.options.seeds?.length) urls.push(...ctx.options.seeds.map((s) => ({ id: Number(s.match(/\d+/)?.[0] ?? 0), loc: s.startsWith('http') ? s : `${SITE}/slab/view/${s}/` })));
106+ urls.sort((a, b) => b.id - a.id);
107+ let count = 0;
108+ for (const u of urls.slice(0, max)) {
109+ if (ctx.signal?.aborted) return;
110+ if (this.reached(ctx, count)) return;
111+ if (!(await ctx.shouldFetch(u.loc))) continue;
112+ const rec = await this.fetchSlab(ctx, u.loc);
113+ if (!rec) continue;
114+ count++;
115+ yield rec;
116+ if (count % 25 === 0) await ctx.setCursor({ lastId: u.id, at: new Date().toISOString() });
117+ }
118+ await ctx.setCursor({ completedAt: new Date().toISOString(), newestId: urls[0]?.id ?? null });
119+ }
120+
121+ async lookup(url: string, ctx: CrawlContext): Promise<RawRecordInput[]> {
122+ const rec = await this.fetchSlab(ctx, url);
123+ return rec ? [rec] : [];
124+ }
125+
126+ async normalize(raw: RawRecordLike): Promise<NormalizedRecord[]> {
127+ const p = RawPayloadSchema.parse(raw.payload);
128+ const desc = (p.description ?? '').replace(/<[^>]+>/g, ' ').replace(/&[a-z]+;/g, ' ').slice(0, 400);
129+ const categorySlug = slabCategory(`${p.name} ${desc}`, p.category);
130+ if (!categorySlug) return [];
131+ // Slab pages carry the grade only when the seller typed it (title or description); never guessed.
132+ const g = parseGradeFromTitle(p.name).grader ? parseGradeFromTitle(p.name) : parseGradeFromTitle(desc);
133+ const cert = p.description?.match(/\b(?:cert|certification|serial)\s*(?:#|no\.?|number)?\s*[:\-]?\s*(\d{6,12})\b/i)?.[1] ?? null;
134+ const year = extractYear(p.name);
135+ const number = p.name.match(/#\s?([A-Za-z0-9-]+)/)?.[1] ?? null;
136+ const a = attrs({ categorySlug, name: p.name.replace(/\s*\b(PSA|BGS|CGC|SGC|TAG|CSG|CBCS)\b.*$/i, '').trim() || p.name, year, number, identifiers: { myslabs_id: p.id }, metadata: { seller: p.seller, category_label: p.category } });
137+ const availability = p.availability === 'OutOfStock' || p.availability === 'SoldOut' ? 'sold' : p.availability === 'InStock' || p.availability === 'PreOrder' ? 'available' : 'unknown';
138+ const currency = (p.currency ?? 'USD').toUpperCase();
139+ const listing = NormalizedListingSchema.parse({
140+ kind: 'listing',
141+ connectorId: this.meta.id,
142+ sourceId: this.meta.sourceId,
143+ sourceUrl: raw.url,
144+ externalId: p.id,
145+ rawTitle: p.name,
146+ description: p.description,
147+ imageUrls: p.images,
148+ attributes: a,
149+ grade: { grader: g.grader, grade: g.grade, qualifier: g.qualifier, certificationNumber: cert },
150+ condition: {},
151+ observedAt: raw.fetchedAt,
152+ confidence: 0.75,
153+ parserVersion: PARSER_VERSION,
154+ listingType: 'fixed_price',
155+ price: p.price,
156+ currency: p.price === null ? null : currency,
157+ seller: p.seller,
158+ availability,
159+ });
160+ return [listing];
161+ }
162+}
163+
164+export default (meta: ConnectorMeta) => new MySlabsConnector(meta);
added connectors/api/myslabs/meta.json +33 −0
@@ -0,0 +1,33 @@
1+{
2+ "id": "myslabs",
3+ "displayName": "MySlabs (graded card & comic marketplace)",
4+ "sourceId": "myslabs",
5+ "sourceName": "MySlabs",
6+ "sourceType": "marketplace",
7+ "sourceUrl": "https://myslabs.com",
8+ "module": "api/myslabs",
9+ "enginePriority": ["api"],
10+ "categories": ["sports_cards", "baseball_cards", "basketball_cards", "football_cards", "hockey_cards", "soccer_cards", "other_sports_cards", "pokemon", "magic_the_gathering", "yugioh", "non_sport_cards", "comics", "marvel_comics", "dc_comics", "independent_comics"],
11+ "regions": ["US"],
12+ "languages": ["en"],
13+ "currency": ["USD"],
14+ "supportsListings": true,
15+ "supportsSold": false,
16+ "supportsAuctions": false,
17+ "supportsImages": true,
18+ "supportsCatalog": false,
19+ "supportsPopulation": false,
20+ "supportsLookup": true,
21+ "refreshFrequencyMinutes": 720,
22+ "priority": "medium",
23+ "trustScore": 0.7,
24+ "attributionRequired": true,
25+ "termsUrl": "https://myslabs.com/static/terms",
26+ "accessNotes": "Public fixed-price marketplace for graded slabs. robots.txt only disallows /account/, /api/, /admin/, /ajax_select/. Discovery uses the public sitemaps (sitemap-slabs-*.xml, newest ids first); slab pages are plain HTML with a schema.org Product JSON-LD (name, images, offers.price USD, availability InStock/OutOfStock). OutOfStock slabs are stored as listings with availability 'sold' at their last asking price — never as confirmed sales. Politeness 1.5 s between pages; ~250 slabs per incremental run (`maxSlabsPerRun`).",
27+ "enabled": true,
28+ "schemaVersion": "1.0",
29+ "config": {
30+ "maxSlabsPerRun": 250,
31+ "backfillSlabs": 2000
32+ }
33+}
added connectors/firecrawl/alt-xyz/index.test.ts +94 −0
@@ -0,0 +1,94 @@
1+import { describe, expect, it } from 'vitest';
2+import meta from './meta.json' with { type: 'json' };
3+import { localMeta } from '../../api/_lib/local-meta.js';
4+import { listFixtures, loadFixture, runFixtureSuite } from '@rareindex/connectors/testing';
5+import createConnector, { parseItemMarkdown } from './index.js';
6+
7+const connector = createConnector(localMeta(meta));
8+
9+const SAMPLE = `Back
10+
11+# 2023 Bowman Chrome Prospect Autograph Purple Refractor Ludwing Espinoza \\#CPALE
12+
13+## Baseball Cards
14+
15+Serial207/250
16+
17+PSA10
18+
19+Pop24
20+
21+## List price
22+
23+### $45
24+
25+Buy nowOffer
26+
27+## LT Value
28+
29+$18
30+
31+### $12 - $29
32+
33+PSA population
34+
35+### Recent transactions
36+
37+[![eBay](https://x/e.png)\\
38+\\
39+AuctionAug 6, 2026\\
40+\\
41+$15](https://www.ebay.com/itm/278232527458)
42+
43+[![eBay](https://x/e.png)\\
44+\\
45+Best offerSep 3, 2024\\
46+\\
47+$20](https://www.ebay.com/itm/405178094963)
48+
49+View all
50+
51+Listings
52+
53+[![eBay](https://x/e.png)\\
54+\\
55+Fixed price\\
56+\\
57+$60\\
58+\\
59+live listing in eBay](https://www.ebay.com/itm/176907976889)
60+`;
61+
62+describe('alt-xyz', () => {
63+ runFixtureSuite(connector, it, expect);
64+
65+ it('parses item markdown', () => {
66+ const p = parseItemMarkdown(SAMPLE, 'abc')!;
67+ expect(p.title).toContain('Ludwing Espinoza #CPALE');
68+ expect(p.category).toBe('Baseball Cards');
69+ expect(p).toMatchObject({ grader: 'psa', grade: '10', pop: 24, listPrice: 45, listingKind: 'fixed_price', altValue: 18, altLow: 12, altHigh: 29, serial: '207/250' });
70+ expect(p.transactions).toEqual([
71+ { type: 'Auction', date: 'Aug 6, 2026', price: 15, url: 'https://www.ebay.com/itm/278232527458' },
72+ { type: 'Best offer', date: 'Sep 3, 2024', price: 20, url: 'https://www.ebay.com/itm/405178094963' },
73+ ]);
74+ });
75+
76+ it('normalises into observation + sales + listing', async () => {
77+ const raw = { url: 'https://alt.xyz/itm/abc', externalId: 'abc', kind: 'sale' as const, engine: 'firecrawl' as const, fetchedAt: new Date('2026-09-07T12:00:00Z'), payload: parseItemMarkdown(SAMPLE, 'abc') };
78+ const out = await connector.normalize(raw);
79+ const kinds = out.map((r) => r.kind);
80+ expect(kinds.filter((k) => k === 'sale')).toHaveLength(2);
81+ expect(kinds).toContain('price_observation');
82+ expect(kinds).toContain('listing');
83+ const sale = out.find((r) => r.kind === 'sale');
84+ if (sale && sale.kind === 'sale') {
85+ expect(sale.saleDate.toISOString()).toBe('2026-08-06T00:00:00.000Z');
86+ expect(sale.attributes.categorySlug).toBe('baseball_cards');
87+ expect(sale.grade.grader).toBe('psa');
88+ }
89+ for (const name of listFixtures('alt-xyz')) {
90+ const fx = await connector.normalize(loadFixture('alt-xyz', name).raw);
91+ expect(fx.length).toBeGreaterThan(0);
92+ }
93+ });
94+});
added connectors/firecrawl/alt-xyz/index.ts +168 −0
@@ -0,0 +1,168 @@
1+import { z } from 'zod';
2+import { BaseConnector, type ConnectorMeta, type CrawlContext, type RawRecordInput, type RawRecordLike } from '@rareindex/connectors';
3+import { NormalizedListingSchema, NormalizedSaleSchema, extractYear, type NormalizedRecord } from '@rareindex/shared';
4+import { attrs, priceObservation } from '../../api/_lib/shared.js';
5+import { BOT_HEADERS, cardCategorySlug, dayOf, fetchMaybeGzip, money, parseMonthDay, parseSitemap } from '../../api/_lib/wave4.js';
6+
7+/** Alt item pages → Alt Value observations, recent transactions (sales) and Alt list-price listings. */
8+const PARSER_VERSION = '1.0.0';
9+const SITE = 'https://alt.xyz';
10+
11+const TxSchema = z.object({ type: z.string(), date: z.string(), price: z.number(), url: z.string().nullable() });
12+const RawPayloadSchema = z.object({
13+ id: z.string(),
14+ title: z.string(),
15+ category: z.string().nullable(),
16+ serial: z.string().nullable(),
17+ grader: z.string().nullable(),
18+ grade: z.string().nullable(),
19+ pop: z.number().nullable(),
20+ listPrice: z.number().nullable(),
21+ listingKind: z.enum(['fixed_price', 'auction']).nullable().default(null),
22+ altValue: z.number().nullable(),
23+ altLow: z.number().nullable(),
24+ altHigh: z.number().nullable(),
25+ transactions: z.array(TxSchema),
26+ images: z.array(z.string()),
27+});
28+export type AltPayload = z.infer<typeof RawPayloadSchema>;
29+
30+/** Parse the Firecrawl markdown of an item page. Exported for tests. */
31+export function parseItemMarkdown(md: string, id: string): AltPayload | null {
32+ const text = md.replace(/\\\n/g, '\n').replace(/\\([#$])/g, '$1');
33+ const title = text.match(/^#\s+(.+)$/m)?.[1]?.trim();
34+ if (!title) return null;
35+ const afterTitle = text.slice(text.indexOf(title) + title.length);
36+ const category = afterTitle.match(/^##\s+([A-Za-z' \-]+Cards?)\s*$/m)?.[1]?.trim() ?? afterTitle.match(/^##\s+(Pok[eé]mon|Magic[^\n]*|Yu-Gi-Oh!?)\s*$/m)?.[1]?.trim() ?? null;
37+ const serial = afterTitle.match(/Serial\s*(\d+\s*\/\s*\d+)/)?.[1]?.replace(/\s/g, '') ?? null;
38+ const gm = afterTitle.match(/^(PSA|BGS|SGC|CGC|CSG|TAG|HGA)\s?(\d{1,2}(?:\.\d)?)\s*$/m);
39+ const pop = afterTitle.match(/^Pop\s*([\d,]+)\s*$/m)?.[1];
40+ const fixed = afterTitle.match(/##\s+List price\s*\n+\s*###\s+\$([\d,]+(?:\.\d+)?)/)?.[1] ?? null;
41+ const bid = afterTitle.match(/(?:Starting bid|Current bid|High bid)\s*\$([\d,]+(?:\.\d+)?)/)?.[1] ?? null;
42+ const listPrice = fixed ?? bid;
43+ const listingKind: 'fixed_price' | 'auction' | null = fixed ? 'fixed_price' : bid ? 'auction' : null;
44+ const av = afterTitle.match(/##\s+(?:LT|Alt) Value\s*\n+\s*\$([\d,]+(?:\.\d+)?)\s*\n+\s*###\s+\$([\d,]+(?:\.\d+)?)\s*-\s*\$([\d,]+(?:\.\d+)?)/);
45+ const txStart = afterTitle.search(/Recent transactions/i);
46+ const txEnd = afterTitle.search(/\n##?\s+(Listings|Similar listings)|\nListings\s*\n/);
47+ const txBlock = txStart >= 0 ? afterTitle.slice(txStart, txEnd > txStart ? txEnd : undefined) : '';
48+ const transactions: z.infer<typeof TxSchema>[] = [];
49+ const re = /(Auction|Best offer|Fixed price|Buy now|Sale)\s*([A-Z][a-z]{2,8}\.? \d{1,2}, \d{4})\s*\n+\s*\$([\d,]+(?:\.\d+)?)\]\((https?:[^)\s]+)\)/g;
50+ let m: RegExpExecArray | null;
51+ while ((m = re.exec(txBlock))) {
52+ const price = money(m[3]);
53+ if (!price) continue;
54+ transactions.push({ type: m[1]!, date: m[2]!, price, url: m[4] ?? null });
55+ }
56+ const images = [...text.matchAll(/!\[[^\]]*\]\((https:\/\/alt-images\.b-cdn\.net\/public\/[^)\s]+width=324[^)\s]*)\)/g)].map((x) => x[1]!);
57+ return {
58+ id,
59+ title,
60+ category,
61+ serial,
62+ grader: gm ? gm[1]!.toLowerCase() : null,
63+ grade: gm ? gm[2]! : null,
64+ pop: pop ? Number(pop.replace(/,/g, '')) : null,
65+ listPrice: money(listPrice),
66+ listingKind,
67+ altValue: av ? money(av[1]) : null,
68+ altLow: av ? money(av[2]) : null,
69+ altHigh: av ? money(av[3]) : null,
70+ transactions,
71+ images: [...new Set(images)],
72+ };
73+}
74+
75+async function fetchGzText(url: string, signal?: AbortSignal): Promise<string> {
76+ return (await fetchMaybeGzip(url, signal)).toString('utf8');
77+}
78+
79+export class AltConnector extends BaseConnector {
80+ readonly version = '1.0.0';
81+ readonly parserVersion = PARSER_VERSION;
82+ override readonly urlPatterns = [/^https?:\/\/(www\.)?alt\.xyz\/itm\/[0-9a-f-]{36}/i];
83+ protected override minIntervalMs = 1500;
84+
85+ private async fetchItem(ctx: CrawlContext, url: string): Promise<RawRecordInput | null> {
86+ await this.throttle();
87+ const res = await ctx.fetch(url, { engines: ['firecrawl'], waitForMs: 9000, timeoutMs: 90_000, expect: ['title', 'price'], parse: (r) => ({ title: r.markdown?.match(/^#\s+.+$/m) ? 'ok' : null, price: r.markdown && /\$\d/.test(r.markdown) ? 1 : null }) });
88+ if (!res.success || !res.markdown) {
89+ ctx.anomaly('page_fetch_failed', `${url}: ${res.error ?? res.httpStatus}`);
90+ return null;
91+ }
92+ const id = url.match(/itm\/([0-9a-f-]{36})/i)?.[1] ?? url;
93+ const payload = parseItemMarkdown(res.markdown, id);
94+ if (!payload) {
95+ ctx.anomaly('parse_failure_item', url);
96+ return null;
97+ }
98+ return { url: `${SITE}/itm/${id}`, externalId: id, kind: 'sale', engine: 'firecrawl', httpStatus: res.httpStatus, payload, fetchedAt: res.fetchedAt };
99+ }
100+
101+ async *crawl(ctx: CrawlContext): AsyncIterable<RawRecordInput> {
102+ const max = ctx.options.mode === 'backfill' ? Number(this.meta.config.maxItemsPerRun ?? 100) * 5 : Number(this.meta.config.maxItemsPerRun ?? 100);
103+ let urls: Array<{ loc: string; lastmod: string }> = [];
104+ if (ctx.options.seeds?.length) urls = ctx.options.seeds.map((s) => ({ loc: s.startsWith('http') ? s : `${SITE}/itm/${s}`, lastmod: '' }));
105+ else {
106+ const idx = await ctx.fetch(`${SITE}/sitemap.xml`, { engines: ['api'], headers: { ...BOT_HEADERS, accept: 'application/xml' }, responseType: 'text' });
107+ if (!idx.success || !idx.html) throw new Error(`alt sitemap index failed: ${idx.error ?? idx.httpStatus}`);
108+ const wanted = (this.meta.config.sitemaps as string[] | undefined) ?? ['fixed-price', 'auctions'];
109+ const children = [...idx.html.matchAll(/<loc>\s*([^<\s]+)\s*<\/loc>/g)].map((x) => x[1]!).filter((u) => wanted.some((w) => u.includes(`/${w}-`)));
110+ for (const child of children.slice(0, 6)) {
111+ try {
112+ const xml = await fetchGzText(child, ctx.signal);
113+ for (const e of parseSitemap(xml)) urls.push({ loc: e.loc, lastmod: e.lastmod ?? '' });
114+ } catch (err) {
115+ ctx.anomaly('page_fetch_failed', `${child}: ${err instanceof Error ? err.message : String(err)}`);
116+ }
117+ }
118+ const since = String(ctx.options.cursor?.since ?? '');
119+ urls = urls.filter((u) => !since || u.lastmod > since).sort((a, b) => b.lastmod.localeCompare(a.lastmod));
120+ }
121+ let count = 0;
122+ let newest = String(ctx.options.cursor?.since ?? '');
123+ for (const u of urls.slice(0, max)) {
124+ if (ctx.signal?.aborted) return;
125+ if (this.reached(ctx, count)) break;
126+ const rec = await this.fetchItem(ctx, u.loc);
127+ if (!rec) continue;
128+ count++;
129+ if (u.lastmod > newest) newest = u.lastmod;
130+ yield rec;
131+ if (count % 20 === 0) await ctx.setCursor({ since: newest, at: new Date().toISOString() });
132+ }
133+ await ctx.setCursor({ since: newest, completedAt: new Date().toISOString() });
134+ }
135+
136+ async lookup(url: string, ctx: CrawlContext): Promise<RawRecordInput[]> {
137+ const rec = await this.fetchItem(ctx, url);
138+ return rec ? [rec] : [];
139+ }
140+
141+ async normalize(raw: RawRecordLike): Promise<NormalizedRecord[]> {
142+ const p = RawPayloadSchema.parse(raw.payload);
143+ const categorySlug = cardCategorySlug(p.category) ?? cardCategorySlug(p.title);
144+ if (!categorySlug) return [];
145+ const year = extractYear(p.title);
146+ const number = p.title.match(/#\s?([A-Za-z0-9-]+)/)?.[1] ?? null;
147+ const name = p.title.replace(/^\d{4}(?:-\d{2})?\s+/, '').replace(/\s*#\S+\s*$/, '').trim() || p.title;
148+ const a = attrs({ categorySlug, name, year, number, identifiers: { alt_item_id: p.id }, metadata: { serial: p.serial, pop: p.pop } });
149+ const grade = { grader: p.grader, grade: p.grade, qualifier: null, certificationNumber: null };
150+ const base = { connectorId: this.meta.id, sourceId: this.meta.sourceId, sourceUrl: raw.url, rawTitle: p.title, imageUrls: p.images, attributes: a, grade, condition: {}, observedAt: raw.fetchedAt, parserVersion: PARSER_VERSION };
151+ const out: NormalizedRecord[] = [];
152+ if (p.altValue) {
153+ out.push(priceObservation({ kind: 'price_observation', ...base, externalId: `${p.id}:altvalue:${dayOf(raw.fetchedAt).toISOString().slice(0, 10)}`, confidence: 0.6, priceKind: 'guide_value', price: p.altValue, currency: 'USD', observationDate: dayOf(raw.fetchedAt), sampleSize: p.transactions.length || null, attributes: { ...a, metadata: { ...a.metadata, low: p.altLow, high: p.altHigh, model: 'Alt Value' } } }));
154+ }
155+ for (const t of p.transactions) {
156+ const saleDate = parseMonthDay(t.date);
157+ if (!saleDate) continue;
158+ const saleType = /auction/i.test(t.type) ? 'auction' : /best offer/i.test(t.type) ? 'best_offer' : 'fixed_price';
159+ out.push(NormalizedSaleSchema.parse({ kind: 'sale', ...base, externalId: `${p.id}:${saleDate.toISOString().slice(0, 10)}:${t.price}`, confidence: 0.65, saleType, saleDate, price: t.price, currency: 'USD', buyerPremiumIncluded: null, quantity: 1, isBundle: false, location: null, auctionHouse: t.url?.includes('ebay.') ? 'eBay (via Alt)' : null, lotNumber: null, attributes: { ...a, metadata: { ...a.metadata, external_url: t.url, via: 'alt.xyz' } } }));
160+ }
161+ if (p.listPrice) {
162+ out.push(NormalizedListingSchema.parse({ kind: 'listing', ...base, externalId: p.id, confidence: 0.75, listingType: p.listingKind ?? 'fixed_price', price: p.listPrice, currency: 'USD', seller: 'Alt marketplace', availability: 'available' }));
163+ }
164+ return out;
165+ }
166+}
167+
168+export default (meta: ConnectorMeta) => new AltConnector(meta);
added connectors/firecrawl/alt-xyz/meta.json +33 −0
@@ -0,0 +1,33 @@
1+{
2+ "id": "alt-xyz",
3+ "displayName": "Alt (graded card values & transactions)",
4+ "sourceId": "alt",
5+ "sourceName": "Alt",
6+ "sourceType": "analytics_provider",
7+ "sourceUrl": "https://alt.xyz",
8+ "module": "firecrawl/alt-xyz",
9+ "enginePriority": ["firecrawl"],
10+ "categories": ["sports_cards", "baseball_cards", "basketball_cards", "football_cards", "hockey_cards", "soccer_cards", "other_sports_cards", "pokemon", "magic_the_gathering", "yugioh", "non_sport_cards"],
11+ "regions": ["US"],
12+ "languages": ["en"],
13+ "currency": ["USD"],
14+ "supportsListings": true,
15+ "supportsSold": true,
16+ "supportsAuctions": false,
17+ "supportsImages": true,
18+ "supportsCatalog": false,
19+ "supportsPopulation": false,
20+ "supportsLookup": true,
21+ "refreshFrequencyMinutes": 1440,
22+ "priority": "low",
23+ "trustScore": 0.7,
24+ "attributionRequired": true,
25+ "termsUrl": "https://alt.xyz/terms",
26+ "accessNotes": "Alt's public item pages (robots.txt allows everything; discovery through the public sitemaps with lastmod) show the card, grade, PSA/BGS population, Alt's list price, the 'Alt Value' model estimate with its range, and a 'Recent transactions' list of observed sales (mostly eBay auctions/best offers) with date and price. Pages are client-rendered → Firecrawl, 1 credit per item, capped by `maxItemsPerRun`. Alt Value is stored as a guide_value observation (confidence 0.6); recent transactions become sales attributed to Alt with the original listing URL in metadata (confidence 0.65 — second-hand aggregation); the Alt list price is a fixed-price listing.",
27+ "enabled": true,
28+ "schemaVersion": "1.0",
29+ "config": {
30+ "sitemaps": ["fixed-price", "auctions"],
31+ "maxItemsPerRun": 100
32+ }
33+}
added connectors/firecrawl/comc/index.test.ts +49 −0
@@ -0,0 +1,49 @@
1+import { describe, expect, it } from 'vitest';
2+import meta from './meta.json' with { type: 'json' };
3+import { localMeta } from '../../api/_lib/local-meta.js';
4+import { listFixtures, loadFixture, runFixtureSuite } from '@rareindex/connectors/testing';
5+import createConnector, { parseBrowseMarkdown, parseComcTitle, parseSetLine } from './index.js';
6+
7+const connector = createConnector(localMeta(meta));
8+
9+const SAMPLE = `Listings **1** \\- **100** of **1,292**
10+
11+[![Steve Johnson [CSG 10 Gem Mint]](https://img.comc.com/i/Basketball/1986-87/Fleer---Base/55/Steve-Johnson.jpg?id=fc68&size=biggerthumb)](https://www.comc.com/Cards/Basketball/1986-87/Fleer_-_Base/55/Steve_Johnson/1395657/Graded/CSG/10_Gem)
12+
13+1986-87 Fleer - \\[Base\\] #55
14+
15+
16+### [Steve Johnson \\[CSG 10 Gem Mint\\]](https://www.comc.com/Cards/Basketball/1986-87/Fleer_-_Base/55/Steve_Johnson/1395657/Graded/CSG/10_Gem)
17+
18+$1,791.76
19+`;
20+
21+describe('comc', () => {
22+ runFixtureSuite(connector, it, expect);
23+
24+ it('parses browse rows, titles and set lines', () => {
25+ const rows = parseBrowseMarkdown(SAMPLE);
26+ expect(rows).toHaveLength(1);
27+ expect(rows[0]).toMatchObject({ price: 1791.76, setLine: '1986-87 Fleer - [Base] #55' });
28+ expect(parseComcTitle('Steve Johnson [CSG 10 Gem Mint]')).toMatchObject({ name: 'Steve Johnson', grader: 'csg', grade: '10' });
29+ expect(parseComcTitle('Michael Jordan [PSA 9 MINT]')).toMatchObject({ grader: 'psa', grade: '9' });
30+ expect(parseComcTitle('Larry Bird')).toMatchObject({ grader: null, grade: null });
31+ expect(parseSetLine('1986-87 Fleer - [Base] #57')).toEqual({ year: 1986, set: '1986 Fleer', variant: null, number: '57' });
32+ expect(parseSetLine('2018-19 Panini Prizm - [Base] - Silver Prizm #280')).toMatchObject({ year: 2018, number: '280' });
33+ });
34+
35+ it('emits USD listings with card attributes', async () => {
36+ for (const name of listFixtures('comc')) {
37+ const out = await connector.normalize(loadFixture('comc', name).raw);
38+ expect(out.length).toBeGreaterThan(0);
39+ for (const r of out) {
40+ expect(r.kind).toBe('listing');
41+ if (r.kind === 'listing') {
42+ expect(r.currency).toBe('USD');
43+ expect(r.price).toBeGreaterThan(0);
44+ expect(r.sourceUrl).toMatch(/comc\.com\/Cards\//);
45+ }
46+ }
47+ }
48+ });
49+});
added connectors/firecrawl/comc/index.ts +149 −0
@@ -0,0 +1,149 @@
1+import { z } from 'zod';
2+import { BaseConnector, type ConnectorMeta, type CrawlContext, type RawRecordInput, type RawRecordLike } from '@rareindex/connectors';
3+import { NormalizedListingSchema, type NormalizedRecord } from '@rareindex/shared';
4+import { attrs } from '../../api/_lib/shared.js';
5+import { cardCategorySlug, money } from '../../api/_lib/wave4.js';
6+
7+/** COMC category browse pages (Firecrawl markdown) → card listings with grades and asking prices. */
8+const PARSER_VERSION = '1.0.0';
9+const SITE = 'https://www.comc.com';
10+
11+const ListingSchema = z.object({ title: z.string(), url: z.string(), image: z.string().nullable(), setLine: z.string().nullable(), price: z.number() });
12+const RawPayloadSchema = z.object({ seed: z.string(), page: z.number(), listings: z.array(ListingSchema) });
13+export type ComcPayload = z.infer<typeof RawPayloadSchema>;
14+
15+/** Parse a rendered browse page. Exported for tests. */
16+export function parseBrowseMarkdown(md: string): z.infer<typeof ListingSchema>[] {
17+ const lines = md.split('\n').map((l) => l.trim());
18+ const out: z.infer<typeof ListingSchema>[] = [];
19+ let setLine: string | null = null;
20+ let image: string | null = null;
21+ for (let i = 0; i < lines.length; i++) {
22+ const l = lines[i]!;
23+ const img = l.match(/^\[!\[[^\]]*\]\((https?:\/\/img\.comc\.com[^)\s]+)\)\]\(https?:\/\/www\.comc\.com\/Cards\/[^)]+\)$/);
24+ if (img) {
25+ image = img[1]!;
26+ continue;
27+ }
28+ if (/^\d{4}(?:-\d{2})?\s.+#\S+$/.test(l.replace(/\\/g, ''))) {
29+ setLine = l.replace(/\\/g, '');
30+ continue;
31+ }
32+ const h = l.match(/^###\s+\[(.+?)\]\((https?:\/\/www\.comc\.com\/Cards\/[^)\s]+)\)\s*$/);
33+ if (!h) continue;
34+ let price: number | null = null;
35+ for (let j = i + 1; j < Math.min(lines.length, i + 5); j++) {
36+ const m = lines[j]!.match(/^\$([\d,]+\.\d{2})$/);
37+ if (m) {
38+ price = money(m[1]);
39+ break;
40+ }
41+ if (lines[j]!.startsWith('###')) break;
42+ }
43+ if (price === null) continue;
44+ out.push({ title: h[1]!.replace(/\\/g, ''), url: h[2]!, image, setLine, price });
45+ image = null;
46+ }
47+ return out;
48+}
49+
50+const GRADE_RE = /\[(PSA|BGS|CSG|CGC|SGC|TAG|HGA|ACE|BVG|GMA|ISA|KSA|MNT|BCCG|PSA\/DNA|BAS|JSA)\s*(\d{1,2}(?:\.\d)?)?\s*([^\]]*)\]/i;
51+
52+export function parseComcTitle(title: string): { name: string; grader: string | null; grade: string | null; qualifier: string | null; auto: boolean } {
53+ const m = title.match(GRADE_RE);
54+ const name = title.replace(/\s*\[[^\]]*\]\s*/g, ' ').replace(/\s+/g, ' ').trim();
55+ if (!m) return { name, grader: null, grade: null, qualifier: null, auto: /\bauto/i.test(title) };
56+ const grader = m[1]!.toLowerCase().replace('/', '_');
57+ const grade = m[2] ?? null;
58+ const qualifier = m[3]?.trim() || null;
59+ const isAuth = /dna|bas|jsa/.test(grader);
60+ return { name, grader: isAuth && !grade ? null : grader, grade, qualifier: isAuth ? `${m[1]} ${qualifier ?? ''}`.trim() : qualifier, auto: /\bauto/i.test(title) };
61+}
62+
63+export function parseSetLine(setLine: string | null): { year: number | null; set: string | null; variant: string | null; number: string | null } {
64+ if (!setLine) return { year: null, set: null, variant: null, number: null };
65+ const m = setLine.match(/^(\d{4})(?:-\d{2})?\s+(.+?)(?:\s+-\s+\[(.+?)\])?\s+#(\S+)$/);
66+ if (!m) return { year: null, set: setLine, variant: null, number: null };
67+ const year = Number(m[1]);
68+ const variant = m[3] && !/^base$/i.test(m[3]) ? m[3] : null;
69+ return { year, set: `${m[1]} ${m[2]}`.trim(), variant, number: m[4] ?? null };
70+}
71+
72+function seedCategory(seed: string): string | null {
73+ const sport = seed.split('/')[1]?.replace(/_/g, ' ') ?? '';
74+ return cardCategorySlug(sport) ?? (/^racing$/i.test(sport) ? 'other_sports_cards' : null);
75+}
76+
77+export class ComcConnector extends BaseConnector {
78+ readonly version = '1.0.0';
79+ readonly parserVersion = PARSER_VERSION;
80+ protected override minIntervalMs = 2000;
81+
82+ async *crawl(ctx: CrawlContext): AsyncIterable<RawRecordInput> {
83+ const seeds = ctx.options.seeds?.length ? ctx.options.seeds : ((this.meta.config.seeds as string[] | undefined) ?? []);
84+ const pages = ctx.options.mode === 'backfill' ? 10 : Number(this.meta.config.pagesPerSeed ?? 2);
85+ let count = 0;
86+ for (const seed of seeds) {
87+ for (let page = 1; page <= pages; page++) {
88+ if (ctx.signal?.aborted) return;
89+ if (this.reached(ctx, count)) return;
90+ const url = `${SITE}/${seed.replace(/^\/+/, '')},sh${page > 1 ? `,p${page}` : ''}`;
91+ await this.throttle();
92+ const res = await ctx.fetch(url, { engines: ['firecrawl'], waitForMs: 3000, timeoutMs: 90_000, expect: ['title', 'price'], parse: (r) => ({ title: r.markdown?.includes('Listings') ? 'ok' : null, price: r.markdown && /\$\d/.test(r.markdown) ? 1 : null }) });
93+ if (!res.success || !res.markdown) {
94+ ctx.anomaly('page_fetch_failed', `${url}: ${res.error ?? res.httpStatus}`);
95+ break;
96+ }
97+ const listings = parseBrowseMarkdown(res.markdown);
98+ if (!listings.length) {
99+ ctx.anomaly('parse_failure_page', url);
100+ break;
101+ }
102+ count++;
103+ const payload: ComcPayload = { seed, page, listings };
104+ yield { url, externalId: `${seed}:p${page}`, kind: 'listing', engine: 'firecrawl', httpStatus: res.httpStatus, payload, fetchedAt: res.fetchedAt };
105+ const total = res.markdown.match(/of\s+\*\*([\d,]+)\*\*/)?.[1];
106+ if (total && Number(total.replace(/,/g, '')) <= page * 100) break;
107+ }
108+ await ctx.setCursor({ seed, at: new Date().toISOString() });
109+ }
110+ }
111+
112+ async normalize(raw: RawRecordLike): Promise<NormalizedRecord[]> {
113+ const p = RawPayloadSchema.parse(raw.payload);
114+ const categorySlug = seedCategory(p.seed);
115+ if (!categorySlug) return [];
116+ const out: NormalizedRecord[] = [];
117+ for (const l of p.listings) {
118+ const t = parseComcTitle(l.title);
119+ const s = parseSetLine(l.setLine);
120+ const externalId = l.url.replace(`${SITE}/Cards/`, '').replace(/\/+$/, '');
121+ const a = attrs({ categorySlug, name: t.name, set: s.set, year: s.year, number: s.number, variant: s.variant, identifiers: { comc_path: externalId }, metadata: { autograph: t.auto } });
122+ out.push(
123+ NormalizedListingSchema.parse({
124+ kind: 'listing',
125+ connectorId: this.meta.id,
126+ sourceId: this.meta.sourceId,
127+ sourceUrl: l.url,
128+ externalId,
129+ rawTitle: l.setLine ? `${l.setLine} ${l.title}` : l.title,
130+ imageUrls: l.image ? [l.image.replace(/size=biggerthumb/, 'size=large')] : [],
131+ attributes: a,
132+ grade: { grader: t.grader, grade: t.grade, qualifier: t.qualifier },
133+ condition: {},
134+ observedAt: raw.fetchedAt,
135+ confidence: 0.8,
136+ parserVersion: PARSER_VERSION,
137+ listingType: 'fixed_price',
138+ price: l.price,
139+ currency: 'USD',
140+ seller: 'COMC consignment',
141+ availability: 'available',
142+ }),
143+ );
144+ }
145+ return out;
146+ }
147+}
148+
149+export default (meta: ConnectorMeta) => new ComcConnector(meta);
added connectors/firecrawl/comc/meta.json +48 −0
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
Menu

Theme