SPB Git forge

spb/rareindex

Public
54commits 1branches 0releases
7.1 MBsize
maindefault branch
10 days agolast push
TypeScript 61.9% HTML 37.2% SQL 0.7%
6.0 KB · 108 lines typescript
Raw Blame History
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