fix: enforce trusted ebay cache listings for manuals affiliate flow
This commit is contained in:
parent
b67bb1e183
commit
1f46c2b390
9 changed files with 458 additions and 221 deletions
|
|
@ -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,
|
|
||||||
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,
|
...part,
|
||||||
ebayListings,
|
ebayListings: rankListingsForPart(part, trustedListings, limit),
|
||||||
}
|
}))
|
||||||
})
|
.sort((a, b) => {
|
||||||
.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 }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 }
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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([])
|
||||||
|
|
|
||||||
|
|
@ -126,10 +126,14 @@ 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("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")
|
: loweredError.includes("rate limit") || loweredError.includes("exceeded")
|
||||||
? "eBay is temporarily rate-limited. Try again in a minute."
|
? "eBay is temporarily rate-limited. Existing cached listings will be reused until refresh resumes."
|
||||||
: error
|
: error
|
||||||
|
|
||||||
return renderStatusCard("eBay unavailable", statusMessage)
|
return renderStatusCard("eBay unavailable", statusMessage)
|
||||||
|
|
|
||||||
138
convex/ebay.ts
138
convex/ebay.ts
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue