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 { AssetAttributesSchema, CurrencySchema, NormalizedListingSchema, parsePrice, type NormalizedRecord } from '@rareindex/shared';5import { watchCategory, watchMaterial, caseSize } from '../_luxury-lib/index.js';6import { extractYear } from '@rareindex/shared';78/**9 * CHRONEXT (Cologne, DE) — certified pre-owned/new watch retailer. Category pages (`/rolex/submariner` …) are10 * server-rendered with one `.product-tile` per watch (brand, model, reference, price "USD 4,380", condition label,11 * image, product URL `/brand/model/reference/V<id>`); pagination links carry a stream id + `offset`. Product pages12 * expose a schema.org Product (sku, mpn, brand, offers) used for URL lookup. Asking prices → `listing`.13 */1415const SITE = 'https://www.chronext.com';16const PARSER_VERSION = '1.0.0';17const PAGE_SIZE = 24;1819export const TileSchema = z.object({ id: z.string(), href: z.string(), brand: z.string().nullable(), model: z.string().nullable(), reference: z.string().nullable(), priceText: z.string().nullable(), condition: z.string().nullable(), image: z.string().nullable() });20export type Tile = z.infer<typeof TileSchema>;21export const ProductSchema = z.object({ id: z.string(), href: z.string(), name: z.string(), sku: z.string().nullable(), mpn: z.string().nullable(), brand: z.string().nullable(), price: z.number().nullable(), currency: z.string().nullable(), availability: z.string().nullable(), condition: z.string().nullable(), image: z.string().nullable() });22export const PagePayloadSchema = z.discriminatedUnion('kind', [23 z.object({ kind: z.literal('category_page'), url: z.string(), seed: z.string(), page: z.number(), tiles: z.array(TileSchema), nextUrl: z.string().nullable(), title: z.string().nullable() }),24 z.object({ kind: z.literal('product_page'), url: z.string(), product: ProductSchema }),25]);26export type PagePayload = z.infer<typeof PagePayloadSchema>;27export type CategoryPagePayload = Extract<PagePayload, { kind: 'category_page' }>;2829export function parseCategoryPage(htmlText: string, url: string, seed: string, page: number): CategoryPagePayload {30 const $ = H.load(htmlText);31 const tiles: Tile[] = [];32 const seen = new Set<string>();33 $('.product-tile').each((_, el) => {34 const $t = $(el);35 const href = $t.find('a[href]').first().attr('href') ?? null;36 const id = href?.match(/\/(V\d+)\/?$/)?.[1] ?? null;37 if (!href || !id || seen.has(id)) return;38 seen.add(id);39 tiles.push({40 id,41 href: href.startsWith('http') ? href : `${SITE}${href}`,42 brand: H.text($t.find('.product-tile__brand').first()),43 model: H.text($t.find('.product-tile__model').first()),44 reference: H.text($t.find('.product-tile__reference').first()),45 priceText: H.text($t.find('.product-tile__price .price').first()) ?? H.text($t.find('.price').first()),46 condition: H.text($t.find('.condition-with-icon__text').first()),47 image: $t.find('img').first().attr('src') ?? null,48 });49 });50 // Pagination links look like "<seed>?s[<stream>][offset]=24&nodeId=…"; the next page is the one whose offset = page × 24.51 let nextUrl: string | null = null;52 $('a[href*="offset"]').each((_, a) => {53 const href = $(a).attr('href') ?? '';54 const off = decodeURIComponent(href).match(/\[offset\]=(\d+)/)?.[1];55 if (off && Number(off) === page * PAGE_SIZE) nextUrl = href.startsWith('http') ? href.replace(/&/g, '&') : `${SITE}${href.replace(/&/g, '&')}`;56 });57 const title = H.text($('h1').first());58 return { kind: 'category_page', url, seed, page, tiles, nextUrl, title };59}6061export function parseProductPage(htmlText: string, url: string): PagePayload | null {62 const prod = H.jsonLd(htmlText, 'Product')[0];63 if (!prod) return null;64 const offers = (Array.isArray(prod.offers) ? prod.offers[0] : prod.offers) as Record<string, unknown> | undefined;65 const brand = prod.brand && typeof prod.brand === 'object' ? String((prod.brand as { name?: string }).name ?? '') : prod.brand ? String(prod.brand) : null;66 const price = offers?.price !== undefined ? Number(offers.price) : NaN;67 const id = url.match(/\/(V\d+)\/?$/)?.[1] ?? String(prod.sku ?? '');68 return {69 kind: 'product_page',70 url,71 product: { id, href: url, name: String(prod.name ?? '').replace(/\s+/g, ' ').trim(), sku: prod.sku ? String(prod.sku) : null, mpn: prod.mpn ? String(prod.mpn) : null, brand: brand || null, price: Number.isFinite(price) && price > 0 ? price : null, currency: offers?.priceCurrency ? String(offers.priceCurrency) : null, availability: offers?.availability ? String(offers.availability).replace(/^https?:\/\/schema\.org\//, '') : null, condition: offers?.itemCondition ? String(offers.itemCondition).replace(/^https?:\/\/schema\.org\//, '') : null, image: typeof prod.image === 'string' ? prod.image : Array.isArray(prod.image) ? String(prod.image[0] ?? '') || null : null },72 };73}7475function conditionFromLabel(label: string | null): string | null {76 if (!label) return null;77 const l = label.toLowerCase();78 if (/like new|mint|excellent/.test(l)) return 'Excellent';79 if (/unworn|\bnew\b/.test(l)) return 'Unworn';80 if (/very good/.test(l)) return 'Very good';81 if (/good/.test(l)) return 'Good';82 if (/fair|vintage/.test(l)) return 'Fair';83 return label;84}8586export class ChronextConnector extends BaseConnector {87 readonly version = '1.0.0';88 readonly parserVersion = PARSER_VERSION;89 protected override minIntervalMs = 4000;90 override readonly urlPatterns = [/^https?:\/\/(?:www\.)?chronext\.com\/([a-z0-9-]+\/[a-z0-9-]+\/[a-z0-9.-]+\/V\d+)/i];9192 async *crawl(ctx: CrawlContext): AsyncIterable<RawRecordInput> {93 const seeds = ctx.options.seeds?.length ? ctx.options.seeds : ((this.meta.config.seeds as string[] | undefined) ?? []);94 const pages = ctx.options.mode === 'backfill' ? this.policy.backfillMaxPages : Number(this.meta.config.pagesPerSeed ?? 1);95 const cur = (ctx.options.cursor ?? {}) as { seedIndex?: number };96 let count = 0;97 for (let si = cur.seedIndex ?? 0; si < seeds.length; si++) {98 const seed = seeds[si]!;99 let url: string | null = `${SITE}${seed.startsWith('/') ? seed : `/${seed}`}`;100 for (let page = 1; page <= pages && url; page++) {101 if (ctx.signal?.aborted || this.reached(ctx, count)) return;102 const pageUrl: string = url;103 await this.throttle(pageUrl);104 const res = await ctx.fetch(pageUrl, { engines: ['api', 'firecrawl'], responseType: 'text', timeoutMs: 90_000, headers: { accept: 'text/html,application/xhtml+xml' }, expect: ['title', 'price', 'identifiers'], parse: (r) => {105 const p = r.html ? parseCategoryPage(r.html, pageUrl, seed, page) : null;106 const t = p?.tiles[0];107 return t ? { title: `${t.brand} ${t.model}`, price: t.priceText, identifiers: t.reference ? { reference: t.reference } : null } : null;108 } });109 if (!res.success || !res.html) {110 ctx.anomaly('page_fetch_failed', `${pageUrl}: ${res.error ?? res.httpStatus}`);111 break;112 }113 const payload = parseCategoryPage(res.html, pageUrl, seed, page);114 if (!payload.tiles.length) {115 if (page === 1) ctx.anomaly('selector_missing', `${pageUrl}: no product tiles`);116 break;117 }118 count++;119 yield { url: pageUrl, externalId: `${seed}#${page}`, kind: 'listing', engine: res.engine, httpStatus: res.httpStatus, payload, fetchedAt: res.fetchedAt };120 await ctx.progress({ page, itemsProcessed: count });121 url = payload.nextUrl;122 }123 await ctx.setCursor({ seedIndex: si + 1, at: new Date().toISOString() });124 }125 await ctx.setCursor({ done: true, at: new Date().toISOString() });126 }127128 async lookup(url: string, ctx: CrawlContext): Promise<RawRecordInput[]> {129 const path = url.match(this.urlPatterns[0]!)?.[1];130 if (!path) return [];131 const target = `${SITE}/${path}`;132 await this.throttle(target);133 const res = await ctx.fetch(target, { engines: ['api', 'firecrawl'], responseType: 'text', headers: { accept: 'text/html,application/xhtml+xml' }, minQuality: 0.2 });134 const payload = res.success && res.html ? parseProductPage(res.html, target) : null;135 if (!payload) return [];136 return [{ url: target, externalId: `product:${path.split('/').pop()}`, kind: 'listing', engine: res.engine, httpStatus: res.httpStatus, payload, fetchedAt: res.fetchedAt }];137 }138139 async normalize(raw: RawRecordLike): Promise<NormalizedRecord[]> {140 const p = PagePayloadSchema.parse(raw.payload);141 const out: NormalizedRecord[] = [];142 if (p.kind === 'product_page') {143 const pr = p.product;144 const categorySlug = watchCategory(pr.brand);145 const conditionRaw = pr.condition === 'NewCondition' ? 'Unworn' : pr.condition === 'RefurbishedCondition' || pr.condition === 'UsedCondition' ? 'Pre-owned' : null;146 const cur = CurrencySchema.safeParse(pr.currency ?? '');147 out.push(148 NormalizedListingSchema.parse({149 kind: 'listing',150 connectorId: this.meta.id,151 sourceId: this.meta.sourceId,152 sourceUrl: pr.href,153 externalId: pr.id,154 rawTitle: pr.name,155 imageUrls: pr.image ? [pr.image] : [],156 attributes: AssetAttributesSchema.parse({ categorySlug, brand: pr.brand, name: pr.name, model: pr.name.replace(new RegExp(`^${(pr.brand ?? '').replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\s*`, 'i'), '') || null, reference: pr.mpn, year: extractYear(pr.name), material: watchMaterial(pr.name), size: caseSize(pr.name), identifiers: { chronext_id: pr.id, ...(pr.sku ? { chronext_sku: pr.sku } : {}), ...(pr.mpn ? { reference: pr.mpn } : {}) }, metadata: { availability_raw: pr.availability, condition_raw: pr.condition } }),157 grade: { grader: null, grade: null, qualifier: null, certificationNumber: null },158 condition: { condition: normalizeCondition(categorySlug, conditionRaw), conditionRaw, completeness: null },159 observedAt: raw.fetchedAt,160 confidence: 0.85,161 parserVersion: PARSER_VERSION,162 listingType: 'fixed_price',163 price: cur.success ? pr.price : null,164 currency: cur.success && pr.price ? cur.data : null,165 seller: 'CHRONEXT',166 location: 'DE',167 quantity: 1,168 availability: pr.availability === 'InStock' ? 'available' : pr.availability === 'OutOfStock' ? 'ended' : 'unknown',169 }),170 );171 return out;172 }173 for (const t of p.tiles) {174 const parsed = t.priceText ? parsePrice(t.priceText) : null;175 const cur = parsed?.currency ? CurrencySchema.safeParse(parsed.currency) : null;176 const categorySlug = watchCategory(t.brand);177 const name = [t.brand, t.model, t.reference].filter(Boolean).join(' ');178 const conditionRaw = conditionFromLabel(t.condition);179 out.push(180 NormalizedListingSchema.parse({181 kind: 'listing',182 connectorId: this.meta.id,183 sourceId: this.meta.sourceId,184 sourceUrl: t.href,185 externalId: t.id,186 rawTitle: name,187 imageUrls: t.image ? [t.image] : [],188 attributes: AssetAttributesSchema.parse({ categorySlug, brand: t.brand, name, model: t.model, reference: t.reference, identifiers: { chronext_id: t.id, ...(t.reference ? { reference: t.reference } : {}) }, metadata: { category_page: p.url.split('?')[0], condition_label: t.condition, price_text: t.priceText } }),189 grade: { grader: null, grade: null, qualifier: null, certificationNumber: null },190 condition: { condition: normalizeCondition(categorySlug, conditionRaw), conditionRaw, completeness: null },191 observedAt: raw.fetchedAt,192 confidence: 0.8,193 parserVersion: PARSER_VERSION,194 listingType: 'fixed_price',195 price: cur?.success && parsed ? parsed.amount : null,196 currency: cur?.success ? cur.data : null,197 seller: 'CHRONEXT',198 location: 'DE',199 quantity: 1,200 availability: 'available',201 }),202 );203 }204 return out;205 }206}207208export default function createConnector(meta: ConnectorMeta) {209 return new ChronextConnector(meta);210}211