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%
23.3 KB · 497 lines typescript
Raw Blame History
1import { z } from 'zod';2import { BaseConnector, type ConnectorMeta, type CrawlContext, type RawRecordInput, type RawRecordLike } from '@rareindex/connectors';3import { NormalizedAuctionLotSchema, NormalizedSaleSchema, type NormalizedRecord } from '@rareindex/shared';4import { normalizeCondition, parseGradeFromTitle } from '@rareindex/taxonomy';5import { brandFromSlug, hintFromLabel, isBundleTitle, legoSetNumber, safeYear, slugFromTitle, watchBrand, watchReference, type DeptHint } from '../../api/_auction-lib/categories.js';67/**8 * Catawiki — closed-lot results (hammer = final bid, EUR) and live lots, two-phase crawl.9 * Engine: Scrapfly. See meta.json accessNotes for the access rationale and cost model.10 */1112const SITE = 'https://www.catawiki.com';1314const SeedSchema = z.object({ id: z.number().int(), path: z.string(), hint: z.string() });15type Seed = z.infer<typeof SeedSchema>;1617export const AuctionInfoSchema = z.object({18  id: z.number().int(),19  title: z.string(),20  url: z.string(),21  status: z.string().nullable(),22  startAt: z.string().nullable(),23  closeAt: z.string().nullable(),24  closedAt: z.string().nullable(),25  categories: z.array(z.string()),26  lotCount: z.number().nullable(),27});28export type AuctionInfo = z.infer<typeof AuctionInfoSchema>;2930export const LiveLotSchema = z.object({31  id: z.number().int(),32  title: z.string(),33  subtitle: z.string().nullable(),34  url: z.string(),35  imageUrl: z.string().nullable(),36  reservePriceSet: z.boolean().nullable(),37  biddingStartTime: z.string().nullable(),38});39export const AuctionLotsPayloadSchema = z.object({40  kind: z.literal('auction_lots'),41  seedHint: z.string(),42  auction: AuctionInfoSchema,43  lots: z.array(LiveLotSchema),44});45export type AuctionLotsPayload = z.infer<typeof AuctionLotsPayloadSchema>;4647export const LotPayloadSchema = z.object({48  kind: z.literal('lot'),49  seedHint: z.string(),50  id: z.number().int(),51  url: z.string(),52  title: z.string(),53  subtitle: z.string().nullable(),54  description: z.string().nullable(),55  images: z.array(z.string()),56  categoryId: z.number().nullable(),57  categoryUrl: z.string().nullable(),58  auction: AuctionInfoSchema.nullable(),59  specs: z.array(z.object({ name: z.string(), value: z.string() })),60  estimateMinEur: z.number().nullable(),61  estimateMaxEur: z.number().nullable(),62  sellerCountry: z.string().nullable(),63  sellerName: z.string().nullable(),64  sellerIsPro: z.boolean().nullable(),65  bidding: z.object({66    closed: z.boolean().nullable(),67    sold: z.boolean().nullable(),68    finalBidEur: z.number().nullable(),69    biddingStartTime: z.number().nullable(),70    biddingEndTime: z.number().nullable(),71    bidCount: z.number().nullable(),72    reservePriceMet: z.boolean().nullable(),73  }),74});75export type LotPayload = z.infer<typeof LotPayloadSchema>;7677const ConfigSchema = z.object({78  seeds: z.array(SeedSchema).default([]),79  maxNewAuctionsPerRun: z.number().int().default(30),80  maxLotFetchesPerRun: z.number().int().default(400),81  pendingCap: z.number().int().default(6000),82  harvestDelayMinutes: z.number().int().default(20),83});8485interface Pending {86  id: number;87  url: string;88  auctionId: number;89  closeAt: string;90  hint: string;91}92interface Cursor {93  auctions?: Record<string, { closeAt: string | null; hint: string }>;94  pending?: Pending[];95}9697export function nextData(html: string): Record<string, any> | null {98  const m = html.match(/<script id="__NEXT_DATA__"[^>]*>([\s\S]*?)<\/script>/);99  if (!m) return null;100  try {101    return (JSON.parse(m[1]!) as { props?: { pageProps?: Record<string, any> } }).props?.pageProps ?? null;102  } catch {103    return null;104  }105}106107function auctionInfo(a: Record<string, any> | null | undefined): AuctionInfo | null {108  if (!a || !a.id) return null;109  return AuctionInfoSchema.parse({110    id: Number(a.id),111    title: String(a.title ?? ''),112    url: String(a.url ?? `${SITE}/en/a/${a.id}`),113    status: a.status ?? null,114    startAt: a.startAt ?? null,115    closeAt: a.closeAt ?? null,116    closedAt: a.closedAt ?? null,117    categories: ((a.categories as Array<{ title?: string; titleEn?: string }> | undefined) ?? []).map((c) => c.titleEn ?? c.title ?? '').filter(Boolean),118    lotCount: typeof a.lotCount === 'number' ? a.lotCount : typeof a.numberOfLots === 'number' ? a.numberOfLots : null,119  });120}121122/** Auction page → auction info + live lot list. */123export function parseAuctionPage(html: string): { auction: AuctionInfo; lots: z.infer<typeof LiveLotSchema>[] } | null {124  const pp = nextData(html);125  const auction = auctionInfo(pp?.auction);126  if (!auction) return null;127  const lots = ((pp?.lots as Array<Record<string, any>> | undefined) ?? []).map((l) =>128    LiveLotSchema.parse({129      id: Number(l.id),130      title: String(l.title ?? ''),131      subtitle: l.subtitle ?? null,132      url: String(l.url ?? `${SITE}/en/l/${l.id}`),133      imageUrl: l.originalImageUrl ?? l.thumbImageUrl ?? null,134      reservePriceSet: typeof l.reservePriceSet === 'boolean' ? l.reservePriceSet : null,135      biddingStartTime: l.biddingStartTime ?? null,136    }),137  );138  return { auction, lots };139}140141/** Lot page → compact payload. */142export function parseLotPage(html: string, url: string, seedHint: string): LotPayload | null {143  const pp = nextData(html);144  const ld = pp?.lotDetailsData;145  if (!ld) return null;146  const bb = pp?.biddingBlockResponse ?? {};147  const live = bb.live?.lot ?? {};148  const est = ld.expertsEstimate ?? {};149  const seller = ld.sellerInfo ?? {};150  const finalBid = typeof live.bid?.EUR === 'number' ? live.bid.EUR : typeof bb.localizedCurrentBidAmount === 'number' ? bb.localizedCurrentBidAmount : null;151  const bids = bb.biddingHistory?.bids;152  const bidCount = Array.isArray(bids) && bids.length ? Number(bids[0]?.totalBids ?? bids.length) : null;153  return LotPayloadSchema.parse({154    kind: 'lot',155    seedHint,156    id: Number(ld.lotId ?? pp?.lotId),157    url,158    title: String(ld.lotTitle ?? ''),159    subtitle: ld.lotSubtitle ?? null,160    description: typeof ld.description === 'string' ? ld.description.replace(/\s+/g, ' ').slice(0, 800) : null,161    images: ((ld.images as Array<{ large?: string; id?: string }> | undefined) ?? []).map((i) => i.large ?? i.id ?? '').filter(Boolean).slice(0, 3),162    categoryId: typeof ld.category?.id === 'number' ? ld.category.id : null,163    categoryUrl: ld.category?.url ?? null,164    auction: auctionInfo(pp?.auction),165    specs: ((ld.specifications as Array<{ name?: string; value?: string }> | undefined) ?? []).filter((s) => s.name && s.value).map((s) => ({ name: String(s.name), value: String(s.value) })),166    estimateMinEur: typeof est.min?.EUR === 'number' && est.min.EUR > 0 ? est.min.EUR : null,167    estimateMaxEur: typeof est.max?.EUR === 'number' && est.max.EUR > 0 ? est.max.EUR : null,168    sellerCountry: seller.address?.country?.shortCode ? String(seller.address.country.shortCode).toUpperCase() : null,169    sellerName: seller.sellerName ?? null,170    sellerIsPro: typeof seller.isPro === 'boolean' ? seller.isPro : null,171    bidding: {172      closed: typeof bb.closed === 'boolean' ? bb.closed : typeof ld.isClosed === 'boolean' ? ld.isClosed : null,173      sold: typeof bb.sold === 'boolean' ? bb.sold : null,174      finalBidEur: finalBid,175      biddingStartTime: typeof bb.biddingStartTime === 'number' ? bb.biddingStartTime : null,176      biddingEndTime: typeof bb.biddingEndTime === 'number' ? bb.biddingEndTime : typeof live.biddingEndTime === 'number' ? live.biddingEndTime : null,177      bidCount,178      reservePriceMet: typeof bb.reservePriceMet === 'boolean' ? bb.reservePriceMet : null,179    },180  });181}182183export function categoryPageAuctionIds(html: string): number[] {184  return [...new Set([...html.matchAll(/\/en\/a\/(\d+)/g)].map((m) => Number(m[1])))];185}186187function spec(specs: Array<{ name: string; value: string }>, ...names: string[]): string | null {188  for (const n of names) {189    const hit = specs.find((s) => s.name.toLowerCase() === n.toLowerCase());190    if (hit) return hit.value;191  }192  return null;193}194195/** Derive taxonomy slug + attributes from a Catawiki lot (seed hint + auction categories + specs + title). */196export function classifyLot(lot: LotPayload): { slug: string | null; hint: DeptHint } {197  const cats = lot.auction?.categories ?? [];198  const labelHint = hintFromLabel(cats[cats.length - 1]) !== 'unknown' ? hintFromLabel(cats[cats.length - 1]) : hintFromLabel(cats[0]);199  const hint: DeptHint = (lot.seedHint as DeptHint) !== 'unknown' && lot.seedHint ? (lot.seedHint as DeptHint) : labelHint;200  const catText = cats.join(' ');201  // The most specific (leaf) category decides; the full path only for franchise-style families.202  const leaf = cats[cats.length - 1] ?? '';203  const text = `${lot.title} ${lot.subtitle ?? ''}`;204  if (/lego/i.test(catText)) return { slug: 'lego_sets', hint };205  if (/funko/i.test(catText)) return { slug: 'funko', hint };206  if (/pok[eé]mon/i.test(catText)) return { slug: 'pokemon', hint };207  if (/model (?:cars|trains)|modelauto|diecast/i.test(leaf)) return { slug: /train/i.test(leaf) ? 'model_trains' : 'model_cars', hint };208  if (/video ?games|retro gaming/i.test(leaf)) return { slug: 'video_games', hint };209  if (/sneaker/i.test(leaf)) return { slug: 'sneakers', hint };210  if (/handbag|bags/i.test(leaf)) return { slug: 'luxury_handbags', hint };211  if (/whisky|whiskey/i.test(leaf)) return { slug: 'whisky', hint };212  if (/\bwine|champagne/i.test(leaf)) return { slug: slugFromTitle(text, 'wine') ?? 'wine', hint };213  if (/watch/i.test(leaf) && !/pen|lighter/i.test(leaf)) return { slug: watchBrand(text).slug, hint: 'watches' };214  if (/banknote/i.test(leaf)) return { slug: 'banknotes', hint };215  if (/stamp/i.test(leaf) && !/coin/i.test(leaf)) return { slug: 'stamps', hint };216  if (/coin|numismat/i.test(leaf)) return { slug: slugFromTitle(text, 'coins') ?? 'coins', hint: 'coins' };217  if (/vinyl|records/i.test(leaf)) return { slug: 'music', hint };218  if (/movie poster|film poster/i.test(leaf)) return { slug: 'movie_posters', hint };219  if (/camera/i.test(leaf)) return { slug: 'cameras', hint };220  return { slug: slugFromTitle(text, hint), hint };221}222223function toDate(ms: number | null | undefined): Date | null {224  return typeof ms === 'number' && ms > 0 ? new Date(ms) : null;225}226227export default function createConnector(meta: ConnectorMeta) {228  return new CatawikiConnector(meta);229}230231export class CatawikiConnector extends BaseConnector {232  readonly version = '1.0.0';233  readonly parserVersion = '1.0.0';234  override readonly urlPatterns = [/^https?:\/\/(www\.)?catawiki\.[a-z]+\/[a-z]{2}\/l\/\d+/i];235  protected override minIntervalMs = 2000;236  private readonly config = ConfigSchema.parse(this.meta.config ?? {});237238  private async html(ctx: CrawlContext, url: string, renderJs: boolean): Promise<string | null> {239    await this.throttle();240    const res = await ctx.fetch(url, { engines: ['scrapfly'], renderJs, country: 'nl', minQuality: 0, waitForMs: renderJs ? 3000 : undefined, timeoutMs: renderJs ? 120_000 : 60_000 });241    if (!res.success || !res.html) {242      ctx.anomaly('page_fetch_failed', `${url}: ${res.error ?? res.httpStatus}`);243      return null;244    }245    return res.html;246  }247248  private async fetchLot(ctx: CrawlContext, url: string, hint: string): Promise<LotPayload | null> {249    const html = await this.html(ctx, url, false);250    if (!html) return null;251    const lot = parseLotPage(html, url, hint);252    if (!lot) ctx.anomaly('parse_failure', `${url}: no lotDetailsData`);253    return lot;254  }255256  async *crawl(ctx: CrawlContext): AsyncIterable<RawRecordInput> {257    const cursor: Cursor = { auctions: {}, pending: [], ...(ctx.options.cursor ?? {}) };258    const probe = ctx.options.mode === 'probe';259    const now = Date.now();260    let yielded = 0;261262    // 0. Explicit seeds (lot or auction URLs) — used by probes, smoke tests and backfills.263    for (const s of ctx.options.seeds ?? []) {264      const lotM = s.match(/\/l\/(\d+)/);265      if (lotM) {266        const lot = await this.fetchLot(ctx, s, 'unknown');267        if (lot && lot.bidding.closed && lot.bidding.sold) {268          yield { url: s, externalId: String(lot.id), kind: 'sale', engine: 'scrapfly', httpStatus: 200, payload: lot, fetchedAt: new Date() };269          if (this.reached(ctx, ++yielded)) return;270        }271        continue;272      }273      if (/\/a\/\d+/.test(s)) {274        const html = await this.html(ctx, s, false);275        const parsed = html ? parseAuctionPage(html) : null;276        if (parsed) {277          const payload: AuctionLotsPayload = { kind: 'auction_lots', seedHint: 'unknown', auction: parsed.auction, lots: parsed.lots };278          yield { url: s, externalId: `a${parsed.auction.id}`, kind: 'auction_lot', engine: 'scrapfly', httpStatus: 200, payload, fetchedAt: new Date() };279          this.remember(cursor, parsed, 'unknown');280        }281      }282    }283    if (probe && (ctx.options.seeds?.length ?? 0) > 0) {284      await ctx.setCursor(cursor as Record<string, unknown>);285      return;286    }287288    // 1. Harvest closed lots289    const delayMs = this.config.harvestDelayMinutes * 60_000;290    const due = (cursor.pending ?? []).filter((p) => new Date(p.closeAt).getTime() + delayMs <= now).slice(0, probe ? 3 : this.config.maxLotFetchesPerRun);291    const dueIds = new Set(due.map((p) => p.id));292    for (const p of due) {293      if (ctx.signal?.aborted) break;294      const lot = await this.fetchLot(ctx, p.url, p.hint);295      if (lot && lot.bidding.closed === false) {296        // extended bidding — try again next run297        dueIds.delete(p.id);298        continue;299      }300      if (lot && lot.bidding.closed && lot.bidding.sold) {301        yield { url: p.url, externalId: String(lot.id), kind: 'sale', engine: 'scrapfly', httpStatus: 200, payload: lot, fetchedAt: new Date() };302        if (this.reached(ctx, ++yielded)) break;303      }304    }305    cursor.pending = (cursor.pending ?? []).filter((p) => !dueIds.has(p.id));306    await ctx.setCursor(cursor as Record<string, unknown>);307308    // 2. Discovery of live auctions per seed category309    const seeds = ctx.options.categories?.length ? this.config.seeds.filter((s) => ctx.options.categories!.includes(s.hint)) : this.config.seeds;310    let newAuctions = 0;311    for (const seed of probe ? seeds.slice(0, 1) : seeds) {312      if (ctx.signal?.aborted) break;313      const html = await this.html(ctx, `${SITE}/en/c/${seed.path}`, true);314      if (!html) continue;315      const ids = categoryPageAuctionIds(html);316      if (!ids.length) ctx.anomaly('empty_page', `category ${seed.path}: no auction links after render`);317      for (const id of ids) {318        if (cursor.auctions?.[String(id)]) continue;319        if (newAuctions >= (probe ? 1 : this.config.maxNewAuctionsPerRun)) break;320        const url = `${SITE}/en/a/${id}`;321        const ahtml = await this.html(ctx, url, false);322        const parsed = ahtml ? parseAuctionPage(ahtml) : null;323        if (!parsed) continue;324        newAuctions++;325        this.remember(cursor, parsed, seed.hint);326        if (parsed.lots.length) {327          const payload: AuctionLotsPayload = { kind: 'auction_lots', seedHint: seed.hint, auction: parsed.auction, lots: parsed.lots };328          yield { url: parsed.auction.url, externalId: `a${parsed.auction.id}`, kind: 'auction_lot', engine: 'scrapfly', httpStatus: 200, payload, fetchedAt: new Date() };329        }330        await ctx.setCursor(cursor as Record<string, unknown>);331      }332    }333    // prune remembered auctions older than 30 days334    const cutoff = now - 30 * 86_400_000;335    for (const [id, a] of Object.entries(cursor.auctions ?? {})) if (a.closeAt && new Date(a.closeAt).getTime() < cutoff) delete cursor.auctions![id];336    await ctx.setCursor(cursor as Record<string, unknown>);337  }338339  private remember(cursor: Cursor, parsed: NonNullable<ReturnType<typeof parseAuctionPage>>, hint: string): void {340    cursor.auctions ??= {};341    cursor.pending ??= [];342    cursor.auctions[String(parsed.auction.id)] = { closeAt: parsed.auction.closeAt, hint };343    if (!parsed.auction.closeAt) return;344    const known = new Set(cursor.pending.map((p) => p.id));345    for (const l of parsed.lots) {346      if (known.has(l.id)) continue;347      cursor.pending.push({ id: l.id, url: l.url, auctionId: parsed.auction.id, closeAt: parsed.auction.closeAt, hint });348    }349    if (cursor.pending.length > this.config.pendingCap) cursor.pending = cursor.pending.slice(-this.config.pendingCap);350  }351352  async lookup(url: string, ctx: CrawlContext): Promise<RawRecordInput[]> {353    const lot = await this.fetchLot(ctx, url, 'unknown');354    if (!lot) return [];355    return [{ url, externalId: String(lot.id), kind: lot.bidding.closed ? 'sale' : 'auction_lot', engine: 'scrapfly', httpStatus: 200, payload: lot, fetchedAt: new Date() }];356  }357358  async normalize(raw: RawRecordLike): Promise<NormalizedRecord[]> {359    const p = raw.payload as { kind?: string };360    if (p?.kind === 'auction_lots') return this.normalizeAuctionLots(raw, AuctionLotsPayloadSchema.parse(raw.payload));361    const lot = LotPayloadSchema.parse(raw.payload);362    const { slug } = classifyLot(lot);363    if (!slug) return [];364    const text = `${lot.title} ${lot.subtitle ?? ''}`.trim();365    const grade = parseGradeFromTitle(text);366    const grader = spec(lot.specs, 'Grading company', 'Graded by');367    const gradeSpec = spec(lot.specs, 'Grade', 'Card grade');368    const brandSpec = spec(lot.specs, 'Brand', 'Manufacturer', 'Producer', 'Publisher', 'Winery', 'Distillery');369    const model = spec(lot.specs, 'Model', 'Series', 'Theme', 'Set');370    const reference = spec(lot.specs, 'Reference number', 'Reference', 'Set number', 'Catalogue number') ?? (['rolex', 'patek_philippe', 'audemars_piguet', 'omega', 'other_watches'].includes(slug) ? watchReference(text) : null);371    const yearSpec = spec(lot.specs, 'Year', 'Year of production', 'Vintage', 'Year of publication', 'Release year');372    const conditionRaw = spec(lot.specs, 'Condition', 'Card condition', 'Condition of the item');373    const identifiers: Record<string, string> = { catawiki_lot_id: String(lot.id) };374    if (reference) identifiers.reference = reference;375    if (slug === 'lego_sets') {376      const n = spec(lot.specs, 'Set number') ?? legoSetNumber(text);377      if (n) identifiers.lego_set_number = n;378    }379    const attributes = {380      categorySlug: slug,381      name: lot.title,382      brand: brandSpec ?? brandFromSlug(slug, text),383      model,384      reference,385      year: yearSpec ? safeYear(yearSpec) : safeYear(text),386      country: spec(lot.specs, 'Country of origin', 'Country'),387      material: spec(lot.specs, 'Material'),388      identifiers,389      metadata: { auction_id: lot.auction?.id ?? null, auction_title: lot.auction?.title ?? null, auction_categories: lot.auction?.categories ?? [], category_id: lot.categoryId, category_url: lot.categoryUrl, specs: Object.fromEntries(lot.specs.map((s) => [s.name, s.value])), estimate_min_eur: lot.estimateMinEur, estimate_max_eur: lot.estimateMaxEur, seller_country: lot.sellerCountry, seller_pro: lot.sellerIsPro, bids: lot.bidding.bidCount, buyer_fee_note: 'Catawiki charges the buyer an additional protection fee (~9% + VAT) on top of the final bid' },390    };391    const base = {392      connectorId: this.meta.id,393      sourceId: this.meta.sourceId,394      sourceUrl: lot.url,395      externalId: String(lot.id),396      rawTitle: text,397      description: lot.description,398      imageUrls: lot.images,399      attributes,400      grade: { grader: grader ? (parseGradeFromTitle(`${grader} 1`).grader ?? grader.toLowerCase()) : grade.grader, grade: gradeSpec ?? grade.grade, qualifier: grade.qualifier, certificationNumber: spec(lot.specs, 'Certificate number', 'Certification number') },401      condition: { condition: normalizeCondition(slug, conditionRaw), conditionRaw, completeness: null },402      observedAt: raw.fetchedAt,403      parserVersion: this.parserVersion,404    };405    const endsAt = toDate(lot.bidding.biddingEndTime);406    if (lot.bidding.closed && lot.bidding.sold && lot.bidding.finalBidEur && endsAt) {407      if (endsAt.getTime() > new Date(raw.fetchedAt).getTime() + 86_400_000) return []; // a 'closed' lot ending after observation is inconsistent408      return [409        NormalizedSaleSchema.parse({410          ...base,411          kind: 'sale',412          confidence: 0.85,413          saleType: 'auction',414          saleDate: endsAt,415          price: lot.bidding.finalBidEur,416          currency: 'EUR',417          buyerPremiumIncluded: false,418          quantity: 1,419          isBundle: isBundleTitle(text),420          location: lot.sellerCountry,421          auctionHouse: 'Catawiki',422          lotNumber: null,423        }),424      ];425    }426    if (!lot.bidding.closed) {427      return [428        NormalizedAuctionLotSchema.parse({429          ...base,430          kind: 'auction_lot',431          confidence: 0.8,432          auctionHouse: 'Catawiki',433          auctionName: lot.auction?.title ?? null,434          lotNumber: null,435          startsAt: toDate(lot.bidding.biddingStartTime),436          endsAt,437          estimateLow: lot.estimateMinEur,438          estimateHigh: lot.estimateMaxEur,439          currentBid: lot.bidding.finalBidEur,440          currency: 'EUR',441          status: 'live',442          location: lot.sellerCountry,443        }),444      ];445    }446    return [];447  }448449  private normalizeAuctionLots(raw: RawRecordLike, page: AuctionLotsPayload): NormalizedRecord[] {450    const out: NormalizedRecord[] = [];451    const a = page.auction;452    const closeAt = a.closeAt ? new Date(a.closeAt) : null;453    const startAt = a.startAt ? new Date(a.startAt) : null;454    // staleness is judged at observation time (raw.fetchedAt), not at normalisation time: fixtures and455    // delayed normalisation must not silently drop lots that were live when crawled456    const observedAtMs = new Date(raw.fetchedAt).getTime();457    if (closeAt && closeAt.getTime() < observedAtMs) return []; // stale listing page458    const catLabel = a.categories[a.categories.length - 1] ?? a.categories[0] ?? null;459    for (const l of page.lots) {460      const text = `${l.title} ${l.subtitle ?? ''}`.trim();461      const slug = classifyLot({ kind: 'lot', seedHint: page.seedHint, id: l.id, url: l.url, title: l.title, subtitle: l.subtitle, description: null, images: [], categoryId: null, categoryUrl: null, auction: a, specs: [], estimateMinEur: null, estimateMaxEur: null, sellerCountry: null, sellerName: null, sellerIsPro: null, bidding: { closed: false, sold: null, finalBidEur: null, biddingStartTime: null, biddingEndTime: null, bidCount: null, reservePriceMet: null } }).slug;462      if (!slug) continue;463      const grade = parseGradeFromTitle(text);464      out.push(465        NormalizedAuctionLotSchema.parse({466          kind: 'auction_lot',467          connectorId: this.meta.id,468          sourceId: this.meta.sourceId,469          sourceUrl: l.url,470          externalId: String(l.id),471          rawTitle: text,472          description: null,473          imageUrls: l.imageUrl ? [l.imageUrl] : [],474          attributes: { categorySlug: slug, name: l.title, brand: brandFromSlug(slug, text), year: safeYear(text), identifiers: { catawiki_lot_id: String(l.id) }, metadata: { auction_id: a.id, auction_title: a.title, auction_categories: a.categories, category_label: catLabel } },475          grade: { grader: grade.grader, grade: grade.grade, qualifier: grade.qualifier, certificationNumber: null },476          condition: {},477          observedAt: raw.fetchedAt,478          confidence: 0.7,479          parserVersion: this.parserVersion,480          auctionHouse: 'Catawiki',481          auctionName: a.title,482          lotNumber: null,483          startsAt: startAt,484          endsAt: closeAt,485          estimateLow: null,486          estimateHigh: null,487          currentBid: null,488          currency: 'EUR',489          status: startAt && startAt.getTime() <= observedAtMs ? 'live' : 'upcoming',490          location: null,491        }),492      );493    }494    return out;495  }496}497