import { z } from 'zod'; import { BaseConnector, type ConnectorMeta, type CrawlContext, type RawRecordInput, type RawRecordLike } from '@rareindex/connectors'; import { AssetAttributesSchema, NormalizedAuctionLotSchema, parsePrice, type NormalizedRecord } from '@rareindex/shared'; import { fetchFirecrawl } from '../../api/_wlib/index.js'; /** * e-Rocks — weekly online mineral auctions run by dealers. Auction pages (rendered by Firecrawl) * list every lot with locality, size class, current bid in EUR (+ USD conversion) and bid count, * plus the auction end time. Ended auction pages hide the final price (only a 'Sold' marker), so * lots are recorded as auction lots (current bid while live), never as sales. */ const BASE = 'https://e-rocks.com'; const PARSER_VERSION = '1.0.0'; export const LotSchema = z.object({ id: z.string(), url: z.string(), name: z.string(), locality: z.string().nullable(), sizeClass: z.string().nullable(), priceText: z.string().nullable(), bids: z.number().nullable(), seller: z.string().nullable(), image: z.string().nullable(), sold: z.boolean().default(false), }); export type Lot = z.infer; export const PagePayloadSchema = z.object({ kind: z.literal('auction_page'), url: z.string(), auctionId: z.string(), auctionName: z.string(), startsAt: z.string().nullable(), endsAt: z.string().nullable(), lots: z.array(LotSchema) }); export type PagePayload = z.infer; /** "07/09/2026 22:10 BST" → ISO (BST = UTC+1, GMT = UTC). */ export function parseErocksDate(s: string | null | undefined): string | null { const m = s?.match(/(\d{2})\/(\d{2})\/(\d{4})\s+(\d{2}):(\d{2})\s*(BST|GMT|UTC)?/); if (!m) return null; const offset = m[6] === 'BST' ? 1 : 0; const d = new Date(Date.UTC(Number(m[3]), Number(m[2]) - 1, Number(m[1]), Number(m[4]) - offset, Number(m[5]))); return Number.isNaN(d.getTime()) ? null : d.toISOString(); } export function parseAuctionMarkdown(md: string, url: string): PagePayload { const auctionId = url.match(/\/items\/auction\/(\d+)/)?.[1] ?? url; const auctionName = md.match(/^#\s+(.+)$/m)?.[1]?.trim() ?? auctionId; const startsAt = parseErocksDate(md.match(/Start:\s*([^|\n]+?)\s{2,}End:/)?.[1] ?? md.match(/Start:\s*([\d/]+\s+[\d:]+\s*\w*)/)?.[1]); const endsAt = parseErocksDate(md.match(/End:\s*([\d/]+\s+[\d:]+\s*\w*)/)?.[1]); const lots: Lot[] = []; const seen = new Set(); // Each lot starts with an image link to /item//; following cells hold locality, size, price, bids, seller. const re = /!\[([^\]]*)\]\(([^)\s]+)\)\]\((https?:\/\/e-rocks\.com\/item\/([a-z0-9]+)\/[^)\s]*)\)([\s\S]*?)(?=!\[[^\]]*\]\([^)\s]+\)\]\(https?:\/\/e-rocks\.com\/item\/|$)/g; for (const m of md.matchAll(re)) { const id = m[4]!.toUpperCase(); if (seen.has(id)) continue; seen.add(id); const block = m[5]!; // live pages render lots as table cells ("|"), ended pages as line breaks — accept both. const cells = block .split(/\n|\|/) .map((c) => c.replace(/\\/g, '').trim()) .filter((c) => c && !/^\[?(Bid|Watch)\]?/.test(c) && !/^(You are|You have|Proxy bid|Delayed)/i.test(c) && !/^€$/.test(c) && !/^\(reserve/.test(c) && !/^!\[/.test(c) && !/^- /.test(c)); const name = (m[1] ?? '').trim() || cells.find((c) => /^\[[^\]]+\]\(/.test(c))?.replace(/^\[([^\]]+)\].*/, '$1') || id; const plain = cells.filter((c) => !/^\[/.test(c)); const priceCell = plain.find((c) => /^[€$£]\s?\d/.test(c)) ?? null; const bidsCell = plain.find((c) => /\d+\s*bids?/.test(c)); const sizeCell = plain.find((c) => /\(\d+(?:\.\d+)?\s*-\s*\d+(?:\.\d+)?\s*(?:cm|mm)\)|^(Thumbnail|Miniature|Small miniature|Small cabinet|Cabinet|Large cabinet|Museum)/i.test(c)) ?? null; const locality = plain.find((c) => c !== name && /,/.test(c) && !/€|bids?|\(\d/.test(c)) ?? null; const sold = plain.some((c) => /^Sold$/i.test(c)); const seller = plain.filter((c) => /^[A-Z0-9 .&'-]{3,}$/.test(c) && c !== id && !/^(SOLD|BIDS?)$/.test(c)).pop() ?? null; lots.push({ id, url: m[3]!.split('?')[0]!, name, locality, sizeClass: sizeCell, priceText: priceCell, bids: bidsCell ? Number(bidsCell.match(/(\d+)\s*bids?/)![1]) : null, seller, image: m[2] ?? null, sold }); } return { kind: 'auction_page', url, auctionId, auctionName, startsAt, endsAt, lots }; } export class ERocksConnector extends BaseConnector { readonly version = '1.0.0'; readonly parserVersion = PARSER_VERSION; protected override minIntervalMs = 10_000; // robots.txt Crawl-delay: 10 async *crawl(ctx: CrawlContext): AsyncIterable { const maxAuctions = Number(this.meta.config.auctionsPerRun ?? 4); let count = 0; await this.throttle(); const index = await fetchFirecrawl(ctx, `${BASE}/auctions`, { timeoutMs: 90_000 }); const links = [...new Set((index.markdown ?? '').match(/https:\/\/e-rocks\.com\/items\/auction\/\d+\/[a-z0-9-]+/g) ?? [])].sort((a, b) => Number(b.match(/auction\/(\d+)/)![1]) - Number(a.match(/auction\/(\d+)/)![1])); if (!links.length) { ctx.anomaly('page_fetch_failed', `auction index: ${index.error ?? index.httpStatus ?? 'no auction links'}`); return; } for (const url of links.slice(0, maxAuctions)) { if (ctx.signal?.aborted || this.reached(ctx, count)) return; await this.throttle(); const res = await fetchFirecrawl(ctx, url, { timeoutMs: 90_000, parse: (r) => (r.markdown ? parseAuctionMarkdown(r.markdown, url).lots.length : 0) }); const payload = res.success && res.markdown ? parseAuctionMarkdown(res.markdown, url) : null; if (!payload?.lots.length) { ctx.anomaly('page_fetch_failed', `${url}: ${res.error ?? res.httpStatus ?? 'no lots'}`); continue; } count++; yield { url, externalId: `auction:${payload.auctionId}`, kind: 'auction_lot', engine: res.engine, httpStatus: res.httpStatus, payload, fetchedAt: res.fetchedAt }; } } async normalize(raw: RawRecordLike): Promise { const p = PagePayloadSchema.parse(raw.payload); const out: NormalizedRecord[] = []; const ends = p.endsAt ? new Date(p.endsAt) : null; const status = ends && ends.getTime() < raw.fetchedAt.getTime() ? 'ended' : 'live'; for (const l of p.lots) { const price = parsePrice(l.priceText ?? null, 'EUR'); const country = l.locality?.split(',').pop()?.trim() ?? null; const attributes = AssetAttributesSchema.parse({ categorySlug: 'minerals', name: l.name, country, size: l.sizeClass, identifiers: { erocks_item: l.id }, metadata: { locality: l.locality, seller: l.seller, auction: p.auctionName, unique_specimen: true, sold_flag: status === 'ended' ? l.sold : false }, }); out.push( NormalizedAuctionLotSchema.parse({ kind: 'auction_lot', connectorId: this.meta.id, sourceId: this.meta.sourceId, sourceUrl: l.url, externalId: l.id, rawTitle: l.locality ? `${l.name} — ${l.locality}` : l.name, imageUrls: l.image ? [l.image] : [], attributes, observedAt: raw.fetchedAt, confidence: 0.7, parserVersion: PARSER_VERSION, auctionHouse: 'e-Rocks', auctionName: p.auctionName, lotNumber: l.id, startsAt: p.startsAt ? new Date(p.startsAt) : null, endsAt: ends, currentBid: price?.amount ?? null, currency: price?.currency ?? 'EUR', status, location: country, }), ); } return out; } } export default function createConnector(meta: ConnectorMeta) { return new ERocksConnector(meta); }