Merge branch 'az-sell'

This commit is contained in:
Victor Noguera
2026-04-14 18:26:30 -04:00
9 changed files with 1085 additions and 55 deletions

View File

@@ -4,4 +4,5 @@ id,name
229534,Software
283155,Books
16310101,Grocery Gourmet Food
599858,Magazine Subscriptions
599858,Magazine Subscriptions
5174,CDs & Vinyl
1 id name
4 229534 Software
5 283155 Books
6 16310101 Grocery Gourmet Food
7 599858 Magazine Subscriptions
8 5174 CDs & Vinyl

View File

@@ -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<CategoryRunSummary, "topAsinsChecked" | "availableAsins" | "fba" | "fbm" | "skip" | "status" | "error">,
summary: Pick<
CategoryRunSummary,
| "topAsinsChecked"
| "availableAsins"
| "fba"
| "fbm"
| "skip"
| "status"
| "error"
>,
): Promise<void> {
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<string, any>): 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<string, any>): 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<string, any>): KeepaData {
};
}
function resolveAmazonIsSeller(
product: Record<string, any>,
stats: Record<string, any> | 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<string, any>,
stats: Record<string, any> | 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<string>,
): 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<Map<string, { keepa: KeepaData; title: string }>> {
@@ -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<void> {
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);

View File

@@ -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);`,

View File

@@ -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<string, any>): 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<string, any>): 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<string, any>): KeepaData {
};
}
function resolveAmazonIsSeller(
product: Record<string, any>,
stats: Record<string, any> | 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<string, any>,
stats: Record<string, any> | 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<string>,
): 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;

View File

@@ -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<string>, 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<string>,
fallback: string,
): string {
if (!sortParam) return fallback;
const clauses = sortParam
.split(",")
@@ -85,7 +104,11 @@ function parseSort(sortParam: string | null, allowed: Set<string>, fallback: str
return clauses.length > 0 ? clauses.join(", ") : fallback;
}
function parseResultSort(sortParam: string | null, allowed: Set<string>, fallback: string): string {
function parseResultSort(
sortParam: string | null,
allowed: Set<string>,
fallback: string,
): string {
if (!sortParam) return fallback;
const clauses = sortParam
.split(",")
@@ -96,7 +119,13 @@ function parseResultSort(sortParam: string | null, allowed: Set<string>, 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<string>, 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<string | number> = [];
@@ -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);
}

View File

@@ -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<string, any>): 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<string, any>): 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<string, any>): KeepaData {
};
}
function resolveAmazonIsSeller(
product: Record<string, any>,
stats: Record<string, any> | 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<string, any>,
stats: Record<string, any> | 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<string>,
): 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<Map<string, { keepa: KeepaData; title: string }>> {
@@ -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 : [];

View File

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

View File

@@ -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<SortState>({ field: "monthly_sold", direction: "DESC" });
const [refreshTick, setRefreshTick] = useState(0);
const [reanalyzing, setReanalyzing] = useState<Record<string, boolean>>({});
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 (
<div className="page">
<button className="back" onClick={onBack}>Back</button>
@@ -565,6 +598,8 @@ function RunDetails({
<th><button onClick={() => setSort(nextSort(sort, "verdict"))}>Verdict</button></th>
<th><button onClick={() => setSort(nextSort(sort, "monthly_sold"))}>Monthly Sold</button></th>
<th><button onClick={() => setSort(nextSort(sort, "seller_count"))}>Sellers</button></th>
<th><button onClick={() => setSort(nextSort(sort, "amazon_is_seller"))}>Amazon Seller</button></th>
<th><button onClick={() => setSort(nextSort(sort, "amazon_buybox_share_pct_90d"))}>Amazon Buy Box 90d %</button></th>
<th><button onClick={() => setSort(nextSort(sort, "sales_rank"))}>Sales Rank</button></th>
<th><button onClick={() => setSort(nextSort(sort, "current_price"))}>Current Price</button></th>
<th><button onClick={() => setSort(nextSort(sort, "product_name"))}>Product</button></th>
@@ -573,11 +608,12 @@ function RunDetails({
<th><button onClick={() => setSort(nextSort(sort, "avg_price_90d"))}>Avg 90d</button></th>
<th><button onClick={() => setSort(nextSort(sort, "confidence"))}>Confidence</button></th>
<th className="reason-col"><button onClick={() => setSort(nextSort(sort, "reasoning"))}>Reasoning</button></th>
<th>Action</th>
</tr>
</thead>
<tbody>
{loading ? (
<tr><td colSpan={12}>Loading...</td></tr>
<tr><td colSpan={15}>Loading...</td></tr>
) : results?.items.length ? (
results.items.map((item) => (
<tr key={`${item.asin}-${item.fetched_at}`}>
@@ -585,6 +621,8 @@ function RunDetails({
<td><span className={verdictBadgeClass(item.verdict)}>{item.verdict}</span></td>
<td>{formatNumber(item.monthly_sold)}</td>
<td>{formatNumber(item.seller_count)}</td>
<td>{formatAmazonSeller(item.amazon_is_seller)}</td>
<td>{formatNumber(item.amazon_buybox_share_pct_90d)}</td>
<td>{formatNumber(item.sales_rank)}</td>
<td>{formatCurrency(item.current_price)}</td>
<td title={item.reasoning || undefined}>{item.product_name || "-"}</td>
@@ -593,10 +631,18 @@ function RunDetails({
<td>{formatCurrency(item.avg_price_90d)}</td>
<td>{formatNumber(item.confidence)}</td>
<td className="reason-col" title={item.reasoning || undefined}>{item.reasoning || "-"}</td>
<td>
<button
onClick={() => reanalyzeAsin(item.asin)}
disabled={Boolean(reanalyzing[item.asin])}
>
{reanalyzing[item.asin] ? "Re-analyzing..." : "Re-analyze"}
</button>
</td>
</tr>
))
) : (
<tr><td colSpan={12}>No results found</td></tr>
<tr><td colSpan={15}>No results found</td></tr>
)}
</tbody>
</table>
@@ -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<SortState>({ field: "monthly_sold", direction: "DESC" });
const [reanalyzing, setReanalyzing] = useState<Record<string, boolean>>({});
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 (
<div className="page">
<button className="back" onClick={onBack}>Back</button>
@@ -679,6 +756,8 @@ function ProductList({ verdict, onBack }: { verdict: VerdictFilter; onBack: () =
<th><button onClick={() => setSort(nextSort(sort, "verdict"))}>Verdict</button></th>
<th><button onClick={() => setSort(nextSort(sort, "monthly_sold"))}>Monthly Sold</button></th>
<th><button onClick={() => setSort(nextSort(sort, "seller_count"))}>Sellers</button></th>
<th><button onClick={() => setSort(nextSort(sort, "amazon_is_seller"))}>Amazon Seller</button></th>
<th><button onClick={() => setSort(nextSort(sort, "amazon_buybox_share_pct_90d"))}>Amazon Buy Box 90d %</button></th>
<th><button onClick={() => setSort(nextSort(sort, "sales_rank"))}>Sales Rank</button></th>
<th><button onClick={() => setSort(nextSort(sort, "current_price"))}>Current Price</button></th>
<th className="product-col"><button onClick={() => setSort(nextSort(sort, "product_name"))}>Product</button></th>
@@ -687,11 +766,12 @@ function ProductList({ verdict, onBack }: { verdict: VerdictFilter; onBack: () =
<th><button onClick={() => setSort(nextSort(sort, "avg_price_90d"))}>Avg 90d</button></th>
<th><button onClick={() => setSort(nextSort(sort, "confidence"))}>Confidence</button></th>
<th className="reason-col"><button onClick={() => setSort(nextSort(sort, "reasoning"))}>Reasoning</button></th>
<th>Action</th>
</tr>
</thead>
<tbody>
{loading ? (
<tr><td colSpan={12}>Loading...</td></tr>
<tr><td colSpan={15}>Loading...</td></tr>
) : items?.items.length ? (
items.items.map((item) => (
<tr key={`${item.processType}-${item.runId}-${item.asin}-${item.fetched_at}`}>
@@ -699,6 +779,8 @@ function ProductList({ verdict, onBack }: { verdict: VerdictFilter; onBack: () =
<td><span className={verdictBadgeClass(item.verdict)}>{item.verdict}</span></td>
<td>{formatNumber(item.monthly_sold)}</td>
<td>{formatNumber(item.seller_count)}</td>
<td>{formatAmazonSeller(item.amazon_is_seller)}</td>
<td>{formatNumber(item.amazon_buybox_share_pct_90d)}</td>
<td>{formatNumber(item.sales_rank)}</td>
<td>{formatCurrency(item.current_price)}</td>
<td className="product-col" title={item.reasoning || undefined}>{item.product_name || "-"}</td>
@@ -707,10 +789,18 @@ function ProductList({ verdict, onBack }: { verdict: VerdictFilter; onBack: () =
<td>{formatCurrency(item.avg_price_90d)}</td>
<td>{formatNumber(item.confidence)}</td>
<td className="reason-col" title={item.reasoning || undefined}>{item.reasoning || "-"}</td>
<td>
<button
onClick={() => reanalyzeAsin(item)}
disabled={Boolean(reanalyzing[`${item.processType}:${item.runId}:${item.asin}`])}
>
{reanalyzing[`${item.processType}:${item.runId}:${item.asin}`] ? "Re-analyzing..." : "Re-analyze"}
</button>
</td>
</tr>
))
) : (
<tr><td colSpan={12}>No products found</td></tr>
<tr><td colSpan={15}>No products found</td></tr>
)}
</tbody>
</table>

View File

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