TypeScript 61.9%
HTML 37.2%
SQL 0.7%
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