Compare commits
10 Commits
4eff4a4a2a
...
0f9b785cce
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0f9b785cce | ||
|
|
f3e4d3ac52 | ||
|
|
41ef57a7bc | ||
|
|
f2c8a9728d | ||
|
|
9b832b7839 | ||
|
|
072a501102 | ||
|
|
32e7b0c485 | ||
|
|
d25cf5d5ec | ||
|
|
b52cdc7f2b | ||
|
|
8d6b0f9e0f |
15
.gitignore
vendored
15
.gitignore
vendored
@@ -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
173
CLAUDE.md
@@ -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
142
README.md
@@ -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
189
bun.lock
@@ -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=="],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,3 +5,4 @@ id,name
|
||||
283155,Books
|
||||
16310101,Grocery Gourmet Food
|
||||
599858,Magazine Subscriptions
|
||||
5174,CDs & Vinyl
|
||||
|
10
package.json
10
package.json
@@ -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
276
src/analysis-pipeline.ts
Normal 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;
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
|
||||
42
src/cache.ts
42
src/cache.ts
@@ -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();
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);`,
|
||||
|
||||
265
src/index.ts
265
src/index.ts
@@ -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 } {
|
||||
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;
|
||||
}
|
||||
|
||||
console.error(
|
||||
`Invalid --sellability value: \"${rawSellability}\". Use \"available\" or \"all\".`,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
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]",
|
||||
"Usage: bun run src/index.ts <input.csv|xlsx> [--out results.csv] [--sellability available|all]",
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
return { inputFile, outputFile };
|
||||
}
|
||||
|
||||
function chunkArray<T>(items: T[], chunkSize: number): T[][] {
|
||||
const chunks: T[][] = [];
|
||||
for (let i = 0; i < items.length; i += chunkSize) {
|
||||
chunks.push(items.slice(i, i + chunkSize));
|
||||
}
|
||||
return chunks;
|
||||
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
241
src/keepa.test.ts
Normal 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");
|
||||
});
|
||||
472
src/keepa.ts
472
src/keepa.ts
@@ -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;
|
||||
|
||||
61
src/llm.ts
61
src/llm.ts
@@ -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
|
||||
|
||||
424
src/mid-range-sellers-by-category.test.ts
Normal file
424
src/mid-range-sellers-by-category.test.ts
Normal 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);
|
||||
});
|
||||
2080
src/mid-range-sellers-by-category.ts
Normal file
2080
src/mid-range-sellers-by-category.ts
Normal file
File diff suppressed because it is too large
Load Diff
881
src/server.ts
881
src/server.ts
File diff suppressed because it is too large
Load Diff
46
src/sp-api.test.ts
Normal file
46
src/sp-api.test.ts
Normal 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");
|
||||
});
|
||||
189
src/sp-api.ts
189
src/sp-api.ts
@@ -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,17 +724,23 @@ export async function fetchSpApiPricingAndFees(
|
||||
}
|
||||
|
||||
try {
|
||||
const pricing = (await spClient.callAPI({
|
||||
operation: "getItemOffers",
|
||||
endpoint: "productPricing",
|
||||
path: { Asin: asin },
|
||||
query: {
|
||||
MarketplaceId: config.spApiMarketplaceId,
|
||||
ItemCondition: "New",
|
||||
},
|
||||
})) as any;
|
||||
let estimatedSalePrice =
|
||||
typeof priceOverride === "number" && Number.isFinite(priceOverride)
|
||||
? priceOverride
|
||||
: 0;
|
||||
if (estimatedSalePrice <= 0) {
|
||||
const pricing = (await spClient.callAPI({
|
||||
operation: "getItemOffers",
|
||||
endpoint: "productPricing",
|
||||
path: { Asin: asin },
|
||||
query: {
|
||||
MarketplaceId: config.spApiMarketplaceId,
|
||||
ItemCondition: "New",
|
||||
},
|
||||
})) 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
134
src/supplier-export.test.ts
Normal 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
158
src/supplier-export.ts
Normal 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);
|
||||
}
|
||||
97
src/supplier-scoring.test.ts
Normal file
97
src/supplier-scoring.test.ts
Normal 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
224
src/supplier-scoring.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -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 [
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
48
src/types.ts
48
src/types.ts
@@ -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
562
src/upc-file-analysis.ts
Normal 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
363
src/upc-file-reader.ts
Normal 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
147
src/upc-lookup.ts
Normal 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);
|
||||
});
|
||||
@@ -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>
|
||||
|
||||
201
src/writer.ts
201
src/writer.ts
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user