From 061f771279731f8fefbe7f70637794c3dbba2619 Mon Sep 17 00:00:00 2001 From: Victor Noguera Date: Sat, 4 Apr 2026 21:33:27 -0400 Subject: [PATCH] feat: initialize asin-check project with Bun - Add README.md with installation and usage instructions. - Create bun.lock for dependency management. - Add package.json to define project metadata and dependencies. - Implement caching with Redis in cache.ts for ASIN data. - Configure environment variables in config.ts for API keys and Redis URL. - Develop main application logic in index.ts to read products, fetch data, and analyze results. - Integrate Keepa API for product data retrieval in keepa.ts. - Create LLM analysis functionality in llm.ts for product viability assessment. - Implement product reading from Excel files in reader.ts. - Stub SP-API integration in sp-api.ts for future implementation. - Define TypeScript types in types.ts for product and analysis data structures. - Write results to console and CSV in writer.ts. - Configure TypeScript settings in tsconfig.json for project compilation. --- .claude/settings.local.json | 10 +++ .env.example | 5 ++ .gitignore | 37 +++++++++ CLAUDE.md | 106 ++++++++++++++++++++++++ README.md | 15 ++++ bun.lock | 70 ++++++++++++++++ package.json | 16 ++++ src/cache.ts | 49 +++++++++++ src/config.ts | 17 ++++ src/index.ts | 155 ++++++++++++++++++++++++++++++++++ src/keepa.ts | 111 +++++++++++++++++++++++++ src/llm.ts | 160 ++++++++++++++++++++++++++++++++++++ src/reader.ts | 81 ++++++++++++++++++ src/sp-api.ts | 18 ++++ src/types.ts | 59 +++++++++++++ src/writer.ts | 67 +++++++++++++++ tsconfig.json | 29 +++++++ 17 files changed, 1005 insertions(+) create mode 100644 .claude/settings.local.json create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 CLAUDE.md create mode 100644 README.md create mode 100644 bun.lock create mode 100644 package.json create mode 100644 src/cache.ts create mode 100644 src/config.ts create mode 100644 src/index.ts create mode 100644 src/keepa.ts create mode 100644 src/llm.ts create mode 100644 src/reader.ts create mode 100644 src/sp-api.ts create mode 100644 src/types.ts create mode 100644 src/writer.ts create mode 100644 tsconfig.json diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..e452c17 --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,10 @@ +{ + "permissions": { + "allow": [ + "WebSearch", + "Bash(bun init:*)", + "Bash(bunx tsc:*)", + "Bash(bun -e ':*)" + ] + } +} diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..c8470c4 --- /dev/null +++ b/.env.example @@ -0,0 +1,5 @@ +KEEPA_API_KEY=your_keepa_api_key_here +REDIS_URL=redis://localhost:6379 +LLM_URL=http://localhost:1234/v1 +LLM_MODEL=default +CACHE_TTL=86400 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..694ac49 --- /dev/null +++ b/.gitignore @@ -0,0 +1,37 @@ +# dependencies (bun install) +node_modules + +# output +out +dist +*.tgz + +# code coverage +coverage +*.lcov + +# logs +logs +_.log +report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json + +# dotenv environment variable files +.env +.env.development.local +.env.test.local +.env.production.local +.env.local + +# caches +.eslintcache +.cache +*.tsbuildinfo + +# IntelliJ based IDEs +.idea + +# Finder (MacOS) folder config +.DS_Store +*.xlsx +*.csv + diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..764c1dd --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,106 @@ + +Default to using Bun instead of Node.js. + +- Use `bun ` instead of `node ` or `ts-node ` +- Use `bun test` instead of `jest` or `vitest` +- Use `bun build ` instead of `webpack` or `esbuild` +- Use `bun install` instead of `npm install` or `yarn install` or `pnpm install` +- Use `bun run + + +``` + +With the following `frontend.tsx`: + +```tsx#frontend.tsx +import React from "react"; +import { createRoot } from "react-dom/client"; + +// import .css files directly and it works +import './index.css'; + +const root = createRoot(document.body); + +export default function Frontend() { + return

Hello, world!

