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 { cachedToken, isBundleTitle, isoDate, safeYear, slugFromTitle } from '../_g10-lib/index.js'; /** * eBay Browse API (official, OAuth2 client-credentials, scope https://api.ebay.com/oauth/api_scope). * GET /buy/browse/v1/item_summary/search?q=&category_ids=&filter=&limit=200&offset=N with the * X-EBAY-C-MARKETPLACE-ID header selects the marketplace (EBAY_US, EBAY_CA, EBAY_GB, EBAY_DE, EBAY_FR, * EBAY_AU…). The Browse API returns LIVE listings only — no sold/completed items (Marketplace Insights * is a limited-release API, see meta.json). Gated: requires EBAY_CLIENT_ID + EBAY_CLIENT_SECRET. */ const PARSER_VERSION = '1.0.0'; const MAX_LIMIT = 200; const MAX_RESULTS = 10_000; // Browse caps offset + limit at 10,000 per query export function apiBase(env: string | undefined): { api: string; token: string } { const sandbox = (env ?? '').toLowerCase() === 'sandbox'; return sandbox ? { api: 'https://api.sandbox.ebay.com', token: 'https://api.sandbox.ebay.com/identity/v1/oauth2/token' } : { api: 'https://api.ebay.com', token: 'https://api.ebay.com/identity/v1/oauth2/token' }; } export const MarketplaceSchema = z.object({ id: z.string().regex(/^EBAY_[A-Z]{2,4}$/), currency: z.string().length(3), country: z.string().length(2), locale: z.string().default('en-US') }); export type Marketplace = z.infer; export const QuerySchema = z.object({ q: z.string().optional(), categoryIds: z.array(z.string()).default([]), categorySlug: z.string().nullable().default(null), filter: z.string().optional(), marketplaces: z.array(z.string()).optional() }); export type Query = z.infer; const ConfigSchema = z.object({ marketplaces: z.array(MarketplaceSchema).min(1), 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(10), /** default eBay field filter appended to every query (buying options, conditions, price range…) */ defaultFilter: z.string().default('buyingOptions:{FIXED_PRICE|AUCTION|BEST_OFFER}'), sort: z.string().default('newlyListed'), queriesPerRun: z.number().int().min(1).default(6), }); const Amount = z.object({ value: z.string(), currency: z.string(), convertedFromValue: z.string().optional(), convertedFromCurrency: z.string().optional() }); export const ItemSummarySchema = z.object({ itemId: z.string(), legacyItemId: z.string().optional(), title: z.string(), shortDescription: z.string().optional(), price: Amount.optional(), currentBidPrice: Amount.optional(), bidCount: z.number().int().optional(), buyingOptions: z.array(z.string()).default([]), condition: z.string().optional(), conditionId: z.string().optional(), itemWebUrl: z.string(), itemAffiliateWebUrl: z.string().optional(), image: z.object({ imageUrl: z.string() }).optional(), thumbnailImages: z.array(z.object({ imageUrl: z.string() })).default([]), additionalImages: z.array(z.object({ imageUrl: z.string() })).default([]), seller: z.object({ username: z.string().optional(), feedbackPercentage: z.string().optional(), feedbackScore: z.number().int().optional(), sellerAccountType: z.string().optional() }).optional(), shippingOptions: z.array(z.object({ shippingCost: Amount.optional(), shippingCostType: z.string().optional(), guaranteedDelivery: z.boolean().optional() })).default([]), itemLocation: z.object({ city: z.string().optional(), stateOrProvince: z.string().optional(), postalCode: z.string().optional(), country: z.string().optional() }).optional(), epid: z.string().optional(), categories: z.array(z.object({ categoryId: z.string(), categoryName: z.string().optional() })).default([]), leafCategoryIds: z.array(z.string()).default([]), itemCreationDate: z.string().optional(), itemEndDate: z.string().optional(), itemGroupHref: z.string().optional(), itemGroupType: z.string().optional(), listingMarketplaceId: z.string().optional(), adultOnly: z.boolean().optional(), topRatedBuyingExperience: z.boolean().optional(), watchCount: z.number().int().optional(), }); export type ItemSummary = z.infer; export const SearchPayloadSchema = z.object({ kind: z.literal('search_page'), url: z.string(), marketplace: MarketplaceSchema, query: QuerySchema, offset: z.number().int(), limit: z.number().int(), total: z.number().int().nullable(), next: z.string().nullable(), items: z.array(ItemSummarySchema), warnings: z.array(z.unknown()).default([]), }); export type SearchPayload = z.infer; /** Trim an API item summary to the fields we persist (drops marketing/compat noise; never invents). */ export function trimItem(raw: unknown): ItemSummary | null { const r = ItemSummarySchema.safeParse(raw); return r.success ? r.data : null; } export function parseSearchResponse(json: unknown): { items: ItemSummary[]; total: number | null; next: string | null; warnings: unknown[]; rejected: number } | null { const j = json as { itemSummaries?: unknown[]; total?: number; next?: string; warnings?: unknown[] } | null; if (!j || typeof j !== 'object' || (!Array.isArray(j.itemSummaries) && typeof j.total !== 'number')) return null; const items: ItemSummary[] = []; let rejected = 0; for (const it of j.itemSummaries ?? []) { const t = trimItem(it); if (t) items.push(t); else rejected++; } return { items, total: typeof j.total === 'number' ? j.total : null, next: typeof j.next === 'string' ? j.next : null, warnings: j.warnings ?? [], rejected }; } export function searchUrl(base: string, q: Query, opts: { limit: number; offset: number; filter: string; sort: string }): string { const u = new URL(`${base}/buy/browse/v1/item_summary/search`); if (q.q) u.searchParams.set('q', q.q); if (q.categoryIds.length) u.searchParams.set('category_ids', q.categoryIds.join(',')); const filters = [opts.filter, q.filter].filter(Boolean).join(','); if (filters) u.searchParams.set('filter', filters); if (opts.sort) u.searchParams.set('sort', opts.sort); u.searchParams.set('limit', String(opts.limit)); u.searchParams.set('offset', String(opts.offset)); return u.toString(); } /** Client-credentials token (cached process-wide until 60 s before expiry). Direct fetch: the token endpoint needs a form body + Basic auth. */ export async function getAppToken(clientId: string, clientSecret: string, tokenUrl: string, fetchImpl: typeof fetch = fetch): Promise { return cachedToken(`ebay:${tokenUrl}:${clientId}`, async () => { const res = await fetchImpl(tokenUrl, { method: 'POST', headers: { authorization: `Basic ${Buffer.from(`${clientId}:${clientSecret}`).toString('base64')}`, 'content-type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ grant_type: 'client_credentials', scope: 'https://api.ebay.com/oauth/api_scope' }).toString(), }); const text = await res.text(); if (!res.ok) throw new Error(`eBay token HTTP ${res.status}: ${text.slice(0, 200)}`); const j = JSON.parse(text) as { access_token?: string; expires_in?: number; token_type?: string }; if (!j.access_token) throw new Error('eBay token response without access_token'); return { token: j.access_token, expiresInSeconds: Number(j.expires_in ?? 7200) }; }); } const num = (a: { value: string } | undefined): number | null => { if (!a) return null; const n = Number.parseFloat(a.value); return Number.isFinite(n) && n >= 0 ? n : null; }; export class EbayBrowseConnector extends BaseConnector { readonly version = '1.0.0'; readonly parserVersion = PARSER_VERSION; protected override minIntervalMs = 600; override readonly urlPatterns = [/^https?:\/\/(www\.)?ebay\.(com|ca|co\.uk|de|fr|com\.au|it|es)\/itm\/(?:[^/]+\/)?(\d+)/i]; private readonly cfg: z.infer; constructor(meta: ConnectorMeta) { super(meta); this.cfg = ConfigSchema.parse(meta.config); } private credentials(ctx: CrawlContext): { id: string; secret: string; base: ReturnType } | null { const id = process.env.EBAY_CLIENT_ID?.trim(); const secret = process.env.EBAY_CLIENT_SECRET?.trim(); if (!id || !secret) { ctx.anomaly('missing_credentials', 'EBAY_CLIENT_ID / EBAY_CLIENT_SECRET not set — connector is gated (DISABLED)'); return null; } return { id, secret, base: apiBase(process.env.EBAY_ENV) }; } async *crawl(ctx: CrawlContext): AsyncIterable { const cred = this.credentials(ctx); if (!cred) return; let token: string; try { token = await getAppToken(cred.id, cred.secret, cred.base.token); } catch (err) { ctx.anomaly('auth_failed', err instanceof Error ? err.message : String(err)); 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({ q: s })) : this.cfg.queries; const cursor = (ctx.options.cursor ?? {}) as { queryIndex?: number }; 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 markets = this.cfg.marketplaces.filter((m) => !q.marketplaces || q.marketplaces.includes(m.id)); for (const market of markets) { for (let page = 0; page < pages; page++) { if (ctx.signal?.aborted || this.reached(ctx, count)) return; const offset = page * this.cfg.limit; if (offset + this.cfg.limit > MAX_RESULTS) break; const url = searchUrl(cred.base.api, q, { limit: this.cfg.limit, offset, filter: this.cfg.defaultFilter, sort: this.cfg.sort }); await this.throttle(url); const res = await ctx.fetch(url, { engines: ['api'], responseType: 'json', headers: { authorization: `Bearer ${token}`, 'x-ebay-c-marketplace-id': market.id, 'accept-language': market.locale, accept: 'application/json' }, expect: ['title', 'price', 'currency'], parse: (r) => { const p = parseSearchResponse(r.json); return p ? { title: p.items[0]?.title ?? (p.total === 0 ? 'empty' : null), price: p.items[0]?.price?.value ?? null, currency: p.items[0]?.price?.currency ?? null } : null; }, minQuality: 0.2, }); const parsed = res.success ? parseSearchResponse(res.json) : null; if (!parsed) { const errs = (res.json as { errors?: Array<{ errorId?: number; message?: string }> } | null)?.errors; ctx.anomaly(res.httpStatus === 429 ? 'rate_limited' : 'page_fetch_failed', `${market.id} ${q.q ?? q.categoryIds.join(',')} offset ${offset}: ${res.httpStatus} ${errs?.map((e) => `${e.errorId} ${e.message}`).join('; ') ?? res.error ?? ''}`); break; } if (parsed.rejected) ctx.anomaly('schema_drift', `${parsed.rejected} item summaries rejected by schema`); if (!parsed.items.length) break; count++; items += parsed.items.length; const payload: SearchPayload = { kind: 'search_page', url, marketplace: market, query: q, offset, limit: this.cfg.limit, total: parsed.total, next: parsed.next, items: parsed.items, warnings: parsed.warnings }; yield { url, externalId: `${market.id}:${q.q ?? ''}:${q.categoryIds.join('+')}:${offset}`, kind: 'listing', engine: res.engine, httpStatus: res.httpStatus, payload, fetchedAt: res.fetchedAt }; await ctx.progress({ page: page + 1, totalPages: parsed.total ? Math.ceil(Math.min(parsed.total, MAX_RESULTS) / this.cfg.limit) : null, itemsProcessed: items }); if (!parsed.next || parsed.items.length < this.cfg.limit) break; } } await ctx.setCursor({ queryIndex: (qi + 1) % queries.length, at: new Date().toISOString() }); } if (backfill) await ctx.setCursor({ queryIndex: 0, done: true, at: new Date().toISOString() }); } async lookup(url: string, ctx: CrawlContext): Promise { const legacy = url.match(this.urlPatterns[0]!)?.[3]; const cred = legacy ? this.credentials(ctx) : null; if (!legacy || !cred) return []; const token = await getAppToken(cred.id, cred.secret, cred.base.token); const tld = url.match(/ebay\.([a-z.]+)\//i)?.[1] ?? 'com'; const market = this.cfg.marketplaces.find((m) => ({ com: 'EBAY_US', ca: 'EBAY_CA', 'co.uk': 'EBAY_GB', de: 'EBAY_DE', fr: 'EBAY_FR', 'com.au': 'EBAY_AU', it: 'EBAY_IT', es: 'EBAY_ES' })[tld] === m.id) ?? this.cfg.marketplaces[0]!; const apiUrl = `${cred.base.api}/buy/browse/v1/item/get_item_by_legacy_id?legacy_item_id=${encodeURIComponent(legacy)}`; await this.throttle(apiUrl); const res = await ctx.fetch(apiUrl, { engines: ['api'], responseType: 'json', headers: { authorization: `Bearer ${token}`, 'x-ebay-c-marketplace-id': market.id }, minQuality: 0 }); const item = res.success ? trimItem({ ...(res.json as object), buyingOptions: (res.json as { buyingOptions?: string[] })?.buyingOptions ?? [] }) : null; if (!item) return []; const payload: SearchPayload = { kind: 'search_page', url: apiUrl, marketplace: market, query: { categoryIds: [], categorySlug: null }, offset: 0, limit: 1, total: 1, next: null, items: [item], warnings: [] }; return [{ url: apiUrl, externalId: `item:${item.itemId}`, kind: 'listing', engine: res.engine, httpStatus: res.httpStatus, payload, fetchedAt: res.fetchedAt }]; } async normalize(raw: RawRecordLike): Promise { const p = SearchPayloadSchema.parse(raw.payload); const out: NormalizedRecord[] = []; for (const it of p.items) { if (it.adultOnly) continue; const categorySlug = p.query.categorySlug ?? slugFromTitle(it.title); if (!categorySlug) continue; const priceAmt = it.price ?? it.currentBidPrice; const price = num(priceAmt); const currency = priceAmt?.currency ?? p.marketplace.currency; const converted = Boolean(priceAmt?.convertedFromCurrency && priceAmt.convertedFromCurrency !== priceAmt.currency); const opts = it.buyingOptions; const listingType = opts.includes('AUCTION') ? 'auction' : opts.includes('BEST_OFFER') ? 'best_offer' : opts.includes('FIXED_PRICE') ? 'fixed_price' : 'unknown'; const ship = it.shippingOptions.find((s) => s.shippingCost && s.shippingCost.currency === currency); const grade = parseGradeFromTitle(it.title); const images = [...new Set([it.image?.imageUrl, ...it.additionalImages.map((i) => i.imageUrl), ...it.thumbnailImages.map((i) => i.imageUrl)].filter((x): x is string => Boolean(x)))].slice(0, 8); const attributes = AssetAttributesSchema.parse({ categorySlug, name: it.title, year: safeYear(it.title), identifiers: { ebay_item_id: it.itemId, ...(it.legacyItemId ? { ebay_legacy_item_id: it.legacyItemId } : {}), ...(it.epid ? { ebay_epid: it.epid } : {}) }, metadata: { marketplace: it.listingMarketplaceId ?? p.marketplace.id, buying_options: opts, condition_id: it.conditionId ?? null, categories: it.categories, leaf_category_ids: it.leafCategoryIds, item_group_type: it.itemGroupType ?? null, price_converted_by_ebay: converted, original_price: converted ? { value: priceAmt?.convertedFromValue, currency: priceAmt?.convertedFromCurrency } : null, shipping_cost_type: ship?.shippingCostType ?? null, watch_count: it.watchCount ?? null, top_rated_buying_experience: it.topRatedBuyingExperience ?? null, query: p.query.q ?? null, is_bundle_title: isBundleTitle(it.title), }, }); out.push( NormalizedListingSchema.parse({ kind: 'listing', connectorId: this.meta.id, sourceId: this.meta.sourceId, sourceUrl: it.itemWebUrl, externalId: it.itemId, rawTitle: it.title, description: it.shortDescription ?? null, imageUrls: images, attributes, grade: { grader: grade.grader, grade: grade.grade, qualifier: grade.qualifier, certificationNumber: null }, condition: { condition: null, conditionRaw: it.condition ?? null, completeness: null }, observedAt: raw.fetchedAt, confidence: it.epid ? 0.85 : p.query.categorySlug ? 0.78 : 0.65, parserVersion: PARSER_VERSION, listingType, price, currency: currency && /^[A-Z]{3}$/.test(currency) ? currency : null, seller: it.seller?.username ?? null, sellerReputation: it.seller ? [it.seller.feedbackPercentage ? `${it.seller.feedbackPercentage}%` : null, typeof it.seller.feedbackScore === 'number' ? `${it.seller.feedbackScore} feedback` : null].filter(Boolean).join(' · ') || null : null, location: [it.itemLocation?.city, it.itemLocation?.stateOrProvince, it.itemLocation?.country].filter(Boolean).join(', ') || null, shippingCost: num(ship?.shippingCost), listedAt: isoDate(it.itemCreationDate), endsAt: isoDate(it.itemEndDate), availability: 'available', bidCount: it.bidCount ?? null, }), ); } return out; } } export default function createConnector(meta: ConnectorMeta): EbayBrowseConnector { return new EbayBrowseConnector(meta); }