import { z } from 'zod'; import { adapters, BaseConnector, html as H, type ConnectorMeta, type CrawlContext, type RawRecordInput, type RawRecordLike } from '@rareindex/connectors'; import type { NormalizedRecord, NormalizedSale } from '@rareindex/shared'; import { makeSale } from '../../firecrawl/_carlib/index.js'; import { clean, coinYear, isNumisBundle, numisAttributes, numisCategory, parseAuctionDate, parseCoinGrade, realized } from '../../firecrawl/_g5-numismatics-lib/index.js'; /** * Fritz Rudolf Künker (Osnabrück) — Europe's largest coin auction house. Every past lot keeps a static * Shopware product page (≈ 725 000 URLs in the product sitemaps) with auction name, lot number, sale * date, estimate and "Hammer price" in EUR, plus a properties table (Nominal/Year, Mint, Condition with * PCGS/NGC slab data, references). Plain HTTPS; the JS lot listings (/widgets/) are robots-disallowed and * never used — discovery goes through the sitemaps. */ const SITE = 'https://www.kuenker.de'; const PARSER_VERSION = '1.0.0'; export const PayloadSchema = z.object({ kind: z.literal('lot_page'), url: z.string(), productId: z.string(), title: z.string(), section1: z.string().nullable(), section2: z.string().nullable(), auctionName: z.string().nullable(), lotNumber: z.string().nullable(), dateText: z.string().nullable(), statusText: z.string().nullable(), estimateText: z.string().nullable(), hammerText: z.string().nullable(), properties: z.record(z.string(), z.string()), description: z.string().nullable(), images: z.array(z.string()), }); export type Payload = z.infer; export function parseLotPage(htmlText: string, url: string): Payload | null { const $ = H.load(htmlText); const productId = url.match(/\/(\d+)\/?(?:[?#].*)?$/)?.[1] ?? null; const h1 = $('h1.product-detail-name').first(); const section1 = H.text(h1.find('.section-1').first()); const section2 = H.text(h1.find('.section-2').first()); const ld = H.jsonLd(htmlText, 'Product')[0] as { name?: string; sku?: string; image?: string[] | string; description?: string } | undefined; // the JSON-LD name carries the full issuer line ("SCHWEDEN KÖNIGREICH Oskar II. …"); the mobile h1 may drop the country const title = (ld?.name ? clean(ld.name) : '') || clean(`${section1 ?? ''} ${section2 ?? ''}`); if (!productId || !title) return null; const info = $('.auction-lot-info').first(); const auctionName = H.text(info.find('b').first()); const lotNumber = (H.text(info.find('span').first()) ?? '').match(/(\d+[A-Za-z]?)/)?.[1] ?? null; const dateBox = $('.date-status-container').first(); const dateText = H.text(dateBox.find('span').first()); const statusText = clean(dateBox.clone().children('span').remove().end().text()) || null; const estimateText = ($('.estimated-price').first().text().match(/:\s*([^\n]+)/)?.[1] ?? '').trim() || null; const hammerBlock = $('.bid-status-finished').first(); const hammerText = /hammer price|zuschlag/i.test(hammerBlock.text()) ? H.text(hammerBlock.find('.price').first()) : null; const properties: Record = {}; $('table.product-detail-properties-table tr.properties-row').each((_, tr) => { const k = H.text($(tr).find('th').first()); const v = H.text($(tr).find('td').first()); if (k && v) properties[k] = v; }); const images: string[] = []; const ldImages = Array.isArray(ld?.image) ? ld!.image : ld?.image ? [ld.image] : []; for (const i of ldImages) if (typeof i === 'string') images.push(i.replace(/\\\//g, '/')); const og = $('meta[property="og:image"]').attr('content'); if (og && !images.includes(og)) images.push(og); const description = ld?.description ? clean(ld.description.replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"')) : null; return { kind: 'lot_page', url, productId, title, section1, section2, auctionName, lotNumber, dateText, statusText, estimateText, hammerText, properties, description, images: images.slice(0, 3) }; } /** "Fast Stempelglanz / In US-Plastikholder der PCGS mit der Bewertung MS 64 PL (812624.64/38930174)." → slab ids. */ export function slabIds(condition: string | null | undefined): { pcgsNumber: string | null; cert: string | null } { const m = condition?.match(/\((\d{4,7})\.(\d{2})\/(\d{7,10})\)/); if (m) return { pcgsNumber: m[1]!, cert: m[3]! }; const c = condition?.match(/\b(?:Zertifikat|cert(?:ificate)?|No\.?|Nr\.?)\s*#?\s*(\d{7,10})\b/i); return { pcgsNumber: null, cert: c?.[1] ?? null }; } interface Cursor { sitemapIndex?: number; urlIndex?: number; done?: boolean; updatedAt?: string; } export class KuenkerConnector extends BaseConnector { readonly version = '1.0.0'; readonly parserVersion = PARSER_VERSION; protected override minIntervalMs = 1500; override readonly urlPatterns = [/kuenker\.de\/[^/]+\/\d+\/?$/i]; async *crawl(ctx: CrawlContext): AsyncIterable { const lotsPerRun = Number(this.meta.config.lotsPerRun ?? 100); const cursor = { ...((ctx.options.cursor ?? {}) as Cursor) }; const seeds = ctx.options.seeds?.filter((s) => /kuenker\.de\//.test(s)) ?? []; let urls: string[] = seeds; if (!urls.length) { await this.throttle(); const index = await ctx.fetch(`${SITE}/sitemap.xml`, { engines: ['api'], responseType: 'text', minQuality: 0, force: true }); const children = index.success && index.html ? adapters.parseSitemapIndex(index.html).filter((c) => /sitemap-products-/.test(c.loc)) : []; if (!children.length) { ctx.anomaly('sitemap_fetch_failed', `sitemap.xml: ${index.error ?? index.httpStatus ?? 'no product sitemaps'}`); return; } children.sort((a, b) => Number(a.loc.match(/products-(\d+)/)?.[1] ?? 0) - Number(b.loc.match(/products-(\d+)/)?.[1] ?? 0)); const si = Math.min(cursor.sitemapIndex ?? 0, children.length - 1); await this.throttle(); const sm = await ctx.fetch(children[si]!.loc, { engines: ['api'], responseType: 'text', minQuality: 0, force: true }); const entries = sm.success && sm.html ? adapters.parseUrlset(sm.html) : []; if (!entries.length) { ctx.anomaly('sitemap_fetch_failed', `${children[si]!.loc}: ${sm.error ?? sm.httpStatus}`); return; } const start = cursor.urlIndex ?? 0; urls = entries.slice(start, start + lotsPerRun).map((e) => e.loc); const nextIndex = start + urls.length; const exhausted = nextIndex >= entries.length; cursor.sitemapIndex = exhausted ? (si + 1 >= children.length ? 0 : si + 1) : si; cursor.urlIndex = exhausted ? 0 : nextIndex; cursor.done = ctx.options.mode === 'backfill' && exhausted && si + 1 >= children.length; if (ctx.options.mode === 'backfill') await ctx.progress({ page: si * 25_000 + nextIndex, totalPages: children.length * 25_000, itemsProcessed: 0 }); } let count = 0; for (const url of urls) { if (ctx.signal?.aborted || this.reached(ctx, count)) break; if (ctx.options.mode !== 'backfill' && !(await ctx.shouldFetch(url))) continue; await this.throttle(); const res = await ctx.fetch(url, { engines: ['api', 'firecrawl'], responseType: 'text', expect: ['title', 'price', 'date'], parse: (r) => { const p = r.html ? parseLotPage(r.html, url) : null; return p ? { title: p.title, price: p.hammerText ?? p.estimateText, date: p.dateText } : null; }, }); const payload = res.success && res.html ? parseLotPage(res.html, url) : null; if (!payload) { ctx.anomaly(res.success ? 'parse_failure_page' : 'page_fetch_failed', `${url}: ${res.error ?? res.httpStatus}`); continue; } count++; yield { url, externalId: payload.productId, kind: 'sale', engine: res.engine, httpStatus: res.httpStatus, payload, fetchedAt: res.fetchedAt }; } await ctx.setCursor({ ...cursor, updatedAt: new Date().toISOString() }); } async normalize(raw: RawRecordLike): Promise { const p = PayloadSchema.parse(raw.payload); const price = realized(p.hammerText, 'EUR'); const saleDate = parseAuctionDate(p.dateText); if (!price || !saleDate) return []; // unsold, withdrawn or still running → no sale const condition = p.properties.Condition ?? p.properties.Erhaltung ?? null; const text = `${p.title} ${condition ?? ''}`; const g = parseCoinGrade(text); const slab = slabIds(condition); const categorySlug = numisCategory(p.title, `${p.auctionName ?? ''} ${p.section1 ?? ''}`, /medaille|medal|orden/i.test(p.title) && !/münze|coin|taler|dukat|mark|pfennig|kreuzer/i.test(p.title) ? 'medals' : 'coins'); const identifiers: Record = { kuenker_product_id: p.productId }; if (slab.pcgsNumber) identifiers.pcgs_number = slab.pcgsNumber; if (slab.cert && g.grader) identifiers[`${g.grader}_cert`] = slab.cert; const estimate = realized(p.estimateText, 'EUR'); const attributes = numisAttributes({ categorySlug, title: p.title, section: p.section1, materialHint: p.properties.Weight ?? p.properties.Gewicht ?? null, year: coinYear(p.properties['Nominal/Year'] ?? p.properties['Nominal/Jahr'] ?? '').year, identifiers, metadata: { auction: p.auctionName, status: p.statusText, estimate: estimate?.amount ?? null, estimate_currency: estimate?.currency ?? null, hammer_price: price.amount, buyer_premium: "excluded — page label 'Hammer price' (Zuschlag); Künker's premium is stated in the auction terms", nominal_year: p.properties['Nominal/Year'] ?? p.properties['Nominal/Jahr'] ?? null, mint: p.properties.Mint ?? p.properties.Prägestätte ?? null, rarity: p.properties.Rarity ?? p.properties.Seltenheit ?? null, weight: p.properties.Weight ?? p.properties.Gewicht ?? null, references: p.properties.Quotes ?? p.properties.Zitate ?? null, condition_text: condition, }, }); if (p.properties.Mint && !attributes.variant) attributes.variant = p.properties.Mint.replace(/\.$/, ''); const sale = makeSale({ meta: this.meta, sourceUrl: p.url, externalId: p.productId, rawTitle: p.title, description: p.description, attributes, price: price.amount, currency: price.currency, saleDate, buyerPremiumIncluded: false, auctionHouse: 'Fritz Rudolf Künker', lotNumber: p.lotNumber, imageUrls: p.images, observedAt: raw.fetchedAt, parserVersion: PARSER_VERSION, confidence: g.grader ? 0.9 : 0.82, isBundle: isNumisBundle(p.title) || /\bLot\s+von\b|\bLots?\b.*\bStück\b/i.test(p.title), conditionRaw: condition?.split('/')[0]?.trim() ?? g.conditionRaw, location: 'DE', }); sale.grade = { grader: g.grader, grade: g.grade, qualifier: g.qualifier, certificationNumber: slab.cert ?? g.certificationNumber }; return [sale]; } } export default (meta: ConnectorMeta) => new KuenkerConnector(meta);