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%
12.1 KB · 211 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 { 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(/&amp;/g, '&') : `${SITE}${href.replace(/&amp;/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