Rocky_Mountain_Vending/lib/ebay-parts-match.ts

516 lines
12 KiB
TypeScript

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 SYNTHETIC_ITEM_PREFIX = "123456789"
const PLACEHOLDER_IMAGE_HOSTS = [
"images.unsplash.com",
"via.placeholder.com",
"placehold.co",
]
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 parsePositivePrice(value: string): number | null {
const match = value.match(/([0-9]+(?:\.[0-9]+)?)/)
if (!match) {
return null
}
const parsed = Number.parseFloat(match[1])
if (!Number.isFinite(parsed) || parsed <= 0) {
return null
}
return parsed
}
function parseUrl(value: string): URL | null {
try {
return new URL(value)
} catch {
return null
}
}
export function isSyntheticEbayListing(
listing: Pick<CachedEbayListing, "itemId" | "viewItemUrl" | "imageUrl">
): boolean {
const itemId = listing.itemId?.trim() || ""
const viewItemUrl = listing.viewItemUrl?.trim() || ""
const imageUrl = listing.imageUrl?.trim() || ""
if (!itemId || itemId.startsWith(SYNTHETIC_ITEM_PREFIX)) {
return true
}
if (viewItemUrl.includes(SYNTHETIC_ITEM_PREFIX)) {
return true
}
if (imageUrl) {
const parsedImageUrl = parseUrl(imageUrl)
const imageHost = parsedImageUrl?.hostname.toLowerCase() || ""
if (PLACEHOLDER_IMAGE_HOSTS.some((host) => imageHost.includes(host))) {
return true
}
}
return false
}
export function isTrustedEbayListing(listing: CachedEbayListing): boolean {
const itemId = listing.itemId?.trim() || ""
if (!/^[0-9]{9,15}$/.test(itemId)) {
return false
}
if (isSyntheticEbayListing(listing)) {
return false
}
const parsedViewUrl = parseUrl(listing.viewItemUrl || "")
if (!parsedViewUrl) {
return false
}
const viewHost = parsedViewUrl.hostname.toLowerCase()
if (!viewHost.includes("ebay.")) {
return false
}
if (!parsedViewUrl.pathname.includes("/itm/")) {
return false
}
if (!parsePositivePrice(listing.price || "")) {
return false
}
return true
}
export function filterTrustedEbayListings(
listings: CachedEbayListing[]
): CachedEbayListing[] {
return listings.filter((listing) => isTrustedEbayListing(listing))
}
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<CachedEbayListing, "title" | "normalizedTitle">): 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,
}
}