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 { 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<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) {
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 }
)
}

View file

@ -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 }
)

View file

@ -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([])

View file

@ -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)
}

View file

@ -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<T>(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<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 {
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(

View file

@ -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.

View file

@ -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<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(

View file

@ -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<PartForPage[]> {
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,
}

View file

@ -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