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 { 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 }
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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([])
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
138
convex/ebay.ts
138
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<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(
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue