Rocky_Mountain_Vending/lib/ebay-api.ts

432 lines
11 KiB
TypeScript

/**
* 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<string, { data: any; timestamp: number }>()
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<void>> = []
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<any>
): Promise<any> {
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<void> {
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>): void {
this.requestQueue.push(request)
this.processQueue()
}
/**
* Search eBay for items
*/
async searchItems(params: eBaySearchParams): Promise<eBaySearchResult[]> {
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<eBaySearchResult[]> {
// 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<eBaySearchResult[]> {
// 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<void> {
return new Promise((resolve) => setTimeout(resolve, delayMs))
}
/**
* Clear cache
*/
clearCache(): void {
apiCache.clear()
}
}
// Export singleton instance
export const ebayClient = new eBayAPIClient()