feat: add top monthly sold by category analysis pipeline
- Introduces a new script to discover categories and identify products with high monthly sales volume. - Filters candidates based on sellability status and a configurable monthly sold threshold. - Enriches product data with Keepa and SP-API metrics before performing LLM-based analysis. - Persists results to SQLite and includes unit tests for the core processing logic.
This commit is contained in:
@@ -5,6 +5,7 @@
|
|||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"bestsellers": "bun run src/bestsellers-by-category.ts",
|
"bestsellers": "bun run src/bestsellers-by-category.ts",
|
||||||
|
"monthly-sold": "bun run src/top-monthly-sold-by-category.ts",
|
||||||
"start": "bun run src/index.ts",
|
"start": "bun run src/index.ts",
|
||||||
"start:web": "bun --hot src/server.ts",
|
"start:web": "bun --hot src/server.ts",
|
||||||
"build:web": "bun build src/web/index.html --outdir dist",
|
"build:web": "bun build src/web/index.html --outdir dist",
|
||||||
|
|||||||
316
src/top-monthly-sold-by-category.test.ts
Normal file
316
src/top-monthly-sold-by-category.test.ts
Normal file
@@ -0,0 +1,316 @@
|
|||||||
|
import { test, expect, beforeAll, afterAll, beforeEach, mock } from "bun:test";
|
||||||
|
import { Database } from "bun:sqlite";
|
||||||
|
import { getDb, initDb, closeDb } from "./database.ts";
|
||||||
|
import path from "node:path";
|
||||||
|
import { rmSync, mkdirSync } from "node:fs";
|
||||||
|
|
||||||
|
const fetchSellabilityBatchMock = mock(async (asins: string[]) => {
|
||||||
|
return new Map(
|
||||||
|
asins.map((asin) => {
|
||||||
|
if (asin === "B000000003") {
|
||||||
|
return [
|
||||||
|
asin,
|
||||||
|
{
|
||||||
|
canSell: false,
|
||||||
|
sellabilityStatus: "restricted" as const,
|
||||||
|
sellabilityReason: "restricted",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
asin,
|
||||||
|
{
|
||||||
|
canSell: true,
|
||||||
|
sellabilityStatus: "available" as const,
|
||||||
|
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) => ({
|
||||||
|
asin: p.record.asin,
|
||||||
|
verdict: "FBA",
|
||||||
|
confidence: 90,
|
||||||
|
reasoning: "mocked",
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
mock.module("./sp-api.ts", () => ({
|
||||||
|
fetchSellabilityBatch: fetchSellabilityBatchMock,
|
||||||
|
fetchSpApiPricingAndFees: fetchSpApiPricingAndFeesMock,
|
||||||
|
}));
|
||||||
|
|
||||||
|
mock.module("./llm.ts", () => ({
|
||||||
|
analyzeProducts: analyzeProductsMock,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const modulePromise = import("./top-monthly-sold-by-category.ts");
|
||||||
|
|
||||||
|
const DB_TEST_PATH = path.join(
|
||||||
|
process.cwd(),
|
||||||
|
"test_output",
|
||||||
|
"test_monthly_sold_analysis.sqlite",
|
||||||
|
);
|
||||||
|
|
||||||
|
let db: Database;
|
||||||
|
let processCategory: (
|
||||||
|
db: Database,
|
||||||
|
runId: number,
|
||||||
|
category: any,
|
||||||
|
perCategoryTop: number,
|
||||||
|
categoryCandidatePool: number,
|
||||||
|
minMonthlySold: 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;
|
||||||
|
|
||||||
|
rmSync(path.dirname(DB_TEST_PATH), { recursive: true, force: true });
|
||||||
|
mkdirSync(path.dirname(DB_TEST_PATH), { recursive: true });
|
||||||
|
initDb(DB_TEST_PATH);
|
||||||
|
db = getDb(DB_TEST_PATH);
|
||||||
|
|
||||||
|
originalFetch = globalThis.fetch;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
globalThis.fetch = originalFetch;
|
||||||
|
closeDb();
|
||||||
|
rmSync(path.dirname(DB_TEST_PATH), { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
db.run("DELETE FROM product_analysis_results");
|
||||||
|
db.run("DELETE FROM category_analysis_runs");
|
||||||
|
|
||||||
|
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",
|
||||||
|
"B000000003",
|
||||||
|
"B000000004",
|
||||||
|
],
|
||||||
|
tokensLeft: 10,
|
||||||
|
refillRate: 1,
|
||||||
|
}),
|
||||||
|
{ status: 200 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url.pathname === "/product") {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
products: [
|
||||||
|
{
|
||||||
|
asin: "B000000001",
|
||||||
|
title: "Product One",
|
||||||
|
monthlySold: 600,
|
||||||
|
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",
|
||||||
|
monthlySold: 250,
|
||||||
|
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" }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
asin: "B000000003",
|
||||||
|
title: "Product Three",
|
||||||
|
monthlySold: 800,
|
||||||
|
stats: {
|
||||||
|
current: [
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
1500,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
1,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
2099,
|
||||||
|
],
|
||||||
|
avg: [2000, null, null, 1800],
|
||||||
|
},
|
||||||
|
csv: [[1, 2099]],
|
||||||
|
categoryTree: [{ name: "Category 1" }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
asin: "B000000004",
|
||||||
|
title: "Product Four",
|
||||||
|
monthlySold: 400,
|
||||||
|
stats: {
|
||||||
|
current: [
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
3000,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
4,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
2899,
|
||||||
|
],
|
||||||
|
avg: [2600, null, null, 2800],
|
||||||
|
},
|
||||||
|
csv: [[1, 2899]],
|
||||||
|
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 filters to sellable ASINs with monthly sold >= threshold and keeps top-N", async () => {
|
||||||
|
const mockCategory = {
|
||||||
|
id: 1,
|
||||||
|
label: "Category 1",
|
||||||
|
parentId: 0,
|
||||||
|
childCount: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
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, 4, 300);
|
||||||
|
|
||||||
|
expect(summary.status).toBe("ok");
|
||||||
|
expect(summary.topAsinsChecked).toBe(4);
|
||||||
|
expect(summary.availableAsins).toBe(2);
|
||||||
|
expect(summary.results?.length).toBe(2);
|
||||||
|
|
||||||
|
const productResults = db
|
||||||
|
.query(
|
||||||
|
"SELECT asin, monthly_sold FROM product_analysis_results ORDER BY monthly_sold DESC",
|
||||||
|
)
|
||||||
|
.all() as Array<{ asin: string; monthly_sold: number }>;
|
||||||
|
|
||||||
|
expect(productResults.length).toBe(2);
|
||||||
|
expect(productResults[0]?.asin).toBe("B000000001");
|
||||||
|
expect(productResults[0]?.monthly_sold).toBe(600);
|
||||||
|
expect(productResults[1]?.asin).toBe("B000000004");
|
||||||
|
expect(productResults[1]?.monthly_sold).toBe(400);
|
||||||
|
});
|
||||||
1279
src/top-monthly-sold-by-category.ts
Normal file
1279
src/top-monthly-sold-by-category.ts
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user