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:
@@ -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 ':*)"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
30
.env.example
30
.env.example
@@ -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
74
.gitignore
vendored
@@ -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
212
CLAUDE.md
@@ -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
270
README.md
@@ -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.
|
||||||
|
|||||||
34
package.json
34
package.json
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
1068
src/bestsellers-by-category.ts
Normal file
1068
src/bestsellers-by-category.ts
Normal file
File diff suppressed because it is too large
Load Diff
132
src/cache.ts
132
src/cache.ts
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
690
src/index.ts
690
src/index.ts
@@ -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);
|
||||||
});
|
});
|
||||||
|
|||||||
282
src/keepa.ts
282
src/keepa.ts
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
706
src/llm.ts
706
src/llm.ts
@@ -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",
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
418
src/reader.ts
418
src/reader.ts
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
1300
src/sp-api.ts
1300
src/sp-api.ts
File diff suppressed because it is too large
Load Diff
@@ -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);
|
||||||
});
|
});
|
||||||
|
|||||||
150
src/types.ts
150
src/types.ts
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
318
src/writer.ts
318
src/writer.ts
@@ -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}`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user