import { client, db } from "../db/index.ts"; import { persistLlmResults, refreshRunStats } from "../db/persistence.ts"; import { sql } from "drizzle-orm"; import { normalizeAsin } from "../asin.ts"; import { analyzeProducts } from "../integrations/llm.ts"; import { fetchSpApiPricingAndFees } from "../integrations/sp-api.ts"; import type { AnalysisResult, EnrichedProduct, KeepaData, ProductRecord, SellabilityInfo, } from "../types.ts"; const LLM_BATCH_SIZE = 5; const LLM_BATCH_DELAY_MS = 5_000; type Args = { stalkerRunId: number; analysisRunId: number; asins: string[]; useClaude: boolean; }; type InventoryRow = { inventoryItemId: number; asin: string; productTitle: string | null; brand: string | null; categoryTree: string | null; currentPrice: number | null; avgPrice90d: number | null; salesRank: number | null; monthlySold: number | null; sellerCount: number | null; amazonIsSeller: boolean | null; canSell: boolean | null; sellabilityStatus: SellabilityInfo["sellabilityStatus"] | null; sellabilityReason: 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 stalkerRunId = Number(readFlagValue(argv, "--stalker-run-id")); const analysisRunId = Number(readFlagValue(argv, "--analysis-run-id")); const useClaude = argv.includes("--claude"); const asins = (readFlagValue(argv, "--asins") ?? "") .split(",") .map((asin) => normalizeAsin(asin)) .filter((asin): asin is string => asin !== null); 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 { stalkerRunId, analysisRunId, asins, useClaude }; } function wait(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); } function parseCategoryTree(value: string | null): string[] { return value ? value.split(" > ").filter(Boolean) : []; } function toProductRecord(row: InventoryRow): ProductRecord { const categoryTree = parseCategoryTree(row.categoryTree); return { asin: row.asin, name: row.productTitle ?? row.asin, brand: row.brand ?? undefined, category: categoryTree.join(" > ") || undefined, unitCost: 0, amazonRank: row.salesRank ?? undefined, sellingPriceFromSheet: row.currentPrice ?? undefined, avgPrice90FromSheet: row.avgPrice90d ?? undefined, }; } function toKeepaData(row: InventoryRow): KeepaData { return { currentPrice: row.currentPrice, avgPrice90: row.avgPrice90d, minPrice90: null, maxPrice90: null, salesRank: row.salesRank, salesRankAvg90: null, salesRankDrops30: null, salesRankDrops90: null, sellerCount: row.sellerCount, amazonIsSeller: row.amazonIsSeller, amazonBuyboxSharePct90d: null, buyBoxSeller: null, buyBoxPrice: null, buyBoxAvg90: null, monthlySold: row.monthlySold, categoryTree: parseCategoryTree(row.categoryTree), }; } function toSellability(row: InventoryRow): SellabilityInfo { return { canSell: row.canSell, sellabilityStatus: row.sellabilityStatus ?? "unknown", sellabilityReason: row.sellabilityReason ?? undefined, }; } async function loadInventoryRows( stalkerRunId: number, asins: string[], ): Promise { if (asins.length === 0) return []; return db.execute( sql`SELECT DISTINCT ON (inventory.product_asin) inventory.id AS "inventoryItemId", inventory.product_asin AS asin, product.name AS "productTitle", product.brand, product.category AS "categoryTree", observation.current_price AS "currentPrice", observation.avg_price_90d AS "avgPrice90d", observation.sales_rank AS "salesRank", observation.monthly_sold AS "monthlySold", observation.seller_count AS "sellerCount", observation.amazon_is_seller AS "amazonIsSeller", observation.can_sell AS "canSell", observation.sellability_status AS "sellabilityStatus", observation.sellability_reason AS "sellabilityReason" FROM stalker_inventory_items inventory JOIN products product ON product.asin = inventory.product_asin JOIN product_observations observation ON observation.id = inventory.observation_id WHERE inventory.run_id = ${stalkerRunId} AND observation.can_sell = true AND observation.sellability_status = 'available' AND inventory.product_asin = ANY(ARRAY[${sql.join(asins.map((asin) => sql`${asin}`), sql`, `)}]) ORDER BY inventory.product_asin, observation.fetched_at DESC`, ); } 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.currentPrice, ); enriched.push({ record: toProductRecord(row), keepa: toKeepaData(row), spApi, fetchedAt: new Date().toISOString(), }); } return enriched; } async function insertProductAnalysisResults( runId: number, results: AnalysisResult[], sourceInventoryIds: Map, ): Promise { if (results.length === 0) return; await persistLlmResults(runId, results, { source: "stalker_analysis", metadataSource: "catalog", sourceInventoryIds, }); } async function refreshAnalysisRun(runId: number): Promise { await refreshRunStats(runId); } async function analyzeInBatches( products: EnrichedProduct[], useClaude: boolean, ): Promise { const results: AnalysisResult[] = []; for (let i = 0; i < products.length; i += LLM_BATCH_SIZE) { const batch = products.slice(i, i + LLM_BATCH_SIZE); const batchNumber = Math.floor(i / LLM_BATCH_SIZE) + 1; const totalBatches = Math.ceil(products.length / LLM_BATCH_SIZE); console.log( `Stalker analysis: LLM batch ${batchNumber}/${totalBatches} (${batch.length} product(s)).`, ); if (i > 0) { await wait(LLM_BATCH_DELAY_MS); } let verdicts; try { verdicts = await analyzeProducts(batch, { useClaude }); } catch (error) { console.warn( `Stalker analysis: LLM batch ${batchNumber} failed: ${ error instanceof Error ? error.message : String(error) }`, ); verdicts = null; } for (let j = 0; j < batch.length; j++) { const product = batch[j]; if (!product) continue; results.push({ product, verdict: verdicts?.[j] ?? { asin: product.record.asin, verdict: "SKIP", confidence: 0, reasoning: "LLM analysis failed or returned no verdict", }, }); } } return results; } async function main(): Promise { const args = parseArgs(); const rows = await loadInventoryRows(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 results = await analyzeInBatches(enriched, args.useClaude); const sourceInventoryIds = new Map( rows.map((row) => [row.asin, row.inventoryItemId]), ); await insertProductAnalysisResults( args.analysisRunId, results, sourceInventoryIds, ); await refreshAnalysisRun(args.analysisRunId); } if (import.meta.main) { main() .catch((error) => { console.error(error instanceof Error ? error.message : String(error)); process.exitCode = 1; }) .finally(async () => { try { await client.end({ timeout: 5 }); } catch { } }); }