feat: enhance Stalker functionality with additional product details and analysis capabilities

This commit is contained in:
Victor Noguera
2026-05-19 19:57:53 -04:00
parent f6178a665c
commit 0552d183b3
7 changed files with 795 additions and 26 deletions

View File

@@ -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")
);
} }

View File

@@ -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
View 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);
});
}

View File

@@ -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,
}, },
]); ]);
}); });

View File

@@ -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);

View File

@@ -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);
} }

View File

@@ -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,11 +1232,24 @@ 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) => {
const categories = parseStringArrayJson(item.category_tree);
return (
<tr key={`${item.runId}-${item.seller_id}-${item.asin}`}> <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><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_id}</td>
<td>{item.seller_name || "-"}</td> <td>{item.seller_name || "-"}</td>
<td>{formatNumber(item.rating_count)}</td> <td>{formatNumber(item.rating_count)}</td>
@@ -1209,9 +1257,10 @@ function StalkerProductsExplorer({
<td>{item.runId}</td> <td>{item.runId}</td>
<td>{formatDate(item.last_seen_at)}</td> <td>{formatDate(item.last_seen_at)}</td>
</tr> </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>