549 lines
14 KiB
TypeScript
549 lines
14 KiB
TypeScript
/**
|
|
* Parts lookup utility for frontend
|
|
*
|
|
* Provides functions to fetch parts data by manual filename.
|
|
* Static JSON remains the primary data source, while live eBay fallback
|
|
* goes through the server route so credentials never reach the browser.
|
|
*/
|
|
|
|
export interface PartForPage {
|
|
partNumber: string
|
|
description: string
|
|
ebayListings: Array<{
|
|
itemId: string
|
|
title: string
|
|
price: string
|
|
currency: string
|
|
imageUrl?: string
|
|
viewItemUrl: string
|
|
condition?: string
|
|
shippingCost?: string
|
|
affiliateLink: string
|
|
}>
|
|
}
|
|
|
|
interface ManualPartsLookup {
|
|
[manualFilename: string]: PartForPage[]
|
|
}
|
|
|
|
interface ManualPagesParts {
|
|
[manualFilename: string]: {
|
|
[pageNumber: string]: PartForPage[]
|
|
}
|
|
}
|
|
|
|
interface EbaySearchResult {
|
|
itemId: string
|
|
title: string
|
|
price: string
|
|
currency: string
|
|
imageUrl?: string
|
|
viewItemUrl: string
|
|
condition?: string
|
|
shippingCost?: string
|
|
affiliateLink: string
|
|
}
|
|
|
|
interface EbaySearchResponse {
|
|
results: EbaySearchResult[]
|
|
error?: string
|
|
}
|
|
|
|
// 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
|
|
|
|
/**
|
|
* Load parts lookup data
|
|
*/
|
|
async function loadPartsData(): Promise<{
|
|
manualParts: ManualPartsLookup
|
|
manualPagesParts: ManualPagesParts
|
|
}> {
|
|
if (manualPartsCache && manualPagesPartsCache) {
|
|
return {
|
|
manualParts: manualPartsCache,
|
|
manualPagesParts: manualPagesPartsCache,
|
|
}
|
|
}
|
|
|
|
try {
|
|
// Try to load from public data directory (relative to public folder)
|
|
const [manualPartsResponse, manualPagesResponse] = await Promise.all([
|
|
fetch('/manual_parts_lookup.json'),
|
|
fetch('/manual_pages_parts.json'),
|
|
])
|
|
|
|
if (manualPartsResponse.ok && manualPagesResponse.ok) {
|
|
const [manualParts, manualPagesParts] = await Promise.all([
|
|
manualPartsResponse.json(),
|
|
manualPagesResponse.json(),
|
|
])
|
|
|
|
manualPartsCache = manualParts
|
|
manualPagesPartsCache = manualPagesParts
|
|
|
|
return { manualParts, manualPagesParts }
|
|
}
|
|
} catch (error) {
|
|
console.warn('Could not load parts data:', error)
|
|
}
|
|
|
|
// Return empty data if loading fails
|
|
return {
|
|
manualParts: {},
|
|
manualPagesParts: {},
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Search eBay for parts with caching.
|
|
* This calls the server route so the app never needs direct eBay credentials
|
|
* in client code.
|
|
*/
|
|
async function searchEBayForParts(
|
|
partNumber: string,
|
|
description?: string,
|
|
manufacturer?: string,
|
|
): Promise<EbaySearchResponse> {
|
|
const cacheKey = `parts:${partNumber}:${description || ''}:${manufacturer || ''}`
|
|
|
|
// Check cache
|
|
const cached = ebaySearchCache.get(cacheKey)
|
|
if (cached && Date.now() - cached.timestamp < EBAY_CACHE_TTL) {
|
|
return { results: cached.results as EbaySearchResult[] }
|
|
}
|
|
|
|
const buildQuery = () => {
|
|
let query = partNumber
|
|
|
|
if (description && description.length > 0 && description.length < 50) {
|
|
const descWords = description
|
|
.split(/\s+/)
|
|
.filter((word) => word.length > 3)
|
|
.slice(0, 3)
|
|
.join(' ')
|
|
|
|
if (descWords) {
|
|
query += ` ${descWords}`
|
|
}
|
|
}
|
|
|
|
if (manufacturer) {
|
|
query += ` ${manufacturer}`
|
|
}
|
|
|
|
return `${query} vending machine`
|
|
}
|
|
|
|
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',
|
|
sortOrder: 'BestMatch',
|
|
})
|
|
|
|
if (categoryId) {
|
|
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 {
|
|
return await request
|
|
} finally {
|
|
inFlightEbaySearches.delete(requestKey)
|
|
}
|
|
}
|
|
|
|
const categorySearch = await searchViaApi('11700')
|
|
if (categorySearch.results.length > 0) {
|
|
ebaySearchCache.set(cacheKey, {
|
|
results: categorySearch.results,
|
|
timestamp: Date.now(),
|
|
})
|
|
return categorySearch
|
|
}
|
|
|
|
const generalSearch = await searchViaApi()
|
|
if (generalSearch.results.length > 0) {
|
|
ebaySearchCache.set(cacheKey, {
|
|
results: generalSearch.results,
|
|
timestamp: Date.now(),
|
|
})
|
|
return generalSearch
|
|
}
|
|
|
|
return {
|
|
results: [],
|
|
error: categorySearch.error || generalSearch.error,
|
|
}
|
|
}
|
|
|
|
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: 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
|
|
}
|
|
|
|
enhancedParts.push({
|
|
...part,
|
|
ebayListings: ebayResults.results.map((result) => ({
|
|
itemId: result.itemId,
|
|
title: result.title,
|
|
price: result.price,
|
|
currency: result.currency,
|
|
imageUrl: result.imageUrl,
|
|
viewItemUrl: result.viewItemUrl,
|
|
condition: result.condition,
|
|
shippingCost: result.shippingCost,
|
|
affiliateLink: result.affiliateLink,
|
|
})),
|
|
})
|
|
continue
|
|
}
|
|
|
|
enhancedParts.push(part)
|
|
}
|
|
|
|
return {
|
|
parts: enhancedParts,
|
|
error: firstError,
|
|
}
|
|
}
|
|
|
|
function findManualParts(
|
|
manualFilename: string,
|
|
manualParts: ManualPartsLookup,
|
|
): PartForPage[] {
|
|
if (manualParts[manualFilename]) {
|
|
return manualParts[manualFilename]
|
|
}
|
|
|
|
const lowerFilename = manualFilename.toLowerCase()
|
|
for (const [filename, parts] of Object.entries(manualParts)) {
|
|
if (filename.toLowerCase() === lowerFilename) {
|
|
return parts
|
|
}
|
|
}
|
|
|
|
const filenameWithoutExt = manualFilename.replace(/\.pdf$/i, '')
|
|
const lowerWithoutExt = filenameWithoutExt.toLowerCase()
|
|
|
|
for (const [filename, parts] of Object.entries(manualParts)) {
|
|
const otherWithoutExt = filename.replace(/\.pdf$/i, '').toLowerCase()
|
|
if (
|
|
otherWithoutExt === lowerWithoutExt ||
|
|
otherWithoutExt.includes(lowerWithoutExt) ||
|
|
lowerWithoutExt.includes(otherWithoutExt)
|
|
) {
|
|
return parts
|
|
}
|
|
}
|
|
|
|
return []
|
|
}
|
|
|
|
function findManualPagesParts(
|
|
manualFilename: string,
|
|
pageNumber: number,
|
|
manualPagesParts: ManualPagesParts,
|
|
): PartForPage[] {
|
|
if (
|
|
manualPagesParts[manualFilename] &&
|
|
manualPagesParts[manualFilename][pageNumber.toString()]
|
|
) {
|
|
return manualPagesParts[manualFilename][pageNumber.toString()]
|
|
}
|
|
|
|
const lowerFilename = manualFilename.toLowerCase()
|
|
for (const [filename, pages] of Object.entries(manualPagesParts)) {
|
|
if (filename.toLowerCase() === lowerFilename && pages[pageNumber.toString()]) {
|
|
return pages[pageNumber.toString()]
|
|
}
|
|
}
|
|
|
|
const filenameWithoutExt = manualFilename.replace(/\.pdf$/i, '')
|
|
const lowerWithoutExt = filenameWithoutExt.toLowerCase()
|
|
|
|
for (const [filename, pages] of Object.entries(manualPagesParts)) {
|
|
const otherWithoutExt = filename.replace(/\.pdf$/i, '').toLowerCase()
|
|
if (
|
|
otherWithoutExt === lowerWithoutExt ||
|
|
otherWithoutExt.includes(lowerWithoutExt) ||
|
|
lowerWithoutExt.includes(otherWithoutExt)
|
|
) {
|
|
if (pages[pageNumber.toString()]) {
|
|
return pages[pageNumber.toString()]
|
|
}
|
|
}
|
|
}
|
|
|
|
return []
|
|
}
|
|
|
|
async function getPartsForManualWithStatus(
|
|
manualFilename: string,
|
|
): Promise<{
|
|
parts: PartForPage[]
|
|
error?: string
|
|
}> {
|
|
const { manualParts } = await loadPartsData()
|
|
const parts = findManualParts(manualFilename, manualParts)
|
|
|
|
return { parts }
|
|
}
|
|
|
|
/**
|
|
* Get all parts for a manual with enhanced eBay data
|
|
*/
|
|
export async function getPartsForManual(
|
|
manualFilename: string
|
|
): Promise<PartForPage[]> {
|
|
const result = await getPartsForManualWithStatus(manualFilename)
|
|
return result.parts
|
|
}
|
|
|
|
/**
|
|
* Get parts for a specific page of a manual with enhanced eBay data
|
|
*/
|
|
export async function getPartsForPage(
|
|
manualFilename: string,
|
|
pageNumber: number
|
|
): Promise<PartForPage[]> {
|
|
const { manualPagesParts } = await loadPartsData()
|
|
const parts = findManualPagesParts(manualFilename, pageNumber, manualPagesParts)
|
|
|
|
if (parts.length === 0) {
|
|
return []
|
|
}
|
|
|
|
const enhanced = await enhancePartsData(parts)
|
|
return enhanced.parts
|
|
}
|
|
|
|
/**
|
|
* Get top N parts for a manual (most relevant) with enhanced eBay data
|
|
*/
|
|
export async function getTopPartsForManual(
|
|
manualFilename: string,
|
|
limit: number = 5
|
|
): Promise<{
|
|
parts: PartForPage[]
|
|
error?: string
|
|
}> {
|
|
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),
|
|
error,
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Check if a manual has parts available
|
|
*/
|
|
export async function hasPartsForManual(manualFilename: string): Promise<boolean> {
|
|
const parts = await getPartsForManual(manualFilename)
|
|
return parts.length > 0
|
|
}
|
|
|
|
/**
|
|
* Get a set of all manual filenames that have parts available
|
|
* Useful for filtering
|
|
*/
|
|
export async function getManualsWithParts(): Promise<Set<string>> {
|
|
const { manualParts } = await loadPartsData()
|
|
const manualsWithParts = new Set<string>()
|
|
|
|
for (const [filename, parts] of Object.entries(manualParts)) {
|
|
if (parts.length > 0) {
|
|
manualsWithParts.add(filename)
|
|
// Also add variations for matching
|
|
manualsWithParts.add(filename.toLowerCase())
|
|
manualsWithParts.add(filename.replace(/\.pdf$/i, ''))
|
|
manualsWithParts.add(filename.replace(/\.pdf$/i, '').toLowerCase())
|
|
}
|
|
}
|
|
|
|
return manualsWithParts
|
|
}
|
|
|
|
/**
|
|
* Clear cache (useful for development)
|
|
*/
|
|
export function clearPartsCache(): void {
|
|
manualPartsCache = null
|
|
manualPagesPartsCache = null
|
|
}
|