import { z } from 'zod'; import { BaseConnector, type ConnectorMeta, type CrawlContext, type RawRecordInput, type RawRecordLike } from '@rareindex/connectors'; import { NormalizedListingSchema, NormalizedSaleSchema, extractYear, type NormalizedRecord } from '@rareindex/shared'; import { attrs, priceObservation } from '../../api/_lib/shared.js'; import { BOT_HEADERS, cardCategorySlug, dayOf, fetchMaybeGzip, money, parseMonthDay, parseSitemap } from '../../api/_lib/wave4.js'; /** Alt item pages → Alt Value observations, recent transactions (sales) and Alt list-price listings. */ const PARSER_VERSION = '1.0.0'; const SITE = 'https://alt.xyz'; const TxSchema = z.object({ type: z.string(), date: z.string(), price: z.number(), url: z.string().nullable() }); const RawPayloadSchema = z.object({ id: z.string(), title: z.string(), category: z.string().nullable(), serial: z.string().nullable(), grader: z.string().nullable(), grade: z.string().nullable(), pop: z.number().nullable(), listPrice: z.number().nullable(), listingKind: z.enum(['fixed_price', 'auction']).nullable().default(null), altValue: z.number().nullable(), altLow: z.number().nullable(), altHigh: z.number().nullable(), transactions: z.array(TxSchema), images: z.array(z.string()), }); export type AltPayload = z.infer; /** Parse the Firecrawl markdown of an item page. Exported for tests. */ export function parseItemMarkdown(md: string, id: string): AltPayload | null { const text = md.replace(/\\\n/g, '\n').replace(/\\([#$])/g, '$1'); const title = text.match(/^#\s+(.+)$/m)?.[1]?.trim(); if (!title) return null; const afterTitle = text.slice(text.indexOf(title) + title.length); 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; const serial = afterTitle.match(/Serial\s*(\d+\s*\/\s*\d+)/)?.[1]?.replace(/\s/g, '') ?? null; const gm = afterTitle.match(/^(PSA|BGS|SGC|CGC|CSG|TAG|HGA)\s?(\d{1,2}(?:\.\d)?)\s*$/m); const pop = afterTitle.match(/^Pop\s*([\d,]+)\s*$/m)?.[1]; const fixed = afterTitle.match(/##\s+List price\s*\n+\s*###\s+\$([\d,]+(?:\.\d+)?)/)?.[1] ?? null; const bid = afterTitle.match(/(?:Starting bid|Current bid|High bid)\s*\$([\d,]+(?:\.\d+)?)/)?.[1] ?? null; const listPrice = fixed ?? bid; const listingKind: 'fixed_price' | 'auction' | null = fixed ? 'fixed_price' : bid ? 'auction' : null; const av = afterTitle.match(/##\s+(?:LT|Alt) Value\s*\n+\s*\$([\d,]+(?:\.\d+)?)\s*\n+\s*###\s+\$([\d,]+(?:\.\d+)?)\s*-\s*\$([\d,]+(?:\.\d+)?)/); const txStart = afterTitle.search(/Recent transactions/i); const txEnd = afterTitle.search(/\n##?\s+(Listings|Similar listings)|\nListings\s*\n/); const txBlock = txStart >= 0 ? afterTitle.slice(txStart, txEnd > txStart ? txEnd : undefined) : ''; const transactions: z.infer[] = []; 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; let m: RegExpExecArray | null; while ((m = re.exec(txBlock))) { const price = money(m[3]); if (!price) continue; transactions.push({ type: m[1]!, date: m[2]!, price, url: m[4] ?? null }); } const images = [...text.matchAll(/!\[[^\]]*\]\((https:\/\/alt-images\.b-cdn\.net\/public\/[^)\s]+width=324[^)\s]*)\)/g)].map((x) => x[1]!); return { id, title, category, serial, grader: gm ? gm[1]!.toLowerCase() : null, grade: gm ? gm[2]! : null, pop: pop ? Number(pop.replace(/,/g, '')) : null, listPrice: money(listPrice), listingKind, altValue: av ? money(av[1]) : null, altLow: av ? money(av[2]) : null, altHigh: av ? money(av[3]) : null, transactions, images: [...new Set(images)], }; } async function fetchGzText(url: string, signal?: AbortSignal): Promise { return (await fetchMaybeGzip(url, signal)).toString('utf8'); } export class AltConnector extends BaseConnector { readonly version = '1.0.0'; readonly parserVersion = PARSER_VERSION; override readonly urlPatterns = [/^https?:\/\/(www\.)?alt\.xyz\/itm\/[0-9a-f-]{36}/i]; protected override minIntervalMs = 1500; private async fetchItem(ctx: CrawlContext, url: string): Promise { await this.throttle(); 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 }) }); if (!res.success || !res.markdown) { ctx.anomaly('page_fetch_failed', `${url}: ${res.error ?? res.httpStatus}`); return null; } const id = url.match(/itm\/([0-9a-f-]{36})/i)?.[1] ?? url; const payload = parseItemMarkdown(res.markdown, id); if (!payload) { ctx.anomaly('parse_failure_item', url); return null; } return { url: `${SITE}/itm/${id}`, externalId: id, kind: 'sale', engine: 'firecrawl', httpStatus: res.httpStatus, payload, fetchedAt: res.fetchedAt }; } async *crawl(ctx: CrawlContext): AsyncIterable { const max = ctx.options.mode === 'backfill' ? Number(this.meta.config.maxItemsPerRun ?? 100) * 5 : Number(this.meta.config.maxItemsPerRun ?? 100); let urls: Array<{ loc: string; lastmod: string }> = []; if (ctx.options.seeds?.length) urls = ctx.options.seeds.map((s) => ({ loc: s.startsWith('http') ? s : `${SITE}/itm/${s}`, lastmod: '' })); else { const idx = await ctx.fetch(`${SITE}/sitemap.xml`, { engines: ['api'], headers: { ...BOT_HEADERS, accept: 'application/xml' }, responseType: 'text' }); if (!idx.success || !idx.html) throw new Error(`alt sitemap index failed: ${idx.error ?? idx.httpStatus}`); const wanted = (this.meta.config.sitemaps as string[] | undefined) ?? ['fixed-price', 'auctions']; const children = [...idx.html.matchAll(/\s*([^<\s]+)\s*<\/loc>/g)].map((x) => x[1]!).filter((u) => wanted.some((w) => u.includes(`/${w}-`))); for (const child of children.slice(0, 6)) { try { const xml = await fetchGzText(child, ctx.signal); for (const e of parseSitemap(xml)) urls.push({ loc: e.loc, lastmod: e.lastmod ?? '' }); } catch (err) { ctx.anomaly('page_fetch_failed', `${child}: ${err instanceof Error ? err.message : String(err)}`); } } const since = String(ctx.options.cursor?.since ?? ''); urls = urls.filter((u) => !since || u.lastmod > since).sort((a, b) => b.lastmod.localeCompare(a.lastmod)); } let count = 0; let newest = String(ctx.options.cursor?.since ?? ''); for (const u of urls.slice(0, max)) { if (ctx.signal?.aborted) return; if (this.reached(ctx, count)) break; const rec = await this.fetchItem(ctx, u.loc); if (!rec) continue; count++; if (u.lastmod > newest) newest = u.lastmod; yield rec; if (count % 20 === 0) await ctx.setCursor({ since: newest, at: new Date().toISOString() }); } await ctx.setCursor({ since: newest, completedAt: new Date().toISOString() }); } async lookup(url: string, ctx: CrawlContext): Promise { const rec = await this.fetchItem(ctx, url); return rec ? [rec] : []; } async normalize(raw: RawRecordLike): Promise { const p = RawPayloadSchema.parse(raw.payload); const categorySlug = cardCategorySlug(p.category) ?? cardCategorySlug(p.title); if (!categorySlug) return []; const year = extractYear(p.title); const number = p.title.match(/#\s?([A-Za-z0-9-]+)/)?.[1] ?? null; const name = p.title.replace(/^\d{4}(?:-\d{2})?\s+/, '').replace(/\s*#\S+\s*$/, '').trim() || p.title; const a = attrs({ categorySlug, name, year, number, identifiers: { alt_item_id: p.id }, metadata: { serial: p.serial, pop: p.pop } }); const grade = { grader: p.grader, grade: p.grade, qualifier: null, certificationNumber: null }; 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 }; const out: NormalizedRecord[] = []; if (p.altValue) { 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' } } })); } for (const t of p.transactions) { const saleDate = parseMonthDay(t.date); if (!saleDate) continue; const saleType = /auction/i.test(t.type) ? 'auction' : /best offer/i.test(t.type) ? 'best_offer' : 'fixed_price'; 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' } } })); } if (p.listPrice) { 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' })); } return out; } } export default (meta: ConnectorMeta) => new AltConnector(meta);