import { z } from 'zod'; import { BaseConnector, type ConnectorMeta, type CrawlContext, type RawRecordInput, type RawRecordLike } from '@rareindex/connectors'; import { AssetAttributesSchema, NormalizedListingSchema, type NormalizedRecord } from '@rareindex/shared'; import { designSlug, plainText, readSeedCursor, yearOrDecade, type DesignVertical } from '../_g10-lib/index.js'; /** * 1stDibs — the largest online marketplace for vintage/antique furniture, design, jewelry, watches, * fashion and art (USD by default). Browse pages (/furniture/seating/, /jewelry/, … ?page=N, 60 items) * embed the server-rendered Relay store in `; } function verticalFor(seed: Seed, item: Item): DesignVertical { if (seed.vertical !== 'unknown') return seed.vertical; const v = item.vertical ?? ''; const code = item.categoryCode ?? ''; if (v === 'jewelry') return code.startsWith('J_WAT') ? 'watches' : 'jewelry'; if (v === 'fashion') return 'fashion'; if (v === 'art') return 'art'; if (code.startsWith('F_LIG')) return 'lighting'; if (code.startsWith('F_DEC') || code.startsWith('F_SER')) return 'decor'; if (code.startsWith('F_RUG') || code.startsWith('F_TEX')) return 'rugs'; return v === 'furniture' ? 'furniture' : 'unknown'; } export class FirstDibsConnector extends BaseConnector { readonly version = '1.0.0'; readonly parserVersion = PARSER_VERSION; protected override minIntervalMs = 5000; override readonly urlPatterns = [/^https?:\/\/(www\.)?1stdibs\.com\/(?:furniture|jewelry|fashion|art)\/.+\/id-([a-z]_\d+)\/?/i]; private readonly cfg: z.infer; constructor(meta: ConnectorMeta) { super(meta); this.cfg = ConfigSchema.parse(meta.config); } async *crawl(ctx: CrawlContext): AsyncIterable { const seeds = ctx.options.seeds?.length ? ctx.options.seeds.map((s) => SeedSchema.parse({ path: s.startsWith('/') ? s : new URL(s).pathname })) : this.cfg.seeds; const backfill = ctx.options.mode === 'backfill'; const maxPages = backfill ? Math.min(this.policy.backfillMaxPages, this.cfg.maxPagesPerSeed) : this.cfg.pagesPerSeed; const start = readSeedCursor(ctx.options.cursor, seeds.length); const seedsThisRun = backfill ? seeds.length : Math.min(seeds.length, this.cfg.seedsPerRun); let count = 0; let items = 0; for (let k = 0; k < seedsThisRun; k++) { const seedIndex = (start.seedIndex + k) % seeds.length; const seed = seeds[seedIndex]!; let page = k === 0 ? start.page : 1; for (; page <= maxPages; page++) { if (ctx.signal?.aborted || this.reached(ctx, count)) return; const url = pageUrl(seed.path, page); await this.throttle(url); const res = await ctx.fetch(url, { engines: ['api'], responseType: 'text', timeoutMs: 90_000, expect: ['title', 'price', 'currency'], parse: (r) => { const p = r.html ? parseBrowseHtml(r.html) : null; const priced = p?.items.find((i) => i.prices.USD); return p?.items.length ? { title: p.items[0]!.title, price: priced?.prices.USD ?? null, currency: priced ? 'USD' : null } : null; }, minQuality: 0.3, }); const parsed = res.success && res.html ? parseBrowseHtml(res.html) : null; if (!parsed) { ctx.anomaly(res.success ? 'schema_drift' : 'page_fetch_failed', `${url}: ${res.error ?? res.httpStatus ?? 'serverVars_data missing'}`); break; } if (!parsed.items.length) { if (page === 1) ctx.anomaly('parse_failure_page', `${url}: no Item records`); break; } count++; items += parsed.items.length; const payload: PagePayload = { kind: 'listing_page', url, seed, page, totalResults: parsed.totalResults, maxPages: parsed.maxPages, items: parsed.items }; yield { url, externalId: `${seed.path}:p${page}`, kind: 'listing', engine: res.engine, httpStatus: res.httpStatus, payload, fetchedAt: res.fetchedAt }; await ctx.setCursor({ seedIndex, page: page + 1, at: new Date().toISOString() }); await ctx.progress({ page, totalPages: parsed.maxPages, itemsProcessed: items }); if (parsed.items.length < PAGE_SIZE || (parsed.maxPages !== null && page >= parsed.maxPages)) break; } const nextSeed = (seedIndex + 1) % seeds.length; await ctx.setCursor({ seedIndex: nextSeed, page: 1, at: new Date().toISOString(), ...(backfill && nextSeed === 0 ? { done: true } : {}) }); } } async normalize(raw: RawRecordLike): Promise { const p = PagePayloadSchema.parse(raw.payload); const out: NormalizedRecord[] = []; for (const it of p.items) { const vertical = verticalFor(p.seed, it); const categorySlug = p.seed.slug ?? designSlug(it.attributesText ?? it.browseUrl, `${it.title} ${it.attributesText ?? ''}`, vertical); if (!categorySlug) continue; const currency = it.prices.USD ? 'USD' : Object.keys(it.prices)[0] ?? null; const price = currency ? it.prices[currency]! : null; const { year, decade } = yearOrDecade(`${it.title} ${it.attributesText ?? ''}`); const period = it.attributesText?.match(/\b(1[5-9]th Century|20th Century|21st Century|Antique|Vintage|Mid-Century Modern|Art Deco|Art Nouveau|Victorian|Georgian|Regency|Louis X[VI]+|Bauhaus|Scandinavian Modern|Hollywood Regency|Brutalist|Space Age|Postmodern|Memphis)\b/i)?.[0] ?? null; const attributes = AssetAttributesSchema.parse({ categorySlug, brand: it.creators[0] ?? null, name: it.title, year, material: it.materials, size: it.measurement, country: null, identifiers: { firstdibs_item_id: it.serviceId, ...(it.seller.serviceId ? { firstdibs_seller_id: it.seller.serviceId } : {}) }, metadata: { decade, period, creators: it.creators, attributes_text: it.attributesText, category_code: it.categoryCode, browse_url: it.browseUrl, vertical: it.vertical, converted_prices: it.prices, amount_type: it.amountType, item_country: it.country, is_new_listing: it.isNewListing }, }); out.push( NormalizedListingSchema.parse({ kind: 'listing', connectorId: this.meta.id, sourceId: this.meta.sourceId, sourceUrl: it.url, externalId: it.serviceId, rawTitle: it.title, description: it.description, imageUrls: it.images, attributes, grade: { grader: null, grade: null, qualifier: null, certificationNumber: null }, condition: { condition: null, conditionRaw: null, completeness: null }, observedAt: raw.fetchedAt, confidence: price ? 0.82 : 0.6, parserVersion: PARSER_VERSION, listingType: 'fixed_price', price, currency, seller: it.seller.company, location: it.location ?? it.country, availability: it.isSold ? 'sold' : it.isOnHold || it.isUnavailable ? 'ended' : 'available', }), ); } return out; } } export default function createConnector(meta: ConnectorMeta): FirstDibsConnector { return new FirstDibsConnector(meta); }