From 1948fd564e412f3ba940befd7d243cb19e2111a9 Mon Sep 17 00:00:00 2001 From: DMleadgen Date: Wed, 1 Apr 2026 15:23:47 -0600 Subject: [PATCH] deploy: fix manuals eBay live search --- app/api/ebay/search/route.ts | 173 ++++++++++++++++++++-------- components/parts-panel.tsx | 36 +++++- lib/parts-lookup.ts | 211 +++++++++++++++++++++++++++++------ 3 files changed, 335 insertions(+), 85 deletions(-) diff --git a/app/api/ebay/search/route.ts b/app/api/ebay/search/route.ts index 3dd0a43e..28a89663 100644 --- a/app/api/ebay/search/route.ts +++ b/app/api/ebay/search/route.ts @@ -26,6 +26,10 @@ interface eBaySearchResult { type MaybeArray = T | T[] +const SEARCH_CACHE_TTL = 15 * 60 * 1000 // 15 minutes +const searchResponseCache = new Map() +const inFlightSearchResponses = new Map>() + // Affiliate campaign ID for generating links const AFFILIATE_CAMPAIGN_ID = process.env.EBAY_AFFILIATE_CAMPAIGN_ID?.trim() || '' @@ -34,7 +38,7 @@ function generateAffiliateLink(viewItemUrl: string): string { if (!AFFILIATE_CAMPAIGN_ID) { return viewItemUrl } - + try { const url = new URL(viewItemUrl) url.searchParams.set('mkcid', '1') @@ -78,20 +82,79 @@ function normalizeItem(item: any): eBaySearchResult { } } +async function readEbayErrorMessage(response: Response) { + const text = await response.text().catch(() => '') + if (!text.trim()) { + return `eBay API error: ${response.status}` + } + + try { + const parsed = JSON.parse(text) as any + const messages = parsed?.errorMessage?.[0]?.error?.[0] + const message = Array.isArray(messages?.message) ? messages.message[0] : messages?.message + + if (typeof message === 'string' && message.trim()) { + const errorId = Array.isArray(messages?.errorId) ? messages.errorId[0] : messages?.errorId + return errorId ? `eBay API error ${errorId}: ${message}` : `eBay API error: ${message}` + } + } catch { + // Fall through to returning the raw text below. + } + + return text.trim() || `eBay API error: ${response.status}` +} + +function buildCacheKey( + keywords: string, + categoryId: string | undefined, + sortOrder: string, + maxResults: number, +): string { + return [ + keywords.trim().toLowerCase(), + categoryId || '', + sortOrder || 'BestMatch', + String(maxResults), + ].join('|') +} + +function getCachedSearchResults(cacheKey: string): eBaySearchResult[] | null { + const cached = searchResponseCache.get(cacheKey) + + if (!cached) { + return null + } + + if (Date.now() - cached.timestamp > SEARCH_CACHE_TTL) { + searchResponseCache.delete(cacheKey) + return null + } + + return cached.results +} + +function setCachedSearchResults(cacheKey: string, results: eBaySearchResult[]) { + searchResponseCache.set(cacheKey, { + results, + timestamp: Date.now(), + }) +} + export async function GET(request: NextRequest) { const { searchParams } = new URL(request.url) - + const keywords = searchParams.get('keywords') const categoryId = searchParams.get('categoryId') || undefined const sortOrder = searchParams.get('sortOrder') || 'BestMatch' const maxResults = parseInt(searchParams.get('maxResults') || '6', 10) - + const cacheKey = buildCacheKey(keywords || '', categoryId, sortOrder, maxResults) + if (!keywords) { return NextResponse.json({ error: 'Keywords parameter is required' }, { status: 400 }) } - + const appId = process.env.EBAY_APP_ID?.trim() - + if (!appId) { console.error('EBAY_APP_ID not configured') return NextResponse.json( @@ -99,11 +162,29 @@ export async function GET(request: NextRequest) { { status: 503 } ) } - + + const cachedResults = getCachedSearchResults(cacheKey) + if (cachedResults) { + return NextResponse.json(cachedResults) + } + + const inFlight = inFlightSearchResponses.get(cacheKey) + if (inFlight) { + try { + const results = await inFlight + return NextResponse.json(results) + } catch (error) { + return NextResponse.json( + { error: error instanceof Error ? error.message : 'Failed to fetch products from eBay' }, + { status: 500 } + ) + } + } + // Build eBay Finding API URL const baseUrl = 'https://svcs.ebay.com/services/search/FindingService/v1' const url = new URL(baseUrl) - + url.searchParams.set('OPERATION-NAME', 'findItemsAdvanced') url.searchParams.set('SERVICE-VERSION', '1.0.0') url.searchParams.set('SECURITY-APPNAME', appId) @@ -112,52 +193,56 @@ export async function GET(request: NextRequest) { url.searchParams.set('keywords', keywords) url.searchParams.set('sortOrder', sortOrder) url.searchParams.set('paginationInput.entriesPerPage', maxResults.toString()) - + 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() - console.error('eBay API error:', response.status, errorText) - return NextResponse.json( - { error: `eBay API error: ${response.status}` }, - { status: response.status } - ) - } - - const data = await response.json() - - // Parse eBay API response - const findItemsAdvancedResponse = data.findItemsAdvancedResponse?.[0] - if (!findItemsAdvancedResponse) { - return NextResponse.json([]) - } - - const searchResult = findItemsAdvancedResponse.searchResult?.[0] - if (!searchResult || !searchResult.item || searchResult.item.length === 0) { - return NextResponse.json([]) - } - - const items = Array.isArray(searchResult.item) ? searchResult.item : [searchResult.item] - const results: eBaySearchResult[] = items.map((item: any) => normalizeItem(item)) - + try { + const request = (async () => { + const response = await fetch(url.toString(), { + method: 'GET', + headers: { + 'Accept': 'application/json', + }, + }) + + if (!response.ok) { + const errorMessage = await readEbayErrorMessage(response) + throw new Error(errorMessage) + } + + const data = await response.json() + + // Parse eBay API response + 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) => normalizeItem(item)) + })() + + inFlightSearchResponses.set(cacheKey, request) + + const results = await request + setCachedSearchResults(cacheKey, results) return NextResponse.json(results) - + } catch (error) { console.error('Error fetching from eBay API:', error) return NextResponse.json( - { error: 'Failed to fetch products from eBay' }, + { error: error instanceof Error ? error.message : 'Failed to fetch products from eBay' }, { status: 500 } ) + } finally { + inFlightSearchResponses.delete(cacheKey) } } diff --git a/components/parts-panel.tsx b/components/parts-panel.tsx index 1712a73b..a7d48d24 100644 --- a/components/parts-panel.tsx +++ b/components/parts-panel.tsx @@ -88,15 +88,20 @@ export function PartsPanel({ manualFilename, className = '' }: PartsPanelProps) } if (error && !hasListings) { + const loweredError = error.toLowerCase() + const statusMessage = error.includes('eBay API not configured') + ? 'Set EBAY_APP_ID in the app environment so live listings can load.' + : loweredError.includes('rate limit') || loweredError.includes('exceeded') + ? 'eBay is temporarily rate-limited. Try again in a minute.' + : error + return renderStatusCard( 'eBay unavailable', - error.includes('eBay API not configured') - ? 'Set EBAY_APP_ID in the app environment so live listings can load.' - : error, + statusMessage, ) } - if (parts.length === 0 || !hasListings) { + if (parts.length === 0) { return (
@@ -109,7 +114,26 @@ export function PartsPanel({ manualFilename, className = '' }: PartsPanelProps)
- No parts available + No parts data extracted for this manual yet +
+
+ ) + } + + if (!hasListings) { + return ( +
+
+
+ + + Parts + +
+
+
+ + No live eBay matches found for these parts yet
) @@ -138,6 +162,8 @@ export function PartsPanel({ manualFilename, className = '' }: PartsPanelProps)

{error.includes('eBay API not configured') ? 'Set EBAY_APP_ID in the app environment, then reload the panel.' + : error.toLowerCase().includes('rate limit') || error.toLowerCase().includes('exceeded') + ? 'eBay is temporarily rate-limited. Reload after a short wait.' : error}

diff --git a/lib/parts-lookup.ts b/lib/parts-lookup.ts index e89c3af6..08385ade 100644 --- a/lib/parts-lookup.ts +++ b/lib/parts-lookup.ts @@ -51,8 +51,35 @@ interface EbaySearchResponse { // Cache for eBay search results const ebaySearchCache = new Map() +const inFlightEbaySearches = new Map>() const EBAY_CACHE_TTL = 15 * 60 * 1000 // 15 minutes +const GENERIC_PART_TERMS = new Set([ + 'and', + 'the', + 'for', + 'with', + 'from', + 'page', + 'part', + 'parts', + 'number', + 'numbers', + 'read', + 'across', + 'refer', + 'reference', + 'shown', + 'figure', + 'fig', + 'rev', + 'revision', + 'item', + 'items', + 'assembly', + 'assy', +]) + let manualPartsCache: ManualPartsLookup | null = null let manualPagesPartsCache: ManualPagesParts | null = null @@ -116,7 +143,7 @@ async function searchEBayForParts( if (cached && Date.now() - cached.timestamp < EBAY_CACHE_TTL) { return { results: cached.results as EbaySearchResult[] } } - + const buildQuery = () => { let query = partNumber @@ -140,6 +167,13 @@ async function searchEBayForParts( } const searchViaApi = async (categoryId?: string): Promise => { + const requestKey = `${cacheKey}:${categoryId || 'general'}` + + const inFlight = inFlightEbaySearches.get(requestKey) + if (inFlight) { + return inFlight + } + const params = new URLSearchParams({ keywords: buildQuery(), maxResults: '3', @@ -150,26 +184,36 @@ async function searchEBayForParts( params.set('categoryId', categoryId) } + const request = (async () => { + try { + const response = await fetch(`/api/ebay/search?${params.toString()}`) + const body = await response.json().catch(() => null) + + if (!response.ok) { + const message = + body && typeof body.error === 'string' + ? body.error + : `eBay API error: ${response.status}` + + return { results: [], error: message } + } + + const results = Array.isArray(body) ? body : [] + return { results } + } catch (error) { + return { + results: [], + error: error instanceof Error ? error.message : 'Failed to search eBay', + } + } + })() + + inFlightEbaySearches.set(requestKey, request) + try { - const response = await fetch(`/api/ebay/search?${params.toString()}`) - const body = await response.json().catch(() => null) - - if (!response.ok) { - const message = - body && typeof body.error === 'string' - ? body.error - : `eBay API error: ${response.status}` - - return { results: [], error: message } - } - - const results = Array.isArray(body) ? body : [] - return { results } - } catch (error) { - return { - results: [], - error: error instanceof Error ? error.message : 'Failed to search eBay', - } + return await request + } finally { + inFlightEbaySearches.delete(requestKey) } } @@ -197,22 +241,105 @@ async function searchEBayForParts( } } +function normalizePartText(value: string): string { + return value.trim().toLowerCase() +} + +function isSyntheticEbayListing(listing: PartForPage['ebayListings'][number]): boolean { + const itemId = listing.itemId?.trim() || '' + const viewItemUrl = listing.viewItemUrl?.trim() || '' + const imageUrl = listing.imageUrl?.trim() || '' + + return ( + imageUrl.includes('images.unsplash.com') || + viewItemUrl.includes('123456789') || + itemId.startsWith('123456789') + ) +} + +function hasLiveEbayListings(listings: PartForPage['ebayListings']): boolean { + return listings.some((listing) => !isSyntheticEbayListing(listing)) +} + +function scorePartForLiveSearch(part: PartForPage, index: number): number { + const partNumber = part.partNumber.trim() + const description = part.description.trim() + const lowerPartNumber = normalizePartText(partNumber) + const lowerDescription = normalizePartText(description) + + if (!partNumber) { + return -1000 + } + + if (GENERIC_PART_TERMS.has(lowerPartNumber)) { + return -500 + } + + let score = 0 + + // Prefer actual part numbers, model numbers, and revision codes over prose. + if (/\d/.test(partNumber)) { + score += 30 + } + if (/[a-z]/i.test(partNumber) && /\d/.test(partNumber)) { + score += 20 + } + if (/^[a-z0-9][a-z0-9\-._/ ]{2,24}$/i.test(partNumber)) { + score += 10 + } + if (partNumber.length >= 4 && partNumber.length <= 20) { + score += 8 + } + + if (description && description.length <= 80) { + score += 6 + } + + if (description && !GENERIC_PART_TERMS.has(lowerDescription)) { + score += 4 + } + + if (lowerPartNumber.includes('rev') || lowerPartNumber.includes('figure')) { + score -= 10 + } + + // Prefer earlier entries when scores are similar. + score -= index * 0.01 + + return score +} + +function selectPartsForLiveSearch(parts: PartForPage[], limit: number): PartForPage[] { + return parts + .map((part, index) => ({ + part, + score: scorePartForLiveSearch(part, index), + })) + .sort((a, b) => b.score - a.score) + .slice(0, limit) + .map(({ part }) => part) +} + async function enhancePartsData(parts: PartForPage[]): Promise<{ parts: PartForPage[] error?: string }> { let firstError: string | undefined - const enhancedParts = await Promise.all(parts.map(async (part) => { - // Only search for parts without existing eBay listings - if (part.ebayListings.length === 0) { + const enhancedParts: PartForPage[] = [] + + for (const part of parts) { + const shouldRefreshListings = + part.ebayListings.length === 0 || !hasLiveEbayListings(part.ebayListings) + + if (shouldRefreshListings) { const ebayResults = await searchEBayForParts(part.partNumber, part.description) if (ebayResults.error && !firstError) { firstError = ebayResults.error } - return { + enhancedParts.push({ ...part, ebayListings: ebayResults.results.map((result) => ({ itemId: result.itemId, @@ -225,11 +352,12 @@ async function enhancePartsData(parts: PartForPage[]): Promise<{ shippingCost: result.shippingCost, affiliateLink: result.affiliateLink, })), - } + }) + continue } - return part - })) + enhancedParts.push(part) + } return { parts: enhancedParts, @@ -316,11 +444,7 @@ async function getPartsForManualWithStatus( const { manualParts } = await loadPartsData() const parts = findManualParts(manualFilename, manualParts) - if (parts.length === 0) { - return { parts: [] } - } - - return enhancePartsData(parts) + return { parts } } /** @@ -361,10 +485,25 @@ export async function getTopPartsForManual( parts: PartForPage[] error?: string }> { - const { parts, error } = await getPartsForManualWithStatus(manualFilename) - - // Sort by number of eBay listings (more listings = more relevant) - const sorted = parts.sort((a, b) => b.ebayListings.length - a.ebayListings.length) + const { parts } = await getPartsForManualWithStatus(manualFilename) + + if (parts.length === 0) { + return { parts: [] } + } + + const liveSearchCandidates = selectPartsForLiveSearch(parts, Math.max(limit * 2, limit)) + const { parts: enrichedParts, error } = await enhancePartsData(liveSearchCandidates) + + const sorted = enrichedParts.sort((a, b) => { + const aHasLiveListings = hasLiveEbayListings(a.ebayListings) ? 1 : 0 + const bHasLiveListings = hasLiveEbayListings(b.ebayListings) ? 1 : 0 + + if (aHasLiveListings !== bHasLiveListings) { + return bHasLiveListings - aHasLiveListings + } + + return b.ebayListings.length - a.ebayListings.length + }) return { parts: sorted.slice(0, limit),