- 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.
1172 lines
30 KiB
TypeScript
1172 lines
30 KiB
TypeScript
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";
|
|
|
|
type RunRecord = {
|
|
processType: ProcessType;
|
|
runId: number;
|
|
timestamp: string;
|
|
status: string;
|
|
jobType: string;
|
|
source: string | null;
|
|
output: string | null;
|
|
totalProducts: number;
|
|
fbaCount: number;
|
|
fbmCount: number;
|
|
skipCount: number;
|
|
};
|
|
|
|
type ProductListRecord = {
|
|
processType: ProcessType;
|
|
runId: number;
|
|
asin: string;
|
|
product_name: string | null;
|
|
brand: string | null;
|
|
category: string | null;
|
|
verdict: "FBA" | "FBM" | "SKIP";
|
|
confidence: number | null;
|
|
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;
|
|
reasoning: string | null;
|
|
fetched_at: string;
|
|
};
|
|
|
|
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);
|
|
|
|
function json(data: unknown, status = 200): Response {
|
|
return new Response(JSON.stringify(data), {
|
|
status,
|
|
headers: { "content-type": "application/json; charset=utf-8" },
|
|
});
|
|
}
|
|
|
|
function csv(text: string, filename: string): Response {
|
|
return new Response(text, {
|
|
status: 200,
|
|
headers: {
|
|
"content-type": "text/csv; charset=utf-8",
|
|
"content-disposition": `attachment; filename="${filename}"`,
|
|
},
|
|
});
|
|
}
|
|
|
|
function parseIntParam(value: string | null, fallback: number): number {
|
|
if (!value) return fallback;
|
|
const parsed = Number.parseInt(value, 10);
|
|
if (!Number.isFinite(parsed) || parsed < 1) return fallback;
|
|
return parsed;
|
|
}
|
|
|
|
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(",")
|
|
.map((chunk) => chunk.trim())
|
|
.filter(Boolean)
|
|
.map((chunk) => {
|
|
const [fieldRaw, dirRaw] = chunk.split(":");
|
|
const field = fieldRaw?.trim();
|
|
const dir = dirRaw?.trim().toUpperCase() === "DESC" ? "DESC" : "ASC";
|
|
if (!field || !allowed.has(field)) return null;
|
|
return `${field} ${dir}`;
|
|
})
|
|
.filter((value): value is string => value !== null);
|
|
|
|
return clauses.length > 0 ? clauses.join(", ") : fallback;
|
|
}
|
|
|
|
function parseResultSort(
|
|
sortParam: string | null,
|
|
allowed: Set<string>,
|
|
fallback: string,
|
|
): string {
|
|
if (!sortParam) return fallback;
|
|
const clauses = sortParam
|
|
.split(",")
|
|
.map((chunk) => chunk.trim())
|
|
.filter(Boolean)
|
|
.map((chunk) => {
|
|
const [fieldRaw, dirRaw] = chunk.split(":");
|
|
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 === "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);
|
|
|
|
return clauses.length > 0 ? clauses.join(", ") : fallback;
|
|
}
|
|
|
|
function escapeCsvValue(value: unknown): string {
|
|
if (value === null || value === undefined) return "";
|
|
const text = String(value);
|
|
const escaped = text.replaceAll('"', '""');
|
|
return /[",\n]/.test(escaped) ? `"${escaped}"` : escaped;
|
|
}
|
|
|
|
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();
|
|
const minConfidence = filters.get("minConfidence")?.trim();
|
|
const maxConfidence = filters.get("maxConfidence")?.trim();
|
|
|
|
const conditions: string[] = ["run_id = ?"];
|
|
const params: Array<string | number> = [runId];
|
|
|
|
if (verdict) {
|
|
conditions.push("verdict = ?");
|
|
params.push(verdict);
|
|
}
|
|
|
|
if (sellabilityStatus) {
|
|
conditions.push("sellability_status = ?");
|
|
params.push(sellabilityStatus);
|
|
}
|
|
|
|
if (minConfidence) {
|
|
conditions.push("confidence >= ?");
|
|
params.push(Number(minConfidence));
|
|
}
|
|
|
|
if (maxConfidence) {
|
|
conditions.push("confidence <= ?");
|
|
params.push(Number(maxConfidence));
|
|
}
|
|
|
|
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 ?)",
|
|
);
|
|
params.push(wildcard, wildcard, wildcard, wildcard, wildcard);
|
|
} else {
|
|
conditions.push(
|
|
"(asin LIKE ? OR name LIKE ? OR brand LIKE ? OR category LIKE ? OR reasoning LIKE ?)",
|
|
);
|
|
params.push(wildcard, wildcard, wildcard, wildcard, wildcard);
|
|
}
|
|
}
|
|
|
|
return {
|
|
where: `WHERE ${conditions.join(" AND ")}`,
|
|
params,
|
|
};
|
|
}
|
|
|
|
function getRuns(filters: URLSearchParams) {
|
|
const q = filters.get("q")?.trim() || "";
|
|
const processType = filters.get("processType")?.trim();
|
|
const status = filters.get("status")?.trim();
|
|
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 offset = (page - 1) * pageSize;
|
|
|
|
const allowedSort = new Set([
|
|
"timestamp",
|
|
"status",
|
|
"totalProducts",
|
|
"fbaCount",
|
|
"fbmCount",
|
|
"skipCount",
|
|
"runId",
|
|
"jobType",
|
|
]);
|
|
const orderBy = parseSort(
|
|
filters.get("sort"),
|
|
allowedSort,
|
|
"timestamp DESC, runId DESC",
|
|
);
|
|
|
|
const conditions: string[] = [];
|
|
const params: Array<string | number> = [];
|
|
|
|
if (processType === "lead_analysis" || processType === "category_analysis") {
|
|
conditions.push("processType = ?");
|
|
params.push(processType);
|
|
}
|
|
|
|
if (status) {
|
|
conditions.push("status = ?");
|
|
params.push(status);
|
|
}
|
|
|
|
if (startDate) {
|
|
conditions.push("timestamp >= ?");
|
|
params.push(startDate);
|
|
}
|
|
|
|
if (endDate) {
|
|
conditions.push("timestamp <= ?");
|
|
params.push(endDate);
|
|
}
|
|
|
|
if (q) {
|
|
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 baseUnion = `
|
|
SELECT
|
|
'lead_analysis' AS processType,
|
|
id AS runId,
|
|
timestamp,
|
|
'completed' AS status,
|
|
'lead_file_analysis' AS jobType,
|
|
input_file AS source,
|
|
output_file AS output,
|
|
COALESCE(total_products, 0) AS totalProducts,
|
|
COALESCE(fba_count, 0) AS fbaCount,
|
|
COALESCE(fbm_count, 0) AS fbmCount,
|
|
COALESCE(skip_count, 0) AS skipCount
|
|
FROM runs
|
|
UNION ALL
|
|
SELECT
|
|
'category_analysis' AS processType,
|
|
id AS runId,
|
|
run_timestamp AS timestamp,
|
|
status,
|
|
category_label AS jobType,
|
|
CAST(category_id AS TEXT) AS source,
|
|
NULL AS output,
|
|
COALESCE(top_asins_checked, 0) AS totalProducts,
|
|
COALESCE(fba_count, 0) AS fbaCount,
|
|
COALESCE(fbm_count, 0) AS fbmCount,
|
|
COALESCE(skip_count, 0) AS skipCount
|
|
FROM category_analysis_runs
|
|
`;
|
|
|
|
const totalRow = db
|
|
.query(`SELECT COUNT(*) as total FROM (${baseUnion}) all_runs ${where}`)
|
|
.get(...params) as { total: number };
|
|
|
|
const items = db
|
|
.query(
|
|
`SELECT * FROM (${baseUnion}) all_runs ${where} ORDER BY ${orderBy} LIMIT ? OFFSET ?`,
|
|
)
|
|
.all(...params, pageSize, offset) as RunRecord[];
|
|
|
|
return {
|
|
items,
|
|
page,
|
|
pageSize,
|
|
total: totalRow.total,
|
|
totalPages: Math.max(1, Math.ceil(totalRow.total / pageSize)),
|
|
};
|
|
}
|
|
|
|
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 offset = (page - 1) * pageSize;
|
|
|
|
const conditions: string[] = [];
|
|
const params: Array<string | number> = [];
|
|
|
|
if (verdict === "FBA" || verdict === "FBM" || verdict === "SKIP") {
|
|
conditions.push("verdict = ?");
|
|
params.push(verdict);
|
|
}
|
|
|
|
if (q) {
|
|
const wildcard = `%${q}%`;
|
|
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 allowedSort = new Set([
|
|
"asin",
|
|
"verdict",
|
|
"monthly_sold",
|
|
"seller_count",
|
|
"amazon_is_seller",
|
|
"amazon_buybox_share_pct_90d",
|
|
"sales_rank",
|
|
"current_price",
|
|
"product_name",
|
|
"brand",
|
|
"category",
|
|
"avg_price_90d",
|
|
"confidence",
|
|
"reasoning",
|
|
"fetched_at",
|
|
]);
|
|
const orderBy = parseResultSort(
|
|
filters.get("sort"),
|
|
allowedSort,
|
|
"CAST(COALESCE(monthly_sold, 0) AS INTEGER) DESC, fetched_at DESC",
|
|
);
|
|
|
|
const baseUnion = `
|
|
SELECT
|
|
'lead_analysis' AS processType,
|
|
run_id AS runId,
|
|
asin,
|
|
product_name,
|
|
brand,
|
|
category,
|
|
verdict,
|
|
confidence,
|
|
sellability_status,
|
|
monthly_sold,
|
|
sellers AS seller_count,
|
|
amazon_is_seller,
|
|
amazon_buybox_share_pct_90d,
|
|
sales_rank,
|
|
current_price,
|
|
avg_price_90d,
|
|
reasoning,
|
|
fetched_at
|
|
FROM results
|
|
UNION ALL
|
|
SELECT
|
|
'category_analysis' AS processType,
|
|
run_id AS runId,
|
|
asin,
|
|
name AS product_name,
|
|
brand,
|
|
category,
|
|
verdict,
|
|
confidence,
|
|
sellability_status,
|
|
monthly_sold,
|
|
seller_count,
|
|
amazon_is_seller,
|
|
amazon_buybox_share_pct_90d,
|
|
sales_rank,
|
|
current_price,
|
|
avg_price_90d,
|
|
reasoning,
|
|
fetched_at
|
|
FROM product_analysis_results
|
|
`;
|
|
|
|
const totalRow = db
|
|
.query(`SELECT COUNT(*) as total FROM (${baseUnion}) all_products ${where}`)
|
|
.get(...params) as { total: number };
|
|
|
|
const items = db
|
|
.query(
|
|
`SELECT * FROM (${baseUnion}) all_products ${where} ORDER BY ${orderBy} LIMIT ? OFFSET ?`,
|
|
)
|
|
.all(...params, pageSize, offset) as ProductListRecord[];
|
|
|
|
return {
|
|
items,
|
|
page,
|
|
pageSize,
|
|
total: totalRow.total,
|
|
totalPages: Math.max(1, Math.ceil(totalRow.total / pageSize)),
|
|
};
|
|
}
|
|
|
|
function getRun(processType: ProcessType, runId: number) {
|
|
if (processType === "lead_analysis") {
|
|
const run = db
|
|
.query(
|
|
`SELECT
|
|
id AS runId,
|
|
timestamp,
|
|
'completed' AS status,
|
|
'lead_file_analysis' AS jobType,
|
|
input_file AS source,
|
|
output_file AS output,
|
|
COALESCE(total_products, 0) AS totalProducts,
|
|
COALESCE(fba_count, 0) AS fbaCount,
|
|
COALESCE(fbm_count, 0) AS fbmCount,
|
|
COALESCE(skip_count, 0) AS skipCount
|
|
FROM runs WHERE id = ?`,
|
|
)
|
|
.get(runId);
|
|
return run ?? null;
|
|
}
|
|
|
|
const run = db
|
|
.query(
|
|
`SELECT
|
|
id AS runId,
|
|
run_timestamp AS timestamp,
|
|
status,
|
|
category_label AS jobType,
|
|
CAST(category_id AS TEXT) AS source,
|
|
NULL AS output,
|
|
COALESCE(top_asins_checked, 0) AS totalProducts,
|
|
COALESCE(fba_count, 0) AS fbaCount,
|
|
COALESCE(fbm_count, 0) AS fbmCount,
|
|
COALESCE(skip_count, 0) AS skipCount,
|
|
error_message AS errorMessage,
|
|
available_asins AS availableAsins
|
|
FROM category_analysis_runs WHERE id = ?`,
|
|
)
|
|
.get(runId);
|
|
return run ?? null;
|
|
}
|
|
|
|
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 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 { where, params } = parseResultFilters(processType, runId, filters);
|
|
|
|
const allowedSort = new Set([
|
|
"asin",
|
|
"product_name",
|
|
"brand",
|
|
"category",
|
|
"current_price",
|
|
"avg_price_90d",
|
|
"sales_rank",
|
|
"seller_count",
|
|
"amazon_is_seller",
|
|
"amazon_buybox_share_pct_90d",
|
|
"monthly_sold",
|
|
"verdict",
|
|
"confidence",
|
|
"fetched_at",
|
|
]);
|
|
const orderBy = parseResultSort(
|
|
filters.get("sort"),
|
|
allowedSort,
|
|
"CAST(COALESCE(monthly_sold, 0) AS INTEGER) DESC, asin ASC",
|
|
);
|
|
|
|
const totalRow = db
|
|
.query(`SELECT COUNT(*) as total FROM ${tableName} ${where}`)
|
|
.get(...params) as { total: number };
|
|
|
|
const items = db
|
|
.query(
|
|
`SELECT
|
|
id,
|
|
run_id,
|
|
asin,
|
|
${productNameSelect},
|
|
brand,
|
|
category,
|
|
unit_cost,
|
|
current_price,
|
|
avg_price_90d,
|
|
avg_price_90d_sheet,
|
|
selling_price_sheet,
|
|
sales_rank,
|
|
${salesRankAvgSelect},
|
|
${sellerCountSelect},
|
|
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
|
|
FROM ${tableName}
|
|
${where}
|
|
ORDER BY ${orderBy}
|
|
LIMIT ? OFFSET ?`,
|
|
)
|
|
.all(...params, pageSize, offset);
|
|
|
|
return {
|
|
items,
|
|
page,
|
|
pageSize,
|
|
total: totalRow.total,
|
|
totalPages: Math.max(1, Math.ceil(totalRow.total / pageSize)),
|
|
};
|
|
}
|
|
|
|
function deleteRun(processType: ProcessType, runId: number) {
|
|
if (processType === "lead_analysis") {
|
|
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,
|
|
deletedResults: resultRows.changes,
|
|
};
|
|
}
|
|
|
|
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";
|
|
|
|
const { where, params } = parseResultFilters(processType, runId, filters);
|
|
|
|
const allowedSort = new Set([
|
|
"asin",
|
|
"product_name",
|
|
"brand",
|
|
"category",
|
|
"current_price",
|
|
"avg_price_90d",
|
|
"sales_rank",
|
|
"seller_count",
|
|
"amazon_is_seller",
|
|
"amazon_buybox_share_pct_90d",
|
|
"monthly_sold",
|
|
"verdict",
|
|
"confidence",
|
|
"fetched_at",
|
|
]);
|
|
const orderBy = parseResultSort(
|
|
filters.get("sort"),
|
|
allowedSort,
|
|
"CAST(COALESCE(monthly_sold, 0) AS INTEGER) DESC, asin ASC",
|
|
);
|
|
|
|
const rows = db
|
|
.query(
|
|
`SELECT
|
|
run_id,
|
|
asin,
|
|
${productNameSelect},
|
|
brand,
|
|
category,
|
|
unit_cost,
|
|
current_price,
|
|
avg_price_90d,
|
|
${salesRankAvgSelect},
|
|
${sellerCountSelect},
|
|
amazon_is_seller,
|
|
amazon_buybox_share_pct_90d,
|
|
monthly_sold,
|
|
sellability_status,
|
|
verdict,
|
|
confidence,
|
|
reasoning,
|
|
fetched_at
|
|
FROM ${tableName}
|
|
${where}
|
|
ORDER BY ${orderBy}`,
|
|
)
|
|
.all(...params) as Array<Record<string, unknown>>;
|
|
|
|
const headers = [
|
|
"run_id",
|
|
"asin",
|
|
"product_name",
|
|
"brand",
|
|
"category",
|
|
"unit_cost",
|
|
"current_price",
|
|
"avg_price_90d",
|
|
"sales_rank_avg_90d",
|
|
"seller_count",
|
|
"amazon_is_seller",
|
|
"amazon_buybox_share_pct_90d",
|
|
"monthly_sold",
|
|
"sellability_status",
|
|
"verdict",
|
|
"confidence",
|
|
"reasoning",
|
|
"fetched_at",
|
|
];
|
|
|
|
const lines = [headers.join(",")];
|
|
for (const row of rows) {
|
|
lines.push(headers.map((h) => escapeCsvValue(row[h])).join(","));
|
|
}
|
|
|
|
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: {
|
|
"/": index,
|
|
"/products": index,
|
|
"/runs/:processType/:runId": index,
|
|
"/api/runs": (req) => {
|
|
const url = new URL(req.url);
|
|
return json(getRuns(url.searchParams));
|
|
},
|
|
"/api/products": (req) => {
|
|
const url = new URL(req.url);
|
|
return json(getProductList(url.searchParams));
|
|
},
|
|
"/api/runs/:processType/:runId": (req) => {
|
|
const processType = req.params.processType as ProcessType;
|
|
const runId = Number(req.params.runId);
|
|
|
|
if (
|
|
!(
|
|
processType === "lead_analysis" || processType === "category_analysis"
|
|
) ||
|
|
!Number.isInteger(runId)
|
|
) {
|
|
return json({ error: "Invalid run identifier" }, 400);
|
|
}
|
|
|
|
if (req.method === "DELETE") {
|
|
const deleted = deleteRun(processType, runId);
|
|
if (!deleted.deletedRun) return json({ error: "Run not found" }, 404);
|
|
return json(deleted);
|
|
}
|
|
|
|
const run = getRun(processType, runId);
|
|
if (!run) return json({ error: "Run not found" }, 404);
|
|
|
|
const summary = {
|
|
totalProducts: (run as { totalProducts: number }).totalProducts,
|
|
fbaCount: (run as { fbaCount: number }).fbaCount,
|
|
fbmCount: (run as { fbmCount: number }).fbmCount,
|
|
skipCount: (run as { skipCount: number }).skipCount,
|
|
};
|
|
|
|
return json({ processType, ...run, summary });
|
|
},
|
|
"/api/runs/:processType/:runId/results": (req) => {
|
|
const processType = req.params.processType as ProcessType;
|
|
const runId = Number(req.params.runId);
|
|
|
|
if (
|
|
!(
|
|
processType === "lead_analysis" || processType === "category_analysis"
|
|
) ||
|
|
!Number.isInteger(runId)
|
|
) {
|
|
return json({ error: "Invalid run identifier" }, 400);
|
|
}
|
|
|
|
const url = new URL(req.url);
|
|
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)
|
|
) {
|
|
return json({ error: "Invalid run identifier" }, 400);
|
|
}
|
|
|
|
const url = new URL(req.url);
|
|
const csvText = exportRunResultsCsv(processType, runId, url.searchParams);
|
|
return csv(csvText, `run-${processType}-${runId}.csv`);
|
|
},
|
|
},
|
|
fetch() {
|
|
return json({ error: "Not found" }, 404);
|
|
},
|
|
development: {
|
|
hmr: true,
|
|
console: true,
|
|
},
|
|
});
|
|
|
|
console.log(`Results viewer running on http://localhost:${server.port}`);
|