import { z } from 'zod'; import { BaseConnector, html as H, type ConnectorMeta, type CrawlContext, type RawRecordInput, type RawRecordLike } from '@rareindex/connectors'; import { normalizeCondition } from '@rareindex/taxonomy'; import { CurrencySchema, extractYear, type AssetAttributes, type NormalizedListing, type NormalizedRecord } from '@rareindex/shared'; /** * Chrono24 connector — watch listings (asking prices) from public model pages. * The page's schema.org ItemList of Offers is the extraction contract; one raw record per page. */ const BASE = 'https://www.chrono24.com'; const PARSER_VERSION = '1.0.0'; export const OfferSchema = z.object({ name: z.string(), price: z.number(), currency: z.string(), url: z.string(), image: z.string().nullable() }); export const PagePayloadSchema = z.object({ kind: z.literal('model_page'), url: z.string(), brand: z.string(), model: z.string(), breadcrumb: z.array(z.string()), offers: z.array(OfferSchema), totalListings: z.number().nullable(), }); export type PagePayload = z.infer; const BRAND_CATEGORY: Record = { rolex: 'rolex', patekphilippe: 'patek_philippe', 'patek-philippe': 'patek_philippe', audemarspiguet: 'audemars_piguet', 'audemars-piguet': 'audemars_piguet', omega: 'omega' }; const BRAND_NAME: Record = { rolex: 'Rolex', patekphilippe: 'Patek Philippe', audemarspiguet: 'Audemars Piguet', omega: 'Omega', cartier: 'Cartier', tudor: 'Tudor', vacheronconstantin: 'Vacheron Constantin', breitling: 'Breitling', iwc: 'IWC', jaegerlecoultre: 'Jaeger-LeCoultre', alangesoehne: 'A. Lange & Söhne', richardmille: 'Richard Mille', grandseiko: 'Grand Seiko', tagheuer: 'TAG Heuer', panerai: 'Panerai', hublot: 'Hublot', zenith: 'Zenith', breguet: 'Breguet', blancpain: 'Blancpain' }; /** Watch reference heuristics: 116500LN, 126610LV, 5711/1A-010, 15400ST.OO.1220ST.01, 311.30.42.30.01.005, RM 011 */ const REF_RE = /\b(\d{4,6}[A-Z]{0,3}(?:\/\d[A-Z0-9]*)?(?:-\d{3})?|\d{5}[A-Z]{2}\.[A-Z]{2}\.\d{4}[A-Z]{2}\.\d{2}|\d{3}\.\d{2}\.\d{2}\.\d{2}\.\d{2}\.\d{3}|RM\s?\d{2,3}(?:-\d{2})?)\b/; export function parseModelPage(htmlText: string, url: string): PagePayload { const ld = H.jsonLd(htmlText); const crumbs: string[] = []; const offers: z.infer[] = []; for (const block of ld) { const type = block['@type']; if (type === 'BreadcrumbList') { for (const it of (block.itemListElement as Array<{ item?: { name?: string } }>) ?? []) if (it.item?.name) crumbs.push(it.item.name); } // Listings are published as an AggregateOffer whose `offers` array holds one Offer per watch. const offerList = type === 'AggregateOffer' ? ((block.offers as Array>) ?? []) : type === 'ItemList' ? (((block.itemListElement as Array>) ?? []).map((el) => (el.item as Record | undefined) ?? el)) : []; if (offerList.length) { const defaultCurrency = String(block.priceCurrency ?? ''); for (const off of offerList) { const t = off?.['@type']; if (t !== 'Offer' && t !== 'Product') continue; const priceRaw = off.price ?? (off.offers as { price?: unknown } | undefined)?.price; const price = Number(priceRaw); const currency = String(off.priceCurrency ?? (off.offers as { priceCurrency?: unknown } | undefined)?.priceCurrency ?? defaultCurrency); const href = String(off.url ?? ''); if (!Number.isFinite(price) || price <= 0 || !href) continue; const img = off.image; const image = Array.isArray(img) ? ((img[0] as { contentUrl?: string; url?: string } | string) ?? null) : (img as string | null | undefined) ?? null; offers.push({ name: String(off.name ?? '').replace(/\s+/g, ' ').trim(), price, currency, url: href, image: typeof image === 'string' ? image : (image?.contentUrl ?? image?.url ?? null) }); } } } const pathBrand = url.match(/chrono24\.com\/([a-z-]+)\//)?.[1] ?? ''; const brand = BRAND_NAME[pathBrand] ?? crumbs[1]?.replace(/\s+watches?$/i, '') ?? pathBrand; const model = (crumbs[crumbs.length - 1] ?? '').replace(/\s+watches?$/i, ''); const total = htmlText.match(/([\d,.]+)\s+(?:listings|watches)\b/)?.[1] ?? null; return { kind: 'model_page', url, brand, model, breadcrumb: crumbs, offers, totalListings: total ? Number(total.replace(/[,.]/g, '')) : null }; } export class Chrono24Connector extends BaseConnector { readonly version = '1.0.0'; readonly parserVersion = PARSER_VERSION; protected override minIntervalMs = 2000; async *crawl(ctx: CrawlContext): AsyncIterable { const seeds = ctx.options.seeds?.length ? ctx.options.seeds : ((this.meta.config.seeds as string[] | undefined) ?? []); const pages = Number(this.meta.config.pagesPerSeed ?? 1); const pageSize = Number(this.meta.config.pageSize ?? 120); let count = 0; for (const seed of seeds) { if (ctx.signal?.aborted) return; for (let page = 1; page <= pages; page++) { if (this.reached(ctx, count)) return; const path = page === 1 ? seed : seed.replace(/--mod(\d+)\.htm$/, `--mod$1-${page}.htm`); const url = `${path.startsWith('http') ? path : BASE + path}?pageSize=${pageSize}&showpage=${page}`; await this.throttle(); const res = await ctx.fetch(url, { renderJs: false, country: 'us', responseType: 'text', expect: ['title', 'price', 'currency', 'images'], parse: (r) => { if (!r.html) return null; const p = parseModelPage(r.html, url); const first = p.offers[0]; return { title: p.model || null, price: first?.price ?? null, currency: first?.currency ?? null, images: p.offers.filter((o) => o.image).map((o) => o.image) }; }, }); if (!res.success || !res.html) { ctx.anomaly('page_fetch_failed', `${url}: ${res.error ?? res.httpStatus}`); break; } const payload = parseModelPage(res.html, url.split('?')[0]!); if (payload.offers.length === 0) { ctx.anomaly('empty_page', url); break; } count++; yield { url: url.split('?')[0]!, externalId: `${seed}#${page}`, kind: 'listing', engine: res.engine, httpStatus: res.httpStatus, payload, fetchedAt: res.fetchedAt }; } } } async normalize(raw: RawRecordLike): Promise { const p = PagePayloadSchema.parse(raw.payload); const pathBrand = p.url.match(/chrono24\.com\/([a-z-]+)\//)?.[1] ?? ''; const categorySlug = BRAND_CATEGORY[pathBrand] ?? 'other_watches'; const out: NormalizedListing[] = []; const seen = new Set(); for (const o of p.offers) { const id = o.url.match(/--id(\d+)\.htm/)?.[1] ?? o.url; if (seen.has(id)) continue; seen.add(id); const cur = CurrencySchema.safeParse(o.currency); if (!cur.success) continue; // First reference-looking token that is not a plain year (e.g. "UNWORN 2024 126500LN" → 126500LN). const ref = [...o.name.matchAll(new RegExp(REF_RE.source, 'g'))].map((m) => m[1]!.replace(/\s+/g, ' ')).find((r) => !/^(18|19|20)\d{2}$/.test(r)) ?? null; const year = extractYear(o.name); const lower = o.name.toLowerCase(); const conditionRaw = /unworn|brand new|new\b/.test(lower) ? 'Unworn' : /\bmint\b|like new|excellent/.test(lower) ? 'Excellent' : /pre-owned|used/.test(lower) ? 'Pre-owned' : null; const completeness = /full set|box (?:and|&|\/) papers|box\/papers|complete set/.test(lower) ? 'full_set' : /papers/.test(lower) ? 'papers_only' : /\bbox\b/.test(lower) ? 'box_only' : null; const attributes: AssetAttributes = { categorySlug, subcategorySlug: null, franchise: null, brand: p.brand, series: null, set: null, setCode: null, name: `${p.brand} ${p.model}`.trim(), model: p.model, reference: ref, number: null, year, edition: null, variant: null, language: null, region: null, country: null, material: /platinum/.test(lower) ? 'platinum' : /yellow gold|rose gold|everose|white gold|18k|gold/.test(lower) ? 'gold' : /two[- ]tone|rolesor/.test(lower) ? 'two-tone' : /steel|stainless/.test(lower) ? 'steel' : /titanium/.test(lower) ? 'titanium' : /ceramic/.test(lower) ? 'ceramic' : null, size: o.name.match(/\b(\d{2}(?:\.\d)?)\s?mm\b/i)?.[1] ? `${o.name.match(/\b(\d{2}(?:\.\d)?)\s?mm\b/i)![1]}mm` : null, color: null, rarity: null, productionQuantity: null, originalMsrp: null, originalMsrpCurrency: null, identifiers: { ...(ref ? { reference: ref } : {}), chrono24_id: id }, metadata: { model_page: p.url }, }; out.push({ kind: 'listing', connectorId: this.meta.id, sourceId: this.meta.sourceId, sourceUrl: o.url, externalId: id, rawTitle: o.name, description: null, imageUrls: o.image ? [o.image] : [], attributes, grade: { grader: null, grade: null, qualifier: null, certificationNumber: null }, condition: { condition: normalizeCondition(categorySlug, conditionRaw), conditionRaw, completeness }, observedAt: raw.fetchedAt, confidence: 0.8, parserVersion: PARSER_VERSION, listingType: 'fixed_price', price: o.price, currency: cur.data, seller: null, sellerReputation: null, location: null, shippingCost: null, quantity: 1, listedAt: null, endsAt: null, availability: 'available', bidCount: null, }); } return out; } } export default (meta: ConnectorMeta) => new Chrono24Connector(meta);