fix: enforce trusted ebay cache listings for manuals affiliate flow

This commit is contained in:
DMleadgen 2026-04-10 15:20:37 -06:00
parent b67bb1e183
commit 1f46c2b390
Signed by: matt
GPG key ID: C2720CF8CD701894
9 changed files with 458 additions and 221 deletions

View file

@ -3,12 +3,12 @@ import { fetchQuery } from "convex/nextjs"
import { api } from "@/convex/_generated/api" import { api } from "@/convex/_generated/api"
import { hasConvexUrl } from "@/lib/convex-config" import { hasConvexUrl } from "@/lib/convex-config"
import { import {
filterTrustedEbayListings,
rankListingsForPart, rankListingsForPart,
type CachedEbayListing, type CachedEbayListing,
type EbayCacheState, type EbayCacheState,
type ManualPartInput, type ManualPartInput,
} from "@/lib/ebay-parts-match" } from "@/lib/ebay-parts-match"
import { findManualParts } from "@/lib/server/manual-parts-data"
type MatchPart = ManualPartInput & { type MatchPart = ManualPartInput & {
key?: string key?: string
@ -23,6 +23,8 @@ type ManualPartsMatchResponse = {
} }
> >
cache: EbayCacheState cache: EbayCacheState
cacheSource: "convex" | "fallback"
error?: string
} }
type ManualPartsRequest = { type ManualPartsRequest = {
@ -31,26 +33,54 @@ type ManualPartsRequest = {
limit?: number limit?: number
} }
function getCacheFallback(message?: string): EbayCacheState { function getDisabledCacheState(message: string): EbayCacheState {
return { return {
key: "manual-parts", key: "manual-parts",
status: "success", status: "disabled",
lastSuccessfulAt: Date.now(), lastSuccessfulAt: null,
lastAttemptAt: null, lastAttemptAt: null,
nextEligibleAt: null, nextEligibleAt: null,
lastError: null, lastError: message,
consecutiveFailures: 0, consecutiveFailures: 0,
queryCount: 0, queryCount: 0,
itemCount: 0, itemCount: 0,
sourceQueries: [], sourceQueries: [],
freshnessMs: 0, freshnessMs: null,
isStale: true, isStale: true,
listingCount: 0, listingCount: 0,
activeListingCount: 0, activeListingCount: 0,
message: message || "Using bundled manual cache.", message,
} }
} }
function getErrorCacheState(message: string): EbayCacheState {
const now = Date.now()
return {
key: "manual-parts",
status: "error",
lastSuccessfulAt: null,
lastAttemptAt: now,
nextEligibleAt: null,
lastError: message,
consecutiveFailures: 1,
queryCount: 0,
itemCount: 0,
sourceQueries: [],
freshnessMs: null,
isStale: true,
listingCount: 0,
activeListingCount: 0,
message,
}
}
function createEmptyListingsParts(parts: MatchPart[]) {
return parts.map((part) => ({
...part,
ebayListings: [],
}))
}
function normalizePartInput(value: unknown): MatchPart | null { function normalizePartInput(value: unknown): MatchPart | null {
if (!value || typeof value !== "object") { if (!value || typeof value !== "object") {
return null return null
@ -82,59 +112,6 @@ function normalizePartInput(value: unknown): MatchPart | null {
} }
} }
function mergeListings(
primary: CachedEbayListing[],
fallback: CachedEbayListing[]
): CachedEbayListing[] {
const seen = new Set<string>()
const merged: CachedEbayListing[] = []
for (const listing of [...primary, ...fallback]) {
const itemId = listing.itemId?.trim()
if (!itemId || seen.has(itemId)) {
continue
}
seen.add(itemId)
merged.push(listing)
}
return merged
}
function getStaticListingsForPart(
part: MatchPart,
staticParts: Awaited<ReturnType<typeof findManualParts>>
): CachedEbayListing[] {
const requestPartNumber = part.partNumber.trim().toLowerCase()
const requestDescription = part.description.trim().toLowerCase()
const match = staticParts.find((candidate) => {
const candidatePartNumber = candidate.partNumber.trim().toLowerCase()
const candidateDescription = candidate.description.trim().toLowerCase()
if (candidatePartNumber && candidatePartNumber === requestPartNumber) {
return true
}
if (candidatePartNumber && requestPartNumber && candidatePartNumber.includes(requestPartNumber)) {
return true
}
if (
candidateDescription &&
requestDescription &&
candidateDescription.includes(requestDescription)
) {
return true
}
return false
})
return Array.isArray(match?.ebayListings) ? match.ebayListings : []
}
export async function POST(request: Request) { export async function POST(request: Request) {
let payload: ManualPartsRequest | null = null let payload: ManualPartsRequest | null = null
@ -161,66 +138,43 @@ export async function POST(request: Request) {
} }
if (!parts.length) { if (!parts.length) {
const message = "No manual parts were provided."
return NextResponse.json({ return NextResponse.json({
manualFilename, manualFilename,
parts: [], parts: [],
cache: getCacheFallback("No manual parts were provided."), cache: getDisabledCacheState(message),
}) cacheSource: "fallback",
error: message,
} satisfies ManualPartsMatchResponse)
} }
const staticManualPartsPromise = findManualParts(manualFilename)
if (!hasConvexUrl()) { if (!hasConvexUrl()) {
const staticManualParts = await staticManualPartsPromise const message =
"Cached eBay backend is disabled because NEXT_PUBLIC_CONVEX_URL is not configured."
return NextResponse.json({ return NextResponse.json({
manualFilename, manualFilename,
parts: parts.map((part) => { parts: createEmptyListingsParts(parts),
const staticListings = getStaticListingsForPart(part, staticManualParts) cache: getDisabledCacheState(message),
const ebayListings = rankListingsForPart( cacheSource: "fallback",
part, error: message,
mergeListings(part.ebayListings || [], staticListings), } satisfies ManualPartsMatchResponse)
limit
)
return {
...part,
ebayListings,
}
}),
cache: getCacheFallback(),
})
} }
try { try {
const [overview, listings, staticManualParts] = await Promise.all([ const [overview, listings] = await Promise.all([
fetchQuery(api.ebay.getCacheOverview, {}), fetchQuery(api.ebay.getCacheOverview, {}),
fetchQuery(api.ebay.listCachedListings, { limit: 200 }), fetchQuery(api.ebay.listCachedListings, { limit: 200 }),
staticManualPartsPromise,
]) ])
const trustedListings = filterTrustedEbayListings(
listings as CachedEbayListing[]
)
const rankedParts = parts const rankedParts = parts
.map((part) => { .map((part) => ({
const backendListings = rankListingsForPart( ...part,
part, ebayListings: rankListingsForPart(part, trustedListings, limit),
listings as CachedEbayListing[], }))
limit .sort((a, b) => {
)
const staticListings = getStaticListingsForPart(part, staticManualParts)
const bundledListings = rankListingsForPart(
part,
mergeListings(part.ebayListings || [], staticListings),
limit
)
const ebayListings = mergeListings(backendListings, bundledListings).slice(
0,
limit
)
return {
...part,
ebayListings,
}
})
.sort((a: MatchPart & { ebayListings: CachedEbayListing[] }, b: MatchPart & { ebayListings: CachedEbayListing[] }) => {
const aCount = a.ebayListings.length const aCount = a.ebayListings.length
const bCount = b.ebayListings.length const bCount = b.ebayListings.length
if (aCount !== bCount) { if (aCount !== bCount) {
@ -237,32 +191,22 @@ export async function POST(request: Request) {
manualFilename, manualFilename,
parts: rankedParts, parts: rankedParts,
cache: overview, cache: overview,
cacheSource: "convex",
} satisfies ManualPartsMatchResponse) } satisfies ManualPartsMatchResponse)
} catch (error) { } catch (error) {
console.error("Failed to load cached eBay matches:", error) console.error("Failed to load cached eBay matches:", error)
const staticManualParts = await staticManualPartsPromise const message =
error instanceof Error
? `Cached eBay listings are unavailable: ${error.message}`
: "Cached eBay listings are unavailable."
return NextResponse.json( return NextResponse.json(
{ {
manualFilename, manualFilename,
parts: parts.map((part: MatchPart) => { parts: createEmptyListingsParts(parts),
const staticListings = getStaticListingsForPart(part, staticManualParts) cache: getErrorCacheState(message),
const ebayListings = rankListingsForPart( cacheSource: "fallback",
part, error: message,
mergeListings(part.ebayListings || [], staticListings), } satisfies ManualPartsMatchResponse,
limit
)
return {
...part,
ebayListings,
}
}),
cache: getCacheFallback(
error instanceof Error
? `Using bundled manual cache because cached listings failed: ${error.message}`
: "Using bundled manual cache because cached listings failed."
),
},
{ status: 200 } { status: 200 }
) )
} }

View file

@ -3,29 +3,52 @@ import { fetchQuery } from "convex/nextjs"
import { api } from "@/convex/_generated/api" import { api } from "@/convex/_generated/api"
import { hasConvexUrl } from "@/lib/convex-config" import { hasConvexUrl } from "@/lib/convex-config"
import { import {
filterTrustedEbayListings,
rankListingsForQuery, rankListingsForQuery,
type CachedEbayListing, type CachedEbayListing,
type EbayCacheState, type EbayCacheState,
} from "@/lib/ebay-parts-match" } from "@/lib/ebay-parts-match"
import { searchStaticEbayListings } from "@/lib/server/manual-parts-data"
function getCacheStateFallback(message?: string): EbayCacheState { type CacheSource = "convex" | "fallback"
function getDisabledCacheState(message: string): EbayCacheState {
return { return {
key: "manual-parts", key: "manual-parts",
status: "success", status: "disabled",
lastSuccessfulAt: Date.now(), lastSuccessfulAt: null,
lastAttemptAt: null, lastAttemptAt: null,
nextEligibleAt: null, nextEligibleAt: null,
lastError: null, lastError: message,
consecutiveFailures: 0, consecutiveFailures: 0,
queryCount: 0, queryCount: 0,
itemCount: 0, itemCount: 0,
sourceQueries: [], sourceQueries: [],
freshnessMs: 0, freshnessMs: null,
isStale: true, isStale: true,
listingCount: 0, listingCount: 0,
activeListingCount: 0, activeListingCount: 0,
message: message || "Using bundled manual cache.", message,
}
}
function getErrorCacheState(message: string): EbayCacheState {
const now = Date.now()
return {
key: "manual-parts",
status: "error",
lastSuccessfulAt: null,
lastAttemptAt: now,
nextEligibleAt: null,
lastError: message,
consecutiveFailures: 1,
queryCount: 0,
itemCount: 0,
sourceQueries: [],
freshnessMs: null,
isStale: true,
listingCount: 0,
activeListingCount: 0,
message,
} }
} }
@ -45,11 +68,14 @@ export async function GET(request: Request) {
} }
if (!hasConvexUrl()) { if (!hasConvexUrl()) {
const staticResults = await searchStaticEbayListings(keywords, maxResults) const message =
"Cached eBay backend is disabled because NEXT_PUBLIC_CONVEX_URL is not configured."
return NextResponse.json({ return NextResponse.json({
query: keywords, query: keywords,
results: staticResults, results: [],
cache: getCacheStateFallback(), cache: getDisabledCacheState(message),
cacheSource: "fallback" satisfies CacheSource,
error: message,
}) })
} }
@ -59,9 +85,12 @@ export async function GET(request: Request) {
fetchQuery(api.ebay.listCachedListings, { limit: 200 }), fetchQuery(api.ebay.listCachedListings, { limit: 200 }),
]) ])
const trustedListings = filterTrustedEbayListings(
listings as CachedEbayListing[]
)
const ranked = rankListingsForQuery( const ranked = rankListingsForQuery(
keywords, keywords,
listings as CachedEbayListing[], trustedListings,
maxResults maxResults
) )
@ -69,19 +98,21 @@ export async function GET(request: Request) {
query: keywords, query: keywords,
results: ranked, results: ranked,
cache: overview, cache: overview,
cacheSource: "convex" satisfies CacheSource,
}) })
} catch (error) { } catch (error) {
console.error("Failed to load cached eBay listings:", error) console.error("Failed to load cached eBay listings:", error)
const staticResults = await searchStaticEbayListings(keywords, maxResults) const message =
error instanceof Error
? `Cached eBay listings are unavailable: ${error.message}`
: "Cached eBay listings are unavailable."
return NextResponse.json( return NextResponse.json(
{ {
query: keywords, query: keywords,
results: staticResults, results: [],
cache: getCacheStateFallback( cache: getErrorCacheState(message),
error instanceof Error cacheSource: "fallback" satisfies CacheSource,
? `Using bundled manual cache because cached listings failed: ${error.message}` error: message,
: "Using bundled manual cache because cached listings failed."
),
}, },
{ status: 200 } { status: 200 }
) )

View file

@ -100,6 +100,7 @@ function ProductSuggestions({
setSuggestions(Array.isArray(body.results) ? body.results : []) setSuggestions(Array.isArray(body.results) ? body.results : [])
setCache(body.cache || null) setCache(body.cache || null)
setError(typeof body.error === "string" ? body.error : null)
} catch (err) { } catch (err) {
console.error("Error loading product suggestions:", err) console.error("Error loading product suggestions:", err)
setSuggestions([]) setSuggestions([])

View file

@ -126,11 +126,15 @@ export function PartsPanel({
if (error && !hasListings) { if (error && !hasListings) {
const loweredError = error.toLowerCase() const loweredError = error.toLowerCase()
const statusMessage = error.includes("eBay API not configured") const statusMessage =
? "Set EBAY_APP_ID in the app environment so live listings can load." loweredError.includes("next_public_convex_url") ||
: loweredError.includes("rate limit") || loweredError.includes("exceeded") loweredError.includes("cached ebay backend is disabled")
? "eBay is temporarily rate-limited. Try again in a minute." ? "Set NEXT_PUBLIC_CONVEX_URL in the app environment so cached eBay listings can load."
: error : loweredError.includes("ebay_app_id")
? "Set EBAY_APP_ID in the app environment so the background cache refresh can run."
: loweredError.includes("rate limit") || loweredError.includes("exceeded")
? "eBay is temporarily rate-limited. Existing cached listings will be reused until refresh resumes."
: error
return renderStatusCard("eBay unavailable", statusMessage) return renderStatusCard("eBay unavailable", statusMessage)
} }

View file

@ -1,5 +1,5 @@
// @ts-nocheck // @ts-nocheck
import { action, internalMutation, query } from "./_generated/server" import { internalAction, internalMutation, query } from "./_generated/server"
import { api, internal } from "./_generated/api" import { api, internal } from "./_generated/api"
import { v } from "convex/values" import { v } from "convex/values"
@ -8,33 +8,56 @@ const LISTING_EXPIRES_MS = 14 * 24 * 60 * 60 * 1000
const STALE_AFTER_MS = 36 * 60 * 60 * 1000 const STALE_AFTER_MS = 36 * 60 * 60 * 1000
const BASE_REFRESH_MS = 24 * 60 * 60 * 1000 const BASE_REFRESH_MS = 24 * 60 * 60 * 1000
const MAX_BACKOFF_MS = 7 * 24 * 60 * 60 * 1000 const MAX_BACKOFF_MS = 7 * 24 * 60 * 60 * 1000
const MAX_QUERIES_PER_RUN = 4
const MAX_RESULTS_PER_QUERY = 8 const MAX_RESULTS_PER_QUERY = 8
const MAX_UNIQUE_RESULTS = 48 const MAX_UNIQUE_RESULTS = 48
const POLL_QUERIES = [ const SYNTHETIC_ITEM_PREFIX = "123456789"
const PLACEHOLDER_IMAGE_HOSTS = [
"images.unsplash.com",
"via.placeholder.com",
"placehold.co",
] as const
const POLL_QUERY_POOL = [
{ {
label: "vending machine parts", label: "dixie narco part number",
keywords: "vending machine part", keywords: "dixie narco vending part number",
categoryId: "11700", categoryId: "11700",
}, },
{ {
label: "coin mech", label: "crane national vendors part",
keywords: "coin mech vending", keywords: "crane national vendors vending part",
categoryId: "11700", categoryId: "11700",
}, },
{ {
label: "control board", label: "seaga vending control board",
keywords: "vending machine control board", keywords: "seaga vending control board",
categoryId: "11700", categoryId: "11700",
}, },
{ {
label: "snack machine parts", label: "coinco coin mech",
keywords: "snack machine part", keywords: "coinco vending coin mech",
categoryId: "11700", categoryId: "11700",
}, },
{ {
label: "beverage machine parts", label: "mei bill validator",
keywords: "beverage machine part", keywords: "mei vending bill validator",
categoryId: "11700",
},
{
label: "wittern delivery motor",
keywords: "wittern vending delivery motor",
categoryId: "11700",
},
{
label: "vending refrigeration deck",
keywords: "vending machine refrigeration deck",
categoryId: "11700",
},
{
label: "vending keypad",
keywords: "vending machine keypad",
categoryId: "11700", categoryId: "11700",
}, },
] as const ] as const
@ -43,6 +66,37 @@ function normalizeText(value: string): string {
return value.toLowerCase().replace(/[^a-z0-9]+/g, " ").replace(/\s+/g, " ").trim() return value.toLowerCase().replace(/[^a-z0-9]+/g, " ").replace(/\s+/g, " ").trim()
} }
function parsePositiveNumber(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 getPollQueriesForRun(now: number) {
const total = POLL_QUERY_POOL.length
if (total === 0) {
return []
}
const startIndex = Math.floor(now / BASE_REFRESH_MS) % total
const count = Math.min(MAX_QUERIES_PER_RUN, total)
const queries: (typeof POLL_QUERY_POOL)[number][] = []
for (let index = 0; index < count; index += 1) {
queries.push(POLL_QUERY_POOL[(startIndex + index) % total])
}
return queries
}
function buildAffiliateLink(viewItemUrl: string): string { function buildAffiliateLink(viewItemUrl: string): string {
const campaignId = process.env.EBAY_AFFILIATE_CAMPAIGN_ID?.trim() || "" const campaignId = process.env.EBAY_AFFILIATE_CAMPAIGN_ID?.trim() || ""
if (!campaignId) { if (!campaignId) {
@ -70,6 +124,54 @@ function firstValue<T>(value: T | T[] | undefined): T | undefined {
return Array.isArray(value) ? value[0] : value return Array.isArray(value) ? value[0] : value
} }
function parseUrl(value: string): URL | null {
try {
return new URL(value)
} catch {
return null
}
}
function isTrustedListingCandidate(listing: ReturnType<typeof normalizeEbayItem>) {
const itemId = listing.itemId?.trim() || ""
if (!/^[0-9]{9,15}$/.test(itemId)) {
return false
}
if (itemId.startsWith(SYNTHETIC_ITEM_PREFIX)) {
return false
}
const parsedUrl = parseUrl(listing.viewItemUrl || "")
if (!parsedUrl) {
return false
}
const host = parsedUrl.hostname.toLowerCase()
if (!host.includes("ebay.")) {
return false
}
if (!parsedUrl.pathname.includes("/itm/")) {
return false
}
const parsedPrice = parsePositiveNumber(listing.price || "")
if (!parsedPrice) {
return false
}
if (listing.imageUrl) {
const parsedImage = parseUrl(listing.imageUrl)
const imageHost = parsedImage?.hostname.toLowerCase() || ""
if (PLACEHOLDER_IMAGE_HOSTS.some((placeholder) => imageHost.includes(placeholder))) {
return false
}
}
return true
}
function isRateLimitError(message: string): boolean { function isRateLimitError(message: string): boolean {
const normalized = message.toLowerCase() const normalized = message.toLowerCase()
return ( return (
@ -163,7 +265,7 @@ function normalizeEbayItem(item: any, fetchedAt: number) {
} }
} }
async function searchEbayListings(query: (typeof POLL_QUERIES)[number]) { async function searchEbayListings(query: (typeof POLL_QUERY_POOL)[number]) {
const appId = process.env.EBAY_APP_ID?.trim() const appId = process.env.EBAY_APP_ID?.trim()
if (!appId) { if (!appId) {
throw new Error("eBay App ID is not configured") throw new Error("eBay App ID is not configured")
@ -275,7 +377,7 @@ export const listCachedListings = query({
}, },
}) })
export const refreshCache = action({ export const refreshCache = internalAction({
args: { args: {
reason: v.optional(v.string()), reason: v.optional(v.string()),
force: v.optional(v.boolean()), force: v.optional(v.boolean()),
@ -338,7 +440,9 @@ export const refreshCache = action({
let rateLimited = false let rateLimited = false
let lastError: string | null = null let lastError: string | null = null
for (const query of POLL_QUERIES) { const pollQueries = getPollQueriesForRun(now)
for (const query of pollQueries) {
if (collectedListings.size >= MAX_UNIQUE_RESULTS) { if (collectedListings.size >= MAX_UNIQUE_RESULTS) {
break break
} }
@ -354,6 +458,10 @@ export const refreshCache = action({
continue continue
} }
if (!isTrustedListingCandidate(listing)) {
continue
}
const existing = collectedListings.get(listing.itemId) const existing = collectedListings.get(listing.itemId)
if (existing) { if (existing) {
existing.sourceQueries = Array.from( existing.sourceQueries = Array.from(

View file

@ -40,13 +40,16 @@ SMOKE_SKIP_BROWSER=1 pnpm diagnose:ebay
## How To Read The Output ## How To Read The Output
- `NEXT_PUBLIC_CONVEX_URL missing` - `NEXT_PUBLIC_CONVEX_URL missing`
- The cache routes will intentionally fall back to `status: disabled`. - The cache routes should return `status: disabled` and no listings.
- `cache.status=disabled` with `Server Error` - `cache.message` mentions bundled/fallback cache
- The app reached Convex, but the backend/query layer failed. - This is not revenue-ready. The app is not using Convex cached inventory.
- `cache.status=success` or `idle` - `cache.status=success` with `listingCount=0`
- The cache backend is reachable. If listings are `0`, the cache is simply empty. - Treat this as backend cache failure or empty cache; not revenue-ready.
- `synthetic placeholder listings` failure
- Listings are fake data and should not be shown in affiliate cards.
- `trusted listings missing affiliate tracking` failure
- Listings may be real but links are not monetized yet.
- Notification challenge returns `200` - Notification challenge returns `200`
- The eBay validation endpoint is wired correctly. - The eBay validation endpoint is wired correctly.
- Admin refresh returns `2xx` - Admin refresh returns `2xx`
- The cache seeding path is available and the admin token is accepted. - The cache seeding path is available and the admin token is accepted.

View file

@ -50,6 +50,13 @@ export type EbayCacheState = {
message?: string 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([ const GENERIC_PART_TERMS = new Set([
"and", "and",
"the", "the",
@ -99,6 +106,91 @@ function normalizeText(value: string): string {
return value.toLowerCase().replace(/[^a-z0-9]+/g, " ").replace(/\s+/g, " ").trim() 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[] { function tokenize(value: string): string[] {
return Array.from( return Array.from(
new Set( new Set(

View file

@ -11,6 +11,10 @@ import type {
EbayCacheState, EbayCacheState,
ManualPartInput, ManualPartInput,
} from "@/lib/ebay-parts-match" } from "@/lib/ebay-parts-match"
import {
filterTrustedEbayListings,
isSyntheticEbayListing,
} from "@/lib/ebay-parts-match"
export interface PartForPage { export interface PartForPage {
partNumber: string partNumber: string
@ -36,12 +40,14 @@ interface CachedPartsResponse {
} }
> >
cache: EbayCacheState cache: EbayCacheState
cacheSource?: "convex" | "fallback"
error?: string error?: string
} }
interface CachedEbaySearchResponse { interface CachedEbaySearchResponse {
results: CachedEbayListing[] results: CachedEbayListing[]
cache: EbayCacheState cache: EbayCacheState
cacheSource?: "convex" | "fallback"
error?: string error?: string
} }
@ -330,20 +336,6 @@ function normalizePartText(value: string): string {
return value.trim().toLowerCase() return value.trim().toLowerCase()
} }
function isSyntheticEbayListing(
listing: PartForPage["ebayListings"][number]
): boolean {
const itemId = listing.itemId?.trim() || ""
const viewItemUrl = listing.viewItemUrl?.trim() || ""
const imageUrl = listing.imageUrl?.trim() || ""
return (
imageUrl.includes("images.unsplash.com") ||
viewItemUrl.includes("123456789") ||
itemId.startsWith("123456789")
)
}
function hasLiveEbayListings(listings: PartForPage["ebayListings"]): boolean { function hasLiveEbayListings(listings: PartForPage["ebayListings"]): boolean {
return listings.some((listing) => !isSyntheticEbayListing(listing)) return listings.some((listing) => !isSyntheticEbayListing(listing))
} }
@ -541,6 +533,13 @@ async function getPartsForManualWithStatus(manualFilename: string): Promise<{
return { parts } 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 * Get all parts for a manual with enhanced eBay data
*/ */
@ -548,7 +547,7 @@ export async function getPartsForManual(
manualFilename: string manualFilename: string
): Promise<PartForPage[]> { ): Promise<PartForPage[]> {
const result = await getPartsForManualWithStatus(manualFilename) const result = await getPartsForManualWithStatus(manualFilename)
return result.parts return sanitizePartListings(result.parts)
} }
/** /**
@ -579,7 +578,7 @@ export async function getPartsForPage(
Math.max(parts.length, 1) Math.max(parts.length, 1)
) )
return matched.parts as PartForPage[] return sanitizePartListings(matched.parts as PartForPage[])
} }
/** /**
@ -614,7 +613,7 @@ export async function getTopPartsForManual(
) )
return { return {
parts: matched.parts as PartForPage[], parts: sanitizePartListings(matched.parts as PartForPage[]),
error: matched.error, error: matched.error,
cache: matched.cache, cache: matched.cache,
} }

View file

@ -78,15 +78,6 @@ function normalizeBaseUrl(value) {
return value.replace(/\/+$/, "") return value.replace(/\/+$/, "")
} }
function isLocalBaseUrl(baseUrl) {
try {
const url = new URL(baseUrl)
return ["localhost", "127.0.0.1", "::1"].includes(url.hostname)
} catch {
return false
}
}
function envPresence(name) { function envPresence(name) {
return Boolean(String(process.env[name] ?? "").trim()) return Boolean(String(process.env[name] ?? "").trim())
} }
@ -123,6 +114,57 @@ function summarizeCache(cache) {
.join(", ") .join(", ")
} }
function parseUrl(value) {
try {
return new URL(String(value || ""))
} catch {
return null
}
}
function listingLooksSynthetic(listing) {
const itemId = String(listing?.itemId || "").trim()
const viewItemUrl = String(listing?.viewItemUrl || "").trim()
const imageUrl = String(listing?.imageUrl || "").trim()
if (!itemId || itemId.startsWith("123456789")) {
return true
}
if (viewItemUrl.includes("123456789")) {
return true
}
const parsedImageUrl = parseUrl(imageUrl)
const imageHost = parsedImageUrl?.hostname?.toLowerCase?.() || ""
if (
imageHost.includes("images.unsplash.com") ||
imageHost.includes("via.placeholder.com") ||
imageHost.includes("placehold.co")
) {
return true
}
return false
}
function listingHasAffiliateCampaign(listing) {
const parsed = parseUrl(listing?.affiliateLink || "")
if (!parsed) {
return false
}
return Boolean(parsed.searchParams.get("campid"))
}
function hasFallbackCacheMessage(cache) {
const message = typeof cache?.message === "string" ? cache.message.toLowerCase() : ""
return (
message.includes("bundled manual cache") ||
message.includes("cached listings failed")
)
}
async function requestJson(url, init) { async function requestJson(url, init) {
const response = await fetch(url, { const response = await fetch(url, {
redirect: "follow", redirect: "follow",
@ -156,7 +198,7 @@ async function checkPages(baseUrl, failures) {
} }
} }
async function checkEbaySearch(baseUrl, failures, isLocalBase) { async function checkEbaySearch(baseUrl, failures) {
heading("eBay Cache Search") heading("eBay Cache Search")
const url = new URL(`${baseUrl}/api/ebay/search`) const url = new URL(`${baseUrl}/api/ebay/search`)
url.searchParams.set("keywords", "vending machine part") url.searchParams.set("keywords", "vending machine part")
@ -178,27 +220,33 @@ async function checkEbaySearch(baseUrl, failures, isLocalBase) {
console.log(` cache: ${summarizeCache(cache)}`) console.log(` cache: ${summarizeCache(cache)}`)
console.log(` results: ${Array.isArray(body?.results) ? body.results.length : 0}`) console.log(` results: ${Array.isArray(body?.results) ? body.results.length : 0}`)
const hasConvexUrl = envPresence("NEXT_PUBLIC_CONVEX_URL")
const disabledCache = cacheStatus === "disabled"
if (hasConvexUrl && disabledCache) {
failures.push(
"eBay search returned disabled cache while NEXT_PUBLIC_CONVEX_URL is configured."
)
}
const results = Array.isArray(body?.results) ? body.results : [] const results = Array.isArray(body?.results) ? body.results : []
if (!hasConvexUrl && isLocalBase && disabledCache) { const trustedResults = results.filter((listing) => !listingLooksSynthetic(listing))
if (hasFallbackCacheMessage(cache)) {
failures.push("eBay search is serving fallback cache data instead of Convex cache.")
}
if (cacheStatus === "success" && Number(cache?.listingCount ?? cache?.itemCount ?? 0) === 0) {
failures.push( failures.push(
"eBay search still returned a disabled cache when local static results should be available." "eBay search returned status=success but cache has zero listings; backend cache is not healthy."
) )
} }
if (!hasConvexUrl && isLocalBase && results.length === 0) { if (results.some((listing) => listingLooksSynthetic(listing))) {
failures.push("eBay search did not return any bundled listings for the smoke query.") failures.push("eBay search returned synthetic placeholder listings.")
}
if (trustedResults.length === 0) {
failures.push("eBay search did not return any trusted listings.")
}
if (trustedResults.length > 0 && !trustedResults.some(listingHasAffiliateCampaign)) {
failures.push("eBay search trusted listings are missing affiliate campaign tracking.")
} }
} }
async function checkManualParts(baseUrl, failures, isLocalBase, manualFilename, partNumber, partDescription) { async function checkManualParts(baseUrl, failures, manualFilename, partNumber, partDescription) {
heading("Manual Parts Match") heading("Manual Parts Match")
const { response, body, text } = await requestJson(`${baseUrl}/api/ebay/manual-parts`, { const { response, body, text } = await requestJson(`${baseUrl}/api/ebay/manual-parts`, {
method: "POST", method: "POST",
@ -234,22 +282,31 @@ async function checkManualParts(baseUrl, failures, isLocalBase, manualFilename,
console.log(` matched parts: ${parts.length}`) console.log(` matched parts: ${parts.length}`)
console.log(` first part listings: ${firstCount}`) console.log(` first part listings: ${firstCount}`)
const hasConvexUrl = envPresence("NEXT_PUBLIC_CONVEX_URL") const allListings = parts.flatMap((part) =>
const disabledCache = cacheStatus === "disabled" Array.isArray(part?.ebayListings) ? part.ebayListings : []
if (hasConvexUrl && disabledCache) { )
const trustedListings = allListings.filter((listing) => !listingLooksSynthetic(listing))
if (hasFallbackCacheMessage(cache)) {
failures.push("Manual parts route is serving fallback cache data instead of Convex cache.")
}
if (cacheStatus === "success" && Number(cache?.listingCount ?? cache?.itemCount ?? 0) === 0) {
failures.push( failures.push(
"Manual parts route returned disabled cache while NEXT_PUBLIC_CONVEX_URL is configured." "Manual parts route returned status=success but cache has zero listings; backend cache is not healthy."
) )
} }
if (!hasConvexUrl && isLocalBase && disabledCache) { if (allListings.some((listing) => listingLooksSynthetic(listing))) {
failures.push( failures.push("Manual parts route returned synthetic placeholder listings.")
"Manual parts route still returned a disabled cache when local static results should be available."
)
} }
if (!hasConvexUrl && isLocalBase && firstCount === 0) { if (trustedListings.length === 0) {
failures.push("Manual parts route did not return bundled listings for the smoke manual.") failures.push("Manual parts route did not return any trusted listings for the smoke manual.")
}
if (trustedListings.length > 0 && !trustedListings.some(listingHasAffiliateCampaign)) {
failures.push("Manual parts trusted listings are missing affiliate campaign tracking.")
} }
if (!body?.manualFilename || body.manualFilename !== manualFilename) { if (!body?.manualFilename || body.manualFilename !== manualFilename) {
@ -410,7 +467,6 @@ async function main() {
loadEnvFile() loadEnvFile()
const args = parseArgs(process.argv.slice(2)) const args = parseArgs(process.argv.slice(2))
const baseUrl = normalizeBaseUrl(args.baseUrl) const baseUrl = normalizeBaseUrl(args.baseUrl)
const isLocalBase = isLocalBaseUrl(baseUrl)
const failures = [] const failures = []
heading("Environment") heading("Environment")
@ -443,11 +499,10 @@ async function main() {
report("Base URL", baseUrl) report("Base URL", baseUrl)
await checkPages(baseUrl, failures) await checkPages(baseUrl, failures)
await checkEbaySearch(baseUrl, failures, isLocalBase) await checkEbaySearch(baseUrl, failures)
await checkManualParts( await checkManualParts(
baseUrl, baseUrl,
failures, failures,
isLocalBase,
args.manualFilename, args.manualFilename,
args.partNumber, args.partNumber,
args.partDescription args.partDescription