deploy: fix manuals eBay live search
This commit is contained in:
parent
60b70e46ab
commit
1948fd564e
3 changed files with 335 additions and 85 deletions
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
Loading…
Reference in a new issue