Merge branch 'az-sell'
This commit is contained in:
@@ -5,3 +5,4 @@ id,name
|
||||
283155,Books
|
||||
16310101,Grocery Gourmet Food
|
||||
599858,Magazine Subscriptions
|
||||
5174,CDs & Vinyl
|
||||
|
@@ -14,7 +14,6 @@ import type {
|
||||
SpApiData,
|
||||
} from "./types.ts";
|
||||
|
||||
|
||||
type CategoryInfo = {
|
||||
id: number;
|
||||
label: string;
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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);`,
|
||||
|
||||
119
src/keepa.ts
119
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<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;
|
||||
|
||||
580
src/server.ts
580
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<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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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 : [];
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user