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, parsePrice, type NormalizedRecord } from '@rareindex/shared'; import { watchFromTitle } from '../_g9-asia-watch-sneaker-lib/index.js'; /** * European Watch Company (Boston dealer) — the server-rendered inventory grid `/all` lists every watch in stock * (brand + model title, USD asking price, status badge, image, product URL). Product pages carry a schema.org * Product (sku, mpn = reference, price, availability, condition) used for URL lookup. Asking prices → `listing`. */ const SITE = 'https://www.europeanwatch.com'; const PARSER_VERSION = '1.0.0'; export const CardSchema = z.object({ id: z.string(), href: z.string(), title: z.string(), price: z.number().nullable(), currency: z.string().nullable(), badge: z.string().nullable(), image: z.string().nullable() }); export type Card = z.infer; export const ProductSchema = z.object({ id: z.string(), href: z.string(), name: z.string(), sku: z.string().nullable(), mpn: z.string().nullable(), brand: z.string().nullable(), price: z.number().nullable(), currency: z.string().nullable(), availability: z.string().nullable(), condition: z.string().nullable(), description: z.string().nullable(), images: z.array(z.string()) }); export type Product = z.infer; export const PagePayloadSchema = z.discriminatedUnion('kind', [ z.object({ kind: z.literal('inventory_page'), url: z.string(), cards: z.array(CardSchema) }), z.object({ kind: z.literal('product_page'), url: z.string(), product: ProductSchema }), ]); export type PagePayload = z.infer; export type InventoryPagePayload = Extract; function decodeNextImage(src: string | undefined): string | null { if (!src) return null; const m = src.match(/[?&]url=([^&]+)/); if (m) { try { return decodeURIComponent(m[1]!); } catch { return null; } } return src.startsWith('http') ? src : null; } /** Inventory grid: each desktop card is a
holding exactly one

(title) and one

(price); the link sits in the sibling image block. */ export function parseInventoryPage(htmlText: string, url: string): InventoryPagePayload { const $ = H.load(htmlText); const cards: Card[] = []; const seen = new Set(); $('div').each((_, d) => { const $d = $(d); if ($d.children('h3').length !== 1 || $d.children('p').length !== 1) return; const card = $d.parent(); const href = card.find('a[href^="/watch/"]').first().attr('href') ?? null; if (!href) return; const id = href.match(/-(\d+)\/?$/)?.[1] ?? href.replace(/^\/watch\//, ''); if (seen.has(id)) return; const title = H.text($d.children('h3').first()); const priceText = H.text($d.children('p').first()); if (!title) return; const price = parsePrice(priceText ?? '', 'USD'); const badge = H.text($d.children('div').first()); const img = card.find('img').first(); seen.add(id); cards.push({ id, href: `${SITE}${href}`, title, price: price && price.amount > 0 ? price.amount : null, currency: price?.currency ?? null, badge, image: decodeNextImage(img.attr('src') ?? img.attr('srcset')?.split(' ')[0]) }); }); return { kind: 'inventory_page', url, cards }; } export function parseProductPage(htmlText: string, url: string): PagePayload | null { const prod = H.jsonLd(htmlText, 'Product')[0]; if (!prod) return null; const offers = (Array.isArray(prod.offers) ? prod.offers[0] : prod.offers) as Record | undefined; const brand = prod.brand && typeof prod.brand === 'object' ? String((prod.brand as { name?: string }).name ?? '') : prod.brand ? String(prod.brand) : null; const img = prod.image; const id = url.match(/-(\d+)\/?$/)?.[1] ?? String(prod.sku ?? ''); const price = offers?.price !== undefined ? Number(offers.price) : NaN; return { kind: 'product_page', url, product: { id, href: url, name: String(prod.name ?? '').replace(/\s+/g, ' ').trim(), sku: prod.sku ? String(prod.sku) : null, mpn: prod.mpn ? String(prod.mpn) : null, brand: brand || null, price: Number.isFinite(price) && price > 0 ? price : null, currency: offers?.priceCurrency ? String(offers.priceCurrency) : null, availability: offers?.availability ? String(offers.availability).replace(/^https?:\/\/schema\.org\//, '') : null, condition: offers?.itemCondition ? String(offers.itemCondition).replace(/^https?:\/\/schema\.org\//, '') : null, description: prod.description ? String(prod.description).slice(0, 1200) : null, images: Array.isArray(img) ? img.slice(0, 4).map(String) : typeof img === 'string' ? [img] : [], }, }; } export class EuropeanWatchCompanyConnector extends BaseConnector { readonly version = '1.0.0'; readonly parserVersion = PARSER_VERSION; protected override minIntervalMs = 3000; override readonly urlPatterns = [/^https?:\/\/(?:www\.)?europeanwatch\.com\/watch\/([a-z0-9-]+)/i]; async *crawl(ctx: CrawlContext): AsyncIterable { const pages = (this.meta.config.pages as string[] | undefined) ?? ['/all']; let count = 0; for (const path of pages) { if (ctx.signal?.aborted || this.reached(ctx, count)) return; const url = `${SITE}${path}`; await this.throttle(url); const res = await ctx.fetch(url, { engines: ['api', 'firecrawl'], responseType: 'text', timeoutMs: 90_000, expect: ['title', 'price', 'currency'], parse: (r) => { const p = r.html ? parseInventoryPage(r.html, url) : null; const c = p?.cards.find((x) => x.price); return c ? { title: c.title, price: c.price, currency: c.currency } : null; } }); if (!res.success || !res.html) { ctx.anomaly('page_fetch_failed', `${url}: ${res.error ?? res.httpStatus}`); continue; } const payload = parseInventoryPage(res.html, url); if (!payload.cards.length) { ctx.anomaly('selector_missing', `${url}: no inventory cards parsed`); continue; } count++; yield { url, externalId: `inventory:${path}`, kind: 'listing', engine: res.engine, httpStatus: res.httpStatus, payload, fetchedAt: res.fetchedAt }; await ctx.setCursor({ page: path, at: new Date().toISOString() }); } await ctx.setCursor({ done: true, at: new Date().toISOString() }); } async lookup(url: string, ctx: CrawlContext): Promise { const slug = url.match(this.urlPatterns[0]!)?.[1]; if (!slug) return []; const target = `${SITE}/watch/${slug}`; await this.throttle(target); const res = await ctx.fetch(target, { engines: ['api', 'firecrawl'], responseType: 'text', minQuality: 0.2 }); const payload = res.success && res.html ? parseProductPage(res.html, target) : null; if (!payload) return []; return [{ url: target, externalId: `product:${slug}`, 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[] = []; if (p.kind === 'product_page') { const pr = p.product; const w = watchFromTitle(pr.name, pr.brand); const reference = pr.mpn ?? w.reference; const conditionRaw = pr.condition === 'NewCondition' ? 'Unworn' : w.conditionRaw ?? (pr.condition ? 'Pre-owned' : null); out.push( NormalizedListingSchema.parse({ kind: 'listing', connectorId: this.meta.id, sourceId: this.meta.sourceId, sourceUrl: pr.href, externalId: pr.id, rawTitle: pr.name, description: pr.description, imageUrls: pr.images, attributes: AssetAttributesSchema.parse({ categorySlug: w.categorySlug, brand: w.brand, name: pr.name, reference, year: w.year, material: w.material, size: w.size, identifiers: { ewc_sku: pr.sku ?? pr.id, ...(reference ? { reference } : {}) }, metadata: { availability_raw: pr.availability, condition_raw: pr.condition } }), grade: { grader: null, grade: null, qualifier: null, certificationNumber: null }, condition: { condition: normalizeCondition(w.categorySlug, conditionRaw), conditionRaw, completeness: w.completeness }, observedAt: raw.fetchedAt, confidence: 0.85, parserVersion: PARSER_VERSION, listingType: 'fixed_price', price: pr.price, currency: pr.currency === 'USD' ? 'USD' : pr.price ? 'USD' : null, seller: 'European Watch Company', location: 'Boston, MA, US', quantity: 1, availability: pr.availability === 'InStock' ? 'available' : pr.availability === 'OutOfStock' ? 'ended' : 'unknown', }), ); return out; } for (const c of p.cards) { const w = watchFromTitle(c.title); const pending = /sale pending/i.test(c.badge ?? ''); out.push( NormalizedListingSchema.parse({ kind: 'listing', connectorId: this.meta.id, sourceId: this.meta.sourceId, sourceUrl: c.href, externalId: c.id, rawTitle: c.title, description: null, imageUrls: c.image ? [c.image] : [], attributes: AssetAttributesSchema.parse({ categorySlug: w.categorySlug, brand: w.brand, name: c.title, reference: w.reference, year: w.year, material: w.material, size: w.size, identifiers: { ewc_sku: c.id, ...(w.reference ? { reference: w.reference } : {}) }, metadata: { badge: c.badge, sale_pending: pending } }), grade: { grader: null, grade: null, qualifier: null, certificationNumber: null }, condition: { condition: normalizeCondition(w.categorySlug, w.conditionRaw), conditionRaw: w.conditionRaw, completeness: w.completeness }, observedAt: raw.fetchedAt, confidence: 0.8, parserVersion: PARSER_VERSION, listingType: 'fixed_price', price: c.price, currency: c.price ? ((c.currency as 'USD' | null) ?? 'USD') : null, seller: 'European Watch Company', location: 'Boston, MA, US', quantity: 1, availability: 'available', }), ); } return out; } } export default function createConnector(meta: ConnectorMeta) { return new EuropeanWatchCompanyConnector(meta); }