516 lines
12 KiB
TypeScript
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,
|
|
}
|
|
}
|