Rocky_Mountain_Vending/lib/parts-lookup.ts

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
}