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 { parseGradeFromTitle } from '@rareindex/taxonomy'; import { epochSeconds, isBundleTitle, plainText, safeYear } from '../_g10-lib/index.js'; /** * Etsy Open API v3 (official; x-api-key header = the app keystring). findAllListingsActive — * GET /v3/application/listings/active?keywords=&taxonomy_id=&limit=100&offset=&sort_on=created&sort_order=desc — * returns active listings with Money prices {amount, divisor, currency_code} in the shop's currency. * Images/shop names are not part of that response; they are attached with one * GET /v3/application/listings/batch?listing_ids=…&includes=Images,Shop call per page (≤ 100 ids). * Taxonomy ids are resolved at run time from /v3/application/seller-taxonomy/nodes by node-name path. * Gated: requires ETSY_API_KEY. Listings only (Etsy exposes no sold prices publicly). */ const API = 'https://openapi.etsy.com/v3/application'; const PARSER_VERSION = '1.0.0'; const MAX_LIMIT = 100; export const QuerySchema = z.object({ keywords: z.string().optional(), taxonomyId: z.number().int().optional(), /** node names from the seller taxonomy root, e.g. ["Art & Collectibles", "Collectibles"] */ taxonomyPath: z.array(z.string()).optional(), categorySlug: z.string(), minPrice: z.number().optional(), maxPrice: z.number().optional(), }); export type Query = z.infer; const ConfigSchema = z.object({ queries: z.array(QuerySchema).min(1), limit: z.number().int().min(1).max(MAX_LIMIT).default(MAX_LIMIT), pagesPerQuery: z.number().int().min(1).default(1), backfillPages: z.number().int().min(1).default(5), queriesPerRun: z.number().int().min(1).default(8), fetchImages: z.boolean().default(true), sortOn: z.enum(['created', 'price', 'updated', 'score']).default('created'), sortOrder: z.enum(['asc', 'desc']).default('desc'), }); export const MoneySchema = z.object({ amount: z.number(), divisor: z.number().positive(), currency_code: z.string() }); export const ListingSchema = z.object({ listing_id: z.number().int(), shop_id: z.number().int().nullable().optional(), title: z.string(), description: z.string().nullable().optional(), state: z.string().nullable().optional(), url: z.string(), quantity: z.number().int().nullable().optional(), price: MoneySchema, taxonomy_id: z.number().int().nullable().optional(), tags: z.array(z.string()).default([]), materials: z.array(z.string()).default([]), when_made: z.string().nullable().optional(), who_made: z.string().nullable().optional(), is_supply: z.boolean().nullable().optional(), is_customizable: z.boolean().nullable().optional(), has_variations: z.boolean().nullable().optional(), listing_type: z.string().nullable().optional(), language: z.string().nullable().optional(), creation_timestamp: z.number().nullable().optional(), original_creation_timestamp: z.number().nullable().optional(), ending_timestamp: z.number().nullable().optional(), last_modified_timestamp: z.number().nullable().optional(), num_favorers: z.number().int().nullable().optional(), views: z.number().int().nullable().optional(), /** attached from /listings/batch?includes=Images,Shop */ images: z.array(z.object({ url_570xN: z.string().optional(), url_fullxfull: z.string().optional(), listing_image_id: z.number().optional() })).default([]), shop: z.object({ shop_name: z.string().optional(), url: z.string().optional(), shop_location_country_iso: z.string().nullable().optional(), review_average: z.number().nullable().optional(), review_count: z.number().nullable().optional() }).nullable().optional(), }); export type Listing = z.infer; export const PagePayloadSchema = z.object({ kind: z.literal('search_page'), url: z.string(), query: QuerySchema, offset: z.number().int(), limit: z.number().int(), count: z.number().int().nullable(), listings: z.array(ListingSchema) }); export type PagePayload = z.infer; /** Trim one API listing to the persisted shape (schema-drift tolerant: unknown → dropped). */ export function trimListing(raw: unknown): Listing | null { if (!raw || typeof raw !== 'object') return null; const r = raw as Record; const parsed = ListingSchema.safeParse({ ...r, description: typeof r.description === 'string' ? plainText(r.description, 800) : null, tags: Array.isArray(r.tags) ? r.tags.slice(0, 20) : [], materials: Array.isArray(r.materials) ? r.materials.slice(0, 10) : [] }); return parsed.success ? parsed.data : null; } export function parseSearchResponse(json: unknown): { count: number | null; listings: Listing[]; rejected: number } | null { const j = json as { count?: number; results?: unknown[] } | null; if (!j || !Array.isArray(j.results)) return null; const listings: Listing[] = []; let rejected = 0; for (const r of j.results) { const t = trimListing(r); if (t) listings.push(t); else rejected++; } return { count: typeof j.count === 'number' ? j.count : null, listings, rejected }; } export function searchUrl(q: Query, opts: { limit: number; offset: number; sortOn: string; sortOrder: string }, taxonomyId: number | null): string { const u = new URL(`${API}/listings/active`); if (q.keywords) u.searchParams.set('keywords', q.keywords); if (taxonomyId) u.searchParams.set('taxonomy_id', String(taxonomyId)); if (q.minPrice !== undefined) u.searchParams.set('min_price', String(q.minPrice)); if (q.maxPrice !== undefined) u.searchParams.set('max_price', String(q.maxPrice)); u.searchParams.set('sort_on', opts.sortOn); u.searchParams.set('sort_order', opts.sortOrder); u.searchParams.set('limit', String(opts.limit)); u.searchParams.set('offset', String(opts.offset)); return u.toString(); } /** Walk the seller taxonomy tree by node names (case-insensitive). */ export function resolveTaxonomyPath(nodes: unknown, path: string[]): number | null { let level = (nodes as { results?: unknown[] } | null)?.results ?? (Array.isArray(nodes) ? nodes : null); let id: number | null = null; for (const name of path) { if (!Array.isArray(level)) return null; const hit = (level as Array<{ id?: number; name?: string; children?: unknown[] }>).find((n) => (n.name ?? '').toLowerCase() === name.toLowerCase()); if (!hit || typeof hit.id !== 'number') return null; id = hit.id; level = hit.children ?? []; } return id; } export function priceOf(m: z.infer): number | null { const v = m.amount / m.divisor; return Number.isFinite(v) && v > 0 ? v : null; } export class EtsyConnector extends BaseConnector { readonly version = '1.0.0'; readonly parserVersion = PARSER_VERSION; protected override minIntervalMs = 400; override readonly urlPatterns = [/^https?:\/\/(www\.)?etsy\.com\/(?:[a-z]{2}\/)?listing\/(\d+)/i]; private readonly cfg: z.infer; constructor(meta: ConnectorMeta) { super(meta); this.cfg = ConfigSchema.parse(meta.config); } private apiKey(ctx: CrawlContext): string | null { const key = process.env.ETSY_API_KEY?.trim(); if (!key) ctx.anomaly('missing_credentials', 'ETSY_API_KEY not set — connector is gated (DISABLED)'); return key ?? null; } private headers(key: string): Record { return { 'x-api-key': key, accept: 'application/json' }; } private async taxonomyId(ctx: CrawlContext, key: string, q: Query, cache: Record): Promise { if (q.taxonomyId) return q.taxonomyId; if (!q.taxonomyPath?.length) return null; const k = q.taxonomyPath.join(' > '); if (cache[k]) return cache[k]!; await this.throttle(); const res = await ctx.fetch(`${API}/seller-taxonomy/nodes`, { engines: ['api'], responseType: 'json', headers: this.headers(key), minQuality: 0, force: true }); const id = res.success ? resolveTaxonomyPath(res.json, q.taxonomyPath) : null; if (!id) ctx.anomaly('selector_missing', `Etsy taxonomy path not found: ${k}`); else cache[k] = id; return id; } private async attachImages(ctx: CrawlContext, key: string, listings: Listing[]): Promise { if (!this.cfg.fetchImages || !listings.length) return; const ids = listings.map((l) => l.listing_id); const url = `${API}/listings/batch?listing_ids=${ids.join(',')}&includes=Images,Shop`; await this.throttle(url); const res = await ctx.fetch(url, { engines: ['api'], responseType: 'json', headers: this.headers(key), minQuality: 0 }); const results = (res.json as { results?: Array> } | null)?.results; if (!res.success || !Array.isArray(results)) { ctx.anomaly('page_fetch_failed', `listings/batch: ${res.error ?? res.httpStatus}`); return; } const byId = new Map(results.map((r) => [Number(r.listing_id), r] as const)); for (const l of listings) { const full = byId.get(l.listing_id); if (!full) continue; const imgs = Array.isArray(full.images) ? (full.images as Array>).slice(0, 6).map((i) => ({ url_570xN: typeof i.url_570xN === 'string' ? i.url_570xN : undefined, url_fullxfull: typeof i.url_fullxfull === 'string' ? i.url_fullxfull : undefined, listing_image_id: typeof i.listing_image_id === 'number' ? i.listing_image_id : undefined })) : []; const shop = full.shop && typeof full.shop === 'object' ? (full.shop as Record) : null; l.images = imgs; l.shop = shop ? { shop_name: typeof shop.shop_name === 'string' ? shop.shop_name : undefined, url: typeof shop.url === 'string' ? shop.url : undefined, shop_location_country_iso: typeof shop.shop_location_country_iso === 'string' ? shop.shop_location_country_iso : null, review_average: typeof shop.review_average === 'number' ? shop.review_average : null, review_count: typeof shop.review_count === 'number' ? shop.review_count : null } : null; } } async *crawl(ctx: CrawlContext): AsyncIterable { const key = this.apiKey(ctx); if (!key) return; const backfill = ctx.options.mode === 'backfill'; const pages = backfill ? this.cfg.backfillPages : this.cfg.pagesPerQuery; const queries = ctx.options.seeds?.length ? ctx.options.seeds.map((s) => QuerySchema.parse({ keywords: s, categorySlug: 'trading_cards' })) : this.cfg.queries; const cursor = (ctx.options.cursor ?? {}) as { queryIndex?: number; taxonomy?: Record }; const taxonomyCache: Record = { ...(cursor.taxonomy ?? {}) }; const startQ = backfill ? 0 : Math.min(cursor.queryIndex ?? 0, queries.length - 1); const perRun = backfill ? queries.length : Math.min(queries.length, this.cfg.queriesPerRun); let count = 0; let items = 0; for (let k = 0; k < perRun; k++) { const qi = (startQ + k) % queries.length; const q = queries[qi]!; const taxId = await this.taxonomyId(ctx, key, q, taxonomyCache); if (q.taxonomyPath?.length && !taxId) continue; for (let page = 0; page < pages; page++) { if (ctx.signal?.aborted || this.reached(ctx, count)) return; const offset = page * this.cfg.limit; const url = searchUrl(q, { limit: this.cfg.limit, offset, sortOn: this.cfg.sortOn, sortOrder: this.cfg.sortOrder }, taxId); await this.throttle(url); const res = await ctx.fetch(url, { engines: ['api'], responseType: 'json', headers: this.headers(key), expect: ['title', 'price', 'currency'], parse: (r) => { const p = parseSearchResponse(r.json); return p ? { title: p.listings[0]?.title ?? (p.count === 0 ? 'empty' : null), price: p.listings[0]?.price.amount ?? null, currency: p.listings[0]?.price.currency_code ?? null } : null; }, minQuality: 0.2, }); const parsed = res.success ? parseSearchResponse(res.json) : null; if (!parsed) { const err = (res.json as { error?: string } | null)?.error; ctx.anomaly(res.httpStatus === 429 ? 'rate_limited' : 'page_fetch_failed', `${q.keywords ?? q.taxonomyPath?.join('>')} offset ${offset}: ${res.httpStatus} ${err ?? res.error ?? ''}`); break; } if (parsed.rejected) ctx.anomaly('schema_drift', `${parsed.rejected} listings rejected by schema`); if (!parsed.listings.length) break; await this.attachImages(ctx, key, parsed.listings); count++; items += parsed.listings.length; const payload: PagePayload = { kind: 'search_page', url, query: q, offset, limit: this.cfg.limit, count: parsed.count, listings: parsed.listings }; yield { url, externalId: `${q.keywords ?? ''}:${taxId ?? ''}:${offset}`, kind: 'listing', engine: res.engine, httpStatus: res.httpStatus, payload, fetchedAt: res.fetchedAt }; await ctx.progress({ page: page + 1, totalPages: parsed.count ? Math.ceil(parsed.count / this.cfg.limit) : null, itemsProcessed: items }); if (parsed.listings.length < this.cfg.limit || (parsed.count !== null && offset + this.cfg.limit >= parsed.count)) break; } await ctx.setCursor({ queryIndex: (qi + 1) % queries.length, taxonomy: taxonomyCache, at: new Date().toISOString() }); } if (backfill) await ctx.setCursor({ queryIndex: 0, taxonomy: taxonomyCache, done: true, at: new Date().toISOString() }); } async lookup(url: string, ctx: CrawlContext): Promise { const id = url.match(this.urlPatterns[0]!)?.[2]; const key = id ? this.apiKey(ctx) : null; if (!id || !key) return []; const apiUrl = `${API}/listings/${id}?includes=Images,Shop`; await this.throttle(apiUrl); const res = await ctx.fetch(apiUrl, { engines: ['api'], responseType: 'json', headers: this.headers(key), minQuality: 0 }); const l = res.success ? trimListing(res.json) : null; if (!l) return []; const payload: PagePayload = { kind: 'search_page', url: apiUrl, query: { categorySlug: 'trading_cards' }, offset: 0, limit: 1, count: 1, listings: [l] }; return [{ url: apiUrl, externalId: `listing:${id}`, kind: 'listing', engine: res.engine, httpStatus: res.httpStatus, payload, fetchedAt: res.fetchedAt }]; } async normalize(raw: RawRecordLike): Promise { const p = PagePayloadSchema.parse(raw.payload); const out: NormalizedRecord[] = []; for (const l of p.listings) { if (l.is_supply || l.when_made === 'made_to_order' || (l.state && l.state !== 'active')) continue; const price = priceOf(l.price); if (!price) continue; const grade = parseGradeFromTitle(l.title); const decade = l.when_made?.match(/^(1[6-9]\d0|20[0-2]0)s$/)?.[0] ?? null; const attributes = AssetAttributesSchema.parse({ categorySlug: p.query.categorySlug, name: l.title, year: safeYear(l.title), material: l.materials[0] ?? null, language: l.language ?? null, identifiers: { etsy_listing_id: String(l.listing_id) }, metadata: { when_made: l.when_made ?? null, decade, who_made: l.who_made ?? null, tags: l.tags, materials: l.materials, taxonomy_id: l.taxonomy_id ?? null, num_favorers: l.num_favorers ?? null, has_variations: l.has_variations ?? null, shop_id: l.shop_id ?? null, shop_country: l.shop?.shop_location_country_iso ?? null, keywords: p.query.keywords ?? null, is_bundle_title: isBundleTitle(l.title) }, }); out.push( NormalizedListingSchema.parse({ kind: 'listing', connectorId: this.meta.id, sourceId: this.meta.sourceId, sourceUrl: l.url, externalId: String(l.listing_id), rawTitle: l.title, description: l.description ?? null, imageUrls: l.images.map((i) => i.url_fullxfull ?? i.url_570xN).filter((x): x is string => Boolean(x)), attributes, grade: { grader: grade.grader, grade: grade.grade, qualifier: grade.qualifier, certificationNumber: null }, condition: { condition: null, conditionRaw: null, completeness: null }, observedAt: raw.fetchedAt, confidence: 0.7, parserVersion: PARSER_VERSION, listingType: 'fixed_price', price, currency: /^[A-Z]{3}$/.test(l.price.currency_code) ? l.price.currency_code : null, seller: l.shop?.shop_name ?? null, sellerReputation: l.shop?.review_average != null && l.shop.review_count != null ? `${l.shop.review_average.toFixed(1)}★ (${l.shop.review_count} reviews)` : null, location: l.shop?.shop_location_country_iso ?? null, quantity: l.quantity ?? null, listedAt: epochSeconds(l.original_creation_timestamp ?? l.creation_timestamp), endsAt: epochSeconds(l.ending_timestamp), availability: 'available', }), ); } return out; } } export default function createConnector(meta: ConnectorMeta): EtsyConnector { return new EtsyConnector(meta); }