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:
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