import { z } from 'zod'; import { BaseConnector, html as H, type ConnectorMeta, type CrawlContext, type RawRecordInput, type RawRecordLike } from '@rareindex/connectors'; import { AssetAttributesSchema, NormalizedCatalogItemSchema, NormalizedPriceObservationSchema, parsePrice, parseSourceDate, type NormalizedRecord } from '@rareindex/shared'; /** * Brickset — LEGO set facts (pieces, minifigs, RRP, launch/exit, barcodes) + Brickset's * "Current value" New/Used estimates. Raw payload = the parsed definition-list of a set page. */ const BASE = 'https://brickset.com'; const PARSER_VERSION = '1.0.0'; export const SetPayloadSchema = z.object({ kind: z.literal('set_page'), url: z.string(), setNumber: z.string(), title: z.string(), image: z.string().nullable(), fields: z.record(z.string(), z.string()), }); export type SetPayload = z.infer; /** Parse the
facts of a set page into a flat label → text map (links flattened). */ export function parseSetPage(htmlText: string, url: string): SetPayload | null { const $ = H.load(htmlText); const fields: Record = {}; $('dl').each((_, dl) => { const dts = $(dl).find('dt'); dts.each((__, dt) => { const label = H.text($(dt)); const dd = $(dt).next('dd'); if (!label || !dd.length) return; const clone = dd.clone(); clone.find('br').replaceWith(' | '); const value = H.text(clone); if (value && !(label in fields)) fields[label] = value; }); }); const setNumber = fields['Set number'] ?? url.match(/\/sets\/(\d+-\d+)/)?.[1] ?? null; const title = ($('title').text().split('|')[0] ?? '').trim(); if (!setNumber || !title) return null; const image = $('meta[property="og:image"]').attr('content') ?? $('img[src*="/sets/images/"]').attr('src') ?? null; return { kind: 'set_page', url, setNumber, title, image, fields }; } export function setLinks(htmlText: string): string[] { const out = new Set(); for (const m of htmlText.matchAll(/href="(\/sets\/\d+-\d+\/[^"#?]*)"/g)) out.add(BASE + m[1]!); return [...out]; } /** "£342.49, $499.99" → { gbp: 342.49, usd: 499.99 } */ export function parseRrp(s: string | undefined): { gbp: number | null; usd: number | null; eur: number | null } { const grab = (re: RegExp) => { const m = s?.match(re); return m ? Number(m[1]!.replace(/,/g, '')) : null; }; return { gbp: grab(/£\s?([\d,]+(?:\.\d+)?)/), usd: grab(/\$\s?([\d,]+(?:\.\d+)?)/), eur: grab(/€\s?([\d,]+(?:\.\d+)?)/) }; } /** "New: ~$2858 | Used: ~$1172" → { newUsd, usedUsd } */ export function parseCurrentValue(s: string | undefined): { newUsd: number | null; usedUsd: number | null } { const n = s?.match(/New:\s*~?\$([\d,]+(?:\.\d+)?)/); const u = s?.match(/Used:\s*~?\$([\d,]+(?:\.\d+)?)/); return { newUsd: n ? Number(n[1]!.replace(/,/g, '')) : null, usedUsd: u ? Number(u[1]!.replace(/,/g, '')) : null }; } /** "01 Oct 07 - 31 Dec 09" → { launch: '2007-10-01', exit: '2009-12-31' } */ export function parseLaunchExit(s: string | undefined): { launch: Date | null; exit: Date | null } { if (!s) return { launch: null, exit: null }; const [a, b] = s.split(/\s+-\s+/); const fix = (x?: string) => { if (!x) return null; const m = x.trim().match(/^(\d{1,2}) ([A-Za-z]{3}) (\d{2})$/); if (!m) return parseSourceDate(x); const yy = Number(m[3]); return parseSourceDate(`${m[1]} ${m[2]} ${yy >= 50 ? 1900 + yy : 2000 + yy}`); }; return { launch: fix(a), exit: fix(b) }; } export class BricksetConnector extends BaseConnector { readonly version = '1.0.0'; readonly parserVersion = PARSER_VERSION; protected override minIntervalMs = 1500; override readonly urlPatterns = [/^https?:\/\/(www\.)?brickset\.com\/sets\/(\d+-\d+)/i]; async *crawl(ctx: CrawlContext): AsyncIterable { const seeds = (ctx.options.seeds?.length ? ctx.options.seeds : (this.meta.config.seeds as string[] | undefined)) ?? []; const pages = Number(this.meta.config.pagesPerSeed ?? 2); const cap = ctx.options.limit ?? Number(this.meta.config.setsPerRun ?? 120); const startSeed = ctx.options.mode === 'incremental' ? Number(ctx.options.cursor?.seedIndex ?? 0) : 0; let count = 0; for (let i = startSeed; i < seeds.length && count < cap; i++) { const seed = seeds[i]!; for (let page = 1; page <= pages && count < cap; page++) { if (ctx.signal?.aborted) return; const listUrl = `${BASE}/sets/${seed.replace(/^\//, '')}${page > 1 ? `/page-${page}` : ''}`; await this.throttle(); const lres = await ctx.fetch(listUrl, { engines: ['api', 'firecrawl'], responseType: 'text', minQuality: 0.2 }); const links = lres.success && lres.html ? setLinks(lres.html) : []; if (!links.length) { ctx.anomaly('discover_failed', `${listUrl}: ${lres.error ?? lres.httpStatus ?? 'no set links'}`); break; } for (const url of links) { if (ctx.signal?.aborted || count >= cap) break; if (!(await ctx.shouldFetch(url))) continue; const rec = await this.fetchSet(url, ctx); if (rec) { count++; yield rec; } } } await ctx.setCursor({ seedIndex: i + 1 >= seeds.length ? 0 : i + 1, updatedAt: new Date().toISOString() }); } } private async fetchSet(url: string, ctx: CrawlContext): Promise { await this.throttle(); const res = await ctx.fetch(url, { engines: ['api', 'firecrawl'], responseType: 'text', expect: ['title', 'identifiers', 'price'], parse: (r) => { const p = r.html ? parseSetPage(r.html, url) : null; return p ? { title: p.title, identifiers: { set: p.setNumber }, price: p.fields['RRP'] ?? p.fields['Current value'] ?? null } : null; }, }); const payload = res.success && res.html ? parseSetPage(res.html, url) : null; if (!payload) { ctx.anomaly('set_parse_failed', `${url}: ${res.error ?? res.httpStatus}`); return null; } return { url, externalId: payload.setNumber, kind: 'catalog_item', engine: res.engine, httpStatus: res.httpStatus, payload, fetchedAt: res.fetchedAt }; } async lookup(url: string, ctx: CrawlContext): Promise { const m = url.match(this.urlPatterns[0]!); if (!m) return []; const rec = await this.fetchSet(`${BASE}/sets/${m[2]}`, ctx); return rec ? [rec] : []; } async normalize(raw: RawRecordLike): Promise { const p = SetPayloadSchema.parse(raw.payload); const f = p.fields; const number = p.setNumber.replace(/-\d+$/, ''); const name = (f['Name'] ?? p.title.replace(/^LEGO\s+\d+(?:-\d+)?\s+/i, '')).trim(); const year = f['Year released'] ? Number(f['Year released'].match(/\d{4}/)?.[0]) || null : null; const rrp = parseRrp(f['RRP']); const value = parseCurrentValue(f['Current value']); const { launch, exit } = parseLaunchExit(f['Launch/exit']); const pieces = f['Pieces'] ? Number(f['Pieces'].replace(/,/g, '')) || null : null; const minifigs = f['Minifigs'] ? Number(f['Minifigs'].match(/\d+/)?.[0]) || null : null; const upc = f['Barcodes']?.match(/UPC:\s*(\d+)/)?.[1] ?? null; const ean = f['Barcodes']?.match(/EAN:\s*(\d+)/)?.[1] ?? null; const theme = f['Theme'] ?? null; const subtheme = f['Subtheme'] ?? null; const rawTitle = `LEGO ${number} ${name}${theme ? ` · ${theme}` : ''}${year ? ` (${year})` : ''}`; const attributes = AssetAttributesSchema.parse({ categorySlug: 'lego_sets', brand: 'LEGO', franchise: theme, series: subtheme, set: theme, name, number, year, productionQuantity: null, originalMsrp: rrp.usd ?? rrp.gbp ?? rrp.eur, originalMsrpCurrency: rrp.usd ? 'USD' : rrp.gbp ? 'GBP' : rrp.eur ? 'EUR' : null, identifiers: { lego_set_number: number, brickset_set: p.setNumber, ...(upc ? { upc } : {}), ...(ean ? { ean } : {}) }, metadata: { pieces, minifigs, rrp_gbp: rrp.gbp, rrp_usd: rrp.usd, availability: f['Availability'] ?? null, packaging: f['Packaging'] ?? null, launch_date: launch ? launch.toISOString().slice(0, 10) : null, exit_date: exit ? exit.toISOString().slice(0, 10) : null, retired: exit ? exit.getTime() < Date.now() : null, age_range: f['Age range'] ?? null, rating: f['Rating'] ? Number(f['Rating'].match(/(\d\.\d)/)?.[1]) || null : null, notes: f['Notes'] ?? null, }, }); const base = { connectorId: this.meta.id, sourceId: this.meta.sourceId, sourceUrl: p.url, rawTitle, imageUrls: p.image ? [p.image] : [], attributes, observedAt: raw.fetchedAt, parserVersion: PARSER_VERSION }; const out: NormalizedRecord[] = [NormalizedCatalogItemSchema.parse({ kind: 'catalog_item', ...base, externalId: p.setNumber, confidence: 0.95, releaseDate: launch })]; const obsDate = raw.fetchedAt; if (value.newUsd) { out.push(NormalizedPriceObservationSchema.parse({ kind: 'price_observation', ...base, externalId: `${p.setNumber}:new`, confidence: 0.65, condition: { condition: 'sealed', conditionRaw: 'New (Brickset current value, BrickLink-derived)', completeness: 'sealed' }, priceKind: 'guide_value', price: value.newUsd, currency: 'USD', observationDate: obsDate, sampleSize: null })); } if (value.usedUsd) { out.push(NormalizedPriceObservationSchema.parse({ kind: 'price_observation', ...base, externalId: `${p.setNumber}:used`, confidence: 0.65, condition: { condition: 'used_complete', conditionRaw: 'Used (Brickset current value, BrickLink-derived)', completeness: 'used_complete' }, priceKind: 'guide_value', price: value.usedUsd, currency: 'USD', observationDate: obsDate, sampleSize: null })); } return out; } } export default function createConnector(meta: ConnectorMeta) { return new BricksetConnector(meta); } // keep parsePrice referenced for future currency-specific fields void parsePrice;