import { z } from 'zod'; import { BaseConnector, html as H, type ConnectorMeta, type CrawlContext, type RawRecordInput, type RawRecordLike } from '@rareindex/connectors'; import { AssetAttributesSchema, NormalizedListingSchema, type NormalizedRecord } from '@rareindex/shared'; import { caseSize, watchCategory, watchMaterial } from '../../api/_luxury-lib/index.js'; /** Watchfinder & Co. — product cards on model pages (GBP asks, box/papers, year). */ const BASE = 'https://www.watchfinder.co.uk'; const PARSER_VERSION = '1.0.0'; export 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() }); export 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) }); export type PagePayload = z.infer; export function parseModelPage(htmlText: string, url: string, seed: string, page: number): PagePayload { const $ = H.load(htmlText); const cards: z.infer[] = []; $('a.product-card').each((_, el) => { const $el = $(el); const sku = $el.attr('data-product-sku'); const brand = $el.attr('data-product-brand'); const href = $el.attr('href'); const priceAttr = $el.find('[data-price-amount]').first().attr('data-price-amount'); const price = Number(priceAttr); if (!sku || !brand || !href || !Number.isFinite(price) || price <= 0) return; const priceText = $el.find('.price').first().text(); const currency = /£/.test(priceText) ? 'GBP' : /€/.test(priceText) ? 'EUR' : /\$/.test(priceText) ? 'USD' : 'GBP'; const spec = (label: string) => { const item = $el.find('.product-card__specs__box-papers__item').filter((__, e) => $(e).text().trim().startsWith(label)).first(); if (!item.length) return null; return item.find('.icon-yes').length > 0 ? true : item.find('.icon-no').length > 0 ? false : null; }; const yearText = $el.find('.product-card__specs__year-location__item__value').first().text().trim(); cards.push({ sku, brand, series: $el.attr('data-product-series') ?? null, model: $el.attr('data-product-model') ?? null, url: href.startsWith('http') ? href : `${BASE}${href}`, image: $el.attr('data-product-image') ?? null, name: $el.find('meta[itemprop="name"]').attr('content') ?? `${brand} ${$el.attr('data-product-series') ?? ''} ${$el.attr('data-product-model') ?? ''}`.trim(), box: spec('Box'), papers: spec('Papers'), year: /^\d{4}$/.test(yearText) ? Number(yearText) : null, price, currency, }); }); const total = $('meta[itemprop="numberOfItems"]').attr('content'); return { kind: 'model_page', url, seed, page, total: total ? Number(total) : null, cards }; } export class WatchfinderConnector extends BaseConnector { readonly version = '1.0.0'; readonly parserVersion = PARSER_VERSION; protected override minIntervalMs = 2000; async *crawl(ctx: CrawlContext): AsyncIterable { const seeds = (ctx.options.seeds?.length ? ctx.options.seeds : (this.meta.config.seeds as string[] | undefined)) ?? []; const pages = ctx.options.mode === 'backfill' ? 8 : Number(this.meta.config.pagesPerSeed ?? 2); let count = 0; for (const seed of seeds) { for (let page = 1; page <= pages; page++) { if (ctx.signal?.aborted || this.reached(ctx, count)) return; const url = `${BASE}/watches/${seed}${page > 1 ? `?p=${page}` : ''}`; await this.throttle(); const res = await ctx.fetch(url, { expect: ['title', 'price', 'identifiers'], parse: (r) => { const first = r.html ? parseModelPage(r.html, url, seed, page).cards[0] : undefined; return first ? { title: first.name, price: first.price, identifiers: { sku: first.sku } } : null; } }); const payload = res.success && res.html ? parseModelPage(res.html, url, seed, page) : null; if (!payload || payload.cards.length === 0) { if (page === 1) ctx.anomaly(payload ? 'empty_page' : 'page_fetch_failed', `${url}: ${res.error ?? res.httpStatus}`); break; } count++; yield { url, externalId: `model:${seed}:${page}`, kind: 'listing', engine: res.engine, httpStatus: res.httpStatus, payload, fetchedAt: res.fetchedAt }; if (payload.total !== null && page * 24 >= payload.total) break; } } } async normalize(raw: RawRecordLike): Promise { const p = PagePayloadSchema.parse(raw.payload); const out: NormalizedRecord[] = []; const seen = new Set(); for (const c of p.cards) { if (seen.has(c.sku)) continue; seen.add(c.sku); const categorySlug = watchCategory(c.brand); 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; const attributes = AssetAttributesSchema.parse({ categorySlug, brand: c.brand, name: `${c.brand} ${c.series ?? ''}`.trim(), model: c.series, reference: c.model, year: c.year, material: watchMaterial(c.name), size: caseSize(c.name), identifiers: { ...(c.model ? { reference: c.model } : {}), watchfinder_sku: c.sku }, metadata: { model_page: p.url }, }); out.push( NormalizedListingSchema.parse({ kind: 'listing', connectorId: this.meta.id, sourceId: this.meta.sourceId, sourceUrl: c.url, externalId: c.sku, rawTitle: c.name, imageUrls: c.image ? [c.image.replace(/&/g, '&')] : [], attributes, condition: { condition: null, conditionRaw: 'Pre-owned (Watchfinder inspected)', completeness }, observedAt: raw.fetchedAt, confidence: 0.85, parserVersion: PARSER_VERSION, listingType: 'fixed_price', price: c.price, currency: c.currency === 'EUR' ? 'EUR' : c.currency === 'USD' ? 'USD' : 'GBP', seller: 'Watchfinder & Co.', location: 'GB', availability: 'available', }), ); } return out; } } export default function createConnector(meta: ConnectorMeta) { return new WatchfinderConnector(meta); }