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

@@ -40,6 +40,7 @@ export type StalkerArgs = {
resume: boolean;
maxSellerRequests: number | null;
sellability: boolean;
analyzeSellable: boolean;
};
export type StalkerOffer = {
@@ -66,6 +67,20 @@ type StalkerInventoryItem = {
asin: string;
rawInventory: unknown;
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 = {
@@ -143,6 +158,11 @@ export function parseArgs(argv = process.argv.slice(2)): StalkerArgs {
const dryRun = hasFlag(argv, "--dry-run");
const resume = !hasFlag(argv, "--no-resume");
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)) {
printUsageAndExit("--max-asins must be a positive integer.");
@@ -194,6 +214,7 @@ export function parseArgs(argv = process.argv.slice(2)): StalkerArgs {
resume,
maxSellerRequests,
sellability,
analyzeSellable,
};
}
@@ -291,6 +312,10 @@ export async function runStalker(args: StalkerArgs): Promise<StalkerRunStats> {
const runId = args.dryRun
? null
: startStalkerRun(database, args.input, resumeFilteredAsins.length);
const analysisRunId =
!args.dryRun && args.analyzeSellable
? startStalkerAnalysisRun(database, args.input)
: null;
const stats: StalkerRunStats = {
scannedAsins: 0,
sourceAsinsWithMatches: 0,
@@ -339,10 +364,23 @@ export async function runStalker(args: StalkerArgs): Promise<StalkerRunStats> {
await enrichInventorySellability(result, stats);
}
applyInventoryPersistencePolicy(result, args.sellability && !args.dryRun);
if (args.sellability && !args.dryRun) {
await enrichInventoryProductDetails(result, apiKey);
}
if (!args.dryRun && runId != null) {
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.matchedSellers += result.matchedSellers.length;
stats.persistedInventoryAsins += sumInventoryAsins(result);
@@ -378,16 +416,23 @@ export async function runStalker(args: StalkerArgs): Promise<StalkerRunStats> {
);
}
logRunSummary(stats, args);
if (!args.dryRun && analysisRunId != null) {
finishStalkerAnalysisRun(database, analysisRunId, "completed");
}
return stats;
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
if (!args.dryRun && runId != null) {
finishStalkerRunWithError(
database,
runId,
stats,
error instanceof Error ? error.message : String(error),
message,
);
}
if (!args.dryRun && analysisRunId != null) {
finishStalkerAnalysisRun(database, analysisRunId, "failed", message);
}
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(
asin: string,
apiKey: string,
@@ -549,6 +612,39 @@ async function fetchKeepaProduct(
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(
sellerIds: string[],
apiKey: string,
@@ -839,12 +935,24 @@ function upsertSellerInventory(
const insert = database.prepare(
`INSERT INTO stalker_seller_inventory (
run_id, seller_id, asin, can_sell, sellability_status,
sellability_reason, last_seen_at, raw_inventory_json
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
sellability_reason, product_title, brand, category_tree, current_price,
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
can_sell = excluded.can_sell,
sellability_status = excluded.sellability_status,
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,
raw_inventory_json = excluded.raw_inventory_json`,
);
@@ -868,6 +976,20 @@ function upsertSellerInventory(
: 0,
item.sellability?.sellabilityStatus ?? 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,
JSON.stringify(item.rawInventory),
);
@@ -890,6 +1012,19 @@ function startStalkerRun(
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> {
const rows = database
.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(
sellers: KeepaApiResponse["sellers"],
): Array<[string, Record<string, any>]> {
@@ -1129,7 +1311,7 @@ function collectStorefrontItems(
const asin = normalizeAsin((value as Record<string, unknown>).asin);
if (asin && !seen.has(asin)) {
seen.add(asin);
items.push({ asin, rawInventory: value, sellability: null });
items.push({ asin, rawInventory: value, sellability: null, productDetails: null });
}
return;
}
@@ -1137,7 +1319,70 @@ function collectStorefrontItems(
const asin = normalizeAsin(value);
if (!asin || seen.has(asin)) return;
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 {
@@ -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 {
const asin = String(value ?? "")
.trim()
@@ -1227,7 +1514,7 @@ function hasFlag(args: string[], flag: string): boolean {
function printUsageAndExit(message: string): never {
console.error(message);
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);
}