/** * eBay Finding API Client * * Uses eBay Finding API to search for products and generate affiliate links * through eBay Partner Network */ interface eBaySearchParams { keywords: string categoryId?: string sortOrder?: | "BestMatch" | "PricePlusShippingHighest" | "PricePlusShippingLowest" maxResults?: number } interface eBayItem { itemId: string title: string globalId: string primaryCategory: { categoryId: string categoryName: string } galleryURL?: string viewItemURL: string location: string country: string shippingInfo?: { shippingServiceCost?: { value: string currencyId: string } shippingType: string shipToLocations: string[] } sellingStatus: { currentPrice: { value: string currencyId: string } convertedCurrentPrice?: { value: string currencyId: string } bidCount?: string timeLeft: string } listingInfo: { listingType: string gift: boolean watchCount?: string } condition?: { conditionId: string conditionDisplayName: string } } interface eBaySearchResponse { findItemsAdvancedResponse: Array<{ searchResult: Array<{ item: eBayItem[] "@count": string }> paginationOutput: Array<{ totalPages: string totalEntries: string pageNumber: string entriesPerPage: string }> }> } interface eBaySearchResult { itemId: string title: string price: string currency: string imageUrl?: string viewItemUrl: string condition?: string shippingCost?: string affiliateLink: string } // Simple in-memory cache for API responses const apiCache = new Map() const CACHE_TTL = 30 * 60 * 1000 // 30 minutes export class eBayAPIClient { private appId: string private affiliateCampaignId: string private baseUrl = "https://svcs.ebay.com/services/search/FindingService/v1" private affiliateBaseUrl = "https://www.ebay.com" private token: string private requestQueue: Array<() => Promise> = [] private isProcessingQueue = false constructor(appId?: string, affiliateCampaignId?: string) { this.appId = appId || process.env.EBAY_APP_ID || "" this.affiliateCampaignId = affiliateCampaignId || process.env.EBAY_AFFILIATE_CAMPAIGN_ID || "" if ( !this.appId && process.env.NODE_ENV === "development" && process.env.EBAY_DEBUG === "true" ) { console.warn( "eBay App ID not configured. Set EBAY_APP_ID environment variable." ) } // Use sandbox token if available this.token = process.env.EBAY_SANDBOX_TOKEN || "" // Also try to use the App ID as token if token is not available (fallback) if (!this.token && this.appId) { this.token = this.appId } } isConfigured(): boolean { return Boolean(this.appId) } /** * Get cached response or fetch from API */ private async getCachedOrFetch( cacheKey: string, fetchFn: () => Promise ): Promise { const cached = apiCache.get(cacheKey) if (cached && Date.now() - cached.timestamp < CACHE_TTL) { return cached.data } const data = await fetchFn() apiCache.set(cacheKey, { data, timestamp: Date.now() }) return data } /** * Process request queue with rate limiting */ private async processQueue(): Promise { if (this.isProcessingQueue) return this.isProcessingQueue = true while (this.requestQueue.length > 0) { const request = this.requestQueue.shift() if (request) { await request() await eBayAPIClient.rateLimit(200) // Rate limit between requests } } this.isProcessingQueue = false } /** * Add request to queue with rate limiting */ private queueRequest(request: () => Promise): void { this.requestQueue.push(request) this.processQueue() } /** * Search eBay for items */ async searchItems(params: eBaySearchParams): Promise { if (!this.appId) { throw new Error("eBay App ID not configured") } const { keywords, categoryId, sortOrder = "BestMatch", maxResults = 5, } = params const cacheKey = `search:${keywords}:${categoryId || ""}:${sortOrder}:${maxResults}` return this.getCachedOrFetch(cacheKey, async () => { // eBay Finding API REST endpoint const url = new URL(`${this.baseUrl}?OPERATION-NAME=findItemsAdvanced`) url.searchParams.set("SERVICE-VERSION", "1.0.0") // Use SECURITY-APPNAME for sandbox environment url.searchParams.set("SECURITY-APPNAME", this.appId) url.searchParams.set("RESPONSE-DATA-FORMAT", "JSON") url.searchParams.set("REST-PAYLOAD", "true") url.searchParams.set("keywords", keywords) url.searchParams.set("sortOrder", sortOrder) url.searchParams.set( "paginationInput.entriesPerPage", maxResults.toString() ) // Filter to vending machine parts category if available // Category ID 11700 is "Business & Industrial > Food Service & Retail > Vending Machines" if (categoryId) { url.searchParams.set("categoryId", categoryId) } try { const response = await fetch(url.toString(), { method: "GET", headers: { Accept: "application/json", }, }) if (!response.ok) { const errorText = await response.text() throw new Error( `eBay API error: ${response.status} ${response.statusText} - ${errorText}` ) } const data: any = await response.json() // Handle eBay API response structure const findItemsAdvancedResponse = data.findItemsAdvancedResponse?.[0] if (!findItemsAdvancedResponse) { return [] } const searchResult = findItemsAdvancedResponse.searchResult?.[0] if ( !searchResult || !searchResult.item || searchResult.item.length === 0 ) { return [] } const items = Array.isArray(searchResult.item) ? searchResult.item : [searchResult.item] return items.map((item: any) => { const price = item.sellingStatus.currentPrice.value const currency = item.sellingStatus.currentPrice.currencyId const viewItemUrl = item.viewItemURL // Generate affiliate link const affiliateLink = this.generateAffiliateLink(viewItemUrl) return { itemId: item.itemId, title: item.title, price: `${price} ${currency}`, currency, imageUrl: item.galleryURL, viewItemUrl, condition: item.condition?.conditionDisplayName, shippingCost: item.shippingInfo?.shippingServiceCost?.value ? `${item.shippingInfo.shippingServiceCost.value} ${item.shippingInfo.shippingServiceCost.currencyId}` : undefined, affiliateLink, } }) } catch (error) { console.error("Error searching eBay:", error) throw error } }) } /** * Generate affiliate link from eBay item URL */ generateAffiliateLink(itemUrl: string): string { try { const url = new URL(itemUrl) // Add affiliate tracking parameters if (this.affiliateCampaignId) { url.searchParams.set("mkevt", "1") url.searchParams.set("mkcid", "1") url.searchParams.set("mkrid", this.affiliateCampaignId) url.searchParams.set("campid", this.affiliateCampaignId) } return url.toString() } catch (error) { // If URL parsing fails, return original return itemUrl } } /** * Search for vending machine parts * Optimized search query for parts */ async searchVendingParts( partNumber: string, description?: string, manufacturer?: string ): Promise { // Build search query let query = partNumber // Add description keywords if available if (description && description.length > 0 && description.length < 50) { const descWords = description .split(/\s+/) .filter((w) => w.length > 3) .slice(0, 3) .join(" ") if (descWords) { query += ` ${descWords}` } } // Add manufacturer and vending machine context if (manufacturer) { query += ` ${manufacturer}` } query += " vending machine" // Try category 11700 (Vending Machines) first try { const results = await this.searchItems({ keywords: query, categoryId: "11700", maxResults: 3, sortOrder: "BestMatch", }) if (results.length > 0) { return results } } catch (error) { console.warn( `Category search failed for ${partNumber}, trying general search:`, error ) } // Fallback to general search try { return await this.searchItems({ keywords: query, maxResults: 3, sortOrder: "BestMatch", }) } catch (error) { console.error(`Search failed for ${partNumber}:`, error) return [] } } /** * Search for related products based on manual content */ async searchRelatedProducts( manualContent: string, maxResults: number = 5 ): Promise { // Extract keywords from manual content const keywords = this.extractKeywordsFromManual(manualContent) if (keywords.length === 0) { return [] } const query = keywords.join(" ") return this.searchItems({ keywords: query, maxResults, sortOrder: "BestMatch", }) } /** * Extract relevant keywords from manual content */ private extractKeywordsFromManual(content: string): string[] { // Extract common vending machine terms and model numbers const vendingKeywords = [ "vending machine", "snack machine", "drink machine", "coffee machine", "food machine", "beverage machine", "combo machine", "ice machine", "frozen machine", "bulk machine", "part", "component", "assembly", ] const words = content .toLowerCase() .split(/\s+/) .filter((word) => word.length > 3 && !vendingKeywords.includes(word)) .slice(0, 5) return [...vendingKeywords.slice(0, 2), ...words] } /** * Rate limit helper - wait between requests */ static async rateLimit(delayMs: number = 200): Promise { return new Promise((resolve) => setTimeout(resolve, delayMs)) } /** * Clear cache */ clearCache(): void { apiCache.clear() } } // Export singleton instance export const ebayClient = new eBayAPIClient()