feat: enhance Stalker functionality with additional product details and analysis capabilities
This commit is contained in:
@@ -419,6 +419,16 @@ export function initStalkerDb(database: Database): void {
|
|||||||
can_sell INTEGER,
|
can_sell INTEGER,
|
||||||
sellability_status TEXT,
|
sellability_status TEXT,
|
||||||
sellability_reason 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,
|
last_seen_at TEXT NOT NULL,
|
||||||
raw_inventory_json TEXT,
|
raw_inventory_json TEXT,
|
||||||
UNIQUE(run_id, seller_id, asin),
|
UNIQUE(run_id, seller_id, asin),
|
||||||
@@ -445,6 +455,9 @@ export function initStalkerDb(database: Database): void {
|
|||||||
database.run(
|
database.run(
|
||||||
`CREATE INDEX IF NOT EXISTS idx_stalker_inventory_asin ON stalker_seller_inventory(asin);`,
|
`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 {
|
function resetLegacyStalkerSchema(database: Database): void {
|
||||||
@@ -473,5 +486,9 @@ function inventoryColumnsHaveSellability(database: Database): boolean {
|
|||||||
const inventoryColumns = database
|
const inventoryColumns = database
|
||||||
.query("PRAGMA table_info(stalker_seller_inventory)")
|
.query("PRAGMA table_info(stalker_seller_inventory)")
|
||||||
.all() as Array<{ name: string }>;
|
.all() as Array<{ name: string }>;
|
||||||
return inventoryColumns.some((column) => column.name === "sellability_status");
|
const columnNames = new Set(inventoryColumns.map((column) => column.name));
|
||||||
|
return (
|
||||||
|
columnNames.has("sellability_status") &&
|
||||||
|
columnNames.has("product_title")
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -82,6 +82,18 @@ type StalkerProductRecord = {
|
|||||||
can_sell: number;
|
can_sell: number;
|
||||||
sellability_status: string;
|
sellability_status: string;
|
||||||
sellability_reason: string | null;
|
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;
|
last_seen_at: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -840,9 +852,16 @@ function parseStalkerProductFilters(filters: URLSearchParams) {
|
|||||||
if (q) {
|
if (q) {
|
||||||
const wildcard = `%${q}%`;
|
const wildcard = `%${q}%`;
|
||||||
conditions.push(
|
conditions.push(
|
||||||
"(inv.asin LIKE ? OR s.seller_id LIKE ? OR s.seller_name LIKE ?)",
|
`(
|
||||||
|
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);
|
params.push(wildcard, wildcard, wildcard, wildcard, wildcard, wildcard);
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -860,9 +879,19 @@ function parseStalkerProductSort(sortParam: string | null): string {
|
|||||||
"rating",
|
"rating",
|
||||||
"rating_count",
|
"rating_count",
|
||||||
"asin",
|
"asin",
|
||||||
|
"product_title",
|
||||||
|
"brand",
|
||||||
|
"current_price",
|
||||||
|
"avg_price_90d",
|
||||||
|
"sales_rank",
|
||||||
|
"monthly_sold",
|
||||||
|
"seller_count",
|
||||||
|
"amazon_is_seller",
|
||||||
|
"verdict",
|
||||||
|
"confidence",
|
||||||
"last_seen_at",
|
"last_seen_at",
|
||||||
]);
|
]);
|
||||||
return parseSort(sortParam, allowedSort, "last_seen_at DESC, asin ASC");
|
return parseSort(sortParam, allowedSort, "monthly_sold DESC, last_seen_at DESC, asin ASC");
|
||||||
}
|
}
|
||||||
|
|
||||||
function getStalkerProducts(filters: URLSearchParams) {
|
function getStalkerProducts(filters: URLSearchParams) {
|
||||||
@@ -887,10 +916,23 @@ function getStalkerProducts(filters: URLSearchParams) {
|
|||||||
inv.can_sell,
|
inv.can_sell,
|
||||||
inv.sellability_status,
|
inv.sellability_status,
|
||||||
inv.sellability_reason,
|
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
|
inv.last_seen_at
|
||||||
FROM stalker_seller_inventory inv
|
FROM stalker_seller_inventory inv
|
||||||
JOIN stalker_runs r ON r.id = inv.run_id
|
JOIN stalker_runs r ON r.id = inv.run_id
|
||||||
JOIN stalker_sellers s ON s.seller_id = inv.seller_id
|
JOIN stalker_sellers s ON s.seller_id = inv.seller_id
|
||||||
|
LEFT JOIN product_analysis_results analysis ON analysis.asin = inv.asin
|
||||||
${where}
|
${where}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
|||||||
329
src/stalker-analyze.ts
Normal file
329
src/stalker-analyze.ts
Normal file
@@ -0,0 +1,329 @@
|
|||||||
|
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";
|
||||||
|
|
||||||
|
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 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 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 verdicts = await analyzeProducts(enriched);
|
||||||
|
const results = enriched.map((product, index) => ({
|
||||||
|
product,
|
||||||
|
verdict: verdicts[index] ?? {
|
||||||
|
asin: product.record.asin,
|
||||||
|
verdict: "SKIP" as const,
|
||||||
|
confidence: 0,
|
||||||
|
reasoning: "LLM analysis returned no verdict",
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -75,6 +75,30 @@ test("sellability checks matched seller inventory, not the source ASIN", async (
|
|||||||
const url = new URL(rawUrl);
|
const url = new URL(rawUrl);
|
||||||
|
|
||||||
if (url.pathname === "/product") {
|
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: [[0, 1999]],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
tokensLeft: 10,
|
||||||
|
refillRate: 10,
|
||||||
|
}),
|
||||||
|
{ status: 200 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return new Response(
|
return new Response(
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
products: [
|
products: [
|
||||||
@@ -126,6 +150,7 @@ test("sellability checks matched seller inventory, not the source ASIN", async (
|
|||||||
resume: true,
|
resume: true,
|
||||||
maxSellerRequests: null,
|
maxSellerRequests: null,
|
||||||
sellability: true,
|
sellability: true,
|
||||||
|
analyzeSellable: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(fetchSellabilityBatchMock.mock.calls.length).toBe(1);
|
expect(fetchSellabilityBatchMock.mock.calls.length).toBe(1);
|
||||||
@@ -146,18 +171,37 @@ test("sellability checks matched seller inventory, not the source ASIN", async (
|
|||||||
|
|
||||||
const inventory = db
|
const inventory = db
|
||||||
.query(
|
.query(
|
||||||
"SELECT asin, can_sell, sellability_status FROM stalker_seller_inventory ORDER BY asin",
|
`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<{
|
.all() as Array<{
|
||||||
asin: string;
|
asin: string;
|
||||||
can_sell: number | null;
|
can_sell: number | null;
|
||||||
sellability_status: string | 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([
|
expect(inventory).toEqual([
|
||||||
{
|
{
|
||||||
asin: "B111111111",
|
asin: "B111111111",
|
||||||
can_sell: 1,
|
can_sell: 1,
|
||||||
sellability_status: "available",
|
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,
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -217,6 +217,7 @@ test("runStalker fetches product offers, filters sellers, and persists storefron
|
|||||||
resume: true,
|
resume: true,
|
||||||
maxSellerRequests: null,
|
maxSellerRequests: null,
|
||||||
sellability: false,
|
sellability: false,
|
||||||
|
analyzeSellable: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(stats.scannedAsins).toBe(1);
|
expect(stats.scannedAsins).toBe(1);
|
||||||
|
|||||||
299
src/stalker.ts
299
src/stalker.ts
@@ -40,6 +40,7 @@ export type StalkerArgs = {
|
|||||||
resume: boolean;
|
resume: boolean;
|
||||||
maxSellerRequests: number | null;
|
maxSellerRequests: number | null;
|
||||||
sellability: boolean;
|
sellability: boolean;
|
||||||
|
analyzeSellable: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type StalkerOffer = {
|
export type StalkerOffer = {
|
||||||
@@ -66,6 +67,20 @@ type StalkerInventoryItem = {
|
|||||||
asin: string;
|
asin: string;
|
||||||
rawInventory: unknown;
|
rawInventory: unknown;
|
||||||
sellability: SellabilityInfo | null;
|
sellability: SellabilityInfo | null;
|
||||||
|
productDetails: StalkerProductDetails | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type StalkerProductDetails = {
|
||||||
|
title: string | null;
|
||||||
|
brand: string | null;
|
||||||
|
categoryTree: string[];
|
||||||
|
currentPrice: number | null;
|
||||||
|
avgPrice90: number | null;
|
||||||
|
salesRank: number | null;
|
||||||
|
monthlySold: number | null;
|
||||||
|
sellerCount: number | null;
|
||||||
|
amazonIsSeller: boolean | null;
|
||||||
|
rawProduct: Record<string, any>;
|
||||||
};
|
};
|
||||||
|
|
||||||
type StalkerAsinResult = {
|
type StalkerAsinResult = {
|
||||||
@@ -143,6 +158,11 @@ export function parseArgs(argv = process.argv.slice(2)): StalkerArgs {
|
|||||||
const dryRun = hasFlag(argv, "--dry-run");
|
const dryRun = hasFlag(argv, "--dry-run");
|
||||||
const resume = !hasFlag(argv, "--no-resume");
|
const resume = !hasFlag(argv, "--no-resume");
|
||||||
const sellability = hasFlag(argv, "--sellability");
|
const sellability = hasFlag(argv, "--sellability");
|
||||||
|
const analyzeSellable = hasFlag(argv, "--analyze-sellable");
|
||||||
|
|
||||||
|
if (analyzeSellable && !sellability) {
|
||||||
|
printUsageAndExit("--analyze-sellable requires --sellability.");
|
||||||
|
}
|
||||||
|
|
||||||
if (maxAsins != null && (!Number.isInteger(maxAsins) || maxAsins <= 0)) {
|
if (maxAsins != null && (!Number.isInteger(maxAsins) || maxAsins <= 0)) {
|
||||||
printUsageAndExit("--max-asins must be a positive integer.");
|
printUsageAndExit("--max-asins must be a positive integer.");
|
||||||
@@ -194,6 +214,7 @@ export function parseArgs(argv = process.argv.slice(2)): StalkerArgs {
|
|||||||
resume,
|
resume,
|
||||||
maxSellerRequests,
|
maxSellerRequests,
|
||||||
sellability,
|
sellability,
|
||||||
|
analyzeSellable,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -291,6 +312,10 @@ export async function runStalker(args: StalkerArgs): Promise<StalkerRunStats> {
|
|||||||
const runId = args.dryRun
|
const runId = args.dryRun
|
||||||
? null
|
? null
|
||||||
: startStalkerRun(database, args.input, resumeFilteredAsins.length);
|
: startStalkerRun(database, args.input, resumeFilteredAsins.length);
|
||||||
|
const analysisRunId =
|
||||||
|
!args.dryRun && args.analyzeSellable
|
||||||
|
? startStalkerAnalysisRun(database, args.input)
|
||||||
|
: null;
|
||||||
const stats: StalkerRunStats = {
|
const stats: StalkerRunStats = {
|
||||||
scannedAsins: 0,
|
scannedAsins: 0,
|
||||||
sourceAsinsWithMatches: 0,
|
sourceAsinsWithMatches: 0,
|
||||||
@@ -339,10 +364,23 @@ export async function runStalker(args: StalkerArgs): Promise<StalkerRunStats> {
|
|||||||
await enrichInventorySellability(result, stats);
|
await enrichInventorySellability(result, stats);
|
||||||
}
|
}
|
||||||
applyInventoryPersistencePolicy(result, args.sellability && !args.dryRun);
|
applyInventoryPersistencePolicy(result, args.sellability && !args.dryRun);
|
||||||
|
if (args.sellability && !args.dryRun) {
|
||||||
|
await enrichInventoryProductDetails(result, apiKey);
|
||||||
|
}
|
||||||
|
|
||||||
if (!args.dryRun && runId != null) {
|
if (!args.dryRun && runId != null) {
|
||||||
persistAsinResult(database, runId, result);
|
persistAsinResult(database, runId, result);
|
||||||
}
|
}
|
||||||
|
const sellableAsins = collectPersistedInventoryAsins(result);
|
||||||
|
if (
|
||||||
|
args.analyzeSellable &&
|
||||||
|
!args.dryRun &&
|
||||||
|
runId != null &&
|
||||||
|
analysisRunId != null &&
|
||||||
|
sellableAsins.length > 0
|
||||||
|
) {
|
||||||
|
await runSellableAnalysisChild(args.dbPath, runId, analysisRunId, sellableAsins);
|
||||||
|
}
|
||||||
stats.scannedAsins += 1;
|
stats.scannedAsins += 1;
|
||||||
stats.matchedSellers += result.matchedSellers.length;
|
stats.matchedSellers += result.matchedSellers.length;
|
||||||
stats.persistedInventoryAsins += sumInventoryAsins(result);
|
stats.persistedInventoryAsins += sumInventoryAsins(result);
|
||||||
@@ -378,16 +416,23 @@ export async function runStalker(args: StalkerArgs): Promise<StalkerRunStats> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
logRunSummary(stats, args);
|
logRunSummary(stats, args);
|
||||||
|
if (!args.dryRun && analysisRunId != null) {
|
||||||
|
finishStalkerAnalysisRun(database, analysisRunId, "completed");
|
||||||
|
}
|
||||||
return stats;
|
return stats;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
if (!args.dryRun && runId != null) {
|
if (!args.dryRun && runId != null) {
|
||||||
finishStalkerRunWithError(
|
finishStalkerRunWithError(
|
||||||
database,
|
database,
|
||||||
runId,
|
runId,
|
||||||
stats,
|
stats,
|
||||||
error instanceof Error ? error.message : String(error),
|
message,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
if (!args.dryRun && analysisRunId != null) {
|
||||||
|
finishStalkerAnalysisRun(database, analysisRunId, "failed", message);
|
||||||
|
}
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -522,6 +567,24 @@ async function enrichInventorySellability(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function enrichInventoryProductDetails(
|
||||||
|
result: StalkerAsinResult,
|
||||||
|
apiKey: string,
|
||||||
|
): Promise<void> {
|
||||||
|
const items = result.matchedSellers.flatMap(({ seller }) => seller.storefrontItems);
|
||||||
|
const uniqueAsins = Array.from(new Set(items.map((item) => item.asin)));
|
||||||
|
if (uniqueAsins.length === 0) return;
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`Stalker inventory details: fetching Keepa product details for ${uniqueAsins.length} sellable ASIN(s)...`,
|
||||||
|
);
|
||||||
|
const detailsByAsin = await fetchKeepaInventoryProductDetails(apiKey, uniqueAsins);
|
||||||
|
|
||||||
|
for (const item of items) {
|
||||||
|
item.productDetails = detailsByAsin.get(item.asin) ?? null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function fetchKeepaProduct(
|
async function fetchKeepaProduct(
|
||||||
asin: string,
|
asin: string,
|
||||||
apiKey: string,
|
apiKey: string,
|
||||||
@@ -549,6 +612,39 @@ async function fetchKeepaProduct(
|
|||||||
return product;
|
return product;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function fetchKeepaInventoryProductDetails(
|
||||||
|
apiKey: string,
|
||||||
|
asins: string[],
|
||||||
|
): Promise<Map<string, StalkerProductDetails>> {
|
||||||
|
const details = new Map<string, StalkerProductDetails>();
|
||||||
|
const chunkSize = 100;
|
||||||
|
|
||||||
|
for (let i = 0; i < asins.length; i += chunkSize) {
|
||||||
|
const chunk = asins.slice(i, i + chunkSize);
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
key: apiKey,
|
||||||
|
domain: DOMAIN_US,
|
||||||
|
asin: chunk.join(","),
|
||||||
|
stats: "30",
|
||||||
|
days: "30",
|
||||||
|
buybox: "1",
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await fetchKeepaWithRetries(
|
||||||
|
`${KEEPA_BASE}/product?${params.toString()}`,
|
||||||
|
`inventory product details ${i + 1}-${i + chunk.length}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const product of data.products ?? []) {
|
||||||
|
const asin = normalizeAsin(product.asin);
|
||||||
|
if (!asin) continue;
|
||||||
|
details.set(asin, parseInventoryProductDetails(product));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return details;
|
||||||
|
}
|
||||||
|
|
||||||
async function fetchSellerMetadata(
|
async function fetchSellerMetadata(
|
||||||
sellerIds: string[],
|
sellerIds: string[],
|
||||||
apiKey: string,
|
apiKey: string,
|
||||||
@@ -839,12 +935,24 @@ function upsertSellerInventory(
|
|||||||
const insert = database.prepare(
|
const insert = database.prepare(
|
||||||
`INSERT INTO stalker_seller_inventory (
|
`INSERT INTO stalker_seller_inventory (
|
||||||
run_id, seller_id, asin, can_sell, sellability_status,
|
run_id, seller_id, asin, can_sell, sellability_status,
|
||||||
sellability_reason, last_seen_at, raw_inventory_json
|
sellability_reason, product_title, brand, category_tree, current_price,
|
||||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
avg_price_90d, sales_rank, monthly_sold, seller_count, amazon_is_seller,
|
||||||
|
raw_product_json, last_seen_at, raw_inventory_json
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
ON CONFLICT(run_id, seller_id, asin) DO UPDATE SET
|
ON CONFLICT(run_id, seller_id, asin) DO UPDATE SET
|
||||||
can_sell = excluded.can_sell,
|
can_sell = excluded.can_sell,
|
||||||
sellability_status = excluded.sellability_status,
|
sellability_status = excluded.sellability_status,
|
||||||
sellability_reason = excluded.sellability_reason,
|
sellability_reason = excluded.sellability_reason,
|
||||||
|
product_title = excluded.product_title,
|
||||||
|
brand = excluded.brand,
|
||||||
|
category_tree = excluded.category_tree,
|
||||||
|
current_price = excluded.current_price,
|
||||||
|
avg_price_90d = excluded.avg_price_90d,
|
||||||
|
sales_rank = excluded.sales_rank,
|
||||||
|
monthly_sold = excluded.monthly_sold,
|
||||||
|
seller_count = excluded.seller_count,
|
||||||
|
amazon_is_seller = excluded.amazon_is_seller,
|
||||||
|
raw_product_json = excluded.raw_product_json,
|
||||||
last_seen_at = excluded.last_seen_at,
|
last_seen_at = excluded.last_seen_at,
|
||||||
raw_inventory_json = excluded.raw_inventory_json`,
|
raw_inventory_json = excluded.raw_inventory_json`,
|
||||||
);
|
);
|
||||||
@@ -868,6 +976,20 @@ function upsertSellerInventory(
|
|||||||
: 0,
|
: 0,
|
||||||
item.sellability?.sellabilityStatus ?? null,
|
item.sellability?.sellabilityStatus ?? null,
|
||||||
item.sellability?.sellabilityReason ?? null,
|
item.sellability?.sellabilityReason ?? null,
|
||||||
|
item.productDetails?.title ?? null,
|
||||||
|
item.productDetails?.brand ?? null,
|
||||||
|
item.productDetails ? JSON.stringify(item.productDetails.categoryTree) : null,
|
||||||
|
item.productDetails?.currentPrice ?? null,
|
||||||
|
item.productDetails?.avgPrice90 ?? null,
|
||||||
|
item.productDetails?.salesRank ?? null,
|
||||||
|
item.productDetails?.monthlySold ?? null,
|
||||||
|
item.productDetails?.sellerCount ?? null,
|
||||||
|
item.productDetails?.amazonIsSeller == null
|
||||||
|
? null
|
||||||
|
: item.productDetails.amazonIsSeller
|
||||||
|
? 1
|
||||||
|
: 0,
|
||||||
|
item.productDetails ? JSON.stringify(item.productDetails.rawProduct) : null,
|
||||||
fetchedAt,
|
fetchedAt,
|
||||||
JSON.stringify(item.rawInventory),
|
JSON.stringify(item.rawInventory),
|
||||||
);
|
);
|
||||||
@@ -890,6 +1012,19 @@ function startStalkerRun(
|
|||||||
return result.lastInsertRowid as number;
|
return result.lastInsertRowid as number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function startStalkerAnalysisRun(database: Database, inputFile: string): number {
|
||||||
|
const result = database
|
||||||
|
.prepare(
|
||||||
|
`INSERT INTO category_analysis_runs (
|
||||||
|
category_id, category_label, run_timestamp, top_asins_checked,
|
||||||
|
available_asins, fba_count, fbm_count, skip_count, status, error_message
|
||||||
|
) VALUES (?, ?, ?, 0, 0, 0, 0, 0, 'running', NULL)`,
|
||||||
|
)
|
||||||
|
.run(0, `Stalker: ${path.basename(inputFile)}`, new Date().toISOString());
|
||||||
|
|
||||||
|
return result.lastInsertRowid as number;
|
||||||
|
}
|
||||||
|
|
||||||
function loadPreviouslyScannedAsins(database: Database): Set<string> {
|
function loadPreviouslyScannedAsins(database: Database): Set<string> {
|
||||||
const rows = database
|
const rows = database
|
||||||
.query(`SELECT DISTINCT source_asin FROM stalker_asin_scans`)
|
.query(`SELECT DISTINCT source_asin FROM stalker_asin_scans`)
|
||||||
@@ -1043,6 +1178,53 @@ function finishStalkerRunWithError(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function finishStalkerAnalysisRun(
|
||||||
|
database: Database,
|
||||||
|
runId: number,
|
||||||
|
status: "completed" | "failed",
|
||||||
|
errorMessage: string | null = null,
|
||||||
|
): 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 = ?,
|
||||||
|
status = ?,
|
||||||
|
error_message = ?
|
||||||
|
WHERE id = ?`,
|
||||||
|
)
|
||||||
|
.run(
|
||||||
|
stats.total ?? 0,
|
||||||
|
stats.total ?? 0,
|
||||||
|
stats.fba ?? 0,
|
||||||
|
stats.fbm ?? 0,
|
||||||
|
stats.skip ?? 0,
|
||||||
|
status,
|
||||||
|
errorMessage,
|
||||||
|
runId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeSellerResponse(
|
function normalizeSellerResponse(
|
||||||
sellers: KeepaApiResponse["sellers"],
|
sellers: KeepaApiResponse["sellers"],
|
||||||
): Array<[string, Record<string, any>]> {
|
): Array<[string, Record<string, any>]> {
|
||||||
@@ -1129,7 +1311,7 @@ function collectStorefrontItems(
|
|||||||
const asin = normalizeAsin((value as Record<string, unknown>).asin);
|
const asin = normalizeAsin((value as Record<string, unknown>).asin);
|
||||||
if (asin && !seen.has(asin)) {
|
if (asin && !seen.has(asin)) {
|
||||||
seen.add(asin);
|
seen.add(asin);
|
||||||
items.push({ asin, rawInventory: value, sellability: null });
|
items.push({ asin, rawInventory: value, sellability: null, productDetails: null });
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -1137,7 +1319,70 @@ function collectStorefrontItems(
|
|||||||
const asin = normalizeAsin(value);
|
const asin = normalizeAsin(value);
|
||||||
if (!asin || seen.has(asin)) return;
|
if (!asin || seen.has(asin)) return;
|
||||||
seen.add(asin);
|
seen.add(asin);
|
||||||
items.push({ asin, rawInventory: { asin }, sellability: null });
|
items.push({ asin, rawInventory: { asin }, sellability: null, productDetails: null });
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseInventoryProductDetails(
|
||||||
|
product: Record<string, any>,
|
||||||
|
): StalkerProductDetails {
|
||||||
|
const stats = product.stats;
|
||||||
|
const csv = product.csv;
|
||||||
|
return {
|
||||||
|
title: extractString(product.title),
|
||||||
|
brand: extractString(product.brand ?? product.manufacturer),
|
||||||
|
categoryTree:
|
||||||
|
product.categoryTree?.map((category: { name?: unknown }) =>
|
||||||
|
extractString(category.name),
|
||||||
|
).filter((name: string | null): name is string => !!name) ?? [],
|
||||||
|
currentPrice: extractCurrentPrice(csv),
|
||||||
|
avgPrice90: stats?.avg?.[0] != null ? stats.avg[0] / 100 : null,
|
||||||
|
salesRank: extractNumber(stats?.current?.[3]),
|
||||||
|
monthlySold:
|
||||||
|
extractNumber(product.monthlySold ?? stats?.monthlySold) ??
|
||||||
|
extractNumber(product.salesRankDrops30 ?? stats?.salesRankDrops30),
|
||||||
|
sellerCount: extractNumber(stats?.current?.[11]),
|
||||||
|
amazonIsSeller: resolveAmazonIsSeller(product, stats, csv),
|
||||||
|
rawProduct: product,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractCurrentPrice(csv: unknown): number | null {
|
||||||
|
if (!Array.isArray(csv)) return null;
|
||||||
|
const amazonPrice = extractLatestPositiveKeepaPrice(csv[0]);
|
||||||
|
if (amazonPrice != null) return amazonPrice;
|
||||||
|
return extractLatestPositiveKeepaPrice(csv[1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractLatestPositiveKeepaPrice(history: unknown): number | null {
|
||||||
|
if (!Array.isArray(history)) return null;
|
||||||
|
for (let i = history.length - 1; i >= 0; i--) {
|
||||||
|
const value = extractNumber(history[i]);
|
||||||
|
if (value != null && value > 0) return value / 100;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveAmazonIsSeller(
|
||||||
|
product: Record<string, any>,
|
||||||
|
stats: Record<string, any> | undefined,
|
||||||
|
csv: unknown,
|
||||||
|
): boolean | null {
|
||||||
|
if (typeof product.isAmazonSeller === "boolean") return product.isAmazonSeller;
|
||||||
|
if (typeof product.availabilityAmazon === "number") {
|
||||||
|
if (product.availabilityAmazon >= 0) return true;
|
||||||
|
if (product.availabilityAmazon === -1 || product.availabilityAmazon === -2) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (stats?.buyBoxIsAmazon === true) return true;
|
||||||
|
if (extractNumber(stats?.current?.[0]) != null) {
|
||||||
|
const currentAmazon = extractNumber(stats?.current?.[0]);
|
||||||
|
if (currentAmazon != null && currentAmazon > 0) return true;
|
||||||
|
}
|
||||||
|
const amazonHistoryPrice = Array.isArray(csv)
|
||||||
|
? extractLatestPositiveKeepaPrice(csv[0])
|
||||||
|
: null;
|
||||||
|
return amazonHistoryPrice == null ? null : amazonHistoryPrice > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
function extractSellerRatingCount(seller: Record<string, any>): number | null {
|
function extractSellerRatingCount(seller: Record<string, any>): number | null {
|
||||||
@@ -1173,6 +1418,48 @@ function sumInventoryAsins(result: StalkerAsinResult): number {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function collectPersistedInventoryAsins(result: StalkerAsinResult): string[] {
|
||||||
|
const seen = new Set<string>();
|
||||||
|
for (const { seller } of result.matchedSellers) {
|
||||||
|
for (const asin of seller.storefrontAsins) {
|
||||||
|
seen.add(asin);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Array.from(seen);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runSellableAnalysisChild(
|
||||||
|
dbPath: string,
|
||||||
|
stalkerRunId: number,
|
||||||
|
analysisRunId: number,
|
||||||
|
asins: string[],
|
||||||
|
): Promise<void> {
|
||||||
|
const child = Bun.spawn({
|
||||||
|
cmd: [
|
||||||
|
"bun",
|
||||||
|
"run",
|
||||||
|
"src/stalker-analyze.ts",
|
||||||
|
"--db",
|
||||||
|
dbPath,
|
||||||
|
"--stalker-run-id",
|
||||||
|
String(stalkerRunId),
|
||||||
|
"--analysis-run-id",
|
||||||
|
String(analysisRunId),
|
||||||
|
"--asins",
|
||||||
|
asins.join(","),
|
||||||
|
],
|
||||||
|
stdout: "inherit",
|
||||||
|
stderr: "inherit",
|
||||||
|
});
|
||||||
|
|
||||||
|
const exitCode = await child.exited;
|
||||||
|
if (exitCode !== 0) {
|
||||||
|
console.warn(
|
||||||
|
`Stalker analysis child failed for ${asins.length} ASIN(s), exit=${exitCode}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeAsin(value: unknown): string | null {
|
function normalizeAsin(value: unknown): string | null {
|
||||||
const asin = String(value ?? "")
|
const asin = String(value ?? "")
|
||||||
.trim()
|
.trim()
|
||||||
@@ -1227,7 +1514,7 @@ function hasFlag(args: string[], flag: string): boolean {
|
|||||||
function printUsageAndExit(message: string): never {
|
function printUsageAndExit(message: string): never {
|
||||||
console.error(message);
|
console.error(message);
|
||||||
console.error(
|
console.error(
|
||||||
"Usage: bun run stalker --input input/asins.xlsx [--db db/results.db] [--max-asins N] [--offer-limit 100] [--seller-limit 30] [--inventory-limit 200] [--storefront-update-hours 168] [--seller-cache-hours 168] [--max-seller-requests N] [--sellability] [--include-stock] [--dry-run] [--no-resume]",
|
"Usage: bun run stalker --input input/asins.xlsx [--db db/results.db] [--max-asins N] [--offer-limit 100] [--seller-limit 30] [--inventory-limit 200] [--storefront-update-hours 168] [--seller-cache-hours 168] [--max-seller-requests N] [--sellability] [--analyze-sellable] [--include-stock] [--dry-run] [--no-resume]",
|
||||||
);
|
);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -151,6 +151,18 @@ type StalkerProductItem = {
|
|||||||
can_sell: number;
|
can_sell: number;
|
||||||
sellability_status: string;
|
sellability_status: string;
|
||||||
sellability_reason: string | null;
|
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;
|
last_seen_at: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -202,6 +214,18 @@ function formatBoolean(value: number | null | undefined): string {
|
|||||||
return value === 1 ? "Yes" : "No";
|
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 {
|
function buildSortValue(sort: SortState): string {
|
||||||
return `${sort.field}:${sort.direction}`;
|
return `${sort.field}:${sort.direction}`;
|
||||||
}
|
}
|
||||||
@@ -1113,7 +1137,7 @@ function StalkerProductsExplorer({
|
|||||||
const [runId, setRunId] = useState("");
|
const [runId, setRunId] = useState("");
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
const [pageSize, setPageSize] = useState(50);
|
const [pageSize, setPageSize] = useState(50);
|
||||||
const [sort, setSort] = useState<SortState>({ field: "last_seen_at", direction: "DESC" });
|
const [sort, setSort] = useState<SortState>({ field: "monthly_sold", direction: "DESC" });
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
@@ -1170,7 +1194,7 @@ function StalkerProductsExplorer({
|
|||||||
|
|
||||||
<div className="card">
|
<div className="card">
|
||||||
<div className="toolbar">
|
<div className="toolbar">
|
||||||
<input value={search} onChange={(e) => { setPage(1); setSearch(e.target.value); }} placeholder="Search ASIN or seller" />
|
<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={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={runId} onChange={(e) => { setPage(1); setRunId(e.target.value); }} placeholder="Run ID" />
|
||||||
<select value={String(pageSize)} onChange={(e) => { setPage(1); setPageSize(Number(e.target.value)); }}>
|
<select value={String(pageSize)} onChange={(e) => { setPage(1); setPageSize(Number(e.target.value)); }}>
|
||||||
@@ -1187,6 +1211,17 @@ function StalkerProductsExplorer({
|
|||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th><button onClick={() => setSort(nextSort(sort, "asin"))}>ASIN</button></th>
|
<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_id"))}>Seller ID</button></th>
|
||||||
<th><button onClick={() => setSort(nextSort(sort, "seller_name"))}>Seller</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><button onClick={() => setSort(nextSort(sort, "rating_count"))}>Rating Count</button></th>
|
||||||
@@ -1197,21 +1232,35 @@ function StalkerProductsExplorer({
|
|||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<tr><td colSpan={7}>Loading...</td></tr>
|
<tr><td colSpan={18}>Loading...</td></tr>
|
||||||
) : results?.items.length ? (
|
) : results?.items.length ? (
|
||||||
results.items.map((item) => (
|
results.items.map((item) => {
|
||||||
<tr key={`${item.runId}-${item.seller_id}-${item.asin}`}>
|
const categories = parseStringArrayJson(item.category_tree);
|
||||||
<td><a href={`http://amazon.com/dp/${item.asin}`} target="_blank" rel="noreferrer">{item.asin}</a></td>
|
return (
|
||||||
<td>{item.seller_id}</td>
|
<tr key={`${item.runId}-${item.seller_id}-${item.asin}`}>
|
||||||
<td>{item.seller_name || "-"}</td>
|
<td><a href={`http://amazon.com/dp/${item.asin}`} target="_blank" rel="noreferrer">{item.asin}</a></td>
|
||||||
<td>{formatNumber(item.rating_count)}</td>
|
<td className="product-col" title={item.product_title || undefined}>{item.product_title || "-"}</td>
|
||||||
<td><span className="badge badge-ok">{item.sellability_status}</span></td>
|
<td>{item.brand || "-"}</td>
|
||||||
<td>{item.runId}</td>
|
<td>{categories.at(-1) || "-"}</td>
|
||||||
<td>{formatDate(item.last_seen_at)}</td>
|
<td>{formatNumber(item.monthly_sold)}</td>
|
||||||
</tr>
|
<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={7}>No sellable Stalker products found</td></tr>
|
<tr><td colSpan={18}>No sellable Stalker products found</td></tr>
|
||||||
)}
|
)}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|||||||
Reference in New Issue
Block a user