export type CachedEbayListing = { itemId: string title: string price: string currency: string imageUrl?: string viewItemUrl: string condition?: string shippingCost?: string affiliateLink: string normalizedTitle?: string sourceQueries?: string[] fetchedAt?: number firstSeenAt?: number lastSeenAt?: number expiresAt?: number active?: boolean } export type ManualPartInput = { partNumber: string description: string manufacturer?: string category?: string manualFilename?: string } export type EbayCacheState = { key: string status: | "idle" | "success" | "rate_limited" | "error" | "missing_config" | "skipped" | "disabled" lastSuccessfulAt: number | null lastAttemptAt: number | null nextEligibleAt: number | null lastError: string | null consecutiveFailures: number queryCount: number itemCount: number sourceQueries: string[] freshnessMs: number | null isStale: boolean listingCount?: number activeListingCount?: number message?: string } 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", "machine", "vending", ]) const COMMON_QUERY_STOPWORDS = new Set([ "a", "an", "and", "for", "in", "of", "the", "to", "with", "vending", "machine", "machines", "part", "parts", ]) function normalizeText(value: string): string { return value.toLowerCase().replace(/[^a-z0-9]+/g, " ").replace(/\s+/g, " ").trim() } function tokenize(value: string): string[] { return Array.from( new Set( normalizeText(value) .split(" ") .map((token) => token.trim()) .filter((token) => token.length > 1 && !COMMON_QUERY_STOPWORDS.has(token)) ) ) } function listingSearchText(listing: Pick): string { return normalizeText(listing.normalizedTitle || listing.title) } function isListingFresh(listing: CachedEbayListing): boolean { if (listing.active === false) { return false } if (typeof listing.expiresAt === "number") { return listing.expiresAt >= Date.now() } return true } function sourceQueryBonus(listing: CachedEbayListing, queryTerms: string[]): number { if (!listing.sourceQueries || listing.sourceQueries.length === 0) { return 0 } const sourceText = listing.sourceQueries.map((query) => normalizeText(query)).join(" ") let bonus = 0 for (const term of queryTerms) { if (sourceText.includes(term)) { bonus += 3 } } return bonus } function computeTokenOverlapScore(queryTerms: string[], haystackText: string): number { let score = 0 for (const term of queryTerms) { if (haystackText.includes(term)) { score += 8 } } return score } function scoreListingForPart(part: ManualPartInput, listing: CachedEbayListing): number { const partNumber = normalizeText(part.partNumber) const description = normalizeText(part.description) const manufacturer = normalizeText(part.manufacturer || "") const category = normalizeText(part.category || "") const titleText = listingSearchText(listing) const listingTokens = tokenize(listing.title) const descriptionTokens = tokenize(part.description) const manufacturerTokens = tokenize(part.manufacturer || "") const categoryTokens = tokenize(part.category || "") let score = isListingFresh(listing) ? 10 : -6 if (!partNumber) { return -100 } if (titleText.includes(partNumber)) { score += 110 } const compactPartNumber = partNumber.replace(/\s+/g, "") const compactTitle = titleText.replace(/\s+/g, "") if (compactPartNumber && compactTitle.includes(compactPartNumber)) { score += 90 } const exactTokenMatch = listingTokens.includes(partNumber) if (exactTokenMatch) { score += 80 } const digitsOnlyPart = partNumber.replace(/[^0-9]/g, "") if (digitsOnlyPart.length >= 4 && compactTitle.includes(digitsOnlyPart)) { score += 40 } if (description) { const overlap = descriptionTokens.filter((token) => titleText.includes(token)).length score += Math.min(overlap * 7, 28) } if (manufacturer) { score += Math.min( manufacturerTokens.filter((token) => titleText.includes(token)).length * 8, 24 ) } if (category) { score += Math.min( categoryTokens.filter((token) => titleText.includes(token)).length * 5, 10 ) } score += computeTokenOverlapScore(tokenize(part.partNumber), titleText) score += sourceQueryBonus(listing, [ partNumber, ...descriptionTokens, ...manufacturerTokens, ...categoryTokens, ]) if (GENERIC_PART_TERMS.has(partNumber)) { score -= 50 } if (titleText.includes("vending") || titleText.includes("machine")) { score += 6 } if (listing.condition && /new|used|refurbished/i.test(listing.condition)) { score += 2 } return score } function scoreListingForQuery(query: string, listing: CachedEbayListing): number { const queryText = normalizeText(query) const titleText = listingSearchText(listing) const queryTerms = tokenize(query) let score = isListingFresh(listing) ? 10 : -6 if (!queryText) { return -100 } if (titleText.includes(queryText)) { score += 70 } score += computeTokenOverlapScore(queryTerms, titleText) score += sourceQueryBonus(listing, queryTerms) if (queryTerms.some((term) => titleText.includes(term))) { score += 20 } if (titleText.includes("vending")) { score += 8 } if (GENERIC_PART_TERMS.has(queryText)) { score -= 30 } return score } export function rankListingsForPart( part: ManualPartInput, listings: CachedEbayListing[], limit: number ): CachedEbayListing[] { return listings .map((listing) => ({ listing, score: scoreListingForPart(part, listing), })) .sort((a, b) => { if (a.score !== b.score) { return b.score - a.score } const aFreshness = a.listing.lastSeenAt ?? a.listing.fetchedAt ?? 0 const bFreshness = b.listing.lastSeenAt ?? b.listing.fetchedAt ?? 0 return bFreshness - aFreshness }) .slice(0, limit) .filter((entry) => entry.score > 0) .map((entry) => entry.listing) } export function rankListingsForQuery( query: string, listings: CachedEbayListing[], limit: number ): CachedEbayListing[] { return listings .map((listing) => ({ listing, score: scoreListingForQuery(query, listing), })) .sort((a, b) => { if (a.score !== b.score) { return b.score - a.score } const aFreshness = a.listing.lastSeenAt ?? a.listing.fetchedAt ?? 0 const bFreshness = b.listing.lastSeenAt ?? b.listing.fetchedAt ?? 0 return bFreshness - aFreshness }) .slice(0, limit) .filter((entry) => entry.score > 0) .map((entry) => entry.listing) } export function isEbayRateLimitError(message: string): boolean { const normalized = message.toLowerCase() return ( normalized.includes("10001") || normalized.includes("rate limit") || normalized.includes("exceeded the number of times") || normalized.includes("too many requests") || normalized.includes("quota") ) } export function buildAffiliateLink( viewItemUrl: string, campaignId?: string | null ): string { const trimmedCampaignId = campaignId?.trim() || "" if (!trimmedCampaignId) { return viewItemUrl } try { const url = new URL(viewItemUrl) url.searchParams.set("mkcid", "1") url.searchParams.set("mkrid", "711-53200-19255-0") url.searchParams.set("siteid", "0") url.searchParams.set("campid", trimmedCampaignId) url.searchParams.set("toolid", "10001") url.searchParams.set("mkevt", "1") return url.toString() } catch { return viewItemUrl } } export function normalizeEbayItem( item: any, options?: { campaignId?: string sourceQuery?: string fetchedAt?: number existing?: CachedEbayListing expiresAt?: number } ): CachedEbayListing { const currentPrice = item?.sellingStatus?.currentPrice const shippingCost = item?.shippingInfo?.shippingServiceCost const condition = item?.condition const viewItemUrl = item?.viewItemURL || item?.viewItemUrl || "" const title = item?.title || "Unknown Item" const fetchedAt = options?.fetchedAt ?? Date.now() const existing = options?.existing const sourceQueries = Array.from( new Set([ ...(existing?.sourceQueries || []), ...(options?.sourceQuery ? [options.sourceQuery] : []), ]) ) return { itemId: String(item?.itemId || existing?.itemId || ""), title, price: `${currentPrice?.value || "0"} ${currentPrice?.currencyId || "USD"}`, currency: currentPrice?.currencyId || "USD", imageUrl: item?.galleryURL || existing?.imageUrl || undefined, viewItemUrl, condition: condition?.conditionDisplayName || existing?.condition || undefined, shippingCost: shippingCost?.value ? `${shippingCost.value} ${shippingCost.currencyId || currentPrice?.currencyId || "USD"}` : existing?.shippingCost, affiliateLink: buildAffiliateLink(viewItemUrl, options?.campaignId), normalizedTitle: normalizeText(title), sourceQueries, firstSeenAt: existing?.firstSeenAt ?? fetchedAt, lastSeenAt: fetchedAt, fetchedAt, expiresAt: options?.expiresAt ?? existing?.expiresAt ?? fetchedAt, active: true, } } export function sortListingsByFreshness(listings: CachedEbayListing[]): CachedEbayListing[] { return [...listings].sort((a, b) => { const aActive = a.active === false ? 0 : 1 const bActive = b.active === false ? 0 : 1 if (aActive !== bActive) { return bActive - aActive } const aFreshness = a.lastSeenAt ?? a.fetchedAt ?? 0 const bFreshness = b.lastSeenAt ?? b.fetchedAt ?? 0 return bFreshness - aFreshness }) } export function estimateListingFreshness(now: number, lastSuccessfulAt?: number) { if (!lastSuccessfulAt) { return { isFresh: false, isStale: true, freshnessMs: null as number | null, } } const freshnessMs = Math.max(0, now - lastSuccessfulAt) return { isFresh: freshnessMs < 24 * 60 * 60 * 1000, isStale: freshnessMs >= 24 * 60 * 60 * 1000, freshnessMs, } }