feat: add Amazon seller and buy box share metrics to product analysis
- Introduced `amazonIsSeller` and `amazonBuyboxSharePct90d` fields in KeepaData type. - Updated database schema and queries to store Amazon seller status and buy box share percentage. - Enhanced product analysis results with new metrics from Keepa API. - Modified frontend components to display Amazon seller status and buy box share percentage. - Implemented reanalysis functionality for products to refresh Amazon-related metrics.
This commit is contained in:
@@ -4,4 +4,5 @@ id,name
|
|||||||
229534,Software
|
229534,Software
|
||||||
283155,Books
|
283155,Books
|
||||||
16310101,Grocery Gourmet Food
|
16310101,Grocery Gourmet Food
|
||||||
599858,Magazine Subscriptions
|
599858,Magazine Subscriptions
|
||||||
|
5174,CDs & Vinyl
|
||||||
|
@@ -13,7 +13,6 @@ import type {
|
|||||||
SellabilityInfo,
|
SellabilityInfo,
|
||||||
SpApiData,
|
SpApiData,
|
||||||
} from "./types.ts";
|
} from "./types.ts";
|
||||||
|
|
||||||
|
|
||||||
type CategoryInfo = {
|
type CategoryInfo = {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -45,6 +44,8 @@ type CategoryRunSummary = {
|
|||||||
|
|
||||||
const KEEPA_BASE = "https://api.keepa.com";
|
const KEEPA_BASE = "https://api.keepa.com";
|
||||||
const DOMAIN_US = 1;
|
const DOMAIN_US = 1;
|
||||||
|
const AMAZON_US_SELLER_ID = "ATVPDKIKX0DER";
|
||||||
|
const KEEPA_MINUTES_OFFSET = 21_564_000;
|
||||||
const DEFAULT_CATEGORY_LIMIT = 32;
|
const DEFAULT_CATEGORY_LIMIT = 32;
|
||||||
const DEFAULT_PER_CATEGORY_TOP = 100;
|
const DEFAULT_PER_CATEGORY_TOP = 100;
|
||||||
const SELLABILITY_BATCH_SIZE = 60;
|
const SELLABILITY_BATCH_SIZE = 60;
|
||||||
@@ -162,7 +163,16 @@ export async function insertCategoryRunSummary(
|
|||||||
export async function updateCategoryRunSummary(
|
export async function updateCategoryRunSummary(
|
||||||
db: Database,
|
db: Database,
|
||||||
runId: number,
|
runId: number,
|
||||||
summary: Pick<CategoryRunSummary, "topAsinsChecked" | "availableAsins" | "fba" | "fbm" | "skip" | "status" | "error">,
|
summary: Pick<
|
||||||
|
CategoryRunSummary,
|
||||||
|
| "topAsinsChecked"
|
||||||
|
| "availableAsins"
|
||||||
|
| "fba"
|
||||||
|
| "fbm"
|
||||||
|
| "skip"
|
||||||
|
| "status"
|
||||||
|
| "error"
|
||||||
|
>,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
db.run(
|
db.run(
|
||||||
`
|
`
|
||||||
@@ -204,14 +214,15 @@ export async function insertProductAnalysisResults(
|
|||||||
asin, run_id, name, brand, category, unit_cost,
|
asin, run_id, name, brand, category, unit_cost,
|
||||||
current_price, avg_price_90d, avg_price_90d_sheet,
|
current_price, avg_price_90d, avg_price_90d_sheet,
|
||||||
selling_price_sheet, sales_rank, sales_rank_avg_90d,
|
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,
|
fba_fee, fbm_fee, referral_percent, can_sell,
|
||||||
sellability_status, sellability_reason,
|
sellability_status, sellability_reason,
|
||||||
verdict, confidence, reasoning, fetched_at
|
verdict, confidence, reasoning, fetched_at
|
||||||
) VALUES (
|
) VALUES (
|
||||||
?, ?, ?, ?, ?, ?, ?, ?, ?, ?,
|
?, ?, ?, ?, ?, ?, ?, ?, ?, ?,
|
||||||
?, ?, ?, ?, ?, ?, ?, ?, ?, ?,
|
?, ?, ?, ?, ?, ?, ?, ?, ?, ?,
|
||||||
?, ?, ?, ?, ?, ?
|
?, ?, ?, ?, ?, ?, ?, ?
|
||||||
)
|
)
|
||||||
ON CONFLICT(asin) DO UPDATE SET
|
ON CONFLICT(asin) DO UPDATE SET
|
||||||
run_id = excluded.run_id,
|
run_id = excluded.run_id,
|
||||||
@@ -226,6 +237,8 @@ export async function insertProductAnalysisResults(
|
|||||||
sales_rank = excluded.sales_rank,
|
sales_rank = excluded.sales_rank,
|
||||||
sales_rank_avg_90d = excluded.sales_rank_avg_90d,
|
sales_rank_avg_90d = excluded.sales_rank_avg_90d,
|
||||||
seller_count = excluded.seller_count,
|
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,
|
monthly_sold = excluded.monthly_sold,
|
||||||
rank_drops_30d = excluded.rank_drops_30d,
|
rank_drops_30d = excluded.rank_drops_30d,
|
||||||
rank_drops_90d = excluded.rank_drops_90d,
|
rank_drops_90d = excluded.rank_drops_90d,
|
||||||
@@ -265,6 +278,12 @@ export async function insertProductAnalysisResults(
|
|||||||
rank ?? null,
|
rank ?? null,
|
||||||
r.product.keepa?.salesRankAvg90 ?? null,
|
r.product.keepa?.salesRankAvg90 ?? null,
|
||||||
r.product.keepa?.sellerCount ?? 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?.monthlySold ?? null,
|
||||||
r.product.keepa?.salesRankDrops30 ?? null,
|
r.product.keepa?.salesRankDrops30 ?? null,
|
||||||
r.product.keepa?.salesRankDrops90 ?? null,
|
r.product.keepa?.salesRankDrops90 ?? null,
|
||||||
@@ -776,6 +795,14 @@ function parseKeepaProduct(product: Record<string, any>): KeepaData {
|
|||||||
const monthlySold =
|
const monthlySold =
|
||||||
pickKeepaNumber(product.monthlySold, stats?.monthlySold) ??
|
pickKeepaNumber(product.monthlySold, stats?.monthlySold) ??
|
||||||
salesRankDrops30;
|
salesRankDrops30;
|
||||||
|
const amazonIsSeller = resolveAmazonIsSeller(product, stats, csv);
|
||||||
|
const amazonBuyboxSharePct90d =
|
||||||
|
extractAmazonBuyboxSharePct90d(product, stats) ??
|
||||||
|
computeAmazonBuyBoxSharePctFromHistory(
|
||||||
|
product.buyBoxSellerIdHistory,
|
||||||
|
90,
|
||||||
|
new Set([AMAZON_US_SELLER_ID]),
|
||||||
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
currentPrice: extractCurrentPrice(csv),
|
currentPrice: extractCurrentPrice(csv),
|
||||||
@@ -787,6 +814,8 @@ function parseKeepaProduct(product: Record<string, any>): KeepaData {
|
|||||||
salesRankDrops30,
|
salesRankDrops30,
|
||||||
salesRankDrops90,
|
salesRankDrops90,
|
||||||
sellerCount: stats?.current?.[11] ?? null,
|
sellerCount: stats?.current?.[11] ?? null,
|
||||||
|
amazonIsSeller,
|
||||||
|
amazonBuyboxSharePct90d,
|
||||||
buyBoxSeller: product.buyBoxSellerId ?? null,
|
buyBoxSeller: product.buyBoxSellerId ?? null,
|
||||||
buyBoxPrice: stats?.current?.[18] != null ? stats.current[18] / 100 : null,
|
buyBoxPrice: stats?.current?.[18] != null ? stats.current[18] / 100 : null,
|
||||||
monthlySold,
|
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(
|
async function fetchKeepaEnrichmentMap(
|
||||||
asins: string[],
|
asins: string[],
|
||||||
): Promise<Map<string, { keepa: KeepaData; title: 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 chunk = asins.slice(i, i + KEEPA_PRODUCT_CHUNK_SIZE);
|
||||||
const asinParam = encodeURIComponent(chunk.join(","));
|
const asinParam = encodeURIComponent(chunk.join(","));
|
||||||
const data = await keepaGetJson(
|
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 : [];
|
const products = Array.isArray(data?.products) ? data.products : [];
|
||||||
@@ -911,7 +1042,10 @@ export async function processCategory(
|
|||||||
|
|
||||||
const uniqueTopAsins = Array.from(new Set(topAsins));
|
const uniqueTopAsins = Array.from(new Set(topAsins));
|
||||||
if (uniqueTopAsins.length !== topAsins.length) {
|
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}`);
|
log("info", ` Top ASINs fetched: ${uniqueTopAsins.length}`);
|
||||||
@@ -922,7 +1056,10 @@ export async function processCategory(
|
|||||||
return info?.canSell === true && info.sellabilityStatus === "available";
|
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) {
|
if (availableAsins.length === 0) {
|
||||||
await updateCategoryRunSummary(db, runId, {
|
await updateCategoryRunSummary(db, runId, {
|
||||||
topAsinsChecked: uniqueTopAsins.length,
|
topAsinsChecked: uniqueTopAsins.length,
|
||||||
@@ -1054,7 +1191,8 @@ export async function main(): Promise<void> {
|
|||||||
assertSpApiPrerequisites();
|
assertSpApiPrerequisites();
|
||||||
|
|
||||||
mkdirSync(args.outputDir, { recursive: true });
|
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);
|
initDb(DB_PATH);
|
||||||
const db = getDb(DB_PATH);
|
const db = getDb(DB_PATH);
|
||||||
|
|
||||||
|
|||||||
@@ -36,6 +36,8 @@ function createProductAnalysisResultsTable(database: Database): void {
|
|||||||
sales_rank INTEGER,
|
sales_rank INTEGER,
|
||||||
sales_rank_avg_90d INTEGER,
|
sales_rank_avg_90d INTEGER,
|
||||||
seller_count INTEGER,
|
seller_count INTEGER,
|
||||||
|
amazon_is_seller INTEGER,
|
||||||
|
amazon_buybox_share_pct_90d REAL,
|
||||||
monthly_sold INTEGER,
|
monthly_sold INTEGER,
|
||||||
rank_drops_30d INTEGER,
|
rank_drops_30d INTEGER,
|
||||||
rank_drops_90d INTEGER,
|
rank_drops_90d INTEGER,
|
||||||
@@ -92,7 +94,9 @@ function ensureProductAnalysisResultsTable(database: Database): void {
|
|||||||
asin, run_id, name, brand, category, unit_cost,
|
asin, run_id, name, brand, category, unit_cost,
|
||||||
current_price, avg_price_90d, avg_price_90d_sheet,
|
current_price, avg_price_90d, avg_price_90d_sheet,
|
||||||
selling_price_sheet, sales_rank, sales_rank_avg_90d,
|
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,
|
fba_fee, fbm_fee, referral_percent, can_sell,
|
||||||
sellability_status, sellability_reason,
|
sellability_status, sellability_reason,
|
||||||
verdict, confidence, reasoning, fetched_at,
|
verdict, confidence, reasoning, fetched_at,
|
||||||
@@ -106,7 +110,8 @@ function ensureProductAnalysisResultsTable(database: Database): void {
|
|||||||
asin, run_id, name, brand, category, unit_cost,
|
asin, run_id, name, brand, category, unit_cost,
|
||||||
current_price, avg_price_90d, avg_price_90d_sheet,
|
current_price, avg_price_90d, avg_price_90d_sheet,
|
||||||
selling_price_sheet, sales_rank, sales_rank_avg_90d,
|
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,
|
fba_fee, fbm_fee, referral_percent, can_sell,
|
||||||
sellability_status, sellability_reason,
|
sellability_status, sellability_reason,
|
||||||
verdict, confidence, reasoning, fetched_at
|
verdict, confidence, reasoning, fetched_at
|
||||||
@@ -115,7 +120,8 @@ function ensureProductAnalysisResultsTable(database: Database): void {
|
|||||||
asin, run_id, name, brand, category, unit_cost,
|
asin, run_id, name, brand, category, unit_cost,
|
||||||
current_price, avg_price_90d, avg_price_90d_sheet,
|
current_price, avg_price_90d, avg_price_90d_sheet,
|
||||||
selling_price_sheet, sales_rank, sales_rank_avg_90d,
|
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,
|
fba_fee, fbm_fee, referral_percent, can_sell,
|
||||||
sellability_status, sellability_reason,
|
sellability_status, sellability_reason,
|
||||||
verdict, confidence, reasoning, fetched_at
|
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 {
|
function ensureResultsTableColumns(database: Database): void {
|
||||||
const tableInfo = database
|
const tableInfo = database
|
||||||
.query("PRAGMA table_info(results)")
|
.query("PRAGMA table_info(results)")
|
||||||
@@ -151,6 +181,8 @@ function ensureResultsTableColumns(database: Database): void {
|
|||||||
{ name: "promo_coupon_code", type: "TEXT" },
|
{ name: "promo_coupon_code", type: "TEXT" },
|
||||||
{ name: "notes", type: "TEXT" },
|
{ name: "notes", type: "TEXT" },
|
||||||
{ name: "lead_date", 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) {
|
for (const column of requiredColumns) {
|
||||||
@@ -192,6 +224,8 @@ export function initDb(dbPath: string): void {
|
|||||||
sales_rank INTEGER,
|
sales_rank INTEGER,
|
||||||
rank_avg_90d INTEGER,
|
rank_avg_90d INTEGER,
|
||||||
sellers INTEGER,
|
sellers INTEGER,
|
||||||
|
amazon_is_seller INTEGER,
|
||||||
|
amazon_buybox_share_pct_90d REAL,
|
||||||
monthly_sold INTEGER,
|
monthly_sold INTEGER,
|
||||||
rank_drops_30d INTEGER,
|
rank_drops_30d INTEGER,
|
||||||
rank_drops_90d INTEGER,
|
rank_drops_90d INTEGER,
|
||||||
@@ -239,6 +273,7 @@ export function initDb(dbPath: string): void {
|
|||||||
);
|
);
|
||||||
`);
|
`);
|
||||||
ensureProductAnalysisResultsTable(database);
|
ensureProductAnalysisResultsTable(database);
|
||||||
|
ensureProductAnalysisResultsColumns(database);
|
||||||
|
|
||||||
database.run(
|
database.run(
|
||||||
`CREATE INDEX IF NOT EXISTS idx_runs_timestamp ON runs(timestamp DESC);`,
|
`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 KEEPA_BASE = "https://api.keepa.com";
|
||||||
const MAX_ASINS_PER_REQUEST = 100;
|
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.
|
// Token-based rate limiting: Keepa Pro = 1 token/min regeneration.
|
||||||
// Each product request costs 1 token regardless of ASIN count (up to 100).
|
// Each product request costs 1 token regardless of ASIN count (up to 100).
|
||||||
@@ -44,7 +46,7 @@ export async function fetchKeepaDataBatch(
|
|||||||
await waitForToken();
|
await waitForToken();
|
||||||
|
|
||||||
const asinParam = chunk.join(",");
|
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(
|
console.log(
|
||||||
`Keepa: fetching ${chunk.length} ASINs (tokens left: ${tokensLeft})...`,
|
`Keepa: fetching ${chunk.length} ASINs (tokens left: ${tokensLeft})...`,
|
||||||
@@ -97,6 +99,14 @@ function parseKeepaProduct(product: Record<string, any>): KeepaData {
|
|||||||
const monthlySold =
|
const monthlySold =
|
||||||
pickKeepaNumber(product.monthlySold, stats?.monthlySold) ??
|
pickKeepaNumber(product.monthlySold, stats?.monthlySold) ??
|
||||||
salesRankDrops30;
|
salesRankDrops30;
|
||||||
|
const amazonIsSeller = resolveAmazonIsSeller(product, stats, csv);
|
||||||
|
const amazonBuyboxSharePct90d =
|
||||||
|
extractAmazonBuyboxSharePct90d(product, stats) ??
|
||||||
|
computeAmazonBuyBoxSharePctFromHistory(
|
||||||
|
product.buyBoxSellerIdHistory,
|
||||||
|
90,
|
||||||
|
new Set([AMAZON_US_SELLER_ID]),
|
||||||
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
currentPrice: extractCurrentPrice(csv),
|
currentPrice: extractCurrentPrice(csv),
|
||||||
@@ -108,6 +118,8 @@ function parseKeepaProduct(product: Record<string, any>): KeepaData {
|
|||||||
salesRankDrops30,
|
salesRankDrops30,
|
||||||
salesRankDrops90,
|
salesRankDrops90,
|
||||||
sellerCount: stats?.current?.[11] ?? null,
|
sellerCount: stats?.current?.[11] ?? null,
|
||||||
|
amazonIsSeller,
|
||||||
|
amazonBuyboxSharePct90d,
|
||||||
buyBoxSeller: product.buyBoxSellerId ?? null,
|
buyBoxSeller: product.buyBoxSellerId ?? null,
|
||||||
buyBoxPrice: stats?.current?.[18] != null ? stats.current[18] / 100 : null,
|
buyBoxPrice: stats?.current?.[18] != null ? stats.current[18] / 100 : null,
|
||||||
monthlySold,
|
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 {
|
function pickKeepaNumber(...values: unknown[]): number | null {
|
||||||
for (const value of values) {
|
for (const value of values) {
|
||||||
if (typeof value !== "number" || !Number.isFinite(value)) continue;
|
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 index from "./web/index.html";
|
||||||
import { getDb, initDb } from "./database.ts";
|
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";
|
type ProcessType = "lead_analysis" | "category_analysis";
|
||||||
|
|
||||||
@@ -29,6 +33,8 @@ type ProductListRecord = {
|
|||||||
sellability_status: string | null;
|
sellability_status: string | null;
|
||||||
monthly_sold: number | null;
|
monthly_sold: number | null;
|
||||||
seller_count: number | null;
|
seller_count: number | null;
|
||||||
|
amazon_is_seller: number | null;
|
||||||
|
amazon_buybox_share_pct_90d: number | null;
|
||||||
sales_rank: number | null;
|
sales_rank: number | null;
|
||||||
current_price: number | null;
|
current_price: number | null;
|
||||||
avg_price_90d: number | null;
|
avg_price_90d: number | null;
|
||||||
@@ -39,6 +45,7 @@ type ProductListRecord = {
|
|||||||
const DB_PATH = process.env.RESULTS_DB_PATH || "./results.db";
|
const DB_PATH = process.env.RESULTS_DB_PATH || "./results.db";
|
||||||
const DEFAULT_PAGE_SIZE = 25;
|
const DEFAULT_PAGE_SIZE = 25;
|
||||||
const MAX_PAGE_SIZE = 200;
|
const MAX_PAGE_SIZE = 200;
|
||||||
|
const ASIN_PATTERN = /^[A-Z0-9]{10}$/;
|
||||||
|
|
||||||
initDb(DB_PATH);
|
initDb(DB_PATH);
|
||||||
const db = getDb(DB_PATH);
|
const db = getDb(DB_PATH);
|
||||||
@@ -67,7 +74,19 @@ function parseIntParam(value: string | null, fallback: number): number {
|
|||||||
return parsed;
|
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;
|
if (!sortParam) return fallback;
|
||||||
const clauses = sortParam
|
const clauses = sortParam
|
||||||
.split(",")
|
.split(",")
|
||||||
@@ -85,7 +104,11 @@ function parseSort(sortParam: string | null, allowed: Set<string>, fallback: str
|
|||||||
return clauses.length > 0 ? clauses.join(", ") : fallback;
|
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;
|
if (!sortParam) return fallback;
|
||||||
const clauses = sortParam
|
const clauses = sortParam
|
||||||
.split(",")
|
.split(",")
|
||||||
@@ -96,7 +119,13 @@ function parseResultSort(sortParam: string | null, allowed: Set<string>, fallbac
|
|||||||
const field = fieldRaw?.trim();
|
const field = fieldRaw?.trim();
|
||||||
const dir = dirRaw?.trim().toUpperCase() === "DESC" ? "DESC" : "ASC";
|
const dir = dirRaw?.trim().toUpperCase() === "DESC" ? "DESC" : "ASC";
|
||||||
if (!field || !allowed.has(field)) return null;
|
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}`;
|
return `${field} ${dir}`;
|
||||||
})
|
})
|
||||||
.filter((value): value is string => value !== null);
|
.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 {
|
function escapeCsvValue(value: unknown): string {
|
||||||
if (value === null || value === undefined) return "";
|
if (value === null || value === undefined) return "";
|
||||||
const text = String(value);
|
const text = String(value);
|
||||||
const escaped = text.replaceAll("\"", "\"\"");
|
const escaped = text.replaceAll('"', '""');
|
||||||
return /[",\n]/.test(escaped) ? `"${escaped}"` : escaped;
|
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 q = filters.get("q")?.trim() || "";
|
||||||
const verdict = filters.get("verdict")?.trim();
|
const verdict = filters.get("verdict")?.trim();
|
||||||
const sellabilityStatus = filters.get("sellabilityStatus")?.trim();
|
const sellabilityStatus = filters.get("sellabilityStatus")?.trim();
|
||||||
@@ -144,10 +177,14 @@ function parseResultFilters(processType: ProcessType, runId: number, filters: UR
|
|||||||
if (q) {
|
if (q) {
|
||||||
const wildcard = `%${q}%`;
|
const wildcard = `%${q}%`;
|
||||||
if (processType === "lead_analysis") {
|
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);
|
params.push(wildcard, wildcard, wildcard, wildcard, wildcard);
|
||||||
} else {
|
} 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);
|
params.push(wildcard, wildcard, wildcard, wildcard, wildcard);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -165,7 +202,10 @@ function getRuns(filters: URLSearchParams) {
|
|||||||
const startDate = filters.get("startDate")?.trim();
|
const startDate = filters.get("startDate")?.trim();
|
||||||
const endDate = filters.get("endDate")?.trim();
|
const endDate = filters.get("endDate")?.trim();
|
||||||
const page = parseIntParam(filters.get("page"), 1);
|
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 offset = (page - 1) * pageSize;
|
||||||
|
|
||||||
const allowedSort = new Set([
|
const allowedSort = new Set([
|
||||||
@@ -178,7 +218,11 @@ function getRuns(filters: URLSearchParams) {
|
|||||||
"runId",
|
"runId",
|
||||||
"jobType",
|
"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 conditions: string[] = [];
|
||||||
const params: Array<string | number> = [];
|
const params: Array<string | number> = [];
|
||||||
@@ -204,12 +248,15 @@ function getRuns(filters: URLSearchParams) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (q) {
|
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}%`;
|
const wildcard = `%${q}%`;
|
||||||
params.push(wildcard, wildcard, wildcard, wildcard);
|
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 = `
|
const baseUnion = `
|
||||||
SELECT
|
SELECT
|
||||||
@@ -246,7 +293,9 @@ function getRuns(filters: URLSearchParams) {
|
|||||||
.get(...params) as { total: number };
|
.get(...params) as { total: number };
|
||||||
|
|
||||||
const items = db
|
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[];
|
.all(...params, pageSize, offset) as RunRecord[];
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -262,7 +311,10 @@ function getProductList(filters: URLSearchParams) {
|
|||||||
const q = filters.get("q")?.trim() || "";
|
const q = filters.get("q")?.trim() || "";
|
||||||
const verdict = filters.get("verdict")?.trim();
|
const verdict = filters.get("verdict")?.trim();
|
||||||
const page = parseIntParam(filters.get("page"), 1);
|
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 offset = (page - 1) * pageSize;
|
||||||
|
|
||||||
const conditions: string[] = [];
|
const conditions: string[] = [];
|
||||||
@@ -275,16 +327,21 @@ function getProductList(filters: URLSearchParams) {
|
|||||||
|
|
||||||
if (q) {
|
if (q) {
|
||||||
const wildcard = `%${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);
|
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([
|
const allowedSort = new Set([
|
||||||
"asin",
|
"asin",
|
||||||
"verdict",
|
"verdict",
|
||||||
"monthly_sold",
|
"monthly_sold",
|
||||||
"seller_count",
|
"seller_count",
|
||||||
|
"amazon_is_seller",
|
||||||
|
"amazon_buybox_share_pct_90d",
|
||||||
"sales_rank",
|
"sales_rank",
|
||||||
"current_price",
|
"current_price",
|
||||||
"product_name",
|
"product_name",
|
||||||
@@ -314,6 +371,8 @@ function getProductList(filters: URLSearchParams) {
|
|||||||
sellability_status,
|
sellability_status,
|
||||||
monthly_sold,
|
monthly_sold,
|
||||||
sellers AS seller_count,
|
sellers AS seller_count,
|
||||||
|
amazon_is_seller,
|
||||||
|
amazon_buybox_share_pct_90d,
|
||||||
sales_rank,
|
sales_rank,
|
||||||
current_price,
|
current_price,
|
||||||
avg_price_90d,
|
avg_price_90d,
|
||||||
@@ -333,6 +392,8 @@ function getProductList(filters: URLSearchParams) {
|
|||||||
sellability_status,
|
sellability_status,
|
||||||
monthly_sold,
|
monthly_sold,
|
||||||
seller_count,
|
seller_count,
|
||||||
|
amazon_is_seller,
|
||||||
|
amazon_buybox_share_pct_90d,
|
||||||
sales_rank,
|
sales_rank,
|
||||||
current_price,
|
current_price,
|
||||||
avg_price_90d,
|
avg_price_90d,
|
||||||
@@ -346,7 +407,9 @@ function getProductList(filters: URLSearchParams) {
|
|||||||
.get(...params) as { total: number };
|
.get(...params) as { total: number };
|
||||||
|
|
||||||
const items = db
|
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[];
|
.all(...params, pageSize, offset) as ProductListRecord[];
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -400,15 +463,30 @@ function getRun(processType: ProcessType, runId: number) {
|
|||||||
return run ?? null;
|
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 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 offset = (page - 1) * pageSize;
|
||||||
|
|
||||||
const tableName = processType === "lead_analysis" ? "results" : "product_analysis_results";
|
const tableName =
|
||||||
const productNameSelect = processType === "lead_analysis" ? "product_name" : "name AS product_name";
|
processType === "lead_analysis" ? "results" : "product_analysis_results";
|
||||||
const sellerCountSelect = processType === "lead_analysis" ? "sellers AS seller_count" : "seller_count";
|
const productNameSelect =
|
||||||
const salesRankAvgSelect = processType === "lead_analysis" ? "rank_avg_90d AS sales_rank_avg_90d" : "sales_rank_avg_90d";
|
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);
|
const { where, params } = parseResultFilters(processType, runId, filters);
|
||||||
|
|
||||||
@@ -421,6 +499,8 @@ function getRunResults(processType: ProcessType, runId: number, filters: URLSear
|
|||||||
"avg_price_90d",
|
"avg_price_90d",
|
||||||
"sales_rank",
|
"sales_rank",
|
||||||
"seller_count",
|
"seller_count",
|
||||||
|
"amazon_is_seller",
|
||||||
|
"amazon_buybox_share_pct_90d",
|
||||||
"monthly_sold",
|
"monthly_sold",
|
||||||
"verdict",
|
"verdict",
|
||||||
"confidence",
|
"confidence",
|
||||||
@@ -453,6 +533,8 @@ function getRunResults(processType: ProcessType, runId: number, filters: URLSear
|
|||||||
sales_rank,
|
sales_rank,
|
||||||
${salesRankAvgSelect},
|
${salesRankAvgSelect},
|
||||||
${sellerCountSelect},
|
${sellerCountSelect},
|
||||||
|
amazon_is_seller,
|
||||||
|
amazon_buybox_share_pct_90d,
|
||||||
monthly_sold,
|
monthly_sold,
|
||||||
rank_drops_30d,
|
rank_drops_30d,
|
||||||
rank_drops_90d,
|
rank_drops_90d,
|
||||||
@@ -484,7 +566,9 @@ function getRunResults(processType: ProcessType, runId: number, filters: URLSear
|
|||||||
|
|
||||||
function deleteRun(processType: ProcessType, runId: number) {
|
function deleteRun(processType: ProcessType, runId: number) {
|
||||||
if (processType === "lead_analysis") {
|
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);
|
const runRows = db.query("DELETE FROM runs WHERE id = ?").run(runId);
|
||||||
return {
|
return {
|
||||||
deletedRun: runRows.changes > 0,
|
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 resultRows = db
|
||||||
const runRows = db.query("DELETE FROM category_analysis_runs WHERE id = ?").run(runId);
|
.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 {
|
return {
|
||||||
deletedRun: runRows.changes > 0,
|
deletedRun: runRows.changes > 0,
|
||||||
deletedResults: resultRows.changes,
|
deletedResults: resultRows.changes,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function exportRunResultsCsv(processType: ProcessType, runId: number, filters: URLSearchParams) {
|
function exportRunResultsCsv(
|
||||||
const tableName = processType === "lead_analysis" ? "results" : "product_analysis_results";
|
processType: ProcessType,
|
||||||
const productNameSelect = processType === "lead_analysis" ? "product_name" : "name AS product_name";
|
runId: number,
|
||||||
const sellerCountSelect = processType === "lead_analysis" ? "sellers AS seller_count" : "seller_count";
|
filters: URLSearchParams,
|
||||||
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);
|
const { where, params } = parseResultFilters(processType, runId, filters);
|
||||||
|
|
||||||
@@ -517,6 +617,8 @@ function exportRunResultsCsv(processType: ProcessType, runId: number, filters: U
|
|||||||
"avg_price_90d",
|
"avg_price_90d",
|
||||||
"sales_rank",
|
"sales_rank",
|
||||||
"seller_count",
|
"seller_count",
|
||||||
|
"amazon_is_seller",
|
||||||
|
"amazon_buybox_share_pct_90d",
|
||||||
"monthly_sold",
|
"monthly_sold",
|
||||||
"verdict",
|
"verdict",
|
||||||
"confidence",
|
"confidence",
|
||||||
@@ -541,6 +643,8 @@ function exportRunResultsCsv(processType: ProcessType, runId: number, filters: U
|
|||||||
avg_price_90d,
|
avg_price_90d,
|
||||||
${salesRankAvgSelect},
|
${salesRankAvgSelect},
|
||||||
${sellerCountSelect},
|
${sellerCountSelect},
|
||||||
|
amazon_is_seller,
|
||||||
|
amazon_buybox_share_pct_90d,
|
||||||
monthly_sold,
|
monthly_sold,
|
||||||
sellability_status,
|
sellability_status,
|
||||||
verdict,
|
verdict,
|
||||||
@@ -564,6 +668,8 @@ function exportRunResultsCsv(processType: ProcessType, runId: number, filters: U
|
|||||||
"avg_price_90d",
|
"avg_price_90d",
|
||||||
"sales_rank_avg_90d",
|
"sales_rank_avg_90d",
|
||||||
"seller_count",
|
"seller_count",
|
||||||
|
"amazon_is_seller",
|
||||||
|
"amazon_buybox_share_pct_90d",
|
||||||
"monthly_sold",
|
"monthly_sold",
|
||||||
"sellability_status",
|
"sellability_status",
|
||||||
"verdict",
|
"verdict",
|
||||||
@@ -580,6 +686,366 @@ function exportRunResultsCsv(processType: ProcessType, runId: number, filters: U
|
|||||||
return lines.join("\n");
|
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({
|
const server = Bun.serve({
|
||||||
port: Number(process.env.PORT || "3000"),
|
port: Number(process.env.PORT || "3000"),
|
||||||
routes: {
|
routes: {
|
||||||
@@ -598,7 +1064,12 @@ const server = Bun.serve({
|
|||||||
const processType = req.params.processType as ProcessType;
|
const processType = req.params.processType as ProcessType;
|
||||||
const runId = Number(req.params.runId);
|
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);
|
return json({ error: "Invalid run identifier" }, 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -624,7 +1095,12 @@ const server = Bun.serve({
|
|||||||
const processType = req.params.processType as ProcessType;
|
const processType = req.params.processType as ProcessType;
|
||||||
const runId = Number(req.params.runId);
|
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);
|
return json({ error: "Invalid run identifier" }, 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -632,11 +1108,49 @@ const server = Bun.serve({
|
|||||||
const payload = getRunResults(processType, runId, url.searchParams);
|
const payload = getRunResults(processType, runId, url.searchParams);
|
||||||
return json(payload);
|
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) => {
|
"/api/runs/:processType/:runId/export.csv": (req) => {
|
||||||
const processType = req.params.processType as ProcessType;
|
const processType = req.params.processType as ProcessType;
|
||||||
const runId = Number(req.params.runId);
|
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);
|
return json({ error: "Invalid run identifier" }, 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -46,6 +46,8 @@ type CategoryRunSummary = {
|
|||||||
|
|
||||||
const KEEPA_BASE = "https://api.keepa.com";
|
const KEEPA_BASE = "https://api.keepa.com";
|
||||||
const DOMAIN_US = 1;
|
const DOMAIN_US = 1;
|
||||||
|
const AMAZON_US_SELLER_ID = "ATVPDKIKX0DER";
|
||||||
|
const KEEPA_MINUTES_OFFSET = 21_564_000;
|
||||||
const DEFAULT_CATEGORY_LIMIT = 32;
|
const DEFAULT_CATEGORY_LIMIT = 32;
|
||||||
const DEFAULT_PER_CATEGORY_TOP = 100;
|
const DEFAULT_PER_CATEGORY_TOP = 100;
|
||||||
const DEFAULT_CATEGORY_CANDIDATE_POOL = 500;
|
const DEFAULT_CATEGORY_CANDIDATE_POOL = 500;
|
||||||
@@ -244,14 +246,15 @@ export async function insertProductAnalysisResults(
|
|||||||
asin, run_id, name, brand, category, unit_cost,
|
asin, run_id, name, brand, category, unit_cost,
|
||||||
current_price, avg_price_90d, avg_price_90d_sheet,
|
current_price, avg_price_90d, avg_price_90d_sheet,
|
||||||
selling_price_sheet, sales_rank, sales_rank_avg_90d,
|
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,
|
fba_fee, fbm_fee, referral_percent, can_sell,
|
||||||
sellability_status, sellability_reason,
|
sellability_status, sellability_reason,
|
||||||
verdict, confidence, reasoning, fetched_at
|
verdict, confidence, reasoning, fetched_at
|
||||||
) VALUES (
|
) VALUES (
|
||||||
?, ?, ?, ?, ?, ?, ?, ?, ?, ?,
|
?, ?, ?, ?, ?, ?, ?, ?, ?, ?,
|
||||||
?, ?, ?, ?, ?, ?, ?, ?, ?, ?,
|
?, ?, ?, ?, ?, ?, ?, ?, ?, ?,
|
||||||
?, ?, ?, ?, ?, ?
|
?, ?, ?, ?, ?, ?, ?, ?
|
||||||
)
|
)
|
||||||
ON CONFLICT(asin) DO UPDATE SET
|
ON CONFLICT(asin) DO UPDATE SET
|
||||||
run_id = excluded.run_id,
|
run_id = excluded.run_id,
|
||||||
@@ -266,6 +269,8 @@ export async function insertProductAnalysisResults(
|
|||||||
sales_rank = excluded.sales_rank,
|
sales_rank = excluded.sales_rank,
|
||||||
sales_rank_avg_90d = excluded.sales_rank_avg_90d,
|
sales_rank_avg_90d = excluded.sales_rank_avg_90d,
|
||||||
seller_count = excluded.seller_count,
|
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,
|
monthly_sold = excluded.monthly_sold,
|
||||||
rank_drops_30d = excluded.rank_drops_30d,
|
rank_drops_30d = excluded.rank_drops_30d,
|
||||||
rank_drops_90d = excluded.rank_drops_90d,
|
rank_drops_90d = excluded.rank_drops_90d,
|
||||||
@@ -305,6 +310,12 @@ export async function insertProductAnalysisResults(
|
|||||||
rank ?? null,
|
rank ?? null,
|
||||||
r.product.keepa?.salesRankAvg90 ?? null,
|
r.product.keepa?.salesRankAvg90 ?? null,
|
||||||
r.product.keepa?.sellerCount ?? 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?.monthlySold ?? null,
|
||||||
r.product.keepa?.salesRankDrops30 ?? null,
|
r.product.keepa?.salesRankDrops30 ?? null,
|
||||||
r.product.keepa?.salesRankDrops90 ?? null,
|
r.product.keepa?.salesRankDrops90 ?? null,
|
||||||
@@ -816,6 +827,14 @@ function parseKeepaProduct(product: Record<string, any>): KeepaData {
|
|||||||
const monthlySold =
|
const monthlySold =
|
||||||
pickKeepaNumber(product.monthlySold, stats?.monthlySold) ??
|
pickKeepaNumber(product.monthlySold, stats?.monthlySold) ??
|
||||||
salesRankDrops30;
|
salesRankDrops30;
|
||||||
|
const amazonIsSeller = resolveAmazonIsSeller(product, stats, csv);
|
||||||
|
const amazonBuyboxSharePct90d =
|
||||||
|
extractAmazonBuyboxSharePct90d(product, stats) ??
|
||||||
|
computeAmazonBuyBoxSharePctFromHistory(
|
||||||
|
product.buyBoxSellerIdHistory,
|
||||||
|
90,
|
||||||
|
new Set([AMAZON_US_SELLER_ID]),
|
||||||
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
currentPrice: extractCurrentPrice(csv),
|
currentPrice: extractCurrentPrice(csv),
|
||||||
@@ -827,6 +846,8 @@ function parseKeepaProduct(product: Record<string, any>): KeepaData {
|
|||||||
salesRankDrops30,
|
salesRankDrops30,
|
||||||
salesRankDrops90,
|
salesRankDrops90,
|
||||||
sellerCount: stats?.current?.[11] ?? null,
|
sellerCount: stats?.current?.[11] ?? null,
|
||||||
|
amazonIsSeller,
|
||||||
|
amazonBuyboxSharePct90d,
|
||||||
buyBoxSeller: product.buyBoxSellerId ?? null,
|
buyBoxSeller: product.buyBoxSellerId ?? null,
|
||||||
buyBoxPrice: stats?.current?.[18] != null ? stats.current[18] / 100 : null,
|
buyBoxPrice: stats?.current?.[18] != null ? stats.current[18] / 100 : null,
|
||||||
monthlySold,
|
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(
|
async function fetchKeepaEnrichmentMap(
|
||||||
asins: string[],
|
asins: string[],
|
||||||
): Promise<Map<string, { keepa: KeepaData; title: 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 chunk = asins.slice(i, i + KEEPA_PRODUCT_CHUNK_SIZE);
|
||||||
const asinParam = encodeURIComponent(chunk.join(","));
|
const asinParam = encodeURIComponent(chunk.join(","));
|
||||||
const data = await keepaGetJson(
|
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 : [];
|
const products = Array.isArray(data?.products) ? data.products : [];
|
||||||
|
|||||||
@@ -36,6 +36,8 @@ export interface KeepaData {
|
|||||||
salesRankDrops30: number | null;
|
salesRankDrops30: number | null;
|
||||||
salesRankDrops90: number | null;
|
salesRankDrops90: number | null;
|
||||||
sellerCount: number | null;
|
sellerCount: number | null;
|
||||||
|
amazonIsSeller: boolean | null;
|
||||||
|
amazonBuyboxSharePct90d: number | null;
|
||||||
buyBoxSeller: string | null;
|
buyBoxSeller: string | null;
|
||||||
buyBoxPrice: number | null;
|
buyBoxPrice: number | null;
|
||||||
monthlySold: number | null;
|
monthlySold: number | null;
|
||||||
|
|||||||
@@ -61,6 +61,8 @@ type ResultItem = {
|
|||||||
seller_count: number | null;
|
seller_count: number | null;
|
||||||
monthly_sold: number | null;
|
monthly_sold: number | null;
|
||||||
verdict: "FBA" | "FBM" | "SKIP";
|
verdict: "FBA" | "FBM" | "SKIP";
|
||||||
|
amazon_is_seller: number | null;
|
||||||
|
amazon_buybox_share_pct_90d: number | null;
|
||||||
confidence: number | null;
|
confidence: number | null;
|
||||||
sellability_status: string | null;
|
sellability_status: string | null;
|
||||||
reasoning: string | null;
|
reasoning: string | null;
|
||||||
@@ -89,6 +91,8 @@ type ProductListItem = {
|
|||||||
sellability_status: string | null;
|
sellability_status: string | null;
|
||||||
monthly_sold: number | null;
|
monthly_sold: number | null;
|
||||||
seller_count: number | null;
|
seller_count: number | null;
|
||||||
|
amazon_is_seller: number | null;
|
||||||
|
amazon_buybox_share_pct_90d: number | null;
|
||||||
sales_rank: number | null;
|
sales_rank: number | null;
|
||||||
current_price: number | null;
|
current_price: number | null;
|
||||||
avg_price_90d: number | null;
|
avg_price_90d: number | null;
|
||||||
@@ -129,6 +133,11 @@ function formatCurrency(value: number | null | undefined): string {
|
|||||||
}).format(value);
|
}).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 {
|
function buildSortValue(sort: SortState): string {
|
||||||
return `${sort.field}:${sort.direction}`;
|
return `${sort.field}:${sort.direction}`;
|
||||||
}
|
}
|
||||||
@@ -426,6 +435,7 @@ function RunDetails({
|
|||||||
const [pageSize, setPageSize] = useState(25);
|
const [pageSize, setPageSize] = useState(25);
|
||||||
const [sort, setSort] = useState<SortState>({ field: "monthly_sold", direction: "DESC" });
|
const [sort, setSort] = useState<SortState>({ field: "monthly_sold", direction: "DESC" });
|
||||||
const [refreshTick, setRefreshTick] = useState(0);
|
const [refreshTick, setRefreshTick] = useState(0);
|
||||||
|
const [reanalyzing, setReanalyzing] = useState<Record<string, boolean>>({});
|
||||||
|
|
||||||
const anomalies = useMemo(() => {
|
const anomalies = useMemo(() => {
|
||||||
if (!results) return [] as ResultItem[];
|
if (!results) return [] as ResultItem[];
|
||||||
@@ -485,6 +495,29 @@ function RunDetails({
|
|||||||
};
|
};
|
||||||
}, [processType, runId]);
|
}, [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 (
|
return (
|
||||||
<div className="page">
|
<div className="page">
|
||||||
<button className="back" onClick={onBack}>Back</button>
|
<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, "verdict"))}>Verdict</button></th>
|
||||||
<th><button onClick={() => setSort(nextSort(sort, "monthly_sold"))}>Monthly Sold</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, "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, "sales_rank"))}>Sales Rank</button></th>
|
||||||
<th><button onClick={() => setSort(nextSort(sort, "current_price"))}>Current Price</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>
|
<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, "avg_price_90d"))}>Avg 90d</button></th>
|
||||||
<th><button onClick={() => setSort(nextSort(sort, "confidence"))}>Confidence</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 className="reason-col"><button onClick={() => setSort(nextSort(sort, "reasoning"))}>Reasoning</button></th>
|
||||||
|
<th>Action</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<tr><td colSpan={12}>Loading...</td></tr>
|
<tr><td colSpan={15}>Loading...</td></tr>
|
||||||
) : results?.items.length ? (
|
) : results?.items.length ? (
|
||||||
results.items.map((item) => (
|
results.items.map((item) => (
|
||||||
<tr key={`${item.asin}-${item.fetched_at}`}>
|
<tr key={`${item.asin}-${item.fetched_at}`}>
|
||||||
@@ -585,6 +621,8 @@ function RunDetails({
|
|||||||
<td><span className={verdictBadgeClass(item.verdict)}>{item.verdict}</span></td>
|
<td><span className={verdictBadgeClass(item.verdict)}>{item.verdict}</span></td>
|
||||||
<td>{formatNumber(item.monthly_sold)}</td>
|
<td>{formatNumber(item.monthly_sold)}</td>
|
||||||
<td>{formatNumber(item.seller_count)}</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>{formatNumber(item.sales_rank)}</td>
|
||||||
<td>{formatCurrency(item.current_price)}</td>
|
<td>{formatCurrency(item.current_price)}</td>
|
||||||
<td title={item.reasoning || undefined}>{item.product_name || "-"}</td>
|
<td title={item.reasoning || undefined}>{item.product_name || "-"}</td>
|
||||||
@@ -593,10 +631,18 @@ function RunDetails({
|
|||||||
<td>{formatCurrency(item.avg_price_90d)}</td>
|
<td>{formatCurrency(item.avg_price_90d)}</td>
|
||||||
<td>{formatNumber(item.confidence)}</td>
|
<td>{formatNumber(item.confidence)}</td>
|
||||||
<td className="reason-col" title={item.reasoning || undefined}>{item.reasoning || "-"}</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>
|
||||||
))
|
))
|
||||||
) : (
|
) : (
|
||||||
<tr><td colSpan={12}>No results found</td></tr>
|
<tr><td colSpan={15}>No results found</td></tr>
|
||||||
)}
|
)}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
@@ -622,6 +668,7 @@ function ProductList({ verdict, onBack }: { verdict: VerdictFilter; onBack: () =
|
|||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
const [pageSize, setPageSize] = useState(25);
|
const [pageSize, setPageSize] = useState(25);
|
||||||
const [sort, setSort] = useState<SortState>({ field: "monthly_sold", direction: "DESC" });
|
const [sort, setSort] = useState<SortState>({ field: "monthly_sold", direction: "DESC" });
|
||||||
|
const [reanalyzing, setReanalyzing] = useState<Record<string, boolean>>({});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setActiveVerdict(verdict);
|
setActiveVerdict(verdict);
|
||||||
@@ -650,6 +697,36 @@ function ProductList({ verdict, onBack }: { verdict: VerdictFilter; onBack: () =
|
|||||||
};
|
};
|
||||||
}, [search, activeVerdict, page, pageSize, sort]);
|
}, [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 (
|
return (
|
||||||
<div className="page">
|
<div className="page">
|
||||||
<button className="back" onClick={onBack}>Back</button>
|
<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, "verdict"))}>Verdict</button></th>
|
||||||
<th><button onClick={() => setSort(nextSort(sort, "monthly_sold"))}>Monthly Sold</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, "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, "sales_rank"))}>Sales Rank</button></th>
|
||||||
<th><button onClick={() => setSort(nextSort(sort, "current_price"))}>Current Price</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>
|
<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, "avg_price_90d"))}>Avg 90d</button></th>
|
||||||
<th><button onClick={() => setSort(nextSort(sort, "confidence"))}>Confidence</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 className="reason-col"><button onClick={() => setSort(nextSort(sort, "reasoning"))}>Reasoning</button></th>
|
||||||
|
<th>Action</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<tr><td colSpan={12}>Loading...</td></tr>
|
<tr><td colSpan={15}>Loading...</td></tr>
|
||||||
) : items?.items.length ? (
|
) : items?.items.length ? (
|
||||||
items.items.map((item) => (
|
items.items.map((item) => (
|
||||||
<tr key={`${item.processType}-${item.runId}-${item.asin}-${item.fetched_at}`}>
|
<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><span className={verdictBadgeClass(item.verdict)}>{item.verdict}</span></td>
|
||||||
<td>{formatNumber(item.monthly_sold)}</td>
|
<td>{formatNumber(item.monthly_sold)}</td>
|
||||||
<td>{formatNumber(item.seller_count)}</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>{formatNumber(item.sales_rank)}</td>
|
||||||
<td>{formatCurrency(item.current_price)}</td>
|
<td>{formatCurrency(item.current_price)}</td>
|
||||||
<td className="product-col" title={item.reasoning || undefined}>{item.product_name || "-"}</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>{formatCurrency(item.avg_price_90d)}</td>
|
||||||
<td>{formatNumber(item.confidence)}</td>
|
<td>{formatNumber(item.confidence)}</td>
|
||||||
<td className="reason-col" title={item.reasoning || undefined}>{item.reasoning || "-"}</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>
|
||||||
))
|
))
|
||||||
) : (
|
) : (
|
||||||
<tr><td colSpan={12}>No products found</td></tr>
|
<tr><td colSpan={15}>No products found</td></tr>
|
||||||
)}
|
)}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|||||||
@@ -30,6 +30,9 @@ function buildRow(r: AnalysisResult) {
|
|||||||
"Sales Rank": rank ?? "",
|
"Sales Rank": rank ?? "",
|
||||||
"Rank Avg 90d": r.product.keepa?.salesRankAvg90 ?? "",
|
"Rank Avg 90d": r.product.keepa?.salesRankAvg90 ?? "",
|
||||||
Sellers: r.product.keepa?.sellerCount ?? "",
|
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 ?? "",
|
"Monthly Sold": r.product.keepa?.monthlySold ?? "",
|
||||||
"Rank Drops 30d": r.product.keepa?.salesRankDrops30 ?? "",
|
"Rank Drops 30d": r.product.keepa?.salesRankDrops30 ?? "",
|
||||||
"Rank Drops 90d": r.product.keepa?.salesRankDrops90 ?? "",
|
"Rank Drops 90d": r.product.keepa?.salesRankDrops90 ?? "",
|
||||||
@@ -106,14 +109,15 @@ export function writeResultsToDb(
|
|||||||
`INSERT INTO results (
|
`INSERT INTO results (
|
||||||
run_id, asin, product_name, brand, category, unit_cost, current_price,
|
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,
|
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,
|
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,
|
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,
|
fba_fee, fbm_fee, referral_percent, can_sell, sellability_status, sellability_reason,
|
||||||
verdict, confidence, reasoning, fetched_at
|
verdict, confidence, reasoning, fetched_at
|
||||||
) VALUES (
|
) VALUES (
|
||||||
?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?,
|
?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?,
|
||||||
?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?
|
?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?
|
||||||
)`,
|
)`,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -134,6 +138,12 @@ export function writeResultsToDb(
|
|||||||
row["Sales Rank"] ?? null,
|
row["Sales Rank"] ?? null,
|
||||||
row["Rank Avg 90d"] ?? null,
|
row["Rank Avg 90d"] ?? null,
|
||||||
row.Sellers ?? 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["Monthly Sold"] ?? null,
|
||||||
row["Rank Drops 30d"] ?? null,
|
row["Rank Drops 30d"] ?? null,
|
||||||
row["Rank Drops 90d"] ?? null,
|
row["Rank Drops 90d"] ?? null,
|
||||||
|
|||||||
Reference in New Issue
Block a user