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": { "permissions": {
"allow": [ "allow": [
"WebSearch", "WebSearch",
"Bash(bun init:*)", "Bash(bun init:*)",
"Bash(bunx tsc:*)", "Bash(bunx tsc:*)",
"Bash(bun -e ':*)" "Bash(bun -e ':*)"
] ]
} }
} }

View File

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

74
.gitignore vendored
View File

@@ -1,37 +1,37 @@
# dependencies (bun install) # dependencies (bun install)
node_modules node_modules
# output # output
out out
dist dist
*.tgz *.tgz
# code coverage # code coverage
coverage coverage
*.lcov *.lcov
# logs # logs
logs logs
_.log _.log
report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
# dotenv environment variable files # dotenv environment variable files
.env .env
.env.development.local .env.development.local
.env.test.local .env.test.local
.env.production.local .env.production.local
.env.local .env.local
# caches # caches
.eslintcache .eslintcache
.cache .cache
*.tsbuildinfo *.tsbuildinfo
# IntelliJ based IDEs # IntelliJ based IDEs
.idea .idea
# Finder (MacOS) folder config # Finder (MacOS) folder config
.DS_Store .DS_Store
*.xlsx *.xlsx
*.csv *.csv

212
CLAUDE.md
View File

@@ -1,106 +1,106 @@
Default to using Bun instead of Node.js. Default to using Bun instead of Node.js.
- Use `bun <file>` instead of `node <file>` or `ts-node <file>` - Use `bun <file>` instead of `node <file>` or `ts-node <file>`
- Use `bun test` instead of `jest` or `vitest` - Use `bun test` instead of `jest` or `vitest`
- Use `bun build <file.html|file.ts|file.css>` instead of `webpack` or `esbuild` - 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 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 `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>` - Use `bunx <package> <command>` instead of `npx <package> <command>`
- Bun automatically loads .env, so don't use dotenv. - Bun automatically loads .env, so don't use dotenv.
## APIs ## APIs
- `Bun.serve()` supports WebSockets, HTTPS, and routes. Don't use `express`. - `Bun.serve()` supports WebSockets, HTTPS, and routes. Don't use `express`.
- `bun:sqlite` for SQLite. Don't use `better-sqlite3`. - `bun:sqlite` for SQLite. Don't use `better-sqlite3`.
- `Bun.redis` for Redis. Don't use `ioredis`. - `Bun.redis` for Redis. Don't use `ioredis`.
- `Bun.sql` for Postgres. Don't use `pg` or `postgres.js`. - `Bun.sql` for Postgres. Don't use `pg` or `postgres.js`.
- `WebSocket` is built-in. Don't use `ws`. - `WebSocket` is built-in. Don't use `ws`.
- Prefer `Bun.file` over `node:fs`'s readFile/writeFile - Prefer `Bun.file` over `node:fs`'s readFile/writeFile
- Bun.$`ls` instead of execa. - Bun.$`ls` instead of execa.
## Testing ## Testing
Use `bun test` to run tests. Use `bun test` to run tests.
```ts#index.test.ts ```ts#index.test.ts
import { test, expect } from "bun:test"; import { test, expect } from "bun:test";
test("hello world", () => { test("hello world", () => {
expect(1).toBe(1); expect(1).toBe(1);
}); });
``` ```
## Frontend ## Frontend
Use HTML imports with `Bun.serve()`. Don't use `vite`. HTML imports fully support React, CSS, Tailwind. Use HTML imports with `Bun.serve()`. Don't use `vite`. HTML imports fully support React, CSS, Tailwind.
Server: Server:
```ts#index.ts ```ts#index.ts
import index from "./index.html" import index from "./index.html"
Bun.serve({ Bun.serve({
routes: { routes: {
"/": index, "/": index,
"/api/users/:id": { "/api/users/:id": {
GET: (req) => { GET: (req) => {
return new Response(JSON.stringify({ id: req.params.id })); return new Response(JSON.stringify({ id: req.params.id }));
}, },
}, },
}, },
// optional websocket support // optional websocket support
websocket: { websocket: {
open: (ws) => { open: (ws) => {
ws.send("Hello, world!"); ws.send("Hello, world!");
}, },
message: (ws, message) => { message: (ws, message) => {
ws.send(message); ws.send(message);
}, },
close: (ws) => { close: (ws) => {
// handle close // handle close
} }
}, },
development: { development: {
hmr: true, hmr: true,
console: 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 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#index.html
<html> <html>
<body> <body>
<h1>Hello, world!</h1> <h1>Hello, world!</h1>
<script type="module" src="./frontend.tsx"></script> <script type="module" src="./frontend.tsx"></script>
</body> </body>
</html> </html>
``` ```
With the following `frontend.tsx`: With the following `frontend.tsx`:
```tsx#frontend.tsx ```tsx#frontend.tsx
import React from "react"; import React from "react";
import { createRoot } from "react-dom/client"; import { createRoot } from "react-dom/client";
// import .css files directly and it works // import .css files directly and it works
import './index.css'; import './index.css';
const root = createRoot(document.body); const root = createRoot(document.body);
export default function Frontend() { export default function Frontend() {
return <h1>Hello, world!</h1>; return <h1>Hello, world!</h1>;
} }
root.render(<Frontend />); root.render(<Frontend />);
``` ```
Then, run index.ts Then, run index.ts
```sh ```sh
bun --hot ./index.ts bun --hot ./index.ts
``` ```
For more information, read the Bun API docs in `node_modules/bun-types/docs/**.mdx`. 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 # 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. 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 ## Requirements
- [Bun](https://bun.com) runtime - [Bun](https://bun.com) runtime
- Redis (local or Docker) - Redis (local or Docker)
- [LM Studio](https://lmstudio.ai) running locally with a model loaded - [LM Studio](https://lmstudio.ai) running locally with a model loaded
- Keepa API key ([keepa.com](https://keepa.com)) - Keepa API key ([keepa.com](https://keepa.com))
- Amazon SP-API private app credentials (LWA + refresh token + IAM) - Amazon SP-API private app credentials (LWA + refresh token + IAM)
## Setup ## Setup
```bash ```bash
bun install bun install
cp .env.example .env cp .env.example .env
# Edit .env and set your KEEPA_API_KEY and SP-API credentials # Edit .env and set your KEEPA_API_KEY and SP-API credentials
``` ```
## Usage ## Usage
```bash ```bash
bun run src/index.ts <input.csv|xlsx> [--out results.csv] bun run src/index.ts <input.csv|xlsx> [--out results.csv]
``` ```
Examples: Examples:
```bash ```bash
bun run src/index.ts leads.xlsx bun run src/index.ts leads.xlsx
bun run src/index.ts leads.csv --out results.xlsx bun run src/index.ts leads.csv --out results.xlsx
``` ```
Large-file behavior: Large-file behavior:
- If the input has more than 50 products, processing is done in chunks of 50. - 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`, ... - 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. - 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: Quick SP-API connectivity tests:
```bash ```bash
bun run src/sp-test.ts # Auth + sellers endpoint 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 B07SN9BHVV # Auth + sellers endpoint + pricing offer check
bun run src/sp-test.ts --sellability B07SN9BHVV # Standalone sellability check bun run src/sp-test.ts --sellability B07SN9BHVV # Standalone sellability check
``` ```
## Input file format ## Input file format
Accepts `.csv` or `.xlsx` files. Column names are matched case-insensitively. Required column: Accepts `.csv` or `.xlsx` files. Column names are matched case-insensitively. Required column:
| Column | Aliases | | Column | Aliases |
| ------ | ------- | | ------ | ------- |
| ASIN | — | | ASIN | — |
Optional but recommended: Optional but recommended:
| Column | Aliases | | Column | Aliases |
| --------------- | ---------------------------- | | --------------- | ---------------------------- |
| Product Name | Name, Title | | Product Name | Name, Title |
| Unit Cost | Cost, Price, Buy Cost | | Unit Cost | Cost, Price, Buy Cost |
| Brand | — | | Brand | — |
| Category | — | | Category | — |
| Amazon Rank | Amazon Rank, BSR, Sales Rank | | Amazon Rank | Amazon Rank, BSR, Sales Rank |
| FBA NET | — | | FBA NET | — |
| Gross Profit $ | Gross Profit | | Gross Profit $ | Gross Profit |
| Gross Profit % | — | | Gross Profit % | — |
| MOQ | Min Order Qty | | MOQ | Min Order Qty |
| MOQ Cost | — | | MOQ Cost | — |
| Total Qty Avail | Qty Available | | Total Qty Avail | Qty Available |
| Link | URL, Source | | Link | URL, Source |
Lead-list format aliases (supported): Lead-list format aliases (supported):
| Column | Aliases | | Column | Aliases |
| ----------------- | ------------------------------------------ | | ----------------- | ------------------------------------------ |
| Name | Product Name, Title, Product Title | | Name | Product Name, Title, Product Title |
| ASIN Link | ASIN URL, Amazon Link | | ASIN Link | ASIN URL, Amazon Link |
| Source URL | Source Link, Supplier URL | | Source URL | Source Link, Supplier URL |
| 90 Day Average | 90-day Average, Avg Price 90d, 90d Average | | 90 Day Average | 90-day Average, Avg Price 90d, 90d Average |
| Cost | Unit Cost, Buy Cost, Price | | Cost | Unit Cost, Buy Cost, Price |
| Selling Price | Sale Price, Sell Price | | Selling Price | Sale Price, Sell Price |
| Net Profit | Gross Profit | | Net Profit | Gross Profit |
| ROI | Gross Profit %, Return on Investment | | ROI | Gross Profit %, Return on Investment |
| Supplier | Vendor | | Supplier | Vendor |
| Promo/Coupon Code | Promo Code, Coupon Code | | Promo/Coupon Code | Promo Code, Coupon Code |
| Notes | Note | | Notes | Note |
| Date | Lead Date | | Date | Lead Date |
Numeric parsing accepts plain numbers as well as formatted values like `$12.50`, `1,209.60`, and `27.5%`. Numeric parsing accepts plain numbers as well as formatted values like `$12.50`, `1,209.60`, and `27.5%`.
## Pipeline ## Pipeline
1. **Read** — parse input file, validate ASINs 1. **Read** — parse input file, validate ASINs
2. **Cache check** — look up each ASIN in Redis (24h TTL by default) 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) 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) 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 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 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 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 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 ## 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 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 ## Environment variables
| Variable | Default | Description | | Variable | Default | Description |
| ----------------------- | -------------------------- | ----------------------------------------------------------------------- | | ----------------------- | -------------------------- | ----------------------------------------------------------------------- |
| `KEEPA_API_KEY` | — | **Required.** Keepa API key | | `KEEPA_API_KEY` | — | **Required.** Keepa API key |
| `SP_API_CLIENT_ID` | — | LWA app client id from Solution Provider Portal | | `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_CLIENT_SECRET` | — | LWA app client secret from Solution Provider Portal |
| `SP_API_REFRESH_TOKEN` | — | Refresh token from self-authorization | | `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_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_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_SELLER_ID` | — | Seller ID used for listing restrictions eligibility checks |
| `SP_API_USE_SANDBOX` | `false` | Enable SP-API sandbox mode (`true`/`false`) | | `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_ACCESS_KEY_ID` | — | AWS credentials for SigV4 signing (required in most private app setups) |
| `AWS_SECRET_ACCESS_KEY` | — | AWS credentials for SigV4 signing | | `AWS_SECRET_ACCESS_KEY` | — | AWS credentials for SigV4 signing |
| `AWS_SESSION_TOKEN` | — | Optional session token when using STS credentials | | `AWS_SESSION_TOKEN` | — | Optional session token when using STS credentials |
| `REDIS_URL` | `redis://localhost:6379` | Redis connection URL | | `REDIS_URL` | `redis://localhost:6379` | Redis connection URL |
| `LLM_URL` | `http://localhost:1234/v1` | LM Studio API base URL | | `LLM_URL` | `http://localhost:1234/v1` | LM Studio API base URL |
| `LLM_MODEL` | `default` | Model name to pass to LM Studio | | `LLM_MODEL` | `default` | Model name to pass to LM Studio |
| `CACHE_TTL` | `86400` | Redis cache TTL in seconds | | `CACHE_TTL` | `86400` | Redis cache TTL in seconds |
## Notes ## 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. - **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. - **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. - **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. - **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. - **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. - **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. - **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", "name": "asin-check",
"module": "src/index.ts", "module": "src/index.ts",
"type": "module", "type": "module",
"private": true, "private": true,
"devDependencies": { "devDependencies": {
"@types/bun": "latest" "@types/bun": "latest"
}, },
"peerDependencies": { "peerDependencies": {
"typescript": "^5" "typescript": "^5"
}, },
"dependencies": { "dependencies": {
"amazon-sp-api": "^1.2.1", "amazon-sp-api": "^1.2.1",
"ioredis": "^5.10.1", "ioredis": "^5.10.1",
"xlsx": "^0.18.5" "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 Redis from "ioredis";
import { config } from "./config.ts"; import { config } from "./config.ts";
import type { EnrichedProduct } from "./types.ts"; import type { EnrichedProduct } from "./types.ts";
let redis: Redis | null = null; let redis: Redis | null = null;
let disabled = false; let disabled = false;
export async function connectCache(): Promise<void> { export async function connectCache(): Promise<void> {
if (disabled) return; if (disabled) return;
try { try {
redis = new Redis(config.redisUrl, { redis = new Redis(config.redisUrl, {
maxRetriesPerRequest: 1, maxRetriesPerRequest: 1,
connectTimeout: 3000, connectTimeout: 3000,
lazyConnect: true, lazyConnect: true,
retryStrategy: () => null, retryStrategy: () => null,
reconnectOnError: () => false, reconnectOnError: () => false,
}); });
// Swallow connection-level errors after we intentionally disable cache. // Swallow connection-level errors after we intentionally disable cache.
redis.on("error", () => { redis.on("error", () => {
// no-op // no-op
}); });
await redis.connect(); await redis.connect();
console.log("Redis connected"); console.log("Redis connected");
} catch (err) { } catch (err) {
console.warn(`Redis unavailable, running without cache: ${err}`); console.warn(`Redis unavailable, running without cache: ${err}`);
if (redis) { if (redis) {
redis.disconnect(); redis.disconnect();
} }
redis = null; redis = null;
disabled = true; disabled = true;
} }
} }
export async function getCache(asin: string): Promise<EnrichedProduct | null> { export async function getCache(asin: string): Promise<EnrichedProduct | null> {
if (!redis) return null; if (!redis) return null;
try { try {
const data = await redis.get(`asin:${asin}`); const data = await redis.get(`asin:${asin}`);
return data ? JSON.parse(data) : null; return data ? JSON.parse(data) : null;
} catch { } catch {
return null; return null;
} }
} }
export async function setCache( export async function setCache(
asin: string, asin: string,
data: EnrichedProduct, data: EnrichedProduct,
): Promise<void> { ): Promise<void> {
if (!redis) return; if (!redis) return;
try { try {
await redis.set( await redis.set(
`asin:${asin}`, `asin:${asin}`,
JSON.stringify(data), JSON.stringify(data),
"EX", "EX",
config.cacheTtl, config.cacheTtl,
); );
} catch { } catch {
// Non-critical, continue without caching // Non-critical, continue without caching
} }
} }
export async function disconnectCache(): Promise<void> { export async function disconnectCache(): Promise<void> {
if (redis) { if (redis) {
await redis.quit(); await redis.quit();
redis = null; redis = null;
} }
} }

View File

@@ -1,34 +1,34 @@
function required(key: string): string { function required(key: string): string {
const val = Bun.env[key]; const val = Bun.env[key];
if (!val) throw new Error(`Missing required env var: ${key}`); if (!val) throw new Error(`Missing required env var: ${key}`);
return val; return val;
} }
function optional(key: string, fallback: string): string { function optional(key: string, fallback: string): string {
return Bun.env[key] || fallback; return Bun.env[key] || fallback;
} }
function optionalBoolean(key: string, fallback: boolean): boolean { function optionalBoolean(key: string, fallback: boolean): boolean {
const raw = Bun.env[key]; const raw = Bun.env[key];
if (!raw) return fallback; if (!raw) return fallback;
const value = raw.trim().toLowerCase(); const value = raw.trim().toLowerCase();
return value === "1" || value === "true" || value === "yes"; return value === "1" || value === "true" || value === "yes";
} }
export const config = { export const config = {
keepaApiKey: required("KEEPA_API_KEY"), keepaApiKey: required("KEEPA_API_KEY"),
redisUrl: optional("REDIS_URL", "redis://localhost:6379"), redisUrl: optional("REDIS_URL", "redis://localhost:6379"),
llmUrl: optional("LLM_URL", "http://localhost:1234/v1"), llmUrl: optional("LLM_URL", "http://localhost:1234/v1"),
llmModel: optional("LLM_MODEL", "default"), llmModel: optional("LLM_MODEL", "default"),
cacheTtl: parseInt(optional("CACHE_TTL", "86400"), 10), cacheTtl: parseInt(optional("CACHE_TTL", "86400"), 10),
spApiClientId: Bun.env.SP_API_CLIENT_ID, spApiClientId: Bun.env.SP_API_CLIENT_ID,
spApiClientSecret: Bun.env.SP_API_CLIENT_SECRET, spApiClientSecret: Bun.env.SP_API_CLIENT_SECRET,
spApiRefreshToken: Bun.env.SP_API_REFRESH_TOKEN, spApiRefreshToken: Bun.env.SP_API_REFRESH_TOKEN,
spApiRegion: optional("SP_API_REGION", "na"), spApiRegion: optional("SP_API_REGION", "na"),
spApiMarketplaceId: optional("SP_API_MARKETPLACE_ID", "ATVPDKIKX0DER"), spApiMarketplaceId: optional("SP_API_MARKETPLACE_ID", "ATVPDKIKX0DER"),
spApiSellerId: Bun.env.SP_API_SELLER_ID, spApiSellerId: Bun.env.SP_API_SELLER_ID,
spApiUseSandbox: optionalBoolean("SP_API_USE_SANDBOX", false), spApiUseSandbox: optionalBoolean("SP_API_USE_SANDBOX", false),
awsAccessKeyId: Bun.env.AWS_ACCESS_KEY_ID, awsAccessKeyId: Bun.env.AWS_ACCESS_KEY_ID,
awsSecretAccessKey: Bun.env.AWS_SECRET_ACCESS_KEY, awsSecretAccessKey: Bun.env.AWS_SECRET_ACCESS_KEY,
awsSessionToken: Bun.env.AWS_SESSION_TOKEN, awsSessionToken: Bun.env.AWS_SESSION_TOKEN,
} as const; } as const;

View File

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

View File

@@ -1,141 +1,141 @@
import { config } from "./config.ts"; import { config } from "./config.ts";
import type { KeepaData } from "./types.ts"; import type { KeepaData } from "./types.ts";
const KEEPA_BASE = "https://api.keepa.com"; const KEEPA_BASE = "https://api.keepa.com";
const MAX_ASINS_PER_REQUEST = 100; const MAX_ASINS_PER_REQUEST = 100;
// Token-based rate limiting: Keepa Pro = 1 token/min regeneration. // Token-based rate limiting: Keepa Pro = 1 token/min regeneration.
// Each product request costs 1 token regardless of ASIN count (up to 100). // 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. // The API response includes tokensLeft and refillRate — we use those to pace.
let tokensLeft = 1; // Conservative start; updated from API response let tokensLeft = 1; // Conservative start; updated from API response
let refillRate = 1; // tokens per minute, updated from API response let refillRate = 1; // tokens per minute, updated from API response
let lastRequestTime = 0; let lastRequestTime = 0;
async function waitForToken(): Promise<void> { async function waitForToken(): Promise<void> {
if (tokensLeft > 0) return; if (tokensLeft > 0) return;
const elapsed = (Date.now() - lastRequestTime) / 60_000; // minutes const elapsed = (Date.now() - lastRequestTime) / 60_000; // minutes
const regenerated = Math.floor(elapsed * refillRate); const regenerated = Math.floor(elapsed * refillRate);
if (regenerated > 0) { if (regenerated > 0) {
tokensLeft += regenerated; tokensLeft += regenerated;
return; return;
} }
// Wait until we regenerate at least 1 token // Wait until we regenerate at least 1 token
const waitMs = const waitMs =
Math.ceil((1 / refillRate) * 60_000) - (Date.now() - lastRequestTime); Math.ceil((1 / refillRate) * 60_000) - (Date.now() - lastRequestTime);
if (waitMs > 0) { if (waitMs > 0) {
console.log( console.log(
`Keepa tokens exhausted. Waiting ${Math.ceil(waitMs / 1000)}s for token regeneration...`, `Keepa tokens exhausted. Waiting ${Math.ceil(waitMs / 1000)}s for token regeneration...`,
); );
await new Promise((r) => setTimeout(r, waitMs)); await new Promise((r) => setTimeout(r, waitMs));
} }
tokensLeft = 1; tokensLeft = 1;
} }
export async function fetchKeepaDataBatch( export async function fetchKeepaDataBatch(
asins: string[], asins: string[],
): Promise<Map<string, KeepaData>> { ): Promise<Map<string, KeepaData>> {
const results = new Map<string, KeepaData>(); const results = new Map<string, KeepaData>();
// Split into chunks of MAX_ASINS_PER_REQUEST // Split into chunks of MAX_ASINS_PER_REQUEST
for (let i = 0; i < asins.length; i += 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); const chunk = asins.slice(i, i + MAX_ASINS_PER_REQUEST);
await waitForToken(); await waitForToken();
const asinParam = chunk.join(","); const asinParam = chunk.join(",");
const url = `${KEEPA_BASE}/product?key=${config.keepaApiKey}&domain=1&asin=${asinParam}&stats=90`; const url = `${KEEPA_BASE}/product?key=${config.keepaApiKey}&domain=1&asin=${asinParam}&stats=90`;
console.log( console.log(
`Keepa: fetching ${chunk.length} ASINs (tokens left: ${tokensLeft})...`, `Keepa: fetching ${chunk.length} ASINs (tokens left: ${tokensLeft})...`,
); );
const res = await fetch(url); const res = await fetch(url);
lastRequestTime = Date.now(); lastRequestTime = Date.now();
if (!res.ok) { if (!res.ok) {
const text = await res.text(); const text = await res.text();
throw new Error(`Keepa API error ${res.status}: ${text}`); throw new Error(`Keepa API error ${res.status}: ${text}`);
} }
const data = (await res.json()) as { const data = (await res.json()) as {
products?: Record<string, any>[]; products?: Record<string, any>[];
tokensLeft?: number; tokensLeft?: number;
refillRate?: number; refillRate?: number;
}; };
// Update token state from API response // Update token state from API response
if (data.tokensLeft != null) tokensLeft = data.tokensLeft; if (data.tokensLeft != null) tokensLeft = data.tokensLeft;
if (data.refillRate != null) refillRate = data.refillRate; if (data.refillRate != null) refillRate = data.refillRate;
console.log( console.log(
`Keepa: ${data.products?.length ?? 0} products returned, ${tokensLeft} tokens remaining (refill: ${refillRate}/min)`, `Keepa: ${data.products?.length ?? 0} products returned, ${tokensLeft} tokens remaining (refill: ${refillRate}/min)`,
); );
if (data.products) { if (data.products) {
for (const product of data.products) { for (const product of data.products) {
const asin = product.asin; const asin = product.asin;
if (!asin) continue; if (!asin) continue;
results.set(asin, parseKeepaProduct(product)); results.set(asin, parseKeepaProduct(product));
} }
} }
} }
return results; return results;
} }
function parseKeepaProduct(product: Record<string, any>): KeepaData { function parseKeepaProduct(product: Record<string, any>): KeepaData {
const stats = product.stats; const stats = product.stats;
const csv = product.csv; const csv = product.csv;
const salesRankDrops30 = pickKeepaNumber( const salesRankDrops30 = pickKeepaNumber(
product.salesRankDrops30, product.salesRankDrops30,
stats?.salesRankDrops30, stats?.salesRankDrops30,
); );
const salesRankDrops90 = const salesRankDrops90 =
pickKeepaNumber(product.salesRankDrops90, stats?.salesRankDrops90) ?? pickKeepaNumber(product.salesRankDrops90, stats?.salesRankDrops90) ??
(salesRankDrops30 != null ? salesRankDrops30 * 3 : null); (salesRankDrops30 != null ? salesRankDrops30 * 3 : null);
const monthlySold = const monthlySold =
pickKeepaNumber(product.monthlySold, stats?.monthlySold) ?? pickKeepaNumber(product.monthlySold, stats?.monthlySold) ??
salesRankDrops30; salesRankDrops30;
return { return {
currentPrice: extractCurrentPrice(csv), currentPrice: extractCurrentPrice(csv),
avgPrice90: stats?.avg?.[0] != null ? stats.avg[0] / 100 : null, avgPrice90: stats?.avg?.[0] != null ? stats.avg[0] / 100 : null,
minPrice90: stats?.min?.[0] != null ? stats.min[0] / 100 : null, minPrice90: stats?.min?.[0] != null ? stats.min[0] / 100 : null,
maxPrice90: stats?.max?.[0] != null ? stats.max[0] / 100 : null, maxPrice90: stats?.max?.[0] != null ? stats.max[0] / 100 : null,
salesRank: stats?.current?.[3] ?? null, salesRank: stats?.current?.[3] ?? null,
salesRankAvg90: stats?.avg?.[3] ?? null, salesRankAvg90: stats?.avg?.[3] ?? null,
salesRankDrops30, salesRankDrops30,
salesRankDrops90, salesRankDrops90,
sellerCount: stats?.current?.[11] ?? null, sellerCount: stats?.current?.[11] ?? null,
buyBoxSeller: product.buyBoxSellerId ?? null, buyBoxSeller: product.buyBoxSellerId ?? null,
buyBoxPrice: stats?.current?.[18] != null ? stats.current[18] / 100 : null, buyBoxPrice: stats?.current?.[18] != null ? stats.current[18] / 100 : null,
monthlySold, monthlySold,
categoryTree: categoryTree:
product.categoryTree?.map((c: { name: string }) => c.name) ?? [], product.categoryTree?.map((c: { name: string }) => c.name) ?? [],
}; };
} }
function pickKeepaNumber(...values: unknown[]): number | null { function pickKeepaNumber(...values: unknown[]): number | null {
for (const value of values) { for (const value of values) {
if (typeof value !== "number" || !Number.isFinite(value)) continue; if (typeof value !== "number" || !Number.isFinite(value)) continue;
// Keepa often uses -1 as "not available". // Keepa often uses -1 as "not available".
if (value < 0) continue; if (value < 0) continue;
return value; return value;
} }
return null; return null;
} }
function extractCurrentPrice(csv: number[][] | undefined): number | null { function extractCurrentPrice(csv: number[][] | undefined): number | null {
if (!csv) return null; if (!csv) return null;
// csv[0] = Amazon price history, csv[1] = Marketplace new price history // csv[0] = Amazon price history, csv[1] = Marketplace new price history
// Each is [time, price, time, price, ...] — last value is most recent // Each is [time, price, time, price, ...] — last value is most recent
for (const series of [csv[0], csv[1]]) { for (const series of [csv[0], csv[1]]) {
if (series && series.length >= 2) { if (series && series.length >= 2) {
const lastPrice = series[series.length - 1]!; const lastPrice = series[series.length - 1]!;
if (lastPrice > 0) return lastPrice / 100; if (lastPrice > 0) return lastPrice / 100;
} }
} }
return null; return null;
} }

View File

@@ -1,353 +1,353 @@
import { config } from "./config.ts"; import { config } from "./config.ts";
import type { EnrichedProduct, LlmVerdict } from "./types.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. 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: 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. 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. 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. 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. 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. 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. 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. 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. 8. **MOQ & Capital**: High MOQ with thin margins is risky.
9. **Supply Availability**: Total quantity available from supplier — low stock means limited runway. 9. **Supply Availability**: Total quantity available from supplier — low stock means limited runway.
10. **Seller Eligibility (critical)**: 10. **Seller Eligibility (critical)**:
- If sellerEligibility.status is "restricted" or "not_available", return verdict = "SKIP". - 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 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. - If canSell is false, return "SKIP" regardless of margin.
Decision policy: Decision policy:
- Do not recommend products that cannot be listed by this seller account. - Do not recommend products that cannot be listed by this seller account.
- Prioritize profitable + high-velocity + listable products. - Prioritize profitable + high-velocity + listable products.
- Use "SKIP" when data quality is poor or risk is high. - 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: 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": "..." }] [{ "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).`; 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( export async function analyzeProducts(
products: EnrichedProduct[], products: EnrichedProduct[],
): Promise<LlmVerdict[]> { ): Promise<LlmVerdict[]> {
try { try {
return await analyzeProductsInternal(products); return await analyzeProductsInternal(products);
} catch (err) { } catch (err) {
const msg = String(err); const msg = String(err);
if (products.length > 1 && msg.includes("Context size has been exceeded")) { if (products.length > 1 && msg.includes("Context size has been exceeded")) {
console.warn( console.warn(
`LLM context exceeded for batch of ${products.length}, retrying one product at a time...`, `LLM context exceeded for batch of ${products.length}, retrying one product at a time...`,
); );
const fallback: LlmVerdict[] = []; const fallback: LlmVerdict[] = [];
for (const product of products) { for (const product of products) {
try { try {
const single = await analyzeProductsInternal([product]); const single = await analyzeProductsInternal([product]);
fallback.push( fallback.push(
single[0] ?? { single[0] ?? {
asin: product.record.asin, asin: product.record.asin,
verdict: "SKIP", verdict: "SKIP",
confidence: 0, confidence: 0,
reasoning: "LLM returned empty verdict", reasoning: "LLM returned empty verdict",
}, },
); );
} catch { } catch {
fallback.push({ fallback.push({
asin: product.record.asin, asin: product.record.asin,
verdict: "SKIP", verdict: "SKIP",
confidence: 0, confidence: 0,
reasoning: "LLM context overflow on single-item fallback", reasoning: "LLM context overflow on single-item fallback",
}); });
} }
} }
return fallback; return fallback;
} }
throw err; throw err;
} }
} }
async function analyzeProductsInternal( async function analyzeProductsInternal(
products: EnrichedProduct[], products: EnrichedProduct[],
): Promise<LlmVerdict[]> { ): Promise<LlmVerdict[]> {
const productSummaries = products.map(summarizeForLlm); const productSummaries = products.map(summarizeForLlm);
const res = await fetch(`${config.llmUrl}/chat/completions`, { const res = await fetch(`${config.llmUrl}/chat/completions`, {
method: "POST", method: "POST",
headers: { headers: {
"Content-Type": "application/json", "Content-Type": "application/json",
Authorization: "Bearer lm-studio", Authorization: "Bearer lm-studio",
}, },
body: JSON.stringify({ body: JSON.stringify({
model: config.llmModel, model: config.llmModel,
messages: [ messages: [
{ role: "system", content: SYSTEM_PROMPT }, { role: "system", content: SYSTEM_PROMPT },
{ role: "user", content: JSON.stringify(productSummaries, null, 2) }, { role: "user", content: JSON.stringify(productSummaries, null, 2) },
], ],
temperature: 0.3, temperature: 0.3,
max_tokens: 2048, max_tokens: 2048,
}), }),
}); });
if (!res.ok) { if (!res.ok) {
throw new Error(`LLM API error ${res.status}: ${await res.text()}`); throw new Error(`LLM API error ${res.status}: ${await res.text()}`);
} }
const data = (await res.json()) as { const data = (await res.json()) as {
choices?: { message?: { content?: string } }[]; choices?: { message?: { content?: string } }[];
}; };
const content = data.choices?.[0]?.message?.content ?? ""; const content = data.choices?.[0]?.message?.content ?? "";
return parseVerdicts(content, products); return parseVerdicts(content, products);
} }
function summarizeForLlm(p: EnrichedProduct) { function summarizeForLlm(p: EnrichedProduct) {
const salePrice = const salePrice =
p.keepa?.currentPrice ?? p.keepa?.currentPrice ??
p.record.sellingPriceFromSheet ?? p.record.sellingPriceFromSheet ??
p.spApi.estimatedSalePrice; p.spApi.estimatedSalePrice;
const referralFee = salePrice * (p.spApi.referralFeePercent / 100); const referralFee = salePrice * (p.spApi.referralFeePercent / 100);
const fbaProfit = const fbaProfit =
salePrice - p.record.unitCost - p.spApi.fbaFee - referralFee; salePrice - p.record.unitCost - p.spApi.fbaFee - referralFee;
const fbmProfit = const fbmProfit =
salePrice - p.record.unitCost - p.spApi.fbmFee - referralFee; salePrice - p.record.unitCost - p.spApi.fbmFee - referralFee;
return { return {
asin: p.record.asin, asin: p.record.asin,
name: clampText(p.record.name, 80), name: clampText(p.record.name, 80),
brand: p.record.brand, brand: p.record.brand,
category: clampText( category: clampText(
p.record.category ?? p.keepa?.categoryTree?.join(" > "), p.record.category ?? p.keepa?.categoryTree?.join(" > "),
60, 60,
), ),
unitCost: p.record.unitCost, unitCost: p.record.unitCost,
currentPrice: salePrice, currentPrice: salePrice,
priceRange90d: p.keepa priceRange90d: p.keepa
? { ? {
min: p.keepa.minPrice90, min: p.keepa.minPrice90,
max: p.keepa.maxPrice90, max: p.keepa.maxPrice90,
avg: p.keepa.avgPrice90, avg: p.keepa.avgPrice90,
} }
: null, : null,
salesRank: p.keepa?.salesRank ?? p.record.amazonRank, salesRank: p.keepa?.salesRank ?? p.record.amazonRank,
salesRankAvg90d: p.keepa?.salesRankAvg90, salesRankAvg90d: p.keepa?.salesRankAvg90,
sellerCount: p.keepa?.sellerCount, sellerCount: p.keepa?.sellerCount,
salesVelocity: { salesVelocity: {
monthlySold: p.keepa?.monthlySold, monthlySold: p.keepa?.monthlySold,
salesRankDrops30: p.keepa?.salesRankDrops30, salesRankDrops30: p.keepa?.salesRankDrops30,
salesRankDrops90: p.keepa?.salesRankDrops90, salesRankDrops90: p.keepa?.salesRankDrops90,
}, },
spreadsheetEstimates: { spreadsheetEstimates: {
avgPrice90: p.record.avgPrice90FromSheet, avgPrice90: p.record.avgPrice90FromSheet,
sellingPrice: p.record.sellingPriceFromSheet, sellingPrice: p.record.sellingPriceFromSheet,
fbaNet: p.record.fbaNet, fbaNet: p.record.fbaNet,
grossProfit: p.record.grossProfit, grossProfit: p.record.grossProfit,
grossProfitPct: p.record.grossProfitPct, grossProfitPct: p.record.grossProfitPct,
netProfit: p.record.netProfitFromSheet, netProfit: p.record.netProfitFromSheet,
roi: p.record.roiFromSheet, roi: p.record.roiFromSheet,
}, },
supplier: clampText(p.record.supplier, 40), supplier: clampText(p.record.supplier, 40),
moq: p.record.moq, moq: p.record.moq,
moqCost: p.record.moqCost, moqCost: p.record.moqCost,
totalQtyAvail: p.record.totalQtyAvail, totalQtyAvail: p.record.totalQtyAvail,
fees: { fees: {
fbaFee: p.spApi.fbaFee, fbaFee: p.spApi.fbaFee,
fbmFee: p.spApi.fbmFee, fbmFee: p.spApi.fbmFee,
referralFeePercent: p.spApi.referralFeePercent, referralFeePercent: p.spApi.referralFeePercent,
referralFee: Math.round(referralFee * 100) / 100, referralFee: Math.round(referralFee * 100) / 100,
}, },
sellerEligibility: { sellerEligibility: {
canSell: p.spApi.canSell, canSell: p.spApi.canSell,
status: p.spApi.sellabilityStatus, status: p.spApi.sellabilityStatus,
reason: clampText(p.spApi.sellabilityReason, 120), reason: clampText(p.spApi.sellabilityReason, 120),
}, },
estimatedProfit: { estimatedProfit: {
fba: Math.round(fbaProfit * 100) / 100, fba: Math.round(fbaProfit * 100) / 100,
fbm: Math.round(fbmProfit * 100) / 100, fbm: Math.round(fbmProfit * 100) / 100,
}, },
estimatedROI: { estimatedROI: {
fba: fba:
p.record.unitCost > 0 p.record.unitCost > 0
? Math.round((fbaProfit / p.record.unitCost) * 100) ? Math.round((fbaProfit / p.record.unitCost) * 100)
: null, : null,
fbm: fbm:
p.record.unitCost > 0 p.record.unitCost > 0
? Math.round((fbmProfit / p.record.unitCost) * 100) ? Math.round((fbmProfit / p.record.unitCost) * 100)
: null, : null,
}, },
}; };
} }
function clampText(value: unknown, maxLen: number): string | undefined { function clampText(value: unknown, maxLen: number): string | undefined {
if (value == null) return undefined; if (value == null) return undefined;
const s = String(value).trim(); const s = String(value).trim();
if (!s) return undefined; if (!s) return undefined;
return s.length > maxLen ? `${s.slice(0, maxLen - 1)}.` : s; return s.length > maxLen ? `${s.slice(0, maxLen - 1)}.` : s;
} }
function cleanLlmJson(text: string): string { function cleanLlmJson(text: string): string {
// Remove ```json ... ``` or ``` ... ``` wrapping // Remove ```json ... ``` or ``` ... ``` wrapping
const fenceMatch = text.match(/```(?:json)?\s*\n?([\s\S]*?)```/); const fenceMatch = text.match(/```(?:json)?\s*\n?([\s\S]*?)```/);
let cleaned = fenceMatch ? fenceMatch[1]!.trim() : text.trim(); let cleaned = fenceMatch ? fenceMatch[1]!.trim() : text.trim();
// Strip any non-JSON wrapper text by taking the largest JSON-looking segment // Strip any non-JSON wrapper text by taking the largest JSON-looking segment
const firstArray = cleaned.indexOf("["); const firstArray = cleaned.indexOf("[");
const firstObject = cleaned.indexOf("{"); const firstObject = cleaned.indexOf("{");
const startCandidates = [firstArray, firstObject].filter((i) => i >= 0); const startCandidates = [firstArray, firstObject].filter((i) => i >= 0);
const start = startCandidates.length > 0 ? Math.min(...startCandidates) : -1; const start = startCandidates.length > 0 ? Math.min(...startCandidates) : -1;
const endArray = cleaned.lastIndexOf("]"); const endArray = cleaned.lastIndexOf("]");
const endObject = cleaned.lastIndexOf("}"); const endObject = cleaned.lastIndexOf("}");
const end = Math.max(endArray, endObject); const end = Math.max(endArray, endObject);
if (start >= 0 && end > start) { if (start >= 0 && end > start) {
cleaned = cleaned.slice(start, end + 1); cleaned = cleaned.slice(start, end + 1);
} }
// Fix trailing comma-quote before closing brace: ,"} → "} // Fix trailing comma-quote before closing brace: ,"} → "}
cleaned = cleaned.replace(/,"\s*}/g, '"}'); cleaned = cleaned.replace(/,"\s*}/g, '"}');
// Fix malformed comma-quote before a closing bracket/brace: ,"} or ,"] // Fix malformed comma-quote before a closing bracket/brace: ,"} or ,"]
cleaned = cleaned.replace(/,\s*"\s*([}\]])/g, "$1"); cleaned = cleaned.replace(/,\s*"\s*([}\]])/g, "$1");
// Fix malformed quote-comma before a closing bracket/brace: ",} or ",] // Fix malformed quote-comma before a closing bracket/brace: ",} or ",]
cleaned = cleaned.replace(/"\s*,\s*([}\]])/g, '"$1'); cleaned = cleaned.replace(/"\s*,\s*([}\]])/g, '"$1');
// Fix trailing commas before ] or } // Fix trailing commas before ] or }
cleaned = cleaned.replace(/,\s*([}\]])/g, "$1"); cleaned = cleaned.replace(/,\s*([}\]])/g, "$1");
return cleaned; return cleaned;
} }
function parseVerdicts( function parseVerdicts(
content: string, content: string,
products: EnrichedProduct[], products: EnrichedProduct[],
): LlmVerdict[] { ): LlmVerdict[] {
const cleaned = cleanLlmJson(content); const cleaned = cleanLlmJson(content);
try { try {
const parsed = JSON.parse(cleaned) as unknown; const parsed = JSON.parse(cleaned) as unknown;
return alignVerdicts(products, normalizeVerdicts(parsed)); return alignVerdicts(products, normalizeVerdicts(parsed));
} catch (err) { } catch (err) {
const salvaged = extractVerdictsLoosely(cleaned); const salvaged = extractVerdictsLoosely(cleaned);
if (salvaged.length > 0) { if (salvaged.length > 0) {
console.warn( console.warn(
`LLM response was invalid JSON; salvaged ${salvaged.length} verdict(s) with loose parsing.`, `LLM response was invalid JSON; salvaged ${salvaged.length} verdict(s) with loose parsing.`,
); );
return alignVerdicts(products, salvaged); return alignVerdicts(products, salvaged);
} }
console.warn( console.warn(
"Failed to parse LLM response, marking all as ANALYSIS_FAILED", "Failed to parse LLM response, marking all as ANALYSIS_FAILED",
); );
console.warn("Raw LLM content:", content.slice(0, 500)); console.warn("Raw LLM content:", content.slice(0, 500));
return products.map((p) => ({ return products.map((p) => ({
asin: p.record.asin, asin: p.record.asin,
verdict: "SKIP" as const, verdict: "SKIP" as const,
confidence: 0, confidence: 0,
reasoning: `Analysis failed: could not parse LLM output`, reasoning: `Analysis failed: could not parse LLM output`,
})); }));
} }
} }
function normalizeVerdicts(parsed: unknown): LlmVerdict[] { function normalizeVerdicts(parsed: unknown): LlmVerdict[] {
const container = const container =
parsed && typeof parsed === "object" parsed && typeof parsed === "object"
? (parsed as Record<string, unknown>) ? (parsed as Record<string, unknown>)
: undefined; : undefined;
const nested = container?.verdicts ?? container?.results; const nested = container?.verdicts ?? container?.results;
const arr: unknown[] = Array.isArray(parsed) const arr: unknown[] = Array.isArray(parsed)
? parsed ? parsed
: Array.isArray(nested) : Array.isArray(nested)
? nested ? nested
: [parsed]; : [parsed];
return arr return arr
.filter((v): v is Record<string, unknown> => !!v && typeof v === "object") .filter((v): v is Record<string, unknown> => !!v && typeof v === "object")
.map((v) => ({ .map((v) => ({
asin: String(v.asin ?? "") asin: String(v.asin ?? "")
.trim() .trim()
.toUpperCase(), .toUpperCase(),
verdict: (String(v.verdict).toUpperCase() === "FBA" || verdict: (String(v.verdict).toUpperCase() === "FBA" ||
String(v.verdict).toUpperCase() === "FBM" || String(v.verdict).toUpperCase() === "FBM" ||
String(v.verdict).toUpperCase() === "SKIP" String(v.verdict).toUpperCase() === "SKIP"
? String(v.verdict).toUpperCase() ? String(v.verdict).toUpperCase()
: "SKIP") as LlmVerdict["verdict"], : "SKIP") as LlmVerdict["verdict"],
confidence: clampConfidence( confidence: clampConfidence(
typeof v.confidence === "number" typeof v.confidence === "number"
? v.confidence ? v.confidence
: Number(v.confidence ?? 0), : Number(v.confidence ?? 0),
), ),
reasoning: String(v.reasoning ?? "No reasoning provided"), reasoning: String(v.reasoning ?? "No reasoning provided"),
})); }));
} }
function extractVerdictsLoosely(text: string): LlmVerdict[] { function extractVerdictsLoosely(text: string): LlmVerdict[] {
const objectMatches = text.match(/\{[\s\S]*?\}/g) ?? []; const objectMatches = text.match(/\{[\s\S]*?\}/g) ?? [];
const verdicts: LlmVerdict[] = []; const verdicts: LlmVerdict[] = [];
for (const chunk of objectMatches) { for (const chunk of objectMatches) {
const asin = extractField(chunk, /"asin"\s*:\s*"?([A-Z0-9]{10})"?/i) ?? ""; const asin = extractField(chunk, /"asin"\s*:\s*"?([A-Z0-9]{10})"?/i) ?? "";
const verdictRaw = const verdictRaw =
extractField(chunk, /"verdict"\s*:\s*"?([A-Z]+)"?/i) ?? "SKIP"; extractField(chunk, /"verdict"\s*:\s*"?([A-Z]+)"?/i) ?? "SKIP";
const confidenceRaw = const confidenceRaw =
extractField(chunk, /"confidence"\s*:\s*([0-9]+(?:\.[0-9]+)?)/i) ?? "0"; extractField(chunk, /"confidence"\s*:\s*([0-9]+(?:\.[0-9]+)?)/i) ?? "0";
const reasoning = const reasoning =
extractField(chunk, /"reasoning"\s*:\s*"([\s\S]*?)"\s*(?:,|})/i) ?? extractField(chunk, /"reasoning"\s*:\s*"([\s\S]*?)"\s*(?:,|})/i) ??
"No reasoning provided"; "No reasoning provided";
const normalizedVerdict = verdictRaw.toUpperCase(); const normalizedVerdict = verdictRaw.toUpperCase();
if (!asin) continue; if (!asin) continue;
verdicts.push({ verdicts.push({
asin, asin,
verdict: (normalizedVerdict === "FBA" || verdict: (normalizedVerdict === "FBA" ||
normalizedVerdict === "FBM" || normalizedVerdict === "FBM" ||
normalizedVerdict === "SKIP" normalizedVerdict === "SKIP"
? normalizedVerdict ? normalizedVerdict
: "SKIP") as LlmVerdict["verdict"], : "SKIP") as LlmVerdict["verdict"],
confidence: clampConfidence(Number(confidenceRaw)), confidence: clampConfidence(Number(confidenceRaw)),
reasoning, reasoning,
}); });
} }
return verdicts; return verdicts;
} }
function extractField(text: string, regex: RegExp): string | undefined { function extractField(text: string, regex: RegExp): string | undefined {
const match = text.match(regex); const match = text.match(regex);
return match?.[1]?.trim(); return match?.[1]?.trim();
} }
function clampConfidence(value: number): number { function clampConfidence(value: number): number {
if (!Number.isFinite(value)) return 0; if (!Number.isFinite(value)) return 0;
return Math.max(0, Math.min(100, Math.round(value))); return Math.max(0, Math.min(100, Math.round(value)));
} }
function alignVerdicts( function alignVerdicts(
products: EnrichedProduct[], products: EnrichedProduct[],
verdicts: LlmVerdict[], verdicts: LlmVerdict[],
): LlmVerdict[] { ): LlmVerdict[] {
const byAsin = new Map<string, LlmVerdict>(); const byAsin = new Map<string, LlmVerdict>();
for (const verdict of verdicts) { for (const verdict of verdicts) {
if (verdict.asin && !byAsin.has(verdict.asin)) { if (verdict.asin && !byAsin.has(verdict.asin)) {
byAsin.set(verdict.asin, verdict); byAsin.set(verdict.asin, verdict);
} }
} }
return products.map((product, index) => { return products.map((product, index) => {
const asin = product.record.asin; const asin = product.record.asin;
const byAsinVerdict = byAsin.get(asin); const byAsinVerdict = byAsin.get(asin);
if (byAsinVerdict) return { ...byAsinVerdict, asin }; if (byAsinVerdict) return { ...byAsinVerdict, asin };
const byIndexVerdict = verdicts[index]; const byIndexVerdict = verdicts[index];
if (byIndexVerdict) return { ...byIndexVerdict, asin }; if (byIndexVerdict) return { ...byIndexVerdict, asin };
return { return {
asin, asin,
verdict: "SKIP" as const, verdict: "SKIP" as const,
confidence: 0, confidence: 0,
reasoning: "LLM returned no verdict for this product", reasoning: "LLM returned no verdict for this product",
}; };
}); });
} }

View File

@@ -1,209 +1,209 @@
import * as XLSX from "xlsx"; import * as XLSX from "xlsx";
import type { ProductRecord } from "./types.ts"; import type { ProductRecord } from "./types.ts";
const ASIN_REGEX = /^B[0-9A-Z]{9}$/; const ASIN_REGEX = /^B[0-9A-Z]{9}$/;
const COLUMN_CANDIDATES = { const COLUMN_CANDIDATES = {
asin: ["asin"], asin: ["asin"],
name: ["name", "product name", "title", "product title"], name: ["name", "product name", "title", "product title"],
cost: ["unit cost", "cost", "unitcost", "unit_cost", "price", "buy cost"], cost: ["unit cost", "cost", "unitcost", "unit_cost", "price", "buy cost"],
brand: ["brand"], brand: ["brand"],
category: ["category"], category: ["category"],
amazonRank: ["amazon rank", "amazonrank", "sales rank", "bsr"], amazonRank: ["amazon rank", "amazonrank", "sales rank", "bsr"],
avgPrice90: [ avgPrice90: [
"90 day average", "90 day average",
"90-day average", "90-day average",
"avg price 90d", "avg price 90d",
"avg 90 day", "avg 90 day",
"90d average", "90d average",
], ],
sellingPrice: ["selling price", "sale price", "sell price"], sellingPrice: ["selling price", "sale price", "sell price"],
fbaNet: ["fba net", "fbanet", "fba_net"], fbaNet: ["fba net", "fbanet", "fba_net"],
grossProfit: ["gross profit $", "gross profit", "grossprofit"], grossProfit: ["gross profit $", "gross profit", "grossprofit"],
grossProfitPct: ["gross profit %", "gross profit pct", "grossprofitpct"], grossProfitPct: ["gross profit %", "gross profit pct", "grossprofitpct"],
netProfit: ["net profit", "netprofit"], netProfit: ["net profit", "netprofit"],
roi: ["roi", "return on investment"], roi: ["roi", "return on investment"],
moq: ["moq", "min order qty", "minimum order quantity"], moq: ["moq", "min order qty", "minimum order quantity"],
moqCost: ["moq cost", "moqcost", "moq_cost"], moqCost: ["moq cost", "moqcost", "moq_cost"],
totalQty: ["total qty avail", "totalqtyavail", "qty available", "quantity"], totalQty: ["total qty avail", "totalqtyavail", "qty available", "quantity"],
link: ["link", "url", "source"], link: ["link", "url", "source"],
asinLink: ["asin link", "amazon link", "asin url"], asinLink: ["asin link", "amazon link", "asin url"],
sourceUrl: ["source url", "supplier url", "source link"], sourceUrl: ["source url", "supplier url", "source link"],
supplier: ["supplier", "vendor"], supplier: ["supplier", "vendor"],
promoCouponCode: [ promoCouponCode: [
"promo/coupon code", "promo/coupon code",
"promo coupon code", "promo coupon code",
"coupon code", "coupon code",
"promo code", "promo code",
], ],
notes: ["notes", "note"], notes: ["notes", "note"],
leadDate: ["date", "lead date"], leadDate: ["date", "lead date"],
} as const; } as const;
type ColumnKey = keyof typeof COLUMN_CANDIDATES; type ColumnKey = keyof typeof COLUMN_CANDIDATES;
type ColumnMap = Record<ColumnKey, string | undefined>; type ColumnMap = Record<ColumnKey, string | undefined>;
export function readProducts(filePath: string): ProductRecord[] { export function readProducts(filePath: string): ProductRecord[] {
const workbook = XLSX.readFile(filePath); const workbook = XLSX.readFile(filePath);
const sheetName = workbook.SheetNames[0]; const sheetName = workbook.SheetNames[0];
if (!sheetName) throw new Error("No sheets found in file"); if (!sheetName) throw new Error("No sheets found in file");
const sheet = workbook.Sheets[sheetName]!; const sheet = workbook.Sheets[sheetName]!;
const rows = XLSX.utils.sheet_to_json<Record<string, unknown>>(sheet); const rows = XLSX.utils.sheet_to_json<Record<string, unknown>>(sheet);
if (rows.length === 0) throw new Error("File contains no data rows"); if (rows.length === 0) throw new Error("File contains no data rows");
const headers = Object.keys(rows[0]!); const headers = Object.keys(rows[0]!);
const columns = detectColumns(headers); const columns = detectColumns(headers);
const asinColumn = columns.asin; const asinColumn = columns.asin;
if (!asinColumn) if (!asinColumn)
throw new Error( throw new Error(
`No ASIN column found. Available columns: ${headers.join(", ")}`, `No ASIN column found. Available columns: ${headers.join(", ")}`,
); );
logColumnDetection(headers, columns); logColumnDetection(headers, columns);
const knownCols = getKnownColumns(columns); const knownCols = getKnownColumns(columns);
const products: ProductRecord[] = []; const products: ProductRecord[] = [];
for (const row of rows) { for (const row of rows) {
const asin = parseAsin(row[asinColumn]); const asin = parseAsin(row[asinColumn]);
if (!asin) continue; if (!asin) continue;
const sourceUrl = getOptionalString(row, columns.sourceUrl); const sourceUrl = getOptionalString(row, columns.sourceUrl);
const asinLink = getOptionalString(row, columns.asinLink); const asinLink = getOptionalString(row, columns.asinLink);
const link = sourceUrl ?? asinLink ?? getOptionalString(row, columns.link); const link = sourceUrl ?? asinLink ?? getOptionalString(row, columns.link);
const extra = getExtraFields(row, headers, knownCols); const extra = getExtraFields(row, headers, knownCols);
const netProfitFromSheet = getOptionalNumber(row, columns.netProfit); const netProfitFromSheet = getOptionalNumber(row, columns.netProfit);
const roiFromSheet = getOptionalNumber(row, columns.roi); const roiFromSheet = getOptionalNumber(row, columns.roi);
products.push({ products.push({
asin, asin,
name: getOptionalString(row, columns.name) ?? "", name: getOptionalString(row, columns.name) ?? "",
unitCost: getOptionalNumber(row, columns.cost) ?? 0, unitCost: getOptionalNumber(row, columns.cost) ?? 0,
brand: getOptionalString(row, columns.brand), brand: getOptionalString(row, columns.brand),
category: getOptionalString(row, columns.category), category: getOptionalString(row, columns.category),
amazonRank: getOptionalNumber(row, columns.amazonRank), amazonRank: getOptionalNumber(row, columns.amazonRank),
avgPrice90FromSheet: getOptionalNumber(row, columns.avgPrice90), avgPrice90FromSheet: getOptionalNumber(row, columns.avgPrice90),
sellingPriceFromSheet: getOptionalNumber(row, columns.sellingPrice), sellingPriceFromSheet: getOptionalNumber(row, columns.sellingPrice),
fbaNet: getOptionalNumber(row, columns.fbaNet), fbaNet: getOptionalNumber(row, columns.fbaNet),
grossProfit: getOptionalNumber(row, columns.grossProfit) ?? netProfitFromSheet, grossProfit: getOptionalNumber(row, columns.grossProfit) ?? netProfitFromSheet,
grossProfitPct: grossProfitPct:
getOptionalNumber(row, columns.grossProfitPct) ?? roiFromSheet, getOptionalNumber(row, columns.grossProfitPct) ?? roiFromSheet,
netProfitFromSheet, netProfitFromSheet,
roiFromSheet, roiFromSheet,
moq: getOptionalNumber(row, columns.moq), moq: getOptionalNumber(row, columns.moq),
moqCost: getOptionalNumber(row, columns.moqCost), moqCost: getOptionalNumber(row, columns.moqCost),
totalQtyAvail: getOptionalNumber(row, columns.totalQty), totalQtyAvail: getOptionalNumber(row, columns.totalQty),
link, link,
asinLink, asinLink,
sourceUrl, sourceUrl,
supplier: getOptionalString(row, columns.supplier), supplier: getOptionalString(row, columns.supplier),
promoCouponCode: getOptionalString(row, columns.promoCouponCode), promoCouponCode: getOptionalString(row, columns.promoCouponCode),
notes: getOptionalString(row, columns.notes), notes: getOptionalString(row, columns.notes),
leadDate: getOptionalString(row, columns.leadDate), leadDate: getOptionalString(row, columns.leadDate),
...extra, ...extra,
}); });
} }
console.log(`Read ${products.length} valid products from ${filePath}`); console.log(`Read ${products.length} valid products from ${filePath}`);
return products; return products;
} }
function detectColumns(headers: string[]): ColumnMap { function detectColumns(headers: string[]): ColumnMap {
const columns = {} as ColumnMap; const columns = {} as ColumnMap;
for (const key of Object.keys(COLUMN_CANDIDATES) as ColumnKey[]) { for (const key of Object.keys(COLUMN_CANDIDATES) as ColumnKey[]) {
columns[key] = findColumn(headers, [...COLUMN_CANDIDATES[key]]); columns[key] = findColumn(headers, [...COLUMN_CANDIDATES[key]]);
} }
return columns; return columns;
} }
function logColumnDetection(headers: string[], columns: ColumnMap): void { function logColumnDetection(headers: string[], columns: ColumnMap): void {
console.log(`Found columns: ${headers.join(", ")}`); console.log(`Found columns: ${headers.join(", ")}`);
console.log( 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"}`, `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> { function getKnownColumns(columns: ColumnMap): Set<string> {
return new Set(Object.values(columns).filter((column): column is string => !!column)); return new Set(Object.values(columns).filter((column): column is string => !!column));
} }
function parseAsin(value: unknown): string | undefined { function parseAsin(value: unknown): string | undefined {
const asin = String(value ?? "") const asin = String(value ?? "")
.trim() .trim()
.toUpperCase(); .toUpperCase();
if (!asin || !ASIN_REGEX.test(asin)) { if (!asin || !ASIN_REGEX.test(asin)) {
console.warn(`Skipping invalid ASIN: "${asin}"`); console.warn(`Skipping invalid ASIN: "${asin}"`);
return undefined; return undefined;
} }
return asin; return asin;
} }
function getOptionalString( function getOptionalString(
row: Record<string, unknown>, row: Record<string, unknown>,
column: string | undefined, column: string | undefined,
): string | undefined { ): string | undefined {
if (!column) return undefined; if (!column) return undefined;
return normalizeOptionalString(row[column]); return normalizeOptionalString(row[column]);
} }
function getOptionalNumber( function getOptionalNumber(
row: Record<string, unknown>, row: Record<string, unknown>,
column: string | undefined, column: string | undefined,
): number | undefined { ): number | undefined {
if (!column) return undefined; if (!column) return undefined;
return parseOptionalNumber(row[column]); return parseOptionalNumber(row[column]);
} }
function getExtraFields( function getExtraFields(
row: Record<string, unknown>, row: Record<string, unknown>,
headers: string[], headers: string[],
knownCols: Set<string>, knownCols: Set<string>,
): Record<string, unknown> { ): Record<string, unknown> {
const extra: Record<string, unknown> = {}; const extra: Record<string, unknown> = {};
for (const header of headers) { for (const header of headers) {
if (!knownCols.has(header)) extra[header] = row[header]; if (!knownCols.has(header)) extra[header] = row[header];
} }
return extra; return extra;
} }
function findColumn( function findColumn(
headers: string[], headers: string[],
candidates: string[], candidates: string[],
): string | undefined { ): string | undefined {
const normalizedCandidates = new Set(candidates.map(normalizeHeader)); const normalizedCandidates = new Set(candidates.map(normalizeHeader));
for (const header of headers) { for (const header of headers) {
if (normalizedCandidates.has(normalizeHeader(header))) { if (normalizedCandidates.has(normalizeHeader(header))) {
return header; return header;
} }
} }
return undefined; return undefined;
} }
function normalizeHeader(value: string): string { function normalizeHeader(value: string): string {
return value return value
.toLowerCase() .toLowerCase()
.trim() .trim()
.replace(/%/g, " pct ") .replace(/%/g, " pct ")
.replace(/\$/g, " usd ") .replace(/\$/g, " usd ")
.replace(/[^a-z0-9]/g, ""); .replace(/[^a-z0-9]/g, "");
} }
function normalizeOptionalString(value: unknown): string | undefined { function normalizeOptionalString(value: unknown): string | undefined {
if (value == null) return undefined; if (value == null) return undefined;
const s = String(value).trim(); const s = String(value).trim();
return s.length > 0 ? s : undefined; return s.length > 0 ? s : undefined;
} }
function parseOptionalNumber(value: unknown): number | undefined { function parseOptionalNumber(value: unknown): number | undefined {
if (value == null || value === "") return undefined; if (value == null || value === "") return undefined;
const cleaned = String(value).trim().replace(/[$,%]/g, "").replace(/,/g, ""); const cleaned = String(value).trim().replace(/[$,%]/g, "").replace(/,/g, "");
const parsed = Number(cleaned); const parsed = Number(cleaned);
return Number.isFinite(parsed) ? parsed : undefined; 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"; import { testSpApiConnectivity, testSpApiSellability } from "./sp-api.ts";
function parseArgs(): { asin?: string; sellabilityMode: boolean } { function parseArgs(): { asin?: string; sellabilityMode: boolean } {
const args = process.argv.slice(2); const args = process.argv.slice(2);
const sellabilityMode = args.includes("--sellability"); const sellabilityMode = args.includes("--sellability");
const asin = args.find((arg) => !arg.startsWith("--")); const asin = args.find((arg) => !arg.startsWith("--"));
return { asin, sellabilityMode }; return { asin, sellabilityMode };
} }
async function main() { async function main() {
const { asin, sellabilityMode } = parseArgs(); const { asin, sellabilityMode } = parseArgs();
console.log("Running SP-API connectivity test..."); console.log("Running SP-API connectivity test...");
if (sellabilityMode) { if (sellabilityMode) {
if (!asin) { if (!asin) {
console.error("Usage: bun run src/sp-test.ts --sellability <ASIN>"); console.error("Usage: bun run src/sp-test.ts --sellability <ASIN>");
process.exit(1); process.exit(1);
} }
console.log(`Running sellability check for ASIN: ${asin}`); console.log(`Running sellability check for ASIN: ${asin}`);
const sellability = await testSpApiSellability(asin); const sellability = await testSpApiSellability(asin);
if (!sellability.ok) { if (!sellability.ok) {
console.error(`SP-API sellability test failed: ${sellability.message}`); console.error(`SP-API sellability test failed: ${sellability.message}`);
process.exit(1); process.exit(1);
} }
console.log(`SP-API sellability test passed: ${sellability.message}`); console.log(`SP-API sellability test passed: ${sellability.message}`);
return; return;
} }
if (asin) { if (asin) {
console.log(`Including pricing connectivity check for ASIN: ${asin}`); console.log(`Including pricing connectivity check for ASIN: ${asin}`);
} }
const result = await testSpApiConnectivity(asin); const result = await testSpApiConnectivity(asin);
if (!result.ok) { if (!result.ok) {
console.error(`SP-API test failed: ${result.message}`); console.error(`SP-API test failed: ${result.message}`);
process.exit(1); process.exit(1);
} }
console.log(`SP-API test passed: ${result.message}`); console.log(`SP-API test passed: ${result.message}`);
} }
main().catch((err) => { main().catch((err) => {
console.error(`SP-API test crashed: ${String(err)}`); console.error(`SP-API test crashed: ${String(err)}`);
process.exit(1); process.exit(1);
}); });

View File

@@ -1,75 +1,75 @@
export interface ProductRecord { export interface ProductRecord {
asin: string; asin: string;
name: string; name: string;
unitCost: number; unitCost: number;
brand?: string; brand?: string;
category?: string; category?: string;
amazonRank?: number; amazonRank?: number;
avgPrice90FromSheet?: number; avgPrice90FromSheet?: number;
sellingPriceFromSheet?: number; sellingPriceFromSheet?: number;
fbaNet?: number; fbaNet?: number;
grossProfit?: number; grossProfit?: number;
grossProfitPct?: number; grossProfitPct?: number;
netProfitFromSheet?: number; netProfitFromSheet?: number;
roiFromSheet?: number; roiFromSheet?: number;
moq?: number; moq?: number;
moqCost?: number; moqCost?: number;
totalQtyAvail?: number; totalQtyAvail?: number;
link?: string; link?: string;
asinLink?: string; asinLink?: string;
sourceUrl?: string; sourceUrl?: string;
supplier?: string; supplier?: string;
promoCouponCode?: string; promoCouponCode?: string;
notes?: string; notes?: string;
leadDate?: string; leadDate?: string;
[key: string]: unknown; [key: string]: unknown;
} }
export interface KeepaData { export interface KeepaData {
currentPrice: number | null; currentPrice: number | null;
avgPrice90: number | null; avgPrice90: number | null;
minPrice90: number | null; minPrice90: number | null;
maxPrice90: number | null; maxPrice90: number | null;
salesRank: number | null; salesRank: number | null;
salesRankAvg90: number | null; salesRankAvg90: number | null;
salesRankDrops30: number | null; salesRankDrops30: number | null;
salesRankDrops90: number | null; salesRankDrops90: number | null;
sellerCount: number | null; sellerCount: number | null;
buyBoxSeller: string | null; buyBoxSeller: string | null;
buyBoxPrice: number | null; buyBoxPrice: number | null;
monthlySold: number | null; monthlySold: number | null;
categoryTree: string[]; categoryTree: string[];
} }
export type SellabilityInfo = { export type SellabilityInfo = {
canSell: boolean | null; canSell: boolean | null;
sellabilityStatus: "available" | "restricted" | "not_available" | "unknown"; sellabilityStatus: "available" | "restricted" | "not_available" | "unknown";
sellabilityReason?: string; sellabilityReason?: string;
}; };
export interface SpApiData extends SellabilityInfo { export interface SpApiData extends SellabilityInfo {
fbaFee: number; fbaFee: number;
fbmFee: number; fbmFee: number;
referralFeePercent: number; referralFeePercent: number;
estimatedSalePrice: number; estimatedSalePrice: number;
} }
export interface EnrichedProduct { export interface EnrichedProduct {
record: ProductRecord; record: ProductRecord;
keepa: KeepaData | null; keepa: KeepaData | null;
spApi: SpApiData; spApi: SpApiData;
fetchedAt: string; fetchedAt: string;
} }
export interface LlmVerdict { export interface LlmVerdict {
asin: string; asin: string;
verdict: "FBA" | "FBM" | "SKIP"; verdict: "FBA" | "FBM" | "SKIP";
confidence: number; confidence: number;
reasoning: string; reasoning: string;
} }
export interface AnalysisResult { export interface AnalysisResult {
product: EnrichedProduct; product: EnrichedProduct;
verdict: LlmVerdict; verdict: LlmVerdict;
} }

View File

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

View File

@@ -1,29 +1,29 @@
{ {
"compilerOptions": { "compilerOptions": {
// Environment setup & latest features // Environment setup & latest features
"lib": ["ESNext"], "lib": ["ESNext"],
"target": "ESNext", "target": "ESNext",
"module": "Preserve", "module": "Preserve",
"moduleDetection": "force", "moduleDetection": "force",
"jsx": "react-jsx", "jsx": "react-jsx",
"allowJs": true, "allowJs": true,
// Bundler mode // Bundler mode
"moduleResolution": "bundler", "moduleResolution": "bundler",
"allowImportingTsExtensions": true, "allowImportingTsExtensions": true,
"verbatimModuleSyntax": true, "verbatimModuleSyntax": true,
"noEmit": true, "noEmit": true,
// Best practices // Best practices
"strict": true, "strict": true,
"skipLibCheck": true, "skipLibCheck": true,
"noFallthroughCasesInSwitch": true, "noFallthroughCasesInSwitch": true,
"noUncheckedIndexedAccess": true, "noUncheckedIndexedAccess": true,
"noImplicitOverride": true, "noImplicitOverride": true,
// Some stricter flags (disabled by default) // Some stricter flags (disabled by default)
"noUnusedLocals": false, "noUnusedLocals": false,
"noUnusedParameters": false, "noUnusedParameters": false,
"noPropertyAccessFromIndexSignature": false "noPropertyAccessFromIndexSignature": false
} }
} }