import { z } from 'zod'; import { BaseConnector, html as H, type ConnectorMeta, type CrawlContext, type RawRecordInput, type RawRecordLike } from '@rareindex/connectors'; import { normalizeCondition } from '@rareindex/taxonomy'; import { AssetAttributesSchema, NormalizedListingSchema, type NormalizedRecord } from '@rareindex/shared'; import { caseSize, currencyOr, moneyNumber, watchCategory, watchCompleteness, watchConditionRaw, watchMaterial, watchReferenceFromText, yearFrom } from '../_luxury-lib/index.js'; /** Bob's Watches — schema.org Product JSON-LD on public model pages (pre-owned Rolex, USD). */ const BASE = 'https://www.bobswatches.com'; const PARSER_VERSION = '1.0.0'; export const ItemSchema = z.object({ name: z.string(), mpn: z.string().nullable(), sku: z.string().nullable(), url: z.string(), color: z.string().nullable(), price: z.number(), currency: z.string(), availability: z.string().nullable(), image: z.string().nullable(), condition: z.string().nullable() }); export const PagePayloadSchema = z.object({ kind: z.literal('model_page'), url: z.string(), seed: z.string(), items: z.array(ItemSchema) }); export type PagePayload = z.infer; export function parseModelPage(htmlText: string, url: string, seed: string): PagePayload { const items: z.infer[] = []; for (const block of H.jsonLd(htmlText, 'Product')) { const offers = (block.offers as Record | undefined) ?? {}; const price = moneyNumber(offers.price as string | number | undefined); if (!price) continue; const img = block.image; items.push({ name: String(block.name ?? '').replace(/\s+/g, ' ').trim(), mpn: block.mpn ? String(block.mpn) : null, sku: block.sku ? String(block.sku) : null, url: String(block.url ?? ''), color: block.color ? String(block.color) : null, price, currency: String(offers.priceCurrency ?? 'USD'), availability: offers.availability ? String(offers.availability).replace(/^.*\//, '') : null, image: Array.isArray(img) ? String(img[0] ?? '') || null : typeof img === 'string' ? img : null, condition: block.itemCondition ? String(block.itemCondition).replace(/^.*\//, '') : null, }); } return { kind: 'model_page', url, seed, items }; } export class BobsWatchesConnector extends BaseConnector { readonly version = '1.0.0'; readonly parserVersion = PARSER_VERSION; protected override minIntervalMs = 1500; override readonly urlPatterns = [/^https?:\/\/(www\.)?bobswatches\.com\/[a-z0-9-]+\.html$/i]; async *crawl(ctx: CrawlContext): AsyncIterable { const seeds = (ctx.options.seeds?.length ? ctx.options.seeds : (this.meta.config.seeds as string[] | undefined)) ?? []; let count = 0; for (const seed of seeds) { if (ctx.signal?.aborted || this.reached(ctx, count)) return; const url = `${BASE}/${seed}`; await this.throttle(); const res = await ctx.fetch(url, { responseType: 'text', expect: ['title', 'price', 'identifiers'], parse: (r) => { const first = r.html ? parseModelPage(r.html, url, seed).items[0] : undefined; return first ? { title: first.name, price: first.price, identifiers: first.mpn ? { mpn: first.mpn } : null } : null; } }); const payload = res.success && res.html ? parseModelPage(res.html, url, seed) : null; if (!payload || payload.items.length === 0) { ctx.anomaly(payload ? 'empty_page' : 'page_fetch_failed', `${url}: ${res.error ?? res.httpStatus}`); continue; } count++; yield { url, externalId: `model:${seed}`, kind: 'listing', engine: res.engine, httpStatus: res.httpStatus, payload, fetchedAt: res.fetchedAt }; } } async lookup(url: string, ctx: CrawlContext): Promise { await this.throttle(); const res = await ctx.fetch(url, { responseType: 'text', minQuality: 0.2 }); if (!res.success || !res.html) return []; const payload = parseModelPage(res.html, url, 'lookup'); return payload.items.length ? [{ url, externalId: `product:${payload.items[0]!.sku ?? url}`, kind: 'listing', engine: res.engine, httpStatus: res.httpStatus, payload, fetchedAt: res.fetchedAt }] : []; } async normalize(raw: RawRecordLike): Promise { const p = PagePayloadSchema.parse(raw.payload); const out: NormalizedRecord[] = []; const seen = new Set(); for (const it of p.items) { const id = it.sku ?? it.url; if (seen.has(id)) continue; seen.add(id); const brandMatch = it.name.match(/\b(Rolex|Omega|Patek Philippe|Audemars Piguet|Cartier|Tudor|Breitling|Panerai|IWC)\b/i); const brand = brandMatch ? brandMatch[1]! : 'Rolex'; const categorySlug = watchCategory(brand); const ref = it.mpn ?? watchReferenceFromText(it.name); const model = it.name.match(/Rolex\s+([A-Z][A-Za-z -]+?)(?:\s+Ref\b|\s+\d|\s+Black|\s+Blue|\s+White|\s+Green|$)/)?.[1]?.trim() ?? p.seed.replace(/^rolex-/, '').replace(/-/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase()); const conditionRaw = watchConditionRaw(it.name) ?? (it.condition === 'UsedCondition' ? 'Pre-owned' : it.condition === 'NewCondition' ? 'Unworn' : null); const attributes = AssetAttributesSchema.parse({ categorySlug, brand, name: `${brand} ${model}`.trim(), model, reference: ref, year: yearFrom(it.name), material: it.color ? (watchMaterial(it.color) ?? it.color.toLowerCase()) : watchMaterial(it.name), size: caseSize(it.name), identifiers: { ...(ref ? { reference: ref } : {}), bobs_sku: id }, metadata: { model_page: p.url, color: it.color }, }); out.push( NormalizedListingSchema.parse({ kind: 'listing', connectorId: this.meta.id, sourceId: this.meta.sourceId, sourceUrl: it.url, externalId: id, rawTitle: it.name, imageUrls: it.image ? [it.image] : [], attributes, condition: { condition: normalizeCondition(categorySlug, conditionRaw), conditionRaw, completeness: watchCompleteness(it.name) }, observedAt: raw.fetchedAt, confidence: 0.85, parserVersion: PARSER_VERSION, listingType: 'fixed_price', price: it.price, currency: currencyOr(it.currency, 'USD'), seller: "Bob's Watches", location: 'US', availability: it.availability === 'InStock' ? 'available' : it.availability === 'SoldOut' || it.availability === 'OutOfStock' ? 'sold' : 'unknown', }), ); } return out; } } export default function createConnector(meta: ConnectorMeta) { return new BobsWatchesConnector(meta); }