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%
8.2 KB · 164 lines typescript
Raw Blame History
1import type { ConnectorMeta, CrawlContext } from '@rareindex/connectors';2import type { ExtractionResult } from '@rareindex/shared';3import { parseEuDate, stripHtml } 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 * Kunsthaus Lempertz (Köln / Brussels) — TYPO3 site. The public results index /de/auktionen/ergebnisse.html8 * lists every past auction (number, title, date, city); each result page carries a `catalogue_uid` and its9 * lot table is loaded from the site's own JSON endpoint POST /de/?endpoint=catalogue10 * {catalogue_uid, catalogue_type:"resultList"} → { lots:[{uid, number, title, subtitle, description,11 * estimate, estimate_to, hammerprice, state, link, …}] }. Lempertz states on the page that every result12 * "beinhaltet den Zuschlagspreis und das Aufgeld" → prices are premium-inclusive (EUR).13 */14const BASE = 'https://www.lempertz.com';1516export interface LempertzLotJson {17  uid?: number | string;18  number?: number | string;19  position?: string | null;20  title?: string | null;21  subtitle?: string | null;22  raw_title?: string | null;23  description?: string | null;24  estimate?: number | null;25  estimate_to?: number | null;26  hammerprice?: number | null;27  hammerprice_type?: string | null;28  state?: string | null;29  link?: string | null;30  slug?: string | null;31  file_name?: string | null;32  online_only?: number | boolean | null;33  session_end?: string | null;34}3536/** Results index: one card per auction with number, title, "DD.MM.YYYY HH:MM, City". */37export function parseResultsIndex(htmlText: string): SaleRef[] {38  const out: SaleRef[] = [];39  const seen = new Set<string>();40  for (const chunk of chunksBetween(htmlText, /<div class="catalogue-result-item">/)) {41    const href = chunk.match(/href="(\/(?:de|en)\/auktionen\/ergebnisse\/detail\/([^"]+)\.html)"/) ?? chunk.match(/href="(\/(?:de|en)\/auctions\/results\/detail\/([^"]+)\.html)"/);42    if (!href) continue;43    const id = href[2]!;44    if (seen.has(id)) continue;45    seen.add(id);46    const title = pick(chunk, /<h3>([\s\S]*?)<\/h3>/) ?? id;47    const dateText = pick(chunk, /catalogue-result-item-info-date-date">([^<]*)</);48    const location = pick(chunk, /<span class="catalogue-result-item-info-date-location">,?\s*([^<]*)</);49    const auctionNo = pick(chunk, /catalogue-result-item-info-auction">([^<]*)</);50    const date = dateText ? parseEuDate(dateText) : null;51    out.push({ id, title, url: `${BASE}${href[1]}`, date: date ? date.toISOString() : null, location: location ? `${location}` : null, extra: { auction_no: auctionNo?.replace(/^Auktion\s*/i, '') ?? null, date_text: dateText } });52  }53  return out;54}5556/** The result detail page exposes the catalogue uid the JSON endpoint needs, plus header facts. */57export function parseResultDetail(htmlText: string): { catalogueUid: string | null; language: string; dateText: string | null; title: string | null; location: string | null; premiumNote: boolean } {58  const uid = htmlText.match(/catalogue_uid="(\d+)"/)?.[1] ?? null;59  const language = htmlText.match(/id="catalogue-result-detail"[^>]*language="(\d)"/)?.[1] === '0' ? 'en' : 'de';60  const info = htmlText.match(/<div class="catalogue-info">([\s\S]*?)<\/div>/)?.[1] ?? '';61  const spans = [...info.matchAll(/<span>([^<]*)<\/span>/g)].map((m) => textOf(m[1])).filter((s) => s && s !== '|');62  const title = pick(htmlText, /<h1 class="catalogue-detail-title">([\s\S]*?)<\/h1>/);63  return { catalogueUid: uid, language, dateText: spans[0] ?? null, title, location: spans[2] ?? null, premiumNote: /Zuschlagspreis und das Aufgeld|hammer price and the buyer'?s premium|inkl\. Aufgeld|incl\. premium/i.test(htmlText) };64}6566/** JSON lots → ParsedLot[] (hammerprice 0 / state K = unsold). */67export function lotsFromJson(json: unknown, sale: SaleRef): ParsedLot[] {68  const j = json as { lots?: LempertzLotJson[] | Record<string, LempertzLotJson> } | null;69  const arr = Array.isArray(j?.lots) ? j!.lots : j?.lots && typeof j.lots === 'object' ? Object.values(j.lots) : [];70  const out: ParsedLot[] = [];71  for (const l of arr) {72    const lotNo = `${l.number ?? ''}${l.position ?? ''}`.trim();73    const title = stripHtml(l.title ?? l.raw_title ?? '', 300);74    if (!lotNo || !title) continue;75    const price = typeof l.hammerprice === 'number' && l.hammerprice > 0 ? l.hammerprice : null;76    const url = l.link ? (l.link.startsWith('http') ? l.link : `${BASE}${l.link}`) : sale.url;77    out.push({78      lotNo,79      title,80      subtitle: stripHtml(l.subtitle ?? null, 200),81      description: stripHtml(l.description ?? null, 500),82      url,83      image: null,84      price,85      currency: 'EUR',86      premiumIncluded: true,87      estimateLow: typeof l.estimate === 'number' && l.estimate > 0 ? l.estimate : null,88      estimateHigh: typeof l.estimate_to === 'number' && l.estimate_to > 0 ? l.estimate_to : null,89      date: null,90      sold: price !== null,91      extra: { lot_uid: l.uid ?? null, state: l.state ?? null, hammerprice_type: l.hammerprice_type ?? null, under_reserve: l.state === 'V' },92    });93  }94  return out;95}9697export class LempertzConnector extends SaleResultsConnector {98  readonly version = '1.0.0';99  readonly house: HouseConfig = { houseName: 'Lempertz', defaultCurrency: 'EUR', location: 'Köln, Germany', idKey: 'lempertz_lot', premiumIncluded: true, fallbackSlug: 'art', minIntervalMs: 2000, maxPagesPerSale: 1 };100  protected override minIntervalMs = 2000;101102  async listSales(ctx: CrawlContext): Promise<SaleRef[]> {103    const url = String(this.meta.config.resultsUrl ?? `${BASE}/de/auktionen/ergebnisse.html`);104    await this.throttle(url);105    const res = await ctx.fetch(url, { engines: ['api'], responseType: 'text', minQuality: 0, timeoutMs: 60_000 });106    if (!res.success || !res.html) {107      ctx.anomaly('past_list_failed', `${url}: ${res.error ?? res.httpStatus}`);108      return [];109    }110    return parseResultsIndex(res.html);111  }112113  salePageUrl(sale: SaleRef): string {114    return sale.url;115  }116117  /** Not used directly (the two-step fetch below builds the page); kept for the interface. */118  parseSalePage(res: ExtractionResult, sale: SaleRef): ParsedSalePage | null {119    return res.json ? { lots: lotsFromJson(res.json, sale), hasMore: false, totalLots: null } : null;120  }121122  protected override async fetchSalePage(ctx: CrawlContext, sale: SaleRef): Promise<{ url: string; res: ExtractionResult; parsed: ParsedSalePage | null }> {123    const url = this.salePageUrl(sale);124    await this.throttle(url);125    const page = await ctx.fetch(url, { engines: ['api'], responseType: 'text', minQuality: 0, timeoutMs: 60_000 });126    if (!page.success || !page.html) return { url, res: page, parsed: null };127    const detail = parseResultDetail(page.html);128    if (!detail.catalogueUid) {129      ctx.anomaly('selector_missing', `${sale.id}: catalogue_uid not found`);130      return { url, res: page, parsed: null };131    }132    const endpoint = `${BASE}/${detail.language}/?endpoint=catalogue`;133    await this.throttle(endpoint);134    const res = await ctx.fetch(endpoint, {135      engines: ['api'],136      method: 'POST',137      body: { catalogue_uid: detail.catalogueUid, catalogue_type: 'resultList' },138      headers: { 'x-requested-with': 'XMLHttpRequest' },139      responseType: 'json',140      timeoutMs: 90_000,141      expect: ['title', 'price'],142      minQuality: 0.3,143      parse: (r) => {144        const lots = lotsFromJson(r.json, sale);145        const sold = lots.find((l) => l.price);146        return lots.length ? { title: lots[0]!.title, price: sold?.price ?? null, currency: sold ? 'EUR' : null } : null;147      },148    });149    if (!res.success) return { url: endpoint, res, parsed: null };150    const lots = lotsFromJson(res.json, sale);151    const date = detail.dateText ? parseEuDate(detail.dateText) : null;152    return {153      url: endpoint,154      res,155      parsed: { lots, hasMore: false, totalLots: lots.length, sale: { date: date ? date.toISOString() : undefined, location: detail.location ?? undefined, title: detail.title?.replace(/^Auktionsergebnisse zu Auktion \d+ - /, '') ?? undefined, extra: { catalogue_uid: detail.catalogueUid, premium_note: detail.premiumNote } } },156    };157  }158159}160161export default function createConnector(meta: ConnectorMeta) {162  return new LempertzConnector(meta);163}164