/** * 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 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) { 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 } } /** * 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 => { 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()