SPB Git forge

spb/rareindex

Public
54commits 1branches 0releases
7.1 MBsize
maindefault branch
10 days agolast push
TypeScript 61.9% HTML 37.2% SQL 0.7%
9.7 KB · 190 lines typescript
Raw Blame History
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