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 { 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(/&/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