Merge branch 'ui-ux'

This commit is contained in:
Victor Noguera
2026-04-13 02:36:50 -04:00
14 changed files with 2484 additions and 567 deletions

16
.abacusai/config.json Normal file
View File

@@ -0,0 +1,16 @@
{
"permissions": {
"allow": [
"Bash(bun install 2>&1 *)",
"Bash(bun run build:web *)",
"Bash(bun *)",
"Bash(curl *)",
"Bash(curl *)",
"Bash(curl *)",
"KillShell",
"Bash(bunx *)",
"Bash(git *)",
"Bash(ls *)"
]
}
}

2
.gitignore vendored
View File

@@ -43,3 +43,5 @@ results.db-wal
output/ output/
temp_output/ temp_output/
dist-server/

View File

@@ -7,10 +7,14 @@
"dependencies": { "dependencies": {
"amazon-sp-api": "^1.2.1", "amazon-sp-api": "^1.2.1",
"ioredis": "^5.10.1", "ioredis": "^5.10.1",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"xlsx": "^0.18.5", "xlsx": "^0.18.5",
}, },
"devDependencies": { "devDependencies": {
"@types/bun": "latest", "@types/bun": "latest",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
}, },
"peerDependencies": { "peerDependencies": {
"typescript": "^5", "typescript": "^5",
@@ -24,6 +28,10 @@
"@types/node": ["@types/node@25.5.2", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-tO4ZIRKNC+MDWV4qKVZe3Ql/woTnmHDr5JD8UI5hn2pwBrHEwOEMZK7WlNb5RKB6EoJ02gwmQS9OrjuFnZYdpg=="], "@types/node": ["@types/node@25.5.2", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-tO4ZIRKNC+MDWV4qKVZe3Ql/woTnmHDr5JD8UI5hn2pwBrHEwOEMZK7WlNb5RKB6EoJ02gwmQS9OrjuFnZYdpg=="],
"@types/react": ["@types/react@19.2.14", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w=="],
"@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="],
"adler-32": ["adler-32@1.3.1", "", {}, "sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A=="], "adler-32": ["adler-32@1.3.1", "", {}, "sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A=="],
"amazon-sp-api": ["amazon-sp-api@1.2.1", "", { "dependencies": { "csvtojson": "^2.0.14", "fast-xml-parser": "^5.3.1", "iconv-lite": "^0.7.0", "qs": "^6.14.0" } }, "sha512-zxX3KtoCDx0wxkkBgFM6qew49JJoL1XZQgUnztfp+8Im2HLHBAt4beSiDo/AkH00Gr8paHBAjdcJY6LC6ISU7w=="], "amazon-sp-api": ["amazon-sp-api@1.2.1", "", { "dependencies": { "csvtojson": "^2.0.14", "fast-xml-parser": "^5.3.1", "iconv-lite": "^0.7.0", "qs": "^6.14.0" } }, "sha512-zxX3KtoCDx0wxkkBgFM6qew49JJoL1XZQgUnztfp+8Im2HLHBAt4beSiDo/AkH00Gr8paHBAjdcJY6LC6ISU7w=="],
@@ -42,6 +50,8 @@
"crc-32": ["crc-32@1.2.2", "", { "bin": { "crc32": "bin/crc32.njs" } }, "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ=="], "crc-32": ["crc-32@1.2.2", "", { "bin": { "crc32": "bin/crc32.njs" } }, "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ=="],
"csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
"csvtojson": ["csvtojson@2.0.14", "", { "dependencies": { "lodash": "^4.17.21" }, "bin": { "csvtojson": "bin/csvtojson" } }, "sha512-F7NNvhhDyob7OsuEGRsH0FM1aqLs/WYITyza3l+hTEEmOK9sGPBlYQZwlVG0ezCojXYpE17lhS5qL6BCOZSPyA=="], "csvtojson": ["csvtojson@2.0.14", "", { "dependencies": { "lodash": "^4.17.21" }, "bin": { "csvtojson": "bin/csvtojson" } }, "sha512-F7NNvhhDyob7OsuEGRsH0FM1aqLs/WYITyza3l+hTEEmOK9sGPBlYQZwlVG0ezCojXYpE17lhS5qL6BCOZSPyA=="],
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
@@ -94,12 +104,18 @@
"qs": ["qs@6.15.1", "", { "dependencies": { "side-channel": "^1.1.0" } }, "sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg=="], "qs": ["qs@6.15.1", "", { "dependencies": { "side-channel": "^1.1.0" } }, "sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg=="],
"react": ["react@19.2.5", "", {}, "sha512-llUJLzz1zTUBrskt2pwZgLq59AemifIftw4aB7JxOqf1HY2FDaGDxgwpAPVzHU1kdWabH7FauP4i1oEeer2WCA=="],
"react-dom": ["react-dom@19.2.5", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.5" } }, "sha512-J5bAZz+DXMMwW/wV3xzKke59Af6CHY7G4uYLN1OvBcKEsWOs4pQExj86BBKamxl/Ik5bx9whOrvBlSDfWzgSag=="],
"redis-errors": ["redis-errors@1.2.0", "", {}, "sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w=="], "redis-errors": ["redis-errors@1.2.0", "", {}, "sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w=="],
"redis-parser": ["redis-parser@3.0.0", "", { "dependencies": { "redis-errors": "^1.0.0" } }, "sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A=="], "redis-parser": ["redis-parser@3.0.0", "", { "dependencies": { "redis-errors": "^1.0.0" } }, "sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A=="],
"safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="], "safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="],
"scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="],
"side-channel": ["side-channel@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3", "side-channel-list": "^1.0.0", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" } }, "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw=="], "side-channel": ["side-channel@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3", "side-channel-list": "^1.0.0", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" } }, "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw=="],
"side-channel-list": ["side-channel-list@1.0.1", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.4" } }, "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w=="], "side-channel-list": ["side-channel-list@1.0.1", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.4" } }, "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w=="],

View File

@@ -1,17 +1,28 @@
{ {
"name": "asin-check", "name": "asin-check",
"module": "src/index.ts", "module": "src/index.ts",
"type": "module", "type": "module",
"private": true, "private": true,
"devDependencies": { "scripts": {
"@types/bun": "latest" "bestsellers": "bun run src/bestsellers-by-category.ts",
}, "start": "bun run src/index.ts",
"peerDependencies": { "start:web": "bun --hot src/server.ts",
"typescript": "^5" "build:web": "bun build src/web/index.html --outdir dist",
}, "test": "bun test"
"dependencies": { },
"amazon-sp-api": "^1.2.1", "devDependencies": {
"ioredis": "^5.10.1", "@types/bun": "latest",
"xlsx": "^0.18.5" "@types/react": "^19.2.14",
} "@types/react-dom": "^19.2.3"
} },
"peerDependencies": {
"typescript": "^5"
},
"dependencies": {
"amazon-sp-api": "^1.2.1",
"ioredis": "^5.10.1",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"xlsx": "^0.18.5"
}
}

View File

