659 lines
16 KiB
TypeScript
659 lines
16 KiB
TypeScript
/**
|
|
* Parts lookup utility for frontend
|
|
*
|
|
* Provides functions to fetch parts data by manual filename.
|
|
* Static JSON remains the primary data source, while cached eBay matches
|
|
* are fetched from the server so normal browsing never reaches eBay.
|
|
*/
|
|
|
|
import type {
|
|
CachedEbayListing,
|
|
EbayCacheState,
|
|
ManualPartInput,
|
|
} from "@/lib/ebay-parts-match"
|
|
import {
|
|
filterTrustedEbayListings,
|
|
isSyntheticEbayListing,
|
|
} from "@/lib/ebay-parts-match"
|
|
|
|
export interface PartForPage {
|
|
partNumber: string
|
|
description: string
|
|
ebayListings: CachedEbayListing[]
|
|
}
|
|
|
|
interface ManualPartsLookup {
|
|
[manualFilename: string]: PartForPage[]
|
|
}
|
|
|
|
interface ManualPagesParts {
|
|
[manualFilename: string]: {
|
|
[pageNumber: string]: PartForPage[]
|
|
}
|
|
}
|
|
|
|
interface CachedPartsResponse {
|
|
manualFilename: string
|
|
parts: Array<
|
|
ManualPartInput & {
|
|
ebayListings: CachedEbayListing[]
|
|
}
|
|
>
|
|
cache: EbayCacheState
|
|
cacheSource?: "convex" | "fallback"
|
|
error?: string
|
|
}
|
|
|
|
interface CachedEbaySearchResponse {
|
|
results: CachedEbayListing[]
|
|
cache: EbayCacheState
|
|
cacheSource?: "convex" | "fallback"
|
|
error?: string
|
|
}
|
|
|
|
const cachedManualMatchResponses = new Map<
|
|
string,
|
|
{ response: CachedPartsResponse; timestamp: number }
|
|
>()
|
|
const inFlightManualMatchRequests = new Map<string, Promise<CachedPartsResponse>>()
|
|
const MANUAL_MATCH_CACHE_TTL = 5 * 60 * 1000
|
|
const cachedEbaySearchResponses = new Map<
|
|
string,
|
|
{ response: CachedEbaySearchResponse; timestamp: number }
|
|
>()
|
|
const inFlightEbaySearches = new Map<
|
|
string,
|
|
Promise<CachedEbaySearchResponse>
|
|
>()
|
|
const EBAY_SEARCH_CACHE_TTL = 5 * 60 * 1000
|
|
|
|
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: {},
|
|
}
|
|
}
|
|
|
|
function makeFallbackCacheState(errorMessage?: string): EbayCacheState {
|
|
return {
|
|
key: "manual-parts",
|
|
status: "disabled",
|
|
lastSuccessfulAt: null,
|
|
lastAttemptAt: null,
|
|
nextEligibleAt: null,
|
|
lastError: errorMessage || "eBay cache unavailable.",
|
|
consecutiveFailures: 0,
|
|
queryCount: 0,
|
|
itemCount: 0,
|
|
sourceQueries: [],
|
|
freshnessMs: null,
|
|
isStale: true,
|
|
listingCount: 0,
|
|
activeListingCount: 0,
|
|
message: errorMessage || "eBay cache unavailable.",
|
|
}
|
|
}
|
|
|
|
async function fetchManualPartsMatches(
|
|
manualFilename: string,
|
|
parts: ManualPartInput[],
|
|
limit: number
|
|
): Promise<CachedPartsResponse> {
|
|
const cacheKey = [
|
|
manualFilename.trim().toLowerCase(),
|
|
String(limit),
|
|
parts
|
|
.map((part) =>
|
|
[
|
|
part.partNumber.trim().toLowerCase(),
|
|
part.description.trim().toLowerCase(),
|
|
part.manufacturer?.trim().toLowerCase() || "",
|
|
part.category?.trim().toLowerCase() || "",
|
|
].join(":")
|
|
)
|
|
.join("|"),
|
|
].join("::")
|
|
|
|
const cached = cachedManualMatchResponses.get(cacheKey)
|
|
if (cached && Date.now() - cached.timestamp < MANUAL_MATCH_CACHE_TTL) {
|
|
return cached.response
|
|
}
|
|
|
|
const inFlight = inFlightManualMatchRequests.get(cacheKey)
|
|
if (inFlight) {
|
|
return inFlight
|
|
}
|
|
|
|
const request = (async () => {
|
|
try {
|
|
const response = await fetch("/api/ebay/manual-parts", {
|
|
method: "POST",
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
},
|
|
body: JSON.stringify({
|
|
manualFilename,
|
|
parts,
|
|
limit,
|
|
}),
|
|
})
|
|
|
|
const body = await response.json().catch(() => null)
|
|
if (!response.ok || !body || typeof body !== "object") {
|
|
const message =
|
|
body && typeof body.error === "string"
|
|
? body.error
|
|
: `Failed to load cached parts (${response.status})`
|
|
return {
|
|
manualFilename,
|
|
parts: parts.map((part) => ({
|
|
...part,
|
|
ebayListings: [],
|
|
})),
|
|
cache: makeFallbackCacheState(message),
|
|
error: message,
|
|
}
|
|
}
|
|
|
|
const partsResponse = body as CachedPartsResponse
|
|
return {
|
|
manualFilename: partsResponse.manualFilename || manualFilename,
|
|
parts: Array.isArray(partsResponse.parts) ? partsResponse.parts : [],
|
|
cache: partsResponse.cache || makeFallbackCacheState(),
|
|
error:
|
|
typeof (partsResponse as CachedPartsResponse).error === "string"
|
|
? (partsResponse as CachedPartsResponse).error
|
|
: undefined,
|
|
}
|
|
} catch (error) {
|
|
const message =
|
|
error instanceof Error ? error.message : "Failed to load cached parts"
|
|
return {
|
|
manualFilename,
|
|
parts: parts.map((part) => ({
|
|
...part,
|
|
ebayListings: [],
|
|
})),
|
|
cache: makeFallbackCacheState(message),
|
|
error: message,
|
|
}
|
|
}
|
|
})()
|
|
|
|
inFlightManualMatchRequests.set(cacheKey, request)
|
|
try {
|
|
const response = await request
|
|
cachedManualMatchResponses.set(cacheKey, {
|
|
response,
|
|
timestamp: Date.now(),
|
|
})
|
|
return response
|
|
} finally {
|
|
inFlightManualMatchRequests.delete(cacheKey)
|
|
}
|
|
}
|
|
|
|
async function searchEBayForParts(
|
|
partNumber: string,
|
|
description?: string,
|
|
manufacturer?: string
|
|
): Promise<CachedEbaySearchResponse> {
|
|
const cacheKey = `parts:${partNumber}:${description || ""}:${manufacturer || ""}`
|
|
|
|
const cached = cachedEbaySearchResponses.get(cacheKey)
|
|
if (cached && Date.now() - cached.timestamp < EBAY_SEARCH_CACHE_TTL) {
|
|
return cached.response
|
|
}
|
|
|
|
const inFlight = inFlightEbaySearches.get(cacheKey)
|
|
if (inFlight) {
|
|
return inFlight
|
|
}
|
|
|
|
const request = (async () => {
|
|
try {
|
|
const params = new URLSearchParams({
|
|
keywords: [partNumber, description, manufacturer, "vending machine"]
|
|
.filter(Boolean)
|
|
.join(" "),
|
|
maxResults: "3",
|
|
sortOrder: "BestMatch",
|
|
})
|
|
|
|
const response = await fetch(`/api/ebay/search?${params.toString()}`)
|
|
const body = await response.json().catch(() => null)
|
|
|
|
if (!response.ok || !body || typeof body !== "object") {
|
|
const message =
|
|
body && typeof body.error === "string"
|
|
? body.error
|
|
: `Failed to load cached eBay listings (${response.status})`
|
|
return {
|
|
results: [],
|
|
cache: makeFallbackCacheState(message),
|
|
error: message,
|
|
}
|
|
}
|
|
|
|
return {
|
|
results: Array.isArray((body as any).results)
|
|
? ((body as any).results as CachedEbayListing[])
|
|
: [],
|
|
cache: (body as any).cache || makeFallbackCacheState(),
|
|
error:
|
|
typeof (body as any).error === "string" ? (body as any).error : undefined,
|
|
}
|
|
} catch (error) {
|
|
const message =
|
|
error instanceof Error ? error.message : "Failed to load cached eBay listings"
|
|
return {
|
|
results: [],
|
|
cache: makeFallbackCacheState(message),
|
|
error: message,
|
|
}
|
|
}
|
|
})()
|
|
|
|
inFlightEbaySearches.set(cacheKey, request)
|
|
try {
|
|
const response = await request
|
|
cachedEbaySearchResponses.set(cacheKey, {
|
|
response,
|
|
timestamp: Date.now(),
|
|
})
|
|
return response
|
|
} finally {
|
|
inFlightEbaySearches.delete(cacheKey)
|
|
}
|
|
}
|
|
|
|
function normalizePartText(value: string): string {
|
|
return value.trim().toLowerCase()
|
|
}
|
|
|
|
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 }
|
|
}
|
|
|
|
function sanitizePartListings(parts: PartForPage[]): PartForPage[] {
|
|
return parts.map((part) => ({
|
|
...part,
|
|
ebayListings: filterTrustedEbayListings(part.ebayListings || []),
|
|
}))
|
|
}
|
|
|
|
/**
|
|
* Get all parts for a manual with enhanced eBay data
|
|
*/
|
|
export async function getPartsForManual(
|
|
manualFilename: string
|
|
): Promise<PartForPage[]> {
|
|
const result = await getPartsForManualWithStatus(manualFilename)
|
|
return sanitizePartListings(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 matched = await fetchManualPartsMatches(
|
|
manualFilename,
|
|
parts.map((part) => ({
|
|
partNumber: part.partNumber,
|
|
description: part.description,
|
|
manualFilename,
|
|
})),
|
|
Math.max(parts.length, 1)
|
|
)
|
|
|
|
return sanitizePartListings(matched.parts as PartForPage[])
|
|
}
|
|
|
|
/**
|
|
* 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
|
|
cache?: EbayCacheState
|
|
}> {
|
|
const { parts } = await getPartsForManualWithStatus(manualFilename)
|
|
|
|
if (parts.length === 0) {
|
|
return { parts: [] }
|
|
}
|
|
|
|
const liveSearchCandidates = selectPartsForLiveSearch(
|
|
parts,
|
|
Math.max(limit * 2, limit)
|
|
)
|
|
const matched = await fetchManualPartsMatches(
|
|
manualFilename,
|
|
liveSearchCandidates.map((part) => ({
|
|
partNumber: part.partNumber,
|
|
description: part.description,
|
|
manualFilename,
|
|
})),
|
|
limit
|
|
)
|
|
|
|
return {
|
|
parts: sanitizePartListings(matched.parts as PartForPage[]),
|
|
error: matched.error,
|
|
cache: matched.cache,
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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
|
|
}
|