432 lines
11 KiB
TypeScript
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()
|