@@ -1,17 +1,51 @@
import { test, expect, beforeAll, afterAll, beforeEach, mock } from "bun:test"; import { test, expect, beforeAll, afterAll, beforeEach, mock } from "bun:test";
import { Database } from "bun:sqlite"; import { Database } from "bun:sqlite";
import { getDb, initDb, closeDb } from "./database"; import { getDb, initDb, closeDb } from "./database.ts";
import path from "node:path"; import path from "node:path";
import { rmSync, mkdirSync } from "node:fs"; import { rmSync, mkdirSync } from "node:fs";
import {
main, const fetchSellabilityBatchMock = mock(async (asins: string[]) => {
processCategory, return new Map(
insertCategoryRunSummary, asins.map((asin) => [
insertProductAnalysisResults, asin,
} from "./bestsellers-by-category"; {
import * as keepaModule from "./keepa"; canSell: true,
import * as spApiModule from "./sp-api"; sellabilityStatus: "available" as const,
import * as llmModule from "./llm"; sellabilityReason: "ok",
},
]),
);
});
const fetchSpApiPricingAndFeesMock = mock(async () => ({
fbaFee: 4,
fbmFee: 2,
referralFeePercent: 15,
estimatedSalePrice: 25,
canSell: true,
sellabilityStatus: "available" as const,
sellabilityReason: "ok",
}));
const analyzeProductsMock = mock(async (products: any[]) => {
return products.map((p, idx) => ({
asin: p.record.asin,
verdict: idx === 0 ? "FBA" : "FBM",
confidence: 90,
reasoning: "mocked",
}));
});
mock.module("./sp-api.ts", () => ({
fetchSellabilityBatch: fetchSellabilityBatchMock,
fetchSpApiPricingAndFees: fetchSpApiPricingAndFeesMock,
}));
mock.module("./llm.ts", () => ({
analyzeProducts: analyzeProductsMock,
}));
const modulePromise = import("./bestsellers-by-category.ts");
const DB_TEST_PATH = path.join( const DB_TEST_PATH = path.join(
process.cwd(), process.cwd(),
@@ -20,24 +54,81 @@ const DB_TEST_PATH = path.join(
); );
let db: Database; let db: Database;
let processCategory: (db: Database, runId: number, category: any, perCategoryTop: number) => Promise<any>;
let insertCategoryRunSummary: (db: Database, summary: any, runTimestamp: string) => Promise<number>;
let originalFetch: typeof globalThis.fetch;
beforeAll(async () => {
const mod = await modulePromise;
processCategory = mod.processCategory;
insertCategoryRunSummary = mod.insertCategoryRunSummary;
beforeAll(() => {
// Ensure the test output directory exists and is clean
rmSync(path.dirname(DB_TEST_PATH), { recursive: true, force: true }); rmSync(path.dirname(DB_TEST_PATH), { recursive: true, force: true });
mkdirSync(path.dirname(DB_TEST_PATH), { recursive: true }); mkdirSync(path.dirname(DB_TEST_PATH), { recursive: true });
initDb(DB_TEST_PATH); initDb(DB_TEST_PATH);
db = getDb(DB_TEST_PATH); db = getDb(DB_TEST_PATH);
originalFetch = globalThis.fetch;
}); });
afterAll(() => { afterAll(() => {
globalThis.fetch = originalFetch;
closeDb(); closeDb();
rmSync(path.dirname(DB_TEST_PATH), { recursive: true, force: true }); rmSync(path.dirname(DB_TEST_PATH), { recursive: true, force: true });
}); });
beforeEach(() => { beforeEach(() => {
// Clear tables before each test if necessary, or use a fresh DB for each test db.run("DELETE FROM product_analysis_results");
// For simplicity, we'll assume tables are clean after initDb in beforeAll db.run("DELETE FROM category_analysis_runs");
// and not clear for each test if data is not interdependent.
globalThis.fetch = mock(async (input: string | URL | Request) => {
const rawUrl =
typeof input === "string"
? input
: input instanceof URL
? input.toString()
: input.url;
const url = new URL(rawUrl);
if (url.pathname === "/bestsellers") {
return new Response(
JSON.stringify({
bestSellersList: ["B000000001", "B000000002"],
tokensLeft: 10,
refillRate: 1,
}),
{ status: 200 },
);
}
if (url.pathname === "/product") {
return new Response(
JSON.stringify({
products: [
{
asin: "B000000001",
title: "Product One",
stats: { current: [null, null, null, 1000, null, null, null, null, null, null, null, 2, null, null, null, null, null, null, 2599], avg: [2400, null, null, 1200] },
csv: [[1, 2599]],
categoryTree: [{ name: "Category 1" }],
},
{
asin: "B000000002",
title: "Product Two",
stats: { current: [null, null, null, 2000, null, null, null, null, null, null, null, 3, null, null, null, null, null, null, 1999], avg: [1800, null, null, 2200] },
csv: [[1, 1999]],
categoryTree: [{ name: "Category 1" }],
},
],
tokensLeft: 10,
refillRate: 1,
}),
{ status: 200 },
);
}
return new Response("not found", { status: 404 });
}) as unknown as typeof globalThis.fetch;
}); });
test("processCategory function test", async () => { test("processCategory function test", async () => {
@@ -48,31 +139,21 @@ test("processCategory function test", async () => {
childCount: 0, childCount: 0,
}; };
const summary = await processCategory(db, mockCategory, 2); const runId = await insertCategoryRunSummary(db, {
categoryId: mockCategory.id,
categoryLabel: mockCategory.label,
topAsinsChecked: 0,
availableAsins: 0,
fba: 0,
fbm: 0,
skip: 0,
status: "running",
error: "",
results: [],
}, new Date().toISOString());
const summary = await processCategory(db, runId, mockCategory, 2);
expect(summary.status).toBe("ok"); const categoryRun = db.query("SELECT * FROM category_analysis_runs").all() as any[];
expect(summary.categoryId).toBe(mockCategory.id);
expect(summary.categoryLabel).toBe(mockCategory.label);
expect(summary.topAsinsChecked).toBe(2);
expect(summary.availableAsins).toBe(2);
expect(summary.fba).toBe(1);
expect(summary.fbm).toBe(1);
expect(summary.skip).toBe(0);
expect(summary.results?.length).toBe(2);
const runId = await insertCategoryRunSummary(
db,
summary,
new Date().toISOString(),
);
if (summary.results) {
await insertProductAnalysisResults(db, runId, summary.results);
}
// Verify category run summary insertion
const categoryRun = db
.query("SELECT * FROM category_analysis_runs")
.all() as any[];
expect(categoryRun.length).toBe(1); expect(categoryRun.length).toBe(1);
expect(categoryRun[0].category_label).toBe("Category 1"); expect(categoryRun[0].category_label).toBe("Category 1");
expect(categoryRun[0].top_asins_checked).toBe(2); expect(categoryRun[0].top_asins_checked).toBe(2);
@@ -81,10 +162,7 @@ test("processCategory function test", async () => {
expect(categoryRun[0].fbm_count).toBe(1); expect(categoryRun[0].fbm_count).toBe(1);
expect(categoryRun[0].status).toBe("ok"); expect(categoryRun[0].status).toBe("ok");
// Verify product analysis results insertion const productResults = db.query("SELECT * FROM product_analysis_results ORDER BY asin").all() as any[];
const productResults = db
.query("SELECT * FROM product_analysis_results ORDER BY asin")
.all() as any[];
expect(productResults.length).toBe(2); expect(productResults.length).toBe(2);
expect(productResults[0].asin).toBe("B000000001"); expect(productResults[0].asin).toBe("B000000001");

View File

@@ -13,6 +13,7 @@ import type {
SellabilityInfo, SellabilityInfo,
SpApiData, SpApiData,
} from "./types.ts"; } from "./types.ts";
type CategoryInfo = { type CategoryInfo = {
id: number; id: number;
@@ -36,7 +37,7 @@ type CategoryRunSummary = {
fba: number; fba: number;
fbm: number; fbm: number;
skip: number; skip: number;
status: "ok" | "empty" | "failed"; status: "running" | "ok" | "empty" | "failed";
error: string; error: string;
runId?: number; runId?: number;
results?: AnalysisResult[]; results?: AnalysisResult[];
@@ -158,6 +159,37 @@ export async function insertCategoryRunSummary(
return Number(result.lastInsertRowid); return Number(result.lastInsertRowid);
} }
export async function updateCategoryRunSummary(
db: Database,
runId: number,
summary: Pick<CategoryRunSummary, "topAsinsChecked" | "availableAsins" | "fba" | "fbm" | "skip" | "status" | "error">,
): Promise<void> {
db.run(
`
UPDATE category_analysis_runs
SET
top_asins_checked = ?,
available_asins = ?,
fba_count = ?,
fbm_count = ?,
skip_count = ?,
status = ?,
error_message = ?
WHERE id = ?
`,
[
summary.topAsinsChecked,
summary.availableAsins,
summary.fba,
summary.fbm,
summary.skip,
summary.status,
summary.error,
runId,
],
);
}
export async function insertProductAnalysisResults( export async function insertProductAnalysisResults(
db: Database, db: Database,
runId: number, runId: number,
@@ -817,16 +849,96 @@ function buildEnrichedProducts(
}); });
} }
async function runLlmInBatches( export async function processCategory(
products: EnrichedProduct[], db: Database,
): Promise<LlmVerdict[]> { runId: number,
const verdicts: LlmVerdict[] = []; category: CategoryInfo,
perCategoryTop: number,
): Promise<CategoryRunSummary> {
log("info", `\nCategory ${category.label} (${category.id})`);
for (let i = 0; i < products.length; i += LLM_BATCH_SIZE) { const topAsins = await fetchCategoryBestSellerAsins(category, perCategoryTop);
const batch = products.slice(i, i + LLM_BATCH_SIZE); if (topAsins.length === 0) {
log("info", " Keepa returned no ASINs for this category.");
await updateCategoryRunSummary(db, runId, {
topAsinsChecked: 0,
availableAsins: 0,
fba: 0,
fbm: 0,
skip: 0,
status: "empty",
error: "No ASINs returned by Keepa",
});
return {
categoryId: category.id,
categoryLabel: category.label,
topAsinsChecked: 0,
availableAsins: 0,
fba: 0,
fbm: 0,
skip: 0,
status: "empty",
error: "No ASINs returned by Keepa",
results: [],
};
}
const uniqueTopAsins = Array.from(new Set(topAsins));
if (uniqueTopAsins.length !== topAsins.length) {
log("warn", ` Removed ${topAsins.length - uniqueTopAsins.length} duplicate ASINs before analysis.`);
}
log("info", ` Top ASINs fetched: ${uniqueTopAsins.length}`);
const sellabilityMap = await fetchSellabilityMap(uniqueTopAsins);
const availableAsins = uniqueTopAsins.filter((asin) => {
const info = sellabilityMap.get(asin);
return info?.canSell === true && info.sellabilityStatus === "available";
});
log("info", ` Sellable ASINs: ${availableAsins.length}/${uniqueTopAsins.length}`);
if (availableAsins.length === 0) {
await updateCategoryRunSummary(db, runId, {
topAsinsChecked: uniqueTopAsins.length,
availableAsins: 0,
fba: 0,
fbm: 0,
skip: 0,
status: "empty",
error: "No sellable ASINs",
});
return {
categoryId: category.id,
categoryLabel: category.label,
topAsinsChecked: uniqueTopAsins.length,
availableAsins: 0,
fba: 0,
fbm: 0,
skip: 0,
status: "empty",
error: "No sellable ASINs",
results: [],
};
}
const keepaEnrichment = await fetchKeepaEnrichmentMap(availableAsins);
const spApiMap = await fetchSpApiMap(availableAsins, sellabilityMap);
const enrichedProducts = buildEnrichedProducts(
availableAsins,
sellabilityMap,
spApiMap,
keepaEnrichment,
);
const results: AnalysisResult[] = [];
let fba = 0;
let fbm = 0;
let skip = 0;
const totalBatches = Math.ceil(enrichedProducts.length / LLM_BATCH_SIZE);
for (let i = 0; i < enrichedProducts.length; i += LLM_BATCH_SIZE) {
const batch = enrichedProducts.slice(i, i + LLM_BATCH_SIZE);
const batchNum = Math.floor(i / LLM_BATCH_SIZE) + 1; const batchNum = Math.floor(i / LLM_BATCH_SIZE) + 1;
const totalBatches = Math.ceil(products.length / LLM_BATCH_SIZE);
log("info", ` LLM batch ${batchNum}/${totalBatches}...`); log("info", ` LLM batch ${batchNum}/${totalBatches}...`);
let batchVerdicts: LlmVerdict[]; let batchVerdicts: LlmVerdict[];
@@ -843,116 +955,64 @@ async function runLlmInBatches(
})); }));
} }
verdicts.push(...batchVerdicts); const verdictByAsin = new Map(batchVerdicts.map((v) => [v.asin, v]));
const batchResults: AnalysisResult[] = batch.map((product) => ({
product,
verdict: verdictByAsin.get(product.record.asin) ?? {
asin: product.record.asin,
verdict: "SKIP",
confidence: 0,
reasoning: "LLM returned no verdict",
},
}));
if (i + LLM_BATCH_SIZE < products.length) { await insertProductAnalysisResults(db, runId, batchResults);
for (const result of batchResults) {
results.push(result);
if (result.verdict.verdict === "FBA") {
fba++;
} else if (result.verdict.verdict === "FBM") {
fbm++;
} else {
skip++;
}
}
await updateCategoryRunSummary(db, runId, {
topAsinsChecked: uniqueTopAsins.length,
availableAsins: availableAsins.length,
fba,
fbm,
skip,
status: "running",
error: "",
});
log(
"info",
` Persisted batch ${batchNum}/${totalBatches} (${batchResults.length} rows, totals FBA/FBM/SKIP=${fba}/${fbm}/${skip})`,
);
if (i + LLM_BATCH_SIZE < enrichedProducts.length) {
await sleep(1500); await sleep(1500);
} }
} }
return verdicts; await updateCategoryRunSummary(db, runId, {
} topAsinsChecked: uniqueTopAsins.length,
availableAsins: availableAsins.length,
export async function processCategory( fba,
db: Database, fbm,
category: CategoryInfo, skip,
perCategoryTop: number, status: "ok",
): Promise<CategoryRunSummary> { error: "",
log("info", `\nCategory ${category.label} (${category.id})`);
const topAsins = await fetchCategoryBestSellerAsins(category, perCategoryTop);
if (topAsins.length === 0) {
log("info", " Keepa returned no ASINs for this category.");
return {
categoryId: category.id,
categoryLabel: category.label,
topAsinsChecked: 0,
availableAsins: 0,
fba: 0,
fbm: 0,
skip: 0,
status: "empty",
error: "No ASINs returned by Keepa",
results: [],
};
}
log("info", ` Top ASINs fetched: ${topAsins.length}`);
const sellabilityMap = await fetchSellabilityMap(topAsins);
const availableAsins = topAsins.filter((asin) => {
const info = sellabilityMap.get(asin);
return info?.canSell === true && info.sellabilityStatus === "available";
}); });
log("info", ` Sellable ASINs: ${availableAsins.length}/${topAsins.length}`);
if (availableAsins.length === 0) {
return {
categoryId: category.id,
categoryLabel: category.label,
topAsinsChecked: topAsins.length,
availableAsins: 0,
fba: 0,
fbm: 0,
skip: 0,
status: "empty",
error: "No sellable ASINs",
results: [],
};
}
const keepaEnrichment = await fetchKeepaEnrichmentMap(availableAsins);
const spApiMap = await fetchSpApiMap(availableAsins, sellabilityMap);
const titleByAsin = new Map<string, string>();
const keepaMap = new Map<string, KeepaData>();
for (const asin of availableAsins) {
const enriched = keepaEnrichment.get(asin);
if (enriched?.title) {
titleByAsin.set(asin, enriched.title);
}
if (enriched?.keepa) {
keepaMap.set(asin, enriched.keepa);
}
}
const enrichedProducts = buildEnrichedProducts(
availableAsins,
sellabilityMap,
spApiMap,
keepaEnrichment,
);
const verdicts = await runLlmInBatches(enrichedProducts);
const verdictByAsin = new Map(verdicts.map((v) => [v.asin, v]));
const results: AnalysisResult[] = enrichedProducts.map((product) => ({
product,
verdict: verdictByAsin.get(product.record.asin) ?? {
asin: product.record.asin,
verdict: "SKIP",
confidence: 0,
reasoning: "LLM returned no verdict",
},
}));
// No longer writing to XLSX, directly insert into DB
// const outputName = `${sanitizeFileSegment(category.label)}_${category.id}.xlsx`;
// const outputPath = path.join(outputDir, outputName);
// writeCategoryResultsWorkbook(results, outputPath);
// The categoryRunId will be provided by the main function after inserting the summary
// We need to pass it here or get it after inserting the summary in main.
// For now, let's assume it's handled in main.
const fba = results.filter((r) => r.verdict.verdict === "FBA").length;
const fbm = results.filter((r) => r.verdict.verdict === "FBM").length;
const skip = results.filter((r) => r.verdict.verdict === "SKIP").length;
return { return {
categoryId: category.id, categoryId: category.id,
categoryLabel: category.label, categoryLabel: category.label,
topAsinsChecked: topAsins.length, topAsinsChecked: uniqueTopAsins.length,
availableAsins: availableAsins.length, availableAsins: availableAsins.length,
fba, fba,
fbm, fbm,
@@ -968,7 +1028,7 @@ export async function main(): Promise<void> {
assertSpApiPrerequisites(); assertSpApiPrerequisites();
mkdirSync(args.outputDir, { recursive: true }); mkdirSync(args.outputDir, { recursive: true });
const DB_PATH = path.join(args.outputDir, "analysis.sqlite"); 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);
@@ -1001,23 +1061,33 @@ export async function main(): Promise<void> {
for (const category of allowedCategories) { for (const category of allowedCategories) {
let categorySummary: CategoryRunSummary; let categorySummary: CategoryRunSummary;
let runId: number | undefined;
try { try {
runId = await insertCategoryRunSummary(
db,
{
categoryId: category.id,
categoryLabel: category.label,
topAsinsChecked: 0,
availableAsins: 0,
fba: 0,
fbm: 0,
skip: 0,
status: "running",
error: "",
results: [],
},
runTimestamp,
);
categorySummary = await processCategory( categorySummary = await processCategory(
db, db,
runId,
category, category,
args.perCategoryTop, args.perCategoryTop,
); );
const runId = await insertCategoryRunSummary( totalInsertedAsins += categorySummary.results?.length ?? 0;
db,
categorySummary,
runTimestamp,
);
if (categorySummary.results) {
await insertProductAnalysisResults(db, runId, categorySummary.results);
totalInsertedAsins += categorySummary.results.length;
}
processedCategories++; processedCategories++;
allCategorySummaries.push({ ...categorySummary, runId }); allCategorySummaries.push({ ...categorySummary, runId });
@@ -1039,8 +1109,19 @@ export async function main(): Promise<void> {
error: message, error: message,
results: [], results: [],
}; };
if (runId) {
await updateCategoryRunSummary(db, runId, {
topAsinsChecked: 0,
availableAsins: 0,
fba: 0,
fbm: 0,
skip: 0,
status: "failed",
error: message,
});
}
processedCategories++; processedCategories++;
allCategorySummaries.push(categorySummary); allCategorySummaries.push({ ...categorySummary, runId });
} }
} }

View File

@@ -19,6 +19,84 @@ export function closeDb(): void {
} }
} }
function createProductAnalysisResultsTable(database: Database): void {
database.run(`
CREATE TABLE IF NOT EXISTS product_analysis_results (
id INTEGER PRIMARY KEY AUTOINCREMENT,
asin TEXT NOT NULL,
run_id INTEGER NOT NULL,
name TEXT NOT NULL,
brand TEXT,
category TEXT,
unit_cost REAL,
current_price REAL,
avg_price_90d REAL,
avg_price_90d_sheet REAL,
selling_price_sheet REAL,
sales_rank INTEGER,
sales_rank_avg_90d INTEGER,
seller_count INTEGER,
monthly_sold INTEGER,
rank_drops_30d INTEGER,
rank_drops_90d INTEGER,
fba_fee REAL,
fbm_fee REAL,
referral_percent REAL,
can_sell TEXT,
sellability_status TEXT,
sellability_reason TEXT,
verdict TEXT NOT NULL,
confidence REAL NOT NULL,
reasoning TEXT,
fetched_at TEXT NOT NULL,
UNIQUE(run_id, asin),
FOREIGN KEY (run_id) REFERENCES category_analysis_runs(id)
);
`);
}
function ensureProductAnalysisResultsTable(database: Database): void {
const tableInfo = database
.query("PRAGMA table_info(product_analysis_results)")
.all() as Array<{ name: string; pk: number }>;
if (tableInfo.length === 0) {
createProductAnalysisResultsTable(database);
return;
}
const hasIdColumn = tableInfo.some((col) => col.name === "id");
const hasAsinPrimaryKey = tableInfo.some(
(col) => col.name === "asin" && col.pk === 1,
);
if (!hasIdColumn || hasAsinPrimaryKey) {
database.run("ALTER TABLE product_analysis_results RENAME TO product_analysis_results_legacy");
createProductAnalysisResultsTable(database);
database.run(`
INSERT INTO product_analysis_results (
asin, run_id, name, brand, category, unit_cost,
current_price, avg_price_90d, avg_price_90d_sheet,
selling_price_sheet, sales_rank, sales_rank_avg_90d,
seller_count, monthly_sold, rank_drops_30d, rank_drops_90d,
fba_fee, fbm_fee, referral_percent, can_sell,
sellability_status, sellability_reason,
verdict, confidence, reasoning, fetched_at
)
SELECT
asin, run_id, name, brand, category, unit_cost,
current_price, avg_price_90d, avg_price_90d_sheet,
selling_price_sheet, sales_rank, sales_rank_avg_90d,
seller_count, monthly_sold, rank_drops_30d, rank_drops_90d,
fba_fee, fbm_fee, referral_percent, can_sell,
sellability_status, sellability_reason,
verdict, confidence, reasoning, fetched_at
FROM product_analysis_results_legacy
`);
database.run("DROP TABLE product_analysis_results_legacy");
}
}
export function initDb(dbPath: string): void { export function initDb(dbPath: string): void {
const database = getDb(dbPath); const database = getDb(dbPath);
database.run(` database.run(`
@@ -80,35 +158,18 @@ export function initDb(dbPath: string): void {
error_message TEXT error_message TEXT
); );
`); `);
database.run(` ensureProductAnalysisResultsTable(database);
CREATE TABLE IF NOT EXISTS product_analysis_results (
asin TEXT PRIMARY KEY, database.run(`CREATE INDEX IF NOT EXISTS idx_runs_timestamp ON runs(timestamp DESC);`);
run_id INTEGER NOT NULL, database.run(`CREATE INDEX IF NOT EXISTS idx_results_run_id ON results(run_id);`);
name TEXT NOT NULL, database.run(`CREATE INDEX IF NOT EXISTS idx_results_verdict ON results(verdict);`);
brand TEXT, database.run(`CREATE INDEX IF NOT EXISTS idx_results_sellability_status ON results(sellability_status);`);
category TEXT, database.run(`CREATE INDEX IF NOT EXISTS idx_results_fetched_at ON results(fetched_at DESC);`);
unit_cost REAL, database.run(`CREATE INDEX IF NOT EXISTS idx_results_asin ON results(asin);`);
current_price REAL, database.run(`CREATE INDEX IF NOT EXISTS idx_category_runs_timestamp ON category_analysis_runs(run_timestamp DESC);`);
avg_price_90d REAL, database.run(`CREATE INDEX IF NOT EXISTS idx_category_runs_status ON category_analysis_runs(status);`);
avg_price_90d_sheet REAL, database.run(`CREATE INDEX IF NOT EXISTS idx_product_results_run_id ON product_analysis_results(run_id);`);
selling_price_sheet REAL, database.run(`CREATE INDEX IF NOT EXISTS idx_product_results_verdict ON product_analysis_results(verdict);`);
sales_rank INTEGER, database.run(`CREATE INDEX IF NOT EXISTS idx_product_results_sellability_status ON product_analysis_results(sellability_status);`);
sales_rank_avg_90d INTEGER, database.run(`CREATE INDEX IF NOT EXISTS idx_product_results_fetched_at ON product_analysis_results(fetched_at DESC);`);
seller_count INTEGER,
monthly_sold INTEGER,
rank_drops_30d INTEGER,
rank_drops_90d INTEGER,
fba_fee REAL,
fbm_fee REAL,
referral_percent REAL,
can_sell TEXT,
sellability_status TEXT,
sellability_reason TEXT,
verdict TEXT NOT NULL,
confidence REAL NOT NULL,
reasoning TEXT,
fetched_at TEXT NOT NULL,
FOREIGN KEY (run_id) REFERENCES category_analysis_runs(id)
);
`);
} }

