feat: enhance Stalker functionality with additional product details and analysis capabilities
This commit is contained in:
299
src/stalker.ts
299
src/stalker.ts
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user