import type { ConnectorMeta, CrawlContext } from '@rareindex/connectors'; import type { ExtractionResult } from '@rareindex/shared'; import { parseEuDate, stripHtml } 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'; /** * Kunsthaus Lempertz (Köln / Brussels) — TYPO3 site. The public results index /de/auktionen/ergebnisse.html * lists every past auction (number, title, date, city); each result page carries a `catalogue_uid` and its * lot table is loaded from the site's own JSON endpoint POST /de/?endpoint=catalogue * {catalogue_uid, catalogue_type:"resultList"} → { lots:[{uid, number, title, subtitle, description, * estimate, estimate_to, hammerprice, state, link, …}] }. Lempertz states on the page that every result * "beinhaltet den Zuschlagspreis und das Aufgeld" → prices are premium-inclusive (EUR). */ const BASE = 'https://www.lempertz.com'; export interface LempertzLotJson { uid?: number | string; number?: number | string; position?: string | null; title?: string | null; subtitle?: string | null; raw_title?: string | null; description?: string | null; estimate?: number | null; estimate_to?: number | null; hammerprice?: number | null; hammerprice_type?: string | null; state?: string | null; link?: string | null; slug?: string | null; file_name?: string | null; online_only?: number | boolean | null; session_end?: string | null; } /** Results index: one card per auction with number, title, "DD.MM.YYYY HH:MM, City". */ export function parseResultsIndex(htmlText: string): SaleRef[] { const out: SaleRef[] = []; const seen = new Set(); for (const chunk of chunksBetween(htmlText, /
/)) { const href = chunk.match(/href="(\/(?:de|en)\/auktionen\/ergebnisse\/detail\/([^"]+)\.html)"/) ?? chunk.match(/href="(\/(?:de|en)\/auctions\/results\/detail\/([^"]+)\.html)"/); if (!href) continue; const id = href[2]!; if (seen.has(id)) continue; seen.add(id); const title = pick(chunk, /

([\s\S]*?)<\/h3>/) ?? id; const dateText = pick(chunk, /catalogue-result-item-info-date-date">([^<]*),?\s*([^<]*)([^<]*)]*language="(\d)"/)?.[1] === '0' ? 'en' : 'de'; const info = htmlText.match(/
([\s\S]*?)<\/div>/)?.[1] ?? ''; const spans = [...info.matchAll(/([^<]*)<\/span>/g)].map((m) => textOf(m[1])).filter((s) => s && s !== '|'); const title = pick(htmlText, /

([\s\S]*?)<\/h1>/); 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) }; } /** JSON lots → ParsedLot[] (hammerprice 0 / state K = unsold). */ export function lotsFromJson(json: unknown, sale: SaleRef): ParsedLot[] { const j = json as { lots?: LempertzLotJson[] | Record } | null; const arr = Array.isArray(j?.lots) ? j!.lots : j?.lots && typeof j.lots === 'object' ? Object.values(j.lots) : []; const out: ParsedLot[] = []; for (const l of arr) { const lotNo = `${l.number ?? ''}${l.position ?? ''}`.trim(); const title = stripHtml(l.title ?? l.raw_title ?? '', 300); if (!lotNo || !title) continue; const price = typeof l.hammerprice === 'number' && l.hammerprice > 0 ? l.hammerprice : null; const url = l.link ? (l.link.startsWith('http') ? l.link : `${BASE}${l.link}`) : sale.url; out.push({ lotNo, title, subtitle: stripHtml(l.subtitle ?? null, 200), description: stripHtml(l.description ?? null, 500), url, image: null, price, currency: 'EUR', premiumIncluded: true, estimateLow: typeof l.estimate === 'number' && l.estimate > 0 ? l.estimate : null, estimateHigh: typeof l.estimate_to === 'number' && l.estimate_to > 0 ? l.estimate_to : null, date: null, sold: price !== null, extra: { lot_uid: l.uid ?? null, state: l.state ?? null, hammerprice_type: l.hammerprice_type ?? null, under_reserve: l.state === 'V' }, }); } return out; } export class LempertzConnector extends SaleResultsConnector { readonly version = '1.0.0'; readonly house: HouseConfig = { houseName: 'Lempertz', defaultCurrency: 'EUR', location: 'Köln, Germany', idKey: 'lempertz_lot', premiumIncluded: true, fallbackSlug: 'art', minIntervalMs: 2000, maxPagesPerSale: 1 }; protected override minIntervalMs = 2000; async listSales(ctx: CrawlContext): Promise { const url = String(this.meta.config.resultsUrl ?? `${BASE}/de/auktionen/ergebnisse.html`); 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): string { return sale.url; } /** Not used directly (the two-step fetch below builds the page); kept for the interface. */ parseSalePage(res: ExtractionResult, sale: SaleRef): ParsedSalePage | null { return res.json ? { lots: lotsFromJson(res.json, sale), hasMore: false, totalLots: null } : null; } protected override async fetchSalePage(ctx: CrawlContext, sale: SaleRef): Promise<{ url: string; res: ExtractionResult; parsed: ParsedSalePage | null }> { const url = this.salePageUrl(sale); await this.throttle(url); const page = await ctx.fetch(url, { engines: ['api'], responseType: 'text', minQuality: 0, timeoutMs: 60_000 }); if (!page.success || !page.html) return { url, res: page, parsed: null }; const detail = parseResultDetail(page.html); if (!detail.catalogueUid) { ctx.anomaly('selector_missing', `${sale.id}: catalogue_uid not found`); return { url, res: page, parsed: null }; } const endpoint = `${BASE}/${detail.language}/?endpoint=catalogue`; await this.throttle(endpoint); const res = await ctx.fetch(endpoint, { engines: ['api'], method: 'POST', body: { catalogue_uid: detail.catalogueUid, catalogue_type: 'resultList' }, headers: { 'x-requested-with': 'XMLHttpRequest' }, responseType: 'json', timeoutMs: 90_000, expect: ['title', 'price'], minQuality: 0.3, parse: (r) => { const lots = lotsFromJson(r.json, sale); const sold = lots.find((l) => l.price); return lots.length ? { title: lots[0]!.title, price: sold?.price ?? null, currency: sold ? 'EUR' : null } : null; }, }); if (!res.success) return { url: endpoint, res, parsed: null }; const lots = lotsFromJson(res.json, sale); const date = detail.dateText ? parseEuDate(detail.dateText) : null; return { url: endpoint, res, 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 } } }, }; } } export default function createConnector(meta: ConnectorMeta) { return new LempertzConnector(meta); }