deploy: fix manuals eBay live search

This commit is contained in:
DMleadgen 2026-04-01 15:23:47 -06:00
parent 60b70e46ab
commit 1948fd564e
Signed by: matt
GPG key ID: C2720CF8CD701894
3 changed files with 335 additions and 85 deletions

View file

@ -26,6 +26,10 @@ interface eBaySearchResult {
type MaybeArray<T> = T | T[]
const SEARCH_CACHE_TTL = 15 * 60 * 1000 // 15 minutes
const searchResponseCache = new Map<string, { results: eBaySearchResult[]; timestamp: number }>()
const inFlightSearchResponses = new Map<string, Promise<eBaySearchResult[]>>()
// 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)
}
}

View file

@ -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 (
<div className={`flex flex-col h-full ${className}`}>
<div className="px-3 py-2 border-b border-yellow-300/20 flex-shrink-0 bg-yellow-100/50 dark:bg-yellow-900/30">
@ -109,7 +114,26 @@ export function PartsPanel({ manualFilename, className = '' }: PartsPanelProps)
</div>
<div className="px-3 py-3 text-xs text-yellow-900/70 dark:text-yellow-100/70 flex items-center justify-center">
<AlertCircle className="h-4 w-4 mr-2 text-yellow-700 dark:text-yellow-300" />
No parts available
No parts data extracted for this manual yet
</div>
</div>
)
}
if (!hasListings) {
return (
<div className={`flex flex-col h-full ${className}`}>
<div className="px-3 py-2 border-b border-yellow-300/20 flex-shrink-0 bg-yellow-100/50 dark:bg-yellow-900/30">
<div className="flex items-center gap-1.5">
<ShoppingCart className="h-3.5 w-3.5 text-yellow-900 dark:text-yellow-100" />
<span className="text-xs font-semibold text-yellow-900 dark:text-yellow-100">
Parts
</span>
</div>
</div>
<div className="px-3 py-3 text-xs text-yellow-900/70 dark:text-yellow-100/70 flex items-center justify-center">
<AlertCircle className="h-4 w-4 mr-2 text-yellow-700 dark:text-yellow-300" />
No live eBay matches found for these parts yet
</div>
</div>
)
@ -138,6 +162,8 @@ export function PartsPanel({ manualFilename, className = '' }: PartsPanelProps)
<p className="mt-0.5 text-yellow-900/70 dark:text-yellow-100/70">
{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}
</p>
</div>

View file

@ -51,8 +51,35 @@ interface EbaySearchResponse {
// Cache for eBay search results
const ebaySearchCache = new Map<string, { results: EbaySearchResult[]; timestamp: number }>()
const inFlightEbaySearches = new Map<string, Promise<EbaySearchResponse>>()
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<EbaySearchResponse> => {
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),