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%
12.9 KB · 220 lines typescript
Raw Blame History
1import { z } from 'zod';2import { BaseConnector, type ConnectorMeta, type CrawlContext, type RawRecordInput, type RawRecordLike } from '@rareindex/connectors';3import { AssetAttributesSchema, NormalizedAuctionLotSchema, NormalizedSaleSchema, parseSourceDate, type NormalizedRecord } from '@rareindex/shared';4import { caseSize, currencyOr, moneyNumber, watchCategory, watchMaterial, watchReferenceFromText } from '../_luxury-lib/index.js';56/** Antiquorum — catalogue lots pages (RDFa Products + 'Sold: CCY amount', premium-inclusive). */7const BASE = 'https://catalog.antiquorum.swiss';8const PARSER_VERSION = '1.0.0';910export const AuctionSchema = z.object({ slug: z.string(), id: z.string().nullable(), title: z.string().nullable(), date: z.string().nullable(), location: z.string().nullable() });11export const LotSchema = z.object({12  lotNumber: z.string(),13  name: z.string(),14  url: z.string(),15  sku: z.string().nullable(),16  image: z.string().nullable(),17  brand: z.string().nullable(),18  model: z.string().nullable(),19  reference: z.string().nullable(),20  year: z.string().nullable(),21  material: z.string().nullable(),22  diameter: z.string().nullable(),23  description: z.string().nullable(),24  estimateLow: z.number().nullable(),25  estimateHigh: z.number().nullable(),26  estimateCurrency: z.string().nullable(),27  soldPrice: z.number().nullable(),28  soldCurrency: z.string().nullable(),29  accessories: z.string().nullable(),30});31export const PagePayloadSchema = z.object({ kind: z.literal('lots_page'), url: z.string(), auction: AuctionSchema, page: z.number(), lots: z.array(LotSchema) });32export type PagePayload = z.infer<typeof PagePayloadSchema>;3334const unesc = (s: string) => s.replace(/&amp;/g, '&').replace(/&#39;/g, "'").replace(/&quot;/g, '"').replace(/\s+/g, ' ').trim();3536/** Auctions listed on the catalogue home: slug (+ numeric id, date, title) per block. */37export function parseAuctionIndex(htmlText: string): z.infer<typeof AuctionSchema>[] {38  const out: z.infer<typeof AuctionSchema>[] = [];39  const seen = new Set<string>();40  const anchors = [...htmlText.matchAll(/href="\/en\/auctions\/([A-Za-z0-9_]+)\/lots"/g)];41  for (let i = 0; i < anchors.length; i++) {42    const slug = anchors[i]![1]!;43    if (seen.has(slug)) continue;44    seen.add(slug);45    const start = anchors[i]!.index!;46    const prevEnd = i > 0 ? anchors[i - 1]!.index! + anchors[i - 1]![0].length : 0;47    const end = i + 1 < anchors.length ? anchors[i + 1]!.index! : Math.min(htmlText.length, start + 4000);48    // title + date precede the lots link inside an auction card; the price-list link follows it49    const before = htmlText.slice(Math.max(prevEnd, start - 1500), start);50    const after = htmlText.slice(start, end);51    const dateRe = /([A-Z][a-z]{2,8} \d{1,2}(?:-\d{1,2})?,? 20\d\d)/g;52    const id = after.match(/\/en\/auctions\/(\d+)\/price-list/)?.[1] ?? before.match(/\/en\/auctions\/(\d+)\/price-list/)?.[1] ?? null;53    const beforeDates = [...before.matchAll(dateRe)].map((m) => m[1]!);54    const date = beforeDates[beforeDates.length - 1] ?? after.match(dateRe)?.[0] ?? null;55    const titles = [...before.matchAll(/<h[1-5][^>]*>\s*([^<]{4,90}?)\s*<\/h[1-5]>/g)].map((m) => m[1]!);56    const title = titles[titles.length - 1] ?? null;57    const loc = slug.match(/hong_kong|geneva|monaco|new_york|dubai/i)?.[0]?.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase()) ?? null;58    out.push({ slug, id, title: title ? unesc(title) : null, date, location: loc });59  }60  return out;61}6263export function parseLotsPage(htmlText: string, url: string, auction: z.infer<typeof AuctionSchema>, page: number): PagePayload {64  const lots: z.infer<typeof LotSchema>[] = [];65  const blocks = htmlText.split(/<h4>\s*LOT\s+/).slice(1);66  for (const b of blocks) {67    const lotNumber = b.match(/^(\d+[A-Z]?)/)?.[1];68    if (!lotNumber) continue;69    const name = b.match(/property="schema:name" content="([^"]*)"/)?.[1];70    const urlRel = b.match(/rel="schema:url" resource="([^"\s]+)/)?.[1] ?? b.match(/href="(\/en\/lots\/[^"]+)"/)?.[1];71    if (!name || !urlRel) continue;72    const spec = (label: string) => b.match(new RegExp(`<strong>${label}</strong>&emsp;([^<]{1,120})`))?.[1]?.trim() ?? null;73    const est = b.match(/N_lots_estimation'\s*>\s*([A-Z]{3})\s*([\d,]+)\s*-\s*([\d,]+)/);74    const sold = b.match(/Sold:\s*([A-Z]{3})\s*([\d,]+)/);75    lots.push({76      lotNumber,77      name: unesc(name),78      url: urlRel.startsWith('http') ? urlRel.trim() : `${BASE}${urlRel}`,79      sku: b.match(/property="schema:sku" content="([^"]*)"/)?.[1] ?? null,80      image: b.match(/rel="schema:image" resource="([^"]+)"/)?.[1] ?? null,81      brand: spec('Brand'),82      model: spec('Model'),83      reference: spec('Reference'),84      year: spec('Year'),85      material: spec('Material'),86      diameter: spec('Diameter'),87      description: b.match(/property="schema:description" content="([^"]*)"/)?.[1]?.slice(0, 500) ?? null,88      estimateLow: est ? moneyNumber(est[2]) : null,89      estimateHigh: est ? moneyNumber(est[3]) : null,90      estimateCurrency: est ? est[1]! : null,91      soldPrice: sold ? moneyNumber(sold[2]) : null,92      soldCurrency: sold ? sold[1]! : null,93      accessories: spec('Accessories')?.slice(0, 200) ?? null,94    });95  }96  return { kind: 'lots_page', url, auction, page, lots };97}9899export class AntiquorumConnector extends BaseConnector {100  readonly version = '1.0.0';101  readonly parserVersion = PARSER_VERSION;102  protected override minIntervalMs = 2000;103  override readonly urlPatterns = [/^https?:\/\/catalog\.antiquorum\.swiss\/en\/lots\/[a-z0-9-]+-lot-(\d+)-(\d+)/i];104105  async *crawl(ctx: CrawlContext): AsyncIterable<RawRecordInput> {106    const perRun = Number(this.meta.config.auctionsPerRun ?? 3);107    const maxPages = Number(this.meta.config.maxPagesPerAuction ?? 25);108    const done = new Set<string>(((ctx.options.cursor?.doneSlugs as string[] | undefined) ?? []));109    await this.throttle();110    const home = await ctx.fetch(`${BASE}/en`, { responseType: 'text', minQuality: 0.2 });111    if (!home.success || !home.html) {112      ctx.anomaly('page_fetch_failed', `home: ${home.error ?? home.httpStatus}`);113      return;114    }115    const auctions = parseAuctionIndex(home.html);116    const now = Date.now();117    // upcoming/current auctions first (they change), then past auctions not yet crawled (backfill).118    const withDate = auctions.map((a) => ({ a, t: a.date ? (parseSourceDate(a.date)?.getTime() ?? 0) : 0 }));119    const upcoming = withDate.filter((x) => x.t >= now - 3 * 86_400_000).map((x) => x.a);120    const past = withDate.filter((x) => x.t < now - 3 * 86_400_000 && !done.has(x.a.slug)).sort((x, y) => y.t - x.t).map((x) => x.a);121    const selected = ctx.options.seeds?.length ? auctions.filter((a) => ctx.options.seeds!.includes(a.slug)) : [...upcoming, ...past].slice(0, ctx.options.mode === 'backfill' ? perRun * 4 : perRun);122    let count = 0;123    for (const auction of selected) {124      for (let page = 1; page <= maxPages; page++) {125        if (ctx.signal?.aborted || this.reached(ctx, count)) return;126        const url = `${BASE}/en/auctions/${auction.slug}/lots${page > 1 ? `?page=${page}` : ''}`;127        await this.throttle();128        const res = await ctx.fetch(url, { responseType: 'text', expect: ['title', 'price', 'date'], parse: (r) => {129          const p = r.html ? parseLotsPage(r.html, url, auction, page) : null;130          const first = p?.lots[0];131          return first ? { title: first.name, price: first.soldPrice ?? first.estimateLow, date: auction.date } : null;132        } });133        const payload = res.success && res.html ? parseLotsPage(res.html, url, auction, page) : null;134        if (!payload || payload.lots.length === 0) {135          if (page === 1) ctx.anomaly(payload ? 'empty_page' : 'page_fetch_failed', `${url}: ${res.error ?? res.httpStatus}`);136          break;137        }138        count++;139        yield { url, externalId: `${auction.slug}:${page}`, kind: payload.lots.some((l) => l.soldPrice) ? 'sale' : 'auction_lot', engine: res.engine, httpStatus: res.httpStatus, payload, fetchedAt: res.fetchedAt };140        if (!(res.html ?? '').includes(`lots?page=${page + 1}`)) break;141      }142      const isPast = auction.date ? (parseSourceDate(auction.date)?.getTime() ?? 0) < now - 3 * 86_400_000 : false;143      if (isPast) {144        done.add(auction.slug);145        await ctx.setCursor({ doneSlugs: [...done].slice(-200) });146      }147    }148  }149150  async lookup(url: string, ctx: CrawlContext): Promise<RawRecordInput[]> {151    const m = url.match(this.urlPatterns[0]!);152    if (!m) return [];153    await this.throttle();154    const home = await ctx.fetch(`${BASE}/en`, { responseType: 'text', minQuality: 0.2 });155    const auction = home.html ? parseAuctionIndex(home.html).find((a) => a.id === m[1]) : undefined;156    if (!auction) return [];157    // lots pages hold 20 lots each; lot N sits on page ceil(N/20)158    const page = Math.max(1, Math.ceil(Number(m[2]) / 20));159    const pageUrl = `${BASE}/en/auctions/${auction.slug}/lots?page=${page}`;160    await this.throttle();161    const res = await ctx.fetch(pageUrl, { responseType: 'text', minQuality: 0.2 });162    if (!res.success || !res.html) return [];163    const payload = parseLotsPage(res.html, pageUrl, auction, page);164    payload.lots = payload.lots.filter((l) => l.url.split('?')[0] === url.split('?')[0]);165    return payload.lots.length ? [{ url: pageUrl, externalId: `${auction.slug}:${page}`, kind: 'sale', engine: res.engine, httpStatus: res.httpStatus, payload, fetchedAt: res.fetchedAt }] : [];166  }167168  async normalize(raw: RawRecordLike): Promise<NormalizedRecord[]> {169    const p = PagePayloadSchema.parse(raw.payload);170    const saleDate = p.auction.date ? parseSourceDate(p.auction.date.replace(/(\d{1,2})-\d{1,2},/, '$1,')) : null;171    const out: NormalizedRecord[] = [];172    for (const lot of p.lots) {173      const brandRaw = lot.brand ?? lot.name.split(',')[0] ?? null;174      const brand = brandRaw ? unesc(brandRaw).replace(/,\s*(switzerland|germany|france|japan|usa|england|u\.?s\.?a\.?)\s*$/i, '').replace(/\b([A-Z])([A-Z]+)\b/g, (_m, a: string, b: string) => a + b.toLowerCase()).trim() : null;175      const isJewelry = /jewel|necklace|bracelet|ring\b|earring|brooch|diamond/i.test(lot.name) && !/watch|wristwatch|chronograph/i.test(lot.name + (lot.description ?? ''));176      const categorySlug = isJewelry ? 'jewelry' : watchCategory(brand);177      const reference = lot.reference ?? watchReferenceFromText(lot.name);178      const yearMatch = lot.year?.match(/(19|20)\d{2}/)?.[0];179      const attributes = AssetAttributesSchema.parse({180        categorySlug,181        brand,182        name: `${brand ?? ''} ${lot.model ?? ''}`.trim() || lot.name,183        model: lot.model,184        reference,185        year: yearMatch ? Number(yearMatch) : null,186        material: lot.material ? (watchMaterial(lot.material) ?? lot.material.toLowerCase()) : watchMaterial(lot.name),187        size: lot.diameter ? caseSize(lot.diameter) : null,188        identifiers: { ...(reference ? { reference } : {}), antiquorum_lot: lot.sku ?? `${p.auction.id ?? p.auction.slug}-${lot.lotNumber}` },189        metadata: { auction: p.auction.title, auction_slug: p.auction.slug, location: p.auction.location, estimate: lot.estimateLow ? { low: lot.estimateLow, high: lot.estimateHigh, currency: lot.estimateCurrency } : null, accessories: lot.accessories },190      });191      const accessories = (lot.accessories ?? '').toLowerCase();192      const completeness = accessories ? (/box/.test(accessories) && /(certificate|papers|warranty|guarantee)/.test(accessories) ? 'full_set' : /(certificate|papers|warranty|guarantee)/.test(accessories) ? 'papers_only' : /box/.test(accessories) ? 'box_only' : null) : null;193      const base = {194        connectorId: this.meta.id,195        sourceId: this.meta.sourceId,196        sourceUrl: lot.url,197        externalId: lot.sku ?? `${p.auction.slug}-${lot.lotNumber}`,198        rawTitle: lot.name,199        description: lot.description,200        imageUrls: lot.image ? [lot.image] : [],201        attributes,202        condition: { condition: null, conditionRaw: null, completeness },203        observedAt: raw.fetchedAt,204        parserVersion: PARSER_VERSION,205      };206      if (lot.soldPrice && lot.soldCurrency && saleDate) {207        out.push(NormalizedSaleSchema.parse({ kind: 'sale', ...base, confidence: 0.92, saleType: 'auction', saleDate, price: lot.soldPrice, currency: currencyOr(lot.soldCurrency, 'CHF'), buyerPremiumIncluded: true, auctionHouse: 'Antiquorum', lotNumber: lot.lotNumber, location: p.auction.location }));208      } else if (!lot.soldPrice) {209        const isPast = saleDate ? saleDate.getTime() < Date.now() - 86_400_000 : false;210        out.push(NormalizedAuctionLotSchema.parse({ kind: 'auction_lot', ...base, confidence: 0.85, auctionHouse: 'Antiquorum', auctionName: p.auction.title, lotNumber: lot.lotNumber, endsAt: saleDate, estimateLow: lot.estimateLow, estimateHigh: lot.estimateHigh, currency: lot.estimateCurrency ? currencyOr(lot.estimateCurrency, 'CHF') : null, status: isPast ? 'ended' : 'upcoming', location: p.auction.location }));211      }212    }213    return out;214  }215}216217export default function createConnector(meta: ConnectorMeta) {218  return new AntiquorumConnector(meta);219}220