; +} + +root.render(); +``` + +Then, run index.ts + +```sh +bun --hot ./index.ts +``` + +For more information, read the Bun API docs in `node_modules/bun-types/docs/**.mdx`. diff --git a/README.md b/README.md new file mode 100644 index 0000000..f64bb6b --- /dev/null +++ b/README.md @@ -0,0 +1,15 @@ +# asin-check + +To install dependencies: + +```bash +bun install +``` + +To run: + +```bash +bun run index.ts +``` + +This project was created using `bun init` in bun v1.3.10. [Bun](https://bun.com) is a fast all-in-one JavaScript runtime. diff --git a/bun.lock b/bun.lock new file mode 100644 index 0000000..466e3dd --- /dev/null +++ b/bun.lock @@ -0,0 +1,70 @@ +{ + "lockfileVersion": 1, + "configVersion": 1, + "workspaces": { + "": { + "name": "asin-check", + "dependencies": { + "ioredis": "^5.10.1", + "xlsx": "^0.18.5", + }, + "devDependencies": { + "@types/bun": "latest", + }, + "peerDependencies": { + "typescript": "^5", + }, + }, + }, + "packages": { + "@ioredis/commands": ["@ioredis/commands@1.5.1", "", {}, "sha512-JH8ZL/ywcJyR9MmJ5BNqZllXNZQqQbnVZOqpPQqE1vHiFgAw4NHbvE0FOduNU8IX9babitBT46571OnPTT0Zcw=="], + + "@types/bun": ["@types/bun@1.3.11", "", { "dependencies": { "bun-types": "1.3.11" } }, "sha512-5vPne5QvtpjGpsGYXiFyycfpDF2ECyPcTSsFBMa0fraoxiQyMJ3SmuQIGhzPg2WJuWxVBoxWJ2kClYTcw/4fAg=="], + + "@types/node": ["@types/node@25.5.2", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-tO4ZIRKNC+MDWV4qKVZe3Ql/woTnmHDr5JD8UI5hn2pwBrHEwOEMZK7WlNb5RKB6EoJ02gwmQS9OrjuFnZYdpg=="], + + "adler-32": ["adler-32@1.3.1", "", {}, "sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A=="], + + "bun-types": ["bun-types@1.3.11", "", { "dependencies": { "@types/node": "*" } }, "sha512-1KGPpoxQWl9f6wcZh57LvrPIInQMn2TQ7jsgxqpRzg+l0QPOFvJVH7HmvHo/AiPgwXy+/Thf6Ov3EdVn1vOabg=="], + + "cfb": ["cfb@1.2.2", "", { "dependencies": { "adler-32": "~1.3.0", "crc-32": "~1.2.0" } }, "sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA=="], + + "cluster-key-slot": ["cluster-key-slot@1.1.2", "", {}, "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA=="], + + "codepage": ["codepage@1.15.0", "", {}, "sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA=="], + + "crc-32": ["crc-32@1.2.2", "", { "bin": { "crc32": "bin/crc32.njs" } }, "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ=="], + + "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], + + "denque": ["denque@2.1.0", "", {}, "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw=="], + + "frac": ["frac@1.1.2", "", {}, "sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA=="], + + "ioredis": ["ioredis@5.10.1", "", { "dependencies": { "@ioredis/commands": "1.5.1", "cluster-key-slot": "^1.1.0", "debug": "^4.3.4", "denque": "^2.1.0", "lodash.defaults": "^4.2.0", "lodash.isarguments": "^3.1.0", "redis-errors": "^1.2.0", "redis-parser": "^3.0.0", "standard-as-callback": "^2.1.0" } }, "sha512-HuEDBTI70aYdx1v6U97SbNx9F1+svQKBDo30o0b9fw055LMepzpOOd0Ccg9Q6tbqmBSJaMuY0fB7yw9/vjBYCA=="], + + "lodash.defaults": ["lodash.defaults@4.2.0", "", {}, "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ=="], + + "lodash.isarguments": ["lodash.isarguments@3.1.0", "", {}, "sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg=="], + + "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], + + "redis-errors": ["redis-errors@1.2.0", "", {}, "sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w=="], + + "redis-parser": ["redis-parser@3.0.0", "", { "dependencies": { "redis-errors": "^1.0.0" } }, "sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A=="], + + "ssf": ["ssf@0.11.2", "", { "dependencies": { "frac": "~1.1.2" } }, "sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g=="], + + "standard-as-callback": ["standard-as-callback@2.1.0", "", {}, "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A=="], + + "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + + "undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="], + + "wmf": ["wmf@1.0.2", "", {}, "sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw=="], + + "word": ["word@0.3.0", "", {}, "sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA=="], + + "xlsx": ["xlsx@0.18.5", "", { "dependencies": { "adler-32": "~1.3.0", "cfb": "~1.2.1", "codepage": "~1.15.0", "crc-32": "~1.2.1", "ssf": "~0.11.2", "wmf": "~1.0.1", "word": "~0.3.0" }, "bin": { "xlsx": "bin/xlsx.njs" } }, "sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ=="], + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..1889ba3 --- /dev/null +++ b/package.json @@ -0,0 +1,16 @@ +{ + "name": "asin-check", + "module": "src/index.ts", + "type": "module", + "private": true, + "devDependencies": { + "@types/bun": "latest" + }, + "peerDependencies": { + "typescript": "^5" + }, + "dependencies": { + "ioredis": "^5.10.1", + "xlsx": "^0.18.5" + } +} diff --git a/src/cache.ts b/src/cache.ts new file mode 100644 index 0000000..e630842 --- /dev/null +++ b/src/cache.ts @@ -0,0 +1,49 @@ +import Redis from "ioredis"; +import { config } from "./config.ts"; +import type { EnrichedProduct } from "./types.ts"; + +let redis: Redis | null = null; +let disabled = false; + +export async function connectCache(): Promise { + if (disabled) return; + try { + redis = new Redis(config.redisUrl, { + maxRetriesPerRequest: 1, + connectTimeout: 3000, + lazyConnect: true, + }); + await redis.connect(); + console.log("Redis connected"); + } catch (err) { + console.warn(`Redis unavailable, running without cache: ${err}`); + redis = null; + disabled = true; + } +} + +export async function getCache(asin: string): Promise { + if (!redis) return null; + try { + const data = await redis.get(`asin:${asin}`); + return data ? JSON.parse(data) : null; + } catch { + return null; + } +} + +export async function setCache(asin: string, data: EnrichedProduct): Promise { + if (!redis) return; + try { + await redis.set(`asin:${asin}`, JSON.stringify(data), "EX", config.cacheTtl); + } catch { + // Non-critical, continue without caching + } +} + +export async function disconnectCache(): Promise { + if (redis) { + await redis.quit(); + redis = null; + } +} diff --git a/src/config.ts b/src/config.ts new file mode 100644 index 0000000..3b229d9 --- /dev/null +++ b/src/config.ts @@ -0,0 +1,17 @@ +function required(key: string): string { + const val = Bun.env[key]; + if (!val) throw new Error(`Missing required env var: ${key}`); + return val; +} + +function optional(key: string, fallback: string): string { + return Bun.env[key] || fallback; +} + +export const config = { + keepaApiKey: required("KEEPA_API_KEY"), + redisUrl: optional("REDIS_URL", "redis://localhost:6379"), + llmUrl: optional("LLM_URL", "http://localhost:1234/v1"), + llmModel: optional("LLM_MODEL", "default"), + cacheTtl: parseInt(optional("CACHE_TTL", "86400"), 10), +} as const; diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..9d34f07 --- /dev/null +++ b/src/index.ts @@ -0,0 +1,155 @@ +import { readProducts } from "./reader.ts"; +import { fetchKeepaDataBatch } from "./keepa.ts"; +import { fetchSpApiData } from "./sp-api.ts"; +import { connectCache, getCache, setCache, disconnectCache } from "./cache.ts"; +import { analyzeProducts } from "./llm.ts"; +import { printResults, writeResultsCsv } from "./writer.ts"; +import type { EnrichedProduct, AnalysisResult, KeepaData, ProductRecord } from "./types.ts"; + +const LLM_BATCH_SIZE = 5; + +function parseArgs(): { inputFile: string; outputFile?: string } { + const args = process.argv.slice(2); + const inputFile = args.find((a) => !a.startsWith("--")); + const outIdx = args.indexOf("--out"); + const outputFile = outIdx !== -1 ? args[outIdx + 1] : undefined; + + if (!inputFile) { + console.error("Usage: bun run src/index.ts [--out results.csv]"); + process.exit(1); + } + return { inputFile, outputFile }; +} + +async function main() { + const { inputFile, outputFile } = parseArgs(); + + console.log("Connecting to Redis..."); + await connectCache(); + + console.log(`\nReading ${inputFile}...`); + const products = readProducts(inputFile); + + if (products.length === 0) { + console.error("No valid products found in input file."); + process.exit(1); + } + + // Phase 1: Check cache for all ASINs + console.log(`\nChecking cache for ${products.length} products...`); + const cached = new Map(); + const uncachedProducts: ProductRecord[] = []; + + for (const p of products) { + const hit = await getCache(p.asin); + if (hit) { + console.log(` [cache hit] ${p.asin}`); + cached.set(p.asin, hit); + } else { + uncachedProducts.push(p); + } + } + console.log(`${cached.size} cached, ${uncachedProducts.length} to fetch`); + + // Phase 2: Batch fetch from Keepa (all uncached ASINs in one request if ≤100) + let keepaResults = new Map(); + if (uncachedProducts.length > 0) { + console.log(`\nFetching ${uncachedProducts.length} ASINs from Keepa...`); + try { + keepaResults = await fetchKeepaDataBatch(uncachedProducts.map((p) => p.asin)); + } catch (err) { + console.warn(`Keepa batch fetch failed: ${err}`); + } + } + + // Phase 3: Build enriched products + console.log(`\nEnriching products...`); + const enriched: EnrichedProduct[] = []; + + for (const p of products) { + const cachedProduct = cached.get(p.asin); + if (cachedProduct) { + enriched.push(cachedProduct); + continue; + } + + const keepa = keepaResults.get(p.asin) ?? null; + const spApi = await fetchSpApiData(p.asin); + + if (keepa?.currentPrice && spApi.estimatedSalePrice === 0) { + spApi.estimatedSalePrice = keepa.currentPrice; + } + + const product: EnrichedProduct = { + record: p, + keepa, + spApi, + fetchedAt: new Date().toISOString(), + }; + + await setCache(p.asin, product); + enriched.push(product); + + if (keepa) { + console.log(` [enriched] ${p.asin} — price: $${keepa.currentPrice ?? "N/A"}, rank: ${keepa.salesRank ?? "N/A"}`); + } else { + console.log(` [no keepa] ${p.asin} — using spreadsheet data only`); + } + } + + // Phase 4: LLM analysis in batches + console.log(`\nAnalyzing products via LLM (batch size: ${LLM_BATCH_SIZE})...\n`); + + const results: AnalysisResult[] = []; + for (let i = 0; i < enriched.length; i += LLM_BATCH_SIZE) { + const batch = enriched.slice(i, i + LLM_BATCH_SIZE); + const batchNum = Math.floor(i / LLM_BATCH_SIZE) + 1; + const totalBatches = Math.ceil(enriched.length / LLM_BATCH_SIZE); + console.log(` LLM batch ${batchNum}/${totalBatches}...`); + + // Wait between batches to avoid overwhelming LM Studio + if (i > 0) { + console.log(` Waiting 5s before next batch...`); + await new Promise((r) => setTimeout(r, 5000)); + } + + let verdicts; + try { + verdicts = await analyzeProducts(batch); + } catch { + console.warn(` LLM batch error, retrying after 10s...`); + await new Promise((r) => setTimeout(r, 10_000)); + try { + verdicts = await analyzeProducts(batch); + } catch (retryErr) { + console.error(` LLM analysis failed: ${retryErr}`); + verdicts = null; + } + } + + for (let j = 0; j < batch.length; j++) { + results.push({ + product: batch[j]!, + verdict: verdicts?.[j] ?? { + asin: batch[j]!.record.asin, + verdict: "SKIP", + confidence: 0, + reasoning: "LLM analysis failed", + }, + }); + } + } + + printResults(results); + + if (outputFile) { + writeResultsCsv(results, outputFile); + } + + await disconnectCache(); +} + +main().catch((err) => { + console.error("Fatal error:", err); + process.exit(1); +}); diff --git a/src/keepa.ts b/src/keepa.ts new file mode 100644 index 0000000..60cf022 --- /dev/null +++ b/src/keepa.ts @@ -0,0 +1,111 @@ +import { config } from "./config.ts"; +import type { KeepaData } from "./types.ts"; + +const KEEPA_BASE = "https://api.keepa.com"; +const MAX_ASINS_PER_REQUEST = 100; + +// Token-based rate limiting: Keepa Pro = 1 token/min regeneration. +// Each product request costs 1 token regardless of ASIN count (up to 100). +// The API response includes tokensLeft and refillRate — we use those to pace. +let tokensLeft = 1; // Conservative start; updated from API response +let refillRate = 1; // tokens per minute, updated from API response +let lastRequestTime = 0; + +async function waitForToken(): Promise { + if (tokensLeft > 0) return; + + const elapsed = (Date.now() - lastRequestTime) / 60_000; // minutes + const regenerated = Math.floor(elapsed * refillRate); + if (regenerated > 0) { + tokensLeft += regenerated; + return; + } + + // Wait until we regenerate at least 1 token + const waitMs = Math.ceil((1 / refillRate) * 60_000) - (Date.now() - lastRequestTime); + if (waitMs > 0) { + console.log(`Keepa tokens exhausted. Waiting ${Math.ceil(waitMs / 1000)}s for token regeneration...`); + await new Promise((r) => setTimeout(r, waitMs)); + } + tokensLeft = 1; +} + +export async function fetchKeepaDataBatch(asins: string[]): Promise> { + const results = new Map(); + + // Split into chunks of MAX_ASINS_PER_REQUEST + for (let i = 0; i < asins.length; i += MAX_ASINS_PER_REQUEST) { + const chunk = asins.slice(i, i + MAX_ASINS_PER_REQUEST); + await waitForToken(); + + const asinParam = chunk.join(","); + const url = `${KEEPA_BASE}/product?key=${config.keepaApiKey}&domain=1&asin=${asinParam}&stats=90`; + + console.log(`Keepa: fetching ${chunk.length} ASINs (tokens left: ${tokensLeft})...`); + + const res = await fetch(url); + lastRequestTime = Date.now(); + + if (!res.ok) { + const text = await res.text(); + throw new Error(`Keepa API error ${res.status}: ${text}`); + } + + const data = (await res.json()) as { + products?: Record[]; + tokensLeft?: number; + refillRate?: number; + }; + + // Update token state from API response + if (data.tokensLeft != null) tokensLeft = data.tokensLeft; + if (data.refillRate != null) refillRate = data.refillRate; + + console.log(`Keepa: ${data.products?.length ?? 0} products returned, ${tokensLeft} tokens remaining (refill: ${refillRate}/min)`); + + if (data.products) { + for (const product of data.products) { + const asin = product.asin; + if (!asin) continue; + results.set(asin, parseKeepaProduct(product)); + } + } + } + + return results; +} + +function parseKeepaProduct(product: Record): KeepaData { + const stats = product.stats; + const csv = product.csv; + + return { + currentPrice: extractCurrentPrice(csv), + avgPrice90: stats?.avg?.[0] != null ? stats.avg[0] / 100 : null, + minPrice90: stats?.min?.[0] != null ? stats.min[0] / 100 : null, + maxPrice90: stats?.max?.[0] != null ? stats.max[0] / 100 : null, + salesRank: stats?.current?.[3] ?? null, + salesRankAvg90: stats?.avg?.[3] ?? null, + salesRankDrops30: product.salesRankDrops30 ?? null, + salesRankDrops90: product.salesRankDrops90 ?? null, + sellerCount: stats?.current?.[11] ?? null, + buyBoxSeller: product.buyBoxSellerId ?? null, + buyBoxPrice: stats?.current?.[18] != null ? stats.current[18] / 100 : null, + monthlySold: product.monthlySold ?? null, + categoryTree: product.categoryTree?.map((c: { name: string }) => c.name) ?? [], + }; +} + +function extractCurrentPrice(csv: number[][] | undefined): number | null { + if (!csv) return null; + + // csv[0] = Amazon price history, csv[1] = Marketplace new price history + // Each is [time, price, time, price, ...] — last value is most recent + for (const series of [csv[0], csv[1]]) { + if (series && series.length >= 2) { + const lastPrice = series[series.length - 1]!; + if (lastPrice > 0) return lastPrice / 100; + } + } + return null; +} diff --git a/src/llm.ts b/src/llm.ts new file mode 100644 index 0000000..ad230ed --- /dev/null +++ b/src/llm.ts @@ -0,0 +1,160 @@ +import { config } from "./config.ts"; +import type { EnrichedProduct, LlmVerdict } from "./types.ts"; + +const SYSTEM_PROMPT = `You are an expert Amazon product analyst specializing in FBA and FBM fulfillment strategy. + +Given product data, evaluate each product's viability for selling on Amazon. Consider: + +1. **Sales Velocity**: monthlySold and salesRankDrops30 are the most important signals. A product that doesn't sell is worthless regardless of margin. salesRankDrops30 = approximate units sold in 30 days. monthlySold is Keepa's estimate. +2. **Margin Analysis**: Sale price minus unit cost minus fees (FBA or FBM). Aim for >30% ROI minimum. The spreadsheet may include FBA NET and gross profit estimates — cross-check against Keepa pricing data. +3. **Sales Rank (BSR)**: Lower rank = higher demand. Rank <50,000 is good, <1,000 is excellent. +4. **Sales Rank Trend**: Compare current rank vs 90d average. Lower current = improving demand. +5. **Competition**: Number of sellers and Buy Box dynamics. Fewer sellers = easier entry. +6. **Price Stability**: Large price swings (high max vs low min over 90d) = volatile/risky. +7. **FBA vs FBM**: FBA preferred for fast-selling, small/light items. FBM for oversized, slow-moving, or high-margin items where fee savings matter. +8. **MOQ & Capital**: High MOQ with thin margins is risky. +9. **Supply Availability**: Total quantity available from supplier — low stock means limited runway. + +Return ONLY a raw JSON array (no markdown, no code fences, no explanation before or after). One verdict per product: +[{ "asin": "B...", "verdict": "FBA" | "FBM" | "SKIP", "confidence": 0-100, "reasoning": "..." }] + +Keep each reasoning under 100 characters to stay within output limits.`; + +export async function analyzeProducts( + products: EnrichedProduct[], +): Promise { + const productSummaries = products.map(summarizeForLlm); + + const res = await fetch(`${config.llmUrl}/chat/completions`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: "Bearer lm-studio", + }, + body: JSON.stringify({ + model: config.llmModel, + messages: [ + { role: "system", content: SYSTEM_PROMPT }, + { role: "user", content: JSON.stringify(productSummaries, null, 2) }, + ], + temperature: 0.3, + max_tokens: 2048, + }), + }); + + if (!res.ok) { + throw new Error(`LLM API error ${res.status}: ${await res.text()}`); + } + + const data = (await res.json()) as { + choices?: { message?: { content?: string } }[]; + }; + const content = data.choices?.[0]?.message?.content ?? ""; + + return parseVerdicts(content, products); +} + +function summarizeForLlm(p: EnrichedProduct) { + const salePrice = p.keepa?.currentPrice ?? p.spApi.estimatedSalePrice; + const referralFee = salePrice * (p.spApi.referralFeePercent / 100); + const fbaProfit = + salePrice - p.record.unitCost - p.spApi.fbaFee - referralFee; + const fbmProfit = + salePrice - p.record.unitCost - p.spApi.fbmFee - referralFee; + + return { + asin: p.record.asin, + name: p.record.name, + brand: p.record.brand, + category: p.record.category ?? p.keepa?.categoryTree?.join(" > "), + unitCost: p.record.unitCost, + currentPrice: salePrice, + priceRange90d: p.keepa + ? { + min: p.keepa.minPrice90, + max: p.keepa.maxPrice90, + avg: p.keepa.avgPrice90, + } + : null, + salesRank: p.keepa?.salesRank ?? p.record.amazonRank, + salesRankAvg90d: p.keepa?.salesRankAvg90, + sellerCount: p.keepa?.sellerCount, + salesVelocity: { + monthlySold: p.keepa?.monthlySold, + salesRankDrops30: p.keepa?.salesRankDrops30, + salesRankDrops90: p.keepa?.salesRankDrops90, + }, + spreadsheetEstimates: { + fbaNet: p.record.fbaNet, + grossProfit: p.record.grossProfit, + grossProfitPct: p.record.grossProfitPct, + }, + moq: p.record.moq, + moqCost: p.record.moqCost, + totalQtyAvail: p.record.totalQtyAvail, + fees: { + fbaFee: p.spApi.fbaFee, + fbmFee: p.spApi.fbmFee, + referralFeePercent: p.spApi.referralFeePercent, + referralFee: Math.round(referralFee * 100) / 100, + }, + estimatedProfit: { + fba: Math.round(fbaProfit * 100) / 100, + fbm: Math.round(fbmProfit * 100) / 100, + }, + estimatedROI: { + fba: + p.record.unitCost > 0 + ? Math.round((fbaProfit / p.record.unitCost) * 100) + : null, + fbm: + p.record.unitCost > 0 + ? Math.round((fbmProfit / p.record.unitCost) * 100) + : null, + }, + }; +} + +function cleanLlmJson(text: string): string { + // Remove ```json ... ``` or ``` ... ``` wrapping + const fenceMatch = text.match(/```(?:json)?\s*\n?([\s\S]*?)```/); + let cleaned = fenceMatch ? fenceMatch[1]!.trim() : text.trim(); + + // Fix trailing comma-quote before closing brace: ,"} → "} + cleaned = cleaned.replace(/,"\s*}/g, '"}'); + + // Fix trailing commas before ] or } + cleaned = cleaned.replace(/,\s*([}\]])/g, "$1"); + + return cleaned; +} + +function parseVerdicts( + content: string, + products: EnrichedProduct[], +): LlmVerdict[] { + try { + const cleaned = cleanLlmJson(content); + const parsed = JSON.parse(cleaned); + const arr = Array.isArray(parsed) + ? parsed + : (parsed.verdicts ?? parsed.results ?? [parsed]); + return arr.map((v: Record) => ({ + asin: String(v.asin ?? ""), + verdict: (["FBA", "FBM", "SKIP"].includes(String(v.verdict)) + ? v.verdict + : "SKIP") as LlmVerdict["verdict"], + confidence: typeof v.confidence === "number" ? v.confidence : 0, + reasoning: String(v.reasoning ?? "No reasoning provided"), + })); + } catch (err) { + console.warn("Failed to parse LLM response, marking all as ANALYSIS_FAILED"); + console.warn("Raw LLM content:", content.slice(0, 500)); + return products.map((p) => ({ + asin: p.record.asin, + verdict: "SKIP" as const, + confidence: 0, + reasoning: `Analysis failed: could not parse LLM output`, + })); + } +} diff --git a/src/reader.ts b/src/reader.ts new file mode 100644 index 0000000..9972dac --- /dev/null +++ b/src/reader.ts @@ -0,0 +1,81 @@ +import * as XLSX from "xlsx"; +import type { ProductRecord } from "./types.ts"; + +export function readProducts(filePath: string): ProductRecord[] { + const workbook = XLSX.readFile(filePath); + const sheetName = workbook.SheetNames[0]; + if (!sheetName) throw new Error("No sheets found in file"); + + const sheet = workbook.Sheets[sheetName]!; + const rows = XLSX.utils.sheet_to_json>(sheet); + + if (rows.length === 0) throw new Error("File contains no data rows"); + + const headers = Object.keys(rows[0]!); + const asinCol = findColumn(headers, ["asin"]); + const nameCol = findColumn(headers, ["name", "product name", "title", "product title"]); + const costCol = findColumn(headers, ["unit cost", "cost", "unitcost", "unit_cost", "price", "buy cost"]); + + const brandCol = findColumn(headers, ["brand"]); + const categoryCol = findColumn(headers, ["category"]); + const amazonRankCol = findColumn(headers, ["amazon rank", "amazonrank", "sales rank", "bsr"]); + const fbaNetCol = findColumn(headers, ["fba net", "fbanet", "fba_net"]); + const grossProfitCol = findColumn(headers, ["gross profit $", "gross profit", "grossprofit"]); + const grossProfitPctCol = findColumn(headers, ["gross profit %", "gross profit pct", "grossprofitpct"]); + const moqCol = findColumn(headers, ["moq", "min order qty", "minimum order quantity"]); + const moqCostCol = findColumn(headers, ["moq cost", "moqcost"]); + const totalQtyCol = findColumn(headers, ["total qty avail", "totalqtyavail", "qty available", "quantity"]); + + const linkCol = findColumn(headers, ["link", "url", "source"]); + + if (!asinCol) throw new Error(`No ASIN column found. Available columns: ${headers.join(", ")}`); + + const knownCols = new Set([asinCol, nameCol, costCol, brandCol, categoryCol, amazonRankCol, fbaNetCol, grossProfitCol, grossProfitPctCol, moqCol, moqCostCol, totalQtyCol, linkCol].filter(Boolean)); + + const products: ProductRecord[] = []; + + for (const row of rows) { + const asin = String(row[asinCol] ?? "").trim().toUpperCase(); + if (!asin || !/^B[0-9A-Z]{9}$/.test(asin)) { + console.warn(`Skipping invalid ASIN: "${asin}"`); + continue; + } + + const name = nameCol ? String(row[nameCol] ?? "") : ""; + const unitCost = costCol ? parseFloat(String(row[costCol] ?? "0")) : 0; + + const extra: Record = {}; + for (const h of headers) { + if (!knownCols.has(h)) extra[h] = row[h]; + } + + products.push({ + asin, + name, + unitCost, + brand: brandCol ? String(row[brandCol] ?? "") : undefined, + category: categoryCol ? String(row[categoryCol] ?? "") : undefined, + amazonRank: amazonRankCol ? Number(row[amazonRankCol]) || undefined : undefined, + fbaNet: fbaNetCol ? Number(row[fbaNetCol]) || undefined : undefined, + grossProfit: grossProfitCol ? Number(row[grossProfitCol]) || undefined : undefined, + grossProfitPct: grossProfitPctCol ? Number(row[grossProfitPctCol]) || undefined : undefined, + moq: moqCol ? Number(row[moqCol]) || undefined : undefined, + moqCost: moqCostCol ? Number(row[moqCostCol]) || undefined : undefined, + totalQtyAvail: totalQtyCol ? Number(row[totalQtyCol]) || undefined : undefined, + + link: linkCol ? String(row[linkCol] ?? "") : undefined, + ...extra, + }); + } + + console.log(`Read ${products.length} valid products from ${filePath}`); + return products; +} + +function findColumn(headers: string[], candidates: string[]): string | undefined { + for (const candidate of candidates) { + const match = headers.find((h) => h.toLowerCase().trim() === candidate); + if (match) return match; + } + return undefined; +} diff --git a/src/sp-api.ts b/src/sp-api.ts new file mode 100644 index 0000000..d9438f5 --- /dev/null +++ b/src/sp-api.ts @@ -0,0 +1,18 @@ +import type { SpApiData } from "./types.ts"; + +// TODO: Implement real SP-API integration with LWA OAuth +// - LWA token endpoint: https://api.amazon.com/auth/o2/token +// - Catalog Items: GET /catalog/2022-04-01/items/{asin} +// - Product Pricing: GET /products/pricing/v0/price +// - Product Fees: GET /products/fees/v0/items/{asin}/feesEstimate + +export async function fetchSpApiData(asin: string): Promise { + // Stub: returns realistic mock fee estimates + // Average FBA referral fee is ~15%, FBA fulfillment fee ~$3-5 for standard size + return { + fbaFee: 5.0, + fbmFee: 1.5, + referralFeePercent: 15, + estimatedSalePrice: 0, // Will be overridden by Keepa current price if available + }; +} diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 0000000..c290e4b --- /dev/null +++ b/src/types.ts @@ -0,0 +1,59 @@ +export interface ProductRecord { + asin: string; + name: string; + unitCost: number; + brand?: string; + category?: string; + amazonRank?: number; + fbaNet?: number; + grossProfit?: number; + grossProfitPct?: number; + moq?: number; + moqCost?: number; + totalQtyAvail?: number; + + link?: string; + [key: string]: unknown; +} + +export interface KeepaData { + currentPrice: number | null; + avgPrice90: number | null; + minPrice90: number | null; + maxPrice90: number | null; + salesRank: number | null; + salesRankAvg90: number | null; + salesRankDrops30: number | null; + salesRankDrops90: number | null; + sellerCount: number | null; + buyBoxSeller: string | null; + buyBoxPrice: number | null; + monthlySold: number | null; + categoryTree: string[]; +} + +export interface SpApiData { + fbaFee: number; + fbmFee: number; + referralFeePercent: number; + estimatedSalePrice: number; +} + +export interface EnrichedProduct { + record: ProductRecord; + keepa: KeepaData | null; + spApi: SpApiData; + fetchedAt: string; +} + +export interface LlmVerdict { + asin: string; + verdict: "FBA" | "FBM" | "SKIP"; + confidence: number; + reasoning: string; +} + +export interface AnalysisResult { + product: EnrichedProduct; + verdict: LlmVerdict; +} diff --git a/src/writer.ts b/src/writer.ts new file mode 100644 index 0000000..8b41168 --- /dev/null +++ b/src/writer.ts @@ -0,0 +1,67 @@ +import * as XLSX from "xlsx"; +import type { AnalysisResult } from "./types.ts"; + +function buildRow(r: AnalysisResult) { + const price = r.product.keepa?.currentPrice ?? r.product.spApi.estimatedSalePrice; + const rank = r.product.keepa?.salesRank ?? r.product.record.amazonRank; + + return { + ASIN: r.product.record.asin, + Name: r.product.record.name, + Brand: r.product.record.brand ?? "", + Category: r.product.record.category ?? r.product.keepa?.categoryTree?.join(" > ") ?? "", + "Unit Cost": r.product.record.unitCost, + "Current Price": price ?? "", + "Avg Price 90d": r.product.keepa?.avgPrice90 ?? "", + "Sales Rank": rank ?? "", + "Rank Avg 90d": r.product.keepa?.salesRankAvg90 ?? "", + Sellers: r.product.keepa?.sellerCount ?? "", + "Monthly Sold": r.product.keepa?.monthlySold ?? "", + "Rank Drops 30d": r.product.keepa?.salesRankDrops30 ?? "", + "Rank Drops 90d": r.product.keepa?.salesRankDrops90 ?? "", + "FBA Net (sheet)": r.product.record.fbaNet ?? "", + "Gross Profit $": r.product.record.grossProfit ?? "", + "Gross Profit %": r.product.record.grossProfitPct ?? "", + MOQ: r.product.record.moq ?? "", + "MOQ Cost": r.product.record.moqCost ?? "", + "Qty Available": r.product.record.totalQtyAvail ?? "", + "FBA Fee": r.product.spApi.fbaFee, + "FBM Fee": r.product.spApi.fbmFee, + "Referral %": r.product.spApi.referralFeePercent, + Verdict: r.verdict.verdict, + Confidence: r.verdict.confidence, + Reasoning: r.verdict.reasoning, + }; +} + +export function printResults(results: AnalysisResult[]): void { + const rows = results.map((r) => { + const row = buildRow(r); + return { + ...row, + Name: row.Name.slice(0, 40), + Category: String(row.Category).slice(0, 20), + Reasoning: row.Reasoning.slice(0, 60), + }; + }); + + console.log("\n=== Analysis Results ===\n"); + console.table(rows); + + const summary = { + FBA: results.filter((r) => r.verdict.verdict === "FBA").length, + FBM: results.filter((r) => r.verdict.verdict === "FBM").length, + SKIP: results.filter((r) => r.verdict.verdict === "SKIP").length, + }; + console.log(`\nSummary: ${summary.FBA} FBA | ${summary.FBM} FBM | ${summary.SKIP} SKIP out of ${results.length} products\n`); +} + +export function writeResultsCsv(results: AnalysisResult[], outputPath: string): void { + const rows = results.map(buildRow); + + const ws = XLSX.utils.json_to_sheet(rows); + const wb = XLSX.utils.book_new(); + XLSX.utils.book_append_sheet(wb, ws, "Results"); + XLSX.writeFile(wb, outputPath); + console.log(`Results written to ${outputPath}`); +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..bfa0fea --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,29 @@ +{ + "compilerOptions": { + // Environment setup & latest features + "lib": ["ESNext"], + "target": "ESNext", + "module": "Preserve", + "moduleDetection": "force", + "jsx": "react-jsx", + "allowJs": true, + + // Bundler mode + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "noEmit": true, + + // Best practices + "strict": true, + "skipLibCheck": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedIndexedAccess": true, + "noImplicitOverride": true, + + // Some stricter flags (disabled by default) + "noUnusedLocals": false, + "noUnusedParameters": false, + "noPropertyAccessFromIndexSignature": false + } +}