import { z } from 'zod'; import { BaseConnector, html as H, type ConnectorMeta, type CrawlContext, type RawRecordInput, type RawRecordLike } from '@rareindex/connectors'; import { NormalizedListingSchema, extractYear, type NormalizedRecord } from '@rareindex/shared'; import { parseGradeFromTitle } from '@rareindex/taxonomy'; import { attrs } from '../_lib/shared.js'; import { BOT_HEADERS, cardCategorySlug, decodeXml, parseSitemap } from '../_lib/wave4.js'; /** MySlabs — graded slab marketplace; public slab pages expose a Product JSON-LD. */ const PARSER_VERSION = '1.0.0'; const SITE = 'https://myslabs.com'; const RawPayloadSchema = z.object({ id: z.string(), name: z.string(), description: z.string().nullable(), images: z.array(z.string()), price: z.number().nullable(), currency: z.string().nullable(), availability: z.string().nullable(), category: z.string().nullable(), seller: z.string().nullable(), }); export type MySlabsPayload = z.infer; 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; const SPORT_WORDS: Array<[RegExp, string]> = [ [/\b(basketball|nba|wnba|hoops|lebron|jordan|curry|wembanyama|kobe|giannis|luka|jokic)\b/i, 'basketball_cards'], [/\b(baseball|mlb|mantle|ohtani|trout|jeter|bowman|goudey|play ball)\b/i, 'baseball_cards'], [/\b(football|nfl|mahomes|brady|rookie qb|panini contenders|donruss optic football)\b/i, 'football_cards'], [/\b(hockey|nhl|gretzky|mcdavid|crosby|o-pee-chee|young guns|bedard)\b/i, 'hockey_cards'], [/\b(soccer|fifa|messi|ronaldo|mbappe|mbappé|haaland|yamal|premier league|champions league)\b/i, 'soccer_cards'], [/\b(f1|formula 1|formula one|verstappen|hamilton|leclerc|norris)\b/i, 'f1_cards'], [/\b(ufc|wwe|wrestling|boxing|golf|tennis|nascar|olympic|mma)\b/i, 'other_sports_cards'], ]; /** Title → taxonomy slug for MySlabs inventory (cards/comics); null when unsure. */ export function slabCategory(title: string, categoryHint: string | null): string | null { const hinted = cardCategorySlug(categoryHint); if (hinted && hinted !== 'trading_cards') return hinted; const tcg = cardCategorySlug(title); 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; 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'; for (const [re, slug] of SPORT_WORDS) if (re.test(title)) return slug; if (SPORTS_BRAND.test(title)) return 'sports_cards'; if (hinted) return hinted; return null; } function extractPayload(id: string, htmlText: string): MySlabsPayload | null { const products = H.jsonLd(htmlText, 'Product'); const p = products[0]; if (!p) return null; const offers = (Array.isArray(p.offers) ? p.offers[0] : p.offers) as Record | undefined; const images = (Array.isArray(p.image) ? p.image : p.image ? [p.image] : []).map((u) => decodeXml(String(u))); const priceRaw = offers?.price; const price = priceRaw === undefined || priceRaw === null || priceRaw === '' ? null : Number(priceRaw); const availability = typeof offers?.availability === 'string' ? offers.availability.replace(/^https?:\/\/schema\.org\//, '') : null; const seller = (offers?.seller as { name?: string } | undefined)?.name ?? null; const $ = H.load(htmlText); const crumbs = $('nav[aria-label="breadcrumb"] a, .breadcrumb a, ol.breadcrumb li') .map((_, el) => $(el).text().trim()) .get() .filter(Boolean); const category = crumbs.find((c) => /card|comic|pok|magic|yu-gi|sport/i.test(c)) ?? null; 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 }; } export class MySlabsConnector extends BaseConnector { readonly version = '1.0.0'; readonly parserVersion = PARSER_VERSION; override readonly urlPatterns = [/^https?:\/\/(www\.)?myslabs\.com\/slab\/view\/\d+/i]; protected override minIntervalMs = 1500; private async fetchSlab(ctx: CrawlContext, url: string): Promise { await this.throttle(); const res = await ctx.fetch(url, { engines: ['api'], headers: { ...BOT_HEADERS, accept: 'text/html' }, responseType: 'text' }); if (!res.success || !res.html) { ctx.anomaly('page_fetch_failed', `${url}: ${res.error ?? res.httpStatus}`); return null; } const id = url.match(/slab\/view\/(\d+)/)?.[1] ?? url; const payload = extractPayload(id, res.html); if (!payload || !payload.name) { ctx.anomaly('parse_failure_slab', url); return null; } return { url, externalId: id, kind: 'listing', engine: 'api', httpStatus: res.httpStatus, payload, fetchedAt: res.fetchedAt }; } async *crawl(ctx: CrawlContext): AsyncIterable { const max = ctx.options.mode === 'backfill' ? Number(this.meta.config.backfillSlabs ?? 2000) : Number(this.meta.config.maxSlabsPerRun ?? 250); const files = ctx.options.mode === 'backfill' ? ['sitemap-slabs-1.xml', 'sitemap-slabs-2.xml'] : ['sitemap-slabs-1.xml']; const urls: Array<{ id: number; loc: string }> = []; for (const f of files) { const res = await ctx.fetch(`${SITE}/${f}`, { engines: ['api'], headers: { ...BOT_HEADERS, accept: 'application/xml,text/xml' }, responseType: 'text', timeoutMs: 120_000 }); if (!res.success || !res.html) { ctx.anomaly('page_fetch_failed', `${f}: ${res.error ?? res.httpStatus}`); continue; } for (const e of parseSitemap(res.html)) { const id = Number(e.loc.match(/slab\/view\/(\d+)/)?.[1]); if (Number.isFinite(id)) urls.push({ id, loc: e.loc }); } } 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}/` }))); urls.sort((a, b) => b.id - a.id); let count = 0; for (const u of urls.slice(0, max)) { if (ctx.signal?.aborted) return; if (this.reached(ctx, count)) return; if (!(await ctx.shouldFetch(u.loc))) continue; const rec = await this.fetchSlab(ctx, u.loc); if (!rec) continue; count++; yield rec; if (count % 25 === 0) await ctx.setCursor({ lastId: u.id, at: new Date().toISOString() }); } await ctx.setCursor({ completedAt: new Date().toISOString(), newestId: urls[0]?.id ?? null }); } async lookup(url: string, ctx: CrawlContext): Promise { const rec = await this.fetchSlab(ctx, url); return rec ? [rec] : []; } async normalize(raw: RawRecordLike): Promise { const p = RawPayloadSchema.parse(raw.payload); const desc = (p.description ?? '').replace(/<[^>]+>/g, ' ').replace(/&[a-z]+;/g, ' ').slice(0, 400); const categorySlug = slabCategory(`${p.name} ${desc}`, p.category); if (!categorySlug) return []; // Slab pages carry the grade only when the seller typed it (title or description); never guessed. const g = parseGradeFromTitle(p.name).grader ? parseGradeFromTitle(p.name) : parseGradeFromTitle(desc); const cert = p.description?.match(/\b(?:cert|certification|serial)\s*(?:#|no\.?|number)?\s*[:\-]?\s*(\d{6,12})\b/i)?.[1] ?? null; const year = extractYear(p.name); const number = p.name.match(/#\s?([A-Za-z0-9-]+)/)?.[1] ?? null; 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 } }); const availability = p.availability === 'OutOfStock' || p.availability === 'SoldOut' ? 'sold' : p.availability === 'InStock' || p.availability === 'PreOrder' ? 'available' : 'unknown'; const currency = (p.currency ?? 'USD').toUpperCase(); const listing = NormalizedListingSchema.parse({ kind: 'listing', connectorId: this.meta.id, sourceId: this.meta.sourceId, sourceUrl: raw.url, externalId: p.id, rawTitle: p.name, description: p.description, imageUrls: p.images, attributes: a, grade: { grader: g.grader, grade: g.grade, qualifier: g.qualifier, certificationNumber: cert }, condition: {}, observedAt: raw.fetchedAt, confidence: 0.75, parserVersion: PARSER_VERSION, listingType: 'fixed_price', price: p.price, currency: p.price === null ? null : currency, seller: p.seller, availability, }); return [listing]; } } export default (meta: ConnectorMeta) => new MySlabsConnector(meta);