TypeScript 61.9%
HTML 37.2%
SQL 0.7%
1import type { ConnectorMeta, CrawlContext } from '@rareindex/connectors';2import type { ExtractionResult } from '@rareindex/shared';3import { parseEuDate, parseEuMoney } from '../_g8-auctions-eu-apac-lib/index.js';4import { SaleResultsConnector, chunksBetween, pick, textOf, type HouseConfig, type ParsedLot, type ParsedSalePage, type SaleRef } from '../_g8-auctions-eu-apac-lib/sale-results.js';56/**7 * Aste Bolaffi (Turin — stamps, coins, banknotes, watches, wine, cars, memorabilia). Laravel site, server-rendered:8 * /it/results lists every past auction (date, city, /it/auction/<id>); /it/auction/<id>?page=N shows 10 lots per9 * page with "Lotto N", title, "Base asta: € 5 | Aggiudicato a : € 5"; the sale date is embedded as10 * getLocationDate('2023-05-07 10:00:00'). "Aggiudicato a" is not labelled hammer/with-fees → premium basis null.11 */12const BASE = 'https://www.astebolaffi.it';1314export function parseResultsIndex(htmlText: string): SaleRef[] {15 const out: SaleRef[] = [];16 const seen = new Set<string>();17 // rows: "<h3>07.05.2023</h3> … <h3>Torino</h3> … /it/auction/1204 … /it/results/1204"18 for (const chunk of chunksBetween(htmlText, /<div class="row\s*">\s*<div class="col-left-table/)) {19 const id = chunk.match(/\/it\/(?:auction|results)\/(\d+)"/)?.[1];20 if (!id || seen.has(id)) continue;21 seen.add(id);22 const h3s = [...chunk.matchAll(/<h3[^>]*>([\s\S]*?)<\/h3>/g)].map((m) => textOf(m[1]));23 const dateText = h3s.find((t) => /^\d{2}\.\d{2}\.\d{4}$/.test(t)) ?? null;24 const city = h3s.find((t) => t && !/^\d{2}\.\d{2}\.\d{4}$/.test(t) && !/Catalogo|Risultati/i.test(t)) ?? null;25 const date = dateText ? parseEuDate(dateText) : null;26 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 } });27 }28 // section headings ("Special Sales", "Francobolli", …) precede their rows → attach as department29 let dept: string | null = null;30 const seq = [...htmlText.matchAll(/<h2 class="title-border-risultati">([\s\S]*?)<\/h2>|\/it\/auction\/(\d+)"/g)];31 const deptById = new Map<string, string>();32 for (const m of seq) {33 if (m[1]) dept = textOf(m[1]);34 else if (m[2] && dept) deptById.set(m[2], dept);35 }36 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 } }));37}3839export function parseAuctionPage(htmlText: string, sale: SaleRef, page: number): ParsedSalePage | null {40 if (!/lot-row|asta-title/.test(htmlText)) return null;41 const lots: ParsedLot[] = [];42 for (const chunk of chunksBetween(htmlText, /<div class="row sezione-news lot-row">/)) {43 const href = chunk.match(/href="(https:\/\/www\.astebolaffi\.it\/it\/lot\/(\d+)\/([^/"]+)\/detail)"/);44 const lotNo = pick(chunk, /<h5 class="asta-title">[\s\S]*?<a[^>]*>\s*Lotto\s*([^<]*)<\/a>/) ?? href?.[3] ?? null;45 if (!href || !lotNo) continue;46 const titleBlock = chunk.match(/<p class="lot-dida">([\s\S]*?)<\/p>\s*<p>\s*Base asta/)?.[1] ?? chunk.match(/<p class="lot-dida">([\s\S]*?)<\/div>/)?.[1] ?? '';47 const strong = pick(titleBlock, /<strong>([\s\S]*?)<\/strong>/);48 const full = textOf(titleBlock);49 const title = strong || full.split(/\s{2,}/)[0] || '';50 if (!title) continue;51 const desc = full.replace(title, '').replace(new RegExp(title.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g'), '').trim();52 const start = pick(chunk, /Base asta:\s*<span class="asta-euro">([\s\S]*?)<\/span>/);53 const sold = pick(chunk, /Aggiudicato a\s*:\s*<span class="asta-euro">([\s\S]*?)<\/span>/);54 const price = sold ? parseEuMoney(sold, 'EUR') : null;55 const image = chunk.match(/<img src="([^"]+)"/)?.[1] ?? null;56 lots.push({57 lotNo: lotNo.trim(),58 title,59 subtitle: null,60 description: desc || null,61 url: href[1]!,62 image: image && !/no_image/.test(image) ? image : null,63 price: price?.amount ?? null,64 currency: 'EUR',65 premiumIncluded: null,66 estimateLow: start ? parseEuMoney(start, 'EUR')?.amount ?? null : null,67 estimateHigh: null,68 date: null,69 sold: price !== null,70 extra: { starting_price_text: start, aggiudicato_text: sold },71 });72 }73 const dateM = htmlText.match(/getLocationDate\('(\d{4}-\d{2}-\d{2})[^']*'\)/);74 const last = [...htmlText.matchAll(/\?page=(\d+)"/g)].map((m) => Number(m[1]));75 const maxPage = last.length ? Math.max(...last) : page;76 const title = pick(htmlText, /<title>([\s\S]*?)<\/title>/);77 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 } };78}7980export class AsteBolaffiConnector extends SaleResultsConnector {81 readonly version = '1.0.0';82 readonly house: HouseConfig = { houseName: 'Aste Bolaffi', defaultCurrency: 'EUR', location: 'Torino, Italy', idKey: 'bolaffi_lot', premiumIncluded: null, fallbackSlug: 'antiques', minIntervalMs: 3000, maxPagesPerSale: 40 };83 protected override minIntervalMs = 3000;8485 async listSales(ctx: CrawlContext): Promise<SaleRef[]> {86 const url = String(this.meta.config.resultsUrl ?? `${BASE}/it/results`);87 await this.throttle(url);88 const res = await ctx.fetch(url, { engines: ['api'], responseType: 'text', minQuality: 0, timeoutMs: 60_000 });89 if (!res.success || !res.html) {90 ctx.anomaly('past_list_failed', `${url}: ${res.error ?? res.httpStatus}`);91 return [];92 }93 return parseResultsIndex(res.html);94 }9596 salePageUrl(sale: SaleRef, page: number): string {97 return page > 1 ? `${sale.url}?page=${page}` : sale.url;98 }99100 parseSalePage(res: ExtractionResult, sale: SaleRef, page: number): ParsedSalePage | null {101 return res.html ? parseAuctionPage(res.html, sale, page) : null;102 }103}104105export default function createConnector(meta: ConnectorMeta) {106 return new AsteBolaffiConnector(meta);107}108