View File

@@ -1,333 +1,297 @@
import { readProducts } from "./reader.ts"; import { readProducts } from "./reader.ts";
import { fetchKeepaDataBatch } from "./keepa.ts"; import { fetchKeepaDataBatch } from "./keepa.ts";
import { fetchSellabilityBatch, fetchSpApiPricingAndFees } from "./sp-api.ts"; import { fetchSellabilityBatch, fetchSpApiPricingAndFees } from "./sp-api.ts";
import { connectCache, getCache, setCache, disconnectCache } from "./cache.ts"; import { connectCache, getCache, setCache, disconnectCache } from "./cache.ts";
import { analyzeProducts } from "./llm.ts"; import { analyzeProducts } from "./llm.ts";
import { printResults, writeResultsToDb } from "./writer.ts"; import { printResults, writeResultsToDb } from "./writer.ts";
import { initDb, closeDb } from "./database.ts"; import { initDb, closeDb } from "./database.ts";
import path from "node:path";
const DB_PATH = "./results.db"; import type {
import type { EnrichedProduct,
EnrichedProduct, AnalysisResult,
AnalysisResult, KeepaData,
KeepaData, ProductRecord,
ProductRecord, SellabilityInfo,
SellabilityInfo, SpApiData,
SpApiData, } from "./types.ts";
} from "./types.ts";
const DB_PATH = "./results.db";
const LLM_BATCH_SIZE = 5; const LLM_BATCH_SIZE = 5;
const INPUT_BATCH_SIZE = 50; const INPUT_BATCH_SIZE = 50;
function parseArgs(): { inputFile: string; outputFile?: string } { function parseArgs(): { inputFile: string; outputFile?: string } {
const args = process.argv.slice(2); const args = process.argv.slice(2);
const inputFile = args.find((a) => !a.startsWith("--")); const inputFile = args.find((a) => !a.startsWith("--"));
const outIdx = args.indexOf("--out"); const outIdx = args.indexOf("--out");
const outputFile = outIdx !== -1 ? args[outIdx + 1] : undefined; const outputFile = outIdx !== -1 ? args[outIdx + 1] : undefined;
if (!inputFile) { if (!inputFile) {
console.error( console.error(
"Usage: bun run src/index.ts <input.csv|xlsx> [--out results.csv]", "Usage: bun run src/index.ts <input.csv|xlsx> [--out results.csv]",
); );
process.exit(1); process.exit(1);
} }
return { inputFile, outputFile };
} return { inputFile, outputFile };
}
async function main() {
const { inputFile, outputFile } = parseArgs(); function chunkArray<T>(items: T[], chunkSize: number): T[][] {
const chunks: T[][] = [];
console.log("Connecting to Redis..."); for (let i = 0; i < items.length; i += chunkSize) {
await connectCache(); chunks.push(items.slice(i, i + chunkSize));
}
// Initialize SQLite DB return chunks;
console.log("Initializing SQLite database..."); }
initDb(DB_PATH);
function resolveBaseOutputPath(inputFile: string, outputFile?: string): string {
// Phase 1: Read input file if (outputFile) return outputFile;
console.log(`\nReading ${inputFile}...`);
const products = readProducts(inputFile); const parsedInput = path.parse(inputFile);
return path.join(parsedInput.dir, `${parsedInput.name}_results.xlsx`);
if (products.length === 0) { }
console.error("No valid products found in input file.");
process.exit(1); async function processProductChunk(
} products: ProductRecord[],
): Promise<AnalysisResult[]> {
// Phase 2: Check cache for all ASINs console.log(`\nChecking cache for ${products.length} products...`);
console.log(`\nChecking cache for ${products.length} products...`); const cached = new Map<string, EnrichedProduct>();
const cached = new Map<string, EnrichedProduct>(); const excludedCachedAsins = new Set<string>();
const excludedCachedAsins = new Set<string>(); const uncachedProducts: ProductRecord[] = [];
const uncachedProducts: ProductRecord[] = [];
for (const p of products) {
for (const p of products) { const hit = await getCache(p.asin);
const hit = await getCache(p.asin); if (hit) {
if (hit) { if (hit.spApi.sellabilityStatus === "available") {
if (hit.spApi.sellabilityStatus === "available") { console.log(` [cache hit] ${p.asin}`);
console.log(` [cache hit] ${p.asin}`); cached.set(p.asin, hit);
cached.set(p.asin, hit); } else {
} else { excludedCachedAsins.add(p.asin);
excludedCachedAsins.add(p.asin); console.log(
console.log( ` [exclude cached] ${p.asin} — status=${hit.spApi.sellabilityStatus}`,
` [exclude cached] ${p.asin} — status=${hit.spApi.sellabilityStatus}`, );
); }
} } else {
} else { uncachedProducts.push(p);
uncachedProducts.push(p); }
} }
}
console.log( console.log(
`${cached.size} cached available, ${excludedCachedAsins.size} cached excluded, ${uncachedProducts.length} to fetch`, `${cached.size} cached available, ${excludedCachedAsins.size} cached excluded, ${uncachedProducts.length} to fetch`,
); );
// Phase 3: Sellability gate — check uncached ASINs before anything else const sellabilityMap = new Map<string, SellabilityInfo>();
const sellabilityMap = new Map<string, SellabilityInfo>(); const availableProducts: ProductRecord[] = [];
const availableProducts: ProductRecord[] = []; const unavailableProducts: ProductRecord[] = [];
const unavailableProducts: ProductRecord[] = [];
if (uncachedProducts.length > 0) {
if (uncachedProducts.length > 0) { console.log(
console.log( `\nChecking sellability for ${uncachedProducts.length} ASINs...`,
`\nChecking sellability for ${uncachedProducts.length} ASINs...`, );
); const sellResults = await fetchSellabilityBatch(
const sellResults = await fetchSellabilityBatch( uncachedProducts.map((p) => p.asin),
uncachedProducts.map((p) => p.asin), );
);
for (const p of uncachedProducts) {
for (const p of uncachedProducts) { const info = sellResults.get(p.asin) ?? {
const info = sellResults.get(p.asin) ?? { canSell: null,
canSell: null, sellabilityStatus: "unknown" as const,
sellabilityStatus: "unknown" as const, sellabilityReason: "Sellability check returned no result",
sellabilityReason: "Sellability check returned no result", };
}; sellabilityMap.set(p.asin, info);
sellabilityMap.set(p.asin, info);
if (info.sellabilityStatus === "available") {
// Keep only ASINs that are explicitly available. availableProducts.push(p);
if (info.sellabilityStatus === "available") { console.log(` [available] ${p.asin} — status=${info.sellabilityStatus}`);
availableProducts.push(p); } else {
console.log( unavailableProducts.push(p);
` [available] ${p.asin} — status=${info.sellabilityStatus}`, console.log(
); ` [exclude] ${p.asin} — status=${info.sellabilityStatus}, reason=${info.sellabilityReason ?? "n/a"}`,
} else { );
unavailableProducts.push(p); }
console.log( }
` [exclude] ${p.asin} — status=${info.sellabilityStatus}, reason=${info.sellabilityReason ?? "n/a"}`,
); console.log(
} `\nSellability gate: ${availableProducts.length} available, ${unavailableProducts.length} excluded`,
} );
}
console.log(
`\nSellability gate: ${availableProducts.length} available, ${unavailableProducts.length} excluded`, let keepaResults = new Map<string, KeepaData>();
); if (availableProducts.length > 0) {
} console.log(`\nFetching ${availableProducts.length} ASINs from Keepa...`);
try {
// Phase 4: Keepa batch fetch — only for available (uncached) ASINs keepaResults = await fetchKeepaDataBatch(
let keepaResults = new Map<string, KeepaData>(); availableProducts.map((p) => p.asin),
if (availableProducts.length > 0) { );
console.log(`\nFetching ${availableProducts.length} ASINs from Keepa...`); } catch (err) {
try { console.warn(`Keepa batch fetch failed: ${err}`);
keepaResults = await fetchKeepaDataBatch( }
availableProducts.map((p) => p.asin), }
);
} catch (err) { console.log(
console.warn(`Keepa batch fetch failed: ${err}`); `\nFetching pricing & fees for ${availableProducts.length} ASINs...`,
} );
} const spApiResults = new Map<string, SpApiData>();
const pricingQueue = [...availableProducts];
// Phase 5: SP-API pricing + fees — only for available ASINs let pricingDone = 0;
console.log(
`\nFetching pricing & fees for ${availableProducts.length} ASINs...`, async function fetchNextPricing(): Promise<void> {
); while (pricingQueue.length > 0) {
const spApiResults = new Map<string, SpApiData>(); const p = pricingQueue.shift()!;
const sellability = sellabilityMap.get(p.asin)!;
// Concurrency-limited pricing+fees fetches const spApi = await fetchSpApiPricingAndFees(p.asin, sellability);
const pricingQueue = [...availableProducts];
let pricingDone = 0; const keepa = keepaResults.get(p.asin);
if (keepa?.currentPrice && spApi.estimatedSalePrice === 0) {
async function fetchNextPricing(): Promise<void> { spApi.estimatedSalePrice = keepa.currentPrice;
while (pricingQueue.length > 0) { }
const p = pricingQueue.shift()!;
const sellability = sellabilityMap.get(p.asin)!; spApiResults.set(p.asin, spApi);
const spApi = await fetchSpApiPricingAndFees(p.asin, sellability); pricingDone++;
if (pricingDone % 10 === 0 || pricingDone === availableProducts.length) {
const keepa = keepaResults.get(p.asin); console.log(
if (keepa?.currentPrice && spApi.estimatedSalePrice === 0) { ` [pricing] ${pricingDone}/${availableProducts.length} fetched`,
spApi.estimatedSalePrice = keepa.currentPrice; );
} }
}
spApiResults.set(p.asin, spApi); }
pricingDone++;
if (pricingDone % 10 === 0 || pricingDone === availableProducts.length) { const pricingWorkers = Array.from(
console.log( { length: Math.min(5, availableProducts.length || 1) },
` [pricing] ${pricingDone}/${availableProducts.length} fetched`, () => fetchNextPricing(),
); );
} await Promise.all(pricingWorkers);
}
} console.log(`\nEnriching products...`);
const enriched: EnrichedProduct[] = [];
const pricingWorkers = Array.from( const availableAsins = new Set(availableProducts.map((ap) => ap.asin));
{ length: Math.min(5, availableProducts.length || 1) },
() => fetchNextPricing(), for (const p of products) {
); if (excludedCachedAsins.has(p.asin)) {
await Promise.all(pricingWorkers); continue;
}
// Phase 6: Build enriched products
console.log(`\nEnriching products...`); const cachedProduct = cached.get(p.asin);
const enriched: EnrichedProduct[] = []; if (cachedProduct) {
const availableAsins = new Set(availableProducts.map((ap) => ap.asin)); enriched.push(cachedProduct);
continue;
for (const p of products) { }
if (excludedCachedAsins.has(p.asin)) {
continue; if (!availableAsins.has(p.asin)) {
} continue;
}
// Cached products — already enriched
const cachedProduct = cached.get(p.asin); const keepa = keepaResults.get(p.asin) ?? null;
if (cachedProduct) { const spApi = spApiResults.get(p.asin) ?? {
enriched.push(cachedProduct); fbaFee: 5.0,
continue; fbmFee: 1.5,
} referralFeePercent: 15,
estimatedSalePrice: 0,
// Exclude products that are not explicitly available. canSell: null,
if (!availableAsins.has(p.asin)) { sellabilityStatus: "unknown" as const,
continue; sellabilityReason: "SP-API data missing",
} };
// Available products — full enrichment const product: EnrichedProduct = {
const keepa = keepaResults.get(p.asin) ?? null; record: p,
const spApi = spApiResults.get(p.asin) ?? { keepa,
fbaFee: 5.0, spApi,
fbmFee: 1.5, fetchedAt: new Date().toISOString(),
referralFeePercent: 15, };
estimatedSalePrice: 0,
canSell: null, await setCache(p.asin, product);
sellabilityStatus: "unknown" as const, enriched.push(product);
sellabilityReason: "SP-API data missing", }
};
console.log(
const product: EnrichedProduct = { `\nAnalyzing ${enriched.length} products via LLM (batch size: ${LLM_BATCH_SIZE})...\n`,
record: p, );
keepa,
spApi, const results: AnalysisResult[] = [];
fetchedAt: new Date().toISOString(), for (let i = 0; i < enriched.length; i += LLM_BATCH_SIZE) {
}; const batch = enriched.slice(i, i + LLM_BATCH_SIZE);
const batchNum = Math.floor(i / LLM_BATCH_SIZE) + 1;
await setCache(p.asin, product); const totalBatches = Math.ceil(enriched.length / LLM_BATCH_SIZE);
enriched.push(product); console.log(` LLM batch ${batchNum}/${totalBatches}...`);
if (keepa) { if (i > 0) {
console.log( await new Promise((r) => setTimeout(r, 5000));
` [enriched] ${p.asin} — price: $${keepa.currentPrice ?? "N/A"}, rank: ${keepa.salesRank ?? "N/A"}`, }
);
} else { let verdicts;
console.log(` [no keepa] ${p.asin} — using spreadsheet data only`); try {
} verdicts = await analyzeProducts(batch);
} } catch {
await new Promise((r) => setTimeout(r, 10_000));
// Phase 7: LLM analysis in batches — only for enriched available products try {
console.log( verdicts = await analyzeProducts(batch);
`\nAnalyzing ${enriched.length} products via LLM (batch size: ${LLM_BATCH_SIZE})...\n`, } catch {
); verdicts = null;
}
const results: AnalysisResult[] = []; }
for (let i = 0; i < enriched.length; i += LLM_BATCH_SIZE) {
const batch = enriched.slice(i, i + LLM_BATCH_SIZE); for (let j = 0; j < batch.length; j++) {
const batchNum = Math.floor(i / LLM_BATCH_SIZE) + 1; results.push({
const totalBatches = Math.ceil(enriched.length / LLM_BATCH_SIZE); product: batch[j]!,
console.log(` LLM batch ${batchNum}/${totalBatches}...`); verdict: verdicts?.[j] ?? {
asin: batch[j]!.record.asin,
// Wait between batches to avoid overwhelming LM Studio verdict: "SKIP",
if (i > 0) { confidence: 0,
console.log(` Waiting 5s before next batch...`); reasoning: "LLM analysis failed",
await new Promise((r) => setTimeout(r, 5000)); },
} });
}
let verdicts; }
try {
verdicts = await analyzeProducts(batch); return results;
} catch { }
console.warn(` LLM batch error, retrying after 10s...`);
await new Promise((r) => setTimeout(r, 10_000)); async function main() {
try { const { inputFile, outputFile } = parseArgs();
verdicts = await analyzeProducts(batch);
} catch (retryErr) { console.log("Connecting to Redis...");
console.error(` LLM analysis failed: ${retryErr}`); await connectCache();
verdicts = null;
} console.log("Initializing SQLite database...");
} initDb(DB_PATH);
for (let j = 0; j < batch.length; j++) { try {
results.push({ console.log(`\nReading ${inputFile}...`);
product: batch[j]!, const products = readProducts(inputFile);
verdict: verdicts?.[j] ?? {
asin: batch[j]!.record.asin, if (products.length === 0) {
verdict: "SKIP", console.error("No valid products found in input file.");
confidence: 0, process.exit(1);
reasoning: "LLM analysis failed", }
},
}); const productChunks = chunkArray(products, INPUT_BATCH_SIZE);
} const allResults: AnalysisResult[] = [];
} const resolvedBaseOutputPath = resolveBaseOutputPath(inputFile, outputFile);
return results; if (productChunks.length > 1) {
} console.log(
`\nLarge input detected (${products.length} products). Processing in chunks of ${INPUT_BATCH_SIZE}.`,
async function main() { );
const { inputFile, outputFile } = parseArgs(); console.log(`Output base path: ${resolvedBaseOutputPath}`);
}
console.log("Connecting to Redis...");
await connectCache(); for (let chunkIndex = 0; chunkIndex < productChunks.length; chunkIndex++) {
const chunk = productChunks[chunkIndex]!;
try { console.log(
// Phase 1: Read input file `\n=== Input chunk ${chunkIndex + 1}/${productChunks.length} (${chunk.length} products) ===`,
console.log(`\nReading ${inputFile}...`); );
const products = readProducts(inputFile); const chunkResults = await processProductChunk(chunk);
allResults.push(...chunkResults);
if (products.length === 0) { }
console.error("No valid products found in input file.");
process.exit(1); printResults(allResults);
} writeResultsToDb(allResults, DB_PATH, inputFile, outputFile);
} finally {
const productChunks = chunkArray(products, INPUT_BATCH_SIZE); await disconnectCache();
const hasMultipleChunks = productChunks.length > 1; closeDb();
const shouldWriteChunkFiles = hasMultipleChunks; }
const resolvedBaseOutputPath = resolveBaseOutputPath(inputFile, outputFile); }
const allResults: AnalysisResult[] = [];
main().catch((err) => {
if (hasMultipleChunks) { console.error("Fatal error:", err);
console.log( process.exit(1);
`\nLarge input detected (${products.length} products). Processing in chunks of ${INPUT_BATCH_SIZE}.`, });
);
console.log(
`Chunk outputs will be written as numbered files using base: ${resolvedBaseOutputPath}`,
);
}
for (let chunkIndex = 0; chunkIndex < productChunks.length; chunkIndex++) {
const chunk = productChunks[chunkIndex]!;
console.log(
`\n=== Input chunk ${chunkIndex + 1}/${productChunks.length} (${chunk.length} products) ===`,
);
const chunkResults = await processProductChunk(chunk);
allResults.push(...chunkResults);
if (shouldWriteChunkFiles) {
const chunkOutputPath = buildChunkOutputPath(
resolvedBaseOutputPath,
chunkIndex,
);
writeResultsCsv(chunkResults, chunkOutputPath);
}
}
printResults(allResults);
writeResultsToDb(allResults, DB_PATH, inputFile, outputFile);
await disconnectCache();
closeDb();
}
main().catch((err) => {
console.error("Fatal error:", err);
process.exit(1);
});

View File

@@ -7,9 +7,14 @@ Given product data, evaluate each product's viability for selling on Amazon. Con
1. **Sales Velocity**: monthlySold and salesRankDrops30 are the most important signals. A product that doesn't sell is worthless regardless of margin. salesRankDrops30 = approximate units sold in 30 days. monthlySold is Keepa's estimate. 1. **Sales Velocity**: monthlySold and salesRankDrops30 are the most important signals. A product that doesn't sell is worthless regardless of margin. salesRankDrops30 = approximate units sold in 30 days. monthlySold is Keepa's estimate.
2. **Margin Analysis**: Sale price minus unit cost minus fees (FBA or FBM). Aim for >30% ROI minimum. The spreadsheet may include FBA NET and gross profit estimates — cross-check against Keepa pricing data. 2. **Margin Analysis**: Sale price minus unit cost minus fees (FBA or FBM). Aim for >30% ROI minimum. The spreadsheet may include FBA NET and gross profit estimates — cross-check against Keepa pricing data.
- If unitCost is 0, it means NO COST DATA is available — this is NOT a free product. Do not assume infinite or zero-cost margins. When estimatedROI is null and unitCost is 0, evaluate purely on demand and velocity signals; never extrapolate profitability.
- If estimatedProfit is null, price data was unavailable at analysis time — treat as elevated uncertainty and be conservative.
3. **Sales Rank (BSR)**: Lower rank = higher demand. Rank <50,000 is good, <1,000 is excellent. 3. **Sales Rank (BSR)**: Lower rank = higher demand. Rank <50,000 is good, <1,000 is excellent.
4. **Sales Rank Trend**: Compare current rank vs 90d average. Lower current = improving demand. 4. **Sales Rank Trend**: Compare current rank vs 90d average. Lower current = improving demand.
5. **Competition**: Number of sellers and Buy Box dynamics. Fewer sellers = easier entry. 5. **Competition & Buy Box**:
- Fewer sellers = easier entry, but verify who holds the Buy Box.
- If buyBoxSeller is "ATVPDKIKX0DER" (Amazon retail), the product is Amazon-exclusive — return "SKIP". Amazon-sold items cannot be competed with by third-party sellers.
- If sellerCount is 1 and buyBoxSeller is not null, assume the market is closed — return "SKIP".
6. **Price Stability**: Large price swings (high max vs low min over 90d) = volatile/risky. 6. **Price Stability**: Large price swings (high max vs low min over 90d) = volatile/risky.
7. **FBA vs FBM**: FBA preferred for fast-selling, small/light items. FBM for oversized, slow-moving, or high-margin items where fee savings matter. 7. **FBA vs FBM**: FBA preferred for fast-selling, small/light items. FBM for oversized, slow-moving, or high-margin items where fee savings matter.
8. **MOQ & Capital**: High MOQ with thin margins is risky. 8. **MOQ & Capital**: High MOQ with thin margins is risky.
@@ -21,6 +26,7 @@ Given product data, evaluate each product's viability for selling on Amazon. Con
Decision policy: Decision policy:
- Do not recommend products that cannot be listed by this seller account. - Do not recommend products that cannot be listed by this seller account.
- Do not recommend Amazon-exclusive products (buyBoxSeller = "ATVPDKIKX0DER").
- Prioritize profitable + high-velocity + listable products. - Prioritize profitable + high-velocity + listable products.
- Use "SKIP" when data quality is poor or risk is high. - Use "SKIP" when data quality is poor or risk is high.
@@ -106,12 +112,20 @@ function summarizeForLlm(p: EnrichedProduct) {
const salePrice = const salePrice =
p.keepa?.currentPrice ?? p.keepa?.currentPrice ??
p.record.sellingPriceFromSheet ?? p.record.sellingPriceFromSheet ??
p.spApi.estimatedSalePrice; (p.spApi.estimatedSalePrice > 0 ? p.spApi.estimatedSalePrice : null) ??
const referralFee = salePrice * (p.spApi.referralFeePercent / 100); p.keepa?.avgPrice90 ??
null;
const referralFee =
salePrice != null ? salePrice * (p.spApi.referralFeePercent / 100) : null;
const fbaProfit = const fbaProfit =
salePrice - p.record.unitCost - p.spApi.fbaFee - referralFee; salePrice != null && referralFee != null
? salePrice - p.record.unitCost - p.spApi.fbaFee - referralFee
: null;
const fbmProfit = const fbmProfit =
salePrice - p.record.unitCost - p.spApi.fbmFee - referralFee; salePrice != null && referralFee != null
? salePrice - p.record.unitCost - p.spApi.fbmFee - referralFee
: null;
return { return {
asin: p.record.asin, asin: p.record.asin,
@@ -121,7 +135,7 @@ function summarizeForLlm(p: EnrichedProduct) {
p.record.category ?? p.keepa?.categoryTree?.join(" > "), p.record.category ?? p.keepa?.categoryTree?.join(" > "),
60, 60,
), ),
unitCost: p.record.unitCost, unitCost: p.record.unitCost > 0 ? p.record.unitCost : null,
currentPrice: salePrice, currentPrice: salePrice,
priceRange90d: p.keepa priceRange90d: p.keepa
? { ? {
@@ -133,6 +147,8 @@ function summarizeForLlm(p: EnrichedProduct) {
salesRank: p.keepa?.salesRank ?? p.record.amazonRank, salesRank: p.keepa?.salesRank ?? p.record.amazonRank,
salesRankAvg90d: p.keepa?.salesRankAvg90, salesRankAvg90d: p.keepa?.salesRankAvg90,
sellerCount: p.keepa?.sellerCount, sellerCount: p.keepa?.sellerCount,
buyBoxSeller: p.keepa?.buyBoxSeller ?? null,
buyBoxPrice: p.keepa?.buyBoxPrice ?? null,
salesVelocity: { salesVelocity: {
monthlySold: p.keepa?.monthlySold, monthlySold: p.keepa?.monthlySold,
salesRankDrops30: p.keepa?.salesRankDrops30, salesRankDrops30: p.keepa?.salesRankDrops30,
@@ -155,27 +171,28 @@ function summarizeForLlm(p: EnrichedProduct) {
fbaFee: p.spApi.fbaFee, fbaFee: p.spApi.fbaFee,
fbmFee: p.spApi.fbmFee, fbmFee: p.spApi.fbmFee,
referralFeePercent: p.spApi.referralFeePercent, referralFeePercent: p.spApi.referralFeePercent,
referralFee: Math.round(referralFee * 100) / 100, referralFee:
referralFee != null ? Math.round(referralFee * 100) / 100 : null,
}, },
sellerEligibility: { sellerEligibility: {
canSell: p.spApi.canSell, canSell: p.spApi.canSell,
status: p.spApi.sellabilityStatus, status: p.spApi.sellabilityStatus,
reason: clampText(p.spApi.sellabilityReason, 120), reason: clampText(p.spApi.sellabilityReason, 120),
}, },
estimatedProfit: { estimatedProfit:
fba: Math.round(fbaProfit * 100) / 100, fbaProfit != null && fbmProfit != null
fbm: Math.round(fbmProfit * 100) / 100, ? {
}, fba: Math.round(fbaProfit * 100) / 100,
estimatedROI: { fbm: Math.round(fbmProfit * 100) / 100,
fba: }
p.record.unitCost > 0 : null,
? Math.round((fbaProfit / p.record.unitCost) * 100) estimatedROI:
: null, p.record.unitCost > 0 && fbaProfit != null && fbmProfit != null
fbm: ? {
p.record.unitCost > 0 fba: Math.round((fbaProfit / p.record.unitCost) * 100),
? Math.round((fbmProfit / p.record.unitCost) * 100) fbm: Math.round((fbmProfit / p.record.unitCost) * 100),
: null, }
}, : null,
}; };
} }

619
src/server.ts Normal file
View File

@@ -0,0 +1,619 @@
import index from "./web/index.html";
import { getDb, initDb } from "./database.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;
fetched_at: string;
};
const DB_PATH = process.env.RESULTS_DB_PATH || "./results.db";
const DEFAULT_PAGE_SIZE = 25;
const MAX_PAGE_SIZE = 200;
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 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}`;
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 baseUnion = `
SELECT
'lead_analysis' AS processType,
run_id AS runId,
asin,
product_name,
brand,
category,
verdict,
confidence,
sellability_status,
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,
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 fetched_at DESC 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",
"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},
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",
"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},
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",
"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");
}
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/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}`);

780
src/web/frontend.tsx Normal file
View File

@@ -0,0 +1,780 @@
import { createRoot } from "react-dom/client";
import { useEffect, useMemo, useState } from "react";
type ProcessType = "lead_analysis" | "category_analysis";
type SortDirection = "ASC" | "DESC";
type Run = {
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 RunsResponse = {
items: Run[];
page: number;
pageSize: number;
total: number;
totalPages: number;
};
type RunDetail = {
processType: ProcessType;
runId: number;
timestamp: string;
status: string;
jobType: string;
source: string | null;
output: string | null;
totalProducts: number;
fbaCount: number;
fbmCount: number;
skipCount: number;
summary: {
totalProducts: number;
fbaCount: number;
fbmCount: number;
skipCount: number;
};
errorMessage?: string;
availableAsins?: number;
};
type ResultItem = {
id?: number;
run_id: number;
asin: string;
product_name: string | null;
brand: string | null;
category: string | null;
current_price: number | null;
avg_price_90d: number | null;
sales_rank: number | null;
seller_count: number | null;
monthly_sold: number | null;
verdict: "FBA" | "FBM" | "SKIP";
confidence: number | null;
sellability_status: string | null;
reasoning: string | null;
fetched_at: string;
};
type ResultsResponse = {
items: ResultItem[];
page: number;
pageSize: number;
total: number;
totalPages: number;
};
type VerdictFilter = "" | "FBA" | "FBM" | "SKIP";
type ProductListItem = {
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;
fetched_at: string;
};
type ProductListResponse = {
items: ProductListItem[];
page: number;
pageSize: number;
total: number;
totalPages: number;
};
type SortState = {
field: string;
direction: SortDirection;
};
function formatDate(input: string): string {
const d = new Date(input);
if (Number.isNaN(d.getTime())) return input;
return d.toLocaleString();
}
function formatNumber(value: number | null | undefined): string {
if (value === null || value === undefined) return "-";
return new Intl.NumberFormat().format(value);
}
function formatCurrency(value: number | null | undefined): string {
if (value === null || value === undefined) return "-";
return new Intl.NumberFormat(undefined, {
style: "currency",
currency: "USD",
maximumFractionDigits: 2,
}).format(value);
}
function buildSortValue(sort: SortState): string {
return `${sort.field}:${sort.direction}`;
}
function nextSort(current: SortState, field: string): SortState {
if (current.field !== field) {
return { field, direction: "ASC" };
}
return {
field,
direction: current.direction === "ASC" ? "DESC" : "ASC",
};
}
function statusBadgeClass(status: string): string {
if (status === "ok" || status === "completed") return "badge badge-ok";
if (status === "failed") return "badge badge-failed";
return "badge badge-empty";
}
function verdictBadgeClass(verdict: string): string {
if (verdict === "FBA") return "badge badge-fba";
if (verdict === "FBM") return "badge badge-fbm";
return "badge badge-skip";
}
function TinyBar({ fba, fbm, skip }: { fba: number; fbm: number; skip: number }) {
const total = Math.max(1, fba + fbm + skip);
const fbaPct = (fba / total) * 100;
const fbmPct = (fbm / total) * 100;
const skipPct = (skip / total) * 100;
return (
<div className="tiny-bar" title={`FBA ${fba}, FBM ${fbm}, SKIP ${skip}`}>
<span className="tiny-fba" style={{ width: `${fbaPct}%` }} />
<span className="tiny-fbm" style={{ width: `${fbmPct}%` }} />
<span className="tiny-skip" style={{ width: `${skipPct}%` }} />
</div>
);
}
function detectAnomaly(item: ResultItem): string {
const confidence = item.confidence ?? 0;
if ((item.sellability_status === "restricted" || item.sellability_status === "not_available") && item.verdict !== "SKIP") {
return "restricted-vs-verdict";
}
if (item.verdict === "FBA" && confidence < 60) {
return "low-confidence-fba";
}
if ((item.sales_rank ?? Number.MAX_SAFE_INTEGER) > 300000 && item.verdict === "FBA") {
return "high-rank-fba";
}
return "";
}
function Dashboard({
onOpenRun,
onOpenProducts,
}: {
onOpenRun: (run: Run) => void;
onOpenProducts: (verdict: VerdictFilter) => void;
}) {
const [runs, setRuns] = useState<RunsResponse | null>(null);
const [loading, setLoading] = useState(false);
const [search, setSearch] = useState("");
const [processType, setProcessType] = useState("");
const [status, setStatus] = useState("");
const [startDate, setStartDate] = useState("");
const [endDate, setEndDate] = useState("");
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(25);
const [sort, setSort] = useState<SortState>({ field: "timestamp", direction: "DESC" });
const [refreshTick, setRefreshTick] = useState(0);
const [deletingKey, setDeletingKey] = useState<string | null>(null);
const summary = useMemo(() => {
if (!runs) return { total: 0, fba: 0, fbm: 0, skip: 0 };
return runs.items.reduce(
(acc, run) => {
acc.total += run.totalProducts;
acc.fba += run.fbaCount;
acc.fbm += run.fbmCount;
acc.skip += run.skipCount;
return acc;
},
{ total: 0, fba: 0, fbm: 0, skip: 0 },
);
}, [runs]);
const timeline = useMemo(() => {
if (!runs) return [] as Run[];
return [...runs.items]
.sort((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime())
.slice(-12);
}, [runs]);
useEffect(() => {
let cancelled = false;
async function load() {
setLoading(true);
const params = new URLSearchParams({
page: String(page),
pageSize: String(pageSize),
sort: buildSortValue(sort),
});
if (search) params.set("q", search);
if (processType) params.set("processType", processType);
if (status) params.set("status", status);
if (startDate) params.set("startDate", `${startDate}T00:00:00.000Z`);
if (endDate) params.set("endDate", `${endDate}T23:59:59.999Z`);
const resp = await fetch(`/api/runs?${params.toString()}`);
const data = (await resp.json()) as RunsResponse;
if (!cancelled) {
setRuns(data);
setLoading(false);
}
}
load();
return () => {
cancelled = true;
};
}, [search, processType, status, startDate, endDate, page, pageSize, sort, refreshTick]);
async function deleteRun(run: Run) {
const key = `${run.processType}-${run.runId}`;
const confirmed = window.confirm(`Delete run ${run.runId} (${run.processType}) and all associated results?`);
if (!confirmed) return;
setDeletingKey(key);
try {
const response = await fetch(`/api/runs/${run.processType}/${run.runId}`, { method: "DELETE" });
if (!response.ok) {
const errorPayload = await response.json().catch(() => null) as { error?: string } | null;
const message = errorPayload?.error ?? "Failed to delete run";
window.alert(message);
return;
}
setPage(1);
setRefreshTick((tick) => tick + 1);
} finally {
setDeletingKey(null);
}
}
return (
<div className="page">
<div className="card">
<h2>Runs Dashboard</h2>
</div>
<div className="metrics">
<div className="metric" role="button" tabIndex={0} onClick={() => onOpenProducts("")} onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") onOpenProducts(""); }}>
<div className="label">Total products</div>
<div className="value">{formatNumber(summary.total)}</div>
</div>
<div className="metric" role="button" tabIndex={0} onClick={() => onOpenProducts("FBA")} onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") onOpenProducts("FBA"); }}>
<div className="label">FBA</div>
<div className="value">{formatNumber(summary.fba)}</div>
</div>
<div className="metric" role="button" tabIndex={0} onClick={() => onOpenProducts("FBM")} onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") onOpenProducts("FBM"); }}>
<div className="label">FBM</div>
<div className="value">{formatNumber(summary.fbm)}</div>
</div>
<div className="metric" role="button" tabIndex={0} onClick={() => onOpenProducts("SKIP")} onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") onOpenProducts("SKIP"); }}>
<div className="label">SKIP</div>
<div className="value">{formatNumber(summary.skip)}</div>
</div>
</div>
<div className="card">
<div className="toolbar">
<input value={search} onChange={(e) => { setPage(1); setSearch(e.target.value); }} placeholder="Search run/job/source" />
<select value={processType} onChange={(e) => { setPage(1); setProcessType(e.target.value); }}>
<option value="">All processes</option>
<option value="lead_analysis">lead_analysis</option>
<option value="category_analysis">category_analysis</option>
</select>
<select value={status} onChange={(e) => { setPage(1); setStatus(e.target.value); }}>
<option value="">All statuses</option>
<option value="completed">completed</option>
<option value="ok">ok</option>
<option value="empty">empty</option>
<option value="failed">failed</option>
</select>
<input type="date" value={startDate} onChange={(e) => { setPage(1); setStartDate(e.target.value); }} />
<input type="date" value={endDate} onChange={(e) => { setPage(1); setEndDate(e.target.value); }} />
<select value={String(pageSize)} onChange={(e) => { setPage(1); setPageSize(Number(e.target.value)); }}>
<option value="25">25 / page</option>
<option value="50">50 / page</option>
<option value="100">100 / page</option>
</select>
</div>
</div>
<div className="card">
<div className="table-wrap">
<table>
<thead>
<tr>
<th><button onClick={() => setSort(nextSort(sort, "runId"))}>Run ID</button></th>
<th><button onClick={() => setSort(nextSort(sort, "processType"))}>Process</button></th>
<th><button onClick={() => setSort(nextSort(sort, "jobType"))}>Job Type</button></th>
<th><button onClick={() => setSort(nextSort(sort, "timestamp"))}>Timestamp</button></th>
<th><button onClick={() => setSort(nextSort(sort, "status"))}>Status</button></th>
<th><button onClick={() => setSort(nextSort(sort, "totalProducts"))}>Total</button></th>
<th>FBA</th>
<th>FBM</th>
<th>SKIP</th>
<th>Mix</th>
<th>Source</th>
<th>Open</th>
<th>Delete</th>
</tr>
</thead>
<tbody>
{loading ? (
<tr><td colSpan={13}>Loading...</td></tr>
) : runs?.items.length ? (
runs.items.map((run) => (
<tr key={`${run.processType}-${run.runId}`}>
<td>{run.runId}</td>
<td>{run.processType}</td>
<td>{run.jobType}</td>
<td>{formatDate(run.timestamp)}</td>
<td><span className={statusBadgeClass(run.status)}>{run.status}</span></td>
<td>{formatNumber(run.totalProducts)}</td>
<td>{formatNumber(run.fbaCount)}</td>
<td>{formatNumber(run.fbmCount)}</td>
<td>{formatNumber(run.skipCount)}</td>
<td><TinyBar fba={run.fbaCount} fbm={run.fbmCount} skip={run.skipCount} /></td>
<td>{run.source || "-"}</td>
<td><button onClick={() => onOpenRun(run)}>View</button></td>
<td>
<button disabled={deletingKey === `${run.processType}-${run.runId}`} onClick={() => deleteRun(run)}>
{deletingKey === `${run.processType}-${run.runId}` ? "Deleting..." : "Delete"}
</button>
</td>
</tr>
))
) : (
<tr><td colSpan={13}>No runs found</td></tr>
)}
</tbody>
</table>
</div>
<div className="pager">
<div>Showing {runs?.items.length ?? 0} of {runs?.total ?? 0}</div>
<div>
<button disabled={page <= 1} onClick={() => setPage((p) => Math.max(1, p - 1))}>Previous</button>
<span style={{ padding: "0 8px" }}>Page {runs?.page ?? page} / {runs?.totalPages ?? 1}</span>
<button disabled={Boolean(runs && page >= runs.totalPages)} onClick={() => setPage((p) => p + 1)}>Next</button>
</div>
</div>
</div>
<div className="card">
<h3>Recent trend (last 12 runs in current view)</h3>
<div className="spark-grid" style={{ marginTop: 10 }}>
{timeline.length === 0 ? (
<div className="meta">No trend data</div>
) : (
timeline.map((run) => (
<div key={`trend-${run.processType}-${run.runId}`} className="spark-item" title={`${run.runId}${formatDate(run.timestamp)}`}>
<div className="spark-label">#{run.runId}</div>
<TinyBar fba={run.fbaCount} fbm={run.fbmCount} skip={run.skipCount} />
</div>
))
)}
</div>
</div>
</div>
);
}
function RunDetails({
processType,
runId,
onBack,
}: {
processType: ProcessType;
runId: number;
onBack: () => void;
}) {
const [run, setRun] = useState<RunDetail | null>(null);
const [results, setResults] = useState<ResultsResponse | null>(null);
const [loading, setLoading] = useState(false);
const [search, setSearch] = useState("");
const [verdict, setVerdict] = useState("");
const [sellabilityStatus, setSellabilityStatus] = useState("");
const [minConfidence, setMinConfidence] = useState("");
const [maxConfidence, setMaxConfidence] = useState("");
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(25);
const [sort, setSort] = useState<SortState>({ field: "monthly_sold", direction: "DESC" });
const [refreshTick, setRefreshTick] = useState(0);
const anomalies = useMemo(() => {
if (!results) return [] as ResultItem[];
return results.items.filter((item) => detectAnomaly(item) !== "");
}, [results]);
useEffect(() => {
let cancelled = false;
async function loadRun() {
const res = await fetch(`/api/runs/${processType}/${runId}`);
const payload = (await res.json()) as RunDetail;
if (!cancelled) {
setRun(payload);
}
}
loadRun();
return () => {
cancelled = true;
};
}, [processType, runId, refreshTick]);
useEffect(() => {
let cancelled = false;
async function loadResults() {
setLoading(true);
const params = new URLSearchParams({
page: String(page),
pageSize: String(pageSize),
sort: buildSortValue(sort),
});
if (search) params.set("q", search);
if (verdict) params.set("verdict", verdict);
if (sellabilityStatus) params.set("sellabilityStatus", sellabilityStatus);
if (minConfidence) params.set("minConfidence", minConfidence);
if (maxConfidence) params.set("maxConfidence", maxConfidence);
const res = await fetch(`/api/runs/${processType}/${runId}/results?${params.toString()}`);
const payload = (await res.json()) as ResultsResponse;
if (!cancelled) {
setResults(payload);
setLoading(false);
}
}
loadResults();
return () => {
cancelled = true;
};
}, [processType, runId, search, verdict, sellabilityStatus, minConfidence, maxConfidence, page, pageSize, sort, refreshTick]);
useEffect(() => {
const interval = window.setInterval(() => {
setRefreshTick((tick) => tick + 1);
}, 4000);
return () => {
window.clearInterval(interval);
};
}, [processType, runId]);
return (
<div className="page">
<button className="back" onClick={onBack}>Back</button>
<div className="card">
<h2>Run Detail</h2>
<div className="meta-grid" style={{ marginTop: 12 }}>
<div className="meta"><strong>Process:</strong> {processType}</div>
<div className="meta"><strong>Run ID:</strong> {runId}</div>
<div className="meta"><strong>Status:</strong> {run ? <span className={statusBadgeClass(run.status)}>{run.status}</span> : "-"}</div>
<div className="meta"><strong>Timestamp:</strong> {run ? formatDate(run.timestamp) : "-"}</div>
<div className="meta"><strong>Job:</strong> {run?.jobType ?? "-"}</div>
<div className="meta"><strong>Source:</strong> {run?.source ?? "-"}</div>
<div className="meta"><strong>Total:</strong> {formatNumber(run?.summary.totalProducts)}</div>
<div className="meta"><strong>FBA/FBM/SKIP:</strong> {formatNumber(run?.summary.fbaCount)}/{formatNumber(run?.summary.fbmCount)}/{formatNumber(run?.summary.skipCount)}</div>
</div>
<div style={{ marginTop: 10 }}>
<TinyBar fba={run?.summary.fbaCount ?? 0} fbm={run?.summary.fbmCount ?? 0} skip={run?.summary.skipCount ?? 0} />
</div>
</div>
<div className="card">
<div className="toolbar">
<input value={search} onChange={(e) => { setPage(1); setSearch(e.target.value); }} placeholder="Search ASIN/name/brand/category/reason" />
<select value={verdict} onChange={(e) => { setPage(1); setVerdict(e.target.value); }}>
<option value="">All verdicts</option>
<option value="FBA">FBA</option>
<option value="FBM">FBM</option>
<option value="SKIP">SKIP</option>
</select>
<select value={sellabilityStatus} onChange={(e) => { setPage(1); setSellabilityStatus(e.target.value); }}>
<option value="">All sellability</option>
<option value="available">available</option>
<option value="restricted">restricted</option>
<option value="not_available">not_available</option>
<option value="unknown">unknown</option>
</select>
<input value={minConfidence} onChange={(e) => { setPage(1); setMinConfidence(e.target.value); }} placeholder="Min confidence" />
<input value={maxConfidence} onChange={(e) => { setPage(1); setMaxConfidence(e.target.value); }} placeholder="Max confidence" />
<select value={String(pageSize)} onChange={(e) => { setPage(1); setPageSize(Number(e.target.value)); }}>
<option value="25">25 / page</option>
<option value="50">50 / page</option>
<option value="100">100 / page</option>
</select>
</div>
<div style={{ marginTop: 10 }}>
<a
href={`/api/runs/${processType}/${runId}/export.csv?q=${encodeURIComponent(search)}&verdict=${encodeURIComponent(verdict)}&sellabilityStatus=${encodeURIComponent(sellabilityStatus)}&minConfidence=${encodeURIComponent(minConfidence)}&maxConfidence=${encodeURIComponent(maxConfidence)}&sort=${encodeURIComponent(buildSortValue(sort))}`}
>
<button>Export filtered CSV</button>
</a>
</div>
</div>
<div className="card">
<h3>Anomalies in current page</h3>
{anomalies.length === 0 ? (
<div className="meta" style={{ marginTop: 8 }}>No anomalies detected with current heuristic.</div>
) : (
<div className="anomaly-list" style={{ marginTop: 8 }}>
{anomalies.slice(0, 8).map((item) => (
<div key={`anom-${item.asin}-${item.fetched_at}`} className="anomaly-item">
<a href={`http://amazon.com/dp/${item.asin}`} target="_blank" rel="noreferrer">{item.asin}</a>
<span className={verdictBadgeClass(item.verdict)}>{item.verdict}</span>
<span>{detectAnomaly(item)}</span>
</div>
))}
</div>
)}
</div>
<div className="card">
<div className="table-wrap">
<table>
<thead>
<tr>
<th><button onClick={() => setSort(nextSort(sort, "asin"))}>ASIN</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, "seller_count"))}>Sellers</button></th>
<th><button onClick={() => setSort(nextSort(sort, "sales_rank"))}>Sales Rank</button></th>
<th><button onClick={() => setSort(nextSort(sort, "current_price"))}>Current Price</button></th>
<th><button onClick={() => setSort(nextSort(sort, "product_name"))}>Product</button></th>
<th><button onClick={() => setSort(nextSort(sort, "brand"))}>Brand</button></th>
<th><button onClick={() => setSort(nextSort(sort, "category"))}>Category</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 className="reason-col"><button onClick={() => setSort(nextSort(sort, "reasoning"))}>Reasoning</button></th>
</tr>
</thead>
<tbody>
{loading ? (
<tr><td colSpan={12}>Loading...</td></tr>
) : results?.items.length ? (
results.items.map((item) => (
<tr key={`${item.asin}-${item.fetched_at}`}>
<td><a href={`http://amazon.com/dp/${item.asin}`} target="_blank" rel="noreferrer">{item.asin}</a></td>
<td><span className={verdictBadgeClass(item.verdict)}>{item.verdict}</span></td>
<td>{formatNumber(item.monthly_sold)}</td>
<td>{formatNumber(item.seller_count)}</td>
<td>{formatNumber(item.sales_rank)}</td>
<td>{formatCurrency(item.current_price)}</td>
<td title={item.reasoning || undefined}>{item.product_name || "-"}</td>
<td>{item.brand || "-"}</td>
<td>{item.category || "-"}</td>
<td>{formatCurrency(item.avg_price_90d)}</td>
<td>{formatNumber(item.confidence)}</td>
<td className="reason-col" title={item.reasoning || undefined}>{item.reasoning || "-"}</td>
</tr>
))
) : (
<tr><td colSpan={12}>No results found</td></tr>
)}
</tbody>
</table>
</div>
<div className="pager">
<div>Showing {results?.items.length ?? 0} of {results?.total ?? 0}</div>
<div>
<button disabled={page <= 1} onClick={() => setPage((p) => Math.max(1, p - 1))}>Previous</button>
<span style={{ padding: "0 8px" }}>Page {results?.page ?? page} / {results?.totalPages ?? 1}</span>
<button disabled={Boolean(results && page >= results.totalPages)} onClick={() => setPage((p) => p + 1)}>Next</button>
</div>
</div>
</div>
</div>
);
}
function ProductList({ verdict, onBack }: { verdict: VerdictFilter; onBack: () => void }) {
const [items, setItems] = useState<ProductListResponse | null>(null);
const [loading, setLoading] = useState(false);
const [search, setSearch] = useState("");
const [activeVerdict, setActiveVerdict] = useState<VerdictFilter>(verdict);
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(25);
useEffect(() => {
setActiveVerdict(verdict);
setPage(1);
}, [verdict]);
useEffect(() => {
let cancelled = false;
async function load() {
setLoading(true);
const params = new URLSearchParams({ page: String(page), pageSize: String(pageSize) });
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;
if (!cancelled) {
setItems(payload);
setLoading(false);
}
}
load();
return () => {
cancelled = true;
};
}, [search, activeVerdict, page, pageSize]);
return (
<div className="page">
<button className="back" onClick={onBack}>Back</button>
<div className="card">
<h2>Products</h2>
<div className="toolbar" style={{ marginTop: 10 }}>
<input value={search} onChange={(e) => { setPage(1); setSearch(e.target.value); }} placeholder="Search ASIN/name/brand/category" />
<select value={activeVerdict} onChange={(e) => { setPage(1); setActiveVerdict(e.target.value as VerdictFilter); }}>
<option value="">All verdicts</option>
<option value="FBA">FBA</option>
<option value="FBM">FBM</option>
<option value="SKIP">SKIP</option>
</select>
<select value={String(pageSize)} onChange={(e) => { setPage(1); setPageSize(Number(e.target.value)); }}>
<option value="25">25 / page</option>
<option value="50">50 / page</option>
<option value="100">100 / page</option>
</select>
</div>
</div>
<div className="card">
<div className="table-wrap">
<table>
<thead>
<tr>
<th>ASIN</th>
<th>Verdict</th>
<th>Product</th>
<th>Brand</th>
<th>Category</th>
<th>Confidence</th>
<th>Process</th>
<th>Run ID</th>
<th>Fetched</th>
</tr>
</thead>
<tbody>
{loading ? (
<tr><td colSpan={9}>Loading...</td></tr>
) : items?.items.length ? (
items.items.map((item) => (
<tr key={`${item.processType}-${item.runId}-${item.asin}-${item.fetched_at}`}>
<td><a href={`http://amazon.com/dp/${item.asin}`} target="_blank" rel="noreferrer">{item.asin}</a></td>
<td><span className={verdictBadgeClass(item.verdict)}>{item.verdict}</span></td>
<td>{item.product_name || "-"}</td>
<td>{item.brand || "-"}</td>
<td>{item.category || "-"}</td>
<td>{formatNumber(item.confidence)}</td>
<td>{item.processType}</td>
<td>{item.runId}</td>
<td>{formatDate(item.fetched_at)}</td>
</tr>
))
) : (
<tr><td colSpan={9}>No products found</td></tr>
)}
</tbody>
</table>
</div>
<div className="pager">
<div>Showing {items?.items.length ?? 0} of {items?.total ?? 0}</div>
<div>
<button disabled={page <= 1} onClick={() => setPage((p) => Math.max(1, p - 1))}>Previous</button>
<span style={{ padding: "0 8px" }}>Page {items?.page ?? page} / {items?.totalPages ?? 1}</span>
<button disabled={Boolean(items && page >= items.totalPages)} onClick={() => setPage((p) => p + 1)}>Next</button>
</div>
</div>
</div>
</div>
);
}
type AppRoute =
| { kind: "dashboard" }
| { kind: "run"; processType: ProcessType; runId: number }
| { kind: "products"; verdict: VerdictFilter };
function parseRoute(pathname: string, search: string): AppRoute {
const runMatch = pathname.match(/^\/runs\/(lead_analysis|category_analysis)\/(\d+)$/);
if (runMatch) {
return { kind: "run", processType: runMatch[1] as ProcessType, runId: Number(runMatch[2]) };
}
if (pathname === "/products") {
const params = new URLSearchParams(search);
const verdictParam = params.get("verdict");
const verdict = verdictParam === "FBA" || verdictParam === "FBM" || verdictParam === "SKIP" ? verdictParam : "";
return { kind: "products", verdict };
}
return { kind: "dashboard" };
}
function App() {
const [route, setRoute] = useState<AppRoute>(() => parseRoute(window.location.pathname, window.location.search));
useEffect(() => {
const onPopState = () => setRoute(parseRoute(window.location.pathname, window.location.search));
window.addEventListener("popstate", onPopState);
return () => window.removeEventListener("popstate", onPopState);
}, []);
function openRun(run: Run) {
const path = `/runs/${run.processType}/${run.runId}`;
history.pushState({}, "", path);
setRoute({ kind: "run", processType: run.processType, runId: run.runId });
}
function openProducts(verdict: VerdictFilter) {
const path = verdict ? `/products?verdict=${encodeURIComponent(verdict)}` : "/products";
history.pushState({}, "", path);
setRoute({ kind: "products", verdict });
}
function backToDashboard() {
history.pushState({}, "", "/");
setRoute({ kind: "dashboard" });
}
if (route.kind === "run") {
return <RunDetails processType={route.processType} runId={route.runId} onBack={backToDashboard} />;
}
if (route.kind === "products") {
return <ProductList verdict={route.verdict} onBack={backToDashboard} />;
}
return <Dashboard onOpenRun={openRun} onOpenProducts={openProducts} />;
}
const root = document.getElementById("root");
if (!root) {
throw new Error("Root element not found");
}
createRoot(root).render(<App />);

13
src/web/index.html Normal file
View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Run Results Viewer</title>
<link rel="stylesheet" href="./styles.css" />
</head>
<body>
<div id="root"></div>
<script type="module" src="./frontend.tsx"></script>
</body>
</html>

258
src/web/styles.css Normal file
View File

@@ -0,0 +1,258 @@
* {
box-sizing: border-box;
}
body {
margin: 0;
font-family: Inter, ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, sans-serif;
background: #f6f7f8;
color: #121418;
}
#root {
max-width: 1280px;
margin: 0 auto;
padding: 20px;
}
h1,
h2,
h3,
p {
margin: 0;
}
.page {
display: flex;
flex-direction: column;
gap: 16px;
}
.card {
background: #ffffff;
border: 1px solid #e7e8ea;
border-radius: 12px;
padding: 14px;
}
.toolbar {
display: grid;
grid-template-columns: repeat(6, minmax(0, 1fr));
gap: 10px;
}
.toolbar input,
.toolbar select,
button {
height: 36px;
border-radius: 8px;
border: 1px solid #d8dce0;
background: #fff;
padding: 0 10px;
font-size: 14px;
}
button {
cursor: pointer;
}
.table-wrap {
overflow: auto;
border: 1px solid #eceef0;
border-radius: 10px;
}
table {
width: 100%;
border-collapse: collapse;
min-width: 980px;
}
th,
td {
text-align: left;
padding: 10px;
border-bottom: 1px solid #f0f1f3;
white-space: nowrap;
font-size: 13px;
}
.reason-col {
min-width: 280px;
max-width: 420px;
white-space: normal;
overflow-wrap: anywhere;
}
th {
background: #fafafb;
font-weight: 600;
}
th button {
all: unset;
cursor: pointer;
}
.badge {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 3px 8px;
border-radius: 999px;
font-size: 12px;
font-weight: 600;
}
.badge-fba {
background: #dff7e6;
color: #1e7a39;
}
.badge-fbm {
background: #e6f0ff;
color: #245fce;
}
.badge-skip {
background: #ffe9e7;
color: #b5382a;
}
.badge-ok {
background: #dff7e6;
color: #1e7a39;
}
.badge-failed {
background: #ffe9e7;
color: #b5382a;
}
.badge-empty {
background: #f2f4f7;
color: #5f6b7a;
}
.metrics {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 10px;
}
.metric {
background: #ffffff;
border: 1px solid #e7e8ea;
border-radius: 12px;
padding: 12px;
}
.metric[role="button"]:hover {
cursor: pointer;
}
.metric .label {
font-size: 12px;
color: #677282;
}
.metric .value {
margin-top: 4px;
font-size: 24px;
font-weight: 700;
}
.pager {
margin-top: 10px;
display: flex;
justify-content: space-between;
align-items: center;
}
.meta-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 12px;
}
.meta {
font-size: 13px;
color: #445060;
}
.back {
width: 120px;
}
.tiny-bar {
width: 100px;
height: 8px;
border-radius: 999px;
background: #eef1f4;
overflow: hidden;
display: flex;
}
.tiny-fba {
background: #1e7a39;
height: 100%;
}
.tiny-fbm {
background: #245fce;
height: 100%;
}
.tiny-skip {
background: #b5382a;
height: 100%;
}
.spark-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 8px;
}
.spark-item {
border: 1px solid #eceef0;
border-radius: 8px;
padding: 8px;
}
.spark-label {
font-size: 11px;
color: #677282;
margin-bottom: 6px;
}
.anomaly-list {
display: grid;
gap: 6px;
}
.anomaly-item {
display: grid;
grid-template-columns: 1fr auto auto;
gap: 10px;
align-items: center;
border: 1px solid #eceef0;
border-radius: 8px;
padding: 8px;
}
@media (max-width: 1000px) {
.toolbar {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.metrics,
.meta-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.spark-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}

View File

@@ -1,9 +1,10 @@
{ {
"compilerOptions": { "compilerOptions": {
// Environment setup & latest features // Environment setup & latest features
"lib": ["ESNext"], "lib": ["ESNext", "DOM", "DOM.Iterable"],
"target": "ESNext", "target": "ESNext",
"module": "Preserve", "module": "Preserve",
"types": ["bun-types", "node"],
"moduleDetection": "force", "moduleDetection": "force",
"jsx": "react-jsx", "jsx": "react-jsx",
"allowJs": true, "allowJs": true,