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 { normalizeCondition } from '@rareindex/taxonomy';4import { AssetAttributesSchema, NormalizedListingSchema, type NormalizedRecord } from '@rareindex/shared';5import { caseSize, currencyOr, moneyNumber, watchCategory, watchCompleteness, watchConditionRaw, watchMaterial, watchReferenceFromText, yearFrom } from '../_luxury-lib/index.js';67/** Bob's Watches — schema.org Product JSON-LD on public model pages (pre-owned Rolex, USD). */8const BASE = 'https://www.bobswatches.com';9const PARSER_VERSION = '1.0.0';1011export 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() });12export const PagePayloadSchema = z.object({ kind: z.literal('model_page'), url: z.string(), seed: z.string(), items: z.array(ItemSchema) });13export type PagePayload = z.infer<typeof PagePayloadSchema>;1415export function parseModelPage(htmlText: string, url: string, seed: string): PagePayload {16 const items: z.infer<typeof ItemSchema>[] = [];17 for (const block of H.jsonLd(htmlText, 'Product')) {18 const offers = (block.offers as Record<string, unknown> | undefined) ?? {};19 const price = moneyNumber(offers.price as string | number | undefined);20 if (!price) continue;21 const img = block.image;22 items.push({23 name: String(block.name ?? '').replace(/\s+/g, ' ').trim(),24 mpn: block.mpn ? String(block.mpn) : null,25 sku: block.sku ? String(block.sku) : null,26 url: String(block.url ?? ''),27 color: block.color ? String(block.color) : null,28 price,29 currency: String(offers.priceCurrency ?? 'USD'),30 availability: offers.availability ? String(offers.availability).replace(/^.*\//, '') : null,31 image: Array.isArray(img) ? String(img[0] ?? '') || null : typeof img === 'string' ? img : null,32 condition: block.itemCondition ? String(block.itemCondition).replace(/^.*\//, '') : null,33 });34 }35 return { kind: 'model_page', url, seed, items };36}3738export class BobsWatchesConnector extends BaseConnector {39 readonly version = '1.0.0';40 readonly parserVersion = PARSER_VERSION;41 protected override minIntervalMs = 1500;42 override readonly urlPatterns = [/^https?:\/\/(www\.)?bobswatches\.com\/[a-z0-9-]+\.html$/i];4344 async *crawl(ctx: CrawlContext): AsyncIterable<RawRecordInput> {45 const seeds = (ctx.options.seeds?.length ? ctx.options.seeds : (this.meta.config.seeds as string[] | undefined)) ?? [];46 let count = 0;47 for (const seed of seeds) {48 if (ctx.signal?.aborted || this.reached(ctx, count)) return;49 const url = `${BASE}/${seed}`;50 await this.throttle();51 const res = await ctx.fetch(url, { responseType: 'text', expect: ['title', 'price', 'identifiers'], parse: (r) => {52 const first = r.html ? parseModelPage(r.html, url, seed).items[0] : undefined;53 return first ? { title: first.name, price: first.price, identifiers: first.mpn ? { mpn: first.mpn } : null } : null;54 } });55 const payload = res.success && res.html ? parseModelPage(res.html, url, seed) : null;56 if (!payload || payload.items.length === 0) {57 ctx.anomaly(payload ? 'empty_page' : 'page_fetch_failed', `${url}: ${res.error ?? res.httpStatus}`);58 continue;59 }60 count++;61 yield { url, externalId: `model:${seed}`, kind: 'listing', engine: res.engine, httpStatus: res.httpStatus, payload, fetchedAt: res.fetchedAt };62 }63 }6465 async lookup(url: string, ctx: CrawlContext): Promise<RawRecordInput[]> {66 await this.throttle();67 const res = await ctx.fetch(url, { responseType: 'text', minQuality: 0.2 });68 if (!res.success || !res.html) return [];69 const payload = parseModelPage(res.html, url, 'lookup');70 return payload.items.length ? [{ url, externalId: `product:${payload.items[0]!.sku ?? url}`, kind: 'listing', engine: res.engine, httpStatus: res.httpStatus, payload, fetchedAt: res.fetchedAt }] : [];71 }7273 async normalize(raw: RawRecordLike): Promise<NormalizedRecord[]> {74 const p = PagePayloadSchema.parse(raw.payload);75 const out: NormalizedRecord[] = [];76 const seen = new Set<string>();77 for (const it of p.items) {78 const id = it.sku ?? it.url;79 if (seen.has(id)) continue;80 seen.add(id);81 const brandMatch = it.name.match(/\b(Rolex|Omega|Patek Philippe|Audemars Piguet|Cartier|Tudor|Breitling|Panerai|IWC)\b/i);82 const brand = brandMatch ? brandMatch[1]! : 'Rolex';83 const categorySlug = watchCategory(brand);84 const ref = it.mpn ?? watchReferenceFromText(it.name);85 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());86 const conditionRaw = watchConditionRaw(it.name) ?? (it.condition === 'UsedCondition' ? 'Pre-owned' : it.condition === 'NewCondition' ? 'Unworn' : null);87 const attributes = AssetAttributesSchema.parse({88 categorySlug,89 brand,90 name: `${brand} ${model}`.trim(),91 model,92 reference: ref,93 year: yearFrom(it.name),94 material: it.color ? (watchMaterial(it.color) ?? it.color.toLowerCase()) : watchMaterial(it.name),95 size: caseSize(it.name),96 identifiers: { ...(ref ? { reference: ref } : {}), bobs_sku: id },97 metadata: { model_page: p.url, color: it.color },98 });99 out.push(100 NormalizedListingSchema.parse({101 kind: 'listing',102 connectorId: this.meta.id,103 sourceId: this.meta.sourceId,104 sourceUrl: it.url,105 externalId: id,106 rawTitle: it.name,107 imageUrls: it.image ? [it.image] : [],108 attributes,109 condition: { condition: normalizeCondition(categorySlug, conditionRaw), conditionRaw, completeness: watchCompleteness(it.name) },110 observedAt: raw.fetchedAt,111 confidence: 0.85,112 parserVersion: PARSER_VERSION,113 listingType: 'fixed_price',114 price: it.price,115 currency: currencyOr(it.currency, 'USD'),116 seller: "Bob's Watches",117 location: 'US',118 availability: it.availability === 'InStock' ? 'available' : it.availability === 'SoldOut' || it.availability === 'OutOfStock' ? 'sold' : 'unknown',119 }),120 );121 }122 return out;123 }124}125126export default function createConnector(meta: ConnectorMeta) {127 return new BobsWatchesConnector(meta);128}129