Compare commits

..

10 Commits

Author SHA1 Message Date
Victor Noguera
0f9b785cce refactor: streamline CLAUDE.md for Bun usage and remove outdated instructions 2026-05-19 01:25:17 -04:00
Victor Noguera
f3e4d3ac52 feat: Implement supplier export functionality with workbook generation
- Add `writeSupplierWorkbook` function to create Excel workbooks for supplier analysis results.
- Introduce `SupplierExportSummary` type for summarizing export data.
- Create tests for `writeSupplierWorkbook` to ensure correct sheet creation and data population.
- Implement supplier scoring logic in `supplier-scoring.ts` to evaluate product profitability and demand.
- Add tests for supplier scoring to validate scoring logic and verdict determination.
- Enhance UPC file analysis to integrate supplier scoring and export results to Excel.
- Update database writing logic to accommodate new supplier analysis results.
- Refactor types to include supplier-specific data structures and scoring metrics.
- Ensure proper cleanup of temporary files after tests.
2026-05-19 01:19:48 -04:00
Victor Noguera
41ef57a7bc Refactor mid-range seller processing to enforce sellability gates and enhance command-line arguments
- Updated test case to reflect changes in processing mid-range matches based on sellability.
- Modified `processCategory` function to implement strict and soft sellability gates.
- Introduced new command-line arguments for category selection and sellability gate configuration.
- Enhanced error handling and validation for new arguments.
- Improved logging for category processing and budget usage.
2026-05-12 14:14:20 -04:00
Victor Noguera
f2c8a9728d feat: add mid-range sellers by category analysis pipeline
This new pipeline identifies products meeting specific monthly sold, price, seller count, and Amazon buy box share criteria across categories. It fetches comprehensive product data from Keepa and SP-API, analyzes it using an LLM, and persists the results.

A key enhancement is the introduction of a dedicated Redis cache for Keepa and SP-API responses. This reduces API token consumption and improves performance for subsequent runs by caching enriched ASIN data with a 12-hour TTL. Products are saved regardless of their sellability status to provide a complete view.
2026-05-02 12:03:31 -04:00
Victor Noguera
9b832b7839 perf: optimize Keepa UPC lookups with lightweight queries and caching
Reduces API token consumption by disabling stats and buybox data for UPC-to-ASIN mapping requests. Additionally, introduces a run-level cache to avoid redundant lookups for the same UPC across different batch chunks.
2026-04-17 01:41:01 -04:00
Victor Noguera
072a501102 Merge branch 'upc-to-asin' 2026-04-16 23:06:59 -04:00
Victor Noguera
32e7b0c485 feat: add UPC to ASIN mapping and large file UPC analysis
Introduces the capability to resolve UPCs to ASINs using the Keepa API. This includes a new `upc-file` command for processing large Excel files of UPCs, a `upc` CLI tool for quick lookups, and API endpoints for web-based integration. The analysis pipeline was refactored into a reusable module to support both standard ASIN leads and new UPC-driven workflows.
2026-04-16 23:06:55 -04:00
Victor Noguera
d25cf5d5ec feat: add amazon seller filter to product list and result parsing 2026-04-14 18:43:35 -04:00
Victor Noguera
b52cdc7f2b Merge branch 'az-sell' 2026-04-14 18:26:30 -04:00
Victor Noguera
8d6b0f9e0f feat: add Amazon seller and buy box share metrics to product analysis
- Introduced `amazonIsSeller` and `amazonBuyboxSharePct90d` fields in KeepaData type.
- Updated database schema and queries to store Amazon seller status and buy box share percentage.
- Enhanced product analysis results with new metrics from Keepa API.
- Modified frontend components to display Amazon seller status and buy box share percentage.
- Implemented reanalysis functionality for products to refresh Amazon-related metrics.
2026-04-14 18:26:22 -04:00
32 changed files with 7536 additions and 514 deletions

15
.gitignore vendored
View File

@@ -6,6 +6,11 @@ out
dist
*.tgz
# local data directories
input/*
output/*
db/*
# code coverage
coverage
*.lcov
@@ -32,16 +37,6 @@ report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
# Finder (MacOS) folder config
.DS_Store
*.xlsx
results.db
results.db-shm
results.db-wal
output/
temp_output/
dist-server/

173
CLAUDE.md
View File

@@ -1,106 +1,99 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
Default to using Bun instead of Node.js.
- Use `bun <file>` instead of `node <file>` or `ts-node <file>`
- Use `bun test` instead of `jest` or `vitest`
- Use `bun build <file.html|file.ts|file.css>` instead of `webpack` or `esbuild`
- Use `bun install` instead of `npm install` or `yarn install` or `pnpm install`
- Use `bun run <script>` instead of `npm run <script>` or `yarn run <script>` or `pnpm run <script>`
- Use `bunx <package> <command>` instead of `npx <package> <command>`
- Use `bun install` instead of `npm install` or `yarn install`
- Use `bun run <script>` instead of `npm run <script>`
- Bun automatically loads .env, so don't use dotenv.
## APIs
- `Bun.serve()` supports WebSockets, HTTPS, and routes. Don't use `express`.
- `bun:sqlite` for SQLite. Don't use `better-sqlite3`.
- `Bun.redis` for Redis. Don't use `ioredis`.
- `Bun.sql` for Postgres. Don't use `pg` or `postgres.js`.
- `WebSocket` is built-in. Don't use `ws`.
- Prefer `Bun.file` over `node:fs`'s readFile/writeFile
- Bun.$`ls` instead of execa.
- Prefer `Bun.file` over `node:fs`'s readFile/writeFile.
- `Bun.$\`cmd\`` instead of execa.
## Testing
Use `bun test` to run tests.
```ts#index.test.ts
import { test, expect } from "bun:test";
test("hello world", () => {
expect(1).toBe(1);
});
```
## Frontend
Use HTML imports with `Bun.serve()`. Don't use `vite`. HTML imports fully support React, CSS, Tailwind.
Server:
```ts#index.ts
import index from "./index.html"
Bun.serve({
routes: {
"/": index,
"/api/users/:id": {
GET: (req) => {
return new Response(JSON.stringify({ id: req.params.id }));
},
},
},
// optional websocket support
websocket: {
open: (ws) => {
ws.send("Hello, world!");
},
message: (ws, message) => {
ws.send(message);
},
close: (ws) => {
// handle close
}
},
development: {
hmr: true,
console: true,
}
})
```
HTML files can import .tsx, .jsx or .js files directly and Bun's bundler will transpile & bundle automatically. `<link>` tags can point to stylesheets and Bun's CSS bundler will bundle.
```html#index.html
<html>
<body>
<h1>Hello, world!</h1>
<script type="module" src="./frontend.tsx"></script>
</body>
</html>
```
With the following `frontend.tsx`:
```tsx#frontend.tsx
import React from "react";
import { createRoot } from "react-dom/client";
// import .css files directly and it works
import './index.css';
const root = createRoot(document.body);
export default function Frontend() {
return <h1>Hello, world!</h1>;
}
root.render(<Frontend />);
```
Then, run index.ts
## Commands
```sh
bun --hot ./index.ts
# Run all tests
bun test
# Run a single test file
bun test src/supplier-scoring.test.ts
# Type-check (no emit)
./node_modules/.bin/tsc --noEmit
# ASIN lead-list pipeline (LLM-based)
bun run src/index.ts input/leads.xlsx --out output/results.xlsx
# Supplier UPC pipeline (deterministic)
bun run upc-file --input input/supplier.xlsx --out output/supplier_ranked.xlsx
# Category discovery pipelines
bun run bestsellers
bun run monthly-sold
bun run mid-range
# Web API server
bun run start:web # http://localhost:3000
# SP-API connectivity tests
bun run src/sp-test.ts
bun run src/sp-test.ts B07SN9BHVV
bun run src/sp-test.ts --sellability B07SN9BHVV
```
For more information, read the Bun API docs in `node_modules/bun-types/docs/**.mdx`.
## Architecture
Two distinct analysis pipelines share infrastructure (Keepa, SP-API, Redis, SQLite) but diverge in how they produce verdicts.
### ASIN Lead-list Pipeline (`src/index.ts` → `src/analysis-pipeline.ts`)
For spreadsheets containing known ASINs. Verdict is LLM-based (FBA/FBM/SKIP via LM Studio).
Flow: `reader.ts` parse → Redis cache check → `sp-api.ts` sellability gate (5 concurrent workers) → `keepa.ts` batch enrichment → `sp-api.ts` pricing + FBA fees (5 concurrent workers) → `llm.ts` batched analysis (5 products/batch) → `writer.ts` XLSX + SQLite.
### Supplier UPC Pipeline (`src/upc-file-analysis.ts`)
For supplier price lists containing UPC/EAN values. Verdict is deterministic (BUY/WATCH/SKIP); never calls LM Studio.
Flow: `upc-file-reader.ts` streaming parse (`.xlsx`) or row-window parse (`.xls`) → SP-API catalog UPC lookup first, Keepa UPC lookup as fallback → `keepa.ts` demand enrichment → `sp-api.ts` sellability + FBA fees → `supplier-scoring.ts` deterministic score → `supplier-export.ts` Excel workbook (`Ranked Leads`, `Skipped`, `Summary` sheets) + SQLite.
UPC resolution priority: SP-API catalog lookup → Keepa fallback (for no-match or request failure only).
### Category Pipelines
`bestsellers-by-category.ts`, `top-monthly-sold-by-category.ts`, `mid-range-sellers-by-category.ts` — Keepa category browsing → SP-API sellability gate → LLM verdict. Each saves results to SQLite. Mid-range applies configurable filters (monthly sold, price, seller count, Amazon buy box share).
### Shared Infrastructure
| Module | Role |
|--------|------|
| `src/types.ts` | All shared interfaces (`ProductRecord`, `KeepaData`, `SpApiData`, `SupplierScore`, etc.) |
| `src/config.ts` | Env var loading via `Bun.env` |
| `src/keepa.ts` | Keepa API: batch ASIN fetch, UPC lookup, auto rate-limiting on token exhaustion |
| `src/sp-api.ts` | SP-API: sellability (`getListingsRestrictions`), pricing+fees, UPC catalog lookup |
| `src/cache.ts` | Redis caching (24h TTL for lead-list; 12h for mid-range) |
| `src/database.ts` | SQLite `runs` + `results` tables; auto-creates `db/results.db` |
| `src/server.ts` | Bun HTTP server exposing REST endpoints for both pipelines |
### File Layout
- `input/` — source spreadsheets (git-ignored)
- `output/` — generated workbooks (git-ignored)
- `db/` — SQLite files (git-ignored)
- `src/` — all source and test files
## Project Rules
- Keep the ASIN lead-list and category flows compatible with their current LLM-based FBA/FBM/SKIP analysis.
- The supplier UPC pipeline must not call LM Studio.
- Supplier UPC files resolve UPC/EAN through SP-API catalog lookup first; Keepa UPC lookup is fallback only (no-match or request-failure cases).
- Supplier workbook output must keep `Ranked Leads`, `Skipped`, and `Summary` sheets.
- When changing UPC supplier behavior, cover SP-API UPC parsing, deterministic scoring, and workbook export with `bun test`.

142
README.md
View File

@@ -21,21 +21,21 @@ cp .env.example .env
## Usage
```bash
bun run src/index.ts <input.csv|xlsx> [--out results.csv]
bun run src/index.ts input/<input.csv|xlsx> [--out output/results.xlsx]
```
Examples:
```bash
bun run src/index.ts leads.xlsx
bun run src/index.ts leads.csv --out results.xlsx
bun run src/index.ts input/leads.xlsx
bun run src/index.ts input/leads.csv --out output/results.xlsx
```
Large-file behavior:
- If the input has more than 50 products, processing is done in chunks of 50.
- Each chunk is analyzed and written to a numbered output file, for example: `results_part_001.xlsx`, `results_part_002.xlsx`, ...
- If `--out` is omitted for large files, the base output name defaults to `<input>_results.xlsx` and chunk files are still written with numbered suffixes.
- Each chunk is analyzed and written to a numbered output file under `output/`, for example: `output/results_part_001.xlsx`, `output/results_part_002.xlsx`, ...
- If `--out` is omitted for large files, the base output name defaults to `output/<input>_results.xlsx` and chunk files are still written with numbered suffixes.
Quick SP-API connectivity tests:
@@ -45,6 +45,136 @@ bun run src/sp-test.ts B07SN9BHVV # Auth + sellers endpoint + pricing offer c
bun run src/sp-test.ts --sellability B07SN9BHVV # Standalone sellability check
```
## Category Pipelines
Run category-focused discovery flows with Keepa + SP-API + LLM:
```bash
bun run bestsellers
bun run monthly-sold
bun run mid-range
```
Mid-range process:
- Script: `bun run mid-range`
- Source: `src/mid-range-sellers-by-category.ts`
- Default filters:
- Monthly sold between `100` and `1000`
- Price between `$15` and `$200` (using Keepa current price, fallback avg 90d)
- Seller count between `3` and `20`
- If Amazon is a seller, Amazon buy box share must be between `15%` and `85%`
- Sellability behavior:
- Sellability is still fetched and saved (`can_sell`, `sellability_status`, `sellability_reason`)
- Matching products are persisted regardless of sellability status
- Caching behavior:
- Uses Redis to cache Keepa + SP-API API enrichment per ASIN
- Cache TTL is fixed at `12 hours`
Example:
```bash
bun run mid-range --category-limit 10 --per-category-top 50 --category-candidate-pool 250 --min-monthly-sold 100 --max-monthly-sold 1000 --min-price 15 --max-price 200 --min-seller-count 3 --max-seller-count 20 --min-amazon-buybox-share-pct 15 --max-amazon-buybox-share-pct 85
```
## UPC to ASIN Mapping
You can map UPCs to ASINs directly through the Keepa integration in `src/keepa.ts`.
```ts
import { mapUpcsToAsins, lookupKeepaUpcs } from "./src/keepa.ts";
const upcs = ["012345678901", "098765432109", "112233445566"];
// Simple map output (UPC -> ASIN) for clean one-to-one matches only.
const asinMap = await mapUpcsToAsins(upcs);
for (const [upc, asin] of asinMap.entries()) {
console.log(`UPC ${upc} -> ASIN ${asin}`);
}
// Rich output includes status for every UPC (invalid, not found, collisions, etc.).
const details = await lookupKeepaUpcs(upcs);
for (const [upc, detail] of details.entries()) {
console.log(upc, detail.status, detail.asin, detail.reason ?? "");
}
```
Behavior:
- Strict validation accepts only 12, 13, or 14 digit UPC values.
- If a UPC resolves to multiple ASINs, it is excluded from the simple map.
- The rich lookup returns all candidate ASINs and status per UPC.
CLI usage:
```bash
bun run upc 012345678901 098765432109
bun run upc 012345678901,098765432109 --detailed
bun run upc --file upcs.txt --detailed --json
```
API usage (when `bun run start:web` is running):
```bash
# Simple one-to-one mapping (GET)
curl "http://localhost:3000/api/upc/map?upc=012345678901&upc=098765432109"
# Detailed lookup with statuses (GET)
curl "http://localhost:3000/api/upc/lookup?upcs=012345678901,098765432109"
# Detailed lookup (POST JSON)
curl -X POST "http://localhost:3000/api/upc/lookup" \
-H "content-type: application/json" \
-d '{"upcs":["012345678901","098765432109"]}'
```
## Large UPC File Analysis (XLS/XLSX)
For supplier price lists that contain UPC/EAN values and unit cost, use the
dedicated UPC-file process. It runs in batches and produces a deterministic
ranked sourcing workbook:
1. Reads UPC rows in batches (`.xlsx` uses streaming reader, `.xls` uses fallback row-window parsing).
2. Resolves UPCs to ASINs with SP-API catalog lookup first, then falls back to Keepa for no-match/request-failure cases.
3. Enriches resolved ASINs with Keepa demand/competition data and SP-API sellability + FBA fees.
4. Scores products with deterministic BUY/WATCH/SKIP logic; this path does not call LM Studio.
5. Writes a ranked Excel workbook and persists rows into the existing `runs` + `results` tables.
CLI usage:
```bash
bun run upc-file --input input/huge-upcs.xlsx
bun run upc-file --input input/supplier.xlsx --out output/supplier_ranked.xlsx
bun run upc-file --input input/huge-upcs.xls --input-batch-size 500 --upc-lookup-batch-size 100 --max-rows 10000
```
Workbook output includes `Ranked Leads`, `Skipped`, and `Summary` sheets with
UPC, ASIN, cost, sale price, FBA fee, profit, margin, ROI, BSR, rank drops,
monthly sold, seller count, Amazon Buy Box share, sellability, score, verdict,
and reason columns.
API usage (when `bun run start:web` is running):
```bash
curl -X POST "http://localhost:3000/api/process/upc-file" \
-H "content-type: application/json" \
-d '{
"inputFile": "/absolute/path/to/input/huge-upcs.xlsx",
"inputBatchSize": 300,
"upcLookupBatchSize": 100
}'
```
Request body fields:
- `inputFile` (required): server-local path to `.xls` or `.xlsx` file.
- `outputFile` (optional): stored in run metadata.
- `inputBatchSize` (optional): number of input rows per processing batch (default `200`).
- `upcLookupBatchSize` (optional): UPC chunk size per Keepa lookup call (default `100`).
- `maxRows` (optional): cap processed valid UPC rows for dry runs.
Response includes run metadata and status counts, including unresolved UPC reasons and lead verdict totals.
## Input file format
Accepts `.csv` or `.xlsx` files. Column names are matched case-insensitively. Required column:
@@ -101,7 +231,7 @@ Numeric parsing accepts plain numbers as well as formatted values like `$12.50`,
## Persistent Storage with SQLite
Results from each run are now stored in a SQLite database named `results.db` in the project root. The SQLite implementation details are handled in `src/database.ts`. This allows you to:
Results from each run are now stored in a SQLite database named `db/results.db` by default. The SQLite implementation details are handled in `src/database.ts`. This allows you to:
- Revisit past analysis results.
- Query and analyze historical data.

189
bun.lock
View File

@@ -6,6 +6,7 @@
"name": "asin-check",
"dependencies": {
"amazon-sp-api": "^1.2.1",
"exceljs": "^4.4.0",
"ioredis": "^5.10.1",
"react": "^19.2.0",
"react-dom": "^19.2.0",
@@ -15,13 +16,15 @@
"@types/bun": "latest",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
},
"peerDependencies": {
"typescript": "^5",
"typescript": "^6.0.3",
},
},
},
"packages": {
"@fast-csv/format": ["@fast-csv/format@4.3.5", "", { "dependencies": { "@types/node": "^14.0.1", "lodash.escaperegexp": "^4.1.2", "lodash.isboolean": "^3.0.3", "lodash.isequal": "^4.5.0", "lodash.isfunction": "^3.0.9", "lodash.isnil": "^4.0.0" } }, "sha512-8iRn6QF3I8Ak78lNAa+Gdl5MJJBM5vRHivFtMRUWINdevNo00K7OXxS2PshawLKTejVwieIlPmK5YlLu6w4u8A=="],
"@fast-csv/parse": ["@fast-csv/parse@4.3.6", "", { "dependencies": { "@types/node": "^14.0.1", "lodash.escaperegexp": "^4.1.2", "lodash.groupby": "^4.6.0", "lodash.isfunction": "^3.0.9", "lodash.isnil": "^4.0.0", "lodash.isundefined": "^3.0.1", "lodash.uniq": "^4.5.0" } }, "sha512-uRsLYksqpbDmWaSmzvJcuApSEe38+6NQZBUsuAyMZKqHxH0g1wcJgsKUvN3WC8tewaqFjBMMGrkHmC+T7k8LvA=="],
"@ioredis/commands": ["@ioredis/commands@1.5.1", "", {}, "sha512-JH8ZL/ywcJyR9MmJ5BNqZllXNZQqQbnVZOqpPQqE1vHiFgAw4NHbvE0FOduNU8IX9babitBT46571OnPTT0Zcw=="],
"@types/bun": ["@types/bun@1.3.11", "", { "dependencies": { "bun-types": "1.3.11" } }, "sha512-5vPne5QvtpjGpsGYXiFyycfpDF2ECyPcTSsFBMa0fraoxiQyMJ3SmuQIGhzPg2WJuWxVBoxWJ2kClYTcw/4fAg=="],
@@ -36,6 +39,34 @@
"amazon-sp-api": ["amazon-sp-api@1.2.1", "", { "dependencies": { "csvtojson": "^2.0.14", "fast-xml-parser": "^5.3.1", "iconv-lite": "^0.7.0", "qs": "^6.14.0" } }, "sha512-zxX3KtoCDx0wxkkBgFM6qew49JJoL1XZQgUnztfp+8Im2HLHBAt4beSiDo/AkH00Gr8paHBAjdcJY6LC6ISU7w=="],
"archiver": ["archiver@5.3.2", "", { "dependencies": { "archiver-utils": "^2.1.0", "async": "^3.2.4", "buffer-crc32": "^0.2.1", "readable-stream": "^3.6.0", "readdir-glob": "^1.1.2", "tar-stream": "^2.2.0", "zip-stream": "^4.1.0" } }, "sha512-+25nxyyznAXF7Nef3y0EbBeqmGZgeN/BxHX29Rs39djAfaFalmQ89SE6CWyDCHzGL0yt/ycBtNOmGTW0FyGWNw=="],
"archiver-utils": ["archiver-utils@2.1.0", "", { "dependencies": { "glob": "^7.1.4", "graceful-fs": "^4.2.0", "lazystream": "^1.0.0", "lodash.defaults": "^4.2.0", "lodash.difference": "^4.5.0", "lodash.flatten": "^4.4.0", "lodash.isplainobject": "^4.0.6", "lodash.union": "^4.6.0", "normalize-path": "^3.0.0", "readable-stream": "^2.0.0" } }, "sha512-bEL/yUb/fNNiNTuUz979Z0Yg5L+LzLxGJz8x79lYmR54fmTIb6ob/hNQgkQnIUDWIFjZVQwl9Xs356I6BAMHfw=="],
"async": ["async@3.2.6", "", {}, "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA=="],
"balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],
"base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="],
"big-integer": ["big-integer@1.6.52", "", {}, "sha512-QxD8cf2eVqJOOz63z6JIN9BzvVs/dlySa5HGSBH5xtR8dPteIRQnBxxKqkNTiT6jbDTF6jAfrd4oMcND9RGbQg=="],
"binary": ["binary@0.3.0", "", { "dependencies": { "buffers": "~0.1.1", "chainsaw": "~0.1.0" } }, "sha512-D4H1y5KYwpJgK8wk1Cue5LLPgmwHKYSChkbspQg5JtVuR5ulGckxfR62H3AE9UDkdMC8yyXlqYihuz3Aqg2XZg=="],
"bl": ["bl@4.1.0", "", { "dependencies": { "buffer": "^5.5.0", "inherits": "^2.0.4", "readable-stream": "^3.4.0" } }, "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w=="],
"bluebird": ["bluebird@3.4.7", "", {}, "sha512-iD3898SR7sWVRHbiQv+sHUtHnMvC1o3nW5rAcqnq3uOn07DSAppZYUkIGslDz6gXC7HfunPe7YVBgoEJASPcHA=="],
"brace-expansion": ["brace-expansion@2.1.0", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w=="],
"buffer": ["buffer@5.7.1", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.1.13" } }, "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ=="],
"buffer-crc32": ["buffer-crc32@0.2.13", "", {}, "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ=="],
"buffer-indexof-polyfill": ["buffer-indexof-polyfill@1.0.2", "", {}, "sha512-I7wzHwA3t1/lwXQh+A5PbNvJxgfo5r3xulgpYDB5zckTu/Z9oUK9biouBKQUjEqzaz3HnAT6TYoovmE+GqSf7A=="],
"buffers": ["buffers@0.1.1", "", {}, "sha512-9q/rDEGSb/Qsvv2qvzIzdluL5k7AaJOTrw23z9reQthrbF7is4CtlT0DXyO1oei2DCp4uojjzQ7igaSHp1kAEQ=="],
"bun-types": ["bun-types@1.3.11", "", { "dependencies": { "@types/node": "*" } }, "sha512-1KGPpoxQWl9f6wcZh57LvrPIInQMn2TQ7jsgxqpRzg+l0QPOFvJVH7HmvHo/AiPgwXy+/Thf6Ov3EdVn1vOabg=="],
"call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="],
@@ -44,78 +75,176 @@
"cfb": ["cfb@1.2.2", "", { "dependencies": { "adler-32": "~1.3.0", "crc-32": "~1.2.0" } }, "sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA=="],
"chainsaw": ["chainsaw@0.1.0", "", { "dependencies": { "traverse": ">=0.3.0 <0.4" } }, "sha512-75kWfWt6MEKNC8xYXIdRpDehRYY/tNSgwKaJq+dbbDcxORuVrrQ+SEHoWsniVn9XPYfP4gmdWIeDk/4YNp1rNQ=="],
"cluster-key-slot": ["cluster-key-slot@1.1.2", "", {}, "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA=="],
"codepage": ["codepage@1.15.0", "", {}, "sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA=="],
"compress-commons": ["compress-commons@4.1.2", "", { "dependencies": { "buffer-crc32": "^0.2.13", "crc32-stream": "^4.0.2", "normalize-path": "^3.0.0", "readable-stream": "^3.6.0" } }, "sha512-D3uMHtGc/fcO1Gt1/L7i1e33VOvD4A9hfQLP+6ewd+BvG/gQ84Yh4oftEhAdjSMgBgwGL+jsppT7JYNpo6MHHg=="],
"concat-map": ["concat-map@0.0.1", "", {}, "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg=="],
"core-util-is": ["core-util-is@1.0.3", "", {}, "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ=="],
"crc-32": ["crc-32@1.2.2", "", { "bin": { "crc32": "bin/crc32.njs" } }, "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ=="],
"crc32-stream": ["crc32-stream@4.0.3", "", { "dependencies": { "crc-32": "^1.2.0", "readable-stream": "^3.4.0" } }, "sha512-NT7w2JVU7DFroFdYkeq8cywxrgjPHWkdX1wjpRQXPX5Asews3tA+Ght6lddQO5Mkumffp3X7GEqku3epj2toIw=="],
"csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
"csvtojson": ["csvtojson@2.0.14", "", { "dependencies": { "lodash": "^4.17.21" }, "bin": { "csvtojson": "bin/csvtojson" } }, "sha512-F7NNvhhDyob7OsuEGRsH0FM1aqLs/WYITyza3l+hTEEmOK9sGPBlYQZwlVG0ezCojXYpE17lhS5qL6BCOZSPyA=="],
"dayjs": ["dayjs@1.11.20", "", {}, "sha512-YbwwqR/uYpeoP4pu043q+LTDLFBLApUP6VxRihdfNTqu4ubqMlGDLd6ErXhEgsyvY0K6nCs7nggYumAN+9uEuQ=="],
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
"denque": ["denque@2.1.0", "", {}, "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw=="],
"dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="],
"duplexer2": ["duplexer2@0.1.4", "", { "dependencies": { "readable-stream": "^2.0.2" } }, "sha512-asLFVfWWtJ90ZyOUHMqk7/S2w2guQKxUI2itj3d92ADHhxUSbCMGi1f1cBcJ7xM1To+pE/Khbwo1yuNbMEPKeA=="],
"end-of-stream": ["end-of-stream@1.4.5", "", { "dependencies": { "once": "^1.4.0" } }, "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg=="],
"es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="],
"es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="],
"es-object-atoms": ["es-object-atoms@1.1.1", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA=="],
"exceljs": ["exceljs@4.4.0", "", { "dependencies": { "archiver": "^5.0.0", "dayjs": "^1.8.34", "fast-csv": "^4.3.1", "jszip": "^3.10.1", "readable-stream": "^3.6.0", "saxes": "^5.0.1", "tmp": "^0.2.0", "unzipper": "^0.10.11", "uuid": "^8.3.0" } }, "sha512-XctvKaEMaj1Ii9oDOqbW/6e1gXknSY4g/aLCDicOXqBE4M0nRWkUu0PTp++UPNzoFY12BNHMfs/VadKIS6llvg=="],
"fast-csv": ["fast-csv@4.3.6", "", { "dependencies": { "@fast-csv/format": "4.3.5", "@fast-csv/parse": "4.3.6" } }, "sha512-2RNSpuwwsJGP0frGsOmTb9oUF+VkFSM4SyLTDgwf2ciHWTarN0lQTC+F2f/t5J9QjW+c65VFIAAu85GsvMIusw=="],
"fast-xml-builder": ["fast-xml-builder@1.1.4", "", { "dependencies": { "path-expression-matcher": "^1.1.3" } }, "sha512-f2jhpN4Eccy0/Uz9csxh3Nu6q4ErKxf0XIsasomfOihuSUa3/xw6w8dnOtCDgEItQFJG8KyXPzQXzcODDrrbOg=="],
"fast-xml-parser": ["fast-xml-parser@5.5.11", "", { "dependencies": { "fast-xml-builder": "^1.1.4", "path-expression-matcher": "^1.4.0", "strnum": "^2.2.3" }, "bin": { "fxparser": "src/cli/cli.js" } }, "sha512-QL0eb0YbSTVWF6tTf1+LEMSgtCEjBYPpnAjoLC8SscESlAjXEIRJ7cHtLG0pLeDFaZLa4VKZLArtA/60ZS7vyA=="],
"frac": ["frac@1.1.2", "", {}, "sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA=="],
"fs-constants": ["fs-constants@1.0.0", "", {}, "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow=="],
"fs.realpath": ["fs.realpath@1.0.0", "", {}, "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw=="],
"fstream": ["fstream@1.0.12", "", { "dependencies": { "graceful-fs": "^4.1.2", "inherits": "~2.0.0", "mkdirp": ">=0.5 0", "rimraf": "2" } }, "sha512-WvJ193OHa0GHPEL+AycEJgxvBEwyfRkN1vhjca23OaPVMCaLCXTd5qAu82AjTcgP1UJmytkOKb63Ypde7raDIg=="],
"function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="],
"get-intrinsic": ["get-intrinsic@1.3.0", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="],
"get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="],
"glob": ["glob@7.2.3", "", { "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", "inherits": "2", "minimatch": "^3.1.1", "once": "^1.3.0", "path-is-absolute": "^1.0.0" } }, "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q=="],
"gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="],
"graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="],
"has-symbols": ["has-symbols@1.1.0", "", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="],
"hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="],
"iconv-lite": ["iconv-lite@0.7.2", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw=="],
"ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="],
"immediate": ["immediate@3.0.6", "", {}, "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ=="],
"inflight": ["inflight@1.0.6", "", { "dependencies": { "once": "^1.3.0", "wrappy": "1" } }, "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA=="],
"inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="],
"ioredis": ["ioredis@5.10.1", "", { "dependencies": { "@ioredis/commands": "1.5.1", "cluster-key-slot": "^1.1.0", "debug": "^4.3.4", "denque": "^2.1.0", "lodash.defaults": "^4.2.0", "lodash.isarguments": "^3.1.0", "redis-errors": "^1.2.0", "redis-parser": "^3.0.0", "standard-as-callback": "^2.1.0" } }, "sha512-HuEDBTI70aYdx1v6U97SbNx9F1+svQKBDo30o0b9fw055LMepzpOOd0Ccg9Q6tbqmBSJaMuY0fB7yw9/vjBYCA=="],
"isarray": ["isarray@1.0.0", "", {}, "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ=="],
"jszip": ["jszip@3.10.1", "", { "dependencies": { "lie": "~3.3.0", "pako": "~1.0.2", "readable-stream": "~2.3.6", "setimmediate": "^1.0.5" } }, "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g=="],
"lazystream": ["lazystream@1.0.1", "", { "dependencies": { "readable-stream": "^2.0.5" } }, "sha512-b94GiNHQNy6JNTrt5w6zNyffMrNkXZb3KTkCZJb2V1xaEGCk093vkZ2jk3tpaeP33/OiXC+WvK9AxUebnf5nbw=="],
"lie": ["lie@3.3.0", "", { "dependencies": { "immediate": "~3.0.5" } }, "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ=="],
"listenercount": ["listenercount@1.0.1", "", {}, "sha512-3mk/Zag0+IJxeDrxSgaDPy4zZ3w05PRZeJNnlWhzFz5OkX49J4krc+A8X2d2M69vGMBEX0uyl8M+W+8gH+kBqQ=="],
"lodash": ["lodash@4.18.1", "", {}, "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q=="],
"lodash.defaults": ["lodash.defaults@4.2.0", "", {}, "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ=="],
"lodash.difference": ["lodash.difference@4.5.0", "", {}, "sha512-dS2j+W26TQ7taQBGN8Lbbq04ssV3emRw4NY58WErlTO29pIqS0HmoT5aJ9+TUQ1N3G+JOZSji4eugsWwGp9yPA=="],
"lodash.escaperegexp": ["lodash.escaperegexp@4.1.2", "", {}, "sha512-TM9YBvyC84ZxE3rgfefxUWiQKLilstD6k7PTGt6wfbtXF8ixIJLOL3VYyV/z+ZiPLsVxAsKAFVwWlWeb2Y8Yyw=="],
"lodash.flatten": ["lodash.flatten@4.4.0", "", {}, "sha512-C5N2Z3DgnnKr0LOpv/hKCgKdb7ZZwafIrsesve6lmzvZIRZRGaZ/l6Q8+2W7NaT+ZwO3fFlSCzCzrDCFdJfZ4g=="],
"lodash.groupby": ["lodash.groupby@4.6.0", "", {}, "sha512-5dcWxm23+VAoz+awKmBaiBvzox8+RqMgFhi7UvX9DHZr2HdxHXM/Wrf8cfKpsW37RNrvtPn6hSwNqurSILbmJw=="],
"lodash.isarguments": ["lodash.isarguments@3.1.0", "", {}, "sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg=="],
"lodash.isboolean": ["lodash.isboolean@3.0.3", "", {}, "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg=="],
"lodash.isequal": ["lodash.isequal@4.5.0", "", {}, "sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ=="],
"lodash.isfunction": ["lodash.isfunction@3.0.9", "", {}, "sha512-AirXNj15uRIMMPihnkInB4i3NHeb4iBtNg9WRWuK2o31S+ePwwNmDPaTL3o7dTJ+VXNZim7rFs4rxN4YU1oUJw=="],
"lodash.isnil": ["lodash.isnil@4.0.0", "", {}, "sha512-up2Mzq3545mwVnMhTDMdfoG1OurpA/s5t88JmQX809eH3C8491iu2sfKhTfhQtKY78oPNhiaHJUpT/dUDAAtng=="],
"lodash.isplainobject": ["lodash.isplainobject@4.0.6", "", {}, "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA=="],
"lodash.isundefined": ["lodash.isundefined@3.0.1", "", {}, "sha512-MXB1is3s899/cD8jheYYE2V9qTHwKvt+npCwpD+1Sxm3Q3cECXCiYHjeHWXNwr6Q0SOBPrYUDxendrO6goVTEA=="],
"lodash.union": ["lodash.union@4.6.0", "", {}, "sha512-c4pB2CdGrGdjMKYLA+XiRDO7Y0PRQbm/Gzg8qMj+QH+pFVAoTp5sBpO0odL3FjoPCGjK96p6qsP+yQoiLoOBcw=="],
"lodash.uniq": ["lodash.uniq@4.5.0", "", {}, "sha512-xfBaXQd9ryd9dlSDvnvI0lvxfLJlYAZzXomUYzLKtUeOQvOP5piqAWuGtrhWeqaXK9hhoM/iyJc5AV+XfsX3HQ=="],
"math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="],
"minimatch": ["minimatch@5.1.9", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw=="],
"mkdirp": ["mkdirp@3.0.1", "", { "bin": { "mkdirp": "dist/cjs/src/bin.js" } }, "sha512-+NsyUUAZDmo6YVHzL/stxSu3t9YS1iljliy3BSDrXJ/dkn1KYdmtZODGGjLcc9XLgVVpH4KshHB8XmZgMhaBXg=="],
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
"normalize-path": ["normalize-path@3.0.0", "", {}, "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA=="],
"object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="],
"once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="],
"pako": ["pako@1.0.11", "", {}, "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw=="],
"path-expression-matcher": ["path-expression-matcher@1.4.0", "", {}, "sha512-s4DQMxIdhj3jLFWd9LxHOplj4p9yQ4ffMGowFf3cpEgrrJjEhN0V5nxw4Ye1EViAGDoL4/1AeO6qHpqYPOzE4Q=="],
"path-is-absolute": ["path-is-absolute@1.0.1", "", {}, "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg=="],
"process-nextick-args": ["process-nextick-args@2.0.1", "", {}, "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag=="],
"qs": ["qs@6.15.1", "", { "dependencies": { "side-channel": "^1.1.0" } }, "sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg=="],
"react": ["react@19.2.5", "", {}, "sha512-llUJLzz1zTUBrskt2pwZgLq59AemifIftw4aB7JxOqf1HY2FDaGDxgwpAPVzHU1kdWabH7FauP4i1oEeer2WCA=="],
"react-dom": ["react-dom@19.2.5", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.5" } }, "sha512-J5bAZz+DXMMwW/wV3xzKke59Af6CHY7G4uYLN1OvBcKEsWOs4pQExj86BBKamxl/Ik5bx9whOrvBlSDfWzgSag=="],
"readable-stream": ["readable-stream@3.6.2", "", { "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", "util-deprecate": "^1.0.1" } }, "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA=="],
"readdir-glob": ["readdir-glob@1.1.3", "", { "dependencies": { "minimatch": "^5.1.0" } }, "sha512-v05I2k7xN8zXvPD9N+z/uhXPaj0sUFCe2rcWZIpBsqxfP7xXFQ0tipAd/wjj1YxWyWtUS5IDJpOG82JKt2EAVA=="],
"redis-errors": ["redis-errors@1.2.0", "", {}, "sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w=="],
"redis-parser": ["redis-parser@3.0.0", "", { "dependencies": { "redis-errors": "^1.0.0" } }, "sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A=="],
"rimraf": ["rimraf@2.7.1", "", { "dependencies": { "glob": "^7.1.3" }, "bin": { "rimraf": "./bin.js" } }, "sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w=="],
"safe-buffer": ["safe-buffer@5.1.2", "", {}, "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="],
"safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="],
"saxes": ["saxes@5.0.1", "", { "dependencies": { "xmlchars": "^2.2.0" } }, "sha512-5LBh1Tls8c9xgGjw3QrMwETmTMVk0oFgvrFSvWx62llR2hcEInrKNZ2GZCCuuy2lvWrdl5jhbpeqc5hRYKFOcw=="],
"scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="],
"setimmediate": ["setimmediate@1.0.5", "", {}, "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA=="],
"side-channel": ["side-channel@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3", "side-channel-list": "^1.0.0", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" } }, "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw=="],
"side-channel-list": ["side-channel-list@1.0.1", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.4" } }, "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w=="],
@@ -128,16 +257,68 @@
"standard-as-callback": ["standard-as-callback@2.1.0", "", {}, "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A=="],
"string_decoder": ["string_decoder@1.3.0", "", { "dependencies": { "safe-buffer": "~5.2.0" } }, "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA=="],
"strnum": ["strnum@2.2.3", "", {}, "sha512-oKx6RUCuHfT3oyVjtnrmn19H1SiCqgJSg+54XqURKp5aCMbrXrhLjRN9TjuwMjiYstZ0MzDrHqkGZ5dFTKd+zg=="],
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
"tar-stream": ["tar-stream@2.2.0", "", { "dependencies": { "bl": "^4.0.3", "end-of-stream": "^1.4.1", "fs-constants": "^1.0.0", "inherits": "^2.0.3", "readable-stream": "^3.1.1" } }, "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ=="],
"tmp": ["tmp@0.2.5", "", {}, "sha512-voyz6MApa1rQGUxT3E+BK7/ROe8itEx7vD8/HEvt4xwXucvQ5G5oeEiHkmHZJuBO21RpOf+YYm9MOivj709jow=="],
"traverse": ["traverse@0.3.9", "", {}, "sha512-iawgk0hLP3SxGKDfnDJf8wTz4p2qImnyihM5Hh/sGvQ3K37dPi/w8sRhdNIxYA1TwFwc5mDhIJq+O0RsvXBKdQ=="],
"typescript": ["typescript@6.0.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw=="],
"undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="],
"unzipper": ["unzipper@0.10.14", "", { "dependencies": { "big-integer": "^1.6.17", "binary": "~0.3.0", "bluebird": "~3.4.1", "buffer-indexof-polyfill": "~1.0.0", "duplexer2": "~0.1.4", "fstream": "^1.0.12", "graceful-fs": "^4.2.2", "listenercount": "~1.0.1", "readable-stream": "~2.3.6", "setimmediate": "~1.0.4" } }, "sha512-ti4wZj+0bQTiX2KmKWuwj7lhV+2n//uXEotUmGuQqrbVZSEGFMbI68+c6JCQ8aAmUWYvtHEz2A8K6wXvueR/6g=="],
"util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="],
"uuid": ["uuid@8.3.2", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg=="],
"wmf": ["wmf@1.0.2", "", {}, "sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw=="],
"word": ["word@0.3.0", "", {}, "sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA=="],
"wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="],
"xlsx": ["xlsx@0.18.5", "", { "dependencies": { "adler-32": "~1.3.0", "cfb": "~1.2.1", "codepage": "~1.15.0", "crc-32": "~1.2.1", "ssf": "~0.11.2", "wmf": "~1.0.1", "word": "~0.3.0" }, "bin": { "xlsx": "bin/xlsx.njs" } }, "sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ=="],
"xmlchars": ["xmlchars@2.2.0", "", {}, "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw=="],
"zip-stream": ["zip-stream@4.1.1", "", { "dependencies": { "archiver-utils": "^3.0.4", "compress-commons": "^4.1.2", "readable-stream": "^3.6.0" } }, "sha512-9qv4rlDiopXg4E69k+vMHjNN63YFMe9sZMrdlvKnCjlCRWeCBswPPMPUfx+ipsAWq1LXHe70RcbaHdJJpS6hyQ=="],
"@fast-csv/format/@types/node": ["@types/node@14.18.63", "", {}, "sha512-fAtCfv4jJg+ExtXhvCkCqUKZ+4ok/JQk01qDKhL5BDDoS3AxKXhV5/MAVUZyQnSEd2GT92fkgZl0pz0Q0AzcIQ=="],
"@fast-csv/parse/@types/node": ["@types/node@14.18.63", "", {}, "sha512-fAtCfv4jJg+ExtXhvCkCqUKZ+4ok/JQk01qDKhL5BDDoS3AxKXhV5/MAVUZyQnSEd2GT92fkgZl0pz0Q0AzcIQ=="],
"archiver-utils/readable-stream": ["readable-stream@2.3.8", "", { "dependencies": { "core-util-is": "~1.0.0", "inherits": "~2.0.3", "isarray": "~1.0.0", "process-nextick-args": "~2.0.0", "safe-buffer": "~5.1.1", "string_decoder": "~1.1.1", "util-deprecate": "~1.0.1" } }, "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA=="],
"duplexer2/readable-stream": ["readable-stream@2.3.8", "", { "dependencies": { "core-util-is": "~1.0.0", "inherits": "~2.0.3", "isarray": "~1.0.0", "process-nextick-args": "~2.0.0", "safe-buffer": "~5.1.1", "string_decoder": "~1.1.1", "util-deprecate": "~1.0.1" } }, "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA=="],
"glob/minimatch": ["minimatch@3.1.5", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w=="],
"jszip/readable-stream": ["readable-stream@2.3.8", "", { "dependencies": { "core-util-is": "~1.0.0", "inherits": "~2.0.3", "isarray": "~1.0.0", "process-nextick-args": "~2.0.0", "safe-buffer": "~5.1.1", "string_decoder": "~1.1.1", "util-deprecate": "~1.0.1" } }, "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA=="],
"lazystream/readable-stream": ["readable-stream@2.3.8", "", { "dependencies": { "core-util-is": "~1.0.0", "inherits": "~2.0.3", "isarray": "~1.0.0", "process-nextick-args": "~2.0.0", "safe-buffer": "~5.1.1", "string_decoder": "~1.1.1", "util-deprecate": "~1.0.1" } }, "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA=="],
"string_decoder/safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="],
"unzipper/readable-stream": ["readable-stream@2.3.8", "", { "dependencies": { "core-util-is": "~1.0.0", "inherits": "~2.0.3", "isarray": "~1.0.0", "process-nextick-args": "~2.0.0", "safe-buffer": "~5.1.1", "string_decoder": "~1.1.1", "util-deprecate": "~1.0.1" } }, "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA=="],
"zip-stream/archiver-utils": ["archiver-utils@3.0.4", "", { "dependencies": { "glob": "^7.2.3", "graceful-fs": "^4.2.0", "lazystream": "^1.0.0", "lodash.defaults": "^4.2.0", "lodash.difference": "^4.5.0", "lodash.flatten": "^4.4.0", "lodash.isplainobject": "^4.0.6", "lodash.union": "^4.6.0", "normalize-path": "^3.0.0", "readable-stream": "^3.6.0" } }, "sha512-KVgf4XQVrTjhyWmx6cte4RxonPLR9onExufI1jhvw/MQ4BB6IsZD5gT8Lq+u/+pRkWna/6JoHpiQioaqFP5Rzw=="],
"archiver-utils/readable-stream/string_decoder": ["string_decoder@1.1.1", "", { "dependencies": { "safe-buffer": "~5.1.0" } }, "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg=="],
"duplexer2/readable-stream/string_decoder": ["string_decoder@1.1.1", "", { "dependencies": { "safe-buffer": "~5.1.0" } }, "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg=="],
"glob/minimatch/brace-expansion": ["brace-expansion@1.1.14", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g=="],
"jszip/readable-stream/string_decoder": ["string_decoder@1.1.1", "", { "dependencies": { "safe-buffer": "~5.1.0" } }, "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg=="],
"lazystream/readable-stream/string_decoder": ["string_decoder@1.1.1", "", { "dependencies": { "safe-buffer": "~5.1.0" } }, "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg=="],
"unzipper/readable-stream/string_decoder": ["string_decoder@1.1.1", "", { "dependencies": { "safe-buffer": "~5.1.0" } }, "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg=="],
}
}

View File

@@ -5,3 +5,4 @@ id,name
283155,Books
16310101,Grocery Gourmet Food
599858,Magazine Subscriptions
5174,CDs & Vinyl
1 id name
5 283155 Books
6 16310101 Grocery Gourmet Food
7 599858 Magazine Subscriptions
8 5174 CDs & Vinyl

View File

@@ -6,6 +6,9 @@
"scripts": {
"bestsellers": "bun run src/bestsellers-by-category.ts",
"monthly-sold": "bun run src/top-monthly-sold-by-category.ts",
"mid-range": "bun run src/mid-range-sellers-by-category.ts",
"upc": "bun run src/upc-lookup.ts",
"upc-file": "bun run src/upc-file-analysis.ts",
"start": "bun run src/index.ts",
"start:web": "bun --hot src/server.ts",
"build:web": "bun build src/web/index.html --outdir dist",
@@ -14,13 +17,12 @@
"devDependencies": {
"@types/bun": "latest",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3"
},
"peerDependencies": {
"typescript": "^5"
"@types/react-dom": "^19.2.3",
"typescript": "^6.0.3"
},
"dependencies": {
"amazon-sp-api": "^1.2.1",
"exceljs": "^4.4.0",
"ioredis": "^5.10.1",
"react": "^19.2.0",
"react-dom": "^19.2.0",

276
src/analysis-pipeline.ts Normal file
View File

@@ -0,0 +1,276 @@
import { fetchKeepaDataBatch } from "./keepa.ts";
import { fetchSellabilityBatch, fetchSpApiPricingAndFees } from "./sp-api.ts";
import { getCache, setCache } from "./cache.ts";
import { analyzeProducts } from "./llm.ts";
import type {
AnalysisResult,
EnrichedProduct,
KeepaData,
ProductRecord,
SellabilityInfo,
SpApiData,
} from "./types.ts";
export const DEFAULT_LLM_BATCH_SIZE = 5;
export const DEFAULT_PRICING_CONCURRENCY = 5;
export type SellabilityFilter = "available" | "all";
export type AnalysisPipelineOptions = {
llmBatchSize?: number;
pricingConcurrency?: number;
llmBatchDelayMs?: number;
llmRetryDelayMs?: number;
sellability?: SellabilityFilter;
};
export function chunkArray<T>(items: T[], chunkSize: number): T[][] {
const chunks: T[][] = [];
for (let i = 0; i < items.length; i += chunkSize) {
chunks.push(items.slice(i, i + chunkSize));
}
return chunks;
}
function wait(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function unknownSpApiData(reason: string): SpApiData {
return {
fbaFee: 5.0,
fbmFee: 1.5,
referralFeePercent: 15,
estimatedSalePrice: 0,
canSell: null,
sellabilityStatus: "unknown",
sellabilityReason: reason,
};
}
export async function processProductChunk(
products: ProductRecord[],
options: AnalysisPipelineOptions = {},
): Promise<AnalysisResult[]> {
const llmBatchSize = options.llmBatchSize ?? DEFAULT_LLM_BATCH_SIZE;
const pricingConcurrency = Math.max(
1,
options.pricingConcurrency ?? DEFAULT_PRICING_CONCURRENCY,
);
const llmBatchDelayMs = Math.max(0, options.llmBatchDelayMs ?? 5_000);
const llmRetryDelayMs = Math.max(0, options.llmRetryDelayMs ?? 10_000);
const sellabilityFilter = options.sellability ?? "available";
console.log(`\nChecking cache for ${products.length} products...`);
const cached = new Map<string, EnrichedProduct>();
const excludedCachedAsins = new Set<string>();
const uncachedProducts: ProductRecord[] = [];
for (const p of products) {
const hit = await getCache(p.asin);
if (hit) {
if (
sellabilityFilter === "all" ||
hit.spApi.sellabilityStatus === "available"
) {
console.log(` [cache hit] ${p.asin}`);
cached.set(p.asin, hit);
} else {
excludedCachedAsins.add(p.asin);
console.log(
` [exclude cached] ${p.asin} - status=${hit.spApi.sellabilityStatus}`,
);
}
} else {
uncachedProducts.push(p);
}
}
console.log(
`${cached.size} cached available, ${excludedCachedAsins.size} cached excluded, ${uncachedProducts.length} to fetch`,
);
const sellabilityMap = new Map<string, SellabilityInfo>();
const availableProducts: ProductRecord[] = [];
const unavailableProducts: ProductRecord[] = [];
if (uncachedProducts.length > 0) {
console.log(
`\nChecking sellability for ${uncachedProducts.length} ASINs...`,
);
const sellResults = await fetchSellabilityBatch(
uncachedProducts.map((p) => p.asin),
);
for (const p of uncachedProducts) {
const info = sellResults.get(p.asin) ?? {
canSell: null,
sellabilityStatus: "unknown" as const,
sellabilityReason: "Sellability check returned no result",
};
sellabilityMap.set(p.asin, info);
if (
sellabilityFilter === "all" ||
info.sellabilityStatus === "available"
) {
availableProducts.push(p);
console.log(
` [available] ${p.asin} - status=${info.sellabilityStatus}`,
);
} else {
unavailableProducts.push(p);
console.log(
` [exclude] ${p.asin} - status=${info.sellabilityStatus}, reason=${info.sellabilityReason ?? "n/a"}`,
);
}
}
if (sellabilityFilter === "all") {
console.log(
`\nSellability gate disabled: including all ${availableProducts.length} products`,
);
} else {
console.log(
`\nSellability gate: ${availableProducts.length} available, ${unavailableProducts.length} excluded`,
);
}
}
let keepaResults = new Map<string, KeepaData>();
if (availableProducts.length > 0) {
console.log(`\nFetching ${availableProducts.length} ASINs from Keepa...`);
try {
keepaResults = await fetchKeepaDataBatch(
availableProducts.map((p) => p.asin),
);
} catch (err) {
console.warn(`Keepa batch fetch failed: ${err}`);
}
}
console.log(
`\nFetching pricing & fees for ${availableProducts.length} ASINs...`,
);
const spApiResults = new Map<string, SpApiData>();
const pricingQueue = [...availableProducts];
let pricingDone = 0;
async function fetchNextPricing(): Promise<void> {
while (pricingQueue.length > 0) {
const p = pricingQueue.shift();
if (!p) return;
const sellability = sellabilityMap.get(p.asin) ?? {
canSell: null,
sellabilityStatus: "unknown" as const,
sellabilityReason: "Sellability check returned no result",
};
const spApi = await fetchSpApiPricingAndFees(p.asin, sellability);
const keepa = keepaResults.get(p.asin);
if (keepa?.currentPrice && spApi.estimatedSalePrice === 0) {
spApi.estimatedSalePrice = keepa.currentPrice;
}
spApiResults.set(p.asin, spApi);
pricingDone++;
if (pricingDone % 10 === 0 || pricingDone === availableProducts.length) {
console.log(
` [pricing] ${pricingDone}/${availableProducts.length} fetched`,
);
}
}
}
const pricingWorkers = Array.from(
{ length: Math.min(pricingConcurrency, availableProducts.length || 1) },
() => fetchNextPricing(),
);
await Promise.all(pricingWorkers);
console.log(`\nEnriching products...`);
const enriched: EnrichedProduct[] = [];
const availableAsins = new Set(availableProducts.map((ap) => ap.asin));
for (const p of products) {
if (excludedCachedAsins.has(p.asin)) {
continue;
}
const cachedProduct = cached.get(p.asin);
if (cachedProduct) {
enriched.push(cachedProduct);
continue;
}
if (!availableAsins.has(p.asin)) {
continue;
}
const keepa = keepaResults.get(p.asin) ?? null;
const spApi =
spApiResults.get(p.asin) ?? unknownSpApiData("SP-API data missing");
const product: EnrichedProduct = {
record: p,
keepa,
spApi,
fetchedAt: new Date().toISOString(),
};
await setCache(p.asin, product);
enriched.push(product);
}
console.log(
`\nAnalyzing ${enriched.length} products via LLM (batch size: ${llmBatchSize})...\n`,
);
const results: AnalysisResult[] = [];
for (let i = 0; i < enriched.length; i += llmBatchSize) {
const batch = enriched.slice(i, i + llmBatchSize);
const batchNum = Math.floor(i / llmBatchSize) + 1;
const totalBatches = Math.ceil(enriched.length / llmBatchSize);
console.log(` LLM batch ${batchNum}/${totalBatches}...`);
if (i > 0 && llmBatchDelayMs > 0) {
await wait(llmBatchDelayMs);
}
let verdicts;
try {
verdicts = await analyzeProducts(batch, {
ignoreSellability: sellabilityFilter === "all",
});
} catch {
if (llmRetryDelayMs > 0) {
await wait(llmRetryDelayMs);
}
try {
verdicts = await analyzeProducts(batch, {
ignoreSellability: sellabilityFilter === "all",
});
} catch {
verdicts = null;
}
}
for (let j = 0; j < batch.length; j++) {
const enrichedProduct = batch[j];
if (!enrichedProduct) continue;
results.push({
product: enrichedProduct,
verdict: verdicts?.[j] ?? {
asin: enrichedProduct.record.asin,
verdict: "SKIP",
confidence: 0,
reasoning: "LLM analysis failed",
},
});
}
}
return results;
}

View File

@@ -14,7 +14,6 @@ import type {
SpApiData,
} from "./types.ts";
type CategoryInfo = {
id: number;
label: string;
@@ -45,6 +44,8 @@ type CategoryRunSummary = {
const KEEPA_BASE = "https://api.keepa.com";
const DOMAIN_US = 1;
const AMAZON_US_SELLER_ID = "ATVPDKIKX0DER";
const KEEPA_MINUTES_OFFSET = 21_564_000;
const DEFAULT_CATEGORY_LIMIT = 32;
const DEFAULT_PER_CATEGORY_TOP = 100;
const SELLABILITY_BATCH_SIZE = 60;
@@ -162,7 +163,16 @@ export async function insertCategoryRunSummary(
export async function updateCategoryRunSummary(
db: Database,
runId: number,
summary: Pick<CategoryRunSummary, "topAsinsChecked" | "availableAsins" | "fba" | "fbm" | "skip" | "status" | "error">,
summary: Pick<
CategoryRunSummary,
| "topAsinsChecked"
| "availableAsins"
| "fba"
| "fbm"
| "skip"
| "status"
| "error"
>,
): Promise<void> {
db.run(
`
@@ -204,14 +214,15 @@ export async function insertProductAnalysisResults(
asin, run_id, name, brand, category, unit_cost,
current_price, avg_price_90d, avg_price_90d_sheet,
selling_price_sheet, sales_rank, sales_rank_avg_90d,
seller_count, monthly_sold, rank_drops_30d, rank_drops_90d,
seller_count, amazon_is_seller, amazon_buybox_share_pct_90d,
monthly_sold, rank_drops_30d, rank_drops_90d,
fba_fee, fbm_fee, referral_percent, can_sell,
sellability_status, sellability_reason,
verdict, confidence, reasoning, fetched_at
) VALUES (
?, ?, ?, ?, ?, ?, ?, ?, ?, ?,
?, ?, ?, ?, ?, ?, ?, ?, ?, ?,
?, ?, ?, ?, ?, ?
?, ?, ?, ?, ?, ?, ?, ?
)
ON CONFLICT(asin) DO UPDATE SET
run_id = excluded.run_id,
@@ -226,6 +237,8 @@ export async function insertProductAnalysisResults(
sales_rank = excluded.sales_rank,
sales_rank_avg_90d = excluded.sales_rank_avg_90d,
seller_count = excluded.seller_count,
amazon_is_seller = excluded.amazon_is_seller,
amazon_buybox_share_pct_90d = excluded.amazon_buybox_share_pct_90d,
monthly_sold = excluded.monthly_sold,
rank_drops_30d = excluded.rank_drops_30d,
rank_drops_90d = excluded.rank_drops_90d,
@@ -265,6 +278,12 @@ export async function insertProductAnalysisResults(
rank ?? null,
r.product.keepa?.salesRankAvg90 ?? null,
r.product.keepa?.sellerCount ?? null,
r.product.keepa?.amazonIsSeller == null
? null
: r.product.keepa.amazonIsSeller
? 1
: 0,
r.product.keepa?.amazonBuyboxSharePct90d ?? null,
r.product.keepa?.monthlySold ?? null,
r.product.keepa?.salesRankDrops30 ?? null,
r.product.keepa?.salesRankDrops90 ?? null,
@@ -776,6 +795,14 @@ function parseKeepaProduct(product: Record<string, any>): KeepaData {
const monthlySold =
pickKeepaNumber(product.monthlySold, stats?.monthlySold) ??
salesRankDrops30;
const amazonIsSeller = resolveAmazonIsSeller(product, stats, csv);
const amazonBuyboxSharePct90d =
extractAmazonBuyboxSharePct90d(product, stats) ??
computeAmazonBuyBoxSharePctFromHistory(
product.buyBoxSellerIdHistory,
90,
new Set([AMAZON_US_SELLER_ID]),
);
return {
currentPrice: extractCurrentPrice(csv),
@@ -787,6 +814,8 @@ function parseKeepaProduct(product: Record<string, any>): KeepaData {
salesRankDrops30,
salesRankDrops90,
sellerCount: stats?.current?.[11] ?? null,
amazonIsSeller,
amazonBuyboxSharePct90d,
buyBoxSeller: product.buyBoxSellerId ?? null,
buyBoxPrice: stats?.current?.[18] != null ? stats.current[18] / 100 : null,
monthlySold,
@@ -795,6 +824,108 @@ function parseKeepaProduct(product: Record<string, any>): KeepaData {
};
}
function resolveAmazonIsSeller(
product: Record<string, any>,
stats: Record<string, any> | undefined,
csv: number[][] | undefined,
): boolean | null {
if (typeof product.isAmazonSeller === "boolean")
return product.isAmazonSeller;
if (typeof product.availabilityAmazon === "number") {
if (product.availabilityAmazon >= 0) return true;
if (
product.availabilityAmazon === -1 ||
product.availabilityAmazon === -2
) {
return false;
}
}
if (stats?.buyBoxIsAmazon === true) return true;
if (typeof stats?.current?.[0] === "number") {
if (stats.current[0] > 0) return true;
if (stats.current[0] === -1 || stats.current[0] === -2) return false;
}
const latestAmazonPrice = extractLatestPositivePrice(csv?.[0]);
if (latestAmazonPrice != null) return true;
return null;
}
function extractAmazonBuyboxSharePct90d(
product: Record<string, any>,
stats: Record<string, any> | undefined,
): number | null {
const candidates: unknown[] = [
product.buyBoxStatsAmazon90,
stats?.buyBoxStatsAmazon90,
product.buyBoxStats?.amazon90,
product.buyBoxStats?.amazon?.[90],
product.buyBoxStats?.amazon?.["90"],
product.buyBoxStats?.[AMAZON_US_SELLER_ID]?.[90],
product.buyBoxStats?.[AMAZON_US_SELLER_ID]?.["90"],
];
for (const value of candidates) {
if (typeof value !== "number" || !Number.isFinite(value)) continue;
if (value < 0 || value > 100) continue;
return Math.round(value * 100) / 100;
}
return null;
}
function computeAmazonBuyBoxSharePctFromHistory(
history: unknown,
windowDays: number,
amazonSellerIds: Set<string>,
): number | null {
if (!Array.isArray(history) || history.length < 2) return null;
const nowKeepaMinutes =
Math.floor(Date.now() / 60_000) - KEEPA_MINUTES_OFFSET;
const windowStart = nowKeepaMinutes - windowDays * 24 * 60;
let qualifiedMinutes = 0;
let amazonMinutes = 0;
for (let i = 0; i < history.length - 1; i += 2) {
const startMinute = Number.parseInt(String(history[i]), 10);
const sellerId = String(history[i + 1] ?? "").toUpperCase();
const nextRaw = i + 2 < history.length ? history[i + 2] : nowKeepaMinutes;
const endMinute = Number.parseInt(String(nextRaw), 10);
if (!Number.isFinite(startMinute) || !Number.isFinite(endMinute)) continue;
if (endMinute <= startMinute) continue;
const intervalStart = Math.max(startMinute, windowStart);
const intervalEnd = Math.min(endMinute, nowKeepaMinutes);
if (intervalEnd <= intervalStart) continue;
if (sellerId === "-1" || sellerId === "-2") continue;
const minutes = intervalEnd - intervalStart;
qualifiedMinutes += minutes;
if (amazonSellerIds.has(sellerId)) {
amazonMinutes += minutes;
}
}
if (qualifiedMinutes === 0) return null;
return Math.round((amazonMinutes / qualifiedMinutes) * 10_000) / 100;
}
function extractLatestPositivePrice(series: unknown): number | null {
if (!Array.isArray(series) || series.length < 2) return null;
const last = series[series.length - 1];
if (typeof last !== "number" || !Number.isFinite(last) || last <= 0) {
return null;
}
return last / 100;
}
async function fetchKeepaEnrichmentMap(
asins: string[],
): Promise<Map<string, { keepa: KeepaData; title: string }>> {
@@ -804,7 +935,7 @@ async function fetchKeepaEnrichmentMap(
const chunk = asins.slice(i, i + KEEPA_PRODUCT_CHUNK_SIZE);
const asinParam = encodeURIComponent(chunk.join(","));
const data = await keepaGetJson(
`/product?key=${encodeURIComponent(config.keepaApiKey)}&domain=${DOMAIN_US}&asin=${asinParam}&stats=90`,
`/product?key=${encodeURIComponent(config.keepaApiKey)}&domain=${DOMAIN_US}&asin=${asinParam}&stats=90&buybox=1&days=90`,
);
const products = Array.isArray(data?.products) ? data.products : [];
@@ -911,7 +1042,10 @@ export async function processCategory(
const uniqueTopAsins = Array.from(new Set(topAsins));
if (uniqueTopAsins.length !== topAsins.length) {
log("warn", ` Removed ${topAsins.length - uniqueTopAsins.length} duplicate ASINs before analysis.`);
log(
"warn",
` Removed ${topAsins.length - uniqueTopAsins.length} duplicate ASINs before analysis.`,
);
}
log("info", ` Top ASINs fetched: ${uniqueTopAsins.length}`);
@@ -922,7 +1056,10 @@ export async function processCategory(
return info?.canSell === true && info.sellabilityStatus === "available";
});
log("info", ` Sellable ASINs: ${availableAsins.length}/${uniqueTopAsins.length}`);
log(
"info",
` Sellable ASINs: ${availableAsins.length}/${uniqueTopAsins.length}`,
);
if (availableAsins.length === 0) {
await updateCategoryRunSummary(db, runId, {
topAsinsChecked: uniqueTopAsins.length,
@@ -1054,7 +1191,8 @@ export async function main(): Promise<void> {
assertSpApiPrerequisites();
mkdirSync(args.outputDir, { recursive: true });
const DB_PATH = process.env.RESULTS_DB_PATH || path.join(process.cwd(), "results.db");
const DB_PATH =
process.env.RESULTS_DB_PATH || path.join(process.cwd(), "db", "results.db");
initDb(DB_PATH);
const db = getDb(DB_PATH);

View File

@@ -1,10 +1,21 @@
import Redis from "ioredis";
import { config } from "./config.ts";
import type { EnrichedProduct } from "./types.ts";
import type { EnrichedProduct, KeepaData, SpApiData } from "./types.ts";
let redis: Redis | null = null;
let disabled = false;
export type ApiCacheEntry = {
title: string;
keepa: KeepaData | null;
spApi: SpApiData;
fetchedAt: string;
};
function getApiCacheKey(asin: string): string {
return `api:asin:${asin}`;
}
export async function connectCache(): Promise<void> {
if (disabled) return;
try {
@@ -58,6 +69,35 @@ export async function setCache(
}
}
export async function getApiCache(asin: string): Promise<ApiCacheEntry | null> {
if (!redis) return null;
try {
const raw = await redis.get(getApiCacheKey(asin));
if (!raw) return null;
return JSON.parse(raw) as ApiCacheEntry;
} catch {
return null;
}
}
export async function setApiCache(
asin: string,
data: ApiCacheEntry,
ttlSeconds: number,
): Promise<void> {
if (!redis) return;
try {
await redis.set(
getApiCacheKey(asin),
JSON.stringify(data),
"EX",
ttlSeconds,
);
} catch {
// Non-critical, continue without caching
}
}
export async function disconnectCache(): Promise<void> {
if (redis) {
await redis.quit();

View File

@@ -2,7 +2,8 @@ import { getDb } from "./database.ts";
import path from "node:path";
async function checkDb() {
const DB_PATH = path.join(process.cwd(), "temp_output", "analysis.sqlite");
const DB_PATH =
process.env.RESULTS_DB_PATH || path.join(process.cwd(), "db", "results.db");
const db = getDb(DB_PATH);
try {

View File

@@ -1,10 +1,16 @@
import { Database } from "bun:sqlite";
import { dirname } from "node:path";
import { mkdirSync } from "node:fs";
export { Database } from "bun:sqlite";
let db: Database | null = null;
export function getDb(dbPath: string): Database {
if (!db) {
const dbDir = dirname(dbPath);
if (dbDir && dbDir !== ".") {
mkdirSync(dbDir, { recursive: true });
}
db = new Database(dbPath);
db.run("PRAGMA journal_mode = WAL;"); // Enable WAL mode for better performance
db.run("PRAGMA foreign_keys = ON;"); // Enforce foreign key constraints
@@ -36,6 +42,8 @@ function createProductAnalysisResultsTable(database: Database): void {
sales_rank INTEGER,
sales_rank_avg_90d INTEGER,
seller_count INTEGER,
amazon_is_seller INTEGER,
amazon_buybox_share_pct_90d REAL,
monthly_sold INTEGER,
rank_drops_30d INTEGER,
rank_drops_90d INTEGER,
@@ -92,7 +100,9 @@ function ensureProductAnalysisResultsTable(database: Database): void {
asin, run_id, name, brand, category, unit_cost,
current_price, avg_price_90d, avg_price_90d_sheet,
selling_price_sheet, sales_rank, sales_rank_avg_90d,
seller_count, monthly_sold, rank_drops_30d, rank_drops_90d,
seller_count, NULL AS amazon_is_seller,
NULL AS amazon_buybox_share_pct_90d,
monthly_sold, rank_drops_30d, rank_drops_90d,
fba_fee, fbm_fee, referral_percent, can_sell,
sellability_status, sellability_reason,
verdict, confidence, reasoning, fetched_at,
@@ -106,7 +116,8 @@ function ensureProductAnalysisResultsTable(database: Database): void {
asin, run_id, name, brand, category, unit_cost,
current_price, avg_price_90d, avg_price_90d_sheet,
selling_price_sheet, sales_rank, sales_rank_avg_90d,
seller_count, monthly_sold, rank_drops_30d, rank_drops_90d,
seller_count, amazon_is_seller, amazon_buybox_share_pct_90d,
monthly_sold, rank_drops_30d, rank_drops_90d,
fba_fee, fbm_fee, referral_percent, can_sell,
sellability_status, sellability_reason,
verdict, confidence, reasoning, fetched_at
@@ -115,7 +126,8 @@ function ensureProductAnalysisResultsTable(database: Database): void {
asin, run_id, name, brand, category, unit_cost,
current_price, avg_price_90d, avg_price_90d_sheet,
selling_price_sheet, sales_rank, sales_rank_avg_90d,
seller_count, monthly_sold, rank_drops_30d, rank_drops_90d,
seller_count, amazon_is_seller, amazon_buybox_share_pct_90d,
monthly_sold, rank_drops_30d, rank_drops_90d,
fba_fee, fbm_fee, referral_percent, can_sell,
sellability_status, sellability_reason,
verdict, confidence, reasoning, fetched_at
@@ -126,6 +138,30 @@ function ensureProductAnalysisResultsTable(database: Database): void {
}
}
function ensureProductAnalysisResultsColumns(database: Database): void {
const tableInfo = database
.query("PRAGMA table_info(product_analysis_results)")
.all() as Array<{ name: string }>;
if (tableInfo.length === 0) {
return;
}
const existingColumns = new Set(tableInfo.map((col) => col.name));
const requiredColumns: Array<{ name: string; type: string }> = [
{ name: "amazon_is_seller", type: "INTEGER" },
{ name: "amazon_buybox_share_pct_90d", type: "REAL" },
];
for (const column of requiredColumns) {
if (!existingColumns.has(column.name)) {
database.run(
`ALTER TABLE product_analysis_results ADD COLUMN ${column.name} ${column.type}`,
);
}
}
}
function ensureResultsTableColumns(database: Database): void {
const tableInfo = database
.query("PRAGMA table_info(results)")
@@ -151,6 +187,17 @@ function ensureResultsTableColumns(database: Database): void {
{ name: "promo_coupon_code", type: "TEXT" },
{ name: "notes", type: "TEXT" },
{ name: "lead_date", type: "TEXT" },
{ name: "amazon_is_seller", type: "INTEGER" },
{ name: "amazon_buybox_share_pct_90d", type: "REAL" },
{ name: "upc", type: "TEXT" },
{ name: "supplier_score", type: "REAL" },
{ name: "supplier_profit", type: "REAL" },
{ name: "supplier_margin", type: "REAL" },
{ name: "supplier_roi", type: "REAL" },
{ name: "supplier_reason", type: "TEXT" },
{ name: "upc_lookup_status", type: "TEXT" },
{ name: "upc_lookup_reason", type: "TEXT" },
{ name: "candidate_asins", type: "TEXT" },
];
for (const column of requiredColumns) {
@@ -192,6 +239,8 @@ export function initDb(dbPath: string): void {
sales_rank INTEGER,
rank_avg_90d INTEGER,
sellers INTEGER,
amazon_is_seller INTEGER,
amazon_buybox_share_pct_90d REAL,
monthly_sold INTEGER,
rank_drops_30d INTEGER,
rank_drops_90d INTEGER,
@@ -209,9 +258,18 @@ export function initDb(dbPath: string): void {
promo_coupon_code TEXT,
notes TEXT,
lead_date TEXT,
upc TEXT,
fba_fee REAL,
fbm_fee REAL,
referral_percent REAL,
supplier_score REAL,
supplier_profit REAL,
supplier_margin REAL,
supplier_roi REAL,
supplier_reason TEXT,
upc_lookup_status TEXT,
upc_lookup_reason TEXT,
candidate_asins TEXT,
can_sell TEXT,
sellability_status TEXT,
sellability_reason TEXT,
@@ -239,6 +297,7 @@ export function initDb(dbPath: string): void {
);
`);
ensureProductAnalysisResultsTable(database);
ensureProductAnalysisResultsColumns(database);
database.run(
`CREATE INDEX IF NOT EXISTS idx_runs_timestamp ON runs(timestamp DESC);`,

View File

@@ -1,252 +1,71 @@
import { readProducts } from "./reader.ts";
import { fetchKeepaDataBatch } from "./keepa.ts";
import { fetchSellabilityBatch, fetchSpApiPricingAndFees } from "./sp-api.ts";
import { connectCache, getCache, setCache, disconnectCache } from "./cache.ts";
import { analyzeProducts } from "./llm.ts";
import { connectCache, disconnectCache } from "./cache.ts";
import { printResults, writeResultsToDb } from "./writer.ts";
import { initDb, closeDb } from "./database.ts";
import {
chunkArray,
processProductChunk,
type SellabilityFilter,
} from "./analysis-pipeline.ts";
import path from "node:path";
import type {
EnrichedProduct,
AnalysisResult,
KeepaData,
ProductRecord,
SellabilityInfo,
SpApiData,
} from "./types.ts";
import type { AnalysisResult } from "./types.ts";
const DB_PATH = "./results.db";
const LLM_BATCH_SIZE = 5;
const DB_PATH = process.env.RESULTS_DB_PATH || path.join("db", "results.db");
const INPUT_BATCH_SIZE = 50;
function parseArgs(): { inputFile: string; outputFile?: string } {
const args = process.argv.slice(2);
const inputFile = args.find((a) => !a.startsWith("--"));
const outIdx = args.indexOf("--out");
const outputFile = outIdx !== -1 ? args[outIdx + 1] : undefined;
function parseSellabilityArg(args: string[]): SellabilityFilter {
const sellabilityArg = args.find((a) => a.startsWith("--sellability="));
const sellabilityValueFromEquals = sellabilityArg?.split("=")[1];
const sellabilityIdx = args.indexOf("--sellability");
const sellabilityValueFromNext =
sellabilityIdx !== -1 ? args[sellabilityIdx + 1] : undefined;
const rawSellability = sellabilityValueFromEquals ?? sellabilityValueFromNext;
if (!rawSellability) return "available";
const normalized = rawSellability.toLowerCase();
if (normalized === "available" || normalized === "all") {
return normalized;
}
if (!inputFile) {
console.error(
"Usage: bun run src/index.ts <input.csv|xlsx> [--out results.csv]",
`Invalid --sellability value: \"${rawSellability}\". Use \"available\" or \"all\".`,
);
process.exit(1);
}
return { inputFile, outputFile };
function parseArgs(): {
inputFile: string;
outputFile?: string;
sellability: SellabilityFilter;
} {
const args = process.argv.slice(2);
const inputFile = args.find((a) => !a.startsWith("--"));
const outIdx = args.indexOf("--out");
const outputFile = outIdx !== -1 ? args[outIdx + 1] : undefined;
const sellability = parseSellabilityArg(args);
if (!inputFile) {
console.error(
"Usage: bun run src/index.ts <input.csv|xlsx> [--out results.csv] [--sellability available|all]",
);
process.exit(1);
}
function chunkArray<T>(items: T[], chunkSize: number): T[][] {
const chunks: T[][] = [];
for (let i = 0; i < items.length; i += chunkSize) {
chunks.push(items.slice(i, i + chunkSize));
}
return chunks;
return { inputFile, outputFile, sellability };
}
function resolveBaseOutputPath(inputFile: string, outputFile?: string): string {
if (outputFile) return outputFile;
const parsedInput = path.parse(inputFile);
return path.join(parsedInput.dir, `${parsedInput.name}_results.xlsx`);
}
async function processProductChunk(
products: ProductRecord[],
): Promise<AnalysisResult[]> {
console.log(`\nChecking cache for ${products.length} products...`);
const cached = new Map<string, EnrichedProduct>();
const excludedCachedAsins = new Set<string>();
const uncachedProducts: ProductRecord[] = [];
for (const p of products) {
const hit = await getCache(p.asin);
if (hit) {
if (hit.spApi.sellabilityStatus === "available") {
console.log(` [cache hit] ${p.asin}`);
cached.set(p.asin, hit);
} else {
excludedCachedAsins.add(p.asin);
console.log(
` [exclude cached] ${p.asin} — status=${hit.spApi.sellabilityStatus}`,
);
}
} else {
uncachedProducts.push(p);
}
}
console.log(
`${cached.size} cached available, ${excludedCachedAsins.size} cached excluded, ${uncachedProducts.length} to fetch`,
);
const sellabilityMap = new Map<string, SellabilityInfo>();
const availableProducts: ProductRecord[] = [];
const unavailableProducts: ProductRecord[] = [];
if (uncachedProducts.length > 0) {
console.log(
`\nChecking sellability for ${uncachedProducts.length} ASINs...`,
);
const sellResults = await fetchSellabilityBatch(
uncachedProducts.map((p) => p.asin),
);
for (const p of uncachedProducts) {
const info = sellResults.get(p.asin) ?? {
canSell: null,
sellabilityStatus: "unknown" as const,
sellabilityReason: "Sellability check returned no result",
};
sellabilityMap.set(p.asin, info);
if (info.sellabilityStatus === "available") {
availableProducts.push(p);
console.log(` [available] ${p.asin} — status=${info.sellabilityStatus}`);
} else {
unavailableProducts.push(p);
console.log(
` [exclude] ${p.asin} — status=${info.sellabilityStatus}, reason=${info.sellabilityReason ?? "n/a"}`,
);
}
}
console.log(
`\nSellability gate: ${availableProducts.length} available, ${unavailableProducts.length} excluded`,
);
}
let keepaResults = new Map<string, KeepaData>();
if (availableProducts.length > 0) {
console.log(`\nFetching ${availableProducts.length} ASINs from Keepa...`);
try {
keepaResults = await fetchKeepaDataBatch(
availableProducts.map((p) => p.asin),
);
} catch (err) {
console.warn(`Keepa batch fetch failed: ${err}`);
}
}
console.log(
`\nFetching pricing & fees for ${availableProducts.length} ASINs...`,
);
const spApiResults = new Map<string, SpApiData>();
const pricingQueue = [...availableProducts];
let pricingDone = 0;
async function fetchNextPricing(): Promise<void> {
while (pricingQueue.length > 0) {
const p = pricingQueue.shift()!;
const sellability = sellabilityMap.get(p.asin)!;
const spApi = await fetchSpApiPricingAndFees(p.asin, sellability);
const keepa = keepaResults.get(p.asin);
if (keepa?.currentPrice && spApi.estimatedSalePrice === 0) {
spApi.estimatedSalePrice = keepa.currentPrice;
}
spApiResults.set(p.asin, spApi);
pricingDone++;
if (pricingDone % 10 === 0 || pricingDone === availableProducts.length) {
console.log(
` [pricing] ${pricingDone}/${availableProducts.length} fetched`,
);
}
}
}
const pricingWorkers = Array.from(
{ length: Math.min(5, availableProducts.length || 1) },
() => fetchNextPricing(),
);
await Promise.all(pricingWorkers);
console.log(`\nEnriching products...`);
const enriched: EnrichedProduct[] = [];
const availableAsins = new Set(availableProducts.map((ap) => ap.asin));
for (const p of products) {
if (excludedCachedAsins.has(p.asin)) {
continue;
}
const cachedProduct = cached.get(p.asin);
if (cachedProduct) {
enriched.push(cachedProduct);
continue;
}
if (!availableAsins.has(p.asin)) {
continue;
}
const keepa = keepaResults.get(p.asin) ?? null;
const spApi = spApiResults.get(p.asin) ?? {
fbaFee: 5.0,
fbmFee: 1.5,
referralFeePercent: 15,
estimatedSalePrice: 0,
canSell: null,
sellabilityStatus: "unknown" as const,
sellabilityReason: "SP-API data missing",
};
const product: EnrichedProduct = {
record: p,
keepa,
spApi,
fetchedAt: new Date().toISOString(),
};
await setCache(p.asin, product);
enriched.push(product);
}
console.log(
`\nAnalyzing ${enriched.length} products via LLM (batch size: ${LLM_BATCH_SIZE})...\n`,
);
const results: AnalysisResult[] = [];
for (let i = 0; i < enriched.length; i += LLM_BATCH_SIZE) {
const batch = enriched.slice(i, i + LLM_BATCH_SIZE);
const batchNum = Math.floor(i / LLM_BATCH_SIZE) + 1;
const totalBatches = Math.ceil(enriched.length / LLM_BATCH_SIZE);
console.log(` LLM batch ${batchNum}/${totalBatches}...`);
if (i > 0) {
await new Promise((r) => setTimeout(r, 5000));
}
let verdicts;
try {
verdicts = await analyzeProducts(batch);
} catch {
await new Promise((r) => setTimeout(r, 10_000));
try {
verdicts = await analyzeProducts(batch);
} catch {
verdicts = null;
}
}
for (let j = 0; j < batch.length; j++) {
results.push({
product: batch[j]!,
verdict: verdicts?.[j] ?? {
asin: batch[j]!.record.asin,
verdict: "SKIP",
confidence: 0,
reasoning: "LLM analysis failed",
},
});
}
}
return results;
return path.join("output", `${parsedInput.name}_results.xlsx`);
}
async function main() {
const { inputFile, outputFile } = parseArgs();
const { inputFile, outputFile, sellability } = parseArgs();
console.log(`Sellability filter: ${sellability}`);
console.log("Connecting to Redis...");
await connectCache();
@@ -279,7 +98,7 @@ async function main() {
console.log(
`\n=== Input chunk ${chunkIndex + 1}/${productChunks.length} (${chunk.length} products) ===`,
);
const chunkResults = await processProductChunk(chunk);
const chunkResults = await processProductChunk(chunk, { sellability });
allResults.push(...chunkResults);
}

241
src/keepa.test.ts Normal file
View File

@@ -0,0 +1,241 @@
import { afterAll, beforeEach, expect, mock, test } from "bun:test";
import { lookupKeepaUpcs, mapUpcsToAsins } from "./keepa.ts";
const originalFetch = globalThis.fetch;
function makeUpc(index: number): string {
return String(index).padStart(12, "0");
}
beforeEach(() => {
globalThis.fetch = originalFetch;
});
afterAll(() => {
globalThis.fetch = originalFetch;
});
test("lookupKeepaUpcs marks invalid UPCs and skips API calls", async () => {
const fetchMock = mock(async () => {
return new Response("should not be called", { status: 500 });
});
globalThis.fetch = fetchMock as unknown as typeof globalThis.fetch;
const details = await lookupKeepaUpcs([
"",
"abc",
"12345678901",
"123456789012345",
]);
expect(fetchMock.mock.calls.length).toBe(0);
expect(details.size).toBe(4);
expect(details.get("")?.status).toBe("invalid_upc");
expect(details.get("abc")?.status).toBe("invalid_upc");
expect(details.get("12345678901")?.status).toBe("invalid_upc");
expect(details.get("123456789012345")?.status).toBe("invalid_upc");
});
test("lookupKeepaUpcs returns found, not_found, and multiple_asins outcomes", async () => {
globalThis.fetch = mock(async () => {
return new Response(
JSON.stringify({
products: [
{
asin: "B000FOUND01",
upcList: ["012345678901"],
stats: {
current: [null, null, null, 1234],
avg: [2500, null, null, 1400],
},
csv: [[1, 2999]],
},
{
asin: "B000MULTI01",
upcList: ["098765432109"],
stats: {
current: [null, null, null, 2000],
avg: [1800, null, null, 2200],
},
csv: [[1, 1999]],
},
{
asin: "B000MULTI02",
upcList: ["098765432109"],
stats: {
current: [null, null, null, 2100],
avg: [1850, null, null, 2250],
},
csv: [[1, 2099]],
},
],
tokensLeft: 10,
refillRate: 1,
}),
{ status: 200 },
);
}) as unknown as typeof globalThis.fetch;
const details = await lookupKeepaUpcs([
"012345678901",
"098765432109",
"111111111111",
]);
expect(details.get("012345678901")?.status).toBe("found");
expect(details.get("012345678901")?.asin).toBe("B000FOUND01");
expect(details.get("012345678901")?.keepaData?.currentPrice).toBe(29.99);
expect(details.get("098765432109")?.status).toBe("multiple_asins");
expect(details.get("098765432109")?.candidateAsins).toEqual([
"B000MULTI01",
"B000MULTI02",
]);
expect(details.get("111111111111")?.status).toBe("not_found");
const simpleMap = await mapUpcsToAsins([
"012345678901",
"098765432109",
"111111111111",
]);
expect(simpleMap.get("012345678901")).toBe("B000FOUND01");
expect(simpleMap.has("098765432109")).toBe(false);
expect(simpleMap.has("111111111111")).toBe(false);
});
test("lookupKeepaUpcs keeps partial success when one chunk fails", async () => {
const upcs = Array.from({ length: 101 }, (_, i) => makeUpc(700000000000 + i));
const firstChunkFirstUpc = upcs[0]!;
const secondChunkUpc = upcs[100]!;
globalThis.fetch = mock(async (input: string | URL | Request) => {
const rawUrl =
typeof input === "string"
? input
: input instanceof URL
? input.toString()
: input.url;
const url = new URL(rawUrl);
const codes = (url.searchParams.get("code") ?? "").split(",");
if (codes.includes(firstChunkFirstUpc)) {
return new Response("first chunk failed", { status: 500 });
}
return new Response(
JSON.stringify({
products: [
{
asin: "B000LAST001",
upcList: [secondChunkUpc],
stats: {
current: [null, null, null, 1000],
avg: [1500, null, null, 1200],
},
csv: [[1, 1599]],
},
],
tokensLeft: 10,
refillRate: 1,
}),
{ status: 200 },
);
}) as unknown as typeof globalThis.fetch;
const details = await lookupKeepaUpcs(upcs);
expect(details.get(firstChunkFirstUpc)?.status).toBe("request_failed");
expect(details.get(secondChunkUpc)?.status).toBe("found");
expect(details.get(secondChunkUpc)?.asin).toBe("B000LAST001");
const simpleMap = await mapUpcsToAsins(upcs);
expect(simpleMap.has(firstChunkFirstUpc)).toBe(false);
expect(simpleMap.get(secondChunkUpc)).toBe("B000LAST001");
});
test("lookupKeepaUpcs retries on 429 and succeeds after refill wait", async () => {
const targetUpc = "123456789012";
const fetchMock = mock(async () => {
const callNumber = fetchMock.mock.calls.length;
if (callNumber === 1) {
return new Response(
JSON.stringify({
refillIn: 0,
refillRate: 21,
tokensLeft: -1,
}),
{ status: 429 },
);
}
return new Response(
JSON.stringify({
products: [
{
asin: "B000RETRY01",
upcList: [targetUpc],
stats: {
current: [null, null, null, 1111],
avg: [1299, null, null, 1234],
},
csv: [[1, 1399]],
},
],
tokensLeft: 10,
refillRate: 21,
}),
{ status: 200 },
);
});
globalThis.fetch = fetchMock as unknown as typeof globalThis.fetch;
const details = await lookupKeepaUpcs([targetUpc]);
expect(fetchMock.mock.calls.length).toBe(2);
expect(details.get(targetUpc)?.status).toBe("found");
expect(details.get(targetUpc)?.asin).toBe("B000RETRY01");
});
test("lookupKeepaUpcs uses lightweight query params for code mapping", async () => {
const targetUpc = "555555555555";
const fetchMock = mock(async (input: string | URL | Request) => {
const rawUrl =
typeof input === "string"
? input
: input instanceof URL
? input.toString()
: input.url;
const url = new URL(rawUrl);
expect(url.searchParams.get("code")).toBe(targetUpc);
expect(url.searchParams.has("stats")).toBe(false);
expect(url.searchParams.has("buybox")).toBe(false);
expect(url.searchParams.has("days")).toBe(false);
return new Response(
JSON.stringify({
products: [
{
asin: "B000LIGHT01",
upcList: [targetUpc],
categoryTree: [{ name: "Test Category" }],
},
],
tokensLeft: 10,
refillRate: 21,
}),
{ status: 200 },
);
});
globalThis.fetch = fetchMock as unknown as typeof globalThis.fetch;
const details = await lookupKeepaUpcs([targetUpc]);
expect(fetchMock.mock.calls.length).toBe(1);
expect(details.get(targetUpc)?.status).toBe("found");
expect(details.get(targetUpc)?.asin).toBe("B000LIGHT01");
});

View File

@@ -1,12 +1,25 @@
import { config } from "./config.ts";
import type { KeepaData } from "./types.ts";
import type { KeepaData, KeepaUpcLookupDetail } from "./types.ts";
const KEEPA_BASE = "https://api.keepa.com";
const MAX_ASINS_PER_REQUEST = 100;
const MAX_CODES_PER_REQUEST = MAX_ASINS_PER_REQUEST;
const MAX_KEEPA_RETRIES = 4;
const KEEP_RETRY_BUFFER_MS = 250;
const AMAZON_US_SELLER_ID = "ATVPDKIKX0DER";
const KEEPA_MINUTES_OFFSET = 21_564_000;
const UPC_PATTERN = /^\d{12,14}$/;
// Token-based rate limiting: Keepa Pro = 1 token/min regeneration.
// Each product request costs 1 token regardless of ASIN count (up to 100).
// The API response includes tokensLeft and refillRate — we use those to pace.
type KeepaApiResponse = {
products?: Record<string, any>[];
tokensLeft?: number;
refillRate?: number;
refillIn?: number;
};
// Token-based rate limiting based on Keepa's tokensLeft/refillRate response fields.
// Actual token cost can be greater than 1 depending on endpoint parameters and payload.
// The client keeps request pace using tokensLeft/refillRate/refillIn to avoid 429 bursts.
let tokensLeft = 1; // Conservative start; updated from API response
let refillRate = 1; // tokens per minute, updated from API response
let lastRequestTime = 0;
@@ -33,6 +46,184 @@ async function waitForToken(): Promise<void> {
tokensLeft = 1;
}
function wait(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function buildProductUrl(
queryParam: "asin" | "code",
values: string[],
options?: {
includeStats?: boolean;
includeBuybox?: boolean;
days?: number;
},
): string {
const includeStats = options?.includeStats ?? true;
const includeBuybox = options?.includeBuybox ?? true;
const days = options?.days ?? 90;
const params = new URLSearchParams({
key: config.keepaApiKey,
domain: "1",
});
if (includeStats) {
params.set("stats", String(days));
params.set("days", String(days));
}
if (includeBuybox) {
params.set("buybox", "1");
}
params.set(queryParam, values.join(","));
return `${KEEPA_BASE}/product?${params.toString()}`;
}
function updateTokenState(data: KeepaApiResponse): void {
if (data.tokensLeft != null) tokensLeft = data.tokensLeft;
if (data.refillRate != null) refillRate = data.refillRate;
}
function computeWaitMsFromRefill(refillIn?: number): number {
if (
typeof refillIn === "number" &&
Number.isFinite(refillIn) &&
refillIn >= 0
) {
return Math.max(
Math.ceil(refillIn) + KEEP_RETRY_BUFFER_MS,
KEEP_RETRY_BUFFER_MS,
);
}
const safeRefillRate = Math.max(1, refillRate);
return Math.ceil((1 / safeRefillRate) * 60_000) + KEEP_RETRY_BUFFER_MS;
}
function parseErrorPayload(text: string): KeepaApiResponse | null {
try {
const parsed = JSON.parse(text) as KeepaApiResponse;
return parsed && typeof parsed === "object" ? parsed : null;
} catch {
return null;
}
}
async function fetchKeepaWithRetries(
url: string,
operationLabel: string,
): Promise<KeepaApiResponse> {
let lastErrorMessage = "Unknown Keepa error";
for (let attempt = 1; attempt <= MAX_KEEPA_RETRIES; attempt++) {
await waitForToken();
const res = await fetch(url);
lastRequestTime = Date.now();
if (res.ok) {
const data = (await res.json()) as KeepaApiResponse;
updateTokenState(data);
return data;
}
const text = await res.text();
const payload = parseErrorPayload(text);
if (payload) {
updateTokenState(payload);
}
lastErrorMessage = `Keepa API error ${res.status}: ${text}`;
if (res.status !== 429 || attempt === MAX_KEEPA_RETRIES) {
break;
}
const waitMs = computeWaitMsFromRefill(payload?.refillIn);
tokensLeft = Math.min(tokensLeft, 0);
console.warn(
`Keepa throttled during ${operationLabel} (attempt ${attempt}/${MAX_KEEPA_RETRIES}). Waiting ${Math.ceil(waitMs / 1000)}s before retry...`,
);
await wait(waitMs);
}
throw new Error(lastErrorMessage);
}
function normalizeUpc(input: string): string {
return input.trim();
}
function isValidUpc(value: string): boolean {
return UPC_PATTERN.test(value);
}
function normalizeCodeFromKeepa(value: string): string {
return value.replace(/\D/g, "");
}
function collectCodes(value: unknown, target: Set<string>): void {
if (Array.isArray(value)) {
for (const item of value) {
collectCodes(item, target);
}
return;
}
if (typeof value === "number" && Number.isFinite(value)) {
const normalized = normalizeCodeFromKeepa(String(Math.trunc(value)));
if (isValidUpc(normalized)) target.add(normalized);
return;
}
if (typeof value !== "string") {
return;
}
for (const rawPart of value.split(/[\s,;|]+/)) {
if (!rawPart) continue;
const normalized = normalizeCodeFromKeepa(rawPart);
if (isValidUpc(normalized)) target.add(normalized);
}
}
function extractUpcsFromProduct(product: Record<string, any>): string[] {
const codes = new Set<string>();
const candidates: unknown[] = [
product.upcList,
product.upc,
product.eanList,
product.ean,
product.gtinList,
product.gtin,
];
for (const candidate of candidates) {
collectCodes(candidate, codes);
}
return Array.from(codes);
}
function buildFailureDetail(
upc: string,
status: "invalid_upc" | "not_found" | "multiple_asins" | "request_failed",
reason: string,
candidateAsins: string[] = [],
): KeepaUpcLookupDetail {
return {
requestedUpc: upc,
normalizedUpc: upc,
status,
asin: null,
candidateAsins,
keepaData: null,
reason,
};
}
export async function fetchKeepaDataBatch(
asins: string[],
): Promise<Map<string, KeepaData>> {
@@ -41,32 +232,17 @@ export async function fetchKeepaDataBatch(
// Split into chunks of MAX_ASINS_PER_REQUEST
for (let i = 0; i < asins.length; i += MAX_ASINS_PER_REQUEST) {
const chunk = asins.slice(i, i + MAX_ASINS_PER_REQUEST);
await waitForToken();
const asinParam = chunk.join(",");
const url = `${KEEPA_BASE}/product?key=${config.keepaApiKey}&domain=1&asin=${asinParam}&stats=90`;
const url = buildProductUrl("asin", chunk, {
includeStats: true,
includeBuybox: true,
days: 90,
});
console.log(
`Keepa: fetching ${chunk.length} ASINs (tokens left: ${tokensLeft})...`,
);
const res = await fetch(url);
lastRequestTime = Date.now();
if (!res.ok) {
const text = await res.text();
throw new Error(`Keepa API error ${res.status}: ${text}`);
}
const data = (await res.json()) as {
products?: Record<string, any>[];
tokensLeft?: number;
refillRate?: number;
};
// Update token state from API response
if (data.tokensLeft != null) tokensLeft = data.tokensLeft;
if (data.refillRate != null) refillRate = data.refillRate;
const data = await fetchKeepaWithRetries(url, "ASIN batch fetch");
console.log(
`Keepa: ${data.products?.length ?? 0} products returned, ${tokensLeft} tokens remaining (refill: ${refillRate}/min)`,
@@ -84,6 +260,136 @@ export async function fetchKeepaDataBatch(
return results;
}
export async function lookupKeepaUpcs(
upcs: string[],
): Promise<Map<string, KeepaUpcLookupDetail>> {
const details = new Map<string, KeepaUpcLookupDetail>();
const validUpcs: string[] = [];
const seenValid = new Set<string>();
for (const rawUpc of upcs) {
const normalized = normalizeUpc(rawUpc);
if (!isValidUpc(normalized)) {
if (!details.has(normalized)) {
details.set(
normalized,
buildFailureDetail(
normalized,
"invalid_upc",
"UPC must be 12, 13, or 14 digits",
),
);
}
continue;
}
if (seenValid.has(normalized)) continue;
seenValid.add(normalized);
validUpcs.push(normalized);
}
for (let i = 0; i < validUpcs.length; i += MAX_CODES_PER_REQUEST) {
const chunk = validUpcs.slice(i, i + MAX_CODES_PER_REQUEST);
const chunkSet = new Set(chunk);
const url = buildProductUrl("code", chunk, {
includeStats: false,
includeBuybox: false,
});
console.log(
`Keepa: mapping ${chunk.length} UPCs to ASINs (tokens left: ${tokensLeft})...`,
);
try {
const data = await fetchKeepaWithRetries(url, "UPC code lookup");
console.log(
`Keepa: ${data.products?.length ?? 0} products returned for UPC query, ${tokensLeft} tokens remaining (refill: ${refillRate}/min)`,
);
const byUpc = new Map<string, Map<string, KeepaData>>();
for (const product of data.products ?? []) {
const asin = String(product.asin ?? "").trim();
if (!asin) continue;
const keepaData = parseKeepaProduct(product);
const productUpcs = extractUpcsFromProduct(product);
for (const upc of productUpcs) {
if (!chunkSet.has(upc)) continue;
if (!byUpc.has(upc)) byUpc.set(upc, new Map());
byUpc.get(upc)!.set(asin, keepaData);
}
}
for (const upc of chunk) {
const asinMap = byUpc.get(upc);
if (!asinMap || asinMap.size === 0) {
details.set(
upc,
buildFailureDetail(
upc,
"not_found",
"No Keepa product matched this UPC",
),
);
continue;
}
const candidateAsins = Array.from(asinMap.keys());
if (candidateAsins.length > 1) {
details.set(
upc,
buildFailureDetail(
upc,
"multiple_asins",
`UPC matched multiple ASINs (${candidateAsins.length})`,
candidateAsins,
),
);
continue;
}
const asin = candidateAsins[0]!;
details.set(upc, {
requestedUpc: upc,
normalizedUpc: upc,
status: "found",
asin,
candidateAsins: [asin],
keepaData: asinMap.get(asin) ?? null,
});
}
} catch (error) {
const reason = error instanceof Error ? error.message : String(error);
console.warn(
`Keepa UPC chunk failed (offset ${i}, size ${chunk.length}): ${reason}`,
);
for (const upc of chunk) {
details.set(upc, buildFailureDetail(upc, "request_failed", reason));
}
}
}
return details;
}
export async function mapUpcsToAsins(
upcs: string[],
): Promise<Map<string, string>> {
const details = await lookupKeepaUpcs(upcs);
const mapping = new Map<string, string>();
for (const [upc, detail] of details.entries()) {
if (detail.status === "found" && detail.asin) {
mapping.set(upc, detail.asin);
}
}
return mapping;
}
function parseKeepaProduct(product: Record<string, any>): KeepaData {
const stats = product.stats;
const csv = product.csv;
@@ -97,6 +403,14 @@ function parseKeepaProduct(product: Record<string, any>): KeepaData {
const monthlySold =
pickKeepaNumber(product.monthlySold, stats?.monthlySold) ??
salesRankDrops30;
const amazonIsSeller = resolveAmazonIsSeller(product, stats, csv);
const amazonBuyboxSharePct90d =
extractAmazonBuyboxSharePct90d(product, stats) ??
computeAmazonBuyBoxSharePctFromHistory(
product.buyBoxSellerIdHistory,
90,
new Set([AMAZON_US_SELLER_ID]),
);
return {
currentPrice: extractCurrentPrice(csv),
@@ -108,14 +422,122 @@ function parseKeepaProduct(product: Record<string, any>): KeepaData {
salesRankDrops30,
salesRankDrops90,
sellerCount: stats?.current?.[11] ?? null,
amazonIsSeller,
amazonBuyboxSharePct90d,
buyBoxSeller: product.buyBoxSellerId ?? null,
buyBoxPrice: stats?.current?.[18] != null ? stats.current[18] / 100 : null,
buyBoxAvg90: stats?.avg?.[18] != null ? stats.avg[18] / 100 : null,
monthlySold,
categoryTree:
product.categoryTree?.map((c: { name: string }) => c.name) ?? [],
};
}
function resolveAmazonIsSeller(
product: Record<string, any>,
stats: Record<string, any> | undefined,
csv: number[][] | undefined,
): boolean | null {
if (typeof product.isAmazonSeller === "boolean") {
return product.isAmazonSeller;
}
if (typeof product.availabilityAmazon === "number") {
if (product.availabilityAmazon >= 0) return true;
if (
product.availabilityAmazon === -1 ||
product.availabilityAmazon === -2
) {
return false;
}
}
if (stats?.buyBoxIsAmazon === true) {
return true;
}
if (typeof stats?.current?.[0] === "number") {
if (stats.current[0] > 0) return true;
if (stats.current[0] === -1 || stats.current[0] === -2) return false;
}
const latestAmazonPrice = extractLatestPositivePrice(csv?.[0]);
if (latestAmazonPrice != null) return true;
return null;
}
function extractAmazonBuyboxSharePct90d(
product: Record<string, any>,
stats: Record<string, any> | undefined,
): number | null {
const candidates: unknown[] = [
product.buyBoxStatsAmazon90,
stats?.buyBoxStatsAmazon90,
product.buyBoxStats?.amazon90,
product.buyBoxStats?.amazon?.[90],
product.buyBoxStats?.amazon?.["90"],
product.buyBoxStats?.[AMAZON_US_SELLER_ID]?.[90],
product.buyBoxStats?.[AMAZON_US_SELLER_ID]?.["90"],
];
for (const value of candidates) {
if (typeof value !== "number" || !Number.isFinite(value)) continue;
if (value < 0 || value > 100) continue;
return Math.round(value * 100) / 100;
}
return null;
}
function computeAmazonBuyBoxSharePctFromHistory(
history: unknown,
windowDays: number,
amazonSellerIds: Set<string>,
): number | null {
if (!Array.isArray(history) || history.length < 2) return null;
const nowKeepaMinutes =
Math.floor(Date.now() / 60_000) - KEEPA_MINUTES_OFFSET;
const windowStart = nowKeepaMinutes - windowDays * 24 * 60;
let qualifiedMinutes = 0;
let amazonMinutes = 0;
for (let i = 0; i < history.length - 1; i += 2) {
const startMinute = Number.parseInt(String(history[i]), 10);
const sellerId = String(history[i + 1] ?? "").toUpperCase();
const nextRaw = i + 2 < history.length ? history[i + 2] : nowKeepaMinutes;
const endMinute = Number.parseInt(String(nextRaw), 10);
if (!Number.isFinite(startMinute) || !Number.isFinite(endMinute)) continue;
if (endMinute <= startMinute) continue;
const intervalStart = Math.max(startMinute, windowStart);
const intervalEnd = Math.min(endMinute, nowKeepaMinutes);
if (intervalEnd <= intervalStart) continue;
if (sellerId === "-1" || sellerId === "-2") continue;
const minutes = intervalEnd - intervalStart;
qualifiedMinutes += minutes;
if (amazonSellerIds.has(sellerId)) {
amazonMinutes += minutes;
}
}
if (qualifiedMinutes === 0) return null;
return Math.round((amazonMinutes / qualifiedMinutes) * 10_000) / 100;
}
function extractLatestPositivePrice(series: unknown): number | null {
if (!Array.isArray(series) || series.length < 2) return null;
const last = series[series.length - 1];
if (typeof last !== "number" || !Number.isFinite(last) || last <= 0) {
return null;
}
return last / 100;
}
function pickKeepaNumber(...values: unknown[]): number | null {
for (const value of values) {
if (typeof value !== "number" || !Number.isFinite(value)) continue;

View File

@@ -1,7 +1,7 @@
import { config } from "./config.ts";
import type { EnrichedProduct, LlmVerdict } from "./types.ts";
const SYSTEM_PROMPT = `You are an expert Amazon product analyst specializing in FBA and FBM fulfillment strategy.
const SYSTEM_PROMPT_STRICT = `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:
@@ -29,11 +29,48 @@ Return ONLY a raw JSON array (no markdown, no code fences, no explanation before
Keep each reasoning under 100 characters to stay within output limits and mention key blocker if skipped (e.g., restricted, low demand, thin margin).`;
const SYSTEM_PROMPT_ASSUME_LISTABLE = `You are an expert Amazon product analyst specializing in FBA and FBM fulfillment strategy.
Given product data, evaluate each product's viability for selling on Amazon. Consider:
1. **Sales Velocity**: monthlySold and salesRankDrops30 are the most important signals. A product that doesn't sell is worthless regardless of margin. salesRankDrops30 = approximate units sold in 30 days. monthlySold is Keepa's estimate.
2. **Margin Analysis**: Sale price minus unit cost minus fees (FBA or FBM). Aim for >30% ROI minimum. The spreadsheet may include FBA NET and gross profit estimates — cross-check against Keepa pricing data.
3. **Sales Rank (BSR)**: Lower rank = higher demand. Rank <50,000 is good, <1,000 is excellent.
4. **Sales Rank Trend**: Compare current rank vs 90d average. Lower current = improving demand.
5. **Competition**: Number of sellers and Buy Box dynamics. Fewer sellers = easier entry.
6. **Price Stability**: Large price swings (high max vs low min over 90d) = volatile/risky.
7. **FBA vs FBM**: FBA preferred for fast-selling, small/light items. FBM for oversized, slow-moving, or high-margin items where fee savings matter.
8. **MOQ & Capital**: High MOQ with thin margins is risky.
9. **Supply Availability**: Total quantity available from supplier — low stock means limited runway.
Decision policy:
- Ignore seller eligibility restrictions/status in this run.
- Assume all products are listable by this seller account.
- Prioritize profitable + high-velocity products.
- Use "SKIP" when data quality is poor or risk is high.
Return ONLY a raw JSON array (no markdown, no code fences, no explanation before or after). One verdict per product:
[{ "asin": "B...", "verdict": "FBA" | "FBM" | "SKIP", "confidence": 0-100, "reasoning": "..." }]
Keep each reasoning under 100 characters to stay within output limits and mention key blocker if skipped (e.g., low demand, thin margin).`;
type AnalyzeProductsOptions = {
ignoreSellability?: boolean;
};
function getSystemPrompt(options: AnalyzeProductsOptions): string {
if (options.ignoreSellability) {
return SYSTEM_PROMPT_ASSUME_LISTABLE;
}
return SYSTEM_PROMPT_STRICT;
}
export async function analyzeProducts(
products: EnrichedProduct[],
options: AnalyzeProductsOptions = {},
): Promise<LlmVerdict[]> {
try {
return await analyzeProductsInternal(products);
return await analyzeProductsInternal(products, options);
} catch (err) {
const msg = String(err);
if (products.length > 1 && msg.includes("Context size has been exceeded")) {
@@ -44,7 +81,7 @@ export async function analyzeProducts(
const fallback: LlmVerdict[] = [];
for (const product of products) {
try {
const single = await analyzeProductsInternal([product]);
const single = await analyzeProductsInternal([product], options);
fallback.push(
single[0] ?? {
asin: product.record.asin,
@@ -70,8 +107,12 @@ export async function analyzeProducts(
async function analyzeProductsInternal(
products: EnrichedProduct[],
options: AnalyzeProductsOptions,
): Promise<LlmVerdict[]> {
const productSummaries = products.map(summarizeForLlm);
const productSummaries = products.map((p) =>
summarizeForLlm(p, options.ignoreSellability === true),
);
const systemPrompt = getSystemPrompt(options);
const res = await fetch(`${config.llmUrl}/chat/completions`, {
method: "POST",
@@ -82,7 +123,7 @@ async function analyzeProductsInternal(
body: JSON.stringify({
model: config.llmModel,
messages: [
{ role: "system", content: SYSTEM_PROMPT },
{ role: "system", content: systemPrompt },
{ role: "user", content: JSON.stringify(productSummaries, null, 2) },
],
temperature: 0.3,
@@ -102,7 +143,7 @@ async function analyzeProductsInternal(
return parseVerdicts(content, products);
}
function summarizeForLlm(p: EnrichedProduct) {
function summarizeForLlm(p: EnrichedProduct, ignoreSellability: boolean) {
const salePrice =
p.keepa?.currentPrice ??
p.record.sellingPriceFromSheet ??
@@ -169,9 +210,11 @@ function summarizeForLlm(p: EnrichedProduct) {
referralFee != null ? Math.round(referralFee * 100) / 100 : null,
},
sellerEligibility: {
canSell: p.spApi.canSell,
status: p.spApi.sellabilityStatus,
reason: clampText(p.spApi.sellabilityReason, 120),
canSell: ignoreSellability ? true : p.spApi.canSell,
status: ignoreSellability ? "available" : p.spApi.sellabilityStatus,
reason: ignoreSellability
? "Assumed listable by sellability=all"
: clampText(p.spApi.sellabilityReason, 120),
},
estimatedProfit:
fbaProfit != null && fbmProfit != null

View File

@@ -0,0 +1,424 @@
import { test, expect, beforeAll, afterAll, beforeEach, mock } from "bun:test";
import { Database } from "bun:sqlite";
import { getDb, initDb, closeDb } from "./database.ts";
import path from "node:path";
import { rmSync, mkdirSync } from "node:fs";
const fetchSellabilityBatchMock = mock(async (asins: string[]) => {
return new Map<string, any>(
asins.map((asin) => {
if (asin === "B000000003") {
return [
asin,
{
canSell: false,
sellabilityStatus: "restricted" as const,
sellabilityReason: "restricted",
},
];
}
return [
asin,
{
canSell: true,
sellabilityStatus: "available" as const,
sellabilityReason: "ok",
},
];
}),
);
});
const fetchSpApiPricingAndFeesMock = mock(
async (_asin: string, sellability: any) => ({
fbaFee: 4,
fbmFee: 2,
referralFeePercent: 15,
estimatedSalePrice: 25,
canSell: sellability?.canSell ?? null,
sellabilityStatus: sellability?.sellabilityStatus ?? "unknown",
sellabilityReason: sellability?.sellabilityReason ?? "missing",
}),
);
const analyzeProductsMock = mock(async (products: any[]) => {
return products.map((p) => ({
asin: p.record.asin,
verdict: "FBA",
confidence: 90,
reasoning: "mocked",
}));
});
mock.module("./sp-api.ts", () => ({
fetchSellabilityBatch: fetchSellabilityBatchMock,
fetchSpApiPricingAndFees: fetchSpApiPricingAndFeesMock,
}));
mock.module("./llm.ts", () => ({
analyzeProducts: analyzeProductsMock,
}));
const modulePromise = import("./mid-range-sellers-by-category.ts");
const DB_TEST_PATH = path.join(
process.cwd(),
"test_output",
"test_mid_range_analysis.sqlite",
);
let db: Database;
let processCategory: any;
let insertCategoryRunSummary: (
db: Database,
summary: any,
runTimestamp: string,
) => Promise<number>;
let originalFetch: typeof globalThis.fetch;
beforeAll(async () => {
const mod = await modulePromise;
processCategory = mod.processCategory;
insertCategoryRunSummary = mod.insertCategoryRunSummary;
rmSync(path.dirname(DB_TEST_PATH), { recursive: true, force: true });
mkdirSync(path.dirname(DB_TEST_PATH), { recursive: true });
initDb(DB_TEST_PATH);
db = getDb(DB_TEST_PATH);
originalFetch = globalThis.fetch;
});
afterAll(() => {
globalThis.fetch = originalFetch;
closeDb();
rmSync(path.dirname(DB_TEST_PATH), { recursive: true, force: true });
});
beforeEach(() => {
db.run("DELETE FROM product_analysis_results");
db.run("DELETE FROM category_analysis_runs");
globalThis.fetch = mock(async (input: string | URL | Request) => {
const rawUrl =
typeof input === "string"
? input
: input instanceof URL
? input.toString()
: input.url;
const url = new URL(rawUrl);
if (url.pathname === "/bestsellers") {
return new Response(
JSON.stringify({
bestSellersList: [
"B000000001",
"B000000002",
"B000000003",
"B000000004",
"B000000005",
],
tokensLeft: 10,
refillRate: 1,
}),
{ status: 200 },
);
}
if (url.pathname === "/product") {
return new Response(
JSON.stringify({
products: [
{
asin: "B000000001",
title: "Product One",
monthlySold: 600,
isAmazonSeller: true,
buyBoxStatsAmazon90: 40,
stats: {
current: [
null,
null,
null,
1000,
null,
null,
null,
null,
null,
null,
null,
5,
null,
null,
null,
null,
null,
null,
2599,
],
avg: [2400, null, null, 1200],
},
csv: [[1, 2599]],
categoryTree: [{ name: "Category 1" }],
},
{
asin: "B000000002",
title: "Product Two",
monthlySold: 250,
isAmazonSeller: true,
buyBoxStatsAmazon90: 50,
stats: {
current: [
null,
null,
null,
2000,
null,
null,
null,
null,
null,
null,
null,
3,
null,
null,
null,
null,
null,
null,
1999,
],
avg: [1800, null, null, 2200],
},
csv: [[1, 1200]],
categoryTree: [{ name: "Category 1" }],
},
{
asin: "B000000003",
title: "Product Three",
monthlySold: 800,
isAmazonSeller: true,
buyBoxStatsAmazon90: 50,
stats: {
current: [
null,
null,
null,
1500,
null,
null,
null,
null,
null,
null,
null,
4,
null,
null,
null,
null,
null,
null,
2099,
],
avg: [2000, null, null, 1800],
},
csv: [[1, 2099]],
categoryTree: [{ name: "Category 1" }],
},
{
asin: "B000000004",
title: "Product Four",
monthlySold: 400,
isAmazonSeller: true,
buyBoxStatsAmazon90: 95,
stats: {
current: [
null,
null,
null,
3000,
null,
null,
null,
null,
null,
null,
null,
4,
null,
null,
null,
null,
null,
null,
2899,
],
avg: [2600, null, null, 2800],
},
csv: [[1, 2899]],
categoryTree: [{ name: "Category 1" }],
},
{
asin: "B000000005",
title: "Product Five",
monthlySold: 450,
isAmazonSeller: false,
stats: {
current: [
null,
null,
null,
3200,
null,
null,
null,
null,
null,
null,
null,
25,
null,
null,
null,
null,
null,
null,
3500,
],
avg: [3200, null, null, 3200],
},
csv: [[1, 3500]],
categoryTree: [{ name: "Category 1" }],
},
],
tokensLeft: 10,
refillRate: 1,
}),
{ status: 200 },
);
}
return new Response("not found", { status: 404 });
}) as unknown as typeof globalThis.fetch;
});
test("processCategory only analyzes sellable mid-range matches", async () => {
const mockCategory = {
id: 1,
label: "Category 1",
parentId: 0,
childCount: 0,
};
const runId = await insertCategoryRunSummary(
db,
{
categoryId: mockCategory.id,
categoryLabel: mockCategory.label,
topAsinsChecked: 0,
availableAsins: 0,
fba: 0,
fbm: 0,
skip: 0,
status: "running",
error: "",
results: [],
},
new Date().toISOString(),
);
const summary = await processCategory(
db,
runId,
mockCategory,
3,
5,
100,
1000,
15,
200,
3,
20,
15,
85,
);
expect(summary.status).toBe("ok");
expect(summary.topAsinsChecked).toBe(5);
expect(summary.availableAsins).toBe(1);
expect(summary.results?.length).toBe(1);
const productResults = db
.query(
"SELECT asin, monthly_sold, can_sell, sellability_status FROM product_analysis_results ORDER BY monthly_sold DESC",
)
.all() as Array<{
asin: string;
monthly_sold: number;
can_sell: string;
sellability_status: string;
}>;
expect(productResults.length).toBe(1);
expect(productResults.map((row) => row.asin)).toEqual(["B000000001"]);
const sellable = productResults.find((row) => row.asin === "B000000001");
expect(sellable?.can_sell).toBe("yes");
expect(sellable?.sellability_status).toBe("available");
});
test("processCategory returns empty when no products match mid-range criteria", async () => {
const mockCategory = {
id: 2,
label: "Category 2",
parentId: 0,
childCount: 0,
};
const runId = await insertCategoryRunSummary(
db,
{
categoryId: mockCategory.id,
categoryLabel: mockCategory.label,
topAsinsChecked: 0,
availableAsins: 0,
fba: 0,
fbm: 0,
skip: 0,
status: "running",
error: "",
results: [],
},
new Date().toISOString(),
);
const summary = await processCategory(
db,
runId,
mockCategory,
3,
5,
100,
1000,
500,
600,
3,
20,
15,
85,
);
expect(summary.status).toBe("empty");
expect(summary.topAsinsChecked).toBe(5);
expect(summary.availableAsins).toBe(0);
expect(summary.results?.length).toBe(0);
const rows = db
.query("SELECT COUNT(*) as c FROM product_analysis_results")
.all() as Array<{ c: number }>;
expect(rows[0]?.c).toBe(0);
});

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

46
src/sp-api.test.ts Normal file
View File

@@ -0,0 +1,46 @@
import { expect, test } from "bun:test";
import { parseCatalogUpcLookupResponse } from "./sp-api.ts";
test("parseCatalogUpcLookupResponse resolves one ASIN", () => {
const detail = parseCatalogUpcLookupResponse("012345678901", {
items: [{ asin: "b000found1" }],
});
expect(detail.status).toBe("found");
expect(detail.asin).toBe("B000FOUND1");
expect(detail.candidateAsins).toEqual(["B000FOUND1"]);
});
test("parseCatalogUpcLookupResponse marks no match", () => {
const detail = parseCatalogUpcLookupResponse("012345678901", {
payload: { items: [] },
});
expect(detail.status).toBe("not_found");
expect(detail.asin).toBeNull();
});
test("parseCatalogUpcLookupResponse marks multiple ASINs", () => {
const detail = parseCatalogUpcLookupResponse("012345678901", {
payload: {
items: [{ asin: "B000000001" }, { asin: "B000000002" }],
},
});
expect(detail.status).toBe("multiple_asins");
expect(detail.candidateAsins).toEqual(["B000000001", "B000000002"]);
});
test("parseCatalogUpcLookupResponse marks invalid UPCs", () => {
const detail = parseCatalogUpcLookupResponse("123", { items: [] });
expect(detail.status).toBe("invalid_upc");
});
test("parseCatalogUpcLookupResponse marks malformed response as failed", () => {
const detail = parseCatalogUpcLookupResponse("012345678901", {
unexpected: true,
});
expect(detail.status).toBe("request_failed");
});

View File

@@ -1,6 +1,11 @@
import { SellingPartner } from "amazon-sp-api";
import { config } from "./config.ts";
import type { SpApiData, SellabilityInfo } from "./types.ts";
import type {
KeepaUpcLookupStatus,
SpApiData,
SellabilityInfo,
UpcLookupDetail,
} from "./types.ts";
type RegionCode = "na" | "eu" | "fe";
@@ -120,6 +125,7 @@ function round2(value: number): number {
const SELLABILITY_CONCURRENCY = 5;
const PRICING_CONCURRENCY = 5;
const UPC_PATTERN = /^\d{12,14}$/;
function parseSellabilityResponse(response: any): SellabilityInfo {
const restrictions = Array.isArray(response?.restrictions)
@@ -173,6 +179,101 @@ function parseSellabilityResponse(response: any): SellabilityInfo {
};
}
function buildUpcLookupDetail(
upc: string,
status: KeepaUpcLookupStatus,
reason: string,
candidateAsins: string[] = [],
): UpcLookupDetail {
const asin = status === "found" ? candidateAsins[0] ?? null : null;
return {
requestedUpc: upc,
normalizedUpc: upc,
status,
asin,
candidateAsins,
keepaData: null,
reason,
};
}
function collectCatalogItems(response: any): any[] | null {
const candidates = [
response?.items,
response?.payload?.items,
response?.payload,
response?.Items,
];
for (const candidate of candidates) {
if (Array.isArray(candidate)) return candidate;
}
return null;
}
function extractCatalogAsin(item: any): string | null {
const raw =
item?.asin ??
item?.ASIN ??
item?.identifiers?.marketplaceASIN?.asin ??
item?.Identifiers?.MarketplaceASIN?.ASIN;
if (typeof raw !== "string") return null;
const asin = raw.trim().toUpperCase();
return asin ? asin : null;
}
export function parseCatalogUpcLookupResponse(
upc: string,
response: unknown,
): UpcLookupDetail {
const normalizedUpc = upc.trim();
if (!UPC_PATTERN.test(normalizedUpc)) {
return buildUpcLookupDetail(
normalizedUpc,
"invalid_upc",
"UPC must be 12, 13, or 14 digits",
);
}
const items = collectCatalogItems(response);
if (!items) {
return buildUpcLookupDetail(
normalizedUpc,
"request_failed",
"Unexpected catalog response shape",
);
}
const candidateAsins = Array.from(
new Set(items.map(extractCatalogAsin).filter((asin): asin is string => !!asin)),
);
if (candidateAsins.length === 0) {
return buildUpcLookupDetail(
normalizedUpc,
"not_found",
"No SP-API catalog item matched this UPC",
);
}
if (candidateAsins.length > 1) {
return buildUpcLookupDetail(
normalizedUpc,
"multiple_asins",
`UPC matched multiple ASINs (${candidateAsins.length})`,
candidateAsins,
);
}
return buildUpcLookupDetail(
normalizedUpc,
"found",
"Matched by SP-API catalog",
candidateAsins,
);
}
async function fetchSellabilityInternal(
spClient: SellingPartner,
asin: string,
@@ -544,9 +645,69 @@ export async function fetchSellabilityBatch(
return results;
}
export async function lookupSpApiUpc(upc: string): Promise<UpcLookupDetail> {
const normalizedUpc = upc.trim();
if (!UPC_PATTERN.test(normalizedUpc)) {
return buildUpcLookupDetail(
normalizedUpc,
"invalid_upc",
"UPC must be 12, 13, or 14 digits",
);
}
const spClient = getSpClient();
if (!spClient) {
return buildUpcLookupDetail(
normalizedUpc,
"request_failed",
"SP-API credentials not configured",
);
}
try {
const response = await spClient.callAPI({
operation: "searchCatalogItems",
endpoint: "catalogItems",
query: {
marketplaceIds: [config.spApiMarketplaceId],
identifiers: [normalizedUpc],
identifiersType: "UPC",
includedData: ["identifiers", "summaries"],
},
});
return parseCatalogUpcLookupResponse(normalizedUpc, response);
} catch (err) {
return buildUpcLookupDetail(
normalizedUpc,
"request_failed",
`SP-API catalog lookup failed: ${extractErrorMessage(err)}`,
);
}
}
export async function lookupSpApiUpcs(
upcs: string[],
): Promise<Map<string, UpcLookupDetail>> {
const results = new Map<string, UpcLookupDetail>();
const uniqueUpcs = Array.from(new Set(upcs.map((upc) => upc.trim())));
let completed = 0;
for (const upc of uniqueUpcs) {
const detail = await lookupSpApiUpc(upc);
results.set(upc, detail);
completed++;
if (completed % 10 === 0 || completed === uniqueUpcs.length) {
console.log(` [sp-api:catalog] ${completed}/${uniqueUpcs.length} UPCs checked`);
}
}
return results;
}
export async function fetchSpApiPricingAndFees(
asin: string,
sellability: SellabilityInfo,
priceOverride?: number | null,
): Promise<SpApiData> {
const fallback: SpApiData = {
fbaFee: 5.0,
@@ -563,6 +724,11 @@ export async function fetchSpApiPricingAndFees(
}
try {
let estimatedSalePrice =
typeof priceOverride === "number" && Number.isFinite(priceOverride)
? priceOverride
: 0;
if (estimatedSalePrice <= 0) {
const pricing = (await spClient.callAPI({
operation: "getItemOffers",
endpoint: "productPricing",
@@ -573,7 +739,8 @@ export async function fetchSpApiPricingAndFees(
},
})) as any;
const estimatedSalePrice = extractEstimatedSalePrice(pricing);
estimatedSalePrice = extractEstimatedSalePrice(pricing);
}
if (!Number.isFinite(estimatedSalePrice) || estimatedSalePrice <= 0) {
console.log(
` [sp-api:fallback] ${asin} reason=no_valid_price_from_offers`,

134
src/supplier-export.test.ts Normal file
View File

@@ -0,0 +1,134 @@
import { afterEach, expect, test } from "bun:test";
import path from "node:path";
import { rmSync } from "node:fs";
import ExcelJS from "exceljs";
import { writeSupplierWorkbook } from "./supplier-export.ts";
import type { SupplierAnalysisResult } from "./types.ts";
const OUTPUT_FILE = path.join("/private/tmp", "asin-check-supplier-export-test.xlsx");
afterEach(() => {
rmSync(OUTPUT_FILE, { force: true });
});
function result(overrides: Partial<SupplierAnalysisResult> = {}): SupplierAnalysisResult {
return {
upc: "012345678901",
rowNumber: 2,
record: {
asin: "B000000001",
name: "Test Product",
unitCost: 10,
brand: "Brand",
category: "Grocery",
},
lookup: {
requestedUpc: "012345678901",
normalizedUpc: "012345678901",
status: "found",
asin: "B000000001",
candidateAsins: ["B000000001"],
keepaData: null,
},
keepa: {
currentPrice: 30,
avgPrice90: 29,
minPrice90: 25,
maxPrice90: 35,
salesRank: 1000,
salesRankAvg90: 1200,
salesRankDrops30: 60,
salesRankDrops90: 180,
sellerCount: 4,
amazonIsSeller: false,
amazonBuyboxSharePct90d: 0,
buyBoxSeller: "SELLER",
buyBoxPrice: 30,
buyBoxAvg90: 29,
monthlySold: 300,
categoryTree: ["Grocery"],
},
spApi: {
fbaFee: 5,
fbmFee: 3,
referralFeePercent: 15,
estimatedSalePrice: 30,
canSell: true,
sellabilityStatus: "available",
sellabilityReason: "ok",
},
score: {
salePrice: 30,
fbaFee: 5,
profit: 15,
margin: 0.5,
roi: 1.5,
demandScore: 1,
competitionPenalty: 1,
score: 70,
verdict: "BUY",
reason: "Profitable with demand",
},
fetchedAt: "2026-05-19T00:00:00.000Z",
...overrides,
};
}
test("writeSupplierWorkbook writes ranked, skipped, and summary sheets", async () => {
await writeSupplierWorkbook(
OUTPUT_FILE,
[
result(),
result({
upc: "111111111111",
record: { asin: "111111111111", name: "Missing", unitCost: 0 },
lookup: {
requestedUpc: "111111111111",
normalizedUpc: "111111111111",
status: "not_found",
asin: null,
candidateAsins: [],
keepaData: null,
reason: "No match",
},
keepa: null,
spApi: null,
score: {
salePrice: null,
fbaFee: null,
profit: null,
margin: null,
roi: null,
demandScore: 0,
competitionPenalty: 1,
score: 0,
verdict: "SKIP",
reason: "No match",
},
}),
],
{
processedRows: 2,
resolvedRows: 1,
eligibleRows: 1,
verdictCounts: { BUY: 1, WATCH: 0, SKIP: 1 },
unresolvedByStatus: {
found: 1,
invalid_upc: 0,
not_found: 1,
multiple_asins: 0,
request_failed: 0,
},
},
);
const workbook = new ExcelJS.Workbook();
await workbook.xlsx.readFile(OUTPUT_FILE);
expect(workbook.getWorksheet("Ranked Leads")).toBeDefined();
expect(workbook.getWorksheet("Skipped")).toBeDefined();
expect(workbook.getWorksheet("Summary")).toBeDefined();
expect(workbook.getWorksheet("Ranked Leads")?.getCell("A1").value).toBe("UPC");
expect(workbook.getWorksheet("Ranked Leads")?.getCell("B2").value).toBe("B000000001");
expect(workbook.getWorksheet("Skipped")?.getCell("A2").value).toBe("111111111111");
});

158
src/supplier-export.ts Normal file
View File

@@ -0,0 +1,158 @@
import ExcelJS from "exceljs";
import { dirname } from "node:path";
import { mkdirSync } from "node:fs";
import type {
KeepaUpcLookupStatus,
SupplierAnalysisResult,
SupplierVerdict,
} from "./types.ts";
export type SupplierExportSummary = {
processedRows: number;
resolvedRows: number;
eligibleRows: number;
verdictCounts: Record<SupplierVerdict, number>;
unresolvedByStatus: Record<KeepaUpcLookupStatus, number>;
};
function pct(value: number | null): number | "" {
return value == null ? "" : Math.round(value * 10_000) / 100;
}
function rowForResult(result: SupplierAnalysisResult) {
const category =
result.record.category ?? result.keepa?.categoryTree?.join(" > ") ?? "";
const canSell =
result.spApi?.canSell == null ? "" : result.spApi.canSell ? "yes" : "no";
return {
UPC: result.upc,
ASIN: result.lookup.asin ?? "",
Name: result.record.name,
Brand: result.record.brand ?? "",
Category: category,
"Unit Cost": result.record.unitCost || "",
"Sale Price": result.score.salePrice ?? "",
"FBA Fee": result.score.fbaFee ?? "",
Profit: result.score.profit ?? "",
"Margin %": pct(result.score.margin),
"ROI %": pct(result.score.roi),
"BSR Current": result.keepa?.salesRank ?? "",
"BSR 90d": result.keepa?.salesRankAvg90 ?? "",
"Rank Drops 30d": result.keepa?.salesRankDrops30 ?? "",
"Rank Drops 90d": result.keepa?.salesRankDrops90 ?? "",
"Monthly Sold": result.keepa?.monthlySold ?? "",
"Seller Count": result.keepa?.sellerCount ?? "",
"Amazon Share 90d %": result.keepa?.amazonBuyboxSharePct90d ?? "",
"Can Sell": canSell,
Sellability: result.spApi?.sellabilityStatus ?? "",
Score: result.score.score,
Verdict: result.score.verdict,
Reason: result.score.reason,
"Lookup Status": result.lookup.status,
"Candidate ASINs": result.lookup.candidateAsins.join(","),
"Lookup Reason": result.lookup.reason ?? "",
};
}
function addRowsSheet(
workbook: ExcelJS.Workbook,
name: string,
rows: ReturnType<typeof rowForResult>[],
): void {
const sheet = workbook.addWorksheet(name);
const headers = rows[0] ? Object.keys(rows[0]) : Object.keys(rowForResult({
upc: "",
record: { asin: "", name: "", unitCost: 0 },
lookup: {
requestedUpc: "",
normalizedUpc: "",
status: "not_found",
asin: null,
candidateAsins: [],
keepaData: null,
},
keepa: null,
spApi: null,
score: {
salePrice: null,
fbaFee: null,
profit: null,
margin: null,
roi: null,
demandScore: 0,
competitionPenalty: 1,
score: 0,
verdict: "SKIP",
reason: "",
},
fetchedAt: "",
}));
sheet.columns = headers.map((header) => ({
header,
key: header,
width: Math.min(Math.max(header.length + 4, 12), 28),
}));
sheet.addRows(rows);
sheet.views = [{ state: "frozen", ySplit: 1 }];
sheet.autoFilter = {
from: { row: 1, column: 1 },
to: { row: 1, column: headers.length },
};
sheet.getRow(1).font = { bold: true };
}
function addSummarySheet(
workbook: ExcelJS.Workbook,
summary: SupplierExportSummary,
): void {
const sheet = workbook.addWorksheet("Summary");
sheet.columns = [
{ header: "Metric", key: "Metric", width: 28 },
{ header: "Value", key: "Value", width: 18 },
];
sheet.addRows([
{ Metric: "Processed Rows", Value: summary.processedRows },
{ Metric: "Resolved Rows", Value: summary.resolvedRows },
{ Metric: "Eligible Rows", Value: summary.eligibleRows },
{ Metric: "BUY", Value: summary.verdictCounts.BUY },
{ Metric: "WATCH", Value: summary.verdictCounts.WATCH },
{ Metric: "SKIP", Value: summary.verdictCounts.SKIP },
{ Metric: "Unresolved invalid_upc", Value: summary.unresolvedByStatus.invalid_upc },
{ Metric: "Unresolved not_found", Value: summary.unresolvedByStatus.not_found },
{ Metric: "Unresolved multiple_asins", Value: summary.unresolvedByStatus.multiple_asins },
{ Metric: "Unresolved request_failed", Value: summary.unresolvedByStatus.request_failed },
]);
sheet.getRow(1).font = { bold: true };
}
export async function writeSupplierWorkbook(
outputFile: string,
results: SupplierAnalysisResult[],
summary: SupplierExportSummary,
): Promise<void> {
const outputDir = dirname(outputFile);
if (outputDir && outputDir !== ".") {
mkdirSync(outputDir, { recursive: true });
}
const workbook = new ExcelJS.Workbook();
workbook.creator = "asin-check";
workbook.created = new Date();
const ranked = results
.filter((result) => result.score.verdict !== "SKIP")
.sort((a, b) => b.score.score - a.score.score)
.map(rowForResult);
const skipped = results
.filter((result) => result.score.verdict === "SKIP")
.map(rowForResult);
addRowsSheet(workbook, "Ranked Leads", ranked);
addRowsSheet(workbook, "Skipped", skipped);
addSummarySheet(workbook, summary);
await workbook.xlsx.writeFile(outputFile);
}

View File

@@ -0,0 +1,97 @@
import { expect, test } from "bun:test";
import { scoreSupplierProduct } from "./supplier-scoring.ts";
import type { KeepaData, ProductRecord, SpApiData } from "./types.ts";
function record(overrides: Partial<ProductRecord> = {}): ProductRecord {
return {
asin: "B000000001",
name: "Test Product",
unitCost: 10,
...overrides,
};
}
function keepa(overrides: Partial<KeepaData> = {}): KeepaData {
return {
currentPrice: 30,
avgPrice90: 29,
minPrice90: 25,
maxPrice90: 35,
salesRank: 8_000,
salesRankAvg90: 10_000,
salesRankDrops30: 80,
salesRankDrops90: 220,
sellerCount: 4,
amazonIsSeller: false,
amazonBuyboxSharePct90d: 0,
buyBoxSeller: "SELLER",
buyBoxPrice: 30,
buyBoxAvg90: 29,
monthlySold: 350,
categoryTree: ["Grocery"],
...overrides,
};
}
function spApi(overrides: Partial<SpApiData> = {}): SpApiData {
return {
fbaFee: 5,
fbmFee: 3,
referralFeePercent: 15,
estimatedSalePrice: 30,
canSell: true,
sellabilityStatus: "available",
sellabilityReason: "ok",
...overrides,
};
}
test("profitable high-demand product ranks above competitive product", () => {
const strong = scoreSupplierProduct(record(), keepa(), spApi());
const competitive = scoreSupplierProduct(
record(),
keepa({
sellerCount: 35,
amazonIsSeller: true,
amazonBuyboxSharePct90d: 90,
}),
spApi(),
);
expect(strong.verdict).toBe("BUY");
expect(strong.score).toBeGreaterThan(competitive.score);
});
test("missing cost skips", () => {
const score = scoreSupplierProduct(record({ unitCost: 0 }), keepa(), spApi());
expect(score.verdict).toBe("SKIP");
expect(score.reason).toContain("unit cost");
});
test("restricted ASIN skips", () => {
const score = scoreSupplierProduct(
record(),
keepa(),
spApi({ canSell: false, sellabilityStatus: "restricted" }),
);
expect(score.verdict).toBe("SKIP");
expect(score.reason).toContain("restricted");
});
test("missing price skips", () => {
const score = scoreSupplierProduct(
record(),
keepa({
currentPrice: null,
avgPrice90: null,
buyBoxPrice: null,
buyBoxAvg90: null,
}),
spApi({ estimatedSalePrice: 0 }),
);
expect(score.verdict).toBe("SKIP");
expect(score.reason).toContain("price");
});

224
src/supplier-scoring.ts Normal file
View File

@@ -0,0 +1,224 @@
import type {
KeepaData,
ProductRecord,
SpApiData,
SupplierScore,
} from "./types.ts";
function round2(value: number): number {
return Math.round(value * 100) / 100;
}
function clamp(value: number, min: number, max: number): number {
return Math.max(min, Math.min(max, value));
}
export function resolveSupplierSalePrice(
keepa: KeepaData | null,
spApi: SpApiData | null,
): number | null {
const candidates = [
keepa?.buyBoxPrice,
keepa?.buyBoxAvg90,
keepa?.currentPrice,
keepa?.avgPrice90,
spApi?.estimatedSalePrice,
];
for (const candidate of candidates) {
if (typeof candidate === "number" && Number.isFinite(candidate) && candidate > 0) {
return round2(candidate);
}
}
return null;
}
export function computeDemandScore(keepa: KeepaData | null): number {
if (!keepa) return 0;
const monthlySold = keepa.monthlySold ?? 0;
const rankDrops30 = keepa.salesRankDrops30 ?? 0;
const rankDrops90 = keepa.salesRankDrops90 ?? 0;
const velocityScore = clamp(
Math.max(monthlySold / 300, rankDrops30 / 60, rankDrops90 / 180),
0,
1,
);
const rankCandidates = [keepa.salesRank, keepa.salesRankAvg90].filter(
(value): value is number =>
typeof value === "number" && Number.isFinite(value) && value > 0,
);
const bestRank = rankCandidates.length > 0 ? Math.min(...rankCandidates) : null;
const rankScore =
bestRank == null
? 0
: bestRank <= 10_000
? 1
: bestRank <= 50_000
? 0.8
: bestRank <= 100_000
? 0.55
: bestRank <= 250_000
? 0.3
: 0.1;
return round2(clamp(velocityScore * 0.65 + rankScore * 0.35, 0, 1));
}
export function computeCompetitionPenalty(keepa: KeepaData | null): number {
if (!keepa) return 1;
const sellerCount = keepa.sellerCount ?? 0;
const sellerPenalty =
sellerCount <= 3
? 0.85
: sellerCount <= 8
? 1
: sellerCount <= 15
? 1.25
: sellerCount <= 30
? 1.6
: 2;
const amazonShare = keepa.amazonBuyboxSharePct90d ?? 0;
const amazonPenalty =
keepa.amazonIsSeller === true
? 1.35
: amazonShare >= 75
? 1.45
: amazonShare >= 35
? 1.2
: 1;
return round2(clamp(sellerPenalty * amazonPenalty, 0.75, 2.5));
}
export function scoreSupplierProduct(
record: ProductRecord,
keepa: KeepaData | null,
spApi: SpApiData | null,
): SupplierScore {
const salePrice = resolveSupplierSalePrice(keepa, spApi);
const fbaFee = spApi?.fbaFee ?? null;
const demandScore = computeDemandScore(keepa);
const competitionPenalty = computeCompetitionPenalty(keepa);
if (spApi && spApi.sellabilityStatus !== "available") {
return {
salePrice,
fbaFee,
profit: null,
margin: null,
roi: null,
demandScore,
competitionPenalty,
score: 0,
verdict: "SKIP",
reason: `Not sellable: ${spApi.sellabilityStatus}`,
};
}
if (!salePrice) {
return {
salePrice,
fbaFee,
profit: null,
margin: null,
roi: null,
demandScore,
competitionPenalty,
score: 0,
verdict: "SKIP",
reason: "Missing sale price",
};
}
if (!record.unitCost || record.unitCost <= 0) {
return {
salePrice,
fbaFee,
profit: null,
margin: null,
roi: null,
demandScore,
competitionPenalty,
score: 0,
verdict: "SKIP",
reason: "Missing or invalid unit cost",
};
}
if (fbaFee == null || fbaFee < 0) {
return {
salePrice,
fbaFee,
profit: null,
margin: null,
roi: null,
demandScore,
competitionPenalty,
score: 0,
verdict: "SKIP",
reason: "Missing FBA fee",
};
}
const profit = round2(salePrice - record.unitCost - fbaFee);
const margin = round2(profit / salePrice);
const roi = round2(profit / record.unitCost);
if (profit <= 0 || margin <= 0 || roi <= 0) {
return {
salePrice,
fbaFee,
profit,
margin,
roi,
demandScore,
competitionPenalty,
score: 0,
verdict: "SKIP",
reason: "Non-positive profit",
};
}
if (demandScore < 0.15) {
return {
salePrice,
fbaFee,
profit,
margin,
roi,
demandScore,
competitionPenalty,
score: 0,
verdict: "SKIP",
reason: "Weak demand signals",
};
}
const rawScore =
((margin * 0.55 + clamp(roi, 0, 2) * 0.45) * demandScore * 100) /
competitionPenalty;
const score = round2(clamp(rawScore, 0, 100));
const verdict = score >= 18 && margin >= 0.18 && roi >= 0.3 ? "BUY" : "WATCH";
const reason =
verdict === "BUY"
? "Profitable with demand"
: "Viable but needs review";
return {
salePrice,
fbaFee,
profit,
margin,
roi,
demandScore,
competitionPenalty,
score,
verdict,
reason,
};
}

View File

@@ -5,7 +5,7 @@ import path from "node:path";
import { rmSync, mkdirSync } from "node:fs";
const fetchSellabilityBatchMock = mock(async (asins: string[]) => {
return new Map(
return new Map<string, any>(
asins.map((asin) => {
if (asin === "B000000003") {
return [

View File

@@ -46,6 +46,8 @@ type CategoryRunSummary = {
const KEEPA_BASE = "https://api.keepa.com";
const DOMAIN_US = 1;
const AMAZON_US_SELLER_ID = "ATVPDKIKX0DER";
const KEEPA_MINUTES_OFFSET = 21_564_000;
const DEFAULT_CATEGORY_LIMIT = 32;
const DEFAULT_PER_CATEGORY_TOP = 100;
const DEFAULT_CATEGORY_CANDIDATE_POOL = 500;
@@ -244,14 +246,15 @@ export async function insertProductAnalysisResults(
asin, run_id, name, brand, category, unit_cost,
current_price, avg_price_90d, avg_price_90d_sheet,
selling_price_sheet, sales_rank, sales_rank_avg_90d,
seller_count, monthly_sold, rank_drops_30d, rank_drops_90d,
seller_count, amazon_is_seller, amazon_buybox_share_pct_90d,
monthly_sold, rank_drops_30d, rank_drops_90d,
fba_fee, fbm_fee, referral_percent, can_sell,
sellability_status, sellability_reason,
verdict, confidence, reasoning, fetched_at
) VALUES (
?, ?, ?, ?, ?, ?, ?, ?, ?, ?,
?, ?, ?, ?, ?, ?, ?, ?, ?, ?,
?, ?, ?, ?, ?, ?
?, ?, ?, ?, ?, ?, ?, ?
)
ON CONFLICT(asin) DO UPDATE SET
run_id = excluded.run_id,
@@ -266,6 +269,8 @@ export async function insertProductAnalysisResults(
sales_rank = excluded.sales_rank,
sales_rank_avg_90d = excluded.sales_rank_avg_90d,
seller_count = excluded.seller_count,
amazon_is_seller = excluded.amazon_is_seller,
amazon_buybox_share_pct_90d = excluded.amazon_buybox_share_pct_90d,
monthly_sold = excluded.monthly_sold,
rank_drops_30d = excluded.rank_drops_30d,
rank_drops_90d = excluded.rank_drops_90d,
@@ -305,6 +310,12 @@ export async function insertProductAnalysisResults(
rank ?? null,
r.product.keepa?.salesRankAvg90 ?? null,
r.product.keepa?.sellerCount ?? null,
r.product.keepa?.amazonIsSeller == null
? null
: r.product.keepa.amazonIsSeller
? 1
: 0,
r.product.keepa?.amazonBuyboxSharePct90d ?? null,
r.product.keepa?.monthlySold ?? null,
r.product.keepa?.salesRankDrops30 ?? null,
r.product.keepa?.salesRankDrops90 ?? null,
@@ -816,6 +827,14 @@ function parseKeepaProduct(product: Record<string, any>): KeepaData {
const monthlySold =
pickKeepaNumber(product.monthlySold, stats?.monthlySold) ??
salesRankDrops30;
const amazonIsSeller = resolveAmazonIsSeller(product, stats, csv);
const amazonBuyboxSharePct90d =
extractAmazonBuyboxSharePct90d(product, stats) ??
computeAmazonBuyBoxSharePctFromHistory(
product.buyBoxSellerIdHistory,
90,
new Set([AMAZON_US_SELLER_ID]),
);
return {
currentPrice: extractCurrentPrice(csv),
@@ -827,6 +846,8 @@ function parseKeepaProduct(product: Record<string, any>): KeepaData {
salesRankDrops30,
salesRankDrops90,
sellerCount: stats?.current?.[11] ?? null,
amazonIsSeller,
amazonBuyboxSharePct90d,
buyBoxSeller: product.buyBoxSellerId ?? null,
buyBoxPrice: stats?.current?.[18] != null ? stats.current[18] / 100 : null,
monthlySold,
@@ -835,6 +856,108 @@ function parseKeepaProduct(product: Record<string, any>): KeepaData {
};
}
function resolveAmazonIsSeller(
product: Record<string, any>,
stats: Record<string, any> | undefined,
csv: number[][] | undefined,
): boolean | null {
if (typeof product.isAmazonSeller === "boolean")
return product.isAmazonSeller;
if (typeof product.availabilityAmazon === "number") {
if (product.availabilityAmazon >= 0) return true;
if (
product.availabilityAmazon === -1 ||
product.availabilityAmazon === -2
) {
return false;
}
}
if (stats?.buyBoxIsAmazon === true) return true;
if (typeof stats?.current?.[0] === "number") {
if (stats.current[0] > 0) return true;
if (stats.current[0] === -1 || stats.current[0] === -2) return false;
}
const latestAmazonPrice = extractLatestPositivePrice(csv?.[0]);
if (latestAmazonPrice != null) return true;
return null;
}
function extractAmazonBuyboxSharePct90d(
product: Record<string, any>,
stats: Record<string, any> | undefined,
): number | null {
const candidates: unknown[] = [
product.buyBoxStatsAmazon90,
stats?.buyBoxStatsAmazon90,
product.buyBoxStats?.amazon90,
product.buyBoxStats?.amazon?.[90],
product.buyBoxStats?.amazon?.["90"],
product.buyBoxStats?.[AMAZON_US_SELLER_ID]?.[90],
product.buyBoxStats?.[AMAZON_US_SELLER_ID]?.["90"],
];
for (const value of candidates) {
if (typeof value !== "number" || !Number.isFinite(value)) continue;
if (value < 0 || value > 100) continue;
return Math.round(value * 100) / 100;
}
return null;
}
function computeAmazonBuyBoxSharePctFromHistory(
history: unknown,
windowDays: number,
amazonSellerIds: Set<string>,
): number | null {
if (!Array.isArray(history) || history.length < 2) return null;
const nowKeepaMinutes =
Math.floor(Date.now() / 60_000) - KEEPA_MINUTES_OFFSET;
const windowStart = nowKeepaMinutes - windowDays * 24 * 60;
let qualifiedMinutes = 0;
let amazonMinutes = 0;
for (let i = 0; i < history.length - 1; i += 2) {
const startMinute = Number.parseInt(String(history[i]), 10);
const sellerId = String(history[i + 1] ?? "").toUpperCase();
const nextRaw = i + 2 < history.length ? history[i + 2] : nowKeepaMinutes;
const endMinute = Number.parseInt(String(nextRaw), 10);
if (!Number.isFinite(startMinute) || !Number.isFinite(endMinute)) continue;
if (endMinute <= startMinute) continue;
const intervalStart = Math.max(startMinute, windowStart);
const intervalEnd = Math.min(endMinute, nowKeepaMinutes);
if (intervalEnd <= intervalStart) continue;
if (sellerId === "-1" || sellerId === "-2") continue;
const minutes = intervalEnd - intervalStart;
qualifiedMinutes += minutes;
if (amazonSellerIds.has(sellerId)) {
amazonMinutes += minutes;
}
}
if (qualifiedMinutes === 0) return null;
return Math.round((amazonMinutes / qualifiedMinutes) * 10_000) / 100;
}
function extractLatestPositivePrice(series: unknown): number | null {
if (!Array.isArray(series) || series.length < 2) return null;
const last = series[series.length - 1];
if (typeof last !== "number" || !Number.isFinite(last) || last <= 0) {
return null;
}
return last / 100;
}
async function fetchKeepaEnrichmentMap(
asins: string[],
): Promise<Map<string, { keepa: KeepaData; title: string }>> {
@@ -844,7 +967,7 @@ async function fetchKeepaEnrichmentMap(
const chunk = asins.slice(i, i + KEEPA_PRODUCT_CHUNK_SIZE);
const asinParam = encodeURIComponent(chunk.join(","));
const data = await keepaGetJson(
`/product?key=${encodeURIComponent(config.keepaApiKey)}&domain=${DOMAIN_US}&asin=${asinParam}&stats=90`,
`/product?key=${encodeURIComponent(config.keepaApiKey)}&domain=${DOMAIN_US}&asin=${asinParam}&stats=90&buybox=1&days=90`,
);
const products = Array.isArray(data?.products) ? data.products : [];
@@ -1163,7 +1286,7 @@ export async function main(): Promise<void> {
mkdirSync(args.outputDir, { recursive: true });
const DB_PATH =
process.env.RESULTS_DB_PATH || path.join(process.cwd(), "results.db");
process.env.RESULTS_DB_PATH || path.join(process.cwd(), "db", "results.db");
initDb(DB_PATH);
const db = getDb(DB_PATH);

View File

@@ -36,12 +36,34 @@ export interface KeepaData {
salesRankDrops30: number | null;
salesRankDrops90: number | null;
sellerCount: number | null;
amazonIsSeller: boolean | null;
amazonBuyboxSharePct90d: number | null;
buyBoxSeller: string | null;
buyBoxPrice: number | null;
buyBoxAvg90?: number | null;
monthlySold: number | null;
categoryTree: string[];
}
export type KeepaUpcLookupStatus =
| "found"
| "invalid_upc"
| "not_found"
| "multiple_asins"
| "request_failed";
export interface KeepaUpcLookupDetail {
requestedUpc: string;
normalizedUpc: string;
status: KeepaUpcLookupStatus;
asin: string | null;
candidateAsins: string[];
keepaData: KeepaData | null;
reason?: string;
}
export type UpcLookupDetail = KeepaUpcLookupDetail;
export type SellabilityInfo = {
canSell: boolean | null;
sellabilityStatus: "available" | "restricted" | "not_available" | "unknown";
@@ -74,6 +96,32 @@ export interface AnalysisResult {
verdict: LlmVerdict;
}
export type SupplierVerdict = "BUY" | "WATCH" | "SKIP";
export interface SupplierScore {
salePrice: number | null;
fbaFee: number | null;
profit: number | null;
margin: number | null;
roi: number | null;
demandScore: number;
competitionPenalty: number;
score: number;
verdict: SupplierVerdict;
reason: string;
}
export interface SupplierAnalysisResult {
upc: string;
rowNumber?: number;
record: ProductRecord;
lookup: UpcLookupDetail;
keepa: KeepaData | null;
spApi: SpApiData | null;
score: SupplierScore;
fetchedAt: string;
}
export interface CategoryRunSummaryDb {
categoryId: number;
categoryLabel: string;

562
src/upc-file-analysis.ts Normal file
View File

@@ -0,0 +1,562 @@
import path from "node:path";
import { fetchKeepaDataBatch, lookupKeepaUpcs } from "./keepa.ts";
import {
fetchSellabilityBatch,
fetchSpApiPricingAndFees,
lookupSpApiUpcs,
} from "./sp-api.ts";
import {
processUpcFileInBatches,
type UpcInputRow,
} from "./upc-file-reader.ts";
import {
appendSupplierResultsToRun,
refreshRunCountsInDb,
startRunInDb,
type RunCounts,
} from "./writer.ts";
import { initDb, closeDb } from "./database.ts";
import { connectCache, disconnectCache } from "./cache.ts";
import { scoreSupplierProduct, resolveSupplierSalePrice } from "./supplier-scoring.ts";
import {
writeSupplierWorkbook,
type SupplierExportSummary,
} from "./supplier-export.ts";
import type {
KeepaUpcLookupDetail,
KeepaUpcLookupStatus,
ProductRecord,
SupplierAnalysisResult,
SupplierScore,
UpcLookupDetail,
} from "./types.ts";
const DB_PATH = process.env.RESULTS_DB_PATH || path.join("db", "results.db");
const DEFAULT_INPUT_BATCH_SIZE = 200;
const DEFAULT_UPC_LOOKUP_BATCH_SIZE = 100;
const DEFAULT_PRICING_CONCURRENCY = 5;
export type UpcFileAnalysisOptions = {
inputFile: string;
outputFile?: string;
inputBatchSize?: number;
upcLookupBatchSize?: number;
maxRows?: number;
manageResources?: boolean;
dbPath?: string;
};
export type UpcFileAnalysisSummary = {
runId: number;
dbPath: string;
inputFile: string;
outputFile?: string;
processedRows: number;
matchedRows: number;
unresolvedByStatus: Record<KeepaUpcLookupStatus, number>;
runCounts: RunCounts;
reader: {
mode: "xlsx_stream" | "xlsx_fallback" | "xls_fallback";
totalRowsSeen: number;
emittedRows: number;
skippedMissingUpc: number;
skippedInvalidUpc: number;
};
};
function printUsage(): void {
console.log("Usage:");
console.log(
" bun run src/upc-file-analysis.ts --input input/<file.xls|file.xlsx> [--out output/results.xlsx] [--input-batch-size 200] [--upc-lookup-batch-size 100] [--max-rows 1000]",
);
}
function parsePositiveInt(value: string | undefined, flagName: string): number {
const parsed = Number.parseInt(String(value), 10);
if (!Number.isFinite(parsed) || parsed < 1) {
throw new Error(`Invalid value for ${flagName}: ${value}`);
}
return parsed;
}
function parseArgs(argv: string[]): UpcFileAnalysisOptions {
let inputFile: string | undefined;
let outputFile: string | undefined;
let inputBatchSize: number | undefined;
let upcLookupBatchSize: number | undefined;
let maxRows: number | undefined;
for (let i = 0; i < argv.length; i++) {
const arg = argv[i]!;
if (arg === "--help" || arg === "-h") {
printUsage();
process.exit(0);
}
if (arg === "--input") {
const next = argv[i + 1];
if (!next) throw new Error("Missing value after --input");
inputFile = next;
i++;
continue;
}
if (arg === "--out") {
const next = argv[i + 1];
if (!next) throw new Error("Missing value after --out");
outputFile = next;
i++;
continue;
}
if (arg === "--input-batch-size") {
inputBatchSize = parsePositiveInt(argv[i + 1], "--input-batch-size");
i++;
continue;
}
if (arg === "--upc-lookup-batch-size") {
upcLookupBatchSize = parsePositiveInt(
argv[i + 1],
"--upc-lookup-batch-size",
);
i++;
continue;
}
if (arg === "--max-rows") {
maxRows = parsePositiveInt(argv[i + 1], "--max-rows");
i++;
continue;
}
if (arg.startsWith("--")) {
throw new Error(`Unknown flag: ${arg}`);
}
if (!inputFile) {
inputFile = arg;
continue;
}
throw new Error(`Unexpected positional argument: ${arg}`);
}
if (!inputFile) {
throw new Error("Missing --input <file.xls|file.xlsx>");
}
return {
inputFile,
outputFile,
inputBatchSize,
upcLookupBatchSize,
maxRows,
};
}
function resolveDefaultOutputPath(inputFile: string): string {
const parsedInput = path.parse(inputFile);
return path.join("output", `${parsedInput.name}_upc_results.xlsx`);
}
function createStatusCounter(): Record<KeepaUpcLookupStatus, number> {
return {
found: 0,
invalid_upc: 0,
not_found: 0,
multiple_asins: 0,
request_failed: 0,
};
}
function chunkArray<T>(items: T[], chunkSize: number): T[][] {
const chunks: T[][] = [];
for (let i = 0; i < items.length; i += chunkSize) {
chunks.push(items.slice(i, i + chunkSize));
}
return chunks;
}
function skippedScore(reason: string): SupplierScore {
return {
salePrice: null,
fbaFee: null,
profit: null,
margin: null,
roi: null,
demandScore: 0,
competitionPenalty: 1,
score: 0,
verdict: "SKIP",
reason,
};
}
async function lookupUpcsWithChunking(
rows: UpcInputRow[],
lookupBatchSize: number,
runCache: Map<string, KeepaUpcLookupDetail>,
): Promise<Map<string, UpcLookupDetail>> {
const uniqueUpcs = Array.from(new Set(rows.map((row) => row.upc)));
const missingUpcs = uniqueUpcs.filter((upc) => !runCache.has(upc));
const chunks = chunkArray(missingUpcs, lookupBatchSize);
const details = new Map<string, UpcLookupDetail>();
const cacheHits = uniqueUpcs.length - missingUpcs.length;
if (cacheHits > 0) {
console.log(
` Reusing cached UPC lookup results for ${cacheHits}/${uniqueUpcs.length} UPCs in this batch.`,
);
}
if (missingUpcs.length === 0) {
for (const upc of uniqueUpcs) {
const detail = runCache.get(upc);
if (detail) details.set(upc, detail);
}
return details;
}
for (let i = 0; i < chunks.length; i++) {
const chunk = chunks[i]!;
console.log(
` SP-API UPC lookup chunk ${i + 1}/${chunks.length} (${chunk.length} UPCs)...`,
);
const spDetails = await lookupSpApiUpcs(chunk);
const fallbackUpcs = Array.from(spDetails.values())
.filter(
(detail) =>
detail.status === "not_found" || detail.status === "request_failed",
)
.map((detail) => detail.normalizedUpc);
const fallbackDetails =
fallbackUpcs.length > 0 ? await lookupKeepaUpcs(fallbackUpcs) : new Map();
const chunkDetails = new Map<string, UpcLookupDetail>();
for (const upc of chunk) {
const spDetail = spDetails.get(upc);
const fallbackDetail = fallbackDetails.get(upc);
chunkDetails.set(
upc,
fallbackDetail && fallbackDetail.status !== "request_failed"
? fallbackDetail
: spDetail!,
);
}
for (const [upc, detail] of chunkDetails.entries()) {
runCache.set(upc, detail);
}
}
for (const upc of uniqueUpcs) {
const detail = runCache.get(upc);
if (detail) {
details.set(upc, detail);
}
}
return details;
}
function toProductRecord(
row: UpcInputRow,
detail: UpcLookupDetail,
): ProductRecord {
const keepaCategory = detail.keepaData?.categoryTree?.[0];
return {
asin: detail.asin ?? row.upc,
name: row.name ?? detail.asin ?? row.upc,
unitCost: row.unitCost ?? 0,
brand: row.brand,
category: row.category ?? keepaCategory,
};
}
async function fetchFeesForProducts(
products: ProductRecord[],
keepaResults: Map<string, NonNullable<SupplierAnalysisResult["keepa"]>>,
sellabilityMap: Awaited<ReturnType<typeof fetchSellabilityBatch>>,
): Promise<Map<string, NonNullable<SupplierAnalysisResult["spApi"]>>> {
const spApiResults = new Map<string, NonNullable<SupplierAnalysisResult["spApi"]>>();
const queue = [...products];
let completed = 0;
async function next(): Promise<void> {
while (queue.length > 0) {
const product = queue.shift();
if (!product) return;
const sellability =
sellabilityMap.get(product.asin) ?? {
canSell: null,
sellabilityStatus: "unknown" as const,
sellabilityReason: "Sellability check returned no result",
};
const price = resolveSupplierSalePrice(
keepaResults.get(product.asin) ?? null,
null,
);
const spApi = await fetchSpApiPricingAndFees(product.asin, sellability, price);
spApiResults.set(product.asin, spApi);
completed++;
if (completed % 10 === 0 || completed === products.length) {
console.log(` [fees] ${completed}/${products.length} fetched`);
}
}
}
const workers = Array.from(
{ length: Math.min(DEFAULT_PRICING_CONCURRENCY, products.length || 1) },
() => next(),
);
await Promise.all(workers);
return spApiResults;
}
function summarizeSupplierResults(
results: SupplierAnalysisResult[],
unresolvedByStatus: Record<KeepaUpcLookupStatus, number>,
): SupplierExportSummary {
return {
processedRows: results.length,
resolvedRows: results.filter((result) => result.lookup.status === "found").length,
eligibleRows: results.filter(
(result) => result.spApi?.sellabilityStatus === "available",
).length,
verdictCounts: {
BUY: results.filter((result) => result.score.verdict === "BUY").length,
WATCH: results.filter((result) => result.score.verdict === "WATCH").length,
SKIP: results.filter((result) => result.score.verdict === "SKIP").length,
},
unresolvedByStatus,
};
}
export async function runUpcFileAnalysis(
options: UpcFileAnalysisOptions,
): Promise<UpcFileAnalysisSummary> {
const dbPath = options.dbPath ?? DB_PATH;
const inputBatchSize = Math.max(
1,
options.inputBatchSize ?? DEFAULT_INPUT_BATCH_SIZE,
);
const lookupBatchSize = Math.max(
1,
options.upcLookupBatchSize ?? DEFAULT_UPC_LOOKUP_BATCH_SIZE,
);
const outputFile =
options.outputFile ?? resolveDefaultOutputPath(options.inputFile);
const manageResources = options.manageResources ?? true;
if (manageResources) {
console.log("Connecting to Redis...");
await connectCache();
console.log("Initializing SQLite database...");
initDb(dbPath);
}
const unresolvedByStatus = createStatusCounter();
const allResults: SupplierAnalysisResult[] = [];
const upcLookupCache = new Map<string, KeepaUpcLookupDetail>();
let processedRows = 0;
let matchedRows = 0;
const runId = startRunInDb(dbPath, options.inputFile, outputFile);
try {
const readerSummary = await processUpcFileInBatches(
options.inputFile,
async ({ batchNumber, rows }) => {
console.log(
`\n=== UPC input batch ${batchNumber} (${rows.length} rows) ===`,
);
processedRows += rows.length;
const detailMap = await lookupUpcsWithChunking(
rows,
lookupBatchSize,
upcLookupCache,
);
const matchedEntries: Array<{
row: UpcInputRow;
detail: UpcLookupDetail;
product: ProductRecord;
}> = [];
for (const row of rows) {
const detail = detailMap.get(row.upc);
if (!detail) {
unresolvedByStatus.request_failed += 1;
continue;
}
unresolvedByStatus[detail.status] += 1;
if (detail.status === "found" && detail.asin) {
matchedRows += 1;
matchedEntries.push({
row,
detail,
product: toProductRecord(row, detail),
});
}
}
const matchedProducts = matchedEntries.map((entry) => entry.product);
console.log(
`Batch ${batchNumber}: ${matchedProducts.length}/${rows.length} rows resolved to single ASINs`,
);
const batchResults: SupplierAnalysisResult[] = [];
for (const row of rows) {
const detail = detailMap.get(row.upc);
if (!detail || detail.status === "found") continue;
batchResults.push({
upc: row.upc,
rowNumber: row.rowNumber,
record: {
asin: detail?.asin ?? row.upc,
name: row.name ?? row.upc,
unitCost: row.unitCost ?? 0,
brand: row.brand,
category: row.category,
},
lookup:
detail ??
({
requestedUpc: row.upc,
normalizedUpc: row.upc,
status: "request_failed",
asin: null,
candidateAsins: [],
keepaData: null,
reason: "UPC lookup returned no result",
} satisfies UpcLookupDetail),
keepa: null,
spApi: null,
score: skippedScore(detail?.reason ?? "UPC unresolved"),
fetchedAt: new Date().toISOString(),
});
}
if (matchedProducts.length > 0) {
console.log(`Fetching ${matchedProducts.length} ASINs from Keepa...`);
const keepaResults = await fetchKeepaDataBatch(
matchedProducts.map((product) => product.asin),
);
console.log(`Checking sellability for ${matchedProducts.length} ASINs...`);
const sellabilityMap = await fetchSellabilityBatch(
matchedProducts.map((product) => product.asin),
);
console.log(`Fetching fees for ${matchedProducts.length} ASINs...`);
const spApiResults = await fetchFeesForProducts(
matchedProducts,
keepaResults,
sellabilityMap,
);
for (const entry of matchedEntries) {
const keepa =
keepaResults.get(entry.product.asin) ??
entry.detail.keepaData ??
null;
const spApi = spApiResults.get(entry.product.asin) ?? null;
batchResults.push({
upc: entry.detail.normalizedUpc,
rowNumber: entry.row.rowNumber,
record: entry.product,
lookup: entry.detail,
keepa,
spApi,
score: scoreSupplierProduct(entry.product, keepa, spApi),
fetchedAt: new Date().toISOString(),
});
}
}
appendSupplierResultsToRun(dbPath, runId, batchResults);
allResults.push(...batchResults);
},
{
batchSize: inputBatchSize,
maxRows: options.maxRows,
},
);
const runCounts = refreshRunCountsInDb(dbPath, runId);
const exportSummary = summarizeSupplierResults(allResults, unresolvedByStatus);
await writeSupplierWorkbook(outputFile, allResults, exportSummary);
if (allResults.length > 0) {
const ranked = allResults
.filter((result) => result.score.verdict !== "SKIP")
.sort((a, b) => b.score.score - a.score.score)
.slice(0, 25)
.map((result) => ({
UPC: result.upc,
ASIN: result.lookup.asin ?? "",
Name: result.record.name.slice(0, 40),
Cost: result.record.unitCost,
Price: result.score.salePrice ?? "",
Profit: result.score.profit ?? "",
ROI: result.score.roi == null ? "" : `${Math.round(result.score.roi * 100)}%`,
Score: result.score.score,
Verdict: result.score.verdict,
Reason: result.score.reason,
}));
console.log("\n=== Top Supplier Leads ===\n");
console.table(ranked);
} else {
console.log("No supplier rows were analyzed.");
}
console.log(`Ranked workbook written: ${outputFile}`);
return {
runId,
dbPath,
inputFile: options.inputFile,
outputFile,
processedRows,
matchedRows,
unresolvedByStatus,
runCounts,
reader: {
mode: readerSummary.mode,
totalRowsSeen: readerSummary.totalRowsSeen,
emittedRows: readerSummary.emittedRows,
skippedMissingUpc: readerSummary.skippedMissingUpc,
skippedInvalidUpc: readerSummary.skippedInvalidUpc,
},
};
} finally {
if (manageResources) {
await disconnectCache();
closeDb();
}
}
}
async function main(): Promise<void> {
const parsed = parseArgs(process.argv.slice(2));
const summary = await runUpcFileAnalysis(parsed);
console.log("\n=== UPC file analysis summary ===");
console.log(JSON.stringify(summary, null, 2));
}
if (import.meta.main) {
main().catch((err) => {
const message = err instanceof Error ? err.message : String(err);
console.error(`UPC file analysis failed: ${message}`);
process.exit(1);
});
}

363
src/upc-file-reader.ts Normal file
View File

@@ -0,0 +1,363 @@
import ExcelJS from "exceljs";
import * as XLSX from "xlsx";
import path from "node:path";
const UPC_PATTERN = /^\d{12,14}$/;
const COLUMN_CANDIDATES = {
upc: ["upc", "upc code", "upc/ean", "ean", "gtin", "barcode", "product code"],
name: ["name", "product name", "title", "product title"],
unitCost: ["unit cost", "cost", "price", "buy cost", "unit_cost", "unitcost"],
brand: ["brand"],
category: ["category"],
} as const;
type ColumnKey = keyof typeof COLUMN_CANDIDATES;
type ColumnMap = Record<ColumnKey, number | undefined>;
export type UpcInputRow = {
rowNumber: number;
upc: string;
name?: string;
unitCost?: number;
brand?: string;
category?: string;
};
export type UpcInputBatch = {
batchNumber: number;
rows: UpcInputRow[];
};
export type UpcReaderSummary = {
filePath: string;
mode: "xlsx_stream" | "xlsx_fallback" | "xls_fallback";
totalRowsSeen: number;
emittedRows: number;
skippedMissingUpc: number;
skippedInvalidUpc: number;
};
export type UpcReaderOptions = {
batchSize?: number;
maxRows?: number;
};
export async function processUpcFileInBatches(
filePath: string,
onBatch: (batch: UpcInputBatch) => Promise<void>,
options: UpcReaderOptions = {},
): Promise<UpcReaderSummary> {
const ext = path.extname(filePath).toLowerCase();
if (ext === ".xlsx") {
try {
return await processXlsxStreaming(filePath, onBatch, options);
} catch (err) {
console.warn(
`XLSX streaming reader failed, falling back to in-memory parser: ${err}`,
);
return processXlsLikeFallback(
filePath,
onBatch,
options,
"xlsx_fallback",
);
}
}
if (ext === ".xls") {
return processXlsLikeFallback(filePath, onBatch, options, "xls_fallback");
}
throw new Error(`Unsupported file extension: ${ext}. Expected .xls or .xlsx`);
}
async function processXlsxStreaming(
filePath: string,
onBatch: (batch: UpcInputBatch) => Promise<void>,
options: UpcReaderOptions,
): Promise<UpcReaderSummary> {
const batchSize = Math.max(1, options.batchSize ?? 200);
const maxRows =
options.maxRows && options.maxRows > 0 ? options.maxRows : null;
let headerDetected = false;
let columns: ColumnMap | null = null;
let seenRows = 0;
let emittedRows = 0;
let skippedMissingUpc = 0;
let skippedInvalidUpc = 0;
let batchNumber = 1;
let currentBatch: UpcInputRow[] = [];
let stop = false;
const flush = async () => {
if (currentBatch.length === 0) return;
await onBatch({ batchNumber, rows: currentBatch });
batchNumber += 1;
currentBatch = [];
};
const workbookReader = new ExcelJS.stream.xlsx.WorkbookReader(filePath, {
worksheets: "emit",
sharedStrings: "cache",
hyperlinks: "ignore",
styles: "ignore",
});
for await (const worksheet of workbookReader) {
if (stop) break;
for await (const row of worksheet) {
const values = normalizeExcelJsRow(row.values as unknown[]);
if (!headerDetected) {
columns = detectColumns(values);
if (columns.upc == null) {
throw new Error(
`No UPC column found in header row. Header row values: ${values.join(", ")}`,
);
}
headerDetected = true;
continue;
}
seenRows += 1;
if (!columns) {
throw new Error("UPC reader columns were not initialized.");
}
const parsed = parseUpcInputRow(values, columns, row.number);
if (!parsed) {
skippedMissingUpc += 1;
continue;
}
if (!isValidUpc(parsed.upc)) {
skippedInvalidUpc += 1;
continue;
}
currentBatch.push(parsed);
emittedRows += 1;
if (currentBatch.length >= batchSize) {
await flush();
}
if (maxRows != null && emittedRows >= maxRows) {
stop = true;
break;
}
}
// Process only the first worksheet.
break;
}
await flush();
if (!headerDetected) {
throw new Error("No rows found in the first worksheet.");
}
return {
filePath,
mode: "xlsx_stream",
totalRowsSeen: seenRows,
emittedRows,
skippedMissingUpc,
skippedInvalidUpc,
};
}
function processXlsLikeFallback(
filePath: string,
onBatch: (batch: UpcInputBatch) => Promise<void>,
options: UpcReaderOptions,
mode: "xlsx_fallback" | "xls_fallback",
): Promise<UpcReaderSummary> {
return new Promise<UpcReaderSummary>(async (resolve, reject) => {
try {
const batchSize = Math.max(1, options.batchSize ?? 200);
const maxRows =
options.maxRows && options.maxRows > 0 ? options.maxRows : null;
const workbook = XLSX.readFile(filePath, { raw: true });
const sheetName = workbook.SheetNames[0];
if (!sheetName) throw new Error("No sheets found in file");
const sheet = workbook.Sheets[sheetName];
if (!sheet || !sheet["!ref"]) throw new Error("Sheet has no data");
const range = XLSX.utils.decode_range(sheet["!ref"]);
const headerValues: string[] = [];
for (let c = range.s.c; c <= range.e.c; c++) {
const cellAddress = XLSX.utils.encode_cell({ r: range.s.r, c });
const value = sheet[cellAddress]?.v;
headerValues.push(normalizeOptionalString(value) ?? "");
}
const columns = detectColumns(headerValues);
if (columns.upc == null) {
throw new Error(
`No UPC column found in header row. Header row values: ${headerValues.join(", ")}`,
);
}
let seenRows = 0;
let emittedRows = 0;
let skippedMissingUpc = 0;
let skippedInvalidUpc = 0;
let batchNumber = 1;
let currentBatch: UpcInputRow[] = [];
const flush = async () => {
if (currentBatch.length === 0) return;
await onBatch({ batchNumber, rows: currentBatch });
batchNumber += 1;
currentBatch = [];
};
for (let r = range.s.r + 1; r <= range.e.r; r++) {
seenRows += 1;
const rowValues: string[] = [];
for (let c = range.s.c; c <= range.e.c; c++) {
const cellAddress = XLSX.utils.encode_cell({ r, c });
rowValues.push(normalizeOptionalString(sheet[cellAddress]?.v) ?? "");
}
const parsed = parseUpcInputRow(rowValues, columns, r + 1);
if (!parsed) {
skippedMissingUpc += 1;
continue;
}
if (!isValidUpc(parsed.upc)) {
skippedInvalidUpc += 1;
continue;
}
currentBatch.push(parsed);
emittedRows += 1;
if (currentBatch.length >= batchSize) {
await flush();
}
if (maxRows != null && emittedRows >= maxRows) {
break;
}
}
await flush();
resolve({
filePath,
mode,
totalRowsSeen: seenRows,
emittedRows,
skippedMissingUpc,
skippedInvalidUpc,
});
} catch (err) {
reject(err);
}
});
}
function detectColumns(headers: string[]): ColumnMap {
const columns = {} as ColumnMap;
for (const key of Object.keys(COLUMN_CANDIDATES) as ColumnKey[]) {
columns[key] = findColumnIndex(headers, [...COLUMN_CANDIDATES[key]]);
}
return columns;
}
function findColumnIndex(
headers: string[],
candidates: string[],
): number | undefined {
const normalizedCandidates = new Set(candidates.map(normalizeHeader));
for (let i = 0; i < headers.length; i++) {
if (normalizedCandidates.has(normalizeHeader(headers[i] ?? ""))) {
return i;
}
}
return undefined;
}
function parseUpcInputRow(
rowValues: string[],
columns: ColumnMap,
rowNumber: number,
): UpcInputRow | null {
if (columns.upc == null) return null;
const rawUpc = rowValues[columns.upc] ?? "";
const upc = rawUpc.replace(/\D/g, "").trim();
if (!upc) {
return null;
}
return {
rowNumber,
upc,
name: getRowString(rowValues, columns.name),
unitCost: parseOptionalNumber(rowValues[columns.unitCost ?? -1]),
brand: getRowString(rowValues, columns.brand),
category: getRowString(rowValues, columns.category),
};
}
function normalizeExcelJsRow(values: unknown[]): string[] {
// ExcelJS row.values is 1-indexed with values[0] intentionally empty.
const normalized: string[] = [];
for (let i = 1; i < values.length; i++) {
normalized.push(normalizeOptionalString(values[i]) ?? "");
}
return normalized;
}
function getRowString(
values: string[],
index: number | undefined,
): string | undefined {
if (index == null || index < 0) return undefined;
const value = values[index];
return value?.trim() ? value.trim() : undefined;
}
function normalizeHeader(value: string): string {
return value
.toLowerCase()
.trim()
.replace(/%/g, " pct ")
.replace(/\$/g, " usd ")
.replace(/[^a-z0-9]/g, "");
}
function normalizeOptionalString(value: unknown): string | undefined {
if (value == null) return undefined;
if (typeof value === "object") {
if ("text" in (value as Record<string, unknown>)) {
return normalizeOptionalString((value as { text?: unknown }).text);
}
if ("result" in (value as Record<string, unknown>)) {
return normalizeOptionalString((value as { result?: unknown }).result);
}
}
const text = String(value).trim();
return text.length > 0 ? text : undefined;
}
function parseOptionalNumber(value: unknown): number | undefined {
if (value == null || value === "") return undefined;
const cleaned = String(value).trim().replace(/[$,%]/g, "").replace(/,/g, "");
const parsed = Number(cleaned);
return Number.isFinite(parsed) ? parsed : undefined;
}
function isValidUpc(value: string): boolean {
return UPC_PATTERN.test(value);
}

147
src/upc-lookup.ts Normal file
View File

@@ -0,0 +1,147 @@
import { lookupKeepaUpcs, mapUpcsToAsins } from "./keepa.ts";
function printUsage(): void {
console.log("Usage:");
console.log(
" bun run src/upc-lookup.ts <upc...> [--detailed] [--json] [--file path]",
);
console.log("");
console.log("Examples:");
console.log(" bun run src/upc-lookup.ts 012345678901 098765432109");
console.log(
" bun run src/upc-lookup.ts 012345678901,098765432109 --detailed",
);
console.log(" bun run src/upc-lookup.ts --file upcs.txt --detailed --json");
}
function splitRawUpcValues(input: string): string[] {
return input
.split(/[\s,;|]+/)
.map((chunk) => chunk.trim())
.filter(Boolean);
}
async function readUpcsFromFile(path: string): Promise<string[]> {
const file = Bun.file(path);
if (!(await file.exists())) {
throw new Error(`UPC file not found: ${path}`);
}
return splitRawUpcValues(await file.text());
}
function parseArgs(args: string[]): {
upcs: string[];
filePaths: string[];
detailed: boolean;
asJson: boolean;
} {
let detailed = false;
let asJson = false;
const collected: string[] = [];
const filePaths: string[] = [];
for (let i = 0; i < args.length; i++) {
const arg = args[i]!;
if (arg === "--help" || arg === "-h") {
printUsage();
process.exit(0);
}
if (arg === "--detailed") {
detailed = true;
continue;
}
if (arg === "--json") {
asJson = true;
continue;
}
if (arg === "--file") {
const next = args[i + 1];
if (!next) {
throw new Error("Missing file path after --file");
}
filePaths.push(next);
i++;
continue;
}
if (arg.startsWith("--")) {
throw new Error(`Unknown flag: ${arg}`);
}
collected.push(...splitRawUpcValues(arg));
}
return {
upcs: collected,
filePaths,
detailed,
asJson,
};
}
function dedupeUpcs(upcs: string[]): string[] {
return Array.from(new Set(upcs.map((upc) => upc.trim()).filter(Boolean)));
}
async function main(): Promise<void> {
const args = process.argv.slice(2);
const parsed = parseArgs(args);
const fileUpcs: string[] = [];
for (const path of parsed.filePaths) {
fileUpcs.push(...(await readUpcsFromFile(path)));
}
const upcs = dedupeUpcs([...parsed.upcs, ...fileUpcs]);
if (upcs.length === 0) {
printUsage();
process.exit(1);
}
if (parsed.detailed) {
const details = await lookupKeepaUpcs(upcs);
const items = Array.from(details.values());
if (parsed.asJson) {
console.log(JSON.stringify(items, null, 2));
return;
}
console.table(
items.map((item) => ({
upc: item.normalizedUpc,
status: item.status,
asin: item.asin ?? "",
candidates: item.candidateAsins.join("|"),
reason: item.reason ?? "",
})),
);
return;
}
const mapping = await mapUpcsToAsins(upcs);
const items = Array.from(mapping.entries()).map(([upc, asin]) => ({
upc,
asin,
}));
if (parsed.asJson) {
console.log(JSON.stringify(items, null, 2));
return;
}
if (items.length === 0) {
console.log(
"No one-to-one UPC to ASIN matches found. Run with --detailed for per-UPC status.",
);
return;
}
console.table(items);
}
main().catch((err) => {
const message = err instanceof Error ? err.message : String(err);
console.error(`UPC lookup failed: ${message}`);
process.exit(1);
});

View File

@@ -61,6 +61,8 @@ type ResultItem = {
seller_count: number | null;
monthly_sold: number | null;
verdict: "FBA" | "FBM" | "SKIP";
amazon_is_seller: number | null;
amazon_buybox_share_pct_90d: number | null;
confidence: number | null;
sellability_status: string | null;
reasoning: string | null;
@@ -76,6 +78,7 @@ type ResultsResponse = {
};
type VerdictFilter = "" | "FBA" | "FBM" | "SKIP";
type AmazonSellerFilter = "" | "yes" | "no";
type ProductListItem = {
processType: ProcessType;
@@ -89,6 +92,8 @@ type ProductListItem = {
sellability_status: string | null;
monthly_sold: number | null;
seller_count: number | null;
amazon_is_seller: number | null;
amazon_buybox_share_pct_90d: number | null;
sales_rank: number | null;
current_price: number | null;
avg_price_90d: number | null;
@@ -129,6 +134,11 @@ function formatCurrency(value: number | null | undefined): string {
}).format(value);
}
function formatAmazonSeller(value: number | null | undefined): string {
if (value === null || value === undefined) return "-";
return value === 1 ? "Yes" : "No";
}
function buildSortValue(sort: SortState): string {
return `${sort.field}:${sort.direction}`;
}
@@ -420,12 +430,15 @@ function RunDetails({
const [search, setSearch] = useState("");
const [verdict, setVerdict] = useState("");
const [sellabilityStatus, setSellabilityStatus] = useState("");
const [amazonSellerFilter, setAmazonSellerFilter] =
useState<AmazonSellerFilter>("");
const [minConfidence, setMinConfidence] = useState("");
const [maxConfidence, setMaxConfidence] = useState("");
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(25);
const [sort, setSort] = useState<SortState>({ field: "monthly_sold", direction: "DESC" });
const [refreshTick, setRefreshTick] = useState(0);
const [reanalyzing, setReanalyzing] = useState<Record<string, boolean>>({});
const anomalies = useMemo(() => {
if (!results) return [] as ResultItem[];
@@ -459,6 +472,7 @@ function RunDetails({
if (search) params.set("q", search);
if (verdict) params.set("verdict", verdict);
if (sellabilityStatus) params.set("sellabilityStatus", sellabilityStatus);
if (amazonSellerFilter) params.set("amazonIsSeller", amazonSellerFilter);
if (minConfidence) params.set("minConfidence", minConfidence);
if (maxConfidence) params.set("maxConfidence", maxConfidence);
@@ -474,7 +488,7 @@ function RunDetails({
return () => {
cancelled = true;
};
}, [processType, runId, search, verdict, sellabilityStatus, minConfidence, maxConfidence, page, pageSize, sort, refreshTick]);
}, [processType, runId, search, verdict, sellabilityStatus, amazonSellerFilter, minConfidence, maxConfidence, page, pageSize, sort, refreshTick]);
useEffect(() => {
const interval = window.setInterval(() => {
@@ -485,6 +499,29 @@ function RunDetails({
};
}, [processType, runId]);
async function reanalyzeAsin(asin: string) {
if (reanalyzing[asin]) return;
setReanalyzing((prev) => ({ ...prev, [asin]: true }));
try {
const response = await fetch(
`/api/runs/${processType}/${runId}/asins/${encodeURIComponent(asin)}/reanalyze`,
{ method: "POST" },
);
if (!response.ok) {
const payload = (await response.json().catch(() => null)) as { error?: string } | null;
window.alert(payload?.error ?? "Failed to re-analyze ASIN");
return;
}
setRefreshTick((tick) => tick + 1);
} finally {
setReanalyzing((prev) => {
const next = { ...prev };
delete next[asin];
return next;
});
}
}
return (
<div className="page">
<button className="back" onClick={onBack}>Back</button>
@@ -522,6 +559,17 @@ function RunDetails({
<option value="not_available">not_available</option>
<option value="unknown">unknown</option>
</select>
<select
value={amazonSellerFilter}
onChange={(e) => {
setPage(1);
setAmazonSellerFilter(e.target.value as AmazonSellerFilter);
}}
>
<option value="">Amazon seller: all</option>
<option value="yes">Amazon seller: yes</option>
<option value="no">Amazon seller: no</option>
</select>
<input value={minConfidence} onChange={(e) => { setPage(1); setMinConfidence(e.target.value); }} placeholder="Min confidence" />
<input value={maxConfidence} onChange={(e) => { setPage(1); setMaxConfidence(e.target.value); }} placeholder="Max confidence" />
<select value={String(pageSize)} onChange={(e) => { setPage(1); setPageSize(Number(e.target.value)); }}>
@@ -532,7 +580,7 @@ function RunDetails({
</div>
<div style={{ marginTop: 10 }}>
<a
href={`/api/runs/${processType}/${runId}/export.csv?q=${encodeURIComponent(search)}&verdict=${encodeURIComponent(verdict)}&sellabilityStatus=${encodeURIComponent(sellabilityStatus)}&minConfidence=${encodeURIComponent(minConfidence)}&maxConfidence=${encodeURIComponent(maxConfidence)}&sort=${encodeURIComponent(buildSortValue(sort))}`}
href={`/api/runs/${processType}/${runId}/export.csv?q=${encodeURIComponent(search)}&verdict=${encodeURIComponent(verdict)}&sellabilityStatus=${encodeURIComponent(sellabilityStatus)}&amazonIsSeller=${encodeURIComponent(amazonSellerFilter)}&minConfidence=${encodeURIComponent(minConfidence)}&maxConfidence=${encodeURIComponent(maxConfidence)}&sort=${encodeURIComponent(buildSortValue(sort))}`}
>
<button>Export filtered CSV</button>
</a>
@@ -565,6 +613,8 @@ function RunDetails({
<th><button onClick={() => setSort(nextSort(sort, "verdict"))}>Verdict</button></th>
<th><button onClick={() => setSort(nextSort(sort, "monthly_sold"))}>Monthly Sold</button></th>
<th><button onClick={() => setSort(nextSort(sort, "seller_count"))}>Sellers</button></th>
<th><button onClick={() => setSort(nextSort(sort, "amazon_is_seller"))}>Amazon Seller</button></th>
<th><button onClick={() => setSort(nextSort(sort, "amazon_buybox_share_pct_90d"))}>Amazon Buy Box 90d %</button></th>
<th><button onClick={() => setSort(nextSort(sort, "sales_rank"))}>Sales Rank</button></th>
<th><button onClick={() => setSort(nextSort(sort, "current_price"))}>Current Price</button></th>
<th><button onClick={() => setSort(nextSort(sort, "product_name"))}>Product</button></th>
@@ -573,11 +623,12 @@ function RunDetails({
<th><button onClick={() => setSort(nextSort(sort, "avg_price_90d"))}>Avg 90d</button></th>
<th><button onClick={() => setSort(nextSort(sort, "confidence"))}>Confidence</button></th>
<th className="reason-col"><button onClick={() => setSort(nextSort(sort, "reasoning"))}>Reasoning</button></th>
<th>Action</th>
</tr>
</thead>
<tbody>
{loading ? (
<tr><td colSpan={12}>Loading...</td></tr>
<tr><td colSpan={15}>Loading...</td></tr>
) : results?.items.length ? (
results.items.map((item) => (
<tr key={`${item.asin}-${item.fetched_at}`}>
@@ -585,6 +636,8 @@ function RunDetails({
<td><span className={verdictBadgeClass(item.verdict)}>{item.verdict}</span></td>
<td>{formatNumber(item.monthly_sold)}</td>
<td>{formatNumber(item.seller_count)}</td>
<td>{formatAmazonSeller(item.amazon_is_seller)}</td>
<td>{formatNumber(item.amazon_buybox_share_pct_90d)}</td>
<td>{formatNumber(item.sales_rank)}</td>
<td>{formatCurrency(item.current_price)}</td>
<td title={item.reasoning || undefined}>{item.product_name || "-"}</td>
@@ -593,10 +646,18 @@ function RunDetails({
<td>{formatCurrency(item.avg_price_90d)}</td>
<td>{formatNumber(item.confidence)}</td>
<td className="reason-col" title={item.reasoning || undefined}>{item.reasoning || "-"}</td>
<td>
<button
onClick={() => reanalyzeAsin(item.asin)}
disabled={Boolean(reanalyzing[item.asin])}
>
{reanalyzing[item.asin] ? "Re-analyzing..." : "Re-analyze"}
</button>
</td>
</tr>
))
) : (
<tr><td colSpan={12}>No results found</td></tr>
<tr><td colSpan={15}>No results found</td></tr>
)}
</tbody>
</table>
@@ -619,9 +680,12 @@ function ProductList({ verdict, onBack }: { verdict: VerdictFilter; onBack: () =
const [loading, setLoading] = useState(false);
const [search, setSearch] = useState("");
const [activeVerdict, setActiveVerdict] = useState<VerdictFilter>(verdict);
const [amazonSellerFilter, setAmazonSellerFilter] =
useState<AmazonSellerFilter>("");
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(25);
const [sort, setSort] = useState<SortState>({ field: "monthly_sold", direction: "DESC" });
const [reanalyzing, setReanalyzing] = useState<Record<string, boolean>>({});
useEffect(() => {
setActiveVerdict(verdict);
@@ -636,6 +700,7 @@ function ProductList({ verdict, onBack }: { verdict: VerdictFilter; onBack: () =
params.set("sort", buildSortValue(sort));
if (search) params.set("q", search);
if (activeVerdict) params.set("verdict", activeVerdict);
if (amazonSellerFilter) params.set("amazonIsSeller", amazonSellerFilter);
const res = await fetch(`/api/products?${params.toString()}`);
const payload = (await res.json()) as ProductListResponse;
if (!cancelled) {
@@ -648,7 +713,38 @@ function ProductList({ verdict, onBack }: { verdict: VerdictFilter; onBack: () =
return () => {
cancelled = true;
};
}, [search, activeVerdict, page, pageSize, sort]);
}, [search, activeVerdict, amazonSellerFilter, page, pageSize, sort]);
async function reanalyzeAsin(item: ProductListItem) {
const key = `${item.processType}:${item.runId}:${item.asin}`;
if (reanalyzing[key]) return;
setReanalyzing((prev) => ({ ...prev, [key]: true }));
try {
const response = await fetch(
`/api/runs/${item.processType}/${item.runId}/asins/${encodeURIComponent(item.asin)}/reanalyze`,
{ method: "POST" },
);
if (!response.ok) {
const payload = (await response.json().catch(() => null)) as { error?: string } | null;
window.alert(payload?.error ?? "Failed to re-analyze ASIN");
return;
}
const params = new URLSearchParams({ page: String(page), pageSize: String(pageSize) });
params.set("sort", buildSortValue(sort));
if (search) params.set("q", search);
if (activeVerdict) params.set("verdict", activeVerdict);
if (amazonSellerFilter) params.set("amazonIsSeller", amazonSellerFilter);
const res = await fetch(`/api/products?${params.toString()}`);
const payload = (await res.json()) as ProductListResponse;
setItems(payload);
} finally {
setReanalyzing((prev) => {
const next = { ...prev };
delete next[key];
return next;
});
}
}
return (
<div className="page">
@@ -663,6 +759,17 @@ function ProductList({ verdict, onBack }: { verdict: VerdictFilter; onBack: () =
<option value="FBM">FBM</option>
<option value="SKIP">SKIP</option>
</select>
<select
value={amazonSellerFilter}
onChange={(e) => {
setPage(1);
setAmazonSellerFilter(e.target.value as AmazonSellerFilter);
}}
>
<option value="">Amazon seller: all</option>
<option value="yes">Amazon seller: yes</option>
<option value="no">Amazon seller: no</option>
</select>
<select value={String(pageSize)} onChange={(e) => { setPage(1); setPageSize(Number(e.target.value)); }}>
<option value="25">25 / page</option>
<option value="50">50 / page</option>
@@ -679,6 +786,8 @@ function ProductList({ verdict, onBack }: { verdict: VerdictFilter; onBack: () =
<th><button onClick={() => setSort(nextSort(sort, "verdict"))}>Verdict</button></th>
<th><button onClick={() => setSort(nextSort(sort, "monthly_sold"))}>Monthly Sold</button></th>
<th><button onClick={() => setSort(nextSort(sort, "seller_count"))}>Sellers</button></th>
<th><button onClick={() => setSort(nextSort(sort, "amazon_is_seller"))}>Amazon Seller</button></th>
<th><button onClick={() => setSort(nextSort(sort, "amazon_buybox_share_pct_90d"))}>Amazon Buy Box 90d %</button></th>
<th><button onClick={() => setSort(nextSort(sort, "sales_rank"))}>Sales Rank</button></th>
<th><button onClick={() => setSort(nextSort(sort, "current_price"))}>Current Price</button></th>
<th className="product-col"><button onClick={() => setSort(nextSort(sort, "product_name"))}>Product</button></th>
@@ -687,11 +796,12 @@ function ProductList({ verdict, onBack }: { verdict: VerdictFilter; onBack: () =
<th><button onClick={() => setSort(nextSort(sort, "avg_price_90d"))}>Avg 90d</button></th>
<th><button onClick={() => setSort(nextSort(sort, "confidence"))}>Confidence</button></th>
<th className="reason-col"><button onClick={() => setSort(nextSort(sort, "reasoning"))}>Reasoning</button></th>
<th>Action</th>
</tr>
</thead>
<tbody>
{loading ? (
<tr><td colSpan={12}>Loading...</td></tr>
<tr><td colSpan={15}>Loading...</td></tr>
) : items?.items.length ? (
items.items.map((item) => (
<tr key={`${item.processType}-${item.runId}-${item.asin}-${item.fetched_at}`}>
@@ -699,6 +809,8 @@ function ProductList({ verdict, onBack }: { verdict: VerdictFilter; onBack: () =
<td><span className={verdictBadgeClass(item.verdict)}>{item.verdict}</span></td>
<td>{formatNumber(item.monthly_sold)}</td>
<td>{formatNumber(item.seller_count)}</td>
<td>{formatAmazonSeller(item.amazon_is_seller)}</td>
<td>{formatNumber(item.amazon_buybox_share_pct_90d)}</td>
<td>{formatNumber(item.sales_rank)}</td>
<td>{formatCurrency(item.current_price)}</td>
<td className="product-col" title={item.reasoning || undefined}>{item.product_name || "-"}</td>
@@ -707,10 +819,18 @@ function ProductList({ verdict, onBack }: { verdict: VerdictFilter; onBack: () =
<td>{formatCurrency(item.avg_price_90d)}</td>
<td>{formatNumber(item.confidence)}</td>
<td className="reason-col" title={item.reasoning || undefined}>{item.reasoning || "-"}</td>
<td>
<button
onClick={() => reanalyzeAsin(item)}
disabled={Boolean(reanalyzing[`${item.processType}:${item.runId}:${item.asin}`])}
>
{reanalyzing[`${item.processType}:${item.runId}:${item.asin}`] ? "Re-analyzing..." : "Re-analyze"}
</button>
</td>
</tr>
))
) : (
<tr><td colSpan={12}>No products found</td></tr>
<tr><td colSpan={15}>No products found</td></tr>
)}
</tbody>
</table>

View File

@@ -1,5 +1,24 @@
import { getDb } from "./database.ts";
import type { AnalysisResult } from "./types.ts";
import type { AnalysisResult, SupplierAnalysisResult } from "./types.ts";
export type RunCounts = {
totalProducts: number;
fbaCount: number;
fbmCount: number;
skipCount: number;
};
function computeRunCountsFromResults(results: AnalysisResult[]): RunCounts {
const fbaCount = results.filter((r) => r.verdict.verdict === "FBA").length;
const fbmCount = results.filter((r) => r.verdict.verdict === "FBM").length;
const skipCount = results.filter((r) => r.verdict.verdict === "SKIP").length;
return {
totalProducts: results.length,
fbaCount,
fbmCount,
skipCount,
};
}
function buildRow(r: AnalysisResult) {
const price =
@@ -30,6 +49,9 @@ function buildRow(r: AnalysisResult) {
"Sales Rank": rank ?? "",
"Rank Avg 90d": r.product.keepa?.salesRankAvg90 ?? "",
Sellers: r.product.keepa?.sellerCount ?? "",
"Amazon Is Seller": r.product.keepa?.amazonIsSeller ?? null,
"Amazon Buy Box Share 90d %":
r.product.keepa?.amazonBuyboxSharePct90d ?? "",
"Monthly Sold": r.product.keepa?.monthlySold ?? "",
"Rank Drops 30d": r.product.keepa?.salesRankDrops30 ?? "",
"Rank Drops 90d": r.product.keepa?.salesRankDrops90 ?? "",
@@ -65,12 +87,25 @@ export function writeResultsToDb(
inputFile: string,
outputFile: string | undefined,
): void {
const database = getDb(dbPath);
const runCounts = computeRunCountsFromResults(results);
const runId = startRunInDb(dbPath, inputFile, outputFile, runCounts);
appendResultsToRun(dbPath, runId, results);
console.log(`Results written to SQLite database for run_id: ${runId}`);
}
export function startRunInDb(
dbPath: string,
inputFile: string,
outputFile: string | undefined,
counts: RunCounts = {
totalProducts: 0,
fbaCount: 0,
fbmCount: 0,
skipCount: 0,
},
): number {
const database = getDb(dbPath);
const timestamp = new Date().toISOString();
const fbaCount = results.filter((r) => r.verdict.verdict === "FBA").length;
const fbmCount = results.filter((r) => r.verdict.verdict === "FBM").length;
const skipCount = results.filter((r) => r.verdict.verdict === "SKIP").length;
const insertRun = database.prepare(
`INSERT INTO runs (
@@ -83,37 +118,52 @@ export function writeResultsToDb(
skip_count
) VALUES (?, ?, ?, ?, ?, ?, ?)`,
);
const runInfo = insertRun.run(
timestamp,
inputFile,
outputFile ?? null,
results.length,
fbaCount,
fbmCount,
skipCount,
counts.totalProducts,
counts.fbaCount,
counts.fbmCount,
counts.skipCount,
);
const runId =
(runInfo.changes as number) > 0
? (runInfo.lastInsertRowid as number)
: null;
if (runId === null) {
console.error("Failed to insert run record into SQLite.");
throw new Error("Failed to insert run record into SQLite.");
}
return runId;
}
export function appendResultsToRun(
dbPath: string,
runId: number,
results: AnalysisResult[],
): void {
if (results.length === 0) {
return;
}
const database = getDb(dbPath);
const insertResult = database.prepare(
`INSERT INTO results (
run_id, asin, product_name, brand, category, unit_cost, current_price,
avg_price_90d, avg_price_90d_sheet, selling_price_sheet, sales_rank, rank_avg_90d,
sellers, monthly_sold, rank_drops_30d, rank_drops_90d, fba_net_sheet,
sellers, amazon_is_seller, amazon_buybox_share_pct_90d,
monthly_sold, rank_drops_30d, rank_drops_90d, fba_net_sheet,
gross_profit_dollar, gross_profit_pct, net_profit_sheet, roi_sheet, moq, moq_cost,
qty_available, supplier, source_url, asin_link, promo_coupon_code, notes, lead_date,
fba_fee, fbm_fee, referral_percent, can_sell, sellability_status, sellability_reason,
verdict, confidence, reasoning, fetched_at
) VALUES (
?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?,
?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?
?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?
)`,
);
@@ -134,6 +184,12 @@ export function writeResultsToDb(
row["Sales Rank"] ?? null,
row["Rank Avg 90d"] ?? null,
row.Sellers ?? null,
row["Amazon Is Seller"] == null
? null
: row["Amazon Is Seller"]
? 1
: 0,
row["Amazon Buy Box Share 90d %"] ?? null,
row["Monthly Sold"] ?? null,
row["Rank Drops 30d"] ?? null,
row["Rank Drops 90d"] ?? null,
@@ -164,7 +220,126 @@ export function writeResultsToDb(
);
}
})();
console.log(`Results written to SQLite database for run_id: ${runId}`);
}
export function appendSupplierResultsToRun(
dbPath: string,
runId: number,
results: SupplierAnalysisResult[],
): void {
if (results.length === 0) {
return;
}
const database = getDb(dbPath);
const insertResult = database.prepare(
`INSERT INTO results (
run_id, asin, product_name, brand, category, unit_cost, current_price,
avg_price_90d, sales_rank, rank_avg_90d, sellers,
amazon_is_seller, amazon_buybox_share_pct_90d, monthly_sold,
rank_drops_30d, rank_drops_90d, upc, fba_fee, fbm_fee,
referral_percent, supplier_score, supplier_profit, supplier_margin,
supplier_roi, supplier_reason, upc_lookup_status, upc_lookup_reason,
candidate_asins, can_sell, sellability_status, sellability_reason,
verdict, confidence, reasoning, fetched_at
) VALUES (
?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?,
?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?
)`,
);
database.transaction(() => {
for (const result of results) {
const keepa = result.keepa;
const spApi = result.spApi;
const asin = result.lookup.asin ?? result.record.asin ?? result.upc;
const category =
result.record.category ?? keepa?.categoryTree?.join(" > ") ?? null;
const canSell =
spApi?.canSell == null ? null : spApi.canSell ? "yes" : "no";
insertResult.run(
runId,
asin,
result.record.name,
result.record.brand ?? null,
category,
result.record.unitCost || null,
result.score.salePrice,
keepa?.avgPrice90 ?? null,
keepa?.salesRank ?? null,
keepa?.salesRankAvg90 ?? null,
keepa?.sellerCount ?? null,
keepa?.amazonIsSeller == null ? null : keepa.amazonIsSeller ? 1 : 0,
keepa?.amazonBuyboxSharePct90d ?? null,
keepa?.monthlySold ?? null,
keepa?.salesRankDrops30 ?? null,
keepa?.salesRankDrops90 ?? null,
result.upc,
result.score.fbaFee,
spApi?.fbmFee ?? null,
spApi?.referralFeePercent ?? null,
result.score.score,
result.score.profit,
result.score.margin,
result.score.roi,
result.score.reason,
result.lookup.status,
result.lookup.reason ?? null,
result.lookup.candidateAsins.join(","),
canSell,
spApi?.sellabilityStatus ?? null,
spApi?.sellabilityReason ?? null,
result.score.verdict,
Math.round(result.score.score),
result.score.reason,
result.fetchedAt,
);
}
})();
}
export function refreshRunCountsInDb(dbPath: string, runId: number): RunCounts {
const database = getDb(dbPath);
const stats = database
.query(
`SELECT
COUNT(*) AS total,
SUM(CASE WHEN verdict = 'FBA' THEN 1 ELSE 0 END) AS fba,
SUM(CASE WHEN verdict = 'FBM' THEN 1 ELSE 0 END) AS fbm,
SUM(CASE WHEN verdict = 'SKIP' THEN 1 ELSE 0 END) AS skip
FROM results
WHERE run_id = ?`,
)
.get(runId) as {
total: number;
fba: number | null;
fbm: number | null;
skip: number | null;
};
const counts: RunCounts = {
totalProducts: stats.total ?? 0,
fbaCount: stats.fba ?? 0,
fbmCount: stats.fbm ?? 0,
skipCount: stats.skip ?? 0,
};
database
.query(
`UPDATE runs
SET total_products = ?, fba_count = ?, fbm_count = ?, skip_count = ?
WHERE id = ?`,
)
.run(
counts.totalProducts,
counts.fbaCount,
counts.fbmCount,
counts.skipCount,
runId,
);
return counts;
}
export function printResults(results: AnalysisResult[]): void {
const rows = results