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.6 KB · 129 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 { 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