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:
580
src/server.ts
580
src/server.ts
@@ -1,5 +1,9 @@
|
||||
import index from "./web/index.html";
|
||||
import { getDb, initDb } from "./database.ts";
|
||||
import { fetchKeepaDataBatch } from "./keepa.ts";
|
||||
import { fetchSellabilityBatch, fetchSpApiPricingAndFees } from "./sp-api.ts";
|
||||
import { analyzeProducts } from "./llm.ts";
|
||||
import type { EnrichedProduct, ProductRecord, SpApiData } from "./types.ts";
|
||||
|
||||
type ProcessType = "lead_analysis" | "category_analysis";
|
||||
|
||||
@@ -29,6 +33,8 @@ type ProductListRecord = {
|
||||
sellability_status: string | null;
|
||||
monthly_sold: number | null;
|
||||
seller_count: number | null;
|
||||
amazon_is_seller: number | null;
|
||||
amazon_buybox_share_pct_90d: number | null;
|
||||
sales_rank: number | null;
|
||||
current_price: number | null;
|
||||
avg_price_90d: number | null;
|
||||
@@ -39,6 +45,7 @@ type ProductListRecord = {
|
||||
const DB_PATH = process.env.RESULTS_DB_PATH || "./results.db";
|
||||
const DEFAULT_PAGE_SIZE = 25;
|
||||
const MAX_PAGE_SIZE = 200;
|
||||
const ASIN_PATTERN = /^[A-Z0-9]{10}$/;
|
||||
|
||||
initDb(DB_PATH);
|
||||
const db = getDb(DB_PATH);
|
||||
@@ -67,7 +74,19 @@ function parseIntParam(value: string | null, fallback: number): number {
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function parseSort(sortParam: string | null, allowed: Set<string>, fallback: string): string {
|
||||
function normalizeAsin(value: string): string {
|
||||
return value.trim().toUpperCase();
|
||||
}
|
||||
|
||||
function isValidAsin(value: string): boolean {
|
||||
return ASIN_PATTERN.test(value);
|
||||
}
|
||||
|
||||
function parseSort(
|
||||
sortParam: string | null,
|
||||
allowed: Set<string>,
|
||||
fallback: string,
|
||||
): string {
|
||||
if (!sortParam) return fallback;
|
||||
const clauses = sortParam
|
||||
.split(",")
|
||||
@@ -85,7 +104,11 @@ function parseSort(sortParam: string | null, allowed: Set<string>, fallback: str
|
||||
return clauses.length > 0 ? clauses.join(", ") : fallback;
|
||||
}
|
||||
|
||||
function parseResultSort(sortParam: string | null, allowed: Set<string>, fallback: string): string {
|
||||
function parseResultSort(
|
||||
sortParam: string | null,
|
||||
allowed: Set<string>,
|
||||
fallback: string,
|
||||
): string {
|
||||
if (!sortParam) return fallback;
|
||||
const clauses = sortParam
|
||||
.split(",")
|
||||
@@ -96,7 +119,13 @@ function parseResultSort(sortParam: string | null, allowed: Set<string>, fallbac
|
||||
const field = fieldRaw?.trim();
|
||||
const dir = dirRaw?.trim().toUpperCase() === "DESC" ? "DESC" : "ASC";
|
||||
if (!field || !allowed.has(field)) return null;
|
||||
if (field === "monthly_sold") return `CAST(COALESCE(monthly_sold, 0) AS INTEGER) ${dir}`;
|
||||
if (field === "monthly_sold")
|
||||
return `CAST(COALESCE(monthly_sold, 0) AS INTEGER) ${dir}`;
|
||||
if (field === "amazon_is_seller")
|
||||
return `CAST(COALESCE(amazon_is_seller, 0) AS INTEGER) ${dir}`;
|
||||
if (field === "amazon_buybox_share_pct_90d") {
|
||||
return `CAST(COALESCE(amazon_buybox_share_pct_90d, 0) AS REAL) ${dir}`;
|
||||
}
|
||||
return `${field} ${dir}`;
|
||||
})
|
||||
.filter((value): value is string => value !== null);
|
||||
@@ -107,11 +136,15 @@ function parseResultSort(sortParam: string | null, allowed: Set<string>, fallbac
|
||||
function escapeCsvValue(value: unknown): string {
|
||||
if (value === null || value === undefined) return "";
|
||||
const text = String(value);
|
||||
const escaped = text.replaceAll("\"", "\"\"");
|
||||
const escaped = text.replaceAll('"', '""');
|
||||
return /[",\n]/.test(escaped) ? `"${escaped}"` : escaped;
|
||||
}
|
||||
|
||||
function parseResultFilters(processType: ProcessType, runId: number, filters: URLSearchParams) {
|
||||
function parseResultFilters(
|
||||
processType: ProcessType,
|
||||
runId: number,
|
||||
filters: URLSearchParams,
|
||||
) {
|
||||
const q = filters.get("q")?.trim() || "";
|
||||
const verdict = filters.get("verdict")?.trim();
|
||||
const sellabilityStatus = filters.get("sellabilityStatus")?.trim();
|
||||
@@ -144,10 +177,14 @@ function parseResultFilters(processType: ProcessType, runId: number, filters: UR
|
||||
if (q) {
|
||||
const wildcard = `%${q}%`;
|
||||
if (processType === "lead_analysis") {
|
||||
conditions.push("(asin LIKE ? OR product_name LIKE ? OR brand LIKE ? OR category LIKE ? OR reasoning LIKE ?)");
|
||||
conditions.push(
|
||||
"(asin LIKE ? OR product_name LIKE ? OR brand LIKE ? OR category LIKE ? OR reasoning LIKE ?)",
|
||||
);
|
||||
params.push(wildcard, wildcard, wildcard, wildcard, wildcard);
|
||||
} else {
|
||||
conditions.push("(asin LIKE ? OR name LIKE ? OR brand LIKE ? OR category LIKE ? OR reasoning LIKE ?)");
|
||||
conditions.push(
|
||||
"(asin LIKE ? OR name LIKE ? OR brand LIKE ? OR category LIKE ? OR reasoning LIKE ?)",
|
||||
);
|
||||
params.push(wildcard, wildcard, wildcard, wildcard, wildcard);
|
||||
}
|
||||
}
|
||||
@@ -165,7 +202,10 @@ function getRuns(filters: URLSearchParams) {
|
||||
const startDate = filters.get("startDate")?.trim();
|
||||
const endDate = filters.get("endDate")?.trim();
|
||||
const page = parseIntParam(filters.get("page"), 1);
|
||||
const pageSize = Math.min(parseIntParam(filters.get("pageSize"), DEFAULT_PAGE_SIZE), MAX_PAGE_SIZE);
|
||||
const pageSize = Math.min(
|
||||
parseIntParam(filters.get("pageSize"), DEFAULT_PAGE_SIZE),
|
||||
MAX_PAGE_SIZE,
|
||||
);
|
||||
const offset = (page - 1) * pageSize;
|
||||
|
||||
const allowedSort = new Set([
|
||||
@@ -178,7 +218,11 @@ function getRuns(filters: URLSearchParams) {
|
||||
"runId",
|
||||
"jobType",
|
||||
]);
|
||||
const orderBy = parseSort(filters.get("sort"), allowedSort, "timestamp DESC, runId DESC");
|
||||
const orderBy = parseSort(
|
||||
filters.get("sort"),
|
||||
allowedSort,
|
||||
"timestamp DESC, runId DESC",
|
||||
);
|
||||
|
||||
const conditions: string[] = [];
|
||||
const params: Array<string | number> = [];
|
||||
@@ -204,12 +248,15 @@ function getRuns(filters: URLSearchParams) {
|
||||
}
|
||||
|
||||
if (q) {
|
||||
conditions.push("(jobType LIKE ? OR source LIKE ? OR output LIKE ? OR CAST(runId AS TEXT) LIKE ?)");
|
||||
conditions.push(
|
||||
"(jobType LIKE ? OR source LIKE ? OR output LIKE ? OR CAST(runId AS TEXT) LIKE ?)",
|
||||
);
|
||||
const wildcard = `%${q}%`;
|
||||
params.push(wildcard, wildcard, wildcard, wildcard);
|
||||
}
|
||||
|
||||
const where = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
|
||||
const where =
|
||||
conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
|
||||
|
||||
const baseUnion = `
|
||||
SELECT
|
||||
@@ -246,7 +293,9 @@ function getRuns(filters: URLSearchParams) {
|
||||
.get(...params) as { total: number };
|
||||
|
||||
const items = db
|
||||
.query(`SELECT * FROM (${baseUnion}) all_runs ${where} ORDER BY ${orderBy} LIMIT ? OFFSET ?`)
|
||||
.query(
|
||||
`SELECT * FROM (${baseUnion}) all_runs ${where} ORDER BY ${orderBy} LIMIT ? OFFSET ?`,
|
||||
)
|
||||
.all(...params, pageSize, offset) as RunRecord[];
|
||||
|
||||
return {
|
||||
@@ -262,7 +311,10 @@ function getProductList(filters: URLSearchParams) {
|
||||
const q = filters.get("q")?.trim() || "";
|
||||
const verdict = filters.get("verdict")?.trim();
|
||||
const page = parseIntParam(filters.get("page"), 1);
|
||||
const pageSize = Math.min(parseIntParam(filters.get("pageSize"), DEFAULT_PAGE_SIZE), MAX_PAGE_SIZE);
|
||||
const pageSize = Math.min(
|
||||
parseIntParam(filters.get("pageSize"), DEFAULT_PAGE_SIZE),
|
||||
MAX_PAGE_SIZE,
|
||||
);
|
||||
const offset = (page - 1) * pageSize;
|
||||
|
||||
const conditions: string[] = [];
|
||||
@@ -275,16 +327,21 @@ function getProductList(filters: URLSearchParams) {
|
||||
|
||||
if (q) {
|
||||
const wildcard = `%${q}%`;
|
||||
conditions.push("(asin LIKE ? OR product_name LIKE ? OR brand LIKE ? OR category LIKE ?)");
|
||||
conditions.push(
|
||||
"(asin LIKE ? OR product_name LIKE ? OR brand LIKE ? OR category LIKE ?)",
|
||||
);
|
||||
params.push(wildcard, wildcard, wildcard, wildcard);
|
||||
}
|
||||
|
||||
const where = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
|
||||
const where =
|
||||
conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
|
||||
const allowedSort = new Set([
|
||||
"asin",
|
||||
"verdict",
|
||||
"monthly_sold",
|
||||
"seller_count",
|
||||
"amazon_is_seller",
|
||||
"amazon_buybox_share_pct_90d",
|
||||
"sales_rank",
|
||||
"current_price",
|
||||
"product_name",
|
||||
@@ -314,6 +371,8 @@ function getProductList(filters: URLSearchParams) {
|
||||
sellability_status,
|
||||
monthly_sold,
|
||||
sellers AS seller_count,
|
||||
amazon_is_seller,
|
||||
amazon_buybox_share_pct_90d,
|
||||
sales_rank,
|
||||
current_price,
|
||||
avg_price_90d,
|
||||
@@ -333,6 +392,8 @@ function getProductList(filters: URLSearchParams) {
|
||||
sellability_status,
|
||||
monthly_sold,
|
||||
seller_count,
|
||||
amazon_is_seller,
|
||||
amazon_buybox_share_pct_90d,
|
||||
sales_rank,
|
||||
current_price,
|
||||
avg_price_90d,
|
||||
@@ -346,7 +407,9 @@ function getProductList(filters: URLSearchParams) {
|
||||
.get(...params) as { total: number };
|
||||
|
||||
const items = db
|
||||
.query(`SELECT * FROM (${baseUnion}) all_products ${where} ORDER BY ${orderBy} LIMIT ? OFFSET ?`)
|
||||
.query(
|
||||
`SELECT * FROM (${baseUnion}) all_products ${where} ORDER BY ${orderBy} LIMIT ? OFFSET ?`,
|
||||
)
|
||||
.all(...params, pageSize, offset) as ProductListRecord[];
|
||||
|
||||
return {
|
||||
@@ -400,15 +463,30 @@ function getRun(processType: ProcessType, runId: number) {
|
||||
return run ?? null;
|
||||
}
|
||||
|
||||
function getRunResults(processType: ProcessType, runId: number, filters: URLSearchParams) {
|
||||
function getRunResults(
|
||||
processType: ProcessType,
|
||||
runId: number,
|
||||
filters: URLSearchParams,
|
||||
) {
|
||||
const page = parseIntParam(filters.get("page"), 1);
|
||||
const pageSize = Math.min(parseIntParam(filters.get("pageSize"), DEFAULT_PAGE_SIZE), MAX_PAGE_SIZE);
|
||||
const pageSize = Math.min(
|
||||
parseIntParam(filters.get("pageSize"), DEFAULT_PAGE_SIZE),
|
||||
MAX_PAGE_SIZE,
|
||||
);
|
||||
const offset = (page - 1) * pageSize;
|
||||
|
||||
const tableName = processType === "lead_analysis" ? "results" : "product_analysis_results";
|
||||
const productNameSelect = processType === "lead_analysis" ? "product_name" : "name AS product_name";
|
||||
const sellerCountSelect = processType === "lead_analysis" ? "sellers AS seller_count" : "seller_count";
|
||||
const salesRankAvgSelect = processType === "lead_analysis" ? "rank_avg_90d AS sales_rank_avg_90d" : "sales_rank_avg_90d";
|
||||
const tableName =
|
||||
processType === "lead_analysis" ? "results" : "product_analysis_results";
|
||||
const productNameSelect =
|
||||
processType === "lead_analysis" ? "product_name" : "name AS product_name";
|
||||
const sellerCountSelect =
|
||||
processType === "lead_analysis"
|
||||
? "sellers AS seller_count"
|
||||
: "seller_count";
|
||||
const salesRankAvgSelect =
|
||||
processType === "lead_analysis"
|
||||
? "rank_avg_90d AS sales_rank_avg_90d"
|
||||
: "sales_rank_avg_90d";
|
||||
|
||||
const { where, params } = parseResultFilters(processType, runId, filters);
|
||||
|
||||
@@ -421,6 +499,8 @@ function getRunResults(processType: ProcessType, runId: number, filters: URLSear
|
||||
"avg_price_90d",
|
||||
"sales_rank",
|
||||
"seller_count",
|
||||
"amazon_is_seller",
|
||||
"amazon_buybox_share_pct_90d",
|
||||
"monthly_sold",
|
||||
"verdict",
|
||||
"confidence",
|
||||
@@ -453,6 +533,8 @@ function getRunResults(processType: ProcessType, runId: number, filters: URLSear
|
||||
sales_rank,
|
||||
${salesRankAvgSelect},
|
||||
${sellerCountSelect},
|
||||
amazon_is_seller,
|
||||
amazon_buybox_share_pct_90d,
|
||||
monthly_sold,
|
||||
rank_drops_30d,
|
||||
rank_drops_90d,
|
||||
@@ -484,7 +566,9 @@ function getRunResults(processType: ProcessType, runId: number, filters: URLSear
|
||||
|
||||
function deleteRun(processType: ProcessType, runId: number) {
|
||||
if (processType === "lead_analysis") {
|
||||
const resultRows = db.query("DELETE FROM results WHERE run_id = ?").run(runId);
|
||||
const resultRows = db
|
||||
.query("DELETE FROM results WHERE run_id = ?")
|
||||
.run(runId);
|
||||
const runRows = db.query("DELETE FROM runs WHERE id = ?").run(runId);
|
||||
return {
|
||||
deletedRun: runRows.changes > 0,
|
||||
@@ -492,19 +576,35 @@ function deleteRun(processType: ProcessType, runId: number) {
|
||||
};
|
||||
}
|
||||
|
||||
const resultRows = db.query("DELETE FROM product_analysis_results WHERE run_id = ?").run(runId);
|
||||
const runRows = db.query("DELETE FROM category_analysis_runs WHERE id = ?").run(runId);
|
||||
const resultRows = db
|
||||
.query("DELETE FROM product_analysis_results WHERE run_id = ?")
|
||||
.run(runId);
|
||||
const runRows = db
|
||||
.query("DELETE FROM category_analysis_runs WHERE id = ?")
|
||||
.run(runId);
|
||||
return {
|
||||
deletedRun: runRows.changes > 0,
|
||||
deletedResults: resultRows.changes,
|
||||
};
|
||||
}
|
||||
|
||||
function exportRunResultsCsv(processType: ProcessType, runId: number, filters: URLSearchParams) {
|
||||
const tableName = processType === "lead_analysis" ? "results" : "product_analysis_results";
|
||||
const productNameSelect = processType === "lead_analysis" ? "product_name" : "name AS product_name";
|
||||
const sellerCountSelect = processType === "lead_analysis" ? "sellers AS seller_count" : "seller_count";
|
||||
const salesRankAvgSelect = processType === "lead_analysis" ? "rank_avg_90d AS sales_rank_avg_90d" : "sales_rank_avg_90d";
|
||||
function exportRunResultsCsv(
|
||||
processType: ProcessType,
|
||||
runId: number,
|
||||
filters: URLSearchParams,
|
||||
) {
|
||||
const tableName =
|
||||
processType === "lead_analysis" ? "results" : "product_analysis_results";
|
||||
const productNameSelect =
|
||||
processType === "lead_analysis" ? "product_name" : "name AS product_name";
|
||||
const sellerCountSelect =
|
||||
processType === "lead_analysis"
|
||||
? "sellers AS seller_count"
|
||||
: "seller_count";
|
||||
const salesRankAvgSelect =
|
||||
processType === "lead_analysis"
|
||||
? "rank_avg_90d AS sales_rank_avg_90d"
|
||||
: "sales_rank_avg_90d";
|
||||
|
||||
const { where, params } = parseResultFilters(processType, runId, filters);
|
||||
|
||||
@@ -517,6 +617,8 @@ function exportRunResultsCsv(processType: ProcessType, runId: number, filters: U
|
||||
"avg_price_90d",
|
||||
"sales_rank",
|
||||
"seller_count",
|
||||
"amazon_is_seller",
|
||||
"amazon_buybox_share_pct_90d",
|
||||
"monthly_sold",
|
||||
"verdict",
|
||||
"confidence",
|
||||
@@ -541,6 +643,8 @@ function exportRunResultsCsv(processType: ProcessType, runId: number, filters: U
|
||||
avg_price_90d,
|
||||
${salesRankAvgSelect},
|
||||
${sellerCountSelect},
|
||||
amazon_is_seller,
|
||||
amazon_buybox_share_pct_90d,
|
||||
monthly_sold,
|
||||
sellability_status,
|
||||
verdict,
|
||||
@@ -564,6 +668,8 @@ function exportRunResultsCsv(processType: ProcessType, runId: number, filters: U
|
||||
"avg_price_90d",
|
||||
"sales_rank_avg_90d",
|
||||
"seller_count",
|
||||
"amazon_is_seller",
|
||||
"amazon_buybox_share_pct_90d",
|
||||
"monthly_sold",
|
||||
"sellability_status",
|
||||
"verdict",
|
||||
@@ -580,6 +686,366 @@ function exportRunResultsCsv(processType: ProcessType, runId: number, filters: U
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
type ReanalyzeSourceRow = {
|
||||
asin: string;
|
||||
product_name: string | null;
|
||||
brand: string | null;
|
||||
category: string | null;
|
||||
unit_cost: number | null;
|
||||
sales_rank: number | null;
|
||||
avg_price_90d_sheet: number | null;
|
||||
selling_price_sheet: number | null;
|
||||
fba_net_sheet: number | null;
|
||||
gross_profit_dollar: number | null;
|
||||
gross_profit_pct: number | null;
|
||||
net_profit_sheet: number | null;
|
||||
roi_sheet: number | null;
|
||||
moq: number | null;
|
||||
moq_cost: number | null;
|
||||
qty_available: number | null;
|
||||
supplier: string | null;
|
||||
source_url: string | null;
|
||||
asin_link: string | null;
|
||||
promo_coupon_code: string | null;
|
||||
notes: string | null;
|
||||
lead_date: string | null;
|
||||
};
|
||||
|
||||
function getReanalyzeSourceRow(
|
||||
processType: ProcessType,
|
||||
runId: number,
|
||||
asin: string,
|
||||
): ReanalyzeSourceRow | null {
|
||||
if (processType === "lead_analysis") {
|
||||
return (
|
||||
(db
|
||||
.query(
|
||||
`SELECT
|
||||
asin,
|
||||
product_name,
|
||||
brand,
|
||||
category,
|
||||
unit_cost,
|
||||
sales_rank,
|
||||
avg_price_90d_sheet,
|
||||
selling_price_sheet,
|
||||
fba_net_sheet,
|
||||
gross_profit_dollar,
|
||||
gross_profit_pct,
|
||||
net_profit_sheet,
|
||||
roi_sheet,
|
||||
moq,
|
||||
moq_cost,
|
||||
qty_available,
|
||||
supplier,
|
||||
source_url,
|
||||
asin_link,
|
||||
promo_coupon_code,
|
||||
notes,
|
||||
lead_date
|
||||
FROM results
|
||||
WHERE run_id = ? AND asin = ?
|
||||
LIMIT 1`,
|
||||
)
|
||||
.get(runId, asin) as ReanalyzeSourceRow | null) ?? null
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
(db
|
||||
.query(
|
||||
`SELECT
|
||||
asin,
|
||||
name AS product_name,
|
||||
brand,
|
||||
category,
|
||||
unit_cost,
|
||||
sales_rank,
|
||||
avg_price_90d_sheet,
|
||||
selling_price_sheet,
|
||||
NULL AS fba_net_sheet,
|
||||
NULL AS gross_profit_dollar,
|
||||
NULL AS gross_profit_pct,
|
||||
NULL AS net_profit_sheet,
|
||||
NULL AS roi_sheet,
|
||||
NULL AS moq,
|
||||
NULL AS moq_cost,
|
||||
NULL AS qty_available,
|
||||
NULL AS supplier,
|
||||
NULL AS source_url,
|
||||
NULL AS asin_link,
|
||||
NULL AS promo_coupon_code,
|
||||
NULL AS notes,
|
||||
NULL AS lead_date
|
||||
FROM product_analysis_results
|
||||
WHERE run_id = ? AND asin = ?
|
||||
LIMIT 1`,
|
||||
)
|
||||
.get(runId, asin) as ReanalyzeSourceRow | null) ?? null
|
||||
);
|
||||
}
|
||||
|
||||
function toProductRecord(row: ReanalyzeSourceRow): ProductRecord {
|
||||
return {
|
||||
asin: row.asin,
|
||||
name: row.product_name ?? row.asin,
|
||||
unitCost: row.unit_cost ?? 0,
|
||||
brand: row.brand ?? undefined,
|
||||
category: row.category ?? undefined,
|
||||
amazonRank: row.sales_rank ?? undefined,
|
||||
avgPrice90FromSheet: row.avg_price_90d_sheet ?? undefined,
|
||||
sellingPriceFromSheet: row.selling_price_sheet ?? undefined,
|
||||
fbaNet: row.fba_net_sheet ?? undefined,
|
||||
grossProfit: row.gross_profit_dollar ?? undefined,
|
||||
grossProfitPct: row.gross_profit_pct ?? undefined,
|
||||
netProfitFromSheet: row.net_profit_sheet ?? undefined,
|
||||
roiFromSheet: row.roi_sheet ?? undefined,
|
||||
moq: row.moq ?? undefined,
|
||||
moqCost: row.moq_cost ?? undefined,
|
||||
totalQtyAvail: row.qty_available ?? undefined,
|
||||
supplier: row.supplier ?? undefined,
|
||||
sourceUrl: row.source_url ?? undefined,
|
||||
asinLink: row.asin_link ?? undefined,
|
||||
promoCouponCode: row.promo_coupon_code ?? undefined,
|
||||
notes: row.notes ?? undefined,
|
||||
leadDate: row.lead_date ?? undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function unknownSpApiData(): SpApiData {
|
||||
return {
|
||||
fbaFee: 5.0,
|
||||
fbmFee: 1.5,
|
||||
referralFeePercent: 15,
|
||||
estimatedSalePrice: 0,
|
||||
canSell: null,
|
||||
sellabilityStatus: "unknown",
|
||||
sellabilityReason: "Sellability check returned no result",
|
||||
};
|
||||
}
|
||||
|
||||
function refreshRunCounts(processType: ProcessType, runId: number): void {
|
||||
if (processType === "lead_analysis") {
|
||||
const stats = db
|
||||
.query(
|
||||
`SELECT
|
||||
COUNT(*) AS total,
|
||||
SUM(CASE WHEN verdict = 'FBA' THEN 1 ELSE 0 END) AS fba,
|
||||
SUM(CASE WHEN verdict = 'FBM' THEN 1 ELSE 0 END) AS fbm,
|
||||
SUM(CASE WHEN verdict = 'SKIP' THEN 1 ELSE 0 END) AS skip
|
||||
FROM results
|
||||
WHERE run_id = ?`,
|
||||
)
|
||||
.get(runId) as {
|
||||
total: number;
|
||||
fba: number | null;
|
||||
fbm: number | null;
|
||||
skip: number | null;
|
||||
};
|
||||
|
||||
db.query(
|
||||
`UPDATE runs
|
||||
SET total_products = ?, fba_count = ?, fbm_count = ?, skip_count = ?
|
||||
WHERE id = ?`,
|
||||
).run(
|
||||
stats.total ?? 0,
|
||||
stats.fba ?? 0,
|
||||
stats.fbm ?? 0,
|
||||
stats.skip ?? 0,
|
||||
runId,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const stats = db
|
||||
.query(
|
||||
`SELECT
|
||||
COUNT(*) AS available,
|
||||
SUM(CASE WHEN verdict = 'FBA' THEN 1 ELSE 0 END) AS fba,
|
||||
SUM(CASE WHEN verdict = 'FBM' THEN 1 ELSE 0 END) AS fbm,
|
||||
SUM(CASE WHEN verdict = 'SKIP' THEN 1 ELSE 0 END) AS skip
|
||||
FROM product_analysis_results
|
||||
WHERE run_id = ?`,
|
||||
)
|
||||
.get(runId) as {
|
||||
available: number;
|
||||
fba: number | null;
|
||||
fbm: number | null;
|
||||
skip: number | null;
|
||||
};
|
||||
|
||||
db.query(
|
||||
`UPDATE category_analysis_runs
|
||||
SET available_asins = ?, fba_count = ?, fbm_count = ?, skip_count = ?
|
||||
WHERE id = ?`,
|
||||
).run(
|
||||
stats.available ?? 0,
|
||||
stats.fba ?? 0,
|
||||
stats.fbm ?? 0,
|
||||
stats.skip ?? 0,
|
||||
runId,
|
||||
);
|
||||
}
|
||||
|
||||
async function reanalyzeSingleAsin(
|
||||
processType: ProcessType,
|
||||
runId: number,
|
||||
asin: string,
|
||||
): Promise<{
|
||||
asin: string;
|
||||
runId: number;
|
||||
processType: ProcessType;
|
||||
fetchedAt: string;
|
||||
}> {
|
||||
const row = getReanalyzeSourceRow(processType, runId, asin);
|
||||
if (!row) {
|
||||
throw new Error("Result row not found");
|
||||
}
|
||||
|
||||
const productRecord = toProductRecord(row);
|
||||
|
||||
let keepa = null;
|
||||
try {
|
||||
const keepaMap = await fetchKeepaDataBatch([asin]);
|
||||
keepa = keepaMap.get(asin) ?? null;
|
||||
} catch {
|
||||
keepa = null;
|
||||
}
|
||||
|
||||
const sellabilityMap = await fetchSellabilityBatch([asin]);
|
||||
const sellability = sellabilityMap.get(asin) ?? {
|
||||
canSell: null,
|
||||
sellabilityStatus: "unknown" as const,
|
||||
sellabilityReason: "Sellability check returned no result",
|
||||
};
|
||||
const spApi = await fetchSpApiPricingAndFees(asin, sellability);
|
||||
|
||||
if (keepa?.currentPrice && spApi.estimatedSalePrice === 0) {
|
||||
spApi.estimatedSalePrice = keepa.currentPrice;
|
||||
}
|
||||
|
||||
const enriched: EnrichedProduct = {
|
||||
record: productRecord,
|
||||
keepa,
|
||||
spApi: spApi ?? unknownSpApiData(),
|
||||
fetchedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
const verdicts = await analyzeProducts([enriched]);
|
||||
const verdict = verdicts[0] ?? {
|
||||
asin,
|
||||
verdict: "SKIP" as const,
|
||||
confidence: 0,
|
||||
reasoning: "LLM analysis returned no verdict",
|
||||
};
|
||||
|
||||
const amazonIsSeller =
|
||||
keepa?.amazonIsSeller == null ? null : keepa.amazonIsSeller ? 1 : 0;
|
||||
const fetchedAt = enriched.fetchedAt;
|
||||
|
||||
if (processType === "lead_analysis") {
|
||||
db.query(
|
||||
`UPDATE results SET
|
||||
current_price = ?,
|
||||
avg_price_90d = ?,
|
||||
sales_rank = ?,
|
||||
rank_avg_90d = ?,
|
||||
sellers = ?,
|
||||
amazon_is_seller = ?,
|
||||
amazon_buybox_share_pct_90d = ?,
|
||||
monthly_sold = ?,
|
||||
rank_drops_30d = ?,
|
||||
rank_drops_90d = ?,
|
||||
fba_fee = ?,
|
||||
fbm_fee = ?,
|
||||
referral_percent = ?,
|
||||
can_sell = ?,
|
||||
sellability_status = ?,
|
||||
sellability_reason = ?,
|
||||
verdict = ?,
|
||||
confidence = ?,
|
||||
reasoning = ?,
|
||||
fetched_at = ?
|
||||
WHERE run_id = ? AND asin = ?`,
|
||||
).run(
|
||||
keepa?.currentPrice ?? null,
|
||||
keepa?.avgPrice90 ?? null,
|
||||
keepa?.salesRank ?? row.sales_rank ?? null,
|
||||
keepa?.salesRankAvg90 ?? null,
|
||||
keepa?.sellerCount ?? null,
|
||||
amazonIsSeller,
|
||||
keepa?.amazonBuyboxSharePct90d ?? null,
|
||||
keepa?.monthlySold ?? null,
|
||||
keepa?.salesRankDrops30 ?? null,
|
||||
keepa?.salesRankDrops90 ?? null,
|
||||
spApi.fbaFee,
|
||||
spApi.fbmFee,
|
||||
spApi.referralFeePercent,
|
||||
spApi.canSell == null ? null : spApi.canSell ? 1 : 0,
|
||||
spApi.sellabilityStatus,
|
||||
spApi.sellabilityReason ?? null,
|
||||
verdict.verdict,
|
||||
verdict.confidence,
|
||||
verdict.reasoning,
|
||||
fetchedAt,
|
||||
runId,
|
||||
asin,
|
||||
);
|
||||
} else {
|
||||
db.query(
|
||||
`UPDATE product_analysis_results SET
|
||||
current_price = ?,
|
||||
avg_price_90d = ?,
|
||||
sales_rank = ?,
|
||||
sales_rank_avg_90d = ?,
|
||||
seller_count = ?,
|
||||
amazon_is_seller = ?,
|
||||
amazon_buybox_share_pct_90d = ?,
|
||||
monthly_sold = ?,
|
||||
rank_drops_30d = ?,
|
||||
rank_drops_90d = ?,
|
||||
fba_fee = ?,
|
||||
fbm_fee = ?,
|
||||
referral_percent = ?,
|
||||
can_sell = ?,
|
||||
sellability_status = ?,
|
||||
sellability_reason = ?,
|
||||
verdict = ?,
|
||||
confidence = ?,
|
||||
reasoning = ?,
|
||||
fetched_at = ?
|
||||
WHERE run_id = ? AND asin = ?`,
|
||||
).run(
|
||||
keepa?.currentPrice ?? null,
|
||||
keepa?.avgPrice90 ?? null,
|
||||
keepa?.salesRank ?? row.sales_rank ?? null,
|
||||
keepa?.salesRankAvg90 ?? null,
|
||||
keepa?.sellerCount ?? null,
|
||||
amazonIsSeller,
|
||||
keepa?.amazonBuyboxSharePct90d ?? null,
|
||||
keepa?.monthlySold ?? null,
|
||||
keepa?.salesRankDrops30 ?? null,
|
||||
keepa?.salesRankDrops90 ?? null,
|
||||
spApi.fbaFee,
|
||||
spApi.fbmFee,
|
||||
spApi.referralFeePercent,
|
||||
spApi.canSell == null ? null : spApi.canSell ? 1 : 0,
|
||||
spApi.sellabilityStatus,
|
||||
spApi.sellabilityReason ?? null,
|
||||
verdict.verdict,
|
||||
verdict.confidence,
|
||||
verdict.reasoning,
|
||||
fetchedAt,
|
||||
runId,
|
||||
asin,
|
||||
);
|
||||
}
|
||||
|
||||
refreshRunCounts(processType, runId);
|
||||
|
||||
return { asin, runId, processType, fetchedAt };
|
||||
}
|
||||
|
||||
const server = Bun.serve({
|
||||
port: Number(process.env.PORT || "3000"),
|
||||
routes: {
|
||||
@@ -598,7 +1064,12 @@ const server = Bun.serve({
|
||||
const processType = req.params.processType as ProcessType;
|
||||
const runId = Number(req.params.runId);
|
||||
|
||||
if (!(processType === "lead_analysis" || processType === "category_analysis") || !Number.isInteger(runId)) {
|
||||
if (
|
||||
!(
|
||||
processType === "lead_analysis" || processType === "category_analysis"
|
||||
) ||
|
||||
!Number.isInteger(runId)
|
||||
) {
|
||||
return json({ error: "Invalid run identifier" }, 400);
|
||||
}
|
||||
|
||||
@@ -624,7 +1095,12 @@ const server = Bun.serve({
|
||||
const processType = req.params.processType as ProcessType;
|
||||
const runId = Number(req.params.runId);
|
||||
|
||||
if (!(processType === "lead_analysis" || processType === "category_analysis") || !Number.isInteger(runId)) {
|
||||
if (
|
||||
!(
|
||||
processType === "lead_analysis" || processType === "category_analysis"
|
||||
) ||
|
||||
!Number.isInteger(runId)
|
||||
) {
|
||||
return json({ error: "Invalid run identifier" }, 400);
|
||||
}
|
||||
|
||||
@@ -632,11 +1108,49 @@ const server = Bun.serve({
|
||||
const payload = getRunResults(processType, runId, url.searchParams);
|
||||
return json(payload);
|
||||
},
|
||||
"/api/runs/:processType/:runId/asins/:asin/reanalyze": async (req) => {
|
||||
const processType = req.params.processType as ProcessType;
|
||||
const runId = Number(req.params.runId);
|
||||
const asin = normalizeAsin(req.params.asin);
|
||||
|
||||
if (
|
||||
!(
|
||||
processType === "lead_analysis" || processType === "category_analysis"
|
||||
) ||
|
||||
!Number.isInteger(runId)
|
||||
) {
|
||||
return json({ error: "Invalid run identifier" }, 400);
|
||||
}
|
||||
|
||||
if (req.method !== "POST") {
|
||||
return json({ error: "Method not allowed" }, 405);
|
||||
}
|
||||
|
||||
if (!isValidAsin(asin)) {
|
||||
return json({ error: "Invalid ASIN" }, 400);
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await reanalyzeSingleAsin(processType, runId, asin);
|
||||
return json(result);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
if (message === "Result row not found") {
|
||||
return json({ error: message }, 404);
|
||||
}
|
||||
return json({ error: message }, 500);
|
||||
}
|
||||
},
|
||||
"/api/runs/:processType/:runId/export.csv": (req) => {
|
||||
const processType = req.params.processType as ProcessType;
|
||||
const runId = Number(req.params.runId);
|
||||
|
||||
if (!(processType === "lead_analysis" || processType === "category_analysis") || !Number.isInteger(runId)) {
|
||||
if (
|
||||
!(
|
||||
processType === "lead_analysis" || processType === "category_analysis"
|
||||
) ||
|
||||
!Number.isInteger(runId)
|
||||
) {
|
||||
return json({ error: "Invalid run identifier" }, 400);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user