From 1f46c2b390c6c38d0601294fef24e92ca7803ea1 Mon Sep 17 00:00:00 2001 From: DMleadgen Date: Fri, 10 Apr 2026 15:20:37 -0600 Subject: [PATCH] fix: enforce trusted ebay cache listings for manuals affiliate flow --- app/api/ebay/manual-parts/route.ts | 192 +++++++++--------------- app/api/ebay/search/route.ts | 67 ++++++--- components/manuals-page-client.tsx | 1 + components/parts-panel.tsx | 14 +- convex/ebay.ts | 138 +++++++++++++++-- docs/operations/EBAY_CACHE_DIAGNOSIS.md | 15 +- lib/ebay-parts-match.ts | 92 ++++++++++++ lib/parts-lookup.ts | 33 ++-- scripts/staging-smoke.mjs | 127 +++++++++++----- 9 files changed, 458 insertions(+), 221 deletions(-) diff --git a/app/api/ebay/manual-parts/route.ts b/app/api/ebay/manual-parts/route.ts index 4e5616f5..6dfc6740 100644 --- a/app/api/ebay/manual-parts/route.ts +++ b/app/api/ebay/manual-parts/route.ts @@ -3,12 +3,12 @@ import { fetchQuery } from "convex/nextjs" import { api } from "@/convex/_generated/api" import { hasConvexUrl } from "@/lib/convex-config" import { + filterTrustedEbayListings, rankListingsForPart, type CachedEbayListing, type EbayCacheState, type ManualPartInput, } from "@/lib/ebay-parts-match" -import { findManualParts } from "@/lib/server/manual-parts-data" type MatchPart = ManualPartInput & { key?: string @@ -23,6 +23,8 @@ type ManualPartsMatchResponse = { } > cache: EbayCacheState + cacheSource: "convex" | "fallback" + error?: string } type ManualPartsRequest = { @@ -31,26 +33,54 @@ type ManualPartsRequest = { limit?: number } -function getCacheFallback(message?: string): EbayCacheState { +function getDisabledCacheState(message: string): EbayCacheState { return { key: "manual-parts", - status: "success", - lastSuccessfulAt: Date.now(), + status: "disabled", + lastSuccessfulAt: null, lastAttemptAt: null, nextEligibleAt: null, - lastError: null, + lastError: message, consecutiveFailures: 0, queryCount: 0, itemCount: 0, sourceQueries: [], - freshnessMs: 0, + freshnessMs: null, isStale: true, listingCount: 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 { if (!value || typeof value !== "object") { return null @@ -82,59 +112,6 @@ function normalizePartInput(value: unknown): MatchPart | null { } } -function mergeListings( - primary: CachedEbayListing[], - fallback: CachedEbayListing[] -): CachedEbayListing[] { - const seen = new Set() - 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> -): 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) { let payload: ManualPartsRequest | null = null @@ -161,66 +138,43 @@ export async function POST(request: Request) { } if (!parts.length) { + const message = "No manual parts were provided." return NextResponse.json({ manualFilename, parts: [], - cache: getCacheFallback("No manual parts were provided."), - }) + cache: getDisabledCacheState(message), + cacheSource: "fallback", + error: message, + } satisfies ManualPartsMatchResponse) } - const staticManualPartsPromise = findManualParts(manualFilename) - if (!hasConvexUrl()) { - const staticManualParts = await staticManualPartsPromise + const message = + "Cached eBay backend is disabled because NEXT_PUBLIC_CONVEX_URL is not configured." return NextResponse.json({ manualFilename, - parts: parts.map((part) => { - const staticListings = getStaticListingsForPart(part, staticManualParts) - const ebayListings = rankListingsForPart( - part, - mergeListings(part.ebayListings || [], staticListings), - limit - ) - - return { - ...part, - ebayListings, - } - }), - cache: getCacheFallback(), - }) + parts: createEmptyListingsParts(parts), + cache: getDisabledCacheState(message), + cacheSource: "fallback", + error: message, + } satisfies ManualPartsMatchResponse) } try { - const [overview, listings, staticManualParts] = await Promise.all([ + const [overview, listings] = await Promise.all([ fetchQuery(api.ebay.getCacheOverview, {}), fetchQuery(api.ebay.listCachedListings, { limit: 200 }), - staticManualPartsPromise, ]) + const trustedListings = filterTrustedEbayListings( + listings as CachedEbayListing[] + ) const rankedParts = parts - .map((part) => { - const backendListings = rankListingsForPart( - part, - listings as CachedEbayListing[], - limit - ) - 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[] }) => { + .map((part) => ({ + ...part, + ebayListings: rankListingsForPart(part, trustedListings, limit), + })) + .sort((a, b) => { const aCount = a.ebayListings.length const bCount = b.ebayListings.length if (aCount !== bCount) { @@ -237,32 +191,22 @@ export async function POST(request: Request) { manualFilename, parts: rankedParts, cache: overview, + cacheSource: "convex", } satisfies ManualPartsMatchResponse) } catch (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( { manualFilename, - parts: parts.map((part: MatchPart) => { - const staticListings = getStaticListingsForPart(part, staticManualParts) - const ebayListings = rankListingsForPart( - part, - mergeListings(part.ebayListings || [], staticListings), - 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." - ), - }, + parts: createEmptyListingsParts(parts), + cache: getErrorCacheState(message), + cacheSource: "fallback", + error: message, + } satisfies ManualPartsMatchResponse, { status: 200 } ) } diff --git a/app/api/ebay/search/route.ts b/app/api/ebay/search/route.ts index 3bd470c9..592b3544 100644 --- a/app/api/ebay/search/route.ts +++ b/app/api/ebay/search/route.ts @@ -3,29 +3,52 @@ import { fetchQuery } from "convex/nextjs" import { api } from "@/convex/_generated/api" import { hasConvexUrl } from "@/lib/convex-config" import { + filterTrustedEbayListings, rankListingsForQuery, type CachedEbayListing, type EbayCacheState, } 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 { key: "manual-parts", - status: "success", - lastSuccessfulAt: Date.now(), + status: "disabled", + lastSuccessfulAt: null, lastAttemptAt: null, nextEligibleAt: null, - lastError: null, + lastError: message, consecutiveFailures: 0, queryCount: 0, itemCount: 0, sourceQueries: [], - freshnessMs: 0, + freshnessMs: null, isStale: true, listingCount: 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()) { - const staticResults = await searchStaticEbayListings(keywords, maxResults) + const message = + "Cached eBay backend is disabled because NEXT_PUBLIC_CONVEX_URL is not configured." return NextResponse.json({ query: keywords, - results: staticResults, - cache: getCacheStateFallback(), + results: [], + 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 }), ]) + const trustedListings = filterTrustedEbayListings( + listings as CachedEbayListing[] + ) const ranked = rankListingsForQuery( keywords, - listings as CachedEbayListing[], + trustedListings, maxResults ) @@ -69,19 +98,21 @@ export async function GET(request: Request) { query: keywords, results: ranked, cache: overview, + cacheSource: "convex" satisfies CacheSource, }) } catch (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( { query: keywords, - results: staticResults, - cache: getCacheStateFallback( - error instanceof Error - ? `Using bundled manual cache because cached listings failed: ${error.message}` - : "Using bundled manual cache because cached listings failed." - ), + results: [], + cache: getErrorCacheState(message), + cacheSource: "fallback" satisfies CacheSource, + error: message, }, { status: 200 } ) diff --git a/components/manuals-page-client.tsx b/components/manuals-page-client.tsx index 597c7a4e..d0bf6e4c 100644 --- a/components/manuals-page-client.tsx +++ b/components/manuals-page-client.tsx @@ -100,6 +100,7 @@ function ProductSuggestions({ setSuggestions(Array.isArray(body.results) ? body.results : []) setCache(body.cache || null) + setError(typeof body.error === "string" ? body.error : null) } catch (err) { console.error("Error loading product suggestions:", err) setSuggestions([]) diff --git a/components/parts-panel.tsx b/components/parts-panel.tsx index 67005a18..8fa7d06c 100644 --- a/components/parts-panel.tsx +++ b/components/parts-panel.tsx @@ -126,11 +126,15 @@ export function PartsPanel({ if (error && !hasListings) { const loweredError = error.toLowerCase() - const statusMessage = error.includes("eBay API not configured") - ? "Set EBAY_APP_ID in the app environment so live listings can load." - : loweredError.includes("rate limit") || loweredError.includes("exceeded") - ? "eBay is temporarily rate-limited. Try again in a minute." - : error + const statusMessage = + loweredError.includes("next_public_convex_url") || + loweredError.includes("cached ebay backend is disabled") + ? "Set NEXT_PUBLIC_CONVEX_URL in the app environment so cached eBay listings can load." + : 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) } diff --git a/convex/ebay.ts b/convex/ebay.ts index 979d0170..a7cbee11 100644 --- a/convex/ebay.ts +++ b/convex/ebay.ts @@ -1,5 +1,5 @@ // @ts-nocheck -import { action, internalMutation, query } from "./_generated/server" +import { internalAction, internalMutation, query } from "./_generated/server" import { api, internal } from "./_generated/api" 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 BASE_REFRESH_MS = 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_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", - keywords: "vending machine part", + label: "dixie narco part number", + keywords: "dixie narco vending part number", categoryId: "11700", }, { - label: "coin mech", - keywords: "coin mech vending", + label: "crane national vendors part", + keywords: "crane national vendors vending part", categoryId: "11700", }, { - label: "control board", - keywords: "vending machine control board", + label: "seaga vending control board", + keywords: "seaga vending control board", categoryId: "11700", }, { - label: "snack machine parts", - keywords: "snack machine part", + label: "coinco coin mech", + keywords: "coinco vending coin mech", categoryId: "11700", }, { - label: "beverage machine parts", - keywords: "beverage machine part", + label: "mei bill validator", + 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", }, ] as const @@ -43,6 +66,37 @@ function normalizeText(value: string): string { 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 { const campaignId = process.env.EBAY_AFFILIATE_CAMPAIGN_ID?.trim() || "" if (!campaignId) { @@ -70,6 +124,54 @@ function firstValue(value: T | T[] | undefined): T | undefined { 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) { + 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 { const normalized = message.toLowerCase() 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() if (!appId) { 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: { reason: v.optional(v.string()), force: v.optional(v.boolean()), @@ -338,7 +440,9 @@ export const refreshCache = action({ let rateLimited = false 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) { break } @@ -354,6 +458,10 @@ export const refreshCache = action({ continue } + if (!isTrustedListingCandidate(listing)) { + continue + } + const existing = collectedListings.get(listing.itemId) if (existing) { existing.sourceQueries = Array.from( diff --git a/docs/operations/EBAY_CACHE_DIAGNOSIS.md b/docs/operations/EBAY_CACHE_DIAGNOSIS.md index a6164641..671f5e47 100644 --- a/docs/operations/EBAY_CACHE_DIAGNOSIS.md +++ b/docs/operations/EBAY_CACHE_DIAGNOSIS.md @@ -40,13 +40,16 @@ SMOKE_SKIP_BROWSER=1 pnpm diagnose:ebay ## How To Read The Output - `NEXT_PUBLIC_CONVEX_URL missing` - - The cache routes will intentionally fall back to `status: disabled`. -- `cache.status=disabled` with `Server Error` - - The app reached Convex, but the backend/query layer failed. -- `cache.status=success` or `idle` - - The cache backend is reachable. If listings are `0`, the cache is simply empty. + - The cache routes should return `status: disabled` and no listings. +- `cache.message` mentions bundled/fallback cache + - This is not revenue-ready. The app is not using Convex cached inventory. +- `cache.status=success` with `listingCount=0` + - 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` - The eBay validation endpoint is wired correctly. - Admin refresh returns `2xx` - The cache seeding path is available and the admin token is accepted. - diff --git a/lib/ebay-parts-match.ts b/lib/ebay-parts-match.ts index 153bd44f..f19eb7e6 100644 --- a/lib/ebay-parts-match.ts +++ b/lib/ebay-parts-match.ts @@ -50,6 +50,13 @@ export type EbayCacheState = { 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", @@ -99,6 +106,91 @@ 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 +): 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( diff --git a/lib/parts-lookup.ts b/lib/parts-lookup.ts index 48be135c..cc1fe6d2 100644 --- a/lib/parts-lookup.ts +++ b/lib/parts-lookup.ts @@ -11,6 +11,10 @@ import type { EbayCacheState, ManualPartInput, } from "@/lib/ebay-parts-match" +import { + filterTrustedEbayListings, + isSyntheticEbayListing, +} from "@/lib/ebay-parts-match" export interface PartForPage { partNumber: string @@ -36,12 +40,14 @@ interface CachedPartsResponse { } > cache: EbayCacheState + cacheSource?: "convex" | "fallback" error?: string } interface CachedEbaySearchResponse { results: CachedEbayListing[] cache: EbayCacheState + cacheSource?: "convex" | "fallback" error?: string } @@ -330,20 +336,6 @@ function normalizePartText(value: string): string { 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 { return listings.some((listing) => !isSyntheticEbayListing(listing)) } @@ -541,6 +533,13 @@ async function getPartsForManualWithStatus(manualFilename: string): Promise<{ 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 */ @@ -548,7 +547,7 @@ export async function getPartsForManual( manualFilename: string ): Promise { 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) ) - return matched.parts as PartForPage[] + return sanitizePartListings(matched.parts as PartForPage[]) } /** @@ -614,7 +613,7 @@ export async function getTopPartsForManual( ) return { - parts: matched.parts as PartForPage[], + parts: sanitizePartListings(matched.parts as PartForPage[]), error: matched.error, cache: matched.cache, } diff --git a/scripts/staging-smoke.mjs b/scripts/staging-smoke.mjs index 08923263..5c819990 100644 --- a/scripts/staging-smoke.mjs +++ b/scripts/staging-smoke.mjs @@ -78,15 +78,6 @@ function normalizeBaseUrl(value) { 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) { return Boolean(String(process.env[name] ?? "").trim()) } @@ -123,6 +114,57 @@ function summarizeCache(cache) { .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) { const response = await fetch(url, { 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") const url = new URL(`${baseUrl}/api/ebay/search`) url.searchParams.set("keywords", "vending machine part") @@ -178,27 +220,33 @@ async function checkEbaySearch(baseUrl, failures, isLocalBase) { console.log(` cache: ${summarizeCache(cache)}`) 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 : [] - 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( - "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) { - failures.push("eBay search did not return any bundled listings for the smoke query.") + if (results.some((listing) => listingLooksSynthetic(listing))) { + 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") const { response, body, text } = await requestJson(`${baseUrl}/api/ebay/manual-parts`, { method: "POST", @@ -234,22 +282,31 @@ async function checkManualParts(baseUrl, failures, isLocalBase, manualFilename, console.log(` matched parts: ${parts.length}`) console.log(` first part listings: ${firstCount}`) - const hasConvexUrl = envPresence("NEXT_PUBLIC_CONVEX_URL") - const disabledCache = cacheStatus === "disabled" - if (hasConvexUrl && disabledCache) { + const allListings = parts.flatMap((part) => + Array.isArray(part?.ebayListings) ? part.ebayListings : [] + ) + 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( - "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) { - failures.push( - "Manual parts route still returned a disabled cache when local static results should be available." - ) + if (allListings.some((listing) => listingLooksSynthetic(listing))) { + failures.push("Manual parts route returned synthetic placeholder listings.") } - if (!hasConvexUrl && isLocalBase && firstCount === 0) { - failures.push("Manual parts route did not return bundled listings for the smoke manual.") + if (trustedListings.length === 0) { + 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) { @@ -410,7 +467,6 @@ async function main() { loadEnvFile() const args = parseArgs(process.argv.slice(2)) const baseUrl = normalizeBaseUrl(args.baseUrl) - const isLocalBase = isLocalBaseUrl(baseUrl) const failures = [] heading("Environment") @@ -443,11 +499,10 @@ async function main() { report("Base URL", baseUrl) await checkPages(baseUrl, failures) - await checkEbaySearch(baseUrl, failures, isLocalBase) + await checkEbaySearch(baseUrl, failures) await checkManualParts( baseUrl, failures, - isLocalBase, args.manualFilename, args.partNumber, args.partDescription