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%
6.5 KB · 133 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 { AssetAttributesSchema, NormalizedListingSchema, type NormalizedRecord } from '@rareindex/shared';4import { caseSize, watchCategory, watchMaterial } from '../../api/_luxury-lib/index.js';56/** Watchfinder & Co. — product cards on model pages (GBP asks, box/papers, year). */7const BASE = 'https://www.watchfinder.co.uk';8const PARSER_VERSION = '1.0.0';910export const CardSchema = z.object({ sku: z.string(), brand: z.string(), series: z.string().nullable(), model: z.string().nullable(), url: z.string(), image: z.string().nullable(), name: z.string(), box: z.boolean().nullable(), papers: z.boolean().nullable(), year: z.number().nullable(), price: z.number(), currency: z.string() });11export const PagePayloadSchema = z.object({ kind: z.literal('model_page'), url: z.string(), seed: z.string(), page: z.number(), total: z.number().nullable(), cards: z.array(CardSchema) });12export type PagePayload = z.infer<typeof PagePayloadSchema>;1314export function parseModelPage(htmlText: string, url: string, seed: string, page: number): PagePayload {15  const $ = H.load(htmlText);16  const cards: z.infer<typeof CardSchema>[] = [];17  $('a.product-card').each((_, el) => {18    const $el = $(el);19    const sku = $el.attr('data-product-sku');20    const brand = $el.attr('data-product-brand');21    const href = $el.attr('href');22    const priceAttr = $el.find('[data-price-amount]').first().attr('data-price-amount');23    const price = Number(priceAttr);24    if (!sku || !brand || !href || !Number.isFinite(price) || price <= 0) return;25    const priceText = $el.find('.price').first().text();26    const currency = /£/.test(priceText) ? 'GBP' : /€/.test(priceText) ? 'EUR' : /\$/.test(priceText) ? 'USD' : 'GBP';27    const spec = (label: string) => {28      const item = $el.find('.product-card__specs__box-papers__item').filter((__, e) => $(e).text().trim().startsWith(label)).first();29      if (!item.length) return null;30      return item.find('.icon-yes').length > 0 ? true : item.find('.icon-no').length > 0 ? false : null;31    };32    const yearText = $el.find('.product-card__specs__year-location__item__value').first().text().trim();33    cards.push({34      sku,35      brand,36      series: $el.attr('data-product-series') ?? null,37      model: $el.attr('data-product-model') ?? null,38      url: href.startsWith('http') ? href : `${BASE}${href}`,39      image: $el.attr('data-product-image') ?? null,40      name: $el.find('meta[itemprop="name"]').attr('content') ?? `${brand} ${$el.attr('data-product-series') ?? ''} ${$el.attr('data-product-model') ?? ''}`.trim(),41      box: spec('Box'),42      papers: spec('Papers'),43      year: /^\d{4}$/.test(yearText) ? Number(yearText) : null,44      price,45      currency,46    });47  });48  const total = $('meta[itemprop="numberOfItems"]').attr('content');49  return { kind: 'model_page', url, seed, page, total: total ? Number(total) : null, cards };50}5152export class WatchfinderConnector extends BaseConnector {53  readonly version = '1.0.0';54  readonly parserVersion = PARSER_VERSION;55  protected override minIntervalMs = 2000;5657  async *crawl(ctx: CrawlContext): AsyncIterable<RawRecordInput> {58    const seeds = (ctx.options.seeds?.length ? ctx.options.seeds : (this.meta.config.seeds as string[] | undefined)) ?? [];59    const pages = ctx.options.mode === 'backfill' ? 8 : Number(this.meta.config.pagesPerSeed ?? 2);60    let count = 0;61    for (const seed of seeds) {62      for (let page = 1; page <= pages; page++) {63        if (ctx.signal?.aborted || this.reached(ctx, count)) return;64        const url = `${BASE}/watches/${seed}${page > 1 ? `?p=${page}` : ''}`;65        await this.throttle();66        const res = await ctx.fetch(url, { expect: ['title', 'price', 'identifiers'], parse: (r) => {67          const first = r.html ? parseModelPage(r.html, url, seed, page).cards[0] : undefined;68          return first ? { title: first.name, price: first.price, identifiers: { sku: first.sku } } : null;69        } });70        const payload = res.success && res.html ? parseModelPage(res.html, url, seed, page) : null;71        if (!payload || payload.cards.length === 0) {72          if (page === 1) ctx.anomaly(payload ? 'empty_page' : 'page_fetch_failed', `${url}: ${res.error ?? res.httpStatus}`);73          break;74        }75        count++;76        yield { url, externalId: `model:${seed}:${page}`, kind: 'listing', engine: res.engine, httpStatus: res.httpStatus, payload, fetchedAt: res.fetchedAt };77        if (payload.total !== null && page * 24 >= payload.total) break;78      }79    }80  }8182  async normalize(raw: RawRecordLike): Promise<NormalizedRecord[]> {83    const p = PagePayloadSchema.parse(raw.payload);84    const out: NormalizedRecord[] = [];85    const seen = new Set<string>();86    for (const c of p.cards) {87      if (seen.has(c.sku)) continue;88      seen.add(c.sku);89      const categorySlug = watchCategory(c.brand);90      const completeness = c.box && c.papers ? 'full_set' : c.papers ? 'papers_only' : c.box ? 'box_only' : c.box === false && c.papers === false ? 'watch_only' : null;91      const attributes = AssetAttributesSchema.parse({92        categorySlug,93        brand: c.brand,94        name: `${c.brand} ${c.series ?? ''}`.trim(),95        model: c.series,96        reference: c.model,97        year: c.year,98        material: watchMaterial(c.name),99        size: caseSize(c.name),100        identifiers: { ...(c.model ? { reference: c.model } : {}), watchfinder_sku: c.sku },101        metadata: { model_page: p.url },102      });103      out.push(104        NormalizedListingSchema.parse({105          kind: 'listing',106          connectorId: this.meta.id,107          sourceId: this.meta.sourceId,108          sourceUrl: c.url,109          externalId: c.sku,110          rawTitle: c.name,111          imageUrls: c.image ? [c.image.replace(/&amp;/g, '&')] : [],112          attributes,113          condition: { condition: null, conditionRaw: 'Pre-owned (Watchfinder inspected)', completeness },114          observedAt: raw.fetchedAt,115          confidence: 0.85,116          parserVersion: PARSER_VERSION,117          listingType: 'fixed_price',118          price: c.price,119          currency: c.currency === 'EUR' ? 'EUR' : c.currency === 'USD' ? 'USD' : 'GBP',120          seller: 'Watchfinder & Co.',121          location: 'GB',122          availability: 'available',123        }),124      );125    }126    return out;127  }128}129130export default function createConnector(meta: ConnectorMeta) {131  return new WatchfinderConnector(meta);132}133