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.
This commit is contained in:
Victor Noguera
2026-04-04 21:33:27 -04:00
commit 061f771279
17 changed files with 1005 additions and 0 deletions

View File

@@ -0,0 +1,10 @@
{
"permissions": {
"allow": [
"WebSearch",
"Bash(bun init:*)",
"Bash(bunx tsc:*)",
"Bash(bun -e ':*)"
]
}
}

5
.env.example Normal file
View File

@@ -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

37
.gitignore vendored Normal file
View File

@@ -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

106
CLAUDE.md Normal file
View File

@@ -0,0 +1,106 @@
Default to using Bun instead of Node.js.
- Use `bun <file>` instead of `node <file>` or `ts-node <file>`
- Use `bun test` instead of `jest` or `vitest`
- Use `bun build <file.html|file.ts|file.css>` instead of `webpack` or `esbuild`
- Use `bun install` instead of `npm install` or `yarn install` or `pnpm install`
- Use `bun run <script>` instead of `npm run <script>` or `yarn run <script>` or `pnpm run <script>`
- Use `bunx <package> <command>` instead of `npx <package> <command>`
- Bun automatically loads .env, so don't use dotenv.
## APIs
- `Bun.serve()` supports WebSockets, HTTPS, and routes. Don't use `express`.
- `bun:sqlite` for SQLite. Don't use `better-sqlite3`.
- `Bun.redis` for Redis. Don't use `ioredis`.
- `Bun.sql` for Postgres. Don't use `pg` or `postgres.js`.
- `WebSocket` is built-in. Don't use `ws`.
- Prefer `Bun.file` over `node:fs`'s readFile/writeFile
- Bun.$`ls` instead of execa.
## Testing
Use `bun test` to run tests.
```ts#index.test.ts
import { test, expect } from "bun:test";
test("hello world", () => {
expect(1).toBe(1);
});
```
## Frontend
Use HTML imports with `Bun.serve()`. Don't use `vite`. HTML imports fully support React, CSS, Tailwind.
Server:
```ts#index.ts
import index from "./index.html"
Bun.serve({
routes: {
"/": index,
"/api/users/:id": {
GET: (req) => {
return new Response(JSON.stringify({ id: req.params.id }));
},
},
},
// optional websocket support
websocket: {
open: (ws) => {
ws.send("Hello, world!");
},
message: (ws, message) => {
ws.send(message);
},
close: (ws) => {
// handle close
}
},
development: {
hmr: true,
console: true,
}
})
```
HTML files can import .tsx, .jsx or .js files directly and Bun's bundler will transpile & bundle automatically. `<link>` tags can point to stylesheets and Bun's CSS bundler will bundle.
```html#index.html
<html>
<body>
<h1>Hello, world!</h1>
<script type="module" src="./frontend.tsx"></script>
</body>
</html>
```
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 <h1>Hello, world!</h1>;
}
root.render(<Frontend />);
```
Then, run index.ts
```sh
bun --hot ./index.ts
```
For more information, read the Bun API docs in `node_modules/bun-types/docs/**.mdx`.

15
README.md Normal file
View File

@@ -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.

70
bun.lock Normal file
View File

@@ -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=="],
}
}

16
package.json Normal file
View File

@@ -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"
}
}

49
src/cache.ts Normal file
View File

@@ -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<void> {
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<EnrichedProduct | null> {
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<void> {
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<void> {
if (redis) {
await redis.quit();
redis = null;
}
}

17
src/config.ts Normal file
View File

@@ -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;

155
src/index.ts Normal file
View File

@@ -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 <input.csv|xlsx> [--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<string, EnrichedProduct>();
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<string, KeepaData>();
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);
});

111
src/keepa.ts Normal file
View File

@@ -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<void> {
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<Map<string, KeepaData>> {
const results = new Map<string, KeepaData>();
// 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<string, any>[];
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<string, any>): 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;
}

160
src/llm.ts Normal file
View File

@@ -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<LlmVerdict[]> {
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<string, unknown>) => ({
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`,
}));
}
}

81
src/reader.ts Normal file
View File

@@ -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<Record<string, unknown>>(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<string, unknown> = {};
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;
}

18
src/sp-api.ts Normal file
View File

@@ -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<SpApiData> {
// 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
};
}

59
src/types.ts Normal file
View File

@@ -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;
}

67
src/writer.ts Normal file
View File

@@ -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}`);
}

29
tsconfig.json Normal file
View File

@@ -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
}
}