import type { ConnectorMeta, CrawlContext } from '@rareindex/connectors'; import type { ExtractionResult } from '@rareindex/shared'; import { parseEuDate, parseEuMoney } from '../_g8-auctions-eu-apac-lib/index.js'; import { SaleResultsConnector, chunksBetween, pick, textOf, type HouseConfig, type ParsedLot, type ParsedSalePage, type SaleRef } from '../_g8-auctions-eu-apac-lib/sale-results.js'; /** * Aste Bolaffi (Turin — stamps, coins, banknotes, watches, wine, cars, memorabilia). Laravel site, server-rendered: * /it/results lists every past auction (date, city, /it/auction/); /it/auction/?page=N shows 10 lots per * page with "Lotto N", title, "Base asta: € 5 | Aggiudicato a : € 5"; the sale date is embedded as * getLocationDate('2023-05-07 10:00:00'). "Aggiudicato a" is not labelled hammer/with-fees → premium basis null. */ const BASE = 'https://www.astebolaffi.it'; export function parseResultsIndex(htmlText: string): SaleRef[] { const out: SaleRef[] = []; const seen = new Set(); // rows: "

07.05.2023

…

Torino

… /it/auction/1204 … /it/results/1204" for (const chunk of chunksBetween(htmlText, /
\s*
]*>([\s\S]*?)<\/h3>/g)].map((m) => textOf(m[1])); const dateText = h3s.find((t) => /^\d{2}\.\d{2}\.\d{4}$/.test(t)) ?? null; const city = h3s.find((t) => t && !/^\d{2}\.\d{2}\.\d{4}$/.test(t) && !/Catalogo|Risultati/i.test(t)) ?? null; const date = dateText ? parseEuDate(dateText) : null; out.push({ id, title: `Asta ${id}`, url: `${BASE}/it/auction/${id}`, date: date ? date.toISOString() : null, location: city ? `${city}, Italy` : null, extra: { date_text: dateText } }); } // section headings ("Special Sales", "Francobolli", …) precede their rows → attach as department let dept: string | null = null; const seq = [...htmlText.matchAll(/

([\s\S]*?)<\/h2>|\/it\/auction\/(\d+)"/g)]; const deptById = new Map(); for (const m of seq) { if (m[1]) dept = textOf(m[1]); else if (m[2] && dept) deptById.set(m[2], dept); } return out.map((s) => ({ ...s, title: deptById.get(s.id) ? `${deptById.get(s.id)} — Asta ${s.id}` : s.title, extra: { ...s.extra, department: deptById.get(s.id) ?? null } })); } export function parseAuctionPage(htmlText: string, sale: SaleRef, page: number): ParsedSalePage | null { if (!/lot-row|asta-title/.test(htmlText)) return null; const lots: ParsedLot[] = []; for (const chunk of chunksBetween(htmlText, /
/)) { const href = chunk.match(/href="(https:\/\/www\.astebolaffi\.it\/it\/lot\/(\d+)\/([^/"]+)\/detail)"/); const lotNo = pick(chunk, /
[\s\S]*?]*>\s*Lotto\s*([^<]*)<\/a>/) ?? href?.[3] ?? null; if (!href || !lotNo) continue; const titleBlock = chunk.match(/

([\s\S]*?)<\/p>\s*

\s*Base asta/)?.[1] ?? chunk.match(/

([\s\S]*?)<\/div>/)?.[1] ?? ''; const strong = pick(titleBlock, /([\s\S]*?)<\/strong>/); const full = textOf(titleBlock); const title = strong || full.split(/\s{2,}/)[0] || ''; if (!title) continue; const desc = full.replace(title, '').replace(new RegExp(title.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g'), '').trim(); const start = pick(chunk, /Base asta:\s*([\s\S]*?)<\/span>/); const sold = pick(chunk, /Aggiudicato a\s*:\s*([\s\S]*?)<\/span>/); const price = sold ? parseEuMoney(sold, 'EUR') : null; const image = chunk.match(/ Number(m[1])); const maxPage = last.length ? Math.max(...last) : page; const title = pick(htmlText, /([\s\S]*?)<\/title>/); return { lots, hasMore: page < maxPage, totalLots: null, sale: { date: dateM ? `${dateM[1]}T00:00:00.000Z` : undefined, title: title && !/^Lotto/i.test(title) && title.length < 120 ? title.replace(/\s*\|\s*Aste Bolaffi\s*$/, '') : undefined } }; } export class AsteBolaffiConnector extends SaleResultsConnector { readonly version = '1.0.0'; readonly house: HouseConfig = { houseName: 'Aste Bolaffi', defaultCurrency: 'EUR', location: 'Torino, Italy', idKey: 'bolaffi_lot', premiumIncluded: null, fallbackSlug: 'antiques', minIntervalMs: 3000, maxPagesPerSale: 40 }; protected override minIntervalMs = 3000; async listSales(ctx: CrawlContext): Promise<SaleRef[]> { const url = String(this.meta.config.resultsUrl ?? `${BASE}/it/results`); await this.throttle(url); const res = await ctx.fetch(url, { engines: ['api'], responseType: 'text', minQuality: 0, timeoutMs: 60_000 }); if (!res.success || !res.html) { ctx.anomaly('past_list_failed', `${url}: ${res.error ?? res.httpStatus}`); return []; } return parseResultsIndex(res.html); } salePageUrl(sale: SaleRef, page: number): string { return page > 1 ? `${sale.url}?page=${page}` : sale.url; } parseSalePage(res: ExtractionResult, sale: SaleRef, page: number): ParsedSalePage | null { return res.html ? parseAuctionPage(res.html, sale, page) : null; } } export default function createConnector(meta: ConnectorMeta) { return new AsteBolaffiConnector(meta); }