Refactor SP-API test script and improve type definitions

- Updated `sp-test.ts` to enhance argument parsing and error handling for sellability checks.
- Refactored `types.ts` to maintain consistent formatting and improve readability.
- Improved `writer.ts` for better result handling and CSV writing, ensuring clarity in output.
- Adjusted `tsconfig.json` formatting for consistency and readability.
This commit is contained in:
Victor Noguera
2026-04-12 23:48:31 -04:00
parent 4386560964
commit dbe5b1ac71
18 changed files with 3497 additions and 2429 deletions

View File

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

View File

@@ -1,15 +1,15 @@
KEEPA_API_KEY=your_keepa_api_key_here
SP_API_CLIENT_ID=your_sp_api_client_id
SP_API_CLIENT_SECRET=your_sp_api_client_secret
SP_API_REFRESH_TOKEN=your_sp_api_refresh_token
SP_API_REGION=na
SP_API_MARKETPLACE_ID=ATVPDKIKX0DER
SP_API_SELLER_ID=your_seller_id
SP_API_USE_SANDBOX=false
AWS_ACCESS_KEY_ID=your_aws_access_key_id
AWS_SECRET_ACCESS_KEY=your_aws_secret_access_key
# AWS_SESSION_TOKEN=optional_if_using_sts
REDIS_URL=redis://localhost:6379
LLM_URL=http://localhost:1234/v1
LLM_MODEL=default
CACHE_TTL=86400
KEEPA_API_KEY=your_keepa_api_key_here
SP_API_CLIENT_ID=your_sp_api_client_id
SP_API_CLIENT_SECRET=your_sp_api_client_secret
SP_API_REFRESH_TOKEN=your_sp_api_refresh_token
SP_API_REGION=na
SP_API_MARKETPLACE_ID=ATVPDKIKX0DER
SP_API_SELLER_ID=your_seller_id
SP_API_USE_SANDBOX=false
AWS_ACCESS_KEY_ID=your_aws_access_key_id
AWS_SECRET_ACCESS_KEY=your_aws_secret_access_key
# AWS_SESSION_TOKEN=optional_if_using_sts
REDIS_URL=redis://localhost:6379
LLM_URL=http://localhost:1234/v1
LLM_MODEL=default
CACHE_TTL=86400

74
.gitignore vendored
View File

@@ -1,37 +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
# 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

212
CLAUDE.md
View File

@@ -1,106 +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`.
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`.

270
README.md
View File

@@ -1,135 +1,135 @@
# asin-check
Amazon product analysis and lead finder agent. Reads product leads from a CSV/XLSX file, enriches them with Keepa pricing and sales data, caches results in Redis, and runs each product through a local LLM to get an FBA/FBM/SKIP verdict.
## Requirements
- [Bun](https://bun.com) runtime
- Redis (local or Docker)
- [LM Studio](https://lmstudio.ai) running locally with a model loaded
- Keepa API key ([keepa.com](https://keepa.com))
- Amazon SP-API private app credentials (LWA + refresh token + IAM)
## Setup
```bash
bun install
cp .env.example .env
# Edit .env and set your KEEPA_API_KEY and SP-API credentials
```
## Usage
```bash
bun run src/index.ts <input.csv|xlsx> [--out results.csv]
```
Examples:
```bash
bun run src/index.ts leads.xlsx
bun run src/index.ts leads.csv --out results.xlsx
```
Large-file behavior:
- If the input has more than 50 products, processing is done in chunks of 50.
- Each chunk is analyzed and written to a numbered output file, for example: `results_part_001.xlsx`, `results_part_002.xlsx`, ...
- If `--out` is omitted for large files, the base output name defaults to `<input>_results.xlsx` and chunk files are still written with numbered suffixes.
Quick SP-API connectivity tests:
```bash
bun run src/sp-test.ts # Auth + sellers endpoint
bun run src/sp-test.ts B07SN9BHVV # Auth + sellers endpoint + pricing offer check
bun run src/sp-test.ts --sellability B07SN9BHVV # Standalone sellability check
```
## Input file format
Accepts `.csv` or `.xlsx` files. Column names are matched case-insensitively. Required column:
| Column | Aliases |
| ------ | ------- |
| ASIN | — |
Optional but recommended:
| Column | Aliases |
| --------------- | ---------------------------- |
| Product Name | Name, Title |
| Unit Cost | Cost, Price, Buy Cost |
| Brand | — |
| Category | — |
| Amazon Rank | Amazon Rank, BSR, Sales Rank |
| FBA NET | — |
| Gross Profit $ | Gross Profit |
| Gross Profit % | — |
| MOQ | Min Order Qty |
| MOQ Cost | — |
| Total Qty Avail | Qty Available |
| Link | URL, Source |
Lead-list format aliases (supported):
| Column | Aliases |
| ----------------- | ------------------------------------------ |
| Name | Product Name, Title, Product Title |
| ASIN Link | ASIN URL, Amazon Link |
| Source URL | Source Link, Supplier URL |
| 90 Day Average | 90-day Average, Avg Price 90d, 90d Average |
| Cost | Unit Cost, Buy Cost, Price |
| Selling Price | Sale Price, Sell Price |
| Net Profit | Gross Profit |
| ROI | Gross Profit %, Return on Investment |
| Supplier | Vendor |
| Promo/Coupon Code | Promo Code, Coupon Code |
| Notes | Note |
| Date | Lead Date |
Numeric parsing accepts plain numbers as well as formatted values like `$12.50`, `1,209.60`, and `27.5%`.
## Pipeline
1. **Read** — parse input file, validate ASINs
2. **Cache check** — look up each ASIN in Redis (24h TTL by default)
3. **Sellability gate** — check all uncached ASINs against SP-API `getListingsRestrictions` (concurrency: 5 workers); immediately skip ASINs with status `not_available` and `canSell=false` (no Keepa/fees wasted)
4. **Keepa fetch** — batch the sellable (uncached) ASINs in a single API call (up to 100 per request)
5. **Enrich** — fetch SP-API pricing + FBA/FBM fees for sellable ASINs; combine with Keepa data and spreadsheet data
6. **LLM analysis** — send batches of 5 available products to LM Studio for FBA/FBM/SKIP verdict
7. **Chunk orchestration** — if input size is greater than 50, run phases 2-6 for each 50-item chunk sequentially
8. **Output** — print results table to console (includes all ASINs); for chunked runs, always write seriated chunk files (`*_part_001`, `*_part_002`, ...); for non-chunked runs, write a single file only when `--out` is provided
## Output columns
ASIN, Name, Brand, Category, Unit Cost, Current Price, Avg Price 90d, Sales Rank, Rank Avg 90d, Sellers, Monthly Sold, Rank Drops 30d, Rank Drops 90d, FBA Net (sheet), Gross Profit $, Gross Profit %, MOQ, MOQ Cost, Qty Available, FBA Fee, FBM Fee, Referral %, Verdict, Confidence, Reasoning
## Environment variables
| Variable | Default | Description |
| ----------------------- | -------------------------- | ----------------------------------------------------------------------- |
| `KEEPA_API_KEY` | — | **Required.** Keepa API key |
| `SP_API_CLIENT_ID` | — | LWA app client id from Solution Provider Portal |
| `SP_API_CLIENT_SECRET` | — | LWA app client secret from Solution Provider Portal |
| `SP_API_REFRESH_TOKEN` | — | Refresh token from self-authorization |
| `SP_API_REGION` | `na` | SP-API endpoint region (`na`, `eu`, `fe`; `us` is accepted as `na`) |
| `SP_API_MARKETPLACE_ID` | `ATVPDKIKX0DER` | Marketplace id used for pricing and fee calls (default: US) |
| `SP_API_SELLER_ID` | — | Seller ID used for listing restrictions eligibility checks |
| `SP_API_USE_SANDBOX` | `false` | Enable SP-API sandbox mode (`true`/`false`) |
| `AWS_ACCESS_KEY_ID` | — | AWS credentials for SigV4 signing (required in most private app setups) |
| `AWS_SECRET_ACCESS_KEY` | — | AWS credentials for SigV4 signing |
| `AWS_SESSION_TOKEN` | — | Optional session token when using STS credentials |
| `REDIS_URL` | `redis://localhost:6379` | Redis connection URL |
| `LLM_URL` | `http://localhost:1234/v1` | LM Studio API base URL |
| `LLM_MODEL` | `default` | Model name to pass to LM Studio |
| `CACHE_TTL` | `86400` | Redis cache TTL in seconds |
## Notes
- **Available-only processing**: SP-API `getListingsRestrictions` is checked first and only ASINs with `sellabilityStatus=available` are enriched, analyzed, and included in outputs. Restricted, not_available, and unknown items are excluded.
- **SP-API concurrency**: `fetchSellabilityBatch` limits concurrent requests to 5 workers to avoid 429 throttling. Pricing+fees fetches also use 5 concurrent workers.
- **No batch endpoint**: Amazon SP-API does not provide batch endpoints for `getListingsRestrictions` or `getMyFeesEstimate*`. Concurrency limiting with the library's built-in `auto_request_throttled` safety net prevents overwhelming the API.
- **Keepa rate limiting**: The client reads `tokensLeft` and `refillRate` from each API response and waits automatically when tokens are exhausted. With a Pro subscription (1 token/min), all 100 ASINs in a batch cost 1 token.
- **Redis is optional**: If Redis is unavailable the tool runs without caching — every run re-fetches from Keepa.
- **SP-API**: `src/sp-api.ts` provides `fetchSellability`, `fetchSellabilityBatch`, and `fetchSpApiPricingAndFees` functions. If SP-API credentials are missing or a call fails, the tool falls back to conservative fee defaults and keeps processing.
- **Sandbox vs production**: When `SP_API_USE_SANDBOX=true`, production ASIN calls can be denied. Use sandbox-compatible test data or set it to `false` for live marketplace connectivity.
# asin-check
Amazon product analysis and lead finder agent. Reads product leads from a CSV/XLSX file, enriches them with Keepa pricing and sales data, caches results in Redis, and runs each product through a local LLM to get an FBA/FBM/SKIP verdict.
## Requirements
- [Bun](https://bun.com) runtime
- Redis (local or Docker)
- [LM Studio](https://lmstudio.ai) running locally with a model loaded
- Keepa API key ([keepa.com](https://keepa.com))
- Amazon SP-API private app credentials (LWA + refresh token + IAM)
## Setup
```bash
bun install
cp .env.example .env
# Edit .env and set your KEEPA_API_KEY and SP-API credentials
```
## Usage
```bash
bun run src/index.ts <input.csv|xlsx> [--out results.csv]
```
Examples:
```bash
bun run src/index.ts leads.xlsx
bun run src/index.ts leads.csv --out results.xlsx
```
Large-file behavior:
- If the input has more than 50 products, processing is done in chunks of 50.
- Each chunk is analyzed and written to a numbered output file, for example: `results_part_001.xlsx`, `results_part_002.xlsx`, ...
- If `--out` is omitted for large files, the base output name defaults to `<input>_results.xlsx` and chunk files are still written with numbered suffixes.
Quick SP-API connectivity tests:
```bash
bun run src/sp-test.ts # Auth + sellers endpoint
bun run src/sp-test.ts B07SN9BHVV # Auth + sellers endpoint + pricing offer check
bun run src/sp-test.ts --sellability B07SN9BHVV # Standalone sellability check
```
## Input file format
Accepts `.csv` or `.xlsx` files. Column names are matched case-insensitively. Required column:
| Column | Aliases |
| ------ | ------- |
| ASIN | — |
Optional but recommended:
| Column | Aliases |
| --------------- | ---------------------------- |
| Product Name | Name, Title |
| Unit Cost | Cost, Price, Buy Cost |
| Brand | — |
| Category | — |
| Amazon Rank | Amazon Rank, BSR, Sales Rank |
| FBA NET | — |
| Gross Profit $ | Gross Profit |
| Gross Profit % | — |
| MOQ | Min Order Qty |
| MOQ Cost | — |
| Total Qty Avail | Qty Available |
| Link | URL, Source |
Lead-list format aliases (supported):
| Column | Aliases |
| ----------------- | ------------------------------------------ |
| Name | Product Name, Title, Product Title |
| ASIN Link | ASIN URL, Amazon Link |
| Source URL | Source Link, Supplier URL |
| 90 Day Average | 90-day Average, Avg Price 90d, 90d Average |
| Cost | Unit Cost, Buy Cost, Price |
| Selling Price | Sale Price, Sell Price |
| Net Profit | Gross Profit |
| ROI | Gross Profit %, Return on Investment |
| Supplier | Vendor |
| Promo/Coupon Code | Promo Code, Coupon Code |
| Notes | Note |
| Date | Lead Date |
Numeric parsing accepts plain numbers as well as formatted values like `$12.50`, `1,209.60`, and `27.5%`.
## Pipeline
1. **Read** — parse input file, validate ASINs
2. **Cache check** — look up each ASIN in Redis (24h TTL by default)
3. **Sellability gate** — check all uncached ASINs against SP-API `getListingsRestrictions` (concurrency: 5 workers); immediately skip ASINs with status `not_available` and `canSell=false` (no Keepa/fees wasted)
4. **Keepa fetch** — batch the sellable (uncached) ASINs in a single API call (up to 100 per request)
5. **Enrich** — fetch SP-API pricing + FBA/FBM fees for sellable ASINs; combine with Keepa data and spreadsheet data
6. **LLM analysis** — send batches of 5 available products to LM Studio for FBA/FBM/SKIP verdict
7. **Chunk orchestration** — if input size is greater than 50, run phases 2-6 for each 50-item chunk sequentially
8. **Output** — print results table to console (includes all ASINs); for chunked runs, always write seriated chunk files (`*_part_001`, `*_part_002`, ...); for non-chunked runs, write a single file only when `--out` is provided
## Output columns
ASIN, Name, Brand, Category, Unit Cost, Current Price, Avg Price 90d, Sales Rank, Rank Avg 90d, Sellers, Monthly Sold, Rank Drops 30d, Rank Drops 90d, FBA Net (sheet), Gross Profit $, Gross Profit %, MOQ, MOQ Cost, Qty Available, FBA Fee, FBM Fee, Referral %, Verdict, Confidence, Reasoning
## Environment variables
| Variable | Default | Description |
| ----------------------- | -------------------------- | ----------------------------------------------------------------------- |
| `KEEPA_API_KEY` | — | **Required.** Keepa API key |
| `SP_API_CLIENT_ID` | — | LWA app client id from Solution Provider Portal |
| `SP_API_CLIENT_SECRET` | — | LWA app client secret from Solution Provider Portal |
| `SP_API_REFRESH_TOKEN` | — | Refresh token from self-authorization |
| `SP_API_REGION` | `na` | SP-API endpoint region (`na`, `eu`, `fe`; `us` is accepted as `na`) |
| `SP_API_MARKETPLACE_ID` | `ATVPDKIKX0DER` | Marketplace id used for pricing and fee calls (default: US) |
| `SP_API_SELLER_ID` | — | Seller ID used for listing restrictions eligibility checks |
| `SP_API_USE_SANDBOX` | `false` | Enable SP-API sandbox mode (`true`/`false`) |
| `AWS_ACCESS_KEY_ID` | — | AWS credentials for SigV4 signing (required in most private app setups) |
| `AWS_SECRET_ACCESS_KEY` | — | AWS credentials for SigV4 signing |
| `AWS_SESSION_TOKEN` | — | Optional session token when using STS credentials |
| `REDIS_URL` | `redis://localhost:6379` | Redis connection URL |
| `LLM_URL` | `http://localhost:1234/v1` | LM Studio API base URL |
| `LLM_MODEL` | `default` | Model name to pass to LM Studio |
| `CACHE_TTL` | `86400` | Redis cache TTL in seconds |
## Notes
- **Available-only processing**: SP-API `getListingsRestrictions` is checked first and only ASINs with `sellabilityStatus=available` are enriched, analyzed, and included in outputs. Restricted, not_available, and unknown items are excluded.
- **SP-API concurrency**: `fetchSellabilityBatch` limits concurrent requests to 5 workers to avoid 429 throttling. Pricing+fees fetches also use 5 concurrent workers.
- **No batch endpoint**: Amazon SP-API does not provide batch endpoints for `getListingsRestrictions` or `getMyFeesEstimate*`. Concurrency limiting with the library's built-in `auto_request_throttled` safety net prevents overwhelming the API.
- **Keepa rate limiting**: The client reads `tokensLeft` and `refillRate` from each API response and waits automatically when tokens are exhausted. With a Pro subscription (1 token/min), all 100 ASINs in a batch cost 1 token.
- **Redis is optional**: If Redis is unavailable the tool runs without caching — every run re-fetches from Keepa.
- **SP-API**: `src/sp-api.ts` provides `fetchSellability`, `fetchSellabilityBatch`, and `fetchSpApiPricingAndFees` functions. If SP-API credentials are missing or a call fails, the tool falls back to conservative fee defaults and keeps processing.
- **Sandbox vs production**: When `SP_API_USE_SANDBOX=true`, production ASIN calls can be denied. Use sandbox-compatible test data or set it to `false` for live marketplace connectivity.

View File

@@ -1,17 +1,17 @@
{
"name": "asin-check",
"module": "src/index.ts",
"type": "module",
"private": true,
"devDependencies": {
"@types/bun": "latest"
},
"peerDependencies": {
"typescript": "^5"
},
"dependencies": {
"amazon-sp-api": "^1.2.1",
"ioredis": "^5.10.1",
"xlsx": "^0.18.5"
}
}
{
"name": "asin-check",
"module": "src/index.ts",
"type": "module",
"private": true,
"devDependencies": {
"@types/bun": "latest"
},
"peerDependencies": {
"typescript": "^5"
},
"dependencies": {
"amazon-sp-api": "^1.2.1",
"ioredis": "^5.10.1",
"xlsx": "^0.18.5"
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,66 +1,66 @@
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,
retryStrategy: () => null,
reconnectOnError: () => false,
});
// Swallow connection-level errors after we intentionally disable cache.
redis.on("error", () => {
// no-op
});
await redis.connect();
console.log("Redis connected");
} catch (err) {
console.warn(`Redis unavailable, running without cache: ${err}`);
if (redis) {
redis.disconnect();
}
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;
}
}
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,
retryStrategy: () => null,
reconnectOnError: () => false,
});
// Swallow connection-level errors after we intentionally disable cache.
redis.on("error", () => {
// no-op
});
await redis.connect();
console.log("Redis connected");
} catch (err) {
console.warn(`Redis unavailable, running without cache: ${err}`);
if (redis) {
redis.disconnect();
}
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;
}
}

View File

@@ -1,34 +1,34 @@
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;
}
function optionalBoolean(key: string, fallback: boolean): boolean {
const raw = Bun.env[key];
if (!raw) return fallback;
const value = raw.trim().toLowerCase();
return value === "1" || value === "true" || value === "yes";
}
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),
spApiClientId: Bun.env.SP_API_CLIENT_ID,
spApiClientSecret: Bun.env.SP_API_CLIENT_SECRET,
spApiRefreshToken: Bun.env.SP_API_REFRESH_TOKEN,
spApiRegion: optional("SP_API_REGION", "na"),
spApiMarketplaceId: optional("SP_API_MARKETPLACE_ID", "ATVPDKIKX0DER"),
spApiSellerId: Bun.env.SP_API_SELLER_ID,
spApiUseSandbox: optionalBoolean("SP_API_USE_SANDBOX", false),
awsAccessKeyId: Bun.env.AWS_ACCESS_KEY_ID,
awsSecretAccessKey: Bun.env.AWS_SECRET_ACCESS_KEY,
awsSessionToken: Bun.env.AWS_SESSION_TOKEN,
} as const;
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;
}
function optionalBoolean(key: string, fallback: boolean): boolean {
const raw = Bun.env[key];
if (!raw) return fallback;
const value = raw.trim().toLowerCase();
return value === "1" || value === "true" || value === "yes";
}
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),
spApiClientId: Bun.env.SP_API_CLIENT_ID,
spApiClientSecret: Bun.env.SP_API_CLIENT_SECRET,
spApiRefreshToken: Bun.env.SP_API_REFRESH_TOKEN,
spApiRegion: optional("SP_API_REGION", "na"),
spApiMarketplaceId: optional("SP_API_MARKETPLACE_ID", "ATVPDKIKX0DER"),
spApiSellerId: Bun.env.SP_API_SELLER_ID,
spApiUseSandbox: optionalBoolean("SP_API_USE_SANDBOX", false),
awsAccessKeyId: Bun.env.AWS_ACCESS_KEY_ID,
awsSecretAccessKey: Bun.env.AWS_SECRET_ACCESS_KEY,
awsSessionToken: Bun.env.AWS_SESSION_TOKEN,
} as const;

View File

@@ -1,345 +1,345 @@
import { readProducts } from "./reader.ts";
import { fetchKeepaDataBatch } from "./keepa.ts";
import { fetchSellabilityBatch, fetchSpApiPricingAndFees } from "./sp-api.ts";
import { connectCache, getCache, setCache, disconnectCache } from "./cache.ts";
import { analyzeProducts } from "./llm.ts";
import { printResults, writeResultsCsv } from "./writer.ts";
import path from "node:path";
import type {
EnrichedProduct,
AnalysisResult,
KeepaData,
ProductRecord,
SellabilityInfo,
SpApiData,
} from "./types.ts";
const LLM_BATCH_SIZE = 5;
const INPUT_BATCH_SIZE = 50;
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 };
}
function chunkArray<T>(items: T[], chunkSize: number): T[][] {
const chunks: T[][] = [];
for (let i = 0; i < items.length; i += chunkSize) {
chunks.push(items.slice(i, i + chunkSize));
}
return chunks;
}
function resolveBaseOutputPath(inputFile: string, outputFile?: string): string {
if (outputFile) return outputFile;
const parsedInput = path.parse(inputFile);
return path.join(parsedInput.dir, `${parsedInput.name}_results.xlsx`);
}
function buildChunkOutputPath(
baseOutputPath: string,
chunkIndex: number,
): string {
const parsed = path.parse(baseOutputPath);
const extension = parsed.ext || ".xlsx";
const chunkSuffix = String(chunkIndex + 1).padStart(3, "0");
return path.join(
parsed.dir,
`${parsed.name}_part_${chunkSuffix}${extension}`,
);
}
async function processProductChunk(
products: ProductRecord[],
): Promise<AnalysisResult[]> {
// Phase 2: Check cache for all ASINs in chunk
console.log(`\nChecking cache for ${products.length} products...`);
const cached = new Map<string, EnrichedProduct>();
const excludedCachedAsins = new Set<string>();
const uncachedProducts: ProductRecord[] = [];
for (const p of products) {
const hit = await getCache(p.asin);
if (hit) {
if (hit.spApi.sellabilityStatus === "available") {
console.log(` [cache hit] ${p.asin}`);
cached.set(p.asin, hit);
} else {
excludedCachedAsins.add(p.asin);
console.log(
` [exclude cached] ${p.asin} — status=${hit.spApi.sellabilityStatus}`,
);
}
} else {
uncachedProducts.push(p);
}
}
console.log(
`${cached.size} cached available, ${excludedCachedAsins.size} cached excluded, ${uncachedProducts.length} to fetch`,
);
// Phase 3: Sellability gate — check uncached ASINs before anything else
const sellabilityMap = new Map<string, SellabilityInfo>();
const availableProducts: ProductRecord[] = [];
const unavailableProducts: ProductRecord[] = [];
if (uncachedProducts.length > 0) {
console.log(
`\nChecking sellability for ${uncachedProducts.length} ASINs...`,
);
const sellResults = await fetchSellabilityBatch(
uncachedProducts.map((p) => p.asin),
);
for (const p of uncachedProducts) {
const info = sellResults.get(p.asin) ?? {
canSell: null,
sellabilityStatus: "unknown" as const,
sellabilityReason: "Sellability check returned no result",
};
sellabilityMap.set(p.asin, info);
// Keep only ASINs that are explicitly available.
if (info.sellabilityStatus === "available") {
availableProducts.push(p);
console.log(
` [available] ${p.asin} — status=${info.sellabilityStatus}`,
);
} else {
unavailableProducts.push(p);
console.log(
` [exclude] ${p.asin} — status=${info.sellabilityStatus}, reason=${info.sellabilityReason ?? "n/a"}`,
);
}
}
console.log(
`\nSellability gate: ${availableProducts.length} available, ${unavailableProducts.length} excluded`,
);
}
// Phase 4: Keepa batch fetch — only for available (uncached) ASINs
let keepaResults = new Map<string, KeepaData>();
if (availableProducts.length > 0) {
console.log(`\nFetching ${availableProducts.length} ASINs from Keepa...`);
try {
keepaResults = await fetchKeepaDataBatch(
availableProducts.map((p) => p.asin),
);
} catch (err) {
console.warn(`Keepa batch fetch failed: ${err}`);
}
}
// Phase 5: SP-API pricing + fees — only for available ASINs
console.log(
`\nFetching pricing & fees for ${availableProducts.length} ASINs...`,
);
const spApiResults = new Map<string, SpApiData>();
// Concurrency-limited pricing+fees fetches
const pricingQueue = [...availableProducts];
let pricingDone = 0;
async function fetchNextPricing(): Promise<void> {
while (pricingQueue.length > 0) {
const p = pricingQueue.shift()!;
const sellability = sellabilityMap.get(p.asin)!;
const spApi = await fetchSpApiPricingAndFees(p.asin, sellability);
const keepa = keepaResults.get(p.asin);
if (keepa?.currentPrice && spApi.estimatedSalePrice === 0) {
spApi.estimatedSalePrice = keepa.currentPrice;
}
spApiResults.set(p.asin, spApi);
pricingDone++;
if (pricingDone % 10 === 0 || pricingDone === availableProducts.length) {
console.log(
` [pricing] ${pricingDone}/${availableProducts.length} fetched`,
);
}
}
}
const pricingWorkers = Array.from(
{ length: Math.min(5, availableProducts.length || 1) },
() => fetchNextPricing(),
);
await Promise.all(pricingWorkers);
// Phase 6: Build enriched products
console.log(`\nEnriching products...`);
const enriched: EnrichedProduct[] = [];
const availableAsins = new Set(availableProducts.map((ap) => ap.asin));
for (const p of products) {
if (excludedCachedAsins.has(p.asin)) {
continue;
}
// Cached products — already enriched
const cachedProduct = cached.get(p.asin);
if (cachedProduct) {
enriched.push(cachedProduct);
continue;
}
// Exclude products that are not explicitly available.
if (!availableAsins.has(p.asin)) {
continue;
}
// Available products — full enrichment
const keepa = keepaResults.get(p.asin) ?? null;
const spApi = spApiResults.get(p.asin) ?? {
fbaFee: 5.0,
fbmFee: 1.5,
referralFeePercent: 15,
estimatedSalePrice: 0,
canSell: null,
sellabilityStatus: "unknown" as const,
sellabilityReason: "SP-API data missing",
};
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 7: LLM analysis in batches — only for enriched available products
console.log(
`\nAnalyzing ${enriched.length} 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",
},
});
}
}
return results;
}
async function main() {
const { inputFile, outputFile } = parseArgs();
console.log("Connecting to Redis...");
await connectCache();
try {
// Phase 1: Read input file
console.log(`\nReading ${inputFile}...`);
const products = readProducts(inputFile);
if (products.length === 0) {
console.error("No valid products found in input file.");
process.exit(1);
}
const productChunks = chunkArray(products, INPUT_BATCH_SIZE);
const hasMultipleChunks = productChunks.length > 1;
const shouldWriteChunkFiles = hasMultipleChunks;
const resolvedBaseOutputPath = resolveBaseOutputPath(inputFile, outputFile);
const allResults: AnalysisResult[] = [];
if (hasMultipleChunks) {
console.log(
`\nLarge input detected (${products.length} products). Processing in chunks of ${INPUT_BATCH_SIZE}.`,
);
console.log(
`Chunk outputs will be written as numbered files using base: ${resolvedBaseOutputPath}`,
);
}
for (let chunkIndex = 0; chunkIndex < productChunks.length; chunkIndex++) {
const chunk = productChunks[chunkIndex]!;
console.log(
`\n=== Input chunk ${chunkIndex + 1}/${productChunks.length} (${chunk.length} products) ===`,
);
const chunkResults = await processProductChunk(chunk);
allResults.push(...chunkResults);
if (shouldWriteChunkFiles) {
const chunkOutputPath = buildChunkOutputPath(
resolvedBaseOutputPath,
chunkIndex,
);
writeResultsCsv(chunkResults, chunkOutputPath);
}
}
printResults(allResults);
if (!hasMultipleChunks && outputFile) {
writeResultsCsv(allResults, outputFile);
}
} finally {
await disconnectCache();
}
}
main().catch((err) => {
console.error("Fatal error:", err);
process.exit(1);
});
import { readProducts } from "./reader.ts";
import { fetchKeepaDataBatch } from "./keepa.ts";
import { fetchSellabilityBatch, fetchSpApiPricingAndFees } from "./sp-api.ts";
import { connectCache, getCache, setCache, disconnectCache } from "./cache.ts";
import { analyzeProducts } from "./llm.ts";
import { printResults, writeResultsCsv } from "./writer.ts";
import path from "node:path";
import type {
EnrichedProduct,
AnalysisResult,
KeepaData,
ProductRecord,
SellabilityInfo,
SpApiData,
} from "./types.ts";
const LLM_BATCH_SIZE = 5;
const INPUT_BATCH_SIZE = 50;
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 };
}
function chunkArray<T>(items: T[], chunkSize: number): T[][] {
const chunks: T[][] = [];
for (let i = 0; i < items.length; i += chunkSize) {
chunks.push(items.slice(i, i + chunkSize));
}
return chunks;
}
function resolveBaseOutputPath(inputFile: string, outputFile?: string): string {
if (outputFile) return outputFile;
const parsedInput = path.parse(inputFile);
return path.join(parsedInput.dir, `${parsedInput.name}_results.xlsx`);
}
function buildChunkOutputPath(
baseOutputPath: string,
chunkIndex: number,
): string {
const parsed = path.parse(baseOutputPath);
const extension = parsed.ext || ".xlsx";
const chunkSuffix = String(chunkIndex + 1).padStart(3, "0");
return path.join(
parsed.dir,
`${parsed.name}_part_${chunkSuffix}${extension}`,
);
}
async function processProductChunk(
products: ProductRecord[],
): Promise<AnalysisResult[]> {
// Phase 2: Check cache for all ASINs in chunk
console.log(`\nChecking cache for ${products.length} products...`);
const cached = new Map<string, EnrichedProduct>();
const excludedCachedAsins = new Set<string>();
const uncachedProducts: ProductRecord[] = [];
for (const p of products) {
const hit = await getCache(p.asin);
if (hit) {
if (hit.spApi.sellabilityStatus === "available") {
console.log(` [cache hit] ${p.asin}`);
cached.set(p.asin, hit);
} else {
excludedCachedAsins.add(p.asin);
console.log(
` [exclude cached] ${p.asin} — status=${hit.spApi.sellabilityStatus}`,
);
}
} else {
uncachedProducts.push(p);
}
}
console.log(
`${cached.size} cached available, ${excludedCachedAsins.size} cached excluded, ${uncachedProducts.length} to fetch`,
);
// Phase 3: Sellability gate — check uncached ASINs before anything else
const sellabilityMap = new Map<string, SellabilityInfo>();
const availableProducts: ProductRecord[] = [];
const unavailableProducts: ProductRecord[] = [];
if (uncachedProducts.length > 0) {
console.log(
`\nChecking sellability for ${uncachedProducts.length} ASINs...`,
);
const sellResults = await fetchSellabilityBatch(
uncachedProducts.map((p) => p.asin),
);
for (const p of uncachedProducts) {
const info = sellResults.get(p.asin) ?? {
canSell: null,
sellabilityStatus: "unknown" as const,
sellabilityReason: "Sellability check returned no result",
};
sellabilityMap.set(p.asin, info);
// Keep only ASINs that are explicitly available.
if (info.sellabilityStatus === "available") {
availableProducts.push(p);
console.log(
` [available] ${p.asin} — status=${info.sellabilityStatus}`,
);
} else {
unavailableProducts.push(p);
console.log(
` [exclude] ${p.asin} — status=${info.sellabilityStatus}, reason=${info.sellabilityReason ?? "n/a"}`,
);
}
}
console.log(
`\nSellability gate: ${availableProducts.length} available, ${unavailableProducts.length} excluded`,
);
}
// Phase 4: Keepa batch fetch — only for available (uncached) ASINs
let keepaResults = new Map<string, KeepaData>();
if (availableProducts.length > 0) {
console.log(`\nFetching ${availableProducts.length} ASINs from Keepa...`);
try {
keepaResults = await fetchKeepaDataBatch(
availableProducts.map((p) => p.asin),
);
} catch (err) {
console.warn(`Keepa batch fetch failed: ${err}`);
}
}
// Phase 5: SP-API pricing + fees — only for available ASINs
console.log(
`\nFetching pricing & fees for ${availableProducts.length} ASINs...`,
);
const spApiResults = new Map<string, SpApiData>();
// Concurrency-limited pricing+fees fetches
const pricingQueue = [...availableProducts];
let pricingDone = 0;
async function fetchNextPricing(): Promise<void> {
while (pricingQueue.length > 0) {
const p = pricingQueue.shift()!;
const sellability = sellabilityMap.get(p.asin)!;
const spApi = await fetchSpApiPricingAndFees(p.asin, sellability);
const keepa = keepaResults.get(p.asin);
if (keepa?.currentPrice && spApi.estimatedSalePrice === 0) {
spApi.estimatedSalePrice = keepa.currentPrice;
}
spApiResults.set(p.asin, spApi);
pricingDone++;
if (pricingDone % 10 === 0 || pricingDone === availableProducts.length) {
console.log(
` [pricing] ${pricingDone}/${availableProducts.length} fetched`,
);
}
}
}
const pricingWorkers = Array.from(
{ length: Math.min(5, availableProducts.length || 1) },
() => fetchNextPricing(),
);
await Promise.all(pricingWorkers);
// Phase 6: Build enriched products
console.log(`\nEnriching products...`);
const enriched: EnrichedProduct[] = [];
const availableAsins = new Set(availableProducts.map((ap) => ap.asin));
for (const p of products) {
if (excludedCachedAsins.has(p.asin)) {
continue;
}
// Cached products — already enriched
const cachedProduct = cached.get(p.asin);
if (cachedProduct) {
enriched.push(cachedProduct);
continue;
}
// Exclude products that are not explicitly available.
if (!availableAsins.has(p.asin)) {
continue;
}
// Available products — full enrichment
const keepa = keepaResults.get(p.asin) ?? null;
const spApi = spApiResults.get(p.asin) ?? {
fbaFee: 5.0,
fbmFee: 1.5,
referralFeePercent: 15,
estimatedSalePrice: 0,
canSell: null,
sellabilityStatus: "unknown" as const,
sellabilityReason: "SP-API data missing",
};
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 7: LLM analysis in batches — only for enriched available products
console.log(
`\nAnalyzing ${enriched.length} 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",
},
});
}
}
return results;
}
async function main() {
const { inputFile, outputFile } = parseArgs();
console.log("Connecting to Redis...");
await connectCache();
try {
// Phase 1: Read input file
console.log(`\nReading ${inputFile}...`);
const products = readProducts(inputFile);
if (products.length === 0) {
console.error("No valid products found in input file.");
process.exit(1);
}
const productChunks = chunkArray(products, INPUT_BATCH_SIZE);
const hasMultipleChunks = productChunks.length > 1;
const shouldWriteChunkFiles = hasMultipleChunks;
const resolvedBaseOutputPath = resolveBaseOutputPath(inputFile, outputFile);
const allResults: AnalysisResult[] = [];
if (hasMultipleChunks) {
console.log(
`\nLarge input detected (${products.length} products). Processing in chunks of ${INPUT_BATCH_SIZE}.`,
);
console.log(
`Chunk outputs will be written as numbered files using base: ${resolvedBaseOutputPath}`,
);
}
for (let chunkIndex = 0; chunkIndex < productChunks.length; chunkIndex++) {
const chunk = productChunks[chunkIndex]!;
console.log(
`\n=== Input chunk ${chunkIndex + 1}/${productChunks.length} (${chunk.length} products) ===`,
);
const chunkResults = await processProductChunk(chunk);
allResults.push(...chunkResults);
if (shouldWriteChunkFiles) {
const chunkOutputPath = buildChunkOutputPath(
resolvedBaseOutputPath,
chunkIndex,
);
writeResultsCsv(chunkResults, chunkOutputPath);
}
}
printResults(allResults);
if (!hasMultipleChunks && outputFile) {
writeResultsCsv(allResults, outputFile);
}
} finally {
await disconnectCache();
}
}
main().catch((err) => {
console.error("Fatal error:", err);
process.exit(1);
});

View File

@@ -1,141 +1,141 @@
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;
const salesRankDrops30 = pickKeepaNumber(
product.salesRankDrops30,
stats?.salesRankDrops30,
);
const salesRankDrops90 =
pickKeepaNumber(product.salesRankDrops90, stats?.salesRankDrops90) ??
(salesRankDrops30 != null ? salesRankDrops30 * 3 : null);
const monthlySold =
pickKeepaNumber(product.monthlySold, stats?.monthlySold) ??
salesRankDrops30;
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,
salesRankDrops90,
sellerCount: stats?.current?.[11] ?? null,
buyBoxSeller: product.buyBoxSellerId ?? null,
buyBoxPrice: stats?.current?.[18] != null ? stats.current[18] / 100 : null,
monthlySold,
categoryTree:
product.categoryTree?.map((c: { name: string }) => c.name) ?? [],
};
}
function pickKeepaNumber(...values: unknown[]): number | null {
for (const value of values) {
if (typeof value !== "number" || !Number.isFinite(value)) continue;
// Keepa often uses -1 as "not available".
if (value < 0) continue;
return value;
}
return null;
}
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;
}
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;
const salesRankDrops30 = pickKeepaNumber(
product.salesRankDrops30,
stats?.salesRankDrops30,
);
const salesRankDrops90 =
pickKeepaNumber(product.salesRankDrops90, stats?.salesRankDrops90) ??
(salesRankDrops30 != null ? salesRankDrops30 * 3 : null);
const monthlySold =
pickKeepaNumber(product.monthlySold, stats?.monthlySold) ??
salesRankDrops30;
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,
salesRankDrops90,
sellerCount: stats?.current?.[11] ?? null,
buyBoxSeller: product.buyBoxSellerId ?? null,
buyBoxPrice: stats?.current?.[18] != null ? stats.current[18] / 100 : null,
monthlySold,
categoryTree:
product.categoryTree?.map((c: { name: string }) => c.name) ?? [],
};
}
function pickKeepaNumber(...values: unknown[]): number | null {
for (const value of values) {
if (typeof value !== "number" || !Number.isFinite(value)) continue;
// Keepa often uses -1 as "not available".
if (value < 0) continue;
return value;
}
return null;
}
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;
}

View File

@@ -1,353 +1,353 @@
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.
10. **Seller Eligibility (critical)**:
- If sellerEligibility.status is "restricted" or "not_available", return verdict = "SKIP".
- If sellerEligibility.status is "unknown", treat as elevated risk and only allow FBA/FBM with clearly strong economics + demand.
- If canSell is false, return "SKIP" regardless of margin.
Decision policy:
- Do not recommend products that cannot be listed by this seller account.
- Prioritize profitable + high-velocity + listable products.
- Use "SKIP" when data quality is poor or risk is high.
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 and mention key blocker if skipped (e.g., restricted, low demand, thin margin).`;
export async function analyzeProducts(
products: EnrichedProduct[],
): Promise<LlmVerdict[]> {
try {
return await analyzeProductsInternal(products);
} catch (err) {
const msg = String(err);
if (products.length > 1 && msg.includes("Context size has been exceeded")) {
console.warn(
`LLM context exceeded for batch of ${products.length}, retrying one product at a time...`,
);
const fallback: LlmVerdict[] = [];
for (const product of products) {
try {
const single = await analyzeProductsInternal([product]);
fallback.push(
single[0] ?? {
asin: product.record.asin,
verdict: "SKIP",
confidence: 0,
reasoning: "LLM returned empty verdict",
},
);
} catch {
fallback.push({
asin: product.record.asin,
verdict: "SKIP",
confidence: 0,
reasoning: "LLM context overflow on single-item fallback",
});
}
}
return fallback;
}
throw err;
}
}
async function analyzeProductsInternal(
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.record.sellingPriceFromSheet ??
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: clampText(p.record.name, 80),
brand: p.record.brand,
category: clampText(
p.record.category ?? p.keepa?.categoryTree?.join(" > "),
60,
),
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: {
avgPrice90: p.record.avgPrice90FromSheet,
sellingPrice: p.record.sellingPriceFromSheet,
fbaNet: p.record.fbaNet,
grossProfit: p.record.grossProfit,
grossProfitPct: p.record.grossProfitPct,
netProfit: p.record.netProfitFromSheet,
roi: p.record.roiFromSheet,
},
supplier: clampText(p.record.supplier, 40),
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,
},
sellerEligibility: {
canSell: p.spApi.canSell,
status: p.spApi.sellabilityStatus,
reason: clampText(p.spApi.sellabilityReason, 120),
},
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 clampText(value: unknown, maxLen: number): string | undefined {
if (value == null) return undefined;
const s = String(value).trim();
if (!s) return undefined;
return s.length > maxLen ? `${s.slice(0, maxLen - 1)}.` : s;
}
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();
// Strip any non-JSON wrapper text by taking the largest JSON-looking segment
const firstArray = cleaned.indexOf("[");
const firstObject = cleaned.indexOf("{");
const startCandidates = [firstArray, firstObject].filter((i) => i >= 0);
const start = startCandidates.length > 0 ? Math.min(...startCandidates) : -1;
const endArray = cleaned.lastIndexOf("]");
const endObject = cleaned.lastIndexOf("}");
const end = Math.max(endArray, endObject);
if (start >= 0 && end > start) {
cleaned = cleaned.slice(start, end + 1);
}
// Fix trailing comma-quote before closing brace: ,"} → "}
cleaned = cleaned.replace(/,"\s*}/g, '"}');
// Fix malformed comma-quote before a closing bracket/brace: ,"} or ,"]
cleaned = cleaned.replace(/,\s*"\s*([}\]])/g, "$1");
// Fix malformed quote-comma before a closing bracket/brace: ",} or ",]
cleaned = cleaned.replace(/"\s*,\s*([}\]])/g, '"$1');
// Fix trailing commas before ] or }
cleaned = cleaned.replace(/,\s*([}\]])/g, "$1");
return cleaned;
}
function parseVerdicts(
content: string,
products: EnrichedProduct[],
): LlmVerdict[] {
const cleaned = cleanLlmJson(content);
try {
const parsed = JSON.parse(cleaned) as unknown;
return alignVerdicts(products, normalizeVerdicts(parsed));
} catch (err) {
const salvaged = extractVerdictsLoosely(cleaned);
if (salvaged.length > 0) {
console.warn(
`LLM response was invalid JSON; salvaged ${salvaged.length} verdict(s) with loose parsing.`,
);
return alignVerdicts(products, salvaged);
}
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`,
}));
}
}
function normalizeVerdicts(parsed: unknown): LlmVerdict[] {
const container =
parsed && typeof parsed === "object"
? (parsed as Record<string, unknown>)
: undefined;
const nested = container?.verdicts ?? container?.results;
const arr: unknown[] = Array.isArray(parsed)
? parsed
: Array.isArray(nested)
? nested
: [parsed];
return arr
.filter((v): v is Record<string, unknown> => !!v && typeof v === "object")
.map((v) => ({
asin: String(v.asin ?? "")
.trim()
.toUpperCase(),
verdict: (String(v.verdict).toUpperCase() === "FBA" ||
String(v.verdict).toUpperCase() === "FBM" ||
String(v.verdict).toUpperCase() === "SKIP"
? String(v.verdict).toUpperCase()
: "SKIP") as LlmVerdict["verdict"],
confidence: clampConfidence(
typeof v.confidence === "number"
? v.confidence
: Number(v.confidence ?? 0),
),
reasoning: String(v.reasoning ?? "No reasoning provided"),
}));
}
function extractVerdictsLoosely(text: string): LlmVerdict[] {
const objectMatches = text.match(/\{[\s\S]*?\}/g) ?? [];
const verdicts: LlmVerdict[] = [];
for (const chunk of objectMatches) {
const asin = extractField(chunk, /"asin"\s*:\s*"?([A-Z0-9]{10})"?/i) ?? "";
const verdictRaw =
extractField(chunk, /"verdict"\s*:\s*"?([A-Z]+)"?/i) ?? "SKIP";
const confidenceRaw =
extractField(chunk, /"confidence"\s*:\s*([0-9]+(?:\.[0-9]+)?)/i) ?? "0";
const reasoning =
extractField(chunk, /"reasoning"\s*:\s*"([\s\S]*?)"\s*(?:,|})/i) ??
"No reasoning provided";
const normalizedVerdict = verdictRaw.toUpperCase();
if (!asin) continue;
verdicts.push({
asin,
verdict: (normalizedVerdict === "FBA" ||
normalizedVerdict === "FBM" ||
normalizedVerdict === "SKIP"
? normalizedVerdict
: "SKIP") as LlmVerdict["verdict"],
confidence: clampConfidence(Number(confidenceRaw)),
reasoning,
});
}
return verdicts;
}
function extractField(text: string, regex: RegExp): string | undefined {
const match = text.match(regex);
return match?.[1]?.trim();
}
function clampConfidence(value: number): number {
if (!Number.isFinite(value)) return 0;
return Math.max(0, Math.min(100, Math.round(value)));
}
function alignVerdicts(
products: EnrichedProduct[],
verdicts: LlmVerdict[],
): LlmVerdict[] {
const byAsin = new Map<string, LlmVerdict>();
for (const verdict of verdicts) {
if (verdict.asin && !byAsin.has(verdict.asin)) {
byAsin.set(verdict.asin, verdict);
}
}
return products.map((product, index) => {
const asin = product.record.asin;
const byAsinVerdict = byAsin.get(asin);
if (byAsinVerdict) return { ...byAsinVerdict, asin };
const byIndexVerdict = verdicts[index];
if (byIndexVerdict) return { ...byIndexVerdict, asin };
return {
asin,
verdict: "SKIP" as const,
confidence: 0,
reasoning: "LLM returned no verdict for this product",
};
});
}
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.
10. **Seller Eligibility (critical)**:
- If sellerEligibility.status is "restricted" or "not_available", return verdict = "SKIP".
- If sellerEligibility.status is "unknown", treat as elevated risk and only allow FBA/FBM with clearly strong economics + demand.
- If canSell is false, return "SKIP" regardless of margin.
Decision policy:
- Do not recommend products that cannot be listed by this seller account.
- Prioritize profitable + high-velocity + listable products.
- Use "SKIP" when data quality is poor or risk is high.
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 and mention key blocker if skipped (e.g., restricted, low demand, thin margin).`;
export async function analyzeProducts(
products: EnrichedProduct[],
): Promise<LlmVerdict[]> {
try {
return await analyzeProductsInternal(products);
} catch (err) {
const msg = String(err);
if (products.length > 1 && msg.includes("Context size has been exceeded")) {
console.warn(
`LLM context exceeded for batch of ${products.length}, retrying one product at a time...`,
);
const fallback: LlmVerdict[] = [];
for (const product of products) {
try {
const single = await analyzeProductsInternal([product]);
fallback.push(
single[0] ?? {
asin: product.record.asin,
verdict: "SKIP",
confidence: 0,
reasoning: "LLM returned empty verdict",
},
);
} catch {
fallback.push({
asin: product.record.asin,
verdict: "SKIP",
confidence: 0,
reasoning: "LLM context overflow on single-item fallback",
});
}
}
return fallback;
}
throw err;
}
}
async function analyzeProductsInternal(
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.record.sellingPriceFromSheet ??
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: clampText(p.record.name, 80),
brand: p.record.brand,
category: clampText(
p.record.category ?? p.keepa?.categoryTree?.join(" > "),
60,
),
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: {
avgPrice90: p.record.avgPrice90FromSheet,
sellingPrice: p.record.sellingPriceFromSheet,
fbaNet: p.record.fbaNet,
grossProfit: p.record.grossProfit,
grossProfitPct: p.record.grossProfitPct,
netProfit: p.record.netProfitFromSheet,
roi: p.record.roiFromSheet,
},
supplier: clampText(p.record.supplier, 40),
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,
},
sellerEligibility: {
canSell: p.spApi.canSell,
status: p.spApi.sellabilityStatus,
reason: clampText(p.spApi.sellabilityReason, 120),
},
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 clampText(value: unknown, maxLen: number): string | undefined {
if (value == null) return undefined;
const s = String(value).trim();
if (!s) return undefined;
return s.length > maxLen ? `${s.slice(0, maxLen - 1)}.` : s;
}
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();
// Strip any non-JSON wrapper text by taking the largest JSON-looking segment
const firstArray = cleaned.indexOf("[");
const firstObject = cleaned.indexOf("{");
const startCandidates = [firstArray, firstObject].filter((i) => i >= 0);
const start = startCandidates.length > 0 ? Math.min(...startCandidates) : -1;
const endArray = cleaned.lastIndexOf("]");
const endObject = cleaned.lastIndexOf("}");
const end = Math.max(endArray, endObject);
if (start >= 0 && end > start) {
cleaned = cleaned.slice(start, end + 1);
}
// Fix trailing comma-quote before closing brace: ,"} → "}
cleaned = cleaned.replace(/,"\s*}/g, '"}');
// Fix malformed comma-quote before a closing bracket/brace: ,"} or ,"]
cleaned = cleaned.replace(/,\s*"\s*([}\]])/g, "$1");
// Fix malformed quote-comma before a closing bracket/brace: ",} or ",]
cleaned = cleaned.replace(/"\s*,\s*([}\]])/g, '"$1');
// Fix trailing commas before ] or }
cleaned = cleaned.replace(/,\s*([}\]])/g, "$1");
return cleaned;
}
function parseVerdicts(
content: string,
products: EnrichedProduct[],
): LlmVerdict[] {
const cleaned = cleanLlmJson(content);
try {
const parsed = JSON.parse(cleaned) as unknown;
return alignVerdicts(products, normalizeVerdicts(parsed));
} catch (err) {
const salvaged = extractVerdictsLoosely(cleaned);
if (salvaged.length > 0) {
console.warn(
`LLM response was invalid JSON; salvaged ${salvaged.length} verdict(s) with loose parsing.`,
);
return alignVerdicts(products, salvaged);
}
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`,
}));
}
}
function normalizeVerdicts(parsed: unknown): LlmVerdict[] {
const container =
parsed && typeof parsed === "object"
? (parsed as Record<string, unknown>)
: undefined;
const nested = container?.verdicts ?? container?.results;
const arr: unknown[] = Array.isArray(parsed)
? parsed
: Array.isArray(nested)
? nested
: [parsed];
return arr
.filter((v): v is Record<string, unknown> => !!v && typeof v === "object")
.map((v) => ({
asin: String(v.asin ?? "")
.trim()
.toUpperCase(),
verdict: (String(v.verdict).toUpperCase() === "FBA" ||
String(v.verdict).toUpperCase() === "FBM" ||
String(v.verdict).toUpperCase() === "SKIP"
? String(v.verdict).toUpperCase()
: "SKIP") as LlmVerdict["verdict"],
confidence: clampConfidence(
typeof v.confidence === "number"
? v.confidence
: Number(v.confidence ?? 0),
),
reasoning: String(v.reasoning ?? "No reasoning provided"),
}));
}
function extractVerdictsLoosely(text: string): LlmVerdict[] {
const objectMatches = text.match(/\{[\s\S]*?\}/g) ?? [];
const verdicts: LlmVerdict[] = [];
for (const chunk of objectMatches) {
const asin = extractField(chunk, /"asin"\s*:\s*"?([A-Z0-9]{10})"?/i) ?? "";
const verdictRaw =
extractField(chunk, /"verdict"\s*:\s*"?([A-Z]+)"?/i) ?? "SKIP";
const confidenceRaw =
extractField(chunk, /"confidence"\s*:\s*([0-9]+(?:\.[0-9]+)?)/i) ?? "0";
const reasoning =
extractField(chunk, /"reasoning"\s*:\s*"([\s\S]*?)"\s*(?:,|})/i) ??
"No reasoning provided";
const normalizedVerdict = verdictRaw.toUpperCase();
if (!asin) continue;
verdicts.push({
asin,
verdict: (normalizedVerdict === "FBA" ||
normalizedVerdict === "FBM" ||
normalizedVerdict === "SKIP"
? normalizedVerdict
: "SKIP") as LlmVerdict["verdict"],
confidence: clampConfidence(Number(confidenceRaw)),
reasoning,
});
}
return verdicts;
}
function extractField(text: string, regex: RegExp): string | undefined {
const match = text.match(regex);
return match?.[1]?.trim();
}
function clampConfidence(value: number): number {
if (!Number.isFinite(value)) return 0;
return Math.max(0, Math.min(100, Math.round(value)));
}
function alignVerdicts(
products: EnrichedProduct[],
verdicts: LlmVerdict[],
): LlmVerdict[] {
const byAsin = new Map<string, LlmVerdict>();
for (const verdict of verdicts) {
if (verdict.asin && !byAsin.has(verdict.asin)) {
byAsin.set(verdict.asin, verdict);
}
}
return products.map((product, index) => {
const asin = product.record.asin;
const byAsinVerdict = byAsin.get(asin);
if (byAsinVerdict) return { ...byAsinVerdict, asin };
const byIndexVerdict = verdicts[index];
if (byIndexVerdict) return { ...byIndexVerdict, asin };
return {
asin,
verdict: "SKIP" as const,
confidence: 0,
reasoning: "LLM returned no verdict for this product",
};
});
}

View File

@@ -1,209 +1,209 @@
import * as XLSX from "xlsx";
import type { ProductRecord } from "./types.ts";
const ASIN_REGEX = /^B[0-9A-Z]{9}$/;
const COLUMN_CANDIDATES = {
asin: ["asin"],
name: ["name", "product name", "title", "product title"],
cost: ["unit cost", "cost", "unitcost", "unit_cost", "price", "buy cost"],
brand: ["brand"],
category: ["category"],
amazonRank: ["amazon rank", "amazonrank", "sales rank", "bsr"],
avgPrice90: [
"90 day average",
"90-day average",
"avg price 90d",
"avg 90 day",
"90d average",
],
sellingPrice: ["selling price", "sale price", "sell price"],
fbaNet: ["fba net", "fbanet", "fba_net"],
grossProfit: ["gross profit $", "gross profit", "grossprofit"],
grossProfitPct: ["gross profit %", "gross profit pct", "grossprofitpct"],
netProfit: ["net profit", "netprofit"],
roi: ["roi", "return on investment"],
moq: ["moq", "min order qty", "minimum order quantity"],
moqCost: ["moq cost", "moqcost", "moq_cost"],
totalQty: ["total qty avail", "totalqtyavail", "qty available", "quantity"],
link: ["link", "url", "source"],
asinLink: ["asin link", "amazon link", "asin url"],
sourceUrl: ["source url", "supplier url", "source link"],
supplier: ["supplier", "vendor"],
promoCouponCode: [
"promo/coupon code",
"promo coupon code",
"coupon code",
"promo code",
],
notes: ["notes", "note"],
leadDate: ["date", "lead date"],
} as const;
type ColumnKey = keyof typeof COLUMN_CANDIDATES;
type ColumnMap = Record<ColumnKey, string | undefined>;
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 columns = detectColumns(headers);
const asinColumn = columns.asin;
if (!asinColumn)
throw new Error(
`No ASIN column found. Available columns: ${headers.join(", ")}`,
);
logColumnDetection(headers, columns);
const knownCols = getKnownColumns(columns);
const products: ProductRecord[] = [];
for (const row of rows) {
const asin = parseAsin(row[asinColumn]);
if (!asin) continue;
const sourceUrl = getOptionalString(row, columns.sourceUrl);
const asinLink = getOptionalString(row, columns.asinLink);
const link = sourceUrl ?? asinLink ?? getOptionalString(row, columns.link);
const extra = getExtraFields(row, headers, knownCols);
const netProfitFromSheet = getOptionalNumber(row, columns.netProfit);
const roiFromSheet = getOptionalNumber(row, columns.roi);
products.push({
asin,
name: getOptionalString(row, columns.name) ?? "",
unitCost: getOptionalNumber(row, columns.cost) ?? 0,
brand: getOptionalString(row, columns.brand),
category: getOptionalString(row, columns.category),
amazonRank: getOptionalNumber(row, columns.amazonRank),
avgPrice90FromSheet: getOptionalNumber(row, columns.avgPrice90),
sellingPriceFromSheet: getOptionalNumber(row, columns.sellingPrice),
fbaNet: getOptionalNumber(row, columns.fbaNet),
grossProfit: getOptionalNumber(row, columns.grossProfit) ?? netProfitFromSheet,
grossProfitPct:
getOptionalNumber(row, columns.grossProfitPct) ?? roiFromSheet,
netProfitFromSheet,
roiFromSheet,
moq: getOptionalNumber(row, columns.moq),
moqCost: getOptionalNumber(row, columns.moqCost),
totalQtyAvail: getOptionalNumber(row, columns.totalQty),
link,
asinLink,
sourceUrl,
supplier: getOptionalString(row, columns.supplier),
promoCouponCode: getOptionalString(row, columns.promoCouponCode),
notes: getOptionalString(row, columns.notes),
leadDate: getOptionalString(row, columns.leadDate),
...extra,
});
}
console.log(`Read ${products.length} valid products from ${filePath}`);
return products;
}
function detectColumns(headers: string[]): ColumnMap {
const columns = {} as ColumnMap;
for (const key of Object.keys(COLUMN_CANDIDATES) as ColumnKey[]) {
columns[key] = findColumn(headers, [...COLUMN_CANDIDATES[key]]);
}
return columns;
}
function logColumnDetection(headers: string[], columns: ColumnMap): void {
console.log(`Found columns: ${headers.join(", ")}`);
console.log(
`Detected columns -> ASIN: ${columns.asin ?? "n/a"}, Name: ${columns.name ?? "n/a"}, Cost: ${columns.cost ?? "n/a"}, 90d Avg: ${columns.avgPrice90 ?? "n/a"}, Selling Price: ${columns.sellingPrice ?? "n/a"}, Net Profit: ${columns.netProfit ?? columns.grossProfit ?? "n/a"}, ROI: ${columns.roi ?? columns.grossProfitPct ?? "n/a"}, Source URL: ${columns.sourceUrl ?? "n/a"}, ASIN Link: ${columns.asinLink ?? "n/a"}`,
);
}
function getKnownColumns(columns: ColumnMap): Set<string> {
return new Set(Object.values(columns).filter((column): column is string => !!column));
}
function parseAsin(value: unknown): string | undefined {
const asin = String(value ?? "")
.trim()
.toUpperCase();
if (!asin || !ASIN_REGEX.test(asin)) {
console.warn(`Skipping invalid ASIN: "${asin}"`);
return undefined;
}
return asin;
}
function getOptionalString(
row: Record<string, unknown>,
column: string | undefined,
): string | undefined {
if (!column) return undefined;
return normalizeOptionalString(row[column]);
}
function getOptionalNumber(
row: Record<string, unknown>,
column: string | undefined,
): number | undefined {
if (!column) return undefined;
return parseOptionalNumber(row[column]);
}
function getExtraFields(
row: Record<string, unknown>,
headers: string[],
knownCols: Set<string>,
): Record<string, unknown> {
const extra: Record<string, unknown> = {};
for (const header of headers) {
if (!knownCols.has(header)) extra[header] = row[header];
}
return extra;
}
function findColumn(
headers: string[],
candidates: string[],
): string | undefined {
const normalizedCandidates = new Set(candidates.map(normalizeHeader));
for (const header of headers) {
if (normalizedCandidates.has(normalizeHeader(header))) {
return header;
}
}
return undefined;
}
function normalizeHeader(value: string): string {
return value
.toLowerCase()
.trim()
.replace(/%/g, " pct ")
.replace(/\$/g, " usd ")
.replace(/[^a-z0-9]/g, "");
}
function normalizeOptionalString(value: unknown): string | undefined {
if (value == null) return undefined;
const s = String(value).trim();
return s.length > 0 ? s : undefined;
}
function parseOptionalNumber(value: unknown): number | undefined {
if (value == null || value === "") return undefined;
const cleaned = String(value).trim().replace(/[$,%]/g, "").replace(/,/g, "");
const parsed = Number(cleaned);
return Number.isFinite(parsed) ? parsed : undefined;
}
import * as XLSX from "xlsx";
import type { ProductRecord } from "./types.ts";
const ASIN_REGEX = /^B[0-9A-Z]{9}$/;
const COLUMN_CANDIDATES = {
asin: ["asin"],
name: ["name", "product name", "title", "product title"],
cost: ["unit cost", "cost", "unitcost", "unit_cost", "price", "buy cost"],
brand: ["brand"],
category: ["category"],
amazonRank: ["amazon rank", "amazonrank", "sales rank", "bsr"],
avgPrice90: [
"90 day average",
"90-day average",
"avg price 90d",
"avg 90 day",
"90d average",
],
sellingPrice: ["selling price", "sale price", "sell price"],
fbaNet: ["fba net", "fbanet", "fba_net"],
grossProfit: ["gross profit $", "gross profit", "grossprofit"],
grossProfitPct: ["gross profit %", "gross profit pct", "grossprofitpct"],
netProfit: ["net profit", "netprofit"],
roi: ["roi", "return on investment"],
moq: ["moq", "min order qty", "minimum order quantity"],
moqCost: ["moq cost", "moqcost", "moq_cost"],
totalQty: ["total qty avail", "totalqtyavail", "qty available", "quantity"],
link: ["link", "url", "source"],
asinLink: ["asin link", "amazon link", "asin url"],
sourceUrl: ["source url", "supplier url", "source link"],
supplier: ["supplier", "vendor"],
promoCouponCode: [
"promo/coupon code",
"promo coupon code",
"coupon code",
"promo code",
],
notes: ["notes", "note"],
leadDate: ["date", "lead date"],
} as const;
type ColumnKey = keyof typeof COLUMN_CANDIDATES;
type ColumnMap = Record<ColumnKey, string | undefined>;
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 columns = detectColumns(headers);
const asinColumn = columns.asin;
if (!asinColumn)
throw new Error(
`No ASIN column found. Available columns: ${headers.join(", ")}`,
);
logColumnDetection(headers, columns);
const knownCols = getKnownColumns(columns);
const products: ProductRecord[] = [];
for (const row of rows) {
const asin = parseAsin(row[asinColumn]);
if (!asin) continue;
const sourceUrl = getOptionalString(row, columns.sourceUrl);
const asinLink = getOptionalString(row, columns.asinLink);
const link = sourceUrl ?? asinLink ?? getOptionalString(row, columns.link);
const extra = getExtraFields(row, headers, knownCols);
const netProfitFromSheet = getOptionalNumber(row, columns.netProfit);
const roiFromSheet = getOptionalNumber(row, columns.roi);
products.push({
asin,
name: getOptionalString(row, columns.name) ?? "",
unitCost: getOptionalNumber(row, columns.cost) ?? 0,
brand: getOptionalString(row, columns.brand),
category: getOptionalString(row, columns.category),
amazonRank: getOptionalNumber(row, columns.amazonRank),
avgPrice90FromSheet: getOptionalNumber(row, columns.avgPrice90),
sellingPriceFromSheet: getOptionalNumber(row, columns.sellingPrice),
fbaNet: getOptionalNumber(row, columns.fbaNet),
grossProfit: getOptionalNumber(row, columns.grossProfit) ?? netProfitFromSheet,
grossProfitPct:
getOptionalNumber(row, columns.grossProfitPct) ?? roiFromSheet,
netProfitFromSheet,
roiFromSheet,
moq: getOptionalNumber(row, columns.moq),
moqCost: getOptionalNumber(row, columns.moqCost),
totalQtyAvail: getOptionalNumber(row, columns.totalQty),
link,
asinLink,
sourceUrl,
supplier: getOptionalString(row, columns.supplier),
promoCouponCode: getOptionalString(row, columns.promoCouponCode),
notes: getOptionalString(row, columns.notes),
leadDate: getOptionalString(row, columns.leadDate),
...extra,
});
}
console.log(`Read ${products.length} valid products from ${filePath}`);
return products;
}
function detectColumns(headers: string[]): ColumnMap {
const columns = {} as ColumnMap;
for (const key of Object.keys(COLUMN_CANDIDATES) as ColumnKey[]) {
columns[key] = findColumn(headers, [...COLUMN_CANDIDATES[key]]);
}
return columns;
}
function logColumnDetection(headers: string[], columns: ColumnMap): void {
console.log(`Found columns: ${headers.join(", ")}`);
console.log(
`Detected columns -> ASIN: ${columns.asin ?? "n/a"}, Name: ${columns.name ?? "n/a"}, Cost: ${columns.cost ?? "n/a"}, 90d Avg: ${columns.avgPrice90 ?? "n/a"}, Selling Price: ${columns.sellingPrice ?? "n/a"}, Net Profit: ${columns.netProfit ?? columns.grossProfit ?? "n/a"}, ROI: ${columns.roi ?? columns.grossProfitPct ?? "n/a"}, Source URL: ${columns.sourceUrl ?? "n/a"}, ASIN Link: ${columns.asinLink ?? "n/a"}`,
);
}
function getKnownColumns(columns: ColumnMap): Set<string> {
return new Set(Object.values(columns).filter((column): column is string => !!column));
}
function parseAsin(value: unknown): string | undefined {
const asin = String(value ?? "")
.trim()
.toUpperCase();
if (!asin || !ASIN_REGEX.test(asin)) {
console.warn(`Skipping invalid ASIN: "${asin}"`);
return undefined;
}
return asin;
}
function getOptionalString(
row: Record<string, unknown>,
column: string | undefined,
): string | undefined {
if (!column) return undefined;
return normalizeOptionalString(row[column]);
}
function getOptionalNumber(
row: Record<string, unknown>,
column: string | undefined,
): number | undefined {
if (!column) return undefined;
return parseOptionalNumber(row[column]);
}
function getExtraFields(
row: Record<string, unknown>,
headers: string[],
knownCols: Set<string>,
): Record<string, unknown> {
const extra: Record<string, unknown> = {};
for (const header of headers) {
if (!knownCols.has(header)) extra[header] = row[header];
}
return extra;
}
function findColumn(
headers: string[],
candidates: string[],
): string | undefined {
const normalizedCandidates = new Set(candidates.map(normalizeHeader));
for (const header of headers) {
if (normalizedCandidates.has(normalizeHeader(header))) {
return header;
}
}
return undefined;
}
function normalizeHeader(value: string): string {
return value
.toLowerCase()
.trim()
.replace(/%/g, " pct ")
.replace(/\$/g, " usd ")
.replace(/[^a-z0-9]/g, "");
}
function normalizeOptionalString(value: unknown): string | undefined {
if (value == null) return undefined;
const s = String(value).trim();
return s.length > 0 ? s : undefined;
}
function parseOptionalNumber(value: unknown): number | undefined {
if (value == null || value === "") return undefined;
const cleaned = String(value).trim().replace(/[$,%]/g, "").replace(/,/g, "");
const parsed = Number(cleaned);
return Number.isFinite(parsed) ? parsed : undefined;
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,48 +1,48 @@
import { testSpApiConnectivity, testSpApiSellability } from "./sp-api.ts";
function parseArgs(): { asin?: string; sellabilityMode: boolean } {
const args = process.argv.slice(2);
const sellabilityMode = args.includes("--sellability");
const asin = args.find((arg) => !arg.startsWith("--"));
return { asin, sellabilityMode };
}
async function main() {
const { asin, sellabilityMode } = parseArgs();
console.log("Running SP-API connectivity test...");
if (sellabilityMode) {
if (!asin) {
console.error("Usage: bun run src/sp-test.ts --sellability <ASIN>");
process.exit(1);
}
console.log(`Running sellability check for ASIN: ${asin}`);
const sellability = await testSpApiSellability(asin);
if (!sellability.ok) {
console.error(`SP-API sellability test failed: ${sellability.message}`);
process.exit(1);
}
console.log(`SP-API sellability test passed: ${sellability.message}`);
return;
}
if (asin) {
console.log(`Including pricing connectivity check for ASIN: ${asin}`);
}
const result = await testSpApiConnectivity(asin);
if (!result.ok) {
console.error(`SP-API test failed: ${result.message}`);
process.exit(1);
}
console.log(`SP-API test passed: ${result.message}`);
}
main().catch((err) => {
console.error(`SP-API test crashed: ${String(err)}`);
process.exit(1);
});
import { testSpApiConnectivity, testSpApiSellability } from "./sp-api.ts";
function parseArgs(): { asin?: string; sellabilityMode: boolean } {
const args = process.argv.slice(2);
const sellabilityMode = args.includes("--sellability");
const asin = args.find((arg) => !arg.startsWith("--"));
return { asin, sellabilityMode };
}
async function main() {
const { asin, sellabilityMode } = parseArgs();
console.log("Running SP-API connectivity test...");
if (sellabilityMode) {
if (!asin) {
console.error("Usage: bun run src/sp-test.ts --sellability <ASIN>");
process.exit(1);
}
console.log(`Running sellability check for ASIN: ${asin}`);
const sellability = await testSpApiSellability(asin);
if (!sellability.ok) {
console.error(`SP-API sellability test failed: ${sellability.message}`);
process.exit(1);
}
console.log(`SP-API sellability test passed: ${sellability.message}`);
return;
}
if (asin) {
console.log(`Including pricing connectivity check for ASIN: ${asin}`);
}
const result = await testSpApiConnectivity(asin);
if (!result.ok) {
console.error(`SP-API test failed: ${result.message}`);
process.exit(1);
}
console.log(`SP-API test passed: ${result.message}`);
}
main().catch((err) => {
console.error(`SP-API test crashed: ${String(err)}`);
process.exit(1);
});

View File

@@ -1,75 +1,75 @@
export interface ProductRecord {
asin: string;
name: string;
unitCost: number;
brand?: string;
category?: string;
amazonRank?: number;
avgPrice90FromSheet?: number;
sellingPriceFromSheet?: number;
fbaNet?: number;
grossProfit?: number;
grossProfitPct?: number;
netProfitFromSheet?: number;
roiFromSheet?: number;
moq?: number;
moqCost?: number;
totalQtyAvail?: number;
link?: string;
asinLink?: string;
sourceUrl?: string;
supplier?: string;
promoCouponCode?: string;
notes?: string;
leadDate?: 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 type SellabilityInfo = {
canSell: boolean | null;
sellabilityStatus: "available" | "restricted" | "not_available" | "unknown";
sellabilityReason?: string;
};
export interface SpApiData extends SellabilityInfo {
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;
}
export interface ProductRecord {
asin: string;
name: string;
unitCost: number;
brand?: string;
category?: string;
amazonRank?: number;
avgPrice90FromSheet?: number;
sellingPriceFromSheet?: number;
fbaNet?: number;
grossProfit?: number;
grossProfitPct?: number;
netProfitFromSheet?: number;
roiFromSheet?: number;
moq?: number;
moqCost?: number;
totalQtyAvail?: number;
link?: string;
asinLink?: string;
sourceUrl?: string;
supplier?: string;
promoCouponCode?: string;
notes?: string;
leadDate?: 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 type SellabilityInfo = {
canSell: boolean | null;
sellabilityStatus: "available" | "restricted" | "not_available" | "unknown";
sellabilityReason?: string;
};
export interface SpApiData extends SellabilityInfo {
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;
}

View File

@@ -1,159 +1,159 @@
import * as XLSX from "xlsx";
import type { AnalysisResult } from "./types.ts";
function buildRow(r: AnalysisResult) {
const price =
r.product.keepa?.currentPrice ??
r.product.record.sellingPriceFromSheet ??
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 ?? "",
"Avg Price 90d (sheet)": r.product.record.avgPrice90FromSheet ?? "",
"Selling Price (sheet)": r.product.record.sellingPriceFromSheet ?? "",
"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 ?? "",
"Net Profit (sheet)": r.product.record.netProfitFromSheet ?? "",
"ROI (sheet)": r.product.record.roiFromSheet ?? "",
MOQ: r.product.record.moq ?? "",
"MOQ Cost": r.product.record.moqCost ?? "",
"Qty Available": r.product.record.totalQtyAvail ?? "",
Supplier: r.product.record.supplier ?? "",
"Source URL": r.product.record.sourceUrl ?? "",
"ASIN Link": r.product.record.asinLink ?? "",
"Promo/Coupon Code": r.product.record.promoCouponCode ?? "",
Notes: r.product.record.notes ?? "",
"Lead Date": r.product.record.leadDate ?? "",
"FBA Fee": r.product.spApi.fbaFee,
"FBM Fee": r.product.spApi.fbmFee,
"Referral %": r.product.spApi.referralFeePercent,
"Can Sell":
r.product.spApi.canSell == null
? "unknown"
: r.product.spApi.canSell
? "yes"
: "no",
Sellability: r.product.spApi.sellabilityStatus,
"Sellability Reason": r.product.spApi.sellabilityReason ?? "",
Verdict: r.verdict.verdict,
Confidence: r.verdict.confidence,
Reasoning: r.verdict.reasoning,
};
}
export function printResults(results: AnalysisResult[]): void {
const rows = results
.filter((r) => r.verdict.verdict === "FBA" || r.verdict.verdict === "FBM")
.map((r) => {
const sellingPrice =
r.product.keepa?.currentPrice ??
r.product.record.sellingPriceFromSheet ??
r.product.spApi.estimatedSalePrice;
const referralFee =
sellingPrice != null
? sellingPrice * (r.product.spApi.referralFeePercent / 100)
: null;
const fulfillmentFee =
r.verdict.verdict === "FBA"
? r.product.spApi.fbaFee
: r.product.spApi.fbmFee;
const netProfit =
sellingPrice != null
? Math.round(
(sellingPrice -
r.product.record.unitCost -
fulfillmentFee -
(referralFee ?? 0)) *
100,
) / 100
: "";
return {
ASIN: r.product.record.asin,
Name: r.product.record.name.slice(0, 40),
Category: String(
r.product.record.category ??
r.product.keepa?.categoryTree?.join(" > ") ??
"",
).slice(0, 20),
"Unit Cost": r.product.record.unitCost,
"Selling Price": sellingPrice ?? "",
"Net Profit": netProfit,
"Monthly Sold": r.product.keepa?.monthlySold ?? "",
"Sold 90 Day": r.product.keepa?.salesRankDrops90 ?? "",
"Can Sell":
r.product.spApi.canSell == null
? "unknown"
: r.product.spApi.canSell
? "yes"
: "no",
Sellability: r.product.spApi.sellabilityStatus,
"Sellability Reason": String(
r.product.spApi.sellabilityReason ?? "",
).slice(0, 60),
Confidence: r.verdict.confidence,
Reasoning: r.verdict.reasoning.slice(0, 60),
};
});
console.log("\n=== Analysis Results ===\n");
if (rows.length === 0) {
console.log("No FBA/FBM leads found.");
} else {
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,
Available: results.filter(
(r) => r.product.spApi.sellabilityStatus === "available",
).length,
Restricted: results.filter(
(r) => r.product.spApi.sellabilityStatus === "restricted",
).length,
NotAvailable: results.filter(
(r) => r.product.spApi.sellabilityStatus === "not_available",
).length,
Unknown: results.filter(
(r) => r.product.spApi.sellabilityStatus === "unknown",
).length,
};
console.log(
`\nSummary: ${summary.FBA} FBA | ${summary.FBM} FBM | ${summary.SKIP} SKIP out of ${results.length} products\n`,
);
console.log(
`Sellability: ${summary.Available} available | ${summary.Restricted} restricted | ${summary.NotAvailable} not_available | ${summary.Unknown} unknown\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}`);
}
import * as XLSX from "xlsx";
import type { AnalysisResult } from "./types.ts";
function buildRow(r: AnalysisResult) {
const price =
r.product.keepa?.currentPrice ??
r.product.record.sellingPriceFromSheet ??
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 ?? "",
"Avg Price 90d (sheet)": r.product.record.avgPrice90FromSheet ?? "",
"Selling Price (sheet)": r.product.record.sellingPriceFromSheet ?? "",
"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 ?? "",
"Net Profit (sheet)": r.product.record.netProfitFromSheet ?? "",
"ROI (sheet)": r.product.record.roiFromSheet ?? "",
MOQ: r.product.record.moq ?? "",
"MOQ Cost": r.product.record.moqCost ?? "",
"Qty Available": r.product.record.totalQtyAvail ?? "",
Supplier: r.product.record.supplier ?? "",
"Source URL": r.product.record.sourceUrl ?? "",
"ASIN Link": r.product.record.asinLink ?? "",
"Promo/Coupon Code": r.product.record.promoCouponCode ?? "",
Notes: r.product.record.notes ?? "",
"Lead Date": r.product.record.leadDate ?? "",
"FBA Fee": r.product.spApi.fbaFee,
"FBM Fee": r.product.spApi.fbmFee,
"Referral %": r.product.spApi.referralFeePercent,
"Can Sell":
r.product.spApi.canSell == null
? "unknown"
: r.product.spApi.canSell
? "yes"
: "no",
Sellability: r.product.spApi.sellabilityStatus,
"Sellability Reason": r.product.spApi.sellabilityReason ?? "",
Verdict: r.verdict.verdict,
Confidence: r.verdict.confidence,
Reasoning: r.verdict.reasoning,
};
}
export function printResults(results: AnalysisResult[]): void {
const rows = results
.filter((r) => r.verdict.verdict === "FBA" || r.verdict.verdict === "FBM")
.map((r) => {
const sellingPrice =
r.product.keepa?.currentPrice ??
r.product.record.sellingPriceFromSheet ??
r.product.spApi.estimatedSalePrice;
const referralFee =
sellingPrice != null
? sellingPrice * (r.product.spApi.referralFeePercent / 100)
: null;
const fulfillmentFee =
r.verdict.verdict === "FBA"
? r.product.spApi.fbaFee
: r.product.spApi.fbmFee;
const netProfit =
sellingPrice != null
? Math.round(
(sellingPrice -
r.product.record.unitCost -
fulfillmentFee -
(referralFee ?? 0)) *
100,
) / 100
: "";
return {
ASIN: r.product.record.asin,
Name: r.product.record.name.slice(0, 40),
Category: String(
r.product.record.category ??
r.product.keepa?.categoryTree?.join(" > ") ??
"",
).slice(0, 20),
"Unit Cost": r.product.record.unitCost,
"Selling Price": sellingPrice ?? "",
"Net Profit": netProfit,
"Monthly Sold": r.product.keepa?.monthlySold ?? "",
"Sold 90 Day": r.product.keepa?.salesRankDrops90 ?? "",
"Can Sell":
r.product.spApi.canSell == null
? "unknown"
: r.product.spApi.canSell
? "yes"
: "no",
Sellability: r.product.spApi.sellabilityStatus,
"Sellability Reason": String(
r.product.spApi.sellabilityReason ?? "",
).slice(0, 60),
Confidence: r.verdict.confidence,
Reasoning: r.verdict.reasoning.slice(0, 60),
};
});
console.log("\n=== Analysis Results ===\n");
if (rows.length === 0) {
console.log("No FBA/FBM leads found.");
} else {
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,
Available: results.filter(
(r) => r.product.spApi.sellabilityStatus === "available",
).length,
Restricted: results.filter(
(r) => r.product.spApi.sellabilityStatus === "restricted",
).length,
NotAvailable: results.filter(
(r) => r.product.spApi.sellabilityStatus === "not_available",
).length,
Unknown: results.filter(
(r) => r.product.spApi.sellabilityStatus === "unknown",
).length,
};
console.log(
`\nSummary: ${summary.FBA} FBA | ${summary.FBM} FBM | ${summary.SKIP} SKIP out of ${results.length} products\n`,
);
console.log(
`Sellability: ${summary.Available} available | ${summary.Restricted} restricted | ${summary.NotAvailable} not_available | ${summary.Unknown} unknown\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}`);
}

View File

@@ -1,29 +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
}
}
{
"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
}
}