TypeScript 61.9%
HTML 37.2%
SQL 0.7%
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