From 0552d183b3a47fbea7acdedad9a36add77a6439c Mon Sep 17 00:00:00 2001 From: Victor Noguera Date: Tue, 19 May 2026 19:57:53 -0400 Subject: [PATCH] feat: enhance Stalker functionality with additional product details and analysis capabilities --- src/database.ts | 19 +- src/server.ts | 48 ++++- src/stalker-analyze.ts | 329 ++++++++++++++++++++++++++++++++ src/stalker-sellability.test.ts | 46 ++++- src/stalker.test.ts | 1 + src/stalker.ts | 299 ++++++++++++++++++++++++++++- src/web/frontend.tsx | 79 ++++++-- 7 files changed, 795 insertions(+), 26 deletions(-) create mode 100644 src/stalker-analyze.ts diff --git a/src/database.ts b/src/database.ts index 35a1074..8d4413f 100644 --- a/src/database.ts +++ b/src/database.ts @@ -419,6 +419,16 @@ export function initStalkerDb(database: Database): void { can_sell INTEGER, sellability_status TEXT, sellability_reason TEXT, + product_title TEXT, + brand TEXT, + category_tree TEXT, + current_price REAL, + avg_price_90d REAL, + sales_rank INTEGER, + monthly_sold INTEGER, + seller_count INTEGER, + amazon_is_seller INTEGER, + raw_product_json TEXT, last_seen_at TEXT NOT NULL, raw_inventory_json TEXT, UNIQUE(run_id, seller_id, asin), @@ -445,6 +455,9 @@ export function initStalkerDb(database: Database): void { database.run( `CREATE INDEX IF NOT EXISTS idx_stalker_inventory_asin ON stalker_seller_inventory(asin);`, ); + database.run( + `CREATE INDEX IF NOT EXISTS idx_stalker_inventory_product_title ON stalker_seller_inventory(product_title);`, + ); } function resetLegacyStalkerSchema(database: Database): void { @@ -473,5 +486,9 @@ function inventoryColumnsHaveSellability(database: Database): boolean { const inventoryColumns = database .query("PRAGMA table_info(stalker_seller_inventory)") .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") + ); } diff --git a/src/server.ts b/src/server.ts index 8d6957e..aa4213b 100644 --- a/src/server.ts +++ b/src/server.ts @@ -82,6 +82,18 @@ type StalkerProductRecord = { can_sell: number; sellability_status: string; sellability_reason: string | null; + product_title: string | null; + brand: string | null; + category_tree: string | null; + current_price: number | null; + avg_price_90d: number | null; + sales_rank: number | null; + monthly_sold: number | null; + seller_count: number | null; + amazon_is_seller: number | null; + verdict: string | null; + confidence: number | null; + reasoning: string | null; last_seen_at: string; }; @@ -840,9 +852,16 @@ function parseStalkerProductFilters(filters: URLSearchParams) { if (q) { const wildcard = `%${q}%`; 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 { @@ -860,9 +879,19 @@ function parseStalkerProductSort(sortParam: string | null): string { "rating", "rating_count", "asin", + "product_title", + "brand", + "current_price", + "avg_price_90d", + "sales_rank", + "monthly_sold", + "seller_count", + "amazon_is_seller", + "verdict", + "confidence", "last_seen_at", ]); - return parseSort(sortParam, allowedSort, "last_seen_at DESC, asin ASC"); + return parseSort(sortParam, allowedSort, "monthly_sold DESC, last_seen_at DESC, asin ASC"); } function getStalkerProducts(filters: URLSearchParams) { @@ -887,10 +916,23 @@ function getStalkerProducts(filters: URLSearchParams) { inv.can_sell, inv.sellability_status, inv.sellability_reason, + inv.product_title, + inv.brand, + inv.category_tree, + inv.current_price, + inv.avg_price_90d, + inv.sales_rank, + inv.monthly_sold, + inv.seller_count, + inv.amazon_is_seller, + analysis.verdict, + analysis.confidence, + analysis.reasoning, inv.last_seen_at FROM stalker_seller_inventory inv JOIN stalker_runs r ON r.id = inv.run_id JOIN stalker_sellers s ON s.seller_id = inv.seller_id + LEFT JOIN product_analysis_results analysis ON analysis.asin = inv.asin ${where} `; diff --git a/src/stalker-analyze.ts b/src/stalker-analyze.ts new file mode 100644 index 0000000..d93f504 --- /dev/null +++ b/src/stalker-analyze.ts @@ -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 { + 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 { + 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); + }); +} diff --git a/src/stalker-sellability.test.ts b/src/stalker-sellability.test.ts index 33d2dbd..da5f4b8 100644 --- a/src/stalker-sellability.test.ts +++ b/src/stalker-sellability.test.ts @@ -75,6 +75,30 @@ test("sellability checks matched seller inventory, not the source ASIN", async ( const url = new URL(rawUrl); if (url.pathname === "/product") { + if (url.searchParams.get("asin") === "B111111111") { + return new Response( + JSON.stringify({ + products: [ + { + asin: "B111111111", + title: "Sellable Storefront Product", + brand: "Good Brand", + categoryTree: [{ name: "Kitchen" }, { name: "Storage" }], + monthlySold: 42, + stats: { + current: [null, null, null, 12345, null, null, null, null, null, null, null, 7], + avg: [2500], + }, + csv: [[0, 1999]], + }, + ], + tokensLeft: 10, + refillRate: 10, + }), + { status: 200 }, + ); + } + return new Response( JSON.stringify({ products: [ @@ -126,6 +150,7 @@ test("sellability checks matched seller inventory, not the source ASIN", async ( resume: true, maxSellerRequests: null, sellability: true, + analyzeSellable: false, }); 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 .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<{ asin: string; can_sell: number | null; sellability_status: string | null; + product_title: string | null; + brand: string | null; + category_tree: string | null; + current_price: number | null; + avg_price_90d: number | null; + sales_rank: number | null; + monthly_sold: number | null; + seller_count: number | null; }>; expect(inventory).toEqual([ { asin: "B111111111", can_sell: 1, sellability_status: "available", + product_title: "Sellable Storefront Product", + brand: "Good Brand", + category_tree: JSON.stringify(["Kitchen", "Storage"]), + current_price: 19.99, + avg_price_90d: 25, + sales_rank: 12345, + monthly_sold: 42, + seller_count: 7, }, ]); }); diff --git a/src/stalker.test.ts b/src/stalker.test.ts index e86e2d5..25845c2 100644 --- a/src/stalker.test.ts +++ b/src/stalker.test.ts @@ -217,6 +217,7 @@ test("runStalker fetches product offers, filters sellers, and persists storefron resume: true, maxSellerRequests: null, sellability: false, + analyzeSellable: false, }); expect(stats.scannedAsins).toBe(1); diff --git a/src/stalker.ts b/src/stalker.ts index 712a030..306ef20 100644 --- a/src/stalker.ts +++ b/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; }; 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 { 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 { 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 { ); } 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 { + 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> { + const details = new Map(); + 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 { 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]> { @@ -1129,7 +1311,7 @@ function collectStorefrontItems( const asin = normalizeAsin((value as Record).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, +): 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, + stats: Record | 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): number | null { @@ -1173,6 +1418,48 @@ function sumInventoryAsins(result: StalkerAsinResult): number { ); } +function collectPersistedInventoryAsins(result: StalkerAsinResult): string[] { + const seen = new Set(); + 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 { + 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); } diff --git a/src/web/frontend.tsx b/src/web/frontend.tsx index dd35182..34685dd 100644 --- a/src/web/frontend.tsx +++ b/src/web/frontend.tsx @@ -151,6 +151,18 @@ type StalkerProductItem = { can_sell: number; sellability_status: string; sellability_reason: string | null; + product_title: string | null; + brand: string | null; + category_tree: string | null; + current_price: number | null; + avg_price_90d: number | null; + sales_rank: number | null; + monthly_sold: number | null; + seller_count: number | null; + amazon_is_seller: number | null; + verdict: "FBA" | "FBM" | "SKIP" | null; + confidence: number | null; + reasoning: string | null; last_seen_at: string; }; @@ -202,6 +214,18 @@ function formatBoolean(value: number | null | undefined): string { return value === 1 ? "Yes" : "No"; } +function parseStringArrayJson(value: string | null | undefined): string[] { + if (!value) return []; + try { + const parsed = JSON.parse(value); + return Array.isArray(parsed) + ? parsed.filter((item): item is string => typeof item === "string") + : []; + } catch { + return []; + } +} + function buildSortValue(sort: SortState): string { return `${sort.field}:${sort.direction}`; } @@ -1113,7 +1137,7 @@ function StalkerProductsExplorer({ const [runId, setRunId] = useState(""); const [page, setPage] = useState(1); const [pageSize, setPageSize] = useState(50); - const [sort, setSort] = useState({ field: "last_seen_at", direction: "DESC" }); + const [sort, setSort] = useState({ field: "monthly_sold", direction: "DESC" }); useEffect(() => { let cancelled = false; @@ -1170,7 +1194,7 @@ function StalkerProductsExplorer({
- { setPage(1); setSearch(e.target.value); }} placeholder="Search ASIN or seller" /> + { setPage(1); setSearch(e.target.value); }} placeholder="Search ASIN, product, brand, category, or seller" /> { setPage(1); setSellerId(e.target.value.toUpperCase()); }} placeholder="Seller ID" /> { setPage(1); setRunId(e.target.value); }} placeholder="Run ID" />