TypeScript 61.9%
HTML 37.2%
SQL 0.7%
1import { z } from 'zod';2import { BaseConnector, html as H, type ConnectorMeta, type CrawlContext, type RawRecordInput, type RawRecordLike } from '@rareindex/connectors';3import { normalizeCondition } from '@rareindex/taxonomy';4import { CurrencySchema, extractYear, type AssetAttributes, type NormalizedListing, type NormalizedRecord } from '@rareindex/shared';56/**7 * Chrono24 connector — watch listings (asking prices) from public model pages.8 * The page's schema.org ItemList of Offers is the extraction contract; one raw record per page.9 */1011const BASE = 'https://www.chrono24.com';12const PARSER_VERSION = '1.0.0';1314export const OfferSchema = z.object({ name: z.string(), price: z.number(), currency: z.string(), url: z.string(), image: z.string().nullable() });15export const PagePayloadSchema = z.object({16 kind: z.literal('model_page'),17 url: z.string(),18 brand: z.string(),19 model: z.string(),20 breadcrumb: z.array(z.string()),21 offers: z.array(OfferSchema),22 totalListings: z.number().nullable(),23});24export type PagePayload = z.infer<typeof PagePayloadSchema>;2526const BRAND_CATEGORY: Record<string, string> = { rolex: 'rolex', patekphilippe: 'patek_philippe', 'patek-philippe': 'patek_philippe', audemarspiguet: 'audemars_piguet', 'audemars-piguet': 'audemars_piguet', omega: 'omega' };27const BRAND_NAME: Record<string, string> = { 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' };2829/** Watch reference heuristics: 116500LN, 126610LV, 5711/1A-010, 15400ST.OO.1220ST.01, 311.30.42.30.01.005, RM 011 */30const 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/;3132export function parseModelPage(htmlText: string, url: string): PagePayload {33 const ld = H.jsonLd(htmlText);34 const crumbs: string[] = [];35 const offers: z.infer<typeof OfferSchema>[] = [];36 for (const block of ld) {37 const type = block['@type'];38 if (type === 'BreadcrumbList') {39 for (const it of (block.itemListElement as Array<{ item?: { name?: string } }>) ?? []) if (it.item?.name) crumbs.push(it.item.name);40 }41 // Listings are published as an AggregateOffer whose `offers` array holds one Offer per watch.42 const offerList = type === 'AggregateOffer' ? ((block.offers as Array<Record<string, unknown>>) ?? []) : type === 'ItemList' ? (((block.itemListElement as Array<Record<string, unknown>>) ?? []).map((el) => (el.item as Record<string, unknown> | undefined) ?? el)) : [];43 if (offerList.length) {44 const defaultCurrency = String(block.priceCurrency ?? '');45 for (const off of offerList) {46 const t = off?.['@type'];47 if (t !== 'Offer' && t !== 'Product') continue;48 const priceRaw = off.price ?? (off.offers as { price?: unknown } | undefined)?.price;49 const price = Number(priceRaw);50 const currency = String(off.priceCurrency ?? (off.offers as { priceCurrency?: unknown } | undefined)?.priceCurrency ?? defaultCurrency);51 const href = String(off.url ?? '');52 if (!Number.isFinite(price) || price <= 0 || !href) continue;53 const img = off.image;54 const image = Array.isArray(img) ? ((img[0] as { contentUrl?: string; url?: string } | string) ?? null) : (img as string | null | undefined) ?? null;55 offers.push({ name: String(off.name ?? '').replace(/\s+/g, ' ').trim(), price, currency, url: href, image: typeof image === 'string' ? image : (image?.contentUrl ?? image?.url ?? null) });56 }57 }58 }59 const pathBrand = url.match(/chrono24\.com\/([a-z-]+)\//)?.[1] ?? '';60 const brand = BRAND_NAME[pathBrand] ?? crumbs[1]?.replace(/\s+watches?$/i, '') ?? pathBrand;61 const model = (crumbs[crumbs.length - 1] ?? '').replace(/\s+watches?$/i, '');62 const total = htmlText.match(/([\d,.]+)\s+(?:listings|watches)\b/)?.[1] ?? null;63 return { kind: 'model_page', url, brand, model, breadcrumb: crumbs, offers, totalListings: total ? Number(total.replace(/[,.]/g, '')) : null };64}6566export class Chrono24Connector extends BaseConnector {67 readonly version = '1.0.0';68 readonly parserVersion = PARSER_VERSION;69 protected override minIntervalMs = 2000;7071 async *crawl(ctx: CrawlContext): AsyncIterable<RawRecordInput> {72 const seeds = ctx.options.seeds?.length ? ctx.options.seeds : ((this.meta.config.seeds as string[] | undefined) ?? []);73 const pages = Number(this.meta.config.pagesPerSeed ?? 1);74 const pageSize = Number(this.meta.config.pageSize ?? 120);75 let count = 0;76 for (const seed of seeds) {77 if (ctx.signal?.aborted) return;78 for (let page = 1; page <= pages; page++) {79 if (this.reached(ctx, count)) return;80 const path = page === 1 ? seed : seed.replace(/--mod(\d+)\.htm$/, `--mod$1-${page}.htm`);81 const url = `${path.startsWith('http') ? path : BASE + path}?pageSize=${pageSize}&showpage=${page}`;82 await this.throttle();83 const res = await ctx.fetch(url, {84 renderJs: false,85 country: 'us',86 responseType: 'text',87 expect: ['title', 'price', 'currency', 'images'],88 parse: (r) => {89 if (!r.html) return null;90 const p = parseModelPage(r.html, url);91 const first = p.offers[0];92 return { title: p.model || null, price: first?.price ?? null, currency: first?.currency ?? null, images: p.offers.filter((o) => o.image).map((o) => o.image) };93 },94 });95 if (!res.success || !res.html) {96 ctx.anomaly('page_fetch_failed', `${url}: ${res.error ?? res.httpStatus}`);97 break;98 }99 const payload = parseModelPage(res.html, url.split('?')[0]!);100 if (payload.offers.length === 0) {101 ctx.anomaly('empty_page', url);102 break;103 }104 count++;105 yield { url: url.split('?')[0]!, externalId: `${seed}#${page}`, kind: 'listing', engine: res.engine, httpStatus: res.httpStatus, payload, fetchedAt: res.fetchedAt };106 }107 }108 }109110 async normalize(raw: RawRecordLike): Promise<NormalizedRecord[]> {111 const p = PagePayloadSchema.parse(raw.payload);112 const pathBrand = p.url.match(/chrono24\.com\/([a-z-]+)\//)?.[1] ?? '';113 const categorySlug = BRAND_CATEGORY[pathBrand] ?? 'other_watches';114 const out: NormalizedListing[] = [];115 const seen = new Set<string>();116 for (const o of p.offers) {117 const id = o.url.match(/--id(\d+)\.htm/)?.[1] ?? o.url;118 if (seen.has(id)) continue;119 seen.add(id);120 const cur = CurrencySchema.safeParse(o.currency);121 if (!cur.success) continue;122 // First reference-looking token that is not a plain year (e.g. "UNWORN 2024 126500LN" → 126500LN).123 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;124 const year = extractYear(o.name);125 const lower = o.name.toLowerCase();126 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;127 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;128 const attributes: AssetAttributes = {129 categorySlug,130 subcategorySlug: null,131 franchise: null,132 brand: p.brand,133 series: null,134 set: null,135 setCode: null,136 name: `${p.brand} ${p.model}`.trim(),137 model: p.model,138 reference: ref,139 number: null,140 year,141 edition: null,142 variant: null,143 language: null,144 region: null,145 country: null,146 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,147 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,148 color: null,149 rarity: null,150 productionQuantity: null,151 originalMsrp: null,152 originalMsrpCurrency: null,153 identifiers: { ...(ref ? { reference: ref } : {}), chrono24_id: id },154 metadata: { model_page: p.url },155 };156 out.push({157 kind: 'listing',158 connectorId: this.meta.id,159 sourceId: this.meta.sourceId,160 sourceUrl: o.url,161 externalId: id,162 rawTitle: o.name,163 description: null,164 imageUrls: o.image ? [o.image] : [],165 attributes,166 grade: { grader: null, grade: null, qualifier: null, certificationNumber: null },167 condition: { condition: normalizeCondition(categorySlug, conditionRaw), conditionRaw, completeness },168 observedAt: raw.fetchedAt,169 confidence: 0.8,170 parserVersion: PARSER_VERSION,171 listingType: 'fixed_price',172 price: o.price,173 currency: cur.data,174 seller: null,175 sellerReputation: null,176 location: null,177 shippingCost: null,178 quantity: 1,179 listedAt: null,180 endsAt: null,181 availability: 'available',182 bidCount: null,183 });184 }185 return out;186 }187}188189export default (meta: ConnectorMeta) => new Chrono24Connector(meta);190