diff --git a/category-blacklist.csv b/category-blacklist.csv index 986ff66..d37de59 100644 --- a/category-blacklist.csv +++ b/category-blacklist.csv @@ -4,4 +4,5 @@ id,name 229534,Software 283155,Books 16310101,Grocery Gourmet Food -599858,Magazine Subscriptions \ No newline at end of file +599858,Magazine Subscriptions +5174,CDs & Vinyl \ No newline at end of file diff --git a/src/bestsellers-by-category.ts b/src/bestsellers-by-category.ts index fc406d9..64ab5a3 100644 --- a/src/bestsellers-by-category.ts +++ b/src/bestsellers-by-category.ts @@ -13,7 +13,6 @@ import type { SellabilityInfo, SpApiData, } from "./types.ts"; - type CategoryInfo = { id: number; @@ -45,6 +44,8 @@ type CategoryRunSummary = { const KEEPA_BASE = "https://api.keepa.com"; const DOMAIN_US = 1; +const AMAZON_US_SELLER_ID = "ATVPDKIKX0DER"; +const KEEPA_MINUTES_OFFSET = 21_564_000; const DEFAULT_CATEGORY_LIMIT = 32; const DEFAULT_PER_CATEGORY_TOP = 100; const SELLABILITY_BATCH_SIZE = 60; @@ -162,7 +163,16 @@ export async function insertCategoryRunSummary( export async function updateCategoryRunSummary( db: Database, runId: number, - summary: Pick, + summary: Pick< + CategoryRunSummary, + | "topAsinsChecked" + | "availableAsins" + | "fba" + | "fbm" + | "skip" + | "status" + | "error" + >, ): Promise { db.run( ` @@ -204,14 +214,15 @@ export async function insertProductAnalysisResults( asin, run_id, name, brand, category, unit_cost, current_price, avg_price_90d, avg_price_90d_sheet, selling_price_sheet, sales_rank, sales_rank_avg_90d, - seller_count, monthly_sold, rank_drops_30d, rank_drops_90d, + seller_count, amazon_is_seller, amazon_buybox_share_pct_90d, + monthly_sold, rank_drops_30d, rank_drops_90d, fba_fee, fbm_fee, referral_percent, can_sell, sellability_status, sellability_reason, verdict, confidence, reasoning, fetched_at ) VALUES ( ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, - ?, ?, ?, ?, ?, ? + ?, ?, ?, ?, ?, ?, ?, ? ) ON CONFLICT(asin) DO UPDATE SET run_id = excluded.run_id, @@ -226,6 +237,8 @@ export async function insertProductAnalysisResults( sales_rank = excluded.sales_rank, sales_rank_avg_90d = excluded.sales_rank_avg_90d, seller_count = excluded.seller_count, + amazon_is_seller = excluded.amazon_is_seller, + amazon_buybox_share_pct_90d = excluded.amazon_buybox_share_pct_90d, monthly_sold = excluded.monthly_sold, rank_drops_30d = excluded.rank_drops_30d, rank_drops_90d = excluded.rank_drops_90d, @@ -265,6 +278,12 @@ export async function insertProductAnalysisResults( rank ?? null, r.product.keepa?.salesRankAvg90 ?? null, r.product.keepa?.sellerCount ?? null, + r.product.keepa?.amazonIsSeller == null + ? null + : r.product.keepa.amazonIsSeller + ? 1 + : 0, + r.product.keepa?.amazonBuyboxSharePct90d ?? null, r.product.keepa?.monthlySold ?? null, r.product.keepa?.salesRankDrops30 ?? null, r.product.keepa?.salesRankDrops90 ?? null, @@ -776,6 +795,14 @@ function parseKeepaProduct(product: Record): KeepaData { const monthlySold = pickKeepaNumber(product.monthlySold, stats?.monthlySold) ?? salesRankDrops30; + const amazonIsSeller = resolveAmazonIsSeller(product, stats, csv); + const amazonBuyboxSharePct90d = + extractAmazonBuyboxSharePct90d(product, stats) ?? + computeAmazonBuyBoxSharePctFromHistory( + product.buyBoxSellerIdHistory, + 90, + new Set([AMAZON_US_SELLER_ID]), + ); return { currentPrice: extractCurrentPrice(csv), @@ -787,6 +814,8 @@ function parseKeepaProduct(product: Record): KeepaData { salesRankDrops30, salesRankDrops90, sellerCount: stats?.current?.[11] ?? null, + amazonIsSeller, + amazonBuyboxSharePct90d, buyBoxSeller: product.buyBoxSellerId ?? null, buyBoxPrice: stats?.current?.[18] != null ? stats.current[18] / 100 : null, monthlySold, @@ -795,6 +824,108 @@ function parseKeepaProduct(product: Record): KeepaData { }; } +function resolveAmazonIsSeller( + product: Record, + stats: Record | undefined, + csv: number[][] | undefined, +): boolean | null { + if (typeof product.isAmazonSeller === "boolean") + return product.isAmazonSeller; + + if (typeof product.availabilityAmazon === "number") { + if (product.availabilityAmazon >= 0) return true; + if ( + product.availabilityAmazon === -1 || + product.availabilityAmazon === -2 + ) { + return false; + } + } + + if (stats?.buyBoxIsAmazon === true) return true; + + if (typeof stats?.current?.[0] === "number") { + if (stats.current[0] > 0) return true; + if (stats.current[0] === -1 || stats.current[0] === -2) return false; + } + + const latestAmazonPrice = extractLatestPositivePrice(csv?.[0]); + if (latestAmazonPrice != null) return true; + + return null; +} + +function extractAmazonBuyboxSharePct90d( + product: Record, + stats: Record | undefined, +): number | null { + const candidates: unknown[] = [ + product.buyBoxStatsAmazon90, + stats?.buyBoxStatsAmazon90, + product.buyBoxStats?.amazon90, + product.buyBoxStats?.amazon?.[90], + product.buyBoxStats?.amazon?.["90"], + product.buyBoxStats?.[AMAZON_US_SELLER_ID]?.[90], + product.buyBoxStats?.[AMAZON_US_SELLER_ID]?.["90"], + ]; + + for (const value of candidates) { + if (typeof value !== "number" || !Number.isFinite(value)) continue; + if (value < 0 || value > 100) continue; + return Math.round(value * 100) / 100; + } + + return null; +} + +function computeAmazonBuyBoxSharePctFromHistory( + history: unknown, + windowDays: number, + amazonSellerIds: Set, +): number | null { + if (!Array.isArray(history) || history.length < 2) return null; + + const nowKeepaMinutes = + Math.floor(Date.now() / 60_000) - KEEPA_MINUTES_OFFSET; + const windowStart = nowKeepaMinutes - windowDays * 24 * 60; + let qualifiedMinutes = 0; + let amazonMinutes = 0; + + for (let i = 0; i < history.length - 1; i += 2) { + const startMinute = Number.parseInt(String(history[i]), 10); + const sellerId = String(history[i + 1] ?? "").toUpperCase(); + const nextRaw = i + 2 < history.length ? history[i + 2] : nowKeepaMinutes; + const endMinute = Number.parseInt(String(nextRaw), 10); + + if (!Number.isFinite(startMinute) || !Number.isFinite(endMinute)) continue; + if (endMinute <= startMinute) continue; + + const intervalStart = Math.max(startMinute, windowStart); + const intervalEnd = Math.min(endMinute, nowKeepaMinutes); + if (intervalEnd <= intervalStart) continue; + + if (sellerId === "-1" || sellerId === "-2") continue; + + const minutes = intervalEnd - intervalStart; + qualifiedMinutes += minutes; + if (amazonSellerIds.has(sellerId)) { + amazonMinutes += minutes; + } + } + + if (qualifiedMinutes === 0) return null; + return Math.round((amazonMinutes / qualifiedMinutes) * 10_000) / 100; +} + +function extractLatestPositivePrice(series: unknown): number | null { + if (!Array.isArray(series) || series.length < 2) return null; + const last = series[series.length - 1]; + if (typeof last !== "number" || !Number.isFinite(last) || last <= 0) { + return null; + } + return last / 100; +} + async function fetchKeepaEnrichmentMap( asins: string[], ): Promise> { @@ -804,7 +935,7 @@ async function fetchKeepaEnrichmentMap( const chunk = asins.slice(i, i + KEEPA_PRODUCT_CHUNK_SIZE); const asinParam = encodeURIComponent(chunk.join(",")); const data = await keepaGetJson( - `/product?key=${encodeURIComponent(config.keepaApiKey)}&domain=${DOMAIN_US}&asin=${asinParam}&stats=90`, + `/product?key=${encodeURIComponent(config.keepaApiKey)}&domain=${DOMAIN_US}&asin=${asinParam}&stats=90&buybox=1&days=90`, ); const products = Array.isArray(data?.products) ? data.products : []; @@ -911,7 +1042,10 @@ export async function processCategory( const uniqueTopAsins = Array.from(new Set(topAsins)); if (uniqueTopAsins.length !== topAsins.length) { - log("warn", ` Removed ${topAsins.length - uniqueTopAsins.length} duplicate ASINs before analysis.`); + log( + "warn", + ` Removed ${topAsins.length - uniqueTopAsins.length} duplicate ASINs before analysis.`, + ); } log("info", ` Top ASINs fetched: ${uniqueTopAsins.length}`); @@ -922,7 +1056,10 @@ export async function processCategory( return info?.canSell === true && info.sellabilityStatus === "available"; }); - log("info", ` Sellable ASINs: ${availableAsins.length}/${uniqueTopAsins.length}`); + log( + "info", + ` Sellable ASINs: ${availableAsins.length}/${uniqueTopAsins.length}`, + ); if (availableAsins.length === 0) { await updateCategoryRunSummary(db, runId, { topAsinsChecked: uniqueTopAsins.length, @@ -1054,7 +1191,8 @@ export async function main(): Promise { assertSpApiPrerequisites(); mkdirSync(args.outputDir, { recursive: true }); - const DB_PATH = process.env.RESULTS_DB_PATH || path.join(process.cwd(), "results.db"); + const DB_PATH = + process.env.RESULTS_DB_PATH || path.join(process.cwd(), "results.db"); initDb(DB_PATH); const db = getDb(DB_PATH); diff --git a/src/database.ts b/src/database.ts index 7e499a7..fc4c3bb 100644 --- a/src/database.ts +++ b/src/database.ts @@ -36,6 +36,8 @@ function createProductAnalysisResultsTable(database: Database): void { sales_rank INTEGER, sales_rank_avg_90d INTEGER, seller_count INTEGER, + amazon_is_seller INTEGER, + amazon_buybox_share_pct_90d REAL, monthly_sold INTEGER, rank_drops_30d INTEGER, rank_drops_90d INTEGER, @@ -92,7 +94,9 @@ function ensureProductAnalysisResultsTable(database: Database): void { asin, run_id, name, brand, category, unit_cost, current_price, avg_price_90d, avg_price_90d_sheet, selling_price_sheet, sales_rank, sales_rank_avg_90d, - seller_count, monthly_sold, rank_drops_30d, rank_drops_90d, + seller_count, NULL AS amazon_is_seller, + NULL AS amazon_buybox_share_pct_90d, + monthly_sold, rank_drops_30d, rank_drops_90d, fba_fee, fbm_fee, referral_percent, can_sell, sellability_status, sellability_reason, verdict, confidence, reasoning, fetched_at, @@ -106,7 +110,8 @@ function ensureProductAnalysisResultsTable(database: Database): void { asin, run_id, name, brand, category, unit_cost, current_price, avg_price_90d, avg_price_90d_sheet, selling_price_sheet, sales_rank, sales_rank_avg_90d, - seller_count, monthly_sold, rank_drops_30d, rank_drops_90d, + seller_count, amazon_is_seller, amazon_buybox_share_pct_90d, + monthly_sold, rank_drops_30d, rank_drops_90d, fba_fee, fbm_fee, referral_percent, can_sell, sellability_status, sellability_reason, verdict, confidence, reasoning, fetched_at @@ -115,7 +120,8 @@ function ensureProductAnalysisResultsTable(database: Database): void { asin, run_id, name, brand, category, unit_cost, current_price, avg_price_90d, avg_price_90d_sheet, selling_price_sheet, sales_rank, sales_rank_avg_90d, - seller_count, monthly_sold, rank_drops_30d, rank_drops_90d, + seller_count, amazon_is_seller, amazon_buybox_share_pct_90d, + monthly_sold, rank_drops_30d, rank_drops_90d, fba_fee, fbm_fee, referral_percent, can_sell, sellability_status, sellability_reason, verdict, confidence, reasoning, fetched_at @@ -126,6 +132,30 @@ function ensureProductAnalysisResultsTable(database: Database): void { } } +function ensureProductAnalysisResultsColumns(database: Database): void { + const tableInfo = database + .query("PRAGMA table_info(product_analysis_results)") + .all() as Array<{ name: string }>; + + if (tableInfo.length === 0) { + return; + } + + const existingColumns = new Set(tableInfo.map((col) => col.name)); + const requiredColumns: Array<{ name: string; type: string }> = [ + { name: "amazon_is_seller", type: "INTEGER" }, + { name: "amazon_buybox_share_pct_90d", type: "REAL" }, + ]; + + for (const column of requiredColumns) { + if (!existingColumns.has(column.name)) { + database.run( + `ALTER TABLE product_analysis_results ADD COLUMN ${column.name} ${column.type}`, + ); + } + } +} + function ensureResultsTableColumns(database: Database): void { const tableInfo = database .query("PRAGMA table_info(results)") @@ -151,6 +181,8 @@ function ensureResultsTableColumns(database: Database): void { { name: "promo_coupon_code", type: "TEXT" }, { name: "notes", type: "TEXT" }, { name: "lead_date", type: "TEXT" }, + { name: "amazon_is_seller", type: "INTEGER" }, + { name: "amazon_buybox_share_pct_90d", type: "REAL" }, ]; for (const column of requiredColumns) { @@ -192,6 +224,8 @@ export function initDb(dbPath: string): void { sales_rank INTEGER, rank_avg_90d INTEGER, sellers INTEGER, + amazon_is_seller INTEGER, + amazon_buybox_share_pct_90d REAL, monthly_sold INTEGER, rank_drops_30d INTEGER, rank_drops_90d INTEGER, @@ -239,6 +273,7 @@ export function initDb(dbPath: string): void { ); `); ensureProductAnalysisResultsTable(database); + ensureProductAnalysisResultsColumns(database); database.run( `CREATE INDEX IF NOT EXISTS idx_runs_timestamp ON runs(timestamp DESC);`, diff --git a/src/keepa.ts b/src/keepa.ts index 2f2f49d..3478e88 100644 --- a/src/keepa.ts +++ b/src/keepa.ts @@ -3,6 +3,8 @@ import type { KeepaData } from "./types.ts"; const KEEPA_BASE = "https://api.keepa.com"; const MAX_ASINS_PER_REQUEST = 100; +const AMAZON_US_SELLER_ID = "ATVPDKIKX0DER"; +const KEEPA_MINUTES_OFFSET = 21_564_000; // Token-based rate limiting: Keepa Pro = 1 token/min regeneration. // Each product request costs 1 token regardless of ASIN count (up to 100). @@ -44,7 +46,7 @@ export async function fetchKeepaDataBatch( await waitForToken(); const asinParam = chunk.join(","); - const url = `${KEEPA_BASE}/product?key=${config.keepaApiKey}&domain=1&asin=${asinParam}&stats=90`; + const url = `${KEEPA_BASE}/product?key=${config.keepaApiKey}&domain=1&asin=${asinParam}&stats=90&buybox=1&days=90`; console.log( `Keepa: fetching ${chunk.length} ASINs (tokens left: ${tokensLeft})...`, @@ -97,6 +99,14 @@ function parseKeepaProduct(product: Record): KeepaData { const monthlySold = pickKeepaNumber(product.monthlySold, stats?.monthlySold) ?? salesRankDrops30; + const amazonIsSeller = resolveAmazonIsSeller(product, stats, csv); + const amazonBuyboxSharePct90d = + extractAmazonBuyboxSharePct90d(product, stats) ?? + computeAmazonBuyBoxSharePctFromHistory( + product.buyBoxSellerIdHistory, + 90, + new Set([AMAZON_US_SELLER_ID]), + ); return { currentPrice: extractCurrentPrice(csv), @@ -108,6 +118,8 @@ function parseKeepaProduct(product: Record): KeepaData { salesRankDrops30, salesRankDrops90, sellerCount: stats?.current?.[11] ?? null, + amazonIsSeller, + amazonBuyboxSharePct90d, buyBoxSeller: product.buyBoxSellerId ?? null, buyBoxPrice: stats?.current?.[18] != null ? stats.current[18] / 100 : null, monthlySold, @@ -116,6 +128,111 @@ function parseKeepaProduct(product: Record): KeepaData { }; } +function resolveAmazonIsSeller( + product: Record, + stats: Record | undefined, + csv: number[][] | undefined, +): boolean | null { + if (typeof product.isAmazonSeller === "boolean") { + return product.isAmazonSeller; + } + + if (typeof product.availabilityAmazon === "number") { + if (product.availabilityAmazon >= 0) return true; + if ( + product.availabilityAmazon === -1 || + product.availabilityAmazon === -2 + ) { + return false; + } + } + + if (stats?.buyBoxIsAmazon === true) { + return true; + } + + if (typeof stats?.current?.[0] === "number") { + if (stats.current[0] > 0) return true; + if (stats.current[0] === -1 || stats.current[0] === -2) return false; + } + + const latestAmazonPrice = extractLatestPositivePrice(csv?.[0]); + if (latestAmazonPrice != null) return true; + + return null; +} + +function extractAmazonBuyboxSharePct90d( + product: Record, + stats: Record | undefined, +): number | null { + const candidates: unknown[] = [ + product.buyBoxStatsAmazon90, + stats?.buyBoxStatsAmazon90, + product.buyBoxStats?.amazon90, + product.buyBoxStats?.amazon?.[90], + product.buyBoxStats?.amazon?.["90"], + product.buyBoxStats?.[AMAZON_US_SELLER_ID]?.[90], + product.buyBoxStats?.[AMAZON_US_SELLER_ID]?.["90"], + ]; + + for (const value of candidates) { + if (typeof value !== "number" || !Number.isFinite(value)) continue; + if (value < 0 || value > 100) continue; + return Math.round(value * 100) / 100; + } + + return null; +} + +function computeAmazonBuyBoxSharePctFromHistory( + history: unknown, + windowDays: number, + amazonSellerIds: Set, +): number | null { + if (!Array.isArray(history) || history.length < 2) return null; + + const nowKeepaMinutes = + Math.floor(Date.now() / 60_000) - KEEPA_MINUTES_OFFSET; + const windowStart = nowKeepaMinutes - windowDays * 24 * 60; + let qualifiedMinutes = 0; + let amazonMinutes = 0; + + for (let i = 0; i < history.length - 1; i += 2) { + const startMinute = Number.parseInt(String(history[i]), 10); + const sellerId = String(history[i + 1] ?? "").toUpperCase(); + const nextRaw = i + 2 < history.length ? history[i + 2] : nowKeepaMinutes; + const endMinute = Number.parseInt(String(nextRaw), 10); + + if (!Number.isFinite(startMinute) || !Number.isFinite(endMinute)) continue; + if (endMinute <= startMinute) continue; + + const intervalStart = Math.max(startMinute, windowStart); + const intervalEnd = Math.min(endMinute, nowKeepaMinutes); + if (intervalEnd <= intervalStart) continue; + + if (sellerId === "-1" || sellerId === "-2") continue; + + const minutes = intervalEnd - intervalStart; + qualifiedMinutes += minutes; + if (amazonSellerIds.has(sellerId)) { + amazonMinutes += minutes; + } + } + + if (qualifiedMinutes === 0) return null; + return Math.round((amazonMinutes / qualifiedMinutes) * 10_000) / 100; +} + +function extractLatestPositivePrice(series: unknown): number | null { + if (!Array.isArray(series) || series.length < 2) return null; + const last = series[series.length - 1]; + if (typeof last !== "number" || !Number.isFinite(last) || last <= 0) { + return null; + } + return last / 100; +} + function pickKeepaNumber(...values: unknown[]): number | null { for (const value of values) { if (typeof value !== "number" || !Number.isFinite(value)) continue; diff --git a/src/server.ts b/src/server.ts index d2418e9..76917b2 100644 --- a/src/server.ts +++ b/src/server.ts @@ -1,5 +1,9 @@ import index from "./web/index.html"; import { getDb, initDb } from "./database.ts"; +import { fetchKeepaDataBatch } from "./keepa.ts"; +import { fetchSellabilityBatch, fetchSpApiPricingAndFees } from "./sp-api.ts"; +import { analyzeProducts } from "./llm.ts"; +import type { EnrichedProduct, ProductRecord, SpApiData } from "./types.ts"; type ProcessType = "lead_analysis" | "category_analysis"; @@ -29,6 +33,8 @@ type ProductListRecord = { sellability_status: string | null; monthly_sold: number | null; seller_count: number | null; + amazon_is_seller: number | null; + amazon_buybox_share_pct_90d: number | null; sales_rank: number | null; current_price: number | null; avg_price_90d: number | null; @@ -39,6 +45,7 @@ type ProductListRecord = { const DB_PATH = process.env.RESULTS_DB_PATH || "./results.db"; const DEFAULT_PAGE_SIZE = 25; const MAX_PAGE_SIZE = 200; +const ASIN_PATTERN = /^[A-Z0-9]{10}$/; initDb(DB_PATH); const db = getDb(DB_PATH); @@ -67,7 +74,19 @@ function parseIntParam(value: string | null, fallback: number): number { return parsed; } -function parseSort(sortParam: string | null, allowed: Set, fallback: string): string { +function normalizeAsin(value: string): string { + return value.trim().toUpperCase(); +} + +function isValidAsin(value: string): boolean { + return ASIN_PATTERN.test(value); +} + +function parseSort( + sortParam: string | null, + allowed: Set, + fallback: string, +): string { if (!sortParam) return fallback; const clauses = sortParam .split(",") @@ -85,7 +104,11 @@ function parseSort(sortParam: string | null, allowed: Set, fallback: str return clauses.length > 0 ? clauses.join(", ") : fallback; } -function parseResultSort(sortParam: string | null, allowed: Set, fallback: string): string { +function parseResultSort( + sortParam: string | null, + allowed: Set, + fallback: string, +): string { if (!sortParam) return fallback; const clauses = sortParam .split(",") @@ -96,7 +119,13 @@ function parseResultSort(sortParam: string | null, allowed: Set, fallbac const field = fieldRaw?.trim(); const dir = dirRaw?.trim().toUpperCase() === "DESC" ? "DESC" : "ASC"; if (!field || !allowed.has(field)) return null; - if (field === "monthly_sold") return `CAST(COALESCE(monthly_sold, 0) AS INTEGER) ${dir}`; + if (field === "monthly_sold") + return `CAST(COALESCE(monthly_sold, 0) AS INTEGER) ${dir}`; + if (field === "amazon_is_seller") + return `CAST(COALESCE(amazon_is_seller, 0) AS INTEGER) ${dir}`; + if (field === "amazon_buybox_share_pct_90d") { + return `CAST(COALESCE(amazon_buybox_share_pct_90d, 0) AS REAL) ${dir}`; + } return `${field} ${dir}`; }) .filter((value): value is string => value !== null); @@ -107,11 +136,15 @@ function parseResultSort(sortParam: string | null, allowed: Set, fallbac function escapeCsvValue(value: unknown): string { if (value === null || value === undefined) return ""; const text = String(value); - const escaped = text.replaceAll("\"", "\"\""); + const escaped = text.replaceAll('"', '""'); return /[",\n]/.test(escaped) ? `"${escaped}"` : escaped; } -function parseResultFilters(processType: ProcessType, runId: number, filters: URLSearchParams) { +function parseResultFilters( + processType: ProcessType, + runId: number, + filters: URLSearchParams, +) { const q = filters.get("q")?.trim() || ""; const verdict = filters.get("verdict")?.trim(); const sellabilityStatus = filters.get("sellabilityStatus")?.trim(); @@ -144,10 +177,14 @@ function parseResultFilters(processType: ProcessType, runId: number, filters: UR if (q) { const wildcard = `%${q}%`; if (processType === "lead_analysis") { - conditions.push("(asin LIKE ? OR product_name LIKE ? OR brand LIKE ? OR category LIKE ? OR reasoning LIKE ?)"); + conditions.push( + "(asin LIKE ? OR product_name LIKE ? OR brand LIKE ? OR category LIKE ? OR reasoning LIKE ?)", + ); params.push(wildcard, wildcard, wildcard, wildcard, wildcard); } else { - conditions.push("(asin LIKE ? OR name LIKE ? OR brand LIKE ? OR category LIKE ? OR reasoning LIKE ?)"); + conditions.push( + "(asin LIKE ? OR name LIKE ? OR brand LIKE ? OR category LIKE ? OR reasoning LIKE ?)", + ); params.push(wildcard, wildcard, wildcard, wildcard, wildcard); } } @@ -165,7 +202,10 @@ function getRuns(filters: URLSearchParams) { const startDate = filters.get("startDate")?.trim(); const endDate = filters.get("endDate")?.trim(); const page = parseIntParam(filters.get("page"), 1); - const pageSize = Math.min(parseIntParam(filters.get("pageSize"), DEFAULT_PAGE_SIZE), MAX_PAGE_SIZE); + const pageSize = Math.min( + parseIntParam(filters.get("pageSize"), DEFAULT_PAGE_SIZE), + MAX_PAGE_SIZE, + ); const offset = (page - 1) * pageSize; const allowedSort = new Set([ @@ -178,7 +218,11 @@ function getRuns(filters: URLSearchParams) { "runId", "jobType", ]); - const orderBy = parseSort(filters.get("sort"), allowedSort, "timestamp DESC, runId DESC"); + const orderBy = parseSort( + filters.get("sort"), + allowedSort, + "timestamp DESC, runId DESC", + ); const conditions: string[] = []; const params: Array = []; @@ -204,12 +248,15 @@ function getRuns(filters: URLSearchParams) { } if (q) { - conditions.push("(jobType LIKE ? OR source LIKE ? OR output LIKE ? OR CAST(runId AS TEXT) LIKE ?)"); + conditions.push( + "(jobType LIKE ? OR source LIKE ? OR output LIKE ? OR CAST(runId AS TEXT) LIKE ?)", + ); const wildcard = `%${q}%`; params.push(wildcard, wildcard, wildcard, wildcard); } - const where = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : ""; + const where = + conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : ""; const baseUnion = ` SELECT @@ -246,7 +293,9 @@ function getRuns(filters: URLSearchParams) { .get(...params) as { total: number }; const items = db - .query(`SELECT * FROM (${baseUnion}) all_runs ${where} ORDER BY ${orderBy} LIMIT ? OFFSET ?`) + .query( + `SELECT * FROM (${baseUnion}) all_runs ${where} ORDER BY ${orderBy} LIMIT ? OFFSET ?`, + ) .all(...params, pageSize, offset) as RunRecord[]; return { @@ -262,7 +311,10 @@ function getProductList(filters: URLSearchParams) { const q = filters.get("q")?.trim() || ""; const verdict = filters.get("verdict")?.trim(); const page = parseIntParam(filters.get("page"), 1); - const pageSize = Math.min(parseIntParam(filters.get("pageSize"), DEFAULT_PAGE_SIZE), MAX_PAGE_SIZE); + const pageSize = Math.min( + parseIntParam(filters.get("pageSize"), DEFAULT_PAGE_SIZE), + MAX_PAGE_SIZE, + ); const offset = (page - 1) * pageSize; const conditions: string[] = []; @@ -275,16 +327,21 @@ function getProductList(filters: URLSearchParams) { if (q) { const wildcard = `%${q}%`; - conditions.push("(asin LIKE ? OR product_name LIKE ? OR brand LIKE ? OR category LIKE ?)"); + conditions.push( + "(asin LIKE ? OR product_name LIKE ? OR brand LIKE ? OR category LIKE ?)", + ); params.push(wildcard, wildcard, wildcard, wildcard); } - const where = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : ""; + const where = + conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : ""; const allowedSort = new Set([ "asin", "verdict", "monthly_sold", "seller_count", + "amazon_is_seller", + "amazon_buybox_share_pct_90d", "sales_rank", "current_price", "product_name", @@ -314,6 +371,8 @@ function getProductList(filters: URLSearchParams) { sellability_status, monthly_sold, sellers AS seller_count, + amazon_is_seller, + amazon_buybox_share_pct_90d, sales_rank, current_price, avg_price_90d, @@ -333,6 +392,8 @@ function getProductList(filters: URLSearchParams) { sellability_status, monthly_sold, seller_count, + amazon_is_seller, + amazon_buybox_share_pct_90d, sales_rank, current_price, avg_price_90d, @@ -346,7 +407,9 @@ function getProductList(filters: URLSearchParams) { .get(...params) as { total: number }; const items = db - .query(`SELECT * FROM (${baseUnion}) all_products ${where} ORDER BY ${orderBy} LIMIT ? OFFSET ?`) + .query( + `SELECT * FROM (${baseUnion}) all_products ${where} ORDER BY ${orderBy} LIMIT ? OFFSET ?`, + ) .all(...params, pageSize, offset) as ProductListRecord[]; return { @@ -400,15 +463,30 @@ function getRun(processType: ProcessType, runId: number) { return run ?? null; } -function getRunResults(processType: ProcessType, runId: number, filters: URLSearchParams) { +function getRunResults( + processType: ProcessType, + runId: number, + filters: URLSearchParams, +) { const page = parseIntParam(filters.get("page"), 1); - const pageSize = Math.min(parseIntParam(filters.get("pageSize"), DEFAULT_PAGE_SIZE), MAX_PAGE_SIZE); + const pageSize = Math.min( + parseIntParam(filters.get("pageSize"), DEFAULT_PAGE_SIZE), + MAX_PAGE_SIZE, + ); const offset = (page - 1) * pageSize; - const tableName = processType === "lead_analysis" ? "results" : "product_analysis_results"; - const productNameSelect = processType === "lead_analysis" ? "product_name" : "name AS product_name"; - const sellerCountSelect = processType === "lead_analysis" ? "sellers AS seller_count" : "seller_count"; - const salesRankAvgSelect = processType === "lead_analysis" ? "rank_avg_90d AS sales_rank_avg_90d" : "sales_rank_avg_90d"; + const tableName = + processType === "lead_analysis" ? "results" : "product_analysis_results"; + const productNameSelect = + processType === "lead_analysis" ? "product_name" : "name AS product_name"; + const sellerCountSelect = + processType === "lead_analysis" + ? "sellers AS seller_count" + : "seller_count"; + const salesRankAvgSelect = + processType === "lead_analysis" + ? "rank_avg_90d AS sales_rank_avg_90d" + : "sales_rank_avg_90d"; const { where, params } = parseResultFilters(processType, runId, filters); @@ -421,6 +499,8 @@ function getRunResults(processType: ProcessType, runId: number, filters: URLSear "avg_price_90d", "sales_rank", "seller_count", + "amazon_is_seller", + "amazon_buybox_share_pct_90d", "monthly_sold", "verdict", "confidence", @@ -453,6 +533,8 @@ function getRunResults(processType: ProcessType, runId: number, filters: URLSear sales_rank, ${salesRankAvgSelect}, ${sellerCountSelect}, + amazon_is_seller, + amazon_buybox_share_pct_90d, monthly_sold, rank_drops_30d, rank_drops_90d, @@ -484,7 +566,9 @@ function getRunResults(processType: ProcessType, runId: number, filters: URLSear function deleteRun(processType: ProcessType, runId: number) { if (processType === "lead_analysis") { - const resultRows = db.query("DELETE FROM results WHERE run_id = ?").run(runId); + const resultRows = db + .query("DELETE FROM results WHERE run_id = ?") + .run(runId); const runRows = db.query("DELETE FROM runs WHERE id = ?").run(runId); return { deletedRun: runRows.changes > 0, @@ -492,19 +576,35 @@ function deleteRun(processType: ProcessType, runId: number) { }; } - const resultRows = db.query("DELETE FROM product_analysis_results WHERE run_id = ?").run(runId); - const runRows = db.query("DELETE FROM category_analysis_runs WHERE id = ?").run(runId); + const resultRows = db + .query("DELETE FROM product_analysis_results WHERE run_id = ?") + .run(runId); + const runRows = db + .query("DELETE FROM category_analysis_runs WHERE id = ?") + .run(runId); return { deletedRun: runRows.changes > 0, deletedResults: resultRows.changes, }; } -function exportRunResultsCsv(processType: ProcessType, runId: number, filters: URLSearchParams) { - const tableName = processType === "lead_analysis" ? "results" : "product_analysis_results"; - const productNameSelect = processType === "lead_analysis" ? "product_name" : "name AS product_name"; - const sellerCountSelect = processType === "lead_analysis" ? "sellers AS seller_count" : "seller_count"; - const salesRankAvgSelect = processType === "lead_analysis" ? "rank_avg_90d AS sales_rank_avg_90d" : "sales_rank_avg_90d"; +function exportRunResultsCsv( + processType: ProcessType, + runId: number, + filters: URLSearchParams, +) { + const tableName = + processType === "lead_analysis" ? "results" : "product_analysis_results"; + const productNameSelect = + processType === "lead_analysis" ? "product_name" : "name AS product_name"; + const sellerCountSelect = + processType === "lead_analysis" + ? "sellers AS seller_count" + : "seller_count"; + const salesRankAvgSelect = + processType === "lead_analysis" + ? "rank_avg_90d AS sales_rank_avg_90d" + : "sales_rank_avg_90d"; const { where, params } = parseResultFilters(processType, runId, filters); @@ -517,6 +617,8 @@ function exportRunResultsCsv(processType: ProcessType, runId: number, filters: U "avg_price_90d", "sales_rank", "seller_count", + "amazon_is_seller", + "amazon_buybox_share_pct_90d", "monthly_sold", "verdict", "confidence", @@ -541,6 +643,8 @@ function exportRunResultsCsv(processType: ProcessType, runId: number, filters: U avg_price_90d, ${salesRankAvgSelect}, ${sellerCountSelect}, + amazon_is_seller, + amazon_buybox_share_pct_90d, monthly_sold, sellability_status, verdict, @@ -564,6 +668,8 @@ function exportRunResultsCsv(processType: ProcessType, runId: number, filters: U "avg_price_90d", "sales_rank_avg_90d", "seller_count", + "amazon_is_seller", + "amazon_buybox_share_pct_90d", "monthly_sold", "sellability_status", "verdict", @@ -580,6 +686,366 @@ function exportRunResultsCsv(processType: ProcessType, runId: number, filters: U return lines.join("\n"); } +type ReanalyzeSourceRow = { + asin: string; + product_name: string | null; + brand: string | null; + category: string | null; + unit_cost: number | null; + sales_rank: number | null; + avg_price_90d_sheet: number | null; + selling_price_sheet: number | null; + fba_net_sheet: number | null; + gross_profit_dollar: number | null; + gross_profit_pct: number | null; + net_profit_sheet: number | null; + roi_sheet: number | null; + moq: number | null; + moq_cost: number | null; + qty_available: number | null; + supplier: string | null; + source_url: string | null; + asin_link: string | null; + promo_coupon_code: string | null; + notes: string | null; + lead_date: string | null; +}; + +function getReanalyzeSourceRow( + processType: ProcessType, + runId: number, + asin: string, +): ReanalyzeSourceRow | null { + if (processType === "lead_analysis") { + return ( + (db + .query( + `SELECT + asin, + product_name, + brand, + category, + unit_cost, + sales_rank, + avg_price_90d_sheet, + selling_price_sheet, + fba_net_sheet, + gross_profit_dollar, + gross_profit_pct, + net_profit_sheet, + roi_sheet, + moq, + moq_cost, + qty_available, + supplier, + source_url, + asin_link, + promo_coupon_code, + notes, + lead_date + FROM results + WHERE run_id = ? AND asin = ? + LIMIT 1`, + ) + .get(runId, asin) as ReanalyzeSourceRow | null) ?? null + ); + } + + return ( + (db + .query( + `SELECT + asin, + name AS product_name, + brand, + category, + unit_cost, + sales_rank, + avg_price_90d_sheet, + selling_price_sheet, + NULL AS fba_net_sheet, + NULL AS gross_profit_dollar, + NULL AS gross_profit_pct, + NULL AS net_profit_sheet, + NULL AS roi_sheet, + NULL AS moq, + NULL AS moq_cost, + NULL AS qty_available, + NULL AS supplier, + NULL AS source_url, + NULL AS asin_link, + NULL AS promo_coupon_code, + NULL AS notes, + NULL AS lead_date + FROM product_analysis_results + WHERE run_id = ? AND asin = ? + LIMIT 1`, + ) + .get(runId, asin) as ReanalyzeSourceRow | null) ?? null + ); +} + +function toProductRecord(row: ReanalyzeSourceRow): ProductRecord { + return { + asin: row.asin, + name: row.product_name ?? row.asin, + unitCost: row.unit_cost ?? 0, + brand: row.brand ?? undefined, + category: row.category ?? undefined, + amazonRank: row.sales_rank ?? undefined, + avgPrice90FromSheet: row.avg_price_90d_sheet ?? undefined, + sellingPriceFromSheet: row.selling_price_sheet ?? undefined, + fbaNet: row.fba_net_sheet ?? undefined, + grossProfit: row.gross_profit_dollar ?? undefined, + grossProfitPct: row.gross_profit_pct ?? undefined, + netProfitFromSheet: row.net_profit_sheet ?? undefined, + roiFromSheet: row.roi_sheet ?? undefined, + moq: row.moq ?? undefined, + moqCost: row.moq_cost ?? undefined, + totalQtyAvail: row.qty_available ?? undefined, + supplier: row.supplier ?? undefined, + sourceUrl: row.source_url ?? undefined, + asinLink: row.asin_link ?? undefined, + promoCouponCode: row.promo_coupon_code ?? undefined, + notes: row.notes ?? undefined, + leadDate: row.lead_date ?? undefined, + }; +} + +function unknownSpApiData(): SpApiData { + return { + fbaFee: 5.0, + fbmFee: 1.5, + referralFeePercent: 15, + estimatedSalePrice: 0, + canSell: null, + sellabilityStatus: "unknown", + sellabilityReason: "Sellability check returned no result", + }; +} + +function refreshRunCounts(processType: ProcessType, runId: number): void { + if (processType === "lead_analysis") { + const stats = db + .query( + `SELECT + COUNT(*) AS total, + SUM(CASE WHEN verdict = 'FBA' THEN 1 ELSE 0 END) AS fba, + SUM(CASE WHEN verdict = 'FBM' THEN 1 ELSE 0 END) AS fbm, + SUM(CASE WHEN verdict = 'SKIP' THEN 1 ELSE 0 END) AS skip + FROM results + WHERE run_id = ?`, + ) + .get(runId) as { + total: number; + fba: number | null; + fbm: number | null; + skip: number | null; + }; + + db.query( + `UPDATE runs + SET total_products = ?, fba_count = ?, fbm_count = ?, skip_count = ? + WHERE id = ?`, + ).run( + stats.total ?? 0, + stats.fba ?? 0, + stats.fbm ?? 0, + stats.skip ?? 0, + runId, + ); + return; + } + + const stats = db + .query( + `SELECT + COUNT(*) AS available, + SUM(CASE WHEN verdict = 'FBA' THEN 1 ELSE 0 END) AS fba, + SUM(CASE WHEN verdict = 'FBM' THEN 1 ELSE 0 END) AS fbm, + SUM(CASE WHEN verdict = 'SKIP' THEN 1 ELSE 0 END) AS skip + FROM product_analysis_results + WHERE run_id = ?`, + ) + .get(runId) as { + available: number; + fba: number | null; + fbm: number | null; + skip: number | null; + }; + + db.query( + `UPDATE category_analysis_runs + SET available_asins = ?, fba_count = ?, fbm_count = ?, skip_count = ? + WHERE id = ?`, + ).run( + stats.available ?? 0, + stats.fba ?? 0, + stats.fbm ?? 0, + stats.skip ?? 0, + runId, + ); +} + +async function reanalyzeSingleAsin( + processType: ProcessType, + runId: number, + asin: string, +): Promise<{ + asin: string; + runId: number; + processType: ProcessType; + fetchedAt: string; +}> { + const row = getReanalyzeSourceRow(processType, runId, asin); + if (!row) { + throw new Error("Result row not found"); + } + + const productRecord = toProductRecord(row); + + let keepa = null; + try { + const keepaMap = await fetchKeepaDataBatch([asin]); + keepa = keepaMap.get(asin) ?? null; + } catch { + keepa = null; + } + + const sellabilityMap = await fetchSellabilityBatch([asin]); + const sellability = sellabilityMap.get(asin) ?? { + canSell: null, + sellabilityStatus: "unknown" as const, + sellabilityReason: "Sellability check returned no result", + }; + const spApi = await fetchSpApiPricingAndFees(asin, sellability); + + if (keepa?.currentPrice && spApi.estimatedSalePrice === 0) { + spApi.estimatedSalePrice = keepa.currentPrice; + } + + const enriched: EnrichedProduct = { + record: productRecord, + keepa, + spApi: spApi ?? unknownSpApiData(), + fetchedAt: new Date().toISOString(), + }; + + const verdicts = await analyzeProducts([enriched]); + const verdict = verdicts[0] ?? { + asin, + verdict: "SKIP" as const, + confidence: 0, + reasoning: "LLM analysis returned no verdict", + }; + + const amazonIsSeller = + keepa?.amazonIsSeller == null ? null : keepa.amazonIsSeller ? 1 : 0; + const fetchedAt = enriched.fetchedAt; + + if (processType === "lead_analysis") { + db.query( + `UPDATE results SET + current_price = ?, + avg_price_90d = ?, + sales_rank = ?, + rank_avg_90d = ?, + sellers = ?, + amazon_is_seller = ?, + amazon_buybox_share_pct_90d = ?, + monthly_sold = ?, + rank_drops_30d = ?, + rank_drops_90d = ?, + fba_fee = ?, + fbm_fee = ?, + referral_percent = ?, + can_sell = ?, + sellability_status = ?, + sellability_reason = ?, + verdict = ?, + confidence = ?, + reasoning = ?, + fetched_at = ? + WHERE run_id = ? AND asin = ?`, + ).run( + keepa?.currentPrice ?? null, + keepa?.avgPrice90 ?? null, + keepa?.salesRank ?? row.sales_rank ?? null, + keepa?.salesRankAvg90 ?? null, + keepa?.sellerCount ?? null, + amazonIsSeller, + keepa?.amazonBuyboxSharePct90d ?? null, + keepa?.monthlySold ?? null, + keepa?.salesRankDrops30 ?? null, + keepa?.salesRankDrops90 ?? null, + spApi.fbaFee, + spApi.fbmFee, + spApi.referralFeePercent, + spApi.canSell == null ? null : spApi.canSell ? 1 : 0, + spApi.sellabilityStatus, + spApi.sellabilityReason ?? null, + verdict.verdict, + verdict.confidence, + verdict.reasoning, + fetchedAt, + runId, + asin, + ); + } else { + db.query( + `UPDATE product_analysis_results SET + current_price = ?, + avg_price_90d = ?, + sales_rank = ?, + sales_rank_avg_90d = ?, + seller_count = ?, + amazon_is_seller = ?, + amazon_buybox_share_pct_90d = ?, + monthly_sold = ?, + rank_drops_30d = ?, + rank_drops_90d = ?, + fba_fee = ?, + fbm_fee = ?, + referral_percent = ?, + can_sell = ?, + sellability_status = ?, + sellability_reason = ?, + verdict = ?, + confidence = ?, + reasoning = ?, + fetched_at = ? + WHERE run_id = ? AND asin = ?`, + ).run( + keepa?.currentPrice ?? null, + keepa?.avgPrice90 ?? null, + keepa?.salesRank ?? row.sales_rank ?? null, + keepa?.salesRankAvg90 ?? null, + keepa?.sellerCount ?? null, + amazonIsSeller, + keepa?.amazonBuyboxSharePct90d ?? null, + keepa?.monthlySold ?? null, + keepa?.salesRankDrops30 ?? null, + keepa?.salesRankDrops90 ?? null, + spApi.fbaFee, + spApi.fbmFee, + spApi.referralFeePercent, + spApi.canSell == null ? null : spApi.canSell ? 1 : 0, + spApi.sellabilityStatus, + spApi.sellabilityReason ?? null, + verdict.verdict, + verdict.confidence, + verdict.reasoning, + fetchedAt, + runId, + asin, + ); + } + + refreshRunCounts(processType, runId); + + return { asin, runId, processType, fetchedAt }; +} + const server = Bun.serve({ port: Number(process.env.PORT || "3000"), routes: { @@ -598,7 +1064,12 @@ const server = Bun.serve({ const processType = req.params.processType as ProcessType; const runId = Number(req.params.runId); - if (!(processType === "lead_analysis" || processType === "category_analysis") || !Number.isInteger(runId)) { + if ( + !( + processType === "lead_analysis" || processType === "category_analysis" + ) || + !Number.isInteger(runId) + ) { return json({ error: "Invalid run identifier" }, 400); } @@ -624,7 +1095,12 @@ const server = Bun.serve({ const processType = req.params.processType as ProcessType; const runId = Number(req.params.runId); - if (!(processType === "lead_analysis" || processType === "category_analysis") || !Number.isInteger(runId)) { + if ( + !( + processType === "lead_analysis" || processType === "category_analysis" + ) || + !Number.isInteger(runId) + ) { return json({ error: "Invalid run identifier" }, 400); } @@ -632,11 +1108,49 @@ const server = Bun.serve({ const payload = getRunResults(processType, runId, url.searchParams); return json(payload); }, + "/api/runs/:processType/:runId/asins/:asin/reanalyze": async (req) => { + const processType = req.params.processType as ProcessType; + const runId = Number(req.params.runId); + const asin = normalizeAsin(req.params.asin); + + if ( + !( + processType === "lead_analysis" || processType === "category_analysis" + ) || + !Number.isInteger(runId) + ) { + return json({ error: "Invalid run identifier" }, 400); + } + + if (req.method !== "POST") { + return json({ error: "Method not allowed" }, 405); + } + + if (!isValidAsin(asin)) { + return json({ error: "Invalid ASIN" }, 400); + } + + try { + const result = await reanalyzeSingleAsin(processType, runId, asin); + return json(result); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + if (message === "Result row not found") { + return json({ error: message }, 404); + } + return json({ error: message }, 500); + } + }, "/api/runs/:processType/:runId/export.csv": (req) => { const processType = req.params.processType as ProcessType; const runId = Number(req.params.runId); - if (!(processType === "lead_analysis" || processType === "category_analysis") || !Number.isInteger(runId)) { + if ( + !( + processType === "lead_analysis" || processType === "category_analysis" + ) || + !Number.isInteger(runId) + ) { return json({ error: "Invalid run identifier" }, 400); } diff --git a/src/top-monthly-sold-by-category.ts b/src/top-monthly-sold-by-category.ts index 34e3bae..01f0a41 100644 --- a/src/top-monthly-sold-by-category.ts +++ b/src/top-monthly-sold-by-category.ts @@ -46,6 +46,8 @@ type CategoryRunSummary = { const KEEPA_BASE = "https://api.keepa.com"; const DOMAIN_US = 1; +const AMAZON_US_SELLER_ID = "ATVPDKIKX0DER"; +const KEEPA_MINUTES_OFFSET = 21_564_000; const DEFAULT_CATEGORY_LIMIT = 32; const DEFAULT_PER_CATEGORY_TOP = 100; const DEFAULT_CATEGORY_CANDIDATE_POOL = 500; @@ -244,14 +246,15 @@ export async function insertProductAnalysisResults( asin, run_id, name, brand, category, unit_cost, current_price, avg_price_90d, avg_price_90d_sheet, selling_price_sheet, sales_rank, sales_rank_avg_90d, - seller_count, monthly_sold, rank_drops_30d, rank_drops_90d, + seller_count, amazon_is_seller, amazon_buybox_share_pct_90d, + monthly_sold, rank_drops_30d, rank_drops_90d, fba_fee, fbm_fee, referral_percent, can_sell, sellability_status, sellability_reason, verdict, confidence, reasoning, fetched_at ) VALUES ( ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, - ?, ?, ?, ?, ?, ? + ?, ?, ?, ?, ?, ?, ?, ? ) ON CONFLICT(asin) DO UPDATE SET run_id = excluded.run_id, @@ -266,6 +269,8 @@ export async function insertProductAnalysisResults( sales_rank = excluded.sales_rank, sales_rank_avg_90d = excluded.sales_rank_avg_90d, seller_count = excluded.seller_count, + amazon_is_seller = excluded.amazon_is_seller, + amazon_buybox_share_pct_90d = excluded.amazon_buybox_share_pct_90d, monthly_sold = excluded.monthly_sold, rank_drops_30d = excluded.rank_drops_30d, rank_drops_90d = excluded.rank_drops_90d, @@ -305,6 +310,12 @@ export async function insertProductAnalysisResults( rank ?? null, r.product.keepa?.salesRankAvg90 ?? null, r.product.keepa?.sellerCount ?? null, + r.product.keepa?.amazonIsSeller == null + ? null + : r.product.keepa.amazonIsSeller + ? 1 + : 0, + r.product.keepa?.amazonBuyboxSharePct90d ?? null, r.product.keepa?.monthlySold ?? null, r.product.keepa?.salesRankDrops30 ?? null, r.product.keepa?.salesRankDrops90 ?? null, @@ -816,6 +827,14 @@ function parseKeepaProduct(product: Record): KeepaData { const monthlySold = pickKeepaNumber(product.monthlySold, stats?.monthlySold) ?? salesRankDrops30; + const amazonIsSeller = resolveAmazonIsSeller(product, stats, csv); + const amazonBuyboxSharePct90d = + extractAmazonBuyboxSharePct90d(product, stats) ?? + computeAmazonBuyBoxSharePctFromHistory( + product.buyBoxSellerIdHistory, + 90, + new Set([AMAZON_US_SELLER_ID]), + ); return { currentPrice: extractCurrentPrice(csv), @@ -827,6 +846,8 @@ function parseKeepaProduct(product: Record): KeepaData { salesRankDrops30, salesRankDrops90, sellerCount: stats?.current?.[11] ?? null, + amazonIsSeller, + amazonBuyboxSharePct90d, buyBoxSeller: product.buyBoxSellerId ?? null, buyBoxPrice: stats?.current?.[18] != null ? stats.current[18] / 100 : null, monthlySold, @@ -835,6 +856,108 @@ function parseKeepaProduct(product: Record): KeepaData { }; } +function resolveAmazonIsSeller( + product: Record, + stats: Record | undefined, + csv: number[][] | undefined, +): boolean | null { + if (typeof product.isAmazonSeller === "boolean") + return product.isAmazonSeller; + + if (typeof product.availabilityAmazon === "number") { + if (product.availabilityAmazon >= 0) return true; + if ( + product.availabilityAmazon === -1 || + product.availabilityAmazon === -2 + ) { + return false; + } + } + + if (stats?.buyBoxIsAmazon === true) return true; + + if (typeof stats?.current?.[0] === "number") { + if (stats.current[0] > 0) return true; + if (stats.current[0] === -1 || stats.current[0] === -2) return false; + } + + const latestAmazonPrice = extractLatestPositivePrice(csv?.[0]); + if (latestAmazonPrice != null) return true; + + return null; +} + +function extractAmazonBuyboxSharePct90d( + product: Record, + stats: Record | undefined, +): number | null { + const candidates: unknown[] = [ + product.buyBoxStatsAmazon90, + stats?.buyBoxStatsAmazon90, + product.buyBoxStats?.amazon90, + product.buyBoxStats?.amazon?.[90], + product.buyBoxStats?.amazon?.["90"], + product.buyBoxStats?.[AMAZON_US_SELLER_ID]?.[90], + product.buyBoxStats?.[AMAZON_US_SELLER_ID]?.["90"], + ]; + + for (const value of candidates) { + if (typeof value !== "number" || !Number.isFinite(value)) continue; + if (value < 0 || value > 100) continue; + return Math.round(value * 100) / 100; + } + + return null; +} + +function computeAmazonBuyBoxSharePctFromHistory( + history: unknown, + windowDays: number, + amazonSellerIds: Set, +): number | null { + if (!Array.isArray(history) || history.length < 2) return null; + + const nowKeepaMinutes = + Math.floor(Date.now() / 60_000) - KEEPA_MINUTES_OFFSET; + const windowStart = nowKeepaMinutes - windowDays * 24 * 60; + let qualifiedMinutes = 0; + let amazonMinutes = 0; + + for (let i = 0; i < history.length - 1; i += 2) { + const startMinute = Number.parseInt(String(history[i]), 10); + const sellerId = String(history[i + 1] ?? "").toUpperCase(); + const nextRaw = i + 2 < history.length ? history[i + 2] : nowKeepaMinutes; + const endMinute = Number.parseInt(String(nextRaw), 10); + + if (!Number.isFinite(startMinute) || !Number.isFinite(endMinute)) continue; + if (endMinute <= startMinute) continue; + + const intervalStart = Math.max(startMinute, windowStart); + const intervalEnd = Math.min(endMinute, nowKeepaMinutes); + if (intervalEnd <= intervalStart) continue; + + if (sellerId === "-1" || sellerId === "-2") continue; + + const minutes = intervalEnd - intervalStart; + qualifiedMinutes += minutes; + if (amazonSellerIds.has(sellerId)) { + amazonMinutes += minutes; + } + } + + if (qualifiedMinutes === 0) return null; + return Math.round((amazonMinutes / qualifiedMinutes) * 10_000) / 100; +} + +function extractLatestPositivePrice(series: unknown): number | null { + if (!Array.isArray(series) || series.length < 2) return null; + const last = series[series.length - 1]; + if (typeof last !== "number" || !Number.isFinite(last) || last <= 0) { + return null; + } + return last / 100; +} + async function fetchKeepaEnrichmentMap( asins: string[], ): Promise> { @@ -844,7 +967,7 @@ async function fetchKeepaEnrichmentMap( const chunk = asins.slice(i, i + KEEPA_PRODUCT_CHUNK_SIZE); const asinParam = encodeURIComponent(chunk.join(",")); const data = await keepaGetJson( - `/product?key=${encodeURIComponent(config.keepaApiKey)}&domain=${DOMAIN_US}&asin=${asinParam}&stats=90`, + `/product?key=${encodeURIComponent(config.keepaApiKey)}&domain=${DOMAIN_US}&asin=${asinParam}&stats=90&buybox=1&days=90`, ); const products = Array.isArray(data?.products) ? data.products : []; diff --git a/src/types.ts b/src/types.ts index 6216e86..48cb0e4 100644 --- a/src/types.ts +++ b/src/types.ts @@ -36,6 +36,8 @@ export interface KeepaData { salesRankDrops30: number | null; salesRankDrops90: number | null; sellerCount: number | null; + amazonIsSeller: boolean | null; + amazonBuyboxSharePct90d: number | null; buyBoxSeller: string | null; buyBoxPrice: number | null; monthlySold: number | null; diff --git a/src/web/frontend.tsx b/src/web/frontend.tsx index f2e22ec..e54d2e8 100644 --- a/src/web/frontend.tsx +++ b/src/web/frontend.tsx @@ -61,6 +61,8 @@ type ResultItem = { seller_count: number | null; monthly_sold: number | null; verdict: "FBA" | "FBM" | "SKIP"; + amazon_is_seller: number | null; + amazon_buybox_share_pct_90d: number | null; confidence: number | null; sellability_status: string | null; reasoning: string | null; @@ -89,6 +91,8 @@ type ProductListItem = { sellability_status: string | null; monthly_sold: number | null; seller_count: number | null; + amazon_is_seller: number | null; + amazon_buybox_share_pct_90d: number | null; sales_rank: number | null; current_price: number | null; avg_price_90d: number | null; @@ -129,6 +133,11 @@ function formatCurrency(value: number | null | undefined): string { }).format(value); } +function formatAmazonSeller(value: number | null | undefined): string { + if (value === null || value === undefined) return "-"; + return value === 1 ? "Yes" : "No"; +} + function buildSortValue(sort: SortState): string { return `${sort.field}:${sort.direction}`; } @@ -426,6 +435,7 @@ function RunDetails({ const [pageSize, setPageSize] = useState(25); const [sort, setSort] = useState({ field: "monthly_sold", direction: "DESC" }); const [refreshTick, setRefreshTick] = useState(0); + const [reanalyzing, setReanalyzing] = useState>({}); const anomalies = useMemo(() => { if (!results) return [] as ResultItem[]; @@ -485,6 +495,29 @@ function RunDetails({ }; }, [processType, runId]); + async function reanalyzeAsin(asin: string) { + if (reanalyzing[asin]) return; + setReanalyzing((prev) => ({ ...prev, [asin]: true })); + try { + const response = await fetch( + `/api/runs/${processType}/${runId}/asins/${encodeURIComponent(asin)}/reanalyze`, + { method: "POST" }, + ); + if (!response.ok) { + const payload = (await response.json().catch(() => null)) as { error?: string } | null; + window.alert(payload?.error ?? "Failed to re-analyze ASIN"); + return; + } + setRefreshTick((tick) => tick + 1); + } finally { + setReanalyzing((prev) => { + const next = { ...prev }; + delete next[asin]; + return next; + }); + } + } + return (
@@ -565,6 +598,8 @@ function RunDetails({ + + @@ -573,11 +608,12 @@ function RunDetails({ + Action {loading ? ( - Loading... + Loading... ) : results?.items.length ? ( results.items.map((item) => ( @@ -585,6 +621,8 @@ function RunDetails({ {item.verdict} {formatNumber(item.monthly_sold)} {formatNumber(item.seller_count)} + {formatAmazonSeller(item.amazon_is_seller)} + {formatNumber(item.amazon_buybox_share_pct_90d)} {formatNumber(item.sales_rank)} {formatCurrency(item.current_price)} {item.product_name || "-"} @@ -593,10 +631,18 @@ function RunDetails({ {formatCurrency(item.avg_price_90d)} {formatNumber(item.confidence)} {item.reasoning || "-"} + + + )) ) : ( - No results found + No results found )} @@ -622,6 +668,7 @@ function ProductList({ verdict, onBack }: { verdict: VerdictFilter; onBack: () = const [page, setPage] = useState(1); const [pageSize, setPageSize] = useState(25); const [sort, setSort] = useState({ field: "monthly_sold", direction: "DESC" }); + const [reanalyzing, setReanalyzing] = useState>({}); useEffect(() => { setActiveVerdict(verdict); @@ -650,6 +697,36 @@ function ProductList({ verdict, onBack }: { verdict: VerdictFilter; onBack: () = }; }, [search, activeVerdict, page, pageSize, sort]); + async function reanalyzeAsin(item: ProductListItem) { + const key = `${item.processType}:${item.runId}:${item.asin}`; + if (reanalyzing[key]) return; + setReanalyzing((prev) => ({ ...prev, [key]: true })); + try { + const response = await fetch( + `/api/runs/${item.processType}/${item.runId}/asins/${encodeURIComponent(item.asin)}/reanalyze`, + { method: "POST" }, + ); + if (!response.ok) { + const payload = (await response.json().catch(() => null)) as { error?: string } | null; + window.alert(payload?.error ?? "Failed to re-analyze ASIN"); + return; + } + const params = new URLSearchParams({ page: String(page), pageSize: String(pageSize) }); + params.set("sort", buildSortValue(sort)); + if (search) params.set("q", search); + if (activeVerdict) params.set("verdict", activeVerdict); + const res = await fetch(`/api/products?${params.toString()}`); + const payload = (await res.json()) as ProductListResponse; + setItems(payload); + } finally { + setReanalyzing((prev) => { + const next = { ...prev }; + delete next[key]; + return next; + }); + } + } + return (
@@ -679,6 +756,8 @@ function ProductList({ verdict, onBack }: { verdict: VerdictFilter; onBack: () = + + @@ -687,11 +766,12 @@ function ProductList({ verdict, onBack }: { verdict: VerdictFilter; onBack: () = + Action {loading ? ( - Loading... + Loading... ) : items?.items.length ? ( items.items.map((item) => ( @@ -699,6 +779,8 @@ function ProductList({ verdict, onBack }: { verdict: VerdictFilter; onBack: () = {item.verdict} {formatNumber(item.monthly_sold)} {formatNumber(item.seller_count)} + {formatAmazonSeller(item.amazon_is_seller)} + {formatNumber(item.amazon_buybox_share_pct_90d)} {formatNumber(item.sales_rank)} {formatCurrency(item.current_price)} {item.product_name || "-"} @@ -707,10 +789,18 @@ function ProductList({ verdict, onBack }: { verdict: VerdictFilter; onBack: () = {formatCurrency(item.avg_price_90d)} {formatNumber(item.confidence)} {item.reasoning || "-"} + + + )) ) : ( - No products found + No products found )} diff --git a/src/writer.ts b/src/writer.ts index 667f072..88d6416 100644 --- a/src/writer.ts +++ b/src/writer.ts @@ -30,6 +30,9 @@ function buildRow(r: AnalysisResult) { "Sales Rank": rank ?? "", "Rank Avg 90d": r.product.keepa?.salesRankAvg90 ?? "", Sellers: r.product.keepa?.sellerCount ?? "", + "Amazon Is Seller": r.product.keepa?.amazonIsSeller ?? null, + "Amazon Buy Box Share 90d %": + r.product.keepa?.amazonBuyboxSharePct90d ?? "", "Monthly Sold": r.product.keepa?.monthlySold ?? "", "Rank Drops 30d": r.product.keepa?.salesRankDrops30 ?? "", "Rank Drops 90d": r.product.keepa?.salesRankDrops90 ?? "", @@ -106,14 +109,15 @@ export function writeResultsToDb( `INSERT INTO results ( run_id, asin, product_name, brand, category, unit_cost, current_price, avg_price_90d, avg_price_90d_sheet, selling_price_sheet, sales_rank, rank_avg_90d, - sellers, monthly_sold, rank_drops_30d, rank_drops_90d, fba_net_sheet, + sellers, amazon_is_seller, amazon_buybox_share_pct_90d, + monthly_sold, rank_drops_30d, rank_drops_90d, fba_net_sheet, gross_profit_dollar, gross_profit_pct, net_profit_sheet, roi_sheet, moq, moq_cost, qty_available, supplier, source_url, asin_link, promo_coupon_code, notes, lead_date, fba_fee, fbm_fee, referral_percent, can_sell, sellability_status, sellability_reason, verdict, confidence, reasoning, fetched_at ) VALUES ( ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, - ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ? + ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ? )`, ); @@ -134,6 +138,12 @@ export function writeResultsToDb( row["Sales Rank"] ?? null, row["Rank Avg 90d"] ?? null, row.Sellers ?? null, + row["Amazon Is Seller"] == null + ? null + : row["Amazon Is Seller"] + ? 1 + : 0, + row["Amazon Buy Box Share 90d %"] ?? null, row["Monthly Sold"] ?? null, row["Rank Drops 30d"] ?? null, row["Rank Drops 90d"] ?? null,