import { z } from 'zod'; import { BaseConnector, html, type ConnectorMeta, type CrawlContext, type RawRecordInput, type RawRecordLike } from '@rareindex/connectors'; import { type NormalizedRecord } from '@rareindex/shared'; import { attrs, catalogItem, makeTitle, priceObservation } from '../_lib/shared.js'; import { HTML_HEADERS, JSON_HEADERS, cleanText, dayOf, usdEur } from '../_g1-cards-eu-jp-lib/index.js'; /** * Limitless TCG Pokémon card database: set index → per-set list tables (number, name, rarity, image, * relayed TCGplayer USD / Cardmarket EUR prices) and, in backfill/lookup, the per-card price history * JSON the public card page itself loads (/api/cards//prices). */ const SITE = 'https://limitlesstcg.com'; const PARSER_VERSION = '1.0.0'; export const LangSchema = z.enum(['en', 'jp']); export type Lang = z.infer; export const SetSchema = z.object({ code: z.string(), name: z.string(), lang: LangSchema, releaseDate: z.string().nullable(), cardCount: z.number().int().nullable() }); export type LimitlessSet = z.infer; const PriceSchema = z.object({ amount: z.number(), currency: z.enum(['USD', 'EUR']), url: z.string().nullable(), tcgplayerId: z.string().nullable() }); export const CardRowSchema = z.object({ number: z.string(), name: z.string(), url: z.string(), type: z.string().nullable(), rarity: z.string().nullable(), image: z.string().nullable(), usd: PriceSchema.nullable(), eur: PriceSchema.nullable(), }); export type CardRow = z.infer; const SetPagePayloadSchema = z.object({ set: SetSchema, cards: z.array(CardRowSchema) }); const HistoryPayloadSchema = z.object({ set: SetSchema, card: z.object({ number: z.string(), name: z.string(), url: z.string(), cardId: z.number().int(), rarity: z.string().nullable(), image: z.string().nullable(), tcgplayerId: z.string().nullable() }), history: z.object({ tcgplayer: z.array(z.tuple([z.number(), z.number()])).default([]), cardmarket: z.array(z.tuple([z.number(), z.number()])).default([]) }), }); const RawPayloadSchema = z.union([SetPagePayloadSchema.extend({ type: z.literal('set') }), HistoryPayloadSchema.extend({ type: z.literal('history') })]); export type LimitlessPayload = z.infer; const MONTHS: Record = { jan: 0, feb: 1, mar: 2, apr: 3, may: 4, jun: 5, jul: 6, aug: 7, sep: 8, oct: 9, nov: 10, dec: 11 }; /** "26 Sep 25" | "17 Jul 26" → "2025-09-26" (Limitless prints 2-digit years). */ export function parseShortDate(s: string | null | undefined): string | null { if (!s) return null; const m = s.trim().match(/^(\d{1,2})\s+([A-Za-z]{3})\s+(\d{2}|\d{4})$/); if (!m) return null; const mo = MONTHS[m[2]!.toLowerCase()]; if (mo === undefined) return null; const y = m[3]!.length === 2 ? 2000 + Number(m[3]) : Number(m[3]); const d = new Date(Date.UTC(y, mo, Number(m[1]))); return Number.isNaN(d.getTime()) ? null : d.toISOString().slice(0, 10); } export function tcgplayerIdFromUrl(href: string | null | undefined): string | null { if (!href) return null; const dec = decodeURIComponent(href); return dec.match(/tcgplayer\.com\/product\/(\d+)/)?.[1] ?? null; } /** Parse the set index tables (/cards and /cards/jp). */ export function parseSetIndex(doc: string, lang: Lang): LimitlessSet[] { const $ = html.load(doc); const out: LimitlessSet[] = []; const prefix = lang === 'jp' ? '/cards/jp/' : '/cards/'; $('table.sets-table tr').each((_, tr) => { const tds = $(tr).find('td'); if (tds.length < 2) return; const a = $(tds[0]).find('a').first(); const href = a.attr('href') ?? ''; if (!href.startsWith(prefix)) return; const code = href.slice(prefix.length).split(/[/?#]/)[0]!; if (!code || (lang === 'en' && code.startsWith('jp'))) return; const codeSpan = a.find('span.code').text().trim(); const name = cleanText(a.clone().children('img, span').remove().end().text()) ?? code; const releaseDate = parseShortDate($(tds[1]).text()); const countText = tds.length > 2 ? $(tds[2]).find('a').clone().children().remove().end().text() : ''; const cardCount = Number.parseInt(countText.replace(/[^0-9]/g, ''), 10); out.push({ code: codeSpan || code, name, lang, releaseDate, cardCount: Number.isFinite(cardCount) ? cardCount : null }); }); return out; } function priceCell($: html.CheerioRoot, td: unknown): z.infer | null { const a = $(td as never).find('a.card-price').first(); if (!a.length) return null; const p = usdEur(a.text()); if (!p) return null; const url = a.attr('href') ?? null; return { amount: p.amount, currency: p.currency, url, tcgplayerId: tcgplayerIdFromUrl(url) }; } /** Parse a set list page (?display=list): one row per card. */ export function parseSetList(doc: string): { setName: string | null; cards: CardRow[] } { const $ = html.load(doc); const cards: CardRow[] = []; let setName: string | null = null; $('table.card-list tr').each((_, tr) => { const tds = $(tr).find('td'); if (tds.length < 3) return; const tooltip = $(tds[0]).find('.card-set').attr('data-tooltip'); if (tooltip && !setName) setName = tooltip.trim(); const numA = $(tds[1]).find('a').first(); const nameA = $(tds[2]).find('a').first(); const number = cleanText(numA.text()); const name = cleanText(nameA.text()); const href = nameA.attr('href') ?? numA.attr('href'); if (!number || !name || !href) return; const type = tds.length > 3 ? cleanText($(tds[3]).text()) : null; const rarity = tds.length > 4 ? cleanText($(tds[4]).text()) : null; const hover = $(tr).attr('data-hover') ?? null; const image = hover ? hover.replace(/_XS\.png$/, '.png') : null; const usd = tds.length > 5 ? priceCell($, tds[5]) : null; const eur = tds.length > 6 ? priceCell($, tds[6]) : null; cards.push({ number, name, url: href.startsWith('http') ? href : `${SITE}${href}`, type, rarity, image, usd, eur }); }); return { setName, cards }; } /** Card page: numeric cardId (drives /api/cards//prices), name, number/rarity line, image. */ export function parseCardPage(doc: string): { cardId: number | null; name: string | null; number: string | null; rarity: string | null; setName: string | null; image: string | null; tcgplayerId: string | null } { const $ = html.load(doc); const cardId = Number(doc.match(/var\s+cardId\s*=\s*(\d+)/)?.[1] ?? NaN); const name = cleanText($('.card-text-name').first().text()); const details = cleanText($('.prints-current-details').text()) ?? ''; const m = details.match(/#\s*([A-Za-z0-9]+)\s*·\s*(.+)$/); const setName = cleanText($('.prints-current-details .text-lg').text())?.replace(/\s*\([A-Z0-9-]+\)\s*$/, '') ?? null; const image = $('.card-image img').attr('data-src') ?? $('.card-image img').attr('src') ?? null; const tcgplayerId = tcgplayerIdFromUrl($('a.card-buy-button.usd, a.card-price.usd').first().attr('href')); return { cardId: Number.isFinite(cardId) ? cardId : null, name, number: m?.[1] ?? null, rarity: m?.[2]?.trim() ?? null, setName, image, tcgplayerId }; } const HISTORY_RESPONSE = z.object({ tcgplayer: z.array(z.tuple([z.number(), z.number()])).default([]), cardmarket: z.array(z.tuple([z.number(), z.number()])).default([]) }); export class LimitlessTcgConnector extends BaseConnector { readonly version = '1.0.0'; readonly parserVersion = PARSER_VERSION; protected override minIntervalMs = 1500; override readonly urlPatterns = [/^https?:\/\/(www\.)?limitlesstcg\.com\/cards\/(jp\/)?[A-Za-z0-9-]+\/[A-Za-z0-9]+\/?$/]; private async page(ctx: CrawlContext, url: string) { await this.throttle(url); return ctx.fetch(url, { engines: ['api'], headers: HTML_HEADERS, responseType: 'text', minQuality: 0 }); } private async sets(ctx: CrawlContext, lang: Lang): Promise { const url = lang === 'jp' ? `${SITE}/cards/jp` : `${SITE}/cards`; const res = await this.page(ctx, url); if (!res.success || !res.html) { ctx.anomaly('page_fetch_failed', `${url}: ${res.error ?? res.httpStatus}`); return null; } const sets = parseSetIndex(res.html, lang); if (!sets.length) ctx.anomaly('selector_missing', `${url}: no sets-table rows`); return sets; } async *crawl(ctx: CrawlContext): AsyncIterable { const langs = z.array(LangSchema).parse(this.meta.config.languages ?? ['en', 'jp']); const backfill = ctx.options.mode === 'backfill'; const maxSets = Number(this.meta.config.maxSetsPerRun ?? 0) || Infinity; let count = 0; let langIdx = Number(ctx.options.cursor?.langIdx ?? 0); let setIdx = Number(ctx.options.cursor?.setIdx ?? 0); const phase = String(ctx.options.cursor?.phase ?? 'sets'); if (phase === 'sets') { for (; langIdx < langs.length; langIdx++, setIdx = 0) { const lang = langs[langIdx]!; let sets = await this.sets(ctx, lang); if (!sets) continue; if (ctx.options.seeds?.length) sets = sets.filter((s) => ctx.options.seeds!.includes(s.code) || ctx.options.seeds!.includes(`${lang}:${s.code}`)); sets = sets.slice(0, Number.isFinite(maxSets) ? maxSets : sets.length); for (; setIdx < sets.length; setIdx++) { if (ctx.signal?.aborted) return; if (this.reached(ctx, count)) { await ctx.setCursor({ phase: 'sets', langIdx, setIdx }); return; } const set = sets[setIdx]!; const url = lang === 'jp' ? `${SITE}/cards/jp/${set.code}?display=list` : `${SITE}/cards/${set.code}?display=list`; const res = await this.page(ctx, url); if (!res.success || !res.html) { ctx.anomaly('page_fetch_failed', `${url}: ${res.error ?? res.httpStatus}`); continue; } const { setName, cards } = parseSetList(res.html); if (!cards.length) { ctx.anomaly('parse_failure_page', url); continue; } count++; const payload: LimitlessPayload = { type: 'set', set: { ...set, name: setName ?? set.name }, cards }; yield { url, externalId: `set:${lang}:${set.code}`, kind: 'catalog_item', engine: 'api', httpStatus: res.httpStatus, payload, fetchedAt: res.fetchedAt }; await ctx.setCursor({ phase: 'sets', langIdx, setIdx: setIdx + 1 }); await ctx.progress({ page: setIdx + 1, totalPages: sets.length, itemsProcessed: count }); } } } if (backfill) { // Price history for English cards of the newest sets (the XHR the public card page performs). const maxCards = Number(this.meta.config.historyCardsPerRun ?? 60); const newest = Number(this.meta.config.historySetsNewestFirst ?? 6); const sets = (await this.sets(ctx, 'en'))?.slice(0, newest) ?? []; let hSet = Number(ctx.options.cursor?.hSet ?? 0); let hCard = Number(ctx.options.cursor?.hCard ?? 0); let done = 0; for (; hSet < sets.length && done < maxCards; hSet++, hCard = 0) { const set = sets[hSet]!; const listRes = await this.page(ctx, `${SITE}/cards/${set.code}?display=list`); if (!listRes.success || !listRes.html) continue; const { cards } = parseSetList(listRes.html); for (; hCard < cards.length && done < maxCards; hCard++) { if (ctx.signal?.aborted) return; if (this.reached(ctx, count)) { await ctx.setCursor({ phase: 'history', hSet, hCard }); return; } const card = cards[hCard]!; const rec = await this.history(ctx, set, card); done++; if (!rec) continue; count++; yield rec; await ctx.setCursor({ phase: 'history', hSet, hCard: hCard + 1 }); } } if (hSet >= sets.length) await ctx.setCursor({ phase: 'sets', langIdx: 0, setIdx: 0, done: true, completedAt: new Date().toISOString() }); else await ctx.setCursor({ phase: 'history', hSet, hCard }); return; } await ctx.setCursor({ phase: 'sets', langIdx: 0, setIdx: 0, completedAt: new Date().toISOString() }); } /** Card page → cardId → /api/cards//prices (dated history). */ private async history(ctx: CrawlContext, set: LimitlessSet, card: Pick & { usd?: CardRow['usd'] }): Promise { const pageRes = await this.page(ctx, card.url); if (!pageRes.success || !pageRes.html) { ctx.anomaly('page_fetch_failed', `${card.url}: ${pageRes.error ?? pageRes.httpStatus}`); return null; } const cp = parseCardPage(pageRes.html); if (!cp.cardId) { ctx.anomaly('selector_missing', `${card.url}: cardId not found`); return null; } const apiUrl = `${SITE}/api/cards/${cp.cardId}/prices`; await this.throttle(apiUrl); const res = await ctx.fetch(apiUrl, { engines: ['api'], headers: { ...JSON_HEADERS, referer: card.url }, minQuality: 0 }); const parsed = HISTORY_RESPONSE.safeParse(res.json); if (!res.success || !parsed.success) { ctx.anomaly('page_fetch_failed', `${apiUrl}: ${res.error ?? 'unexpected history shape'}`); return null; } const payload: LimitlessPayload = { type: 'history', set, card: { number: card.number, name: cp.name ?? card.name, url: card.url, cardId: cp.cardId, rarity: cp.rarity ?? card.rarity ?? null, image: cp.image ?? card.image ?? null, tcgplayerId: cp.tcgplayerId ?? card.usd?.tcgplayerId ?? null }, history: parsed.data, }; return { url: card.url, externalId: `history:${set.lang}:${set.code}/${card.number}`, kind: 'price_observation', engine: 'api', httpStatus: res.httpStatus, payload, fetchedAt: res.fetchedAt }; } async lookup(url: string, ctx: CrawlContext): Promise { const m = url.match(/limitlesstcg\.com\/cards\/(jp\/)?([A-Za-z0-9-]+)\/([A-Za-z0-9]+)/); if (!m) return []; const lang: Lang = m[1] ? 'jp' : 'en'; const pageRes = await this.page(ctx, url); if (!pageRes.success || !pageRes.html) return []; const cp = parseCardPage(pageRes.html); const set: LimitlessSet = { code: m[2]!, name: cp.setName ?? m[2]!, lang, releaseDate: null, cardCount: null }; const rec = await this.history(ctx, set, { number: cp.number ?? m[3]!, name: cp.name ?? m[3]!, url, rarity: cp.rarity, image: cp.image }); return rec ? [rec] : []; } private attrsFor(set: LimitlessSet, card: { number: string; name: string; rarity: string | null; type?: string | null; tcgplayerId: string | null }) { const ids: Record = { limitless_card: `${set.lang}/${set.code}/${card.number}` }; if (card.tcgplayerId) ids.tcgplayer_id = card.tcgplayerId; return attrs({ categorySlug: 'pokemon', franchise: 'Pokémon', brand: 'The Pokémon Company', set: set.name, setCode: set.code, name: card.name, number: card.number, year: set.releaseDate ? Number(set.releaseDate.slice(0, 4)) || null : null, language: set.lang === 'jp' ? 'Japanese' : 'English', rarity: card.rarity, identifiers: ids, metadata: { card_type: card.type ?? null, set_card_count: set.cardCount, release_date: set.releaseDate }, }); } async normalize(raw: RawRecordLike): Promise { const p = RawPayloadSchema.parse(raw.payload); const observedAt = raw.fetchedAt; const out: NormalizedRecord[] = []; const releaseDate = p.set.releaseDate ? new Date(p.set.releaseDate) : null; if (p.type === 'set') { for (const c of p.cards) { const a = this.attrsFor(p.set, { ...c, tcgplayerId: c.usd?.tcgplayerId ?? null }); const rawTitle = makeTitle({ name: c.name, set: p.set.name, number: c.number, total: p.set.cardCount, year: a.year }); const images = c.image ? [c.image] : []; out.push(catalogItem({ kind: 'catalog_item', connectorId: this.meta.id, sourceId: this.meta.sourceId, sourceUrl: c.url, externalId: `${p.set.lang}:${p.set.code}/${c.number}`, rawTitle, imageUrls: images, attributes: a, observedAt, confidence: 0.85, parserVersion: PARSER_VERSION, releaseDate })); for (const [provider, price] of [['tcgplayer', c.usd], ['cardmarket', c.eur]] as const) { if (!price) continue; out.push( priceObservation({ kind: 'price_observation', connectorId: this.meta.id, sourceId: this.meta.sourceId, sourceUrl: c.url, externalId: `${p.set.lang}:${p.set.code}/${c.number}:${provider}`, rawTitle, imageUrls: images, attributes: { ...a, metadata: { ...a.metadata, provider, relayed: true, vendor_url: price.url } }, observedAt, confidence: 0.6, parserVersion: PARSER_VERSION, priceKind: 'market', price: price.amount, currency: price.currency, observationDate: dayOf(observedAt), sampleSize: null, }), ); } } return out; } const a = this.attrsFor(p.set, { number: p.card.number, name: p.card.name, rarity: p.card.rarity, tcgplayerId: p.card.tcgplayerId }); const rawTitle = makeTitle({ name: p.card.name, set: p.set.name, number: p.card.number, year: a.year }); const images = p.card.image ? [p.card.image] : []; for (const [provider, series, currency] of [['tcgplayer', p.history.tcgplayer, 'USD'], ['cardmarket', p.history.cardmarket, 'EUR']] as const) { const seen = new Set(); for (const [ms, cents] of series) { if (!(cents > 0)) continue; const day = dayOf(new Date(ms)); const key = day.toISOString().slice(0, 10); if (seen.has(key) || day.getTime() > Date.now()) continue; seen.add(key); out.push( priceObservation({ kind: 'price_observation', connectorId: this.meta.id, sourceId: this.meta.sourceId, sourceUrl: p.card.url, externalId: `${p.set.lang}:${p.set.code}/${p.card.number}:${provider}:${key}`, rawTitle, imageUrls: images, attributes: { ...a, metadata: { ...a.metadata, provider, relayed: true, limitless_card_id: p.card.cardId } }, observedAt, confidence: 0.6, parserVersion: PARSER_VERSION, priceKind: 'market', price: cents / 100, currency, observationDate: day, sampleSize: null, }), ); } } return out; } } export default (meta: ConnectorMeta) => new LimitlessTcgConnector(meta);