Merge branch 'jaime'
This commit is contained in:
@@ -7,6 +7,7 @@
|
||||
"bestsellers": "bun run src/bestsellers-by-category.ts",
|
||||
"monthly-sold": "bun run src/top-monthly-sold-by-category.ts",
|
||||
"mid-range": "bun run src/mid-range-sellers-by-category.ts",
|
||||
"stalker": "bun run src/stalker.ts",
|
||||
"upc": "bun run src/upc-lookup.ts",
|
||||
"upc-file": "bun run src/upc-file-analysis.ts",
|
||||
"start": "bun run src/index.ts",
|
||||
|
||||
158
src/database.ts
158
src/database.ts
@@ -333,4 +333,162 @@ export function initDb(dbPath: string): void {
|
||||
database.run(
|
||||
`CREATE INDEX IF NOT EXISTS idx_product_results_fetched_at ON product_analysis_results(fetched_at DESC);`,
|
||||
);
|
||||
initStalkerDb(database);
|
||||
}
|
||||
|
||||
export function initStalkerDb(database: Database): void {
|
||||
resetLegacyStalkerSchema(database);
|
||||
|
||||
database.run(`
|
||||
CREATE TABLE IF NOT EXISTS stalker_runs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
input_file TEXT NOT NULL,
|
||||
started_at TEXT NOT NULL,
|
||||
completed_at TEXT,
|
||||
requested_asins INTEGER NOT NULL DEFAULT 0,
|
||||
skipped_asins INTEGER NOT NULL DEFAULT 0,
|
||||
scanned_asins INTEGER NOT NULL DEFAULT 0,
|
||||
source_asins_with_matches INTEGER NOT NULL DEFAULT 0,
|
||||
candidate_sellers INTEGER NOT NULL DEFAULT 0,
|
||||
qualifying_sellers INTEGER NOT NULL DEFAULT 0,
|
||||
matched_sellers INTEGER NOT NULL DEFAULT 0,
|
||||
seller_metadata_requests INTEGER NOT NULL DEFAULT 0,
|
||||
seller_storefront_requests INTEGER NOT NULL DEFAULT 0,
|
||||
inventory_sellability_checked_asins INTEGER NOT NULL DEFAULT 0,
|
||||
inventory_sellability_available_asins INTEGER NOT NULL DEFAULT 0,
|
||||
inventory_sellability_excluded_asins INTEGER NOT NULL DEFAULT 0,
|
||||
persisted_inventory_asins INTEGER NOT NULL DEFAULT 0,
|
||||
status TEXT NOT NULL,
|
||||
error_message TEXT
|
||||
);
|
||||
`);
|
||||
|
||||
database.run(`
|
||||
CREATE TABLE IF NOT EXISTS stalker_asin_scans (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
run_id INTEGER NOT NULL,
|
||||
source_asin TEXT NOT NULL,
|
||||
title TEXT,
|
||||
offer_count INTEGER NOT NULL DEFAULT 0,
|
||||
candidate_seller_count INTEGER NOT NULL DEFAULT 0,
|
||||
matched_seller_count INTEGER NOT NULL DEFAULT 0,
|
||||
fetched_at TEXT NOT NULL,
|
||||
raw_product_json TEXT,
|
||||
UNIQUE(run_id, source_asin),
|
||||
FOREIGN KEY (run_id) REFERENCES stalker_runs(id) ON DELETE CASCADE
|
||||
);
|
||||
`);
|
||||
|
||||
database.run(`
|
||||
CREATE TABLE IF NOT EXISTS stalker_sellers (
|
||||
seller_id TEXT PRIMARY KEY,
|
||||
seller_name TEXT,
|
||||
rating REAL,
|
||||
rating_count INTEGER,
|
||||
storefront_asin_total INTEGER,
|
||||
persisted_inventory_sample_count INTEGER,
|
||||
last_updated_at TEXT NOT NULL,
|
||||
raw_seller_json TEXT
|
||||
);
|
||||
`);
|
||||
|
||||
database.run(`
|
||||
CREATE TABLE IF NOT EXISTS stalker_asin_sellers (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
scan_id INTEGER NOT NULL,
|
||||
seller_id TEXT NOT NULL,
|
||||
offer_price REAL,
|
||||
condition TEXT,
|
||||
is_fba INTEGER,
|
||||
stock INTEGER,
|
||||
seller_rating REAL,
|
||||
seller_rating_count INTEGER,
|
||||
raw_offer_json TEXT,
|
||||
UNIQUE(scan_id, seller_id),
|
||||
FOREIGN KEY (scan_id) REFERENCES stalker_asin_scans(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (seller_id) REFERENCES stalker_sellers(seller_id)
|
||||
);
|
||||
`);
|
||||
|
||||
database.run(`
|
||||
CREATE TABLE IF NOT EXISTS stalker_seller_inventory (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
run_id INTEGER NOT NULL,
|
||||
seller_id TEXT NOT NULL,
|
||||
asin TEXT NOT NULL,
|
||||
can_sell INTEGER,
|
||||
sellability_status TEXT,
|
||||
sellability_reason TEXT,
|
||||
product_title TEXT,
|
||||
brand TEXT,
|
||||
category_tree TEXT,
|
||||
current_price REAL,
|
||||
avg_price_90d REAL,
|
||||
sales_rank INTEGER,
|
||||
monthly_sold INTEGER,
|
||||
seller_count INTEGER,
|
||||
amazon_is_seller INTEGER,
|
||||
raw_product_json TEXT,
|
||||
last_seen_at TEXT NOT NULL,
|
||||
raw_inventory_json TEXT,
|
||||
UNIQUE(run_id, seller_id, asin),
|
||||
FOREIGN KEY (run_id) REFERENCES stalker_runs(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (seller_id) REFERENCES stalker_sellers(seller_id)
|
||||
);
|
||||
`);
|
||||
|
||||
database.run(
|
||||
`CREATE INDEX IF NOT EXISTS idx_stalker_runs_started_at ON stalker_runs(started_at DESC);`,
|
||||
);
|
||||
database.run(
|
||||
`CREATE INDEX IF NOT EXISTS idx_stalker_scans_run_id ON stalker_asin_scans(run_id);`,
|
||||
);
|
||||
database.run(
|
||||
`CREATE INDEX IF NOT EXISTS idx_stalker_scans_source_asin ON stalker_asin_scans(source_asin);`,
|
||||
);
|
||||
database.run(
|
||||
`CREATE INDEX IF NOT EXISTS idx_stalker_asin_sellers_seller_id ON stalker_asin_sellers(seller_id);`,
|
||||
);
|
||||
database.run(
|
||||
`CREATE INDEX IF NOT EXISTS idx_stalker_inventory_seller_id ON stalker_seller_inventory(seller_id);`,
|
||||
);
|
||||
database.run(
|
||||
`CREATE INDEX IF NOT EXISTS idx_stalker_inventory_asin ON stalker_seller_inventory(asin);`,
|
||||
);
|
||||
database.run(
|
||||
`CREATE INDEX IF NOT EXISTS idx_stalker_inventory_product_title ON stalker_seller_inventory(product_title);`,
|
||||
);
|
||||
}
|
||||
|
||||
function resetLegacyStalkerSchema(database: Database): void {
|
||||
const runColumns = database
|
||||
.query("PRAGMA table_info(stalker_runs)")
|
||||
.all() as Array<{ name: string }>;
|
||||
if (runColumns.length === 0) return;
|
||||
|
||||
const columnNames = new Set(runColumns.map((column) => column.name));
|
||||
if (
|
||||
columnNames.has("scanned_asins") &&
|
||||
columnNames.has("inventory_sellability_checked_asins") &&
|
||||
inventoryColumnsHaveSellability(database)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
database.run("DROP TABLE IF EXISTS stalker_seller_inventory");
|
||||
database.run("DROP TABLE IF EXISTS stalker_asin_sellers");
|
||||
database.run("DROP TABLE IF EXISTS stalker_sellers");
|
||||
database.run("DROP TABLE IF EXISTS stalker_asin_scans");
|
||||
database.run("DROP TABLE IF EXISTS stalker_runs");
|
||||
}
|
||||
|
||||
function inventoryColumnsHaveSellability(database: Database): boolean {
|
||||
const inventoryColumns = database
|
||||
.query("PRAGMA table_info(stalker_seller_inventory)")
|
||||
.all() as Array<{ name: string }>;
|
||||
const columnNames = new Set(inventoryColumns.map((column) => column.name));
|
||||
return (
|
||||
columnNames.has("sellability_status") &&
|
||||
columnNames.has("product_title")
|
||||
);
|
||||
}
|
||||
|
||||
59
src/index.ts
59
src/index.ts
@@ -1,6 +1,10 @@
|
||||
import { readProducts } from "./reader.ts";
|
||||
import { connectCache, disconnectCache } from "./cache.ts";
|
||||
import { printResults, writeResultsToDb } from "./writer.ts";
|
||||
import {
|
||||
printResults,
|
||||
writeResultsToDb,
|
||||
writeResultsWorkbook,
|
||||
} from "./writer.ts";
|
||||
import { initDb, closeDb } from "./database.ts";
|
||||
import {
|
||||
chunkArray,
|
||||
@@ -40,14 +44,18 @@ function parseArgs(): {
|
||||
sellability: SellabilityFilter;
|
||||
} {
|
||||
const args = process.argv.slice(2);
|
||||
const inputFile = args.find((a) => !a.startsWith("--"));
|
||||
const outIdx = args.indexOf("--out");
|
||||
const outputFile = outIdx !== -1 ? args[outIdx + 1] : undefined;
|
||||
const outputFile = readFlagValue(args, "--out", "--output");
|
||||
const inputFile = readInputFileArg(
|
||||
args,
|
||||
"--out",
|
||||
"--output",
|
||||
"--sellability",
|
||||
);
|
||||
const sellability = parseSellabilityArg(args);
|
||||
|
||||
if (!inputFile) {
|
||||
console.error(
|
||||
"Usage: bun run src/index.ts <input.csv|xlsx> [--out results.csv] [--sellability available|all]",
|
||||
"Usage: bun run src/index.ts <input.csv|xlsx> [--out results.xlsx|--output results.xlsx] [--sellability available|all]",
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
@@ -55,6 +63,44 @@ function parseArgs(): {
|
||||
return { inputFile, outputFile, sellability };
|
||||
}
|
||||
|
||||
function readFlagValue(args: string[], ...flags: string[]): string | undefined {
|
||||
for (const flag of flags) {
|
||||
const equalsArg = args.find((arg) => arg.startsWith(`${flag}=`));
|
||||
if (equalsArg) {
|
||||
const value = equalsArg.slice(flag.length + 1);
|
||||
if (value) return value;
|
||||
}
|
||||
|
||||
const flagIdx = args.indexOf(flag);
|
||||
if (flagIdx !== -1) {
|
||||
return args[flagIdx + 1];
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function readInputFileArg(
|
||||
args: string[],
|
||||
...flagsWithValues: string[]
|
||||
): string | undefined {
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
const arg = args[i]!;
|
||||
if (flagsWithValues.includes(arg)) {
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
if (flagsWithValues.some((flag) => arg.startsWith(`${flag}=`))) {
|
||||
continue;
|
||||
}
|
||||
if (!arg.startsWith("--")) {
|
||||
return arg;
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function resolveBaseOutputPath(inputFile: string, outputFile?: string): string {
|
||||
if (outputFile) return outputFile;
|
||||
|
||||
@@ -103,7 +149,8 @@ async function main() {
|
||||
}
|
||||
|
||||
printResults(allResults);
|
||||
writeResultsToDb(allResults, DB_PATH, inputFile, outputFile);
|
||||
writeResultsWorkbook(allResults, resolvedBaseOutputPath);
|
||||
writeResultsToDb(allResults, DB_PATH, inputFile, resolvedBaseOutputPath);
|
||||
} finally {
|
||||
await disconnectCache();
|
||||
closeDb();
|
||||
|
||||
@@ -48,7 +48,7 @@ test("lookupKeepaUpcs returns found, not_found, and multiple_asins outcomes", as
|
||||
current: [null, null, null, 1234],
|
||||
avg: [2500, null, null, 1400],
|
||||
},
|
||||
csv: [[1, 2999]],
|
||||
csv: [[5000000, 2999, 5000100]],
|
||||
},
|
||||
{
|
||||
asin: "B000MULTI01",
|
||||
@@ -85,6 +85,7 @@ test("lookupKeepaUpcs returns found, not_found, and multiple_asins outcomes", as
|
||||
expect(details.get("012345678901")?.status).toBe("found");
|
||||
expect(details.get("012345678901")?.asin).toBe("B000FOUND01");
|
||||
expect(details.get("012345678901")?.keepaData?.currentPrice).toBe(29.99);
|
||||
expect(details.get("012345678901")?.keepaData?.currentPrice).toBe(29.99);
|
||||
|
||||
expect(details.get("098765432109")?.status).toBe("multiple_asins");
|
||||
expect(details.get("098765432109")?.candidateAsins).toEqual([
|
||||
|
||||
19
src/keepa.ts
19
src/keepa.ts
@@ -531,11 +531,14 @@ function computeAmazonBuyBoxSharePctFromHistory(
|
||||
|
||||
function extractLatestPositivePrice(series: unknown): number | null {
|
||||
if (!Array.isArray(series) || series.length < 2) return null;
|
||||
const last = series[series.length - 1];
|
||||
if (typeof last !== "number" || !Number.isFinite(last) || last <= 0) {
|
||||
return null;
|
||||
for (let i = series.length - 1; i >= 1; i--) {
|
||||
if (i % 2 === 0) continue;
|
||||
const value = series[i];
|
||||
if (typeof value === "number" && Number.isFinite(value) && value > 0) {
|
||||
return value / 100;
|
||||
}
|
||||
return last / 100;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function pickKeepaNumber(...values: unknown[]): number | null {
|
||||
@@ -552,12 +555,10 @@ function extractCurrentPrice(csv: number[][] | undefined): number | null {
|
||||
if (!csv) return null;
|
||||
|
||||
// csv[0] = Amazon price history, csv[1] = Marketplace new price history
|
||||
// Each is [time, price, time, price, ...] — last value is most recent
|
||||
// Each is [time, price, time, price, ...]. Only odd indexes are prices.
|
||||
for (const series of [csv[0], csv[1]]) {
|
||||
if (series && series.length >= 2) {
|
||||
const lastPrice = series[series.length - 1]!;
|
||||
if (lastPrice > 0) return lastPrice / 100;
|
||||
}
|
||||
const latestPrice = extractLatestPositivePrice(series);
|
||||
if (latestPrice != null) return latestPrice;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
564
src/server.ts
564
src/server.ts
@@ -1,5 +1,6 @@
|
||||
import index from "./web/index.html";
|
||||
import path from "node:path";
|
||||
import * as XLSX from "xlsx";
|
||||
import { getDb, initDb } from "./database.ts";
|
||||
import {
|
||||
fetchKeepaDataBatch,
|
||||
@@ -53,6 +54,50 @@ type ProductListRecord = {
|
||||
fetched_at: string;
|
||||
};
|
||||
|
||||
type StalkerResultRecord = {
|
||||
runId: number;
|
||||
started_at: string;
|
||||
status: string;
|
||||
input_file: string;
|
||||
seller_id: string;
|
||||
seller_name: string | null;
|
||||
rating: number | null;
|
||||
rating_count: number | null;
|
||||
storefront_asin_total: number | null;
|
||||
persisted_inventory_sample_count: number | null;
|
||||
discovered_from_count: number;
|
||||
first_seen_at: string;
|
||||
last_seen_at: string;
|
||||
persisted_inventory_asin_count: number;
|
||||
inventory_sample_asins: string | null;
|
||||
};
|
||||
|
||||
type StalkerProductRecord = {
|
||||
runId: number;
|
||||
started_at: string;
|
||||
seller_id: string;
|
||||
seller_name: string | null;
|
||||
rating: number | null;
|
||||
rating_count: number | null;
|
||||
asin: string;
|
||||
can_sell: number;
|
||||
sellability_status: string;
|
||||
sellability_reason: string | null;
|
||||
product_title: string | null;
|
||||
brand: string | null;
|
||||
category_tree: string | null;
|
||||
current_price: number | null;
|
||||
avg_price_90d: number | null;
|
||||
sales_rank: number | null;
|
||||
monthly_sold: number | null;
|
||||
seller_count: number | null;
|
||||
amazon_is_seller: number | null;
|
||||
verdict: string | null;
|
||||
confidence: number | null;
|
||||
reasoning: string | null;
|
||||
last_seen_at: string;
|
||||
};
|
||||
|
||||
const DB_PATH = process.env.RESULTS_DB_PATH || path.join("db", "results.db");
|
||||
const DEFAULT_PAGE_SIZE = 25;
|
||||
const MAX_PAGE_SIZE = 200;
|
||||
@@ -79,6 +124,16 @@ function csv(text: string, filename: string): Response {
|
||||
});
|
||||
}
|
||||
|
||||
function xlsx(buffer: ArrayBuffer, filename: string): Response {
|
||||
return new Response(buffer, {
|
||||
status: 200,
|
||||
headers: {
|
||||
"content-type": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
"content-disposition": `attachment; filename="${filename}"`,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function parseIntParam(value: string | null, fallback: number): number {
|
||||
if (!value) return fallback;
|
||||
const parsed = Number.parseInt(value, 10);
|
||||
@@ -629,6 +684,495 @@ function getProductList(filters: URLSearchParams) {
|
||||
};
|
||||
}
|
||||
|
||||
function parseStalkerFilters(filters: URLSearchParams) {
|
||||
const q = filters.get("q")?.trim() || "";
|
||||
const sellerId = filters.get("sellerId")?.trim().toUpperCase() || "";
|
||||
const runIdRaw = filters.get("runId")?.trim() || "";
|
||||
const minRatingCountRaw = filters.get("minRatingCount")?.trim() || "";
|
||||
const maxRatingCountRaw = filters.get("maxRatingCount")?.trim() || "";
|
||||
|
||||
const conditions: string[] = [];
|
||||
const params: Array<string | number> = [];
|
||||
|
||||
if (runIdRaw) {
|
||||
const runId = Number(runIdRaw);
|
||||
if (Number.isInteger(runId) && runId > 0) {
|
||||
conditions.push("r.id = ?");
|
||||
params.push(runId);
|
||||
}
|
||||
}
|
||||
|
||||
if (sellerId) {
|
||||
conditions.push("s.seller_id = ?");
|
||||
params.push(sellerId);
|
||||
}
|
||||
|
||||
if (minRatingCountRaw) {
|
||||
conditions.push("s.rating_count >= ?");
|
||||
params.push(Number(minRatingCountRaw));
|
||||
}
|
||||
|
||||
if (maxRatingCountRaw) {
|
||||
conditions.push("s.rating_count <= ?");
|
||||
params.push(Number(maxRatingCountRaw));
|
||||
}
|
||||
|
||||
if (q) {
|
||||
const wildcard = `%${q}%`;
|
||||
conditions.push(
|
||||
`(s.seller_id LIKE ? OR s.seller_name LIKE ? OR EXISTS (
|
||||
SELECT 1 FROM stalker_seller_inventory inv_q
|
||||
WHERE inv_q.run_id = r.id
|
||||
AND inv_q.seller_id = s.seller_id
|
||||
AND inv_q.asin LIKE ?
|
||||
))`,
|
||||
);
|
||||
params.push(wildcard, wildcard, wildcard);
|
||||
}
|
||||
|
||||
return {
|
||||
where: conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "",
|
||||
params,
|
||||
};
|
||||
}
|
||||
|
||||
function parseStalkerSort(sortParam: string | null): string {
|
||||
const allowedSort = new Set([
|
||||
"runId",
|
||||
"started_at",
|
||||
"seller_id",
|
||||
"seller_name",
|
||||
"rating",
|
||||
"rating_count",
|
||||
"discovered_from_count",
|
||||
"persisted_inventory_asin_count",
|
||||
"storefront_asin_total",
|
||||
"last_seen_at",
|
||||
]);
|
||||
const parsed = parseSort(
|
||||
sortParam,
|
||||
allowedSort,
|
||||
"persisted_inventory_asin_count DESC, last_seen_at DESC, seller_id ASC",
|
||||
);
|
||||
|
||||
return parsed
|
||||
.replaceAll("runId", "runId")
|
||||
.replaceAll("rating_count", "rating_count")
|
||||
.replaceAll("persisted_inventory_asin_count", "persisted_inventory_asin_count")
|
||||
.replaceAll("storefront_asin_total", "storefront_asin_total");
|
||||
}
|
||||
|
||||
function getStalkerResults(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 { where, params } = parseStalkerFilters(filters);
|
||||
const orderBy = parseStalkerSort(filters.get("sort"));
|
||||
|
||||
const baseSelect = `
|
||||
SELECT
|
||||
r.id AS runId,
|
||||
r.started_at,
|
||||
r.status,
|
||||
r.input_file,
|
||||
s.seller_id,
|
||||
s.seller_name,
|
||||
s.rating,
|
||||
s.rating_count,
|
||||
s.storefront_asin_total,
|
||||
s.persisted_inventory_sample_count,
|
||||
COUNT(DISTINCT sc.source_asin) AS discovered_from_count,
|
||||
MIN(sc.fetched_at) AS first_seen_at,
|
||||
MAX(sc.fetched_at) AS last_seen_at,
|
||||
COUNT(DISTINCT inv.asin) AS persisted_inventory_asin_count,
|
||||
GROUP_CONCAT(DISTINCT inv.asin) AS inventory_sample_asins
|
||||
FROM stalker_asin_sellers sas
|
||||
JOIN stalker_asin_scans sc ON sc.id = sas.scan_id
|
||||
JOIN stalker_runs r ON r.id = sc.run_id
|
||||
JOIN stalker_sellers s ON s.seller_id = sas.seller_id
|
||||
LEFT JOIN stalker_seller_inventory inv
|
||||
ON inv.run_id = r.id
|
||||
AND inv.seller_id = s.seller_id
|
||||
${where}
|
||||
GROUP BY r.id, s.seller_id
|
||||
`;
|
||||
|
||||
const totalRow = db
|
||||
.query(`SELECT COUNT(*) AS total FROM (${baseSelect}) stalker_rows`)
|
||||
.get(...params) as { total: number };
|
||||
|
||||
const summary = db
|
||||
.query(
|
||||
`SELECT
|
||||
COUNT(DISTINCT runId) AS runs,
|
||||
COUNT(DISTINCT seller_id) AS sellers,
|
||||
COALESCE(SUM(persisted_inventory_asin_count), 0) AS persistedInventoryAsins
|
||||
FROM (${baseSelect}) stalker_rows`,
|
||||
)
|
||||
.get(...params) as {
|
||||
runs: number;
|
||||
sellers: number;
|
||||
persistedInventoryAsins: number;
|
||||
};
|
||||
|
||||
const items = db
|
||||
.query(
|
||||
`SELECT * FROM (${baseSelect}) stalker_rows
|
||||
ORDER BY ${orderBy}
|
||||
LIMIT ? OFFSET ?`,
|
||||
)
|
||||
.all(...params, pageSize, offset) as StalkerResultRecord[];
|
||||
|
||||
return {
|
||||
items,
|
||||
summary,
|
||||
page,
|
||||
pageSize,
|
||||
total: totalRow.total,
|
||||
totalPages: Math.max(1, Math.ceil(totalRow.total / pageSize)),
|
||||
};
|
||||
}
|
||||
|
||||
function parseStalkerProductFilters(filters: URLSearchParams) {
|
||||
const q = filters.get("q")?.trim() || "";
|
||||
const sellerId = filters.get("sellerId")?.trim().toUpperCase() || "";
|
||||
const runIdRaw = filters.get("runId")?.trim() || "";
|
||||
const verdict = filters.get("verdict")?.trim().toUpperCase() || "";
|
||||
const amazonIsSeller = filters.get("amazonIsSeller")?.trim() || "";
|
||||
const minPriceRaw = filters.get("minPrice")?.trim() || "";
|
||||
const maxPriceRaw = filters.get("maxPrice")?.trim() || "";
|
||||
const minMonthlySoldRaw = filters.get("minMonthlySold")?.trim() || "";
|
||||
const maxMonthlySoldRaw = filters.get("maxMonthlySold")?.trim() || "";
|
||||
const minSalesRankRaw = filters.get("minSalesRank")?.trim() || "";
|
||||
const maxSalesRankRaw = filters.get("maxSalesRank")?.trim() || "";
|
||||
const minSellerCountRaw = filters.get("minSellerCount")?.trim() || "";
|
||||
const maxSellerCountRaw = filters.get("maxSellerCount")?.trim() || "";
|
||||
const minRatingCountRaw = filters.get("minRatingCount")?.trim() || "";
|
||||
const maxRatingCountRaw = filters.get("maxRatingCount")?.trim() || "";
|
||||
const minConfidenceRaw = filters.get("minConfidence")?.trim() || "";
|
||||
const maxConfidenceRaw = filters.get("maxConfidence")?.trim() || "";
|
||||
|
||||
const conditions = [
|
||||
"inv.can_sell = 1",
|
||||
"inv.sellability_status = 'available'",
|
||||
];
|
||||
const params: Array<string | number> = [];
|
||||
|
||||
if (runIdRaw) {
|
||||
const runId = Number(runIdRaw);
|
||||
if (Number.isInteger(runId) && runId > 0) {
|
||||
conditions.push("r.id = ?");
|
||||
params.push(runId);
|
||||
}
|
||||
}
|
||||
|
||||
if (sellerId) {
|
||||
conditions.push("s.seller_id = ?");
|
||||
params.push(sellerId);
|
||||
}
|
||||
|
||||
if (verdict === "FBA" || verdict === "FBM" || verdict === "SKIP") {
|
||||
conditions.push("analysis.verdict = ?");
|
||||
params.push(verdict);
|
||||
} else if (verdict === "UNANALYZED") {
|
||||
conditions.push("analysis.verdict IS NULL");
|
||||
}
|
||||
|
||||
if (amazonIsSeller === "yes") {
|
||||
conditions.push("inv.amazon_is_seller = 1");
|
||||
} else if (amazonIsSeller === "no") {
|
||||
conditions.push("inv.amazon_is_seller = 0");
|
||||
} else if (amazonIsSeller === "unknown") {
|
||||
conditions.push("inv.amazon_is_seller IS NULL");
|
||||
}
|
||||
|
||||
const numericFilters: Array<[string, string, string]> = [
|
||||
[minPriceRaw, "inv.current_price >= ?", "minPrice"],
|
||||
[maxPriceRaw, "inv.current_price <= ?", "maxPrice"],
|
||||
[minMonthlySoldRaw, "inv.monthly_sold >= ?", "minMonthlySold"],
|
||||
[maxMonthlySoldRaw, "inv.monthly_sold <= ?", "maxMonthlySold"],
|
||||
[minSalesRankRaw, "inv.sales_rank >= ?", "minSalesRank"],
|
||||
[maxSalesRankRaw, "inv.sales_rank <= ?", "maxSalesRank"],
|
||||
[minSellerCountRaw, "inv.seller_count >= ?", "minSellerCount"],
|
||||
[maxSellerCountRaw, "inv.seller_count <= ?", "maxSellerCount"],
|
||||
[minRatingCountRaw, "s.rating_count >= ?", "minRatingCount"],
|
||||
[maxRatingCountRaw, "s.rating_count <= ?", "maxRatingCount"],
|
||||
[minConfidenceRaw, "analysis.confidence >= ?", "minConfidence"],
|
||||
[maxConfidenceRaw, "analysis.confidence <= ?", "maxConfidence"],
|
||||
];
|
||||
|
||||
for (const [raw, condition] of numericFilters) {
|
||||
if (!raw) continue;
|
||||
const value = Number(raw);
|
||||
if (Number.isFinite(value)) {
|
||||
conditions.push(condition);
|
||||
params.push(value);
|
||||
}
|
||||
}
|
||||
|
||||
if (q) {
|
||||
const wildcard = `%${q}%`;
|
||||
conditions.push(
|
||||
`(
|
||||
inv.asin LIKE ?
|
||||
OR inv.product_title LIKE ?
|
||||
OR inv.brand LIKE ?
|
||||
OR inv.category_tree LIKE ?
|
||||
OR s.seller_id LIKE ?
|
||||
OR s.seller_name LIKE ?
|
||||
)`,
|
||||
);
|
||||
params.push(wildcard, wildcard, wildcard, wildcard, wildcard, wildcard);
|
||||
}
|
||||
|
||||
return {
|
||||
where: `WHERE ${conditions.join(" AND ")}`,
|
||||
params,
|
||||
};
|
||||
}
|
||||
|
||||
function parseStalkerProductSort(sortParam: string | null): string {
|
||||
const allowedSort = new Set([
|
||||
"runId",
|
||||
"started_at",
|
||||
"seller_id",
|
||||
"seller_name",
|
||||
"rating",
|
||||
"rating_count",
|
||||
"asin",
|
||||
"product_title",
|
||||
"brand",
|
||||
"current_price",
|
||||
"avg_price_90d",
|
||||
"sales_rank",
|
||||
"monthly_sold",
|
||||
"seller_count",
|
||||
"amazon_is_seller",
|
||||
"verdict",
|
||||
"confidence",
|
||||
"last_seen_at",
|
||||
]);
|
||||
return parseSort(sortParam, allowedSort, "monthly_sold DESC, last_seen_at DESC, asin ASC");
|
||||
}
|
||||
|
||||
function getStalkerProducts(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 { where, params } = parseStalkerProductFilters(filters);
|
||||
const orderBy = parseStalkerProductSort(filters.get("sort"));
|
||||
|
||||
const baseSelect = `
|
||||
SELECT
|
||||
r.id AS runId,
|
||||
r.started_at,
|
||||
s.seller_id,
|
||||
s.seller_name,
|
||||
s.rating,
|
||||
s.rating_count,
|
||||
inv.asin,
|
||||
inv.can_sell,
|
||||
inv.sellability_status,
|
||||
inv.sellability_reason,
|
||||
inv.product_title,
|
||||
inv.brand,
|
||||
inv.category_tree,
|
||||
inv.current_price,
|
||||
inv.avg_price_90d,
|
||||
inv.sales_rank,
|
||||
inv.monthly_sold,
|
||||
inv.seller_count,
|
||||
inv.amazon_is_seller,
|
||||
analysis.verdict,
|
||||
analysis.confidence,
|
||||
analysis.reasoning,
|
||||
inv.last_seen_at
|
||||
FROM stalker_seller_inventory inv
|
||||
JOIN stalker_runs r ON r.id = inv.run_id
|
||||
JOIN stalker_sellers s ON s.seller_id = inv.seller_id
|
||||
LEFT JOIN product_analysis_results analysis ON analysis.asin = inv.asin
|
||||
${where}
|
||||
`;
|
||||
|
||||
const totalRow = db
|
||||
.query(`SELECT COUNT(*) AS total FROM (${baseSelect}) stalker_products`)
|
||||
.get(...params) as { total: number };
|
||||
|
||||
const summary = db
|
||||
.query(
|
||||
`SELECT
|
||||
COUNT(DISTINCT runId) AS runs,
|
||||
COUNT(DISTINCT seller_id) AS sellers,
|
||||
COUNT(DISTINCT asin) AS products
|
||||
FROM (${baseSelect}) stalker_products`,
|
||||
)
|
||||
.get(...params) as {
|
||||
runs: number;
|
||||
sellers: number;
|
||||
products: number;
|
||||
};
|
||||
|
||||
const items = db
|
||||
.query(
|
||||
`SELECT * FROM (${baseSelect}) stalker_products
|
||||
ORDER BY ${orderBy}
|
||||
LIMIT ? OFFSET ?`,
|
||||
)
|
||||
.all(...params, pageSize, offset) as StalkerProductRecord[];
|
||||
|
||||
return {
|
||||
items,
|
||||
summary,
|
||||
page,
|
||||
pageSize,
|
||||
total: totalRow.total,
|
||||
totalPages: Math.max(1, Math.ceil(totalRow.total / pageSize)),
|
||||
};
|
||||
}
|
||||
|
||||
function getStalkerProductsForExport(filters: URLSearchParams): StalkerProductRecord[] {
|
||||
const { where, params } = parseStalkerProductFilters(filters);
|
||||
const orderBy = parseStalkerProductSort(filters.get("sort"));
|
||||
|
||||
return db
|
||||
.query(
|
||||
`SELECT * FROM (
|
||||
SELECT
|
||||
r.id AS runId,
|
||||
r.started_at,
|
||||
s.seller_id,
|
||||
s.seller_name,
|
||||
s.rating,
|
||||
s.rating_count,
|
||||
inv.asin,
|
||||
inv.can_sell,
|
||||
inv.sellability_status,
|
||||
inv.sellability_reason,
|
||||
inv.product_title,
|
||||
inv.brand,
|
||||
inv.category_tree,
|
||||
inv.current_price,
|
||||
inv.avg_price_90d,
|
||||
inv.sales_rank,
|
||||
inv.monthly_sold,
|
||||
inv.seller_count,
|
||||
inv.amazon_is_seller,
|
||||
analysis.verdict,
|
||||
analysis.confidence,
|
||||
analysis.reasoning,
|
||||
inv.last_seen_at
|
||||
FROM stalker_seller_inventory inv
|
||||
JOIN stalker_runs r ON r.id = inv.run_id
|
||||
JOIN stalker_sellers s ON s.seller_id = inv.seller_id
|
||||
LEFT JOIN product_analysis_results analysis ON analysis.asin = inv.asin
|
||||
${where}
|
||||
) stalker_products
|
||||
ORDER BY ${orderBy}`,
|
||||
)
|
||||
.all(...params) as StalkerProductRecord[];
|
||||
}
|
||||
|
||||
function parseCategoryTreeForExport(value: string | null): string {
|
||||
if (!value) return "";
|
||||
try {
|
||||
const parsed = JSON.parse(value);
|
||||
return Array.isArray(parsed)
|
||||
? parsed.filter((item) => typeof item === "string").join(" > ")
|
||||
: "";
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
function exportStalkerProductsXlsx(filters: URLSearchParams): Response {
|
||||
const rows = getStalkerProductsForExport(filters);
|
||||
const data = rows.map((row) => ({
|
||||
ASIN: row.asin,
|
||||
"Amazon URL": `https://amazon.com/dp/${row.asin}`,
|
||||
Product: row.product_title ?? "",
|
||||
Brand: row.brand ?? "",
|
||||
Category: parseCategoryTreeForExport(row.category_tree),
|
||||
"Monthly Sold": row.monthly_sold ?? null,
|
||||
Sellers: row.seller_count ?? null,
|
||||
"Amazon Seller": row.amazon_is_seller == null ? "" : row.amazon_is_seller === 1 ? "Yes" : "No",
|
||||
"Sales Rank": row.sales_rank ?? null,
|
||||
"Current Price": row.current_price ?? null,
|
||||
"Avg 90d": row.avg_price_90d ?? null,
|
||||
Verdict: row.verdict ?? "",
|
||||
Confidence: row.confidence ?? null,
|
||||
Reasoning: row.reasoning ?? "",
|
||||
"Seller ID": row.seller_id,
|
||||
Seller: row.seller_name ?? "",
|
||||
"Seller Rating": row.rating ?? null,
|
||||
"Seller Rating Count": row.rating_count ?? null,
|
||||
"Sellability Status": row.sellability_status,
|
||||
"Sellability Reason": row.sellability_reason ?? "",
|
||||
"Run ID": row.runId,
|
||||
"Last Seen": row.last_seen_at,
|
||||
}));
|
||||
|
||||
const workbook = XLSX.utils.book_new();
|
||||
const worksheet = XLSX.utils.json_to_sheet(data);
|
||||
worksheet["!cols"] = [
|
||||
{ wch: 12 },
|
||||
{ wch: 32 },
|
||||
{ wch: 48 },
|
||||
{ wch: 20 },
|
||||
{ wch: 34 },
|
||||
{ wch: 14 },
|
||||
{ wch: 10 },
|
||||
{ wch: 14 },
|
||||
{ wch: 12 },
|
||||
{ wch: 12 },
|
||||
{ wch: 12 },
|
||||
{ wch: 10 },
|
||||
{ wch: 12 },
|
||||
{ wch: 60 },
|
||||
{ wch: 18 },
|
||||
{ wch: 24 },
|
||||
{ wch: 12 },
|
||||
{ wch: 20 },
|
||||
{ wch: 18 },
|
||||
{ wch: 40 },
|
||||
{ wch: 10 },
|
||||
{ wch: 24 },
|
||||
];
|
||||
XLSX.utils.book_append_sheet(workbook, worksheet, "Sellable Products");
|
||||
|
||||
const buffer = XLSX.write(workbook, {
|
||||
type: "array",
|
||||
bookType: "xlsx",
|
||||
}) as ArrayBuffer;
|
||||
|
||||
return xlsx(buffer, "stalker-sellable-products.xlsx");
|
||||
}
|
||||
|
||||
function purgeStalkerData() {
|
||||
const counts = {
|
||||
inventory: (db.query("SELECT COUNT(*) AS count FROM stalker_seller_inventory").get() as { count: number }).count,
|
||||
asinSellers: (db.query("SELECT COUNT(*) AS count FROM stalker_asin_sellers").get() as { count: number }).count,
|
||||
sellers: (db.query("SELECT COUNT(*) AS count FROM stalker_sellers").get() as { count: number }).count,
|
||||
scans: (db.query("SELECT COUNT(*) AS count FROM stalker_asin_scans").get() as { count: number }).count,
|
||||
runs: (db.query("SELECT COUNT(*) AS count FROM stalker_runs").get() as { count: number }).count,
|
||||
};
|
||||
|
||||
db.transaction(() => {
|
||||
db.run("DELETE FROM stalker_seller_inventory");
|
||||
db.run("DELETE FROM stalker_asin_sellers");
|
||||
db.run("DELETE FROM stalker_sellers");
|
||||
db.run("DELETE FROM stalker_asin_scans");
|
||||
db.run("DELETE FROM stalker_runs");
|
||||
})();
|
||||
|
||||
return { ok: true, deleted: counts };
|
||||
}
|
||||
|
||||
function getRun(processType: ProcessType, runId: number) {
|
||||
if (processType === "lead_analysis") {
|
||||
const run = db
|
||||
@@ -1259,6 +1803,8 @@ const server = Bun.serve({
|
||||
routes: {
|
||||
"/": index,
|
||||
"/products": index,
|
||||
"/stalker": index,
|
||||
"/stalker/products": index,
|
||||
"/runs/:processType/:runId": index,
|
||||
"/api/runs": (req) => {
|
||||
const url = new URL(req.url);
|
||||
@@ -1268,6 +1814,24 @@ const server = Bun.serve({
|
||||
const url = new URL(req.url);
|
||||
return json(getProductList(url.searchParams));
|
||||
},
|
||||
"/api/stalker/results": (req) => {
|
||||
const url = new URL(req.url);
|
||||
return json(getStalkerResults(url.searchParams));
|
||||
},
|
||||
"/api/stalker/products": (req) => {
|
||||
const url = new URL(req.url);
|
||||
return json(getStalkerProducts(url.searchParams));
|
||||
},
|
||||
"/api/stalker/products/export.xlsx": (req) => {
|
||||
const url = new URL(req.url);
|
||||
return exportStalkerProductsXlsx(url.searchParams);
|
||||
},
|
||||
"/api/stalker/purge": (req) => {
|
||||
if (req.method !== "DELETE" && req.method !== "POST") {
|
||||
return json({ error: "Method not allowed" }, 405);
|
||||
}
|
||||
return json(purgeStalkerData());
|
||||
},
|
||||
"/api/upc/map": async (req) => {
|
||||
let upcs: string[];
|
||||
try {
|
||||
|
||||
@@ -123,7 +123,10 @@ function round2(value: number): number {
|
||||
return Math.round(value * 100) / 100;
|
||||
}
|
||||
|
||||
const SELLABILITY_CONCURRENCY = 5;
|
||||
const LISTINGS_RESTRICTIONS_RATE_LIMIT_PER_SECOND = 5;
|
||||
const LISTINGS_RESTRICTIONS_BURST_REQUESTS = 10;
|
||||
const SELLABILITY_CONCURRENCY = LISTINGS_RESTRICTIONS_RATE_LIMIT_PER_SECOND;
|
||||
const SELLABILITY_PROGRESS_INTERVAL = LISTINGS_RESTRICTIONS_BURST_REQUESTS;
|
||||
const PRICING_CONCURRENCY = 5;
|
||||
const UPC_PATTERN = /^\d{12,14}$/;
|
||||
|
||||
@@ -621,7 +624,6 @@ export async function fetchSellabilityBatch(
|
||||
}
|
||||
|
||||
let completed = 0;
|
||||
let running = 0;
|
||||
const queue = [...asins];
|
||||
|
||||
async function next(): Promise<void> {
|
||||
@@ -630,7 +632,10 @@ export async function fetchSellabilityBatch(
|
||||
const info = await fetchSellabilityInternal(spClient!, asin);
|
||||
results.set(asin, info);
|
||||
completed++;
|
||||
if (completed % 10 === 0 || completed === asins.length) {
|
||||
if (
|
||||
completed % SELLABILITY_PROGRESS_INTERVAL === 0 ||
|
||||
completed === asins.length
|
||||
) {
|
||||
console.log(` [sellability] ${completed}/${asins.length} checked`);
|
||||
}
|
||||
}
|
||||
|
||||
374
src/stalker-analyze.ts
Normal file
374
src/stalker-analyze.ts
Normal file
@@ -0,0 +1,374 @@
|
||||
import { type Database, closeDb, getDb, initDb } from "./database.ts";
|
||||
import { analyzeProducts } from "./llm.ts";
|
||||
import { fetchSpApiPricingAndFees } from "./sp-api.ts";
|
||||
import type {
|
||||
AnalysisResult,
|
||||
EnrichedProduct,
|
||||
KeepaData,
|
||||
ProductRecord,
|
||||
SellabilityInfo,
|
||||
} from "./types.ts";
|
||||
|
||||
const LLM_BATCH_SIZE = 5;
|
||||
const LLM_BATCH_DELAY_MS = 5_000;
|
||||
|
||||
type Args = {
|
||||
dbPath: string;
|
||||
stalkerRunId: number;
|
||||
analysisRunId: number;
|
||||
asins: string[];
|
||||
};
|
||||
|
||||
type InventoryRow = {
|
||||
asin: string;
|
||||
product_title: string | null;
|
||||
brand: string | null;
|
||||
category_tree: string | null;
|
||||
current_price: number | null;
|
||||
avg_price_90d: number | null;
|
||||
sales_rank: number | null;
|
||||
monthly_sold: number | null;
|
||||
seller_count: number | null;
|
||||
amazon_is_seller: number | null;
|
||||
can_sell: number | null;
|
||||
sellability_status: SellabilityInfo["sellabilityStatus"] | null;
|
||||
sellability_reason: string | null;
|
||||
};
|
||||
|
||||
function readFlagValue(args: string[], flag: string): string | undefined {
|
||||
const index = args.indexOf(flag);
|
||||
if (index === -1) return undefined;
|
||||
return args[index + 1];
|
||||
}
|
||||
|
||||
function parseArgs(argv = process.argv.slice(2)): Args {
|
||||
const dbPath = readFlagValue(argv, "--db");
|
||||
const stalkerRunId = Number(readFlagValue(argv, "--stalker-run-id"));
|
||||
const analysisRunId = Number(readFlagValue(argv, "--analysis-run-id"));
|
||||
const asins = (readFlagValue(argv, "--asins") ?? "")
|
||||
.split(",")
|
||||
.map((asin) => asin.trim().toUpperCase())
|
||||
.filter(Boolean);
|
||||
|
||||
if (!dbPath) throw new Error("Missing --db");
|
||||
if (!Number.isInteger(stalkerRunId) || stalkerRunId <= 0) {
|
||||
throw new Error("--stalker-run-id must be a positive integer");
|
||||
}
|
||||
if (!Number.isInteger(analysisRunId) || analysisRunId <= 0) {
|
||||
throw new Error("--analysis-run-id must be a positive integer");
|
||||
}
|
||||
if (asins.length === 0) throw new Error("Missing --asins");
|
||||
|
||||
return { dbPath, stalkerRunId, analysisRunId, asins };
|
||||
}
|
||||
|
||||
function wait(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
function parseCategoryTree(value: string | null): string[] {
|
||||
if (!value) return [];
|
||||
try {
|
||||
const parsed = JSON.parse(value);
|
||||
return Array.isArray(parsed)
|
||||
? parsed.filter((item): item is string => typeof item === "string")
|
||||
: [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function toProductRecord(row: InventoryRow): ProductRecord {
|
||||
const categoryTree = parseCategoryTree(row.category_tree);
|
||||
return {
|
||||
asin: row.asin,
|
||||
name: row.product_title ?? row.asin,
|
||||
brand: row.brand ?? undefined,
|
||||
category: categoryTree.join(" > ") || undefined,
|
||||
unitCost: 0,
|
||||
amazonRank: row.sales_rank ?? undefined,
|
||||
sellingPriceFromSheet: row.current_price ?? undefined,
|
||||
avgPrice90FromSheet: row.avg_price_90d ?? undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function toKeepaData(row: InventoryRow): KeepaData {
|
||||
return {
|
||||
currentPrice: row.current_price,
|
||||
avgPrice90: row.avg_price_90d,
|
||||
minPrice90: null,
|
||||
maxPrice90: null,
|
||||
salesRank: row.sales_rank,
|
||||
salesRankAvg90: null,
|
||||
salesRankDrops30: null,
|
||||
salesRankDrops90: null,
|
||||
sellerCount: row.seller_count,
|
||||
amazonIsSeller:
|
||||
row.amazon_is_seller == null ? null : row.amazon_is_seller === 1,
|
||||
amazonBuyboxSharePct90d: null,
|
||||
buyBoxSeller: null,
|
||||
buyBoxPrice: null,
|
||||
buyBoxAvg90: null,
|
||||
monthlySold: row.monthly_sold,
|
||||
categoryTree: parseCategoryTree(row.category_tree),
|
||||
};
|
||||
}
|
||||
|
||||
function toSellability(row: InventoryRow): SellabilityInfo {
|
||||
return {
|
||||
canSell: row.can_sell == null ? null : row.can_sell === 1,
|
||||
sellabilityStatus: row.sellability_status ?? "unknown",
|
||||
sellabilityReason: row.sellability_reason ?? undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function loadInventoryRows(
|
||||
database: Database,
|
||||
stalkerRunId: number,
|
||||
asins: string[],
|
||||
): InventoryRow[] {
|
||||
const placeholders = asins.map(() => "?").join(",");
|
||||
return database
|
||||
.query(
|
||||
`SELECT
|
||||
asin, product_title, brand, category_tree, current_price, avg_price_90d,
|
||||
sales_rank, monthly_sold, seller_count, amazon_is_seller, can_sell,
|
||||
sellability_status, sellability_reason
|
||||
FROM stalker_seller_inventory
|
||||
WHERE run_id = ?
|
||||
AND can_sell = 1
|
||||
AND sellability_status = 'available'
|
||||
AND asin IN (${placeholders})
|
||||
GROUP BY asin`,
|
||||
)
|
||||
.all(stalkerRunId, ...asins) as InventoryRow[];
|
||||
}
|
||||
|
||||
async function buildEnrichedProducts(
|
||||
rows: InventoryRow[],
|
||||
): Promise<EnrichedProduct[]> {
|
||||
const enriched: EnrichedProduct[] = [];
|
||||
|
||||
for (const row of rows) {
|
||||
const sellability = toSellability(row);
|
||||
const spApi = await fetchSpApiPricingAndFees(
|
||||
row.asin,
|
||||
sellability,
|
||||
row.current_price,
|
||||
);
|
||||
|
||||
enriched.push({
|
||||
record: toProductRecord(row),
|
||||
keepa: toKeepaData(row),
|
||||
spApi,
|
||||
fetchedAt: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
return enriched;
|
||||
}
|
||||
|
||||
function insertProductAnalysisResults(
|
||||
database: Database,
|
||||
runId: number,
|
||||
results: AnalysisResult[],
|
||||
): void {
|
||||
if (results.length === 0) return;
|
||||
|
||||
const insert = database.prepare(`
|
||||
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, amazon_is_seller, amazon_buybox_share_pct_90d,
|
||||
monthly_sold, rank_drops_30d, rank_drops_90d,
|
||||
fba_fee, fbm_fee, referral_percent, can_sell,
|
||||
sellability_status, sellability_reason,
|
||||
verdict, confidence, reasoning, fetched_at
|
||||
) VALUES (
|
||||
?, ?, ?, ?, ?, ?, ?, ?, ?, ?,
|
||||
?, ?, ?, ?, ?, ?, ?, ?, ?, ?,
|
||||
?, ?, ?, ?, ?, ?, ?, ?
|
||||
)
|
||||
ON CONFLICT(asin) DO UPDATE SET
|
||||
run_id = excluded.run_id,
|
||||
name = excluded.name,
|
||||
brand = excluded.brand,
|
||||
category = excluded.category,
|
||||
unit_cost = excluded.unit_cost,
|
||||
current_price = excluded.current_price,
|
||||
avg_price_90d = excluded.avg_price_90d,
|
||||
avg_price_90d_sheet = excluded.avg_price_90d_sheet,
|
||||
selling_price_sheet = excluded.selling_price_sheet,
|
||||
sales_rank = excluded.sales_rank,
|
||||
sales_rank_avg_90d = excluded.sales_rank_avg_90d,
|
||||
seller_count = excluded.seller_count,
|
||||
amazon_is_seller = excluded.amazon_is_seller,
|
||||
amazon_buybox_share_pct_90d = excluded.amazon_buybox_share_pct_90d,
|
||||
monthly_sold = excluded.monthly_sold,
|
||||
rank_drops_30d = excluded.rank_drops_30d,
|
||||
rank_drops_90d = excluded.rank_drops_90d,
|
||||
fba_fee = excluded.fba_fee,
|
||||
fbm_fee = excluded.fbm_fee,
|
||||
referral_percent = excluded.referral_percent,
|
||||
can_sell = excluded.can_sell,
|
||||
sellability_status = excluded.sellability_status,
|
||||
sellability_reason = excluded.sellability_reason,
|
||||
verdict = excluded.verdict,
|
||||
confidence = excluded.confidence,
|
||||
reasoning = excluded.reasoning,
|
||||
fetched_at = excluded.fetched_at
|
||||
`);
|
||||
|
||||
database.transaction((batch: AnalysisResult[]) => {
|
||||
for (const result of batch) {
|
||||
const keepa = result.product.keepa;
|
||||
const record = result.product.record;
|
||||
const spApi = result.product.spApi;
|
||||
insert.run(
|
||||
record.asin,
|
||||
runId,
|
||||
record.name,
|
||||
record.brand ?? null,
|
||||
record.category ?? keepa?.categoryTree.join(" > ") ?? null,
|
||||
record.unitCost ?? null,
|
||||
keepa?.currentPrice ?? spApi.estimatedSalePrice ?? null,
|
||||
keepa?.avgPrice90 ?? null,
|
||||
record.avgPrice90FromSheet ?? null,
|
||||
record.sellingPriceFromSheet ?? null,
|
||||
keepa?.salesRank ?? record.amazonRank ?? null,
|
||||
keepa?.salesRankAvg90 ?? null,
|
||||
keepa?.sellerCount ?? null,
|
||||
keepa?.amazonIsSeller == null ? null : keepa.amazonIsSeller ? 1 : 0,
|
||||
keepa?.amazonBuyboxSharePct90d ?? null,
|
||||
keepa?.monthlySold ?? null,
|
||||
keepa?.salesRankDrops30 ?? null,
|
||||
keepa?.salesRankDrops90 ?? null,
|
||||
spApi.fbaFee ?? null,
|
||||
spApi.fbmFee ?? null,
|
||||
spApi.referralFeePercent ?? null,
|
||||
spApi.canSell == null ? "unknown" : spApi.canSell ? "yes" : "no",
|
||||
spApi.sellabilityStatus ?? null,
|
||||
spApi.sellabilityReason ?? null,
|
||||
result.verdict.verdict,
|
||||
result.verdict.confidence,
|
||||
result.verdict.reasoning ?? null,
|
||||
result.product.fetchedAt,
|
||||
);
|
||||
}
|
||||
})(results);
|
||||
}
|
||||
|
||||
function refreshAnalysisRun(database: Database, runId: number): void {
|
||||
const stats = database
|
||||
.query(
|
||||
`SELECT
|
||||
COUNT(*) AS total,
|
||||
SUM(CASE WHEN verdict = 'FBA' THEN 1 ELSE 0 END) AS fba,
|
||||
SUM(CASE WHEN verdict = 'FBM' THEN 1 ELSE 0 END) AS fbm,
|
||||
SUM(CASE WHEN verdict = 'SKIP' THEN 1 ELSE 0 END) AS skip
|
||||
FROM product_analysis_results
|
||||
WHERE run_id = ?`,
|
||||
)
|
||||
.get(runId) as {
|
||||
total: number;
|
||||
fba: number | null;
|
||||
fbm: number | null;
|
||||
skip: number | null;
|
||||
};
|
||||
|
||||
database
|
||||
.prepare(
|
||||
`UPDATE category_analysis_runs
|
||||
SET top_asins_checked = ?,
|
||||
available_asins = ?,
|
||||
fba_count = ?,
|
||||
fbm_count = ?,
|
||||
skip_count = ?
|
||||
WHERE id = ?`,
|
||||
)
|
||||
.run(
|
||||
stats.total ?? 0,
|
||||
stats.total ?? 0,
|
||||
stats.fba ?? 0,
|
||||
stats.fbm ?? 0,
|
||||
stats.skip ?? 0,
|
||||
runId,
|
||||
);
|
||||
}
|
||||
|
||||
async function analyzeInBatches(
|
||||
products: EnrichedProduct[],
|
||||
): Promise<AnalysisResult[]> {
|
||||
const results: AnalysisResult[] = [];
|
||||
|
||||
for (let i = 0; i < products.length; i += LLM_BATCH_SIZE) {
|
||||
const batch = products.slice(i, i + LLM_BATCH_SIZE);
|
||||
const batchNumber = Math.floor(i / LLM_BATCH_SIZE) + 1;
|
||||
const totalBatches = Math.ceil(products.length / LLM_BATCH_SIZE);
|
||||
console.log(
|
||||
`Stalker analysis: LLM batch ${batchNumber}/${totalBatches} (${batch.length} product(s)).`,
|
||||
);
|
||||
|
||||
if (i > 0) {
|
||||
await wait(LLM_BATCH_DELAY_MS);
|
||||
}
|
||||
|
||||
let verdicts;
|
||||
try {
|
||||
verdicts = await analyzeProducts(batch);
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
`Stalker analysis: LLM batch ${batchNumber} failed: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
);
|
||||
verdicts = null;
|
||||
}
|
||||
|
||||
for (let j = 0; j < batch.length; j++) {
|
||||
const product = batch[j];
|
||||
if (!product) continue;
|
||||
results.push({
|
||||
product,
|
||||
verdict: verdicts?.[j] ?? {
|
||||
asin: product.record.asin,
|
||||
verdict: "SKIP",
|
||||
confidence: 0,
|
||||
reasoning: "LLM analysis failed or returned no verdict",
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const args = parseArgs();
|
||||
initDb(args.dbPath);
|
||||
const database = getDb(args.dbPath);
|
||||
|
||||
try {
|
||||
const rows = loadInventoryRows(database, args.stalkerRunId, args.asins);
|
||||
if (rows.length === 0) {
|
||||
console.log("Stalker analysis: no sellable inventory rows to analyze.");
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`Stalker analysis: analyzing ${rows.length} sellable ASIN(s).`);
|
||||
const enriched = await buildEnrichedProducts(rows);
|
||||
const results = await analyzeInBatches(enriched);
|
||||
insertProductAnalysisResults(database, args.analysisRunId, results);
|
||||
refreshAnalysisRun(database, args.analysisRunId);
|
||||
} finally {
|
||||
closeDb();
|
||||
}
|
||||
}
|
||||
|
||||
if (import.meta.main) {
|
||||
main().catch((error) => {
|
||||
console.error(error instanceof Error ? error.message : String(error));
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
207
src/stalker-sellability.test.ts
Normal file
207
src/stalker-sellability.test.ts
Normal file
@@ -0,0 +1,207 @@
|
||||
import { afterAll, beforeEach, expect, mock, test } from "bun:test";
|
||||
import { mkdirSync, rmSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import * as XLSX from "xlsx";
|
||||
import { closeDb, getDb } from "./database.ts";
|
||||
|
||||
const TEST_DIR = path.join(process.cwd(), "test_output", "stalker-sellability");
|
||||
const originalFetch = globalThis.fetch;
|
||||
const originalKeepaKey = Bun.env.KEEPA_API_KEY;
|
||||
|
||||
const fetchSellabilityBatchMock = mock(async (asins: string[]) => {
|
||||
return new Map(
|
||||
asins.map((asin) => [
|
||||
asin,
|
||||
asin === "B111111111"
|
||||
? {
|
||||
canSell: true,
|
||||
sellabilityStatus: "available" as const,
|
||||
sellabilityReason: "No listing restrictions reported",
|
||||
}
|
||||
: {
|
||||
canSell: false,
|
||||
sellabilityStatus: "restricted" as const,
|
||||
sellabilityReason: "approval required",
|
||||
},
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
mock.module("./sp-api.ts", () => ({
|
||||
fetchSellabilityBatch: fetchSellabilityBatchMock,
|
||||
}));
|
||||
|
||||
const modulePromise = import("./stalker.ts");
|
||||
|
||||
beforeEach(() => {
|
||||
closeDb();
|
||||
rmSync(TEST_DIR, { recursive: true, force: true });
|
||||
mkdirSync(TEST_DIR, { recursive: true });
|
||||
globalThis.fetch = originalFetch;
|
||||
Bun.env.KEEPA_API_KEY = "test-keepa-key";
|
||||
fetchSellabilityBatchMock.mockClear();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
globalThis.fetch = originalFetch;
|
||||
if (originalKeepaKey == null) {
|
||||
delete Bun.env.KEEPA_API_KEY;
|
||||
} else {
|
||||
Bun.env.KEEPA_API_KEY = originalKeepaKey;
|
||||
}
|
||||
closeDb();
|
||||
rmSync(TEST_DIR, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test("sellability checks matched seller inventory, not the source ASIN", async () => {
|
||||
const { runStalker } = await modulePromise;
|
||||
const inputPath = path.join(TEST_DIR, "input.xlsx");
|
||||
const dbPath = path.join(TEST_DIR, "stalker.sqlite");
|
||||
const workbook = XLSX.utils.book_new();
|
||||
XLSX.utils.book_append_sheet(
|
||||
workbook,
|
||||
XLSX.utils.json_to_sheet([{ asin: "B000000001" }]),
|
||||
"Input",
|
||||
);
|
||||
XLSX.writeFile(workbook, inputPath);
|
||||
|
||||
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 === "/product") {
|
||||
if (url.searchParams.get("asin") === "B111111111") {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
products: [
|
||||
{
|
||||
asin: "B111111111",
|
||||
title: "Sellable Storefront Product",
|
||||
brand: "Good Brand",
|
||||
categoryTree: [{ name: "Kitchen" }, { name: "Storage" }],
|
||||
monthlySold: 42,
|
||||
stats: {
|
||||
current: [null, null, null, 12345, null, null, null, null, null, null, null, 7],
|
||||
avg: [2500],
|
||||
},
|
||||
csv: [[5000000, 1999, 5000100]],
|
||||
},
|
||||
],
|
||||
tokensLeft: 10,
|
||||
refillRate: 10,
|
||||
}),
|
||||
{ status: 200 },
|
||||
);
|
||||
}
|
||||
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
products: [
|
||||
{
|
||||
asin: "B000000001",
|
||||
title: "Source Product",
|
||||
offers: [{ sellerId: "AQUALIFIED", price: 1999 }],
|
||||
},
|
||||
],
|
||||
tokensLeft: 10,
|
||||
refillRate: 10,
|
||||
}),
|
||||
{ status: 200 },
|
||||
);
|
||||
}
|
||||
|
||||
if (url.pathname === "/seller") {
|
||||
const wantsStorefront = url.searchParams.get("storefront") === "1";
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
sellers: {
|
||||
AQUALIFIED: {
|
||||
sellerName: "New Seller",
|
||||
currentRatingCount: 12,
|
||||
asinList: wantsStorefront ? ["B111111111", "B222222222"] : [],
|
||||
},
|
||||
},
|
||||
tokensLeft: 10,
|
||||
refillRate: 10,
|
||||
}),
|
||||
{ status: 200 },
|
||||
);
|
||||
}
|
||||
|
||||
return new Response("not found", { status: 404 });
|
||||
}) as unknown as typeof globalThis.fetch;
|
||||
|
||||
const stats = await runStalker({
|
||||
input: inputPath,
|
||||
dbPath,
|
||||
maxAsins: null,
|
||||
storefrontUpdateHours: 168,
|
||||
offerLimit: 20,
|
||||
sellerLimit: 30,
|
||||
inventoryLimit: 200,
|
||||
sellerCacheHours: 168,
|
||||
includeStock: false,
|
||||
dryRun: false,
|
||||
resume: true,
|
||||
maxSellerRequests: null,
|
||||
sellability: true,
|
||||
analyzeSellable: false,
|
||||
});
|
||||
|
||||
expect(fetchSellabilityBatchMock.mock.calls.length).toBe(1);
|
||||
expect(fetchSellabilityBatchMock.mock.calls[0]?.[0]).toEqual([
|
||||
"B111111111",
|
||||
"B222222222",
|
||||
]);
|
||||
expect(stats.inventorySellabilityCheckedAsins).toBe(2);
|
||||
expect(stats.inventorySellabilityAvailableAsins).toBe(1);
|
||||
expect(stats.inventorySellabilityExcludedAsins).toBe(1);
|
||||
expect(stats.persistedInventoryAsins).toBe(1);
|
||||
|
||||
const db = getDb(dbPath);
|
||||
const scan = db.query("SELECT source_asin FROM stalker_asin_scans").get() as {
|
||||
source_asin: string;
|
||||
};
|
||||
expect(scan.source_asin).toBe("B000000001");
|
||||
|
||||
const inventory = db
|
||||
.query(
|
||||
`SELECT asin, can_sell, sellability_status, product_title, brand,
|
||||
category_tree, current_price, avg_price_90d, sales_rank, monthly_sold,
|
||||
seller_count
|
||||
FROM stalker_seller_inventory ORDER BY asin`,
|
||||
)
|
||||
.all() as Array<{
|
||||
asin: string;
|
||||
can_sell: number | null;
|
||||
sellability_status: string | null;
|
||||
product_title: string | null;
|
||||
brand: string | null;
|
||||
category_tree: string | null;
|
||||
current_price: number | null;
|
||||
avg_price_90d: number | null;
|
||||
sales_rank: number | null;
|
||||
monthly_sold: number | null;
|
||||
seller_count: number | null;
|
||||
}>;
|
||||
expect(inventory).toEqual([
|
||||
{
|
||||
asin: "B111111111",
|
||||
can_sell: 1,
|
||||
sellability_status: "available",
|
||||
product_title: "Sellable Storefront Product",
|
||||
brand: "Good Brand",
|
||||
category_tree: JSON.stringify(["Kitchen", "Storage"]),
|
||||
current_price: 19.99,
|
||||
avg_price_90d: 25,
|
||||
sales_rank: 12345,
|
||||
monthly_sold: 42,
|
||||
seller_count: 7,
|
||||
},
|
||||
]);
|
||||
});
|
||||
283
src/stalker.test.ts
Normal file
283
src/stalker.test.ts
Normal file
@@ -0,0 +1,283 @@
|
||||
import { afterAll, beforeEach, expect, mock, test } from "bun:test";
|
||||
import { mkdirSync, rmSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import * as XLSX from "xlsx";
|
||||
import { closeDb, getDb, initDb } from "./database.ts";
|
||||
import {
|
||||
extractLiveOfferSellerCandidates,
|
||||
isQualifyingSeller,
|
||||
readAsinsFromXlsx,
|
||||
runStalker,
|
||||
} from "./stalker.ts";
|
||||
|
||||
const TEST_DIR = path.join(process.cwd(), "test_output", "stalker");
|
||||
const originalFetch = globalThis.fetch;
|
||||
const originalKeepaKey = Bun.env.KEEPA_API_KEY;
|
||||
|
||||
beforeEach(() => {
|
||||
closeDb();
|
||||
rmSync(TEST_DIR, { recursive: true, force: true });
|
||||
mkdirSync(TEST_DIR, { recursive: true });
|
||||
globalThis.fetch = originalFetch;
|
||||
Bun.env.KEEPA_API_KEY = "test-keepa-key";
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
globalThis.fetch = originalFetch;
|
||||
if (originalKeepaKey == null) {
|
||||
delete Bun.env.KEEPA_API_KEY;
|
||||
} else {
|
||||
Bun.env.KEEPA_API_KEY = originalKeepaKey;
|
||||
}
|
||||
closeDb();
|
||||
rmSync(TEST_DIR, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test("readAsinsFromXlsx extracts valid ASINs and deduplicates in order", () => {
|
||||
const filePath = path.join(TEST_DIR, "asins.xlsx");
|
||||
const workbook = XLSX.utils.book_new();
|
||||
const sheet = XLSX.utils.json_to_sheet([
|
||||
{ ASIN: "b000000001" },
|
||||
{ ASIN: "invalid" },
|
||||
{ ASIN: "B000000002" },
|
||||
{ ASIN: "B000000001" },
|
||||
{ ASIN: "" },
|
||||
]);
|
||||
XLSX.utils.book_append_sheet(workbook, sheet, "Input");
|
||||
XLSX.writeFile(workbook, filePath);
|
||||
|
||||
expect(readAsinsFromXlsx(filePath)).toEqual(["B000000001", "B000000002"]);
|
||||
});
|
||||
|
||||
test("isQualifyingSeller accepts rating counts from 1 to 30 only", () => {
|
||||
expect(isQualifyingSeller({ ratingCount: null })).toBe(false);
|
||||
expect(isQualifyingSeller({ ratingCount: 0 })).toBe(false);
|
||||
expect(isQualifyingSeller({ ratingCount: 1 })).toBe(true);
|
||||
expect(isQualifyingSeller({ ratingCount: 30 })).toBe(true);
|
||||
expect(isQualifyingSeller({ ratingCount: 31 })).toBe(false);
|
||||
});
|
||||
|
||||
test("extractLiveOfferSellerCandidates ignores Amazon, missing seller IDs, and duplicates", () => {
|
||||
const offers = extractLiveOfferSellerCandidates({
|
||||
offers: [
|
||||
{ sellerId: "ATVPDKIKX0DER", price: 1999 },
|
||||
{ price: 1899 },
|
||||
{ sellerId: "A1SELLER", price: 1599, isFBA: true, stock: 4 },
|
||||
{ sellerId: "A1SELLER", price: 1499 },
|
||||
{ sellerID: "A2SELLER", currentPrice: 2499, isFba: false },
|
||||
],
|
||||
});
|
||||
|
||||
expect(offers.map((offer) => offer.sellerId)).toEqual([
|
||||
"A1SELLER",
|
||||
"A2SELLER",
|
||||
]);
|
||||
expect(offers[0]?.offerPrice).toBe(15.99);
|
||||
expect(offers[0]?.isFba).toBe(true);
|
||||
expect(offers[0]?.stock).toBe(4);
|
||||
});
|
||||
|
||||
test("initDb creates stalker tables and indexes", () => {
|
||||
const dbPath = path.join(TEST_DIR, "schema.sqlite");
|
||||
initDb(dbPath);
|
||||
const db = getDb(dbPath);
|
||||
|
||||
const tables = db
|
||||
.query(
|
||||
`SELECT name FROM sqlite_master WHERE type = 'table' AND name LIKE 'stalker_%' ORDER BY name`,
|
||||
)
|
||||
.all() as Array<{ name: string }>;
|
||||
expect(tables.map((row) => row.name)).toEqual([
|
||||
"stalker_asin_scans",
|
||||
"stalker_asin_sellers",
|
||||
"stalker_runs",
|
||||
"stalker_seller_inventory",
|
||||
"stalker_sellers",
|
||||
]);
|
||||
|
||||
const indexes = db
|
||||
.query(
|
||||
`SELECT name FROM sqlite_master WHERE type = 'index' AND name LIKE 'idx_stalker_%' ORDER BY name`,
|
||||
)
|
||||
.all() as Array<{ name: string }>;
|
||||
expect(indexes.length).toBeGreaterThanOrEqual(6);
|
||||
});
|
||||
|
||||
test("runStalker fetches product offers, filters sellers, and persists storefront inventory", async () => {
|
||||
const inputPath = path.join(TEST_DIR, "input.xlsx");
|
||||
const dbPath = path.join(TEST_DIR, "stalker.sqlite");
|
||||
const workbook = XLSX.utils.book_new();
|
||||
XLSX.utils.book_append_sheet(
|
||||
workbook,
|
||||
XLSX.utils.json_to_sheet([{ asin: "B000000001" }]),
|
||||
"Input",
|
||||
);
|
||||
XLSX.writeFile(workbook, inputPath);
|
||||
|
||||
const fetchMock = 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 === "/product") {
|
||||
expect(url.searchParams.get("asin")).toBe("B000000001");
|
||||
expect(url.searchParams.get("offers")).toBe("20");
|
||||
expect(url.searchParams.get("only-live-offers")).toBe("1");
|
||||
expect(url.searchParams.has("stock")).toBe(false);
|
||||
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
products: [
|
||||
{
|
||||
asin: "B000000001",
|
||||
title: "Tracked Product",
|
||||
offers: [
|
||||
{
|
||||
sellerId: "AQUALIFIED",
|
||||
price: 1999,
|
||||
condition: "New",
|
||||
isFBA: true,
|
||||
stock: 3,
|
||||
},
|
||||
{
|
||||
sellerId: "AOLDSELLER",
|
||||
price: 2099,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
tokensLeft: 10,
|
||||
refillRate: 10,
|
||||
}),
|
||||
{ status: 200 },
|
||||
);
|
||||
}
|
||||
|
||||
if (url.pathname === "/seller") {
|
||||
const wantsStorefront = url.searchParams.get("storefront") === "1";
|
||||
if (wantsStorefront) {
|
||||
expect(url.searchParams.get("update")).toBe("168");
|
||||
}
|
||||
const sellerId = url.searchParams.get("seller");
|
||||
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
sellers: {
|
||||
...(!wantsStorefront && sellerId === "AQUALIFIED,AOLDSELLER"
|
||||
? {
|
||||
AQUALIFIED: {
|
||||
sellerName: "New Seller",
|
||||
currentRating: 96,
|
||||
currentRatingCount: 12,
|
||||
},
|
||||
AOLDSELLER: {
|
||||
sellerName: "Old Seller",
|
||||
currentRating: 99,
|
||||
currentRatingCount: 120,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
...(wantsStorefront && sellerId === "AQUALIFIED"
|
||||
? {
|
||||
AQUALIFIED: {
|
||||
sellerName: "New Seller",
|
||||
currentRating: 96,
|
||||
currentRatingCount: 12,
|
||||
asinList: ["B111111111", "B222222222"],
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
tokensLeft: 10,
|
||||
refillRate: 10,
|
||||
}),
|
||||
{ status: 200 },
|
||||
);
|
||||
}
|
||||
|
||||
return new Response("not found", { status: 404 });
|
||||
});
|
||||
globalThis.fetch = fetchMock as unknown as typeof globalThis.fetch;
|
||||
|
||||
const stats = await runStalker({
|
||||
input: inputPath,
|
||||
dbPath,
|
||||
maxAsins: null,
|
||||
storefrontUpdateHours: 168,
|
||||
offerLimit: 20,
|
||||
sellerLimit: 30,
|
||||
inventoryLimit: 200,
|
||||
sellerCacheHours: 168,
|
||||
includeStock: false,
|
||||
dryRun: false,
|
||||
resume: true,
|
||||
maxSellerRequests: null,
|
||||
sellability: false,
|
||||
analyzeSellable: false,
|
||||
});
|
||||
|
||||
expect(stats.scannedAsins).toBe(1);
|
||||
expect(stats.sourceAsinsWithMatches).toBe(1);
|
||||
expect(stats.matchedSellers).toBe(1);
|
||||
expect(stats.persistedInventoryAsins).toBe(0);
|
||||
expect(stats.failedAsins).toBe(0);
|
||||
expect(stats.candidateSellers).toBe(2);
|
||||
expect(stats.qualifyingSellers).toBe(1);
|
||||
expect(stats.sellerMetadataRequests).toBe(1);
|
||||
expect(stats.sellerStorefrontRequests).toBe(1);
|
||||
const sellerCalls = fetchMock.mock.calls.filter((call) => {
|
||||
const rawUrl =
|
||||
typeof call[0] === "string"
|
||||
? call[0]
|
||||
: call[0] instanceof URL
|
||||
? call[0].toString()
|
||||
: (call[0] as Request).url;
|
||||
return new URL(rawUrl).pathname === "/seller";
|
||||
});
|
||||
expect(sellerCalls.length).toBe(2);
|
||||
|
||||
const db = getDb(dbPath);
|
||||
const run = db.query("SELECT * FROM stalker_runs").get() as any;
|
||||
expect(run.status).toBe("completed");
|
||||
expect(run.requested_asins).toBe(1);
|
||||
expect(run.scanned_asins).toBe(1);
|
||||
expect(run.source_asins_with_matches).toBe(1);
|
||||
expect(run.candidate_sellers).toBe(2);
|
||||
expect(run.qualifying_sellers).toBe(1);
|
||||
expect(run.matched_sellers).toBe(1);
|
||||
expect(run.seller_metadata_requests).toBe(1);
|
||||
expect(run.seller_storefront_requests).toBe(1);
|
||||
expect(run.inventory_sellability_checked_asins).toBe(0);
|
||||
expect(run.inventory_sellability_available_asins).toBe(0);
|
||||
expect(run.inventory_sellability_excluded_asins).toBe(0);
|
||||
expect(run.persisted_inventory_asins).toBe(0);
|
||||
|
||||
const scan = db.query("SELECT * FROM stalker_asin_scans").get() as any;
|
||||
expect(scan.source_asin).toBe("B000000001");
|
||||
expect(scan.title).toBe("Tracked Product");
|
||||
expect(scan.offer_count).toBe(2);
|
||||
expect(scan.candidate_seller_count).toBe(2);
|
||||
expect(scan.matched_seller_count).toBe(1);
|
||||
|
||||
const sellers = db.query("SELECT * FROM stalker_sellers").all() as any[];
|
||||
expect(sellers.length).toBe(1);
|
||||
expect(sellers[0].seller_id).toBe("AQUALIFIED");
|
||||
expect(sellers[0].rating_count).toBe(12);
|
||||
expect(sellers[0].storefront_asin_total).toBe(2);
|
||||
expect(sellers[0].persisted_inventory_sample_count).toBe(0);
|
||||
|
||||
const asinSellers = db.query("SELECT * FROM stalker_asin_sellers").all() as any[];
|
||||
expect(asinSellers.length).toBe(1);
|
||||
expect(asinSellers[0].offer_price).toBe(19.99);
|
||||
expect(asinSellers[0].is_fba).toBe(1);
|
||||
expect(asinSellers[0].stock).toBe(3);
|
||||
|
||||
const inventory = db
|
||||
.query("SELECT asin FROM stalker_seller_inventory ORDER BY asin")
|
||||
.all() as Array<{ asin: string }>;
|
||||
expect(inventory.map((row) => row.asin)).toEqual([]);
|
||||
});
|
||||
1596
src/stalker.ts
Normal file
1596
src/stalker.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -109,6 +109,76 @@ type ProductListResponse = {
|
||||
totalPages: number;
|
||||
};
|
||||
|
||||
type StalkerResultItem = {
|
||||
runId: number;
|
||||
started_at: string;
|
||||
status: string;
|
||||
input_file: string;
|
||||
seller_id: string;
|
||||
seller_name: string | null;
|
||||
rating: number | null;
|
||||
rating_count: number | null;
|
||||
storefront_asin_total: number | null;
|
||||
persisted_inventory_sample_count: number | null;
|
||||
discovered_from_count: number;
|
||||
first_seen_at: string;
|
||||
last_seen_at: string;
|
||||
persisted_inventory_asin_count: number;
|
||||
inventory_sample_asins: string | null;
|
||||
};
|
||||
|
||||
type StalkerResultsResponse = {
|
||||
items: StalkerResultItem[];
|
||||
summary: {
|
||||
runs: number;
|
||||
sellers: number;
|
||||
persistedInventoryAsins: number;
|
||||
};
|
||||
page: number;
|
||||
pageSize: number;
|
||||
total: number;
|
||||
totalPages: number;
|
||||
};
|
||||
|
||||
type StalkerProductItem = {
|
||||
runId: number;
|
||||
started_at: string;
|
||||
seller_id: string;
|
||||
seller_name: string | null;
|
||||
rating: number | null;
|
||||
rating_count: number | null;
|
||||
asin: string;
|
||||
can_sell: number;
|
||||
sellability_status: string;
|
||||
sellability_reason: string | null;
|
||||
product_title: string | null;
|
||||
brand: string | null;
|
||||
category_tree: string | null;
|
||||
current_price: number | null;
|
||||
avg_price_90d: number | null;
|
||||
sales_rank: number | null;
|
||||
monthly_sold: number | null;
|
||||
seller_count: number | null;
|
||||
amazon_is_seller: number | null;
|
||||
verdict: "FBA" | "FBM" | "SKIP" | null;
|
||||
confidence: number | null;
|
||||
reasoning: string | null;
|
||||
last_seen_at: string;
|
||||
};
|
||||
|
||||
type StalkerProductsResponse = {
|
||||
items: StalkerProductItem[];
|
||||
summary: {
|
||||
runs: number;
|
||||
sellers: number;
|
||||
products: number;
|
||||
};
|
||||
page: number;
|
||||
pageSize: number;
|
||||
total: number;
|
||||
totalPages: number;
|
||||
};
|
||||
|
||||
type SortState = {
|
||||
field: string;
|
||||
direction: SortDirection;
|
||||
@@ -139,6 +209,23 @@ function formatAmazonSeller(value: number | null | undefined): string {
|
||||
return value === 1 ? "Yes" : "No";
|
||||
}
|
||||
|
||||
function formatBoolean(value: number | null | undefined): string {
|
||||
if (value === null || value === undefined) return "-";
|
||||
return value === 1 ? "Yes" : "No";
|
||||
}
|
||||
|
||||
function parseStringArrayJson(value: string | null | undefined): string[] {
|
||||
if (!value) return [];
|
||||
try {
|
||||
const parsed = JSON.parse(value);
|
||||
return Array.isArray(parsed)
|
||||
? parsed.filter((item): item is string => typeof item === "string")
|
||||
: [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function buildSortValue(sort: SortState): string {
|
||||
return `${sort.field}:${sort.direction}`;
|
||||
}
|
||||
@@ -197,9 +284,13 @@ function detectAnomaly(item: ResultItem): string {
|
||||
function Dashboard({
|
||||
onOpenRun,
|
||||
onOpenProducts,
|
||||
onOpenStalker,
|
||||
onOpenStalkerProducts,
|
||||
}: {
|
||||
onOpenRun: (run: Run) => void;
|
||||
onOpenProducts: (verdict: VerdictFilter) => void;
|
||||
onOpenStalker: () => void;
|
||||
onOpenStalkerProducts: () => void;
|
||||
}) {
|
||||
const [runs, setRuns] = useState<RunsResponse | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
@@ -288,7 +379,13 @@ function Dashboard({
|
||||
return (
|
||||
<div className="page">
|
||||
<div className="card">
|
||||
<div className="section-header">
|
||||
<h2>Runs Dashboard</h2>
|
||||
<div className="button-row">
|
||||
<button onClick={onOpenStalker}>Stalker sellers</button>
|
||||
<button onClick={onOpenStalkerProducts}>Sellable products</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="metrics">
|
||||
@@ -848,10 +945,451 @@ function ProductList({ verdict, onBack }: { verdict: VerdictFilter; onBack: () =
|
||||
);
|
||||
}
|
||||
|
||||
function StalkerExplorer({
|
||||
onBack,
|
||||
onOpenProducts,
|
||||
}: {
|
||||
onBack: () => void;
|
||||
onOpenProducts: () => void;
|
||||
}) {
|
||||
const [results, setResults] = useState<StalkerResultsResponse | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [purging, setPurging] = useState(false);
|
||||
const [search, setSearch] = useState("");
|
||||
const [sellerId, setSellerId] = useState("");
|
||||
const [runId, setRunId] = useState("");
|
||||
const [minRatingCount, setMinRatingCount] = useState("1");
|
||||
const [maxRatingCount, setMaxRatingCount] = useState("30");
|
||||
const [page, setPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(25);
|
||||
const [sort, setSort] = useState<SortState>({ field: "persisted_inventory_asin_count", direction: "DESC" });
|
||||
const [refreshTick, setRefreshTick] = useState(0);
|
||||
|
||||
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 (sellerId) params.set("sellerId", sellerId);
|
||||
if (runId) params.set("runId", runId);
|
||||
if (minRatingCount) params.set("minRatingCount", minRatingCount);
|
||||
if (maxRatingCount) params.set("maxRatingCount", maxRatingCount);
|
||||
|
||||
const res = await fetch(`/api/stalker/results?${params.toString()}`);
|
||||
const payload = (await res.json()) as StalkerResultsResponse;
|
||||
if (!cancelled) {
|
||||
setResults(payload);
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
load();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [search, sellerId, runId, minRatingCount, maxRatingCount, page, pageSize, sort, refreshTick]);
|
||||
|
||||
async function purgeStalkerData() {
|
||||
const confirmed = window.confirm("Permanently delete all Stalker runs, sellers, and sellable products from the database?");
|
||||
if (!confirmed) return;
|
||||
|
||||
setPurging(true);
|
||||
try {
|
||||
const res = await fetch("/api/stalker/purge", { method: "DELETE" });
|
||||
if (!res.ok) {
|
||||
const payload = await res.json().catch(() => null) as { error?: string } | null;
|
||||
window.alert(payload?.error ?? "Failed to purge Stalker data");
|
||||
return;
|
||||
}
|
||||
setPage(1);
|
||||
setRefreshTick((tick) => tick + 1);
|
||||
} finally {
|
||||
setPurging(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="page">
|
||||
<button className="back" onClick={onBack}>Back</button>
|
||||
|
||||
<div className="card">
|
||||
<div className="section-header">
|
||||
<h2>Seller Storefronts</h2>
|
||||
<div className="button-row">
|
||||
<button onClick={onOpenProducts}>Sellable products</button>
|
||||
<button className="danger" disabled={purging} onClick={purgeStalkerData}>
|
||||
{purging ? "Purging..." : "Purge Stalker data"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="metrics">
|
||||
<div className="metric">
|
||||
<div className="label">Runs</div>
|
||||
<div className="value">{formatNumber(results?.summary.runs)}</div>
|
||||
</div>
|
||||
<div className="metric">
|
||||
<div className="label">Matched sellers</div>
|
||||
<div className="value">{formatNumber(results?.summary.sellers)}</div>
|
||||
</div>
|
||||
<div className="metric">
|
||||
<div className="label">Sellable inventory ASINs</div>
|
||||
<div className="value">{formatNumber(results?.summary.persistedInventoryAsins)}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<div className="toolbar">
|
||||
<input value={search} onChange={(e) => { setPage(1); setSearch(e.target.value); }} placeholder="Search seller or sellable ASIN" />
|
||||
<input value={sellerId} onChange={(e) => { setPage(1); setSellerId(e.target.value.toUpperCase()); }} placeholder="Seller ID" />
|
||||
<input value={runId} onChange={(e) => { setPage(1); setRunId(e.target.value); }} placeholder="Run ID" />
|
||||
<input value={minRatingCount} onChange={(e) => { setPage(1); setMinRatingCount(e.target.value); }} placeholder="Min rating count" />
|
||||
<input value={maxRatingCount} onChange={(e) => { setPage(1); setMaxRatingCount(e.target.value); }} placeholder="Max rating count" />
|
||||
<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 className="stalker-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th><button onClick={() => setSort(nextSort(sort, "runId"))}>Run</button></th>
|
||||
<th><button onClick={() => setSort(nextSort(sort, "seller_id"))}>Seller ID</button></th>
|
||||
<th><button onClick={() => setSort(nextSort(sort, "seller_name"))}>Seller</button></th>
|
||||
<th><button onClick={() => setSort(nextSort(sort, "rating"))}>Rating</button></th>
|
||||
<th><button onClick={() => setSort(nextSort(sort, "rating_count"))}>Rating Count</button></th>
|
||||
<th><button onClick={() => setSort(nextSort(sort, "discovered_from_count"))}>Hits</button></th>
|
||||
<th><button onClick={() => setSort(nextSort(sort, "storefront_asin_total"))}>Storefront total</button></th>
|
||||
<th><button onClick={() => setSort(nextSort(sort, "persisted_inventory_asin_count"))}>Sellable sample</button></th>
|
||||
<th><button onClick={() => setSort(nextSort(sort, "last_seen_at"))}>Last Seen</button></th>
|
||||
<th className="inventory-col">Sellable inventory ASIN sample</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{loading ? (
|
||||
<tr><td colSpan={10}>Loading...</td></tr>
|
||||
) : results?.items.length ? (
|
||||
results.items.map((item) => {
|
||||
const inventorySample = (item.inventory_sample_asins ?? "")
|
||||
.split(",")
|
||||
.filter(Boolean)
|
||||
.slice(0, 20);
|
||||
return (
|
||||
<tr key={`${item.runId}-${item.seller_id}`}>
|
||||
<td>{item.runId}</td>
|
||||
<td>{item.seller_id}</td>
|
||||
<td>{item.seller_name || "-"}</td>
|
||||
<td>{formatNumber(item.rating)}</td>
|
||||
<td>{formatNumber(item.rating_count)}</td>
|
||||
<td>{formatNumber(item.discovered_from_count)}</td>
|
||||
<td>{formatNumber(item.storefront_asin_total)}</td>
|
||||
<td>{formatNumber(item.persisted_inventory_asin_count)}</td>
|
||||
<td>{formatDate(item.last_seen_at)}</td>
|
||||
<td className="inventory-col">
|
||||
{inventorySample.length === 0 ? "-" : inventorySample.map((asin) => (
|
||||
<a key={asin} href={`http://amazon.com/dp/${asin}`} target="_blank" rel="noreferrer">{asin}</a>
|
||||
))}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<tr><td colSpan={10}>No seller storefronts 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 StalkerProductsExplorer({
|
||||
onBack,
|
||||
onOpenSellers,
|
||||
}: {
|
||||
onBack: () => void;
|
||||
onOpenSellers: () => void;
|
||||
}) {
|
||||
const [results, setResults] = useState<StalkerProductsResponse | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [search, setSearch] = useState("");
|
||||
const [sellerId, setSellerId] = useState("");
|
||||
const [runId, setRunId] = useState("");
|
||||
const [verdict, setVerdict] = useState("");
|
||||
const [amazonIsSeller, setAmazonIsSeller] = useState("");
|
||||
const [minPrice, setMinPrice] = useState("");
|
||||
const [maxPrice, setMaxPrice] = useState("");
|
||||
const [minMonthlySold, setMinMonthlySold] = useState("");
|
||||
const [maxMonthlySold, setMaxMonthlySold] = useState("");
|
||||
const [minSalesRank, setMinSalesRank] = useState("");
|
||||
const [maxSalesRank, setMaxSalesRank] = useState("");
|
||||
const [minSellerCount, setMinSellerCount] = useState("");
|
||||
const [maxSellerCount, setMaxSellerCount] = useState("");
|
||||
const [minRatingCount, setMinRatingCount] = useState("");
|
||||
const [maxRatingCount, setMaxRatingCount] = useState("");
|
||||
const [minConfidence, setMinConfidence] = useState("");
|
||||
const [maxConfidence, setMaxConfidence] = useState("");
|
||||
const [page, setPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(50);
|
||||
const [sort, setSort] = useState<SortState>({ field: "monthly_sold", direction: "DESC" });
|
||||
|
||||
function buildStalkerProductParams(includePaging: boolean): URLSearchParams {
|
||||
const params = new URLSearchParams({
|
||||
sort: buildSortValue(sort),
|
||||
});
|
||||
if (includePaging) {
|
||||
params.set("page", String(page));
|
||||
params.set("pageSize", String(pageSize));
|
||||
}
|
||||
if (search) params.set("q", search);
|
||||
if (sellerId) params.set("sellerId", sellerId);
|
||||
if (runId) params.set("runId", runId);
|
||||
if (verdict) params.set("verdict", verdict);
|
||||
if (amazonIsSeller) params.set("amazonIsSeller", amazonIsSeller);
|
||||
if (minPrice) params.set("minPrice", minPrice);
|
||||
if (maxPrice) params.set("maxPrice", maxPrice);
|
||||
if (minMonthlySold) params.set("minMonthlySold", minMonthlySold);
|
||||
if (maxMonthlySold) params.set("maxMonthlySold", maxMonthlySold);
|
||||
if (minSalesRank) params.set("minSalesRank", minSalesRank);
|
||||
if (maxSalesRank) params.set("maxSalesRank", maxSalesRank);
|
||||
if (minSellerCount) params.set("minSellerCount", minSellerCount);
|
||||
if (maxSellerCount) params.set("maxSellerCount", maxSellerCount);
|
||||
if (minRatingCount) params.set("minRatingCount", minRatingCount);
|
||||
if (maxRatingCount) params.set("maxRatingCount", maxRatingCount);
|
||||
if (minConfidence) params.set("minConfidence", minConfidence);
|
||||
if (maxConfidence) params.set("maxConfidence", maxConfidence);
|
||||
return params;
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
async function load() {
|
||||
setLoading(true);
|
||||
const params = buildStalkerProductParams(true);
|
||||
|
||||
const res = await fetch(`/api/stalker/products?${params.toString()}`);
|
||||
const payload = (await res.json()) as StalkerProductsResponse;
|
||||
if (!cancelled) {
|
||||
setResults(payload);
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
load();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [
|
||||
search,
|
||||
sellerId,
|
||||
runId,
|
||||
verdict,
|
||||
amazonIsSeller,
|
||||
minPrice,
|
||||
maxPrice,
|
||||
minMonthlySold,
|
||||
maxMonthlySold,
|
||||
minSalesRank,
|
||||
maxSalesRank,
|
||||
minSellerCount,
|
||||
maxSellerCount,
|
||||
minRatingCount,
|
||||
maxRatingCount,
|
||||
minConfidence,
|
||||
maxConfidence,
|
||||
page,
|
||||
pageSize,
|
||||
sort,
|
||||
]);
|
||||
|
||||
function resetFilters() {
|
||||
setSearch("");
|
||||
setSellerId("");
|
||||
setRunId("");
|
||||
setVerdict("");
|
||||
setAmazonIsSeller("");
|
||||
setMinPrice("");
|
||||
setMaxPrice("");
|
||||
setMinMonthlySold("");
|
||||
setMaxMonthlySold("");
|
||||
setMinSalesRank("");
|
||||
setMaxSalesRank("");
|
||||
setMinSellerCount("");
|
||||
setMaxSellerCount("");
|
||||
setMinRatingCount("");
|
||||
setMaxRatingCount("");
|
||||
setMinConfidence("");
|
||||
setMaxConfidence("");
|
||||
setPage(1);
|
||||
}
|
||||
|
||||
const exportHref = `/api/stalker/products/export.xlsx?${buildStalkerProductParams(false).toString()}`;
|
||||
|
||||
return (
|
||||
<div className="page">
|
||||
<button className="back" onClick={onBack}>Back</button>
|
||||
|
||||
<div className="card">
|
||||
<div className="section-header">
|
||||
<h2>Sellable Stalker Products</h2>
|
||||
<button onClick={onOpenSellers}>Seller storefronts</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="metrics">
|
||||
<div className="metric">
|
||||
<div className="label">Runs</div>
|
||||
<div className="value">{formatNumber(results?.summary.runs)}</div>
|
||||
</div>
|
||||
<div className="metric">
|
||||
<div className="label">Sellers</div>
|
||||
<div className="value">{formatNumber(results?.summary.sellers)}</div>
|
||||
</div>
|
||||
<div className="metric">
|
||||
<div className="label">Sellable products</div>
|
||||
<div className="value">{formatNumber(results?.summary.products)}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<div className="toolbar">
|
||||
<input value={search} onChange={(e) => { setPage(1); setSearch(e.target.value); }} placeholder="Search ASIN, product, brand, category, or seller" />
|
||||
<input value={sellerId} onChange={(e) => { setPage(1); setSellerId(e.target.value.toUpperCase()); }} placeholder="Seller ID" />
|
||||
<input value={runId} onChange={(e) => { setPage(1); setRunId(e.target.value); }} placeholder="Run ID" />
|
||||
<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>
|
||||
<option value="UNANALYZED">Unanalyzed</option>
|
||||
</select>
|
||||
<select value={amazonIsSeller} onChange={(e) => { setPage(1); setAmazonIsSeller(e.target.value); }}>
|
||||
<option value="">Amazon seller: all</option>
|
||||
<option value="yes">Amazon seller: yes</option>
|
||||
<option value="no">Amazon seller: no</option>
|
||||
<option value="unknown">Amazon seller: unknown</option>
|
||||
</select>
|
||||
<input value={minPrice} onChange={(e) => { setPage(1); setMinPrice(e.target.value); }} placeholder="Min price" />
|
||||
<input value={maxPrice} onChange={(e) => { setPage(1); setMaxPrice(e.target.value); }} placeholder="Max price" />
|
||||
<input value={minMonthlySold} onChange={(e) => { setPage(1); setMinMonthlySold(e.target.value); }} placeholder="Min monthly sold" />
|
||||
<input value={maxMonthlySold} onChange={(e) => { setPage(1); setMaxMonthlySold(e.target.value); }} placeholder="Max monthly sold" />
|
||||
<input value={minSalesRank} onChange={(e) => { setPage(1); setMinSalesRank(e.target.value); }} placeholder="Min rank" />
|
||||
<input value={maxSalesRank} onChange={(e) => { setPage(1); setMaxSalesRank(e.target.value); }} placeholder="Max rank" />
|
||||
<input value={minSellerCount} onChange={(e) => { setPage(1); setMinSellerCount(e.target.value); }} placeholder="Min sellers" />
|
||||
<input value={maxSellerCount} onChange={(e) => { setPage(1); setMaxSellerCount(e.target.value); }} placeholder="Max sellers" />
|
||||
<input value={minRatingCount} onChange={(e) => { setPage(1); setMinRatingCount(e.target.value); }} placeholder="Min seller rating count" />
|
||||
<input value={maxRatingCount} onChange={(e) => { setPage(1); setMaxRatingCount(e.target.value); }} placeholder="Max seller rating count" />
|
||||
<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>
|
||||
<button onClick={resetFilters}>Reset filters</button>
|
||||
<a className="button-link" href={exportHref}>Export XLSX</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<div className="table-wrap">
|
||||
<table className="stalker-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th><button onClick={() => setSort(nextSort(sort, "asin"))}>ASIN</button></th>
|
||||
<th className="product-col"><button onClick={() => setSort(nextSort(sort, "product_title"))}>Product</button></th>
|
||||
<th><button onClick={() => setSort(nextSort(sort, "brand"))}>Brand</button></th>
|
||||
<th>Category</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, "amazon_is_seller"))}>Amazon Seller</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, "avg_price_90d"))}>Avg 90d</button></th>
|
||||
<th><button onClick={() => setSort(nextSort(sort, "verdict"))}>Verdict</button></th>
|
||||
<th><button onClick={() => setSort(nextSort(sort, "confidence"))}>Confidence</button></th>
|
||||
<th><button onClick={() => setSort(nextSort(sort, "seller_id"))}>Seller ID</button></th>
|
||||
<th><button onClick={() => setSort(nextSort(sort, "seller_name"))}>Seller</button></th>
|
||||
<th><button onClick={() => setSort(nextSort(sort, "rating_count"))}>Rating Count</button></th>
|
||||
<th>Status</th>
|
||||
<th><button onClick={() => setSort(nextSort(sort, "runId"))}>Run</button></th>
|
||||
<th><button onClick={() => setSort(nextSort(sort, "last_seen_at"))}>Last Seen</button></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{loading ? (
|
||||
<tr><td colSpan={18}>Loading...</td></tr>
|
||||
) : results?.items.length ? (
|
||||
results.items.map((item) => {
|
||||
const categories = parseStringArrayJson(item.category_tree);
|
||||
return (
|
||||
<tr key={`${item.runId}-${item.seller_id}-${item.asin}`}>
|
||||
<td><a href={`http://amazon.com/dp/${item.asin}`} target="_blank" rel="noreferrer">{item.asin}</a></td>
|
||||
<td className="product-col" title={item.product_title || undefined}>{item.product_title || "-"}</td>
|
||||
<td>{item.brand || "-"}</td>
|
||||
<td>{categories.at(-1) || "-"}</td>
|
||||
<td>{formatNumber(item.monthly_sold)}</td>
|
||||
<td>{formatNumber(item.seller_count)}</td>
|
||||
<td>{formatAmazonSeller(item.amazon_is_seller)}</td>
|
||||
<td>{formatNumber(item.sales_rank)}</td>
|
||||
<td>{formatCurrency(item.current_price)}</td>
|
||||
<td>{formatCurrency(item.avg_price_90d)}</td>
|
||||
<td>{item.verdict ? <span className={verdictBadgeClass(item.verdict)}>{item.verdict}</span> : "-"}</td>
|
||||
<td title={item.reasoning || undefined}>{formatNumber(item.confidence)}</td>
|
||||
<td>{item.seller_id}</td>
|
||||
<td>{item.seller_name || "-"}</td>
|
||||
<td>{formatNumber(item.rating_count)}</td>
|
||||
<td><span className="badge badge-ok">{item.sellability_status}</span></td>
|
||||
<td>{item.runId}</td>
|
||||
<td>{formatDate(item.last_seen_at)}</td>
|
||||
</tr>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<tr><td colSpan={18}>No sellable Stalker products 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>
|
||||
);
|
||||
}
|
||||
|
||||
type AppRoute =
|
||||
| { kind: "dashboard" }
|
||||
| { kind: "run"; processType: ProcessType; runId: number }
|
||||
| { kind: "products"; verdict: VerdictFilter };
|
||||
| { kind: "products"; verdict: VerdictFilter }
|
||||
| { kind: "stalker" }
|
||||
| { kind: "stalker-products" };
|
||||
|
||||
function parseRoute(pathname: string, search: string): AppRoute {
|
||||
const runMatch = pathname.match(/^\/runs\/(lead_analysis|category_analysis)\/(\d+)$/);
|
||||
@@ -866,6 +1404,14 @@ function parseRoute(pathname: string, search: string): AppRoute {
|
||||
return { kind: "products", verdict };
|
||||
}
|
||||
|
||||
if (pathname === "/stalker") {
|
||||
return { kind: "stalker" };
|
||||
}
|
||||
|
||||
if (pathname === "/stalker/products") {
|
||||
return { kind: "stalker-products" };
|
||||
}
|
||||
|
||||
return { kind: "dashboard" };
|
||||
}
|
||||
|
||||
@@ -890,6 +1436,16 @@ function App() {
|
||||
setRoute({ kind: "products", verdict });
|
||||
}
|
||||
|
||||
function openStalker() {
|
||||
history.pushState({}, "", "/stalker");
|
||||
setRoute({ kind: "stalker" });
|
||||
}
|
||||
|
||||
function openStalkerProducts() {
|
||||
history.pushState({}, "", "/stalker/products");
|
||||
setRoute({ kind: "stalker-products" });
|
||||
}
|
||||
|
||||
function backToDashboard() {
|
||||
history.pushState({}, "", "/");
|
||||
setRoute({ kind: "dashboard" });
|
||||
@@ -903,7 +1459,22 @@ function App() {
|
||||
return <ProductList verdict={route.verdict} onBack={backToDashboard} />;
|
||||
}
|
||||
|
||||
return <Dashboard onOpenRun={openRun} onOpenProducts={openProducts} />;
|
||||
if (route.kind === "stalker") {
|
||||
return <StalkerExplorer onBack={backToDashboard} onOpenProducts={openStalkerProducts} />;
|
||||
}
|
||||
|
||||
if (route.kind === "stalker-products") {
|
||||
return <StalkerProductsExplorer onBack={backToDashboard} onOpenSellers={openStalker} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Dashboard
|
||||
onOpenRun={openRun}
|
||||
onOpenProducts={openProducts}
|
||||
onOpenStalker={openStalker}
|
||||
onOpenStalkerProducts={openStalkerProducts}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const root = document.getElementById("root");
|
||||
|
||||
@@ -41,9 +41,24 @@ p {
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.section-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.button-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.toolbar input,
|
||||
.toolbar select,
|
||||
button {
|
||||
button,
|
||||
.button-link {
|
||||
height: 36px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #d8dce0;
|
||||
@@ -52,10 +67,29 @@ button {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.button-link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
button {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
button.danger {
|
||||
border-color: #efb8b8;
|
||||
color: #9f1c1c;
|
||||
background: #fff6f6;
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.58;
|
||||
}
|
||||
|
||||
.table-wrap {
|
||||
overflow: auto;
|
||||
border: 1px solid #eceef0;
|
||||
@@ -91,6 +125,23 @@ td {
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.inventory-col {
|
||||
min-width: 360px;
|
||||
max-width: 520px;
|
||||
white-space: normal;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.inventory-col a {
|
||||
display: inline-block;
|
||||
margin-right: 8px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.stalker-table {
|
||||
min-width: 1320px;
|
||||
}
|
||||
|
||||
th {
|
||||
background: #fafafb;
|
||||
font-weight: 600;
|
||||
@@ -262,4 +313,9 @@ th button {
|
||||
.spark-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.section-header {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { getDb } from "./database.ts";
|
||||
import type { AnalysisResult, SupplierAnalysisResult } from "./types.ts";
|
||||
import { mkdirSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import * as XLSX from "xlsx";
|
||||
|
||||
export type RunCounts = {
|
||||
totalProducts: number;
|
||||
@@ -93,6 +96,22 @@ export function writeResultsToDb(
|
||||
console.log(`Results written to SQLite database for run_id: ${runId}`);
|
||||
}
|
||||
|
||||
export function writeResultsWorkbook(
|
||||
results: AnalysisResult[],
|
||||
outputFile: string,
|
||||
): void {
|
||||
const outputDir = path.dirname(outputFile);
|
||||
if (outputDir && outputDir !== ".") {
|
||||
mkdirSync(outputDir, { recursive: true });
|
||||
}
|
||||
|
||||
const workbook = XLSX.utils.book_new();
|
||||
const worksheet = XLSX.utils.json_to_sheet(results.map(buildRow));
|
||||
XLSX.utils.book_append_sheet(workbook, worksheet, "Results");
|
||||
XLSX.writeFile(workbook, outputFile);
|
||||
console.log(`Results workbook written: ${outputFile}`);
|
||||
}
|
||||
|
||||
export function startRunInDb(
|
||||
dbPath: string,
|
||||
inputFile: string,
|
||||
|
||||
Reference in New Issue
Block a user