import { z } from 'zod'; import { BaseConnector, type ConnectorMeta, type CrawlContext, type RawRecordInput, type RawRecordLike } from '@rareindex/connectors'; import { parsePrice, parseSourceDate, type AssetAttributes, type NormalizedCatalogItem, type NormalizedPriceObservation, type NormalizedRecord } from '@rareindex/shared'; /** * BrickEconomy connector — LEGO set catalog + estimated values (New/Sealed, Used) + retail price. * Raw payload = the markdown sections of the set page (Set Details, Set Pricing, Set Predictions); * normalisation re-parses that markdown so parser fixes can be replayed on stored raw records. */ const BASE = 'https://www.brickeconomy.com'; const PARSER_VERSION = '1.0.0'; export const SetPayloadSchema = z.object({ kind: z.literal('set_page'), url: z.string(), title: z.string(), markdown: z.string(), images: z.array(z.string()), }); export type SetPayload = z.infer; /** Extract "#### Section" blocks and return a compact markdown excerpt. */ export function excerptSections(md: string): string { const wanted = ['Set Details', 'Set Pricing', 'Set Predictions', 'Minifig Details', 'Minifig Pricing']; const parts: string[] = []; const re = /^####\s+(.+)$/gm; const heads: Array<{ name: string; start: number; end: number }> = []; let m: RegExpExecArray | null; while ((m = re.exec(md))) heads.push({ name: m[1]!.trim(), start: m.index, end: md.length }); for (let i = 0; i < heads.length; i++) heads[i]!.end = heads[i + 1]?.start ?? md.length; for (const h of heads) if (wanted.includes(h.name)) parts.push(md.slice(h.start, h.end).trim()); return parts.join('\n\n'); } /** Read the value that follows a label line in the markdown ("Label\n\nValue"). */ function field(md: string, label: string): string | null { const re = new RegExp(`(?:^|\\n)${label.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\s*\\n+([^\\n]+)`, 'i'); const m = md.match(re); if (!m) return null; const v = m[1]!.replace(/\[([^\]]+)\]\([^)]*\)/g, '$1').replace(/\*\*/g, '').trim(); return v || null; } export interface ParsedSet { setNumber: string | null; name: string | null; theme: string | null; subtheme: string | null; year: number | null; released: string | null; retired: string | null; availability: string | null; pieces: number | null; minifigs: number | null; retailPrice: number | null; newValue: number | null; usedValue: number | null; usedRangeLow: number | null; usedRangeHigh: number | null; growthPct: number | null; } export function parseSetMarkdown(md: string): ParsedSet { const details = md.slice(md.indexOf('#### Set Details'), md.indexOf('#### Set Pricing') > 0 ? md.indexOf('#### Set Pricing') : undefined); const pricing = md.slice(md.indexOf('#### Set Pricing') >= 0 ? md.indexOf('#### Set Pricing') : md.length); const num = (s: string | null) => { if (!s) return null; const p = parsePrice(s, 'USD'); return p && p.amount > 0 ? p.amount : null; }; const int = (s: string | null) => { const m = s?.replace(/,/g, '').match(/\d+/); return m ? Number(m[0]) : null; }; const yearTxt = field(details, 'Year'); // New/Sealed value: "New/Sealed\n\nValue\n\n**$2,946.69**" ; still-available sets: "Market price\n\n$764.15-10.1%" const newBlock = pricing.match(/New\/Sealed\s*\n+Value\s*\n+([^\n]+)/i)?.[1] ?? null; const marketPrice = pricing.match(/Market price\s*\n+\$?([\d,]+\.?\d*)/i)?.[1] ?? null; const usedBlock = pricing.match(/Used\s*\n+Value\s*\n+([^\n]+)/i)?.[1] ?? null; const usedRange = pricing.match(/Used[\s\S]{0,200}?Range\s*\n+\$?([\d,]+\.?\d*)\s*-\s*\$?([\d,]+\.?\d*)/i); const growth = pricing.match(/(?:^|\n)Growth\s*\n+([+-]?[\d.]+)%/i)?.[1] ?? null; return { setNumber: field(details, 'Set number'), name: field(details, 'Name'), theme: field(details, 'Theme'), subtheme: field(details, 'Subtheme'), year: yearTxt ? int(yearTxt) : null, released: field(details, 'Released'), retired: field(details, 'Retired'), availability: field(details, 'Availability'), pieces: int(field(details, 'Pieces')), minifigs: int(field(details, 'Minifigs')), retailPrice: num(field(pricing, 'Retail price')), newValue: num(newBlock) ?? (marketPrice ? Number(marketPrice.replace(/,/g, '')) : null), usedValue: num(usedBlock), usedRangeLow: usedRange ? Number(usedRange[1]!.replace(/,/g, '')) : null, usedRangeHigh: usedRange ? Number(usedRange[2]!.replace(/,/g, '')) : null, growthPct: growth ? Number(growth) : null, }; } export class BrickEconomyConnector extends BaseConnector { readonly version = '1.0.0'; readonly parserVersion = PARSER_VERSION; override readonly urlPatterns = [/^https?:\/\/(www\.)?brickeconomy\.com\/set\/[^/]+\/[^/?#]+/i]; protected override minIntervalMs = 1500; async *crawl(ctx: CrawlContext): AsyncIterable { const seeds = ctx.options.seeds?.length ? ctx.options.seeds : ((this.meta.config.seeds as string[] | undefined) ?? []); const perSeed = Number(this.meta.config.setsPerSeed ?? 60); const cursor = ctx.options.cursor ?? {}; const done = new Set((cursor.doneSeeds as string[] | undefined) ?? []); let count = 0; const seenSets = new Set(); for (const seed of seeds) { if (done.has(seed) && ctx.options.mode !== 'backfill') continue; if (ctx.signal?.aborted) return; const setUrls = seed.includes('/set/') ? [seed] : await this.discover(ctx, seed, perSeed); if (setUrls.length === 0) ctx.anomaly('empty_seed', seed); for (const url of setUrls) { if (this.reached(ctx, count)) return; if (seenSets.has(url)) continue; seenSets.add(url); if (!(await ctx.shouldFetch(url))) continue; await this.throttle(); const rec = await this.fetchSet(ctx, url); if (rec) { count++; yield rec; } } done.add(seed); await ctx.setCursor({ doneSeeds: [...done], updatedAt: new Date().toISOString() }); } await ctx.setCursor({ doneSeeds: [], updatedAt: new Date().toISOString() }); } private async discover(ctx: CrawlContext, listUrl: string, max: number): Promise { await this.throttle(); const res = await ctx.fetch(listUrl, { minQuality: 0.2 }); const text = `${res.markdown ?? ''}\n${res.html ?? ''}`; if (!res.success || !text.trim()) { ctx.anomaly('discover_failed', `${listUrl}: ${res.error ?? res.httpStatus}`); return []; } const urls = new Set(); for (const m of text.matchAll(/https?:\/\/www\.brickeconomy\.com\/set\/([0-9]+-[0-9]+)\/([a-z0-9-]+)/g)) urls.add(`${BASE}/set/${m[1]}/${m[2]}`); for (const m of text.matchAll(/href="\/set\/([0-9]+-[0-9]+)\/([a-z0-9-]+)"/g)) urls.add(`${BASE}/set/${m[1]}/${m[2]}`); return [...urls].slice(0, max); } private async fetchSet(ctx: CrawlContext, url: string): Promise { const res = await ctx.fetch(url, { expect: ['title', 'price', 'identifiers'], parse: (r) => { const md = r.markdown ?? ''; const p = parseSetMarkdown(md); return { title: p.name, price: p.newValue ?? p.usedValue ?? p.retailPrice, identifiers: p.setNumber ? { set: p.setNumber } : null }; }, }); if (!res.success || !res.markdown) { ctx.anomaly('set_fetch_failed', `${url}: ${res.error ?? res.httpStatus}`); return null; } const md = res.markdown; const title = md.match(/^#\s+(.+)$/m)?.[1]?.trim() ?? ''; const images = [...new Set([...md.matchAll(/https:\/\/www\.brickeconomy\.com\/resources\/images\/sets\/[^\s)]+_large\.jpg/g)].map((m) => m[0]))].slice(0, 3); const excerpt = excerptSections(md); if (!excerpt.includes('Set number')) { ctx.anomaly('parse_failure_details', url); return null; } const payload: SetPayload = { kind: 'set_page', url, title, markdown: excerpt, images }; const setNumber = parseSetMarkdown(excerpt).setNumber; return { url, externalId: setNumber, kind: 'catalog_item', engine: res.engine, httpStatus: res.httpStatus, payload, fetchedAt: res.fetchedAt }; } async lookup(url: string, ctx: CrawlContext): Promise { const rec = await this.fetchSet(ctx, url.split('?')[0]!); return rec ? [rec] : []; } async normalize(raw: RawRecordLike): Promise { const p = SetPayloadSchema.parse(raw.payload); const s = parseSetMarkdown(p.markdown); if (!s.setNumber || !s.name) return []; const baseNumber = s.setNumber.replace(/-\d+$/, ''); const attributes: AssetAttributes = { categorySlug: 'lego_sets', subcategorySlug: null, franchise: s.theme, brand: 'LEGO', series: s.subtheme, set: s.theme, setCode: null, name: s.name, model: null, reference: null, number: baseNumber, year: s.year, edition: null, variant: s.setNumber.endsWith('-1') ? null : `variant ${s.setNumber.split('-')[1]}`, language: null, region: null, country: null, material: null, size: null, color: null, rarity: null, productionQuantity: null, originalMsrp: s.retailPrice, originalMsrpCurrency: s.retailPrice !== null ? 'USD' : null, identifiers: { lego_set_number: baseNumber, brickeconomy_set: s.setNumber }, metadata: { pieces: s.pieces, minifigs: s.minifigs, released: s.released, retired: s.retired, availability: s.availability, subtheme: s.subtheme, growth_pct: s.growthPct }, }; const observedAt = raw.fetchedAt; const base = { connectorId: this.meta.id, sourceId: this.meta.sourceId, sourceUrl: p.url, externalId: s.setNumber, rawTitle: p.title || `${baseNumber} LEGO ${s.theme ?? ''} ${s.name}`.trim(), description: null, imageUrls: p.images, attributes, grade: { grader: null, grade: null, qualifier: null, certificationNumber: null }, observedAt, confidence: 0.9, parserVersion: PARSER_VERSION, }; const out: NormalizedRecord[] = []; const catalog: NormalizedCatalogItem = { kind: 'catalog_item', ...base, condition: { condition: null, conditionRaw: null, completeness: null }, releaseDate: parseSourceDate(s.released) }; out.push(catalog); const obsDate = new Date(Date.UTC(observedAt.getUTCFullYear(), observedAt.getUTCMonth(), observedAt.getUTCDate())); const obs = (price: number, completeness: 'sealed' | 'used_complete', priceKind: NormalizedPriceObservation['priceKind'], raw: string): NormalizedPriceObservation => ({ kind: 'price_observation', ...base, condition: { condition: completeness, conditionRaw: raw, completeness }, priceKind, price, currency: 'USD', observationDate: obsDate, sampleSize: null, confidence: 0.8, }); if (s.newValue) out.push(obs(s.newValue, 'sealed', 'guide_value', 'New/Sealed value (BrickEconomy estimate)')); if (s.usedValue) out.push(obs(s.usedValue, 'used_complete', 'guide_value', 'Used value (BrickEconomy estimate)')); if (s.usedRangeLow) out.push(obs(s.usedRangeLow, 'used_complete', 'low', 'Used range low (BrickEconomy)')); if (s.usedRangeHigh) out.push(obs(s.usedRangeHigh, 'used_complete', 'high', 'Used range high (BrickEconomy)')); return out; } } export default (meta: ConnectorMeta) => new BrickEconomyConnector(meta);