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%
11.3 KB · 217 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, extractYear, parseSourceDate, type NormalizedRecord } from '@rareindex/shared';45/**6 * Artcurial sale results. One raw record per sale (compact lot list parsed from the rendered7 * markdown); normalise → one sale per sold lot (EUR) and one auction_lot for unsold lots.8 */910const BASE = 'https://www.artcurial.com';11const PARSER_VERSION = '1.0.0';1213export const SaleRefSchema = z.object({ number: z.string(), title: z.string(), subtitle: z.string().nullable(), date: z.string().nullable(), location: z.string().nullable(), url: z.string(), online: z.boolean() });14export type SaleRef = z.infer<typeof SaleRefSchema>;15export const LotSchema = z.object({ lotNo: z.string(), title: z.string(), subtitle: z.string().nullable(), estimateLow: z.number().nullable(), estimateHigh: z.number().nullable(), sold: z.number().nullable(), url: z.string(), image: z.string().nullable() });16export const SalePayloadSchema = z.object({ kind: z.literal('sale_results'), url: z.string(), sale: SaleRefSchema, lotCount: z.number().nullable(), lots: z.array(LotSchema) });17export type SalePayload = z.infer<typeof SalePayloadSchema>;1819function lines(block: string): string[] {20  return block21    .split(/\\\\\n|\n/)22    .map((l) => l.replace(/\\$/g, '').replace(/\\\|/g, '|').trim())23    .filter((l) => l && l !== '\\' && l !== '|' && !l.startsWith('- '));24}2526/** Find markdown link blocks "[...](url)" whose URL matches `urlRe`, handling nested image brackets. */27export function linkBlocks(md: string, urlRe: RegExp): Array<{ text: string; url: string }> {28  const out: Array<{ text: string; url: string }> = [];29  const re = new RegExp(`\\]\\((${urlRe.source})\\)`, 'g');30  let m: RegExpExecArray | null;31  while ((m = re.exec(md))) {32    const end = m.index;33    let depth = 0;34    let start = -1;35    for (let i = end - 1; i >= 0; i--) {36      const ch = md[i];37      if (ch === ']') depth++;38      else if (ch === '[') {39        if (depth === 0) {40          start = i;41          break;42        }43        depth--;44      }45    }46    if (start < 0) continue;47    out.push({ text: md.slice(start + 1, end), url: m[1]! });48  }49  return out;50}5152/** Parse the results index: blocks ending in (https://www.artcurial.com/en/sales/<n>). */53export function parseResultsIndex(md: string): SaleRef[] {54  const out: SaleRef[] = [];55  const seen = new Set<string>();56  for (const b of linkBlocks(md, /https:\/\/www\.artcurial\.com\/en\/sales\/[A-Za-z0-9-]+/)) {57    const number = b.url.replace(/.*\/sales\//, '');58    if (seen.has(number)) continue;59    const ls = lines(b.text).filter((l) => !l.startsWith('!['));60    const dateIdx = ls.findIndex((l) => /^[A-Z][a-z]{2} \d{1,2}, \d{4}$/.test(l));61    if (dateIdx < 0) continue;62    const date = parseSourceDate(ls[dateIdx]!);63    const numIdx = ls.findIndex((l, i) => i > dateIdx && l.replace(/\s/g, '') === number.replace(/\s/g, ''));64    const title = ls[numIdx + 1] ?? ls[dateIdx + 2] ?? number;65    const subtitle = ls[numIdx + 2] && !/^\d{1,2}:\d{2}|Online Only|Sessions/i.test(ls[numIdx + 2]!) ? ls[numIdx + 2]! : null;66    const online = ls.some((l) => /online only/i.test(l));67    const location = ls.find((l) => /Artcurial, |Hôtel|Monte-Carlo|Paris|Marrakech/i.test(l)) ?? (online ? 'Online' : null);68    seen.add(number);69    out.push({ number, title, subtitle, date: date ? date.toISOString() : null, location, url: b.url, online });70  }71  return out;72}7374function eur(s: string | undefined): number | null {75  if (!s) return null;76  const n = Number(s.replace(/[^\d.]/g, ''));77  return Number.isFinite(n) && n > 0 ? n : null;78}7980/** Parse a sale page: lot blocks "[1\\ ![img](u)\\ Title\\ Sub\\ Estimate: €300 - 500\\ Sold€1,589](lot url)". */81export function parseSalePage(md: string, saleNumber: string): { lotCount: number | null; lots: z.infer<typeof LotSchema>[] } {82  const lotCount = Number(md.match(/All Lots \((\d+)\)/)?.[1]) || null;83  const lots: z.infer<typeof LotSchema>[] = [];84  const esc = saleNumber.replace(/[-/\\^$*+?.()|[\]{}]/g, '\\$&');85  for (const b of linkBlocks(md, new RegExp(`https://www\\.artcurial\\.com/en/sales/${esc}/lots/[^)\\s]+`))) {86    const image = b.text.match(/!\[[^\]]*\]\(([^)\s]+)/)?.[1] ?? null;87    const ls = lines(b.text).filter((l) => !l.startsWith('!['));88    const lotNo = ls[0] && /^\d{1,4}[A-Za-z]?$/.test(ls[0]) ? ls[0] : null;89    if (!lotNo) continue;90    const estLine = ls.find((l) => /^Estimate/i.test(l)) ?? '';91    const est = estLine.match(/€\s?([\d,.]+)\s*-\s*€?\s?([\d,.]+)/);92    const soldLine = ls.find((l) => /^Sold/i.test(l));93    const sold = eur(soldLine?.match(/€\s?([\d,.]+)/)?.[1]);94    const body = ls.slice(1).filter((l) => !/^(Estimate|Sold|No reserve)/i.test(l));95    const title = body[0] ?? null;96    if (!title) continue;97    lots.push({ lotNo, title, subtitle: body[1] ?? null, estimateLow: eur(est?.[1]), estimateHigh: eur(est?.[2]), sold, url: b.url, image });98  }99  return { lotCount, lots };100}101102const SALE_CATEGORY: Array<[RegExp, string]> = [103  [/horlogerie|watches|montres/i, 'other_watches'],104  [/hermès|hermes|luxury bags|sacs|handbags|vuitton|chanel/i, 'luxury_handbags'],105  [/joaillerie|bijoux|jewel/i, 'jewelry'],106  [/vins|wine|spiritueux|spirits|whisky/i, 'wine'],107  [/bande dessinée|bandes dessinées|comics|bd\b/i, 'comics'],108  [/photograph/i, 'photography'],109  [/design/i, 'design_furniture'],110  [/livres|books|manuscrits|manuscripts/i, 'books'],111  [/automobiles|motorcars|le mans|voitures|racing|automobilia/i, 'automobiles'],112  [/contemporain|contemporary|urban art|street art|post-war|impressionniste|moderne|modern/i, 'contemporary_art'],113  [/mobilier|furniture|arts décoratifs|decorative|tableaux anciens|old master|asian|asiatique|antiquités|antiquities|orientalist|souvenirs historiques/i, 'antiques'],114];115export function categoryForSale(title: string): string {116  return SALE_CATEGORY.find(([re]) => re.test(title))?.[1] ?? 'art';117}118const LOT_CATEGORY: Array<[RegExp, string]> = [119  [/\b(poster|affiche|programme|photograph|helmet|casque|trophy|trophée|book|livre|drawing|dessin|sign|plaque|model|maquette|miniature)s?\b/i, 'automotive_memorabilia'],120  [/rolex|patek|omega|wristwatch|montre/i, 'other_watches'],121];122export function categoryForLot(base: string, title: string): string {123  if (base !== 'automobiles') return base;124  return LOT_CATEGORY.find(([re]) => re.test(title))?.[1] ?? base;125}126127export class ArtcurialConnector extends BaseConnector {128  readonly version = '1.0.0';129  readonly parserVersion = PARSER_VERSION;130  protected override minIntervalMs = 3000;131132  async *crawl(ctx: CrawlContext): AsyncIterable<RawRecordInput> {133    const resultsUrl = String(this.meta.config.resultsUrl ?? `${BASE}/en/results-auctions-sales`);134    const perRun = Number(this.meta.config.salesPerRun ?? 3);135    const cursor = (ctx.options.cursor ?? {}) as { done?: string[] };136    const done = new Set<string>(cursor.done ?? []);137    const idx = await ctx.fetch(resultsUrl, { engines: ['firecrawl', 'scrapfly'], waitForMs: 5000, timeoutMs: 90_000, minQuality: 0 });138    if (!idx.success || !idx.markdown) {139      ctx.anomaly('results_index_failed', idx.error ?? String(idx.httpStatus));140      return;141    }142    const sales = parseResultsIndex(idx.markdown).filter((s) => !s.date || new Date(s.date).getTime() <= Date.now());143    sales.sort((a, b) => (b.date ?? '').localeCompare(a.date ?? ''));144    let fetched = 0;145    let count = 0;146    for (const sale of sales) {147      if (ctx.signal?.aborted || this.reached(ctx, count) || fetched >= perRun) break;148      if (done.has(sale.number) && ctx.options.mode !== 'backfill') continue;149      await this.throttle();150      fetched++;151      const res = await ctx.fetch(sale.url, {152        engines: ['firecrawl', 'scrapfly'],153        waitForMs: 5000,154        timeoutMs: 90_000,155        expect: ['title', 'price', 'currency'],156        parse: (r) => {157          const p = r.markdown ? parseSalePage(r.markdown, sale.number) : null;158          const sold = p?.lots.find((l) => l.sold);159          return p?.lots.length ? { title: p.lots[0]!.title, price: sold?.sold ?? null, currency: sold ? 'EUR' : null } : null;160        },161      });162      const parsed = res.success && res.markdown ? parseSalePage(res.markdown, sale.number) : null;163      if (!parsed || parsed.lots.length === 0) {164        ctx.anomaly('sale_parse_failed', `${sale.number}: ${res.error ?? res.httpStatus}`);165        continue;166      }167      if (parsed.lotCount && parsed.lots.length < parsed.lotCount * 0.5) ctx.anomaly('partial_lot_list', `${sale.number}: ${parsed.lots.length}/${parsed.lotCount}`);168      const payload: SalePayload = { kind: 'sale_results', url: sale.url, sale, lotCount: parsed.lotCount, lots: parsed.lots };169      count++;170      done.add(sale.number);171      await ctx.setCursor({ done: [...done].slice(-400) });172      yield { url: sale.url, externalId: sale.number, kind: 'sale', engine: res.engine, httpStatus: res.httpStatus, payload, fetchedAt: res.fetchedAt };173    }174  }175176  async normalize(raw: RawRecordLike): Promise<NormalizedRecord[]> {177    const p = SalePayloadSchema.parse(raw.payload);178    const saleDate = p.sale.date ? new Date(p.sale.date) : null;179    const base = categoryForSale(`${p.sale.title} ${p.sale.subtitle ?? ''}`);180    const out: NormalizedRecord[] = [];181    for (const lot of p.lots) {182      const text = `${lot.title} ${lot.subtitle ?? ''}`;183      const attributes = AssetAttributesSchema.parse({184        categorySlug: categoryForLot(base, text),185        name: lot.title,186        model: lot.subtitle,187        year: extractYear(text),188        identifiers: { artcurial_lot: `${p.sale.number}/${lot.lotNo}` },189        metadata: { sale_number: p.sale.number, sale_title: p.sale.title, sale_subtitle: p.sale.subtitle, estimate_low: lot.estimateLow, estimate_high: lot.estimateHigh, online_only: p.sale.online },190      });191      const common = {192        connectorId: this.meta.id,193        sourceId: this.meta.sourceId,194        sourceUrl: lot.url,195        externalId: `${p.sale.number}:${lot.lotNo}`,196        rawTitle: lot.subtitle ? `${lot.title} — ${lot.subtitle}` : lot.title,197        description: null,198        imageUrls: lot.image ? [lot.image] : [],199        attributes,200        grade: { grader: null, grade: null, qualifier: null, certificationNumber: null },201        condition: { condition: null, conditionRaw: null, completeness: null },202        observedAt: raw.fetchedAt,203        confidence: 0.88,204        parserVersion: PARSER_VERSION,205      };206      if (lot.sold && saleDate) {207        out.push(NormalizedSaleSchema.parse({ kind: 'sale', ...common, saleType: 'auction', saleDate, price: lot.sold, currency: 'EUR', buyerPremiumIncluded: null, quantity: 1, isBundle: /\b(lot of|ensemble de|set of|\d+\s+(pieces|pièces))\b/i.test(text), location: p.sale.location, auctionHouse: 'Artcurial', lotNumber: lot.lotNo }));208      } else {209        out.push(NormalizedAuctionLotSchema.parse({ kind: 'auction_lot', ...common, auctionHouse: 'Artcurial', auctionName: p.sale.title, lotNumber: lot.lotNo, startsAt: saleDate, endsAt: saleDate, estimateLow: lot.estimateLow, estimateHigh: lot.estimateHigh, currentBid: null, currency: 'EUR', status: saleDate && saleDate.getTime() < Date.now() ? 'ended' : 'unknown', location: p.sale.location }));210      }211    }212    return out;213  }214}215216export default (meta: ConnectorMeta) => new ArtcurialConnector(meta);217