feat: Integrate Amazon SP-API for product sellability and pricing
- Added `amazon-sp-api` dependency to package.json. - Enhanced configuration to include SP-API credentials and settings. - Implemented SP-API client initialization and error handling in sp-api.ts. - Developed functions to fetch product sellability and pricing data. - Updated main processing logic in index.ts to incorporate sellability checks before fetching pricing. - Modified LLM analysis to account for sellability status and reasons. - Created a new sp-test.ts script for testing SP-API connectivity and sellability. - Updated types.ts to define SellabilityInfo and extend SpApiData. - Enhanced result reporting in writer.ts to include sellability information.
This commit is contained in:
10
.env.example
10
.env.example
@@ -1,4 +1,14 @@
|
|||||||
KEEPA_API_KEY=your_keepa_api_key_here
|
KEEPA_API_KEY=your_keepa_api_key_here
|
||||||
|
SP_API_CLIENT_ID=your_sp_api_client_id
|
||||||
|
SP_API_CLIENT_SECRET=your_sp_api_client_secret
|
||||||
|
SP_API_REFRESH_TOKEN=your_sp_api_refresh_token
|
||||||
|
SP_API_REGION=na
|
||||||
|
SP_API_MARKETPLACE_ID=ATVPDKIKX0DER
|
||||||
|
SP_API_SELLER_ID=your_seller_id
|
||||||
|
SP_API_USE_SANDBOX=false
|
||||||
|
AWS_ACCESS_KEY_ID=your_aws_access_key_id
|
||||||
|
AWS_SECRET_ACCESS_KEY=your_aws_secret_access_key
|
||||||
|
# AWS_SESSION_TOKEN=optional_if_using_sts
|
||||||
REDIS_URL=redis://localhost:6379
|
REDIS_URL=redis://localhost:6379
|
||||||
LLM_URL=http://localhost:1234/v1
|
LLM_URL=http://localhost:1234/v1
|
||||||
LLM_MODEL=default
|
LLM_MODEL=default
|
||||||
|
|||||||
26
README.md
26
README.md
@@ -8,13 +8,14 @@ Amazon product analysis and lead finder agent. Reads product leads from a CSV/XL
|
|||||||
- Redis (local or Docker)
|
- Redis (local or Docker)
|
||||||
- [LM Studio](https://lmstudio.ai) running locally with a model loaded
|
- [LM Studio](https://lmstudio.ai) running locally with a model loaded
|
||||||
- Keepa API key ([keepa.com](https://keepa.com))
|
- Keepa API key ([keepa.com](https://keepa.com))
|
||||||
|
- Amazon SP-API private app credentials (LWA + refresh token + IAM)
|
||||||
|
|
||||||
## Setup
|
## Setup
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
bun install
|
bun install
|
||||||
cp .env.example .env
|
cp .env.example .env
|
||||||
# Edit .env and set your KEEPA_API_KEY
|
# Edit .env and set your KEEPA_API_KEY and SP-API credentials
|
||||||
```
|
```
|
||||||
|
|
||||||
## Usage
|
## Usage
|
||||||
@@ -30,6 +31,14 @@ bun run src/index.ts leads.xlsx
|
|||||||
bun run src/index.ts leads.csv --out results.xlsx
|
bun run src/index.ts leads.csv --out results.xlsx
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Quick SP-API connectivity test:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bun run src/sp-test.ts
|
||||||
|
bun run src/sp-test.ts B07SN9BHVV
|
||||||
|
bun run src/sp-test.ts --sellability B07SN9BHVV
|
||||||
|
```
|
||||||
|
|
||||||
## Input file format
|
## Input file format
|
||||||
|
|
||||||
Accepts `.csv` or `.xlsx` files. Column names are matched case-insensitively. Required column:
|
Accepts `.csv` or `.xlsx` files. Column names are matched case-insensitively. Required column:
|
||||||
@@ -90,8 +99,18 @@ ASIN, Name, Brand, Category, Unit Cost, Current Price, Avg Price 90d, Sales Rank
|
|||||||
## Environment variables
|
## Environment variables
|
||||||
|
|
||||||
| Variable | Default | Description |
|
| Variable | Default | Description |
|
||||||
| --------------- | -------------------------- | ------------------------------- |
|
| ----------------------- | -------------------------- | ----------------------------------------------------------------------- |
|
||||||
| `KEEPA_API_KEY` | — | **Required.** Keepa API key |
|
| `KEEPA_API_KEY` | — | **Required.** Keepa API key |
|
||||||
|
| `SP_API_CLIENT_ID` | — | LWA app client id from Solution Provider Portal |
|
||||||
|
| `SP_API_CLIENT_SECRET` | — | LWA app client secret from Solution Provider Portal |
|
||||||
|
| `SP_API_REFRESH_TOKEN` | — | Refresh token from self-authorization |
|
||||||
|
| `SP_API_REGION` | `na` | SP-API endpoint region (`na`, `eu`, `fe`; `us` is accepted as `na`) |
|
||||||
|
| `SP_API_MARKETPLACE_ID` | `ATVPDKIKX0DER` | Marketplace id used for pricing and fee calls (default: US) |
|
||||||
|
| `SP_API_SELLER_ID` | — | Seller ID used for listing restrictions eligibility checks |
|
||||||
|
| `SP_API_USE_SANDBOX` | `false` | Enable SP-API sandbox mode (`true`/`false`) |
|
||||||
|
| `AWS_ACCESS_KEY_ID` | — | AWS credentials for SigV4 signing (required in most private app setups) |
|
||||||
|
| `AWS_SECRET_ACCESS_KEY` | — | AWS credentials for SigV4 signing |
|
||||||
|
| `AWS_SESSION_TOKEN` | — | Optional session token when using STS credentials |
|
||||||
| `REDIS_URL` | `redis://localhost:6379` | Redis connection URL |
|
| `REDIS_URL` | `redis://localhost:6379` | Redis connection URL |
|
||||||
| `LLM_URL` | `http://localhost:1234/v1` | LM Studio API base URL |
|
| `LLM_URL` | `http://localhost:1234/v1` | LM Studio API base URL |
|
||||||
| `LLM_MODEL` | `default` | Model name to pass to LM Studio |
|
| `LLM_MODEL` | `default` | Model name to pass to LM Studio |
|
||||||
@@ -101,4 +120,5 @@ ASIN, Name, Brand, Category, Unit Cost, Current Price, Avg Price 90d, Sales Rank
|
|||||||
|
|
||||||
- **Keepa rate limiting**: The client reads `tokensLeft` and `refillRate` from each API response and waits automatically when tokens are exhausted. With a Pro subscription (1 token/min), all 100 ASINs in a batch cost 1 token.
|
- **Keepa rate limiting**: The client reads `tokensLeft` and `refillRate` from each API response and waits automatically when tokens are exhausted. With a Pro subscription (1 token/min), all 100 ASINs in a batch cost 1 token.
|
||||||
- **Redis is optional**: If Redis is unavailable the tool runs without caching — every run re-fetches from Keepa.
|
- **Redis is optional**: If Redis is unavailable the tool runs without caching — every run re-fetches from Keepa.
|
||||||
- **SP-API**: Fee data is currently stubbed with estimates. The `src/sp-api.ts` module has TODO comments marking where real LWA OAuth + fee endpoint calls should go.
|
- **SP-API**: `src/sp-api.ts` now uses `amazon-sp-api` to fetch offer pricing and FBA/FBM fee estimates. If SP-API credentials are missing or a call fails, the tool falls back to conservative fee defaults and keeps processing.
|
||||||
|
- **Sandbox vs production**: When `SP_API_USE_SANDBOX=true`, production ASIN calls can be denied. Use sandbox-compatible test data or set it to `false` for live marketplace connectivity.
|
||||||
|
|||||||
57
bun.lock
57
bun.lock
@@ -5,6 +5,7 @@
|
|||||||
"": {
|
"": {
|
||||||
"name": "asin-check",
|
"name": "asin-check",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"amazon-sp-api": "^1.2.1",
|
||||||
"ioredis": "^5.10.1",
|
"ioredis": "^5.10.1",
|
||||||
"xlsx": "^0.18.5",
|
"xlsx": "^0.18.5",
|
||||||
},
|
},
|
||||||
@@ -25,8 +26,14 @@
|
|||||||
|
|
||||||
"adler-32": ["adler-32@1.3.1", "", {}, "sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A=="],
|
"adler-32": ["adler-32@1.3.1", "", {}, "sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A=="],
|
||||||
|
|
||||||
|
"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=="],
|
||||||
|
|
||||||
"bun-types": ["bun-types@1.3.11", "", { "dependencies": { "@types/node": "*" } }, "sha512-1KGPpoxQWl9f6wcZh57LvrPIInQMn2TQ7jsgxqpRzg+l0QPOFvJVH7HmvHo/AiPgwXy+/Thf6Ov3EdVn1vOabg=="],
|
"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=="],
|
||||||
|
|
||||||
|
"call-bound": ["call-bound@1.0.4", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "get-intrinsic": "^1.3.0" } }, "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg=="],
|
||||||
|
|
||||||
"cfb": ["cfb@1.2.2", "", { "dependencies": { "adler-32": "~1.3.0", "crc-32": "~1.2.0" } }, "sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA=="],
|
"cfb": ["cfb@1.2.2", "", { "dependencies": { "adler-32": "~1.3.0", "crc-32": "~1.2.0" } }, "sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA=="],
|
||||||
|
|
||||||
"cluster-key-slot": ["cluster-key-slot@1.1.2", "", {}, "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA=="],
|
"cluster-key-slot": ["cluster-key-slot@1.1.2", "", {}, "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA=="],
|
||||||
@@ -35,28 +42,78 @@
|
|||||||
|
|
||||||
"crc-32": ["crc-32@1.2.2", "", { "bin": { "crc32": "bin/crc32.njs" } }, "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ=="],
|
"crc-32": ["crc-32@1.2.2", "", { "bin": { "crc32": "bin/crc32.njs" } }, "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ=="],
|
||||||
|
|
||||||
|
"csvtojson": ["csvtojson@2.0.14", "", { "dependencies": { "lodash": "^4.17.21" }, "bin": { "csvtojson": "bin/csvtojson" } }, "sha512-F7NNvhhDyob7OsuEGRsH0FM1aqLs/WYITyza3l+hTEEmOK9sGPBlYQZwlVG0ezCojXYpE17lhS5qL6BCOZSPyA=="],
|
||||||
|
|
||||||
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
|
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
|
||||||
|
|
||||||
"denque": ["denque@2.1.0", "", {}, "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw=="],
|
"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=="],
|
||||||
|
|
||||||
|
"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=="],
|
||||||
|
|
||||||
|
"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=="],
|
"frac": ["frac@1.1.2", "", {}, "sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA=="],
|
||||||
|
|
||||||
|
"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=="],
|
||||||
|
|
||||||
|
"gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="],
|
||||||
|
|
||||||
|
"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=="],
|
||||||
|
|
||||||
"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=="],
|
"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=="],
|
||||||
|
|
||||||
|
"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.defaults": ["lodash.defaults@4.2.0", "", {}, "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ=="],
|
||||||
|
|
||||||
"lodash.isarguments": ["lodash.isarguments@3.1.0", "", {}, "sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg=="],
|
"lodash.isarguments": ["lodash.isarguments@3.1.0", "", {}, "sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg=="],
|
||||||
|
|
||||||
|
"math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="],
|
||||||
|
|
||||||
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
|
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
|
||||||
|
|
||||||
|
"object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="],
|
||||||
|
|
||||||
|
"path-expression-matcher": ["path-expression-matcher@1.4.0", "", {}, "sha512-s4DQMxIdhj3jLFWd9LxHOplj4p9yQ4ffMGowFf3cpEgrrJjEhN0V5nxw4Ye1EViAGDoL4/1AeO6qHpqYPOzE4Q=="],
|
||||||
|
|
||||||
|
"qs": ["qs@6.15.1", "", { "dependencies": { "side-channel": "^1.1.0" } }, "sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg=="],
|
||||||
|
|
||||||
"redis-errors": ["redis-errors@1.2.0", "", {}, "sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w=="],
|
"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=="],
|
"redis-parser": ["redis-parser@3.0.0", "", { "dependencies": { "redis-errors": "^1.0.0" } }, "sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A=="],
|
||||||
|
|
||||||
|
"safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="],
|
||||||
|
|
||||||
|
"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=="],
|
||||||
|
|
||||||
|
"side-channel-map": ["side-channel-map@1.0.1", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3" } }, "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA=="],
|
||||||
|
|
||||||
|
"side-channel-weakmap": ["side-channel-weakmap@1.0.2", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3", "side-channel-map": "^1.0.1" } }, "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A=="],
|
||||||
|
|
||||||
"ssf": ["ssf@0.11.2", "", { "dependencies": { "frac": "~1.1.2" } }, "sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g=="],
|
"ssf": ["ssf@0.11.2", "", { "dependencies": { "frac": "~1.1.2" } }, "sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g=="],
|
||||||
|
|
||||||
"standard-as-callback": ["standard-as-callback@2.1.0", "", {}, "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A=="],
|
"standard-as-callback": ["standard-as-callback@2.1.0", "", {}, "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A=="],
|
||||||
|
|
||||||
|
"strnum": ["strnum@2.2.3", "", {}, "sha512-oKx6RUCuHfT3oyVjtnrmn19H1SiCqgJSg+54XqURKp5aCMbrXrhLjRN9TjuwMjiYstZ0MzDrHqkGZ5dFTKd+zg=="],
|
||||||
|
|
||||||
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
|
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
|
||||||
|
|
||||||
"undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="],
|
"undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="],
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
"typescript": "^5"
|
"typescript": "^5"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"amazon-sp-api": "^1.2.1",
|
||||||
"ioredis": "^5.10.1",
|
"ioredis": "^5.10.1",
|
||||||
"xlsx": "^0.18.5"
|
"xlsx": "^0.18.5"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,10 +8,27 @@ function optional(key: string, fallback: string): string {
|
|||||||
return Bun.env[key] || fallback;
|
return Bun.env[key] || fallback;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function optionalBoolean(key: string, fallback: boolean): boolean {
|
||||||
|
const raw = Bun.env[key];
|
||||||
|
if (!raw) return fallback;
|
||||||
|
const value = raw.trim().toLowerCase();
|
||||||
|
return value === "1" || value === "true" || value === "yes";
|
||||||
|
}
|
||||||
|
|
||||||
export const config = {
|
export const config = {
|
||||||
keepaApiKey: required("KEEPA_API_KEY"),
|
keepaApiKey: required("KEEPA_API_KEY"),
|
||||||
redisUrl: optional("REDIS_URL", "redis://localhost:6379"),
|
redisUrl: optional("REDIS_URL", "redis://localhost:6379"),
|
||||||
llmUrl: optional("LLM_URL", "http://localhost:1234/v1"),
|
llmUrl: optional("LLM_URL", "http://localhost:1234/v1"),
|
||||||
llmModel: optional("LLM_MODEL", "default"),
|
llmModel: optional("LLM_MODEL", "default"),
|
||||||
cacheTtl: parseInt(optional("CACHE_TTL", "86400"), 10),
|
cacheTtl: parseInt(optional("CACHE_TTL", "86400"), 10),
|
||||||
|
spApiClientId: Bun.env.SP_API_CLIENT_ID,
|
||||||
|
spApiClientSecret: Bun.env.SP_API_CLIENT_SECRET,
|
||||||
|
spApiRefreshToken: Bun.env.SP_API_REFRESH_TOKEN,
|
||||||
|
spApiRegion: optional("SP_API_REGION", "na"),
|
||||||
|
spApiMarketplaceId: optional("SP_API_MARKETPLACE_ID", "ATVPDKIKX0DER"),
|
||||||
|
spApiSellerId: Bun.env.SP_API_SELLER_ID,
|
||||||
|
spApiUseSandbox: optionalBoolean("SP_API_USE_SANDBOX", false),
|
||||||
|
awsAccessKeyId: Bun.env.AWS_ACCESS_KEY_ID,
|
||||||
|
awsSecretAccessKey: Bun.env.AWS_SECRET_ACCESS_KEY,
|
||||||
|
awsSessionToken: Bun.env.AWS_SESSION_TOKEN,
|
||||||
} as const;
|
} as const;
|
||||||
|
|||||||
175
src/index.ts
175
src/index.ts
@@ -1,10 +1,16 @@
|
|||||||
import { readProducts } from "./reader.ts";
|
import { readProducts } from "./reader.ts";
|
||||||
import { fetchKeepaDataBatch } from "./keepa.ts";
|
import { fetchKeepaDataBatch } from "./keepa.ts";
|
||||||
import { fetchSpApiData } from "./sp-api.ts";
|
import { fetchSellabilityBatch, fetchSpApiPricingAndFees } from "./sp-api.ts";
|
||||||
import { connectCache, getCache, setCache, disconnectCache } from "./cache.ts";
|
import { connectCache, getCache, setCache, disconnectCache } from "./cache.ts";
|
||||||
import { analyzeProducts } from "./llm.ts";
|
import { analyzeProducts } from "./llm.ts";
|
||||||
import { printResults, writeResultsCsv } from "./writer.ts";
|
import { printResults, writeResultsCsv } from "./writer.ts";
|
||||||
import type { EnrichedProduct, AnalysisResult, KeepaData, ProductRecord } from "./types.ts";
|
import type {
|
||||||
|
EnrichedProduct,
|
||||||
|
AnalysisResult,
|
||||||
|
KeepaData,
|
||||||
|
ProductRecord,
|
||||||
|
SellabilityInfo,
|
||||||
|
} from "./types.ts";
|
||||||
|
|
||||||
const LLM_BATCH_SIZE = 5;
|
const LLM_BATCH_SIZE = 5;
|
||||||
|
|
||||||
@@ -15,7 +21,9 @@ function parseArgs(): { inputFile: string; outputFile?: string } {
|
|||||||
const outputFile = outIdx !== -1 ? args[outIdx + 1] : undefined;
|
const outputFile = outIdx !== -1 ? args[outIdx + 1] : undefined;
|
||||||
|
|
||||||
if (!inputFile) {
|
if (!inputFile) {
|
||||||
console.error("Usage: bun run src/index.ts <input.csv|xlsx> [--out results.csv]");
|
console.error(
|
||||||
|
"Usage: bun run src/index.ts <input.csv|xlsx> [--out results.csv]",
|
||||||
|
);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
return { inputFile, outputFile };
|
return { inputFile, outputFile };
|
||||||
@@ -27,6 +35,7 @@ async function main() {
|
|||||||
console.log("Connecting to Redis...");
|
console.log("Connecting to Redis...");
|
||||||
await connectCache();
|
await connectCache();
|
||||||
|
|
||||||
|
// Phase 1: Read input file
|
||||||
console.log(`\nReading ${inputFile}...`);
|
console.log(`\nReading ${inputFile}...`);
|
||||||
const products = readProducts(inputFile);
|
const products = readProducts(inputFile);
|
||||||
|
|
||||||
@@ -35,7 +44,7 @@ async function main() {
|
|||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Phase 1: Check cache for all ASINs
|
// Phase 2: Check cache for all ASINs
|
||||||
console.log(`\nChecking cache for ${products.length} products...`);
|
console.log(`\nChecking cache for ${products.length} products...`);
|
||||||
const cached = new Map<string, EnrichedProduct>();
|
const cached = new Map<string, EnrichedProduct>();
|
||||||
const uncachedProducts: ProductRecord[] = [];
|
const uncachedProducts: ProductRecord[] = [];
|
||||||
@@ -51,35 +60,156 @@ async function main() {
|
|||||||
}
|
}
|
||||||
console.log(`${cached.size} cached, ${uncachedProducts.length} to fetch`);
|
console.log(`${cached.size} cached, ${uncachedProducts.length} to fetch`);
|
||||||
|
|
||||||
// Phase 2: Batch fetch from Keepa (all uncached ASINs in one request if ≤100)
|
// Phase 3: Sellability gate — check all uncached ASINs before anything else
|
||||||
let keepaResults = new Map<string, KeepaData>();
|
const sellabilityMap = new Map<string, SellabilityInfo>();
|
||||||
|
const sellableProducts: ProductRecord[] = [];
|
||||||
|
const skippedProducts: ProductRecord[] = [];
|
||||||
|
|
||||||
if (uncachedProducts.length > 0) {
|
if (uncachedProducts.length > 0) {
|
||||||
console.log(`\nFetching ${uncachedProducts.length} ASINs from Keepa...`);
|
console.log(
|
||||||
|
`\nChecking sellability for ${uncachedProducts.length} ASINs...`,
|
||||||
|
);
|
||||||
|
const sellResults = await fetchSellabilityBatch(
|
||||||
|
uncachedProducts.map((p) => p.asin),
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const p of uncachedProducts) {
|
||||||
|
const info = sellResults.get(p.asin) ?? {
|
||||||
|
canSell: null,
|
||||||
|
sellabilityStatus: "unknown" as const,
|
||||||
|
sellabilityReason: "Sellability check returned no result",
|
||||||
|
};
|
||||||
|
sellabilityMap.set(p.asin, info);
|
||||||
|
|
||||||
|
// Keep: available, restricted (can request approval), unknown (proceed cautiously)
|
||||||
|
// Skip: not_available with canSell explicitly false
|
||||||
|
if (
|
||||||
|
info.sellabilityStatus === "not_available" &&
|
||||||
|
info.canSell === false
|
||||||
|
) {
|
||||||
|
skippedProducts.push(p);
|
||||||
|
console.log(
|
||||||
|
` [skip] ${p.asin} — ${info.sellabilityReason ?? "not available"}`,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
sellableProducts.push(p);
|
||||||
|
console.log(
|
||||||
|
` [sellable] ${p.asin} — status=${info.sellabilityStatus}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`\nSellability gate: ${sellableProducts.length} sellable, ${skippedProducts.length} skipped`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Phase 4: Keepa batch fetch — only for sellable (uncached) ASINs
|
||||||
|
let keepaResults = new Map<string, KeepaData>();
|
||||||
|
if (sellableProducts.length > 0) {
|
||||||
|
console.log(`\nFetching ${sellableProducts.length} ASINs from Keepa...`);
|
||||||
try {
|
try {
|
||||||
keepaResults = await fetchKeepaDataBatch(uncachedProducts.map((p) => p.asin));
|
keepaResults = await fetchKeepaDataBatch(
|
||||||
|
sellableProducts.map((p) => p.asin),
|
||||||
|
);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.warn(`Keepa batch fetch failed: ${err}`);
|
console.warn(`Keepa batch fetch failed: ${err}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Phase 3: Build enriched products
|
// Phase 5: SP-API pricing + fees — only for sellable ASINs
|
||||||
|
console.log(
|
||||||
|
`\nFetching pricing & fees for ${sellableProducts.length} ASINs...`,
|
||||||
|
);
|
||||||
|
const spApiResults = new Map<string, import("./types.ts").SpApiData>();
|
||||||
|
|
||||||
|
// Concurrency-limited pricing+fees fetches
|
||||||
|
const pricingQueue = [...sellableProducts];
|
||||||
|
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 === sellableProducts.length) {
|
||||||
|
console.log(
|
||||||
|
` [pricing] ${pricingDone}/${sellableProducts.length} fetched`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const pricingWorkers = Array.from(
|
||||||
|
{ length: Math.min(5, sellableProducts.length || 1) },
|
||||||
|
() => fetchNextPricing(),
|
||||||
|
);
|
||||||
|
await Promise.all(pricingWorkers);
|
||||||
|
|
||||||
|
// Phase 6: Build enriched products
|
||||||
console.log(`\nEnriching products...`);
|
console.log(`\nEnriching products...`);
|
||||||
const enriched: EnrichedProduct[] = [];
|
const enriched: EnrichedProduct[] = [];
|
||||||
|
const autoSkipResults: AnalysisResult[] = [];
|
||||||
|
|
||||||
for (const p of products) {
|
for (const p of products) {
|
||||||
|
// Cached products — already enriched
|
||||||
const cachedProduct = cached.get(p.asin);
|
const cachedProduct = cached.get(p.asin);
|
||||||
if (cachedProduct) {
|
if (cachedProduct) {
|
||||||
enriched.push(cachedProduct);
|
enriched.push(cachedProduct);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const keepa = keepaResults.get(p.asin) ?? null;
|
// Skipped products — not sellable, auto-SKIP
|
||||||
const spApi = await fetchSpApiData(p.asin);
|
if (skippedProducts.some((sp) => sp.asin === p.asin)) {
|
||||||
|
const sellability = sellabilityMap.get(p.asin)!;
|
||||||
if (keepa?.currentPrice && spApi.estimatedSalePrice === 0) {
|
const product: EnrichedProduct = {
|
||||||
spApi.estimatedSalePrice = keepa.currentPrice;
|
record: p,
|
||||||
|
keepa: null,
|
||||||
|
spApi: {
|
||||||
|
fbaFee: 0,
|
||||||
|
fbmFee: 0,
|
||||||
|
referralFeePercent: 15,
|
||||||
|
estimatedSalePrice: 0,
|
||||||
|
...sellability,
|
||||||
|
},
|
||||||
|
fetchedAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
autoSkipResults.push({
|
||||||
|
product,
|
||||||
|
verdict: {
|
||||||
|
asin: p.asin,
|
||||||
|
verdict: "SKIP",
|
||||||
|
confidence: 100,
|
||||||
|
reasoning:
|
||||||
|
`Not sellable: ${sellability.sellabilityReason ?? sellability.sellabilityStatus}`.slice(
|
||||||
|
0,
|
||||||
|
100,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Sellable products — full enrichment
|
||||||
|
const keepa = keepaResults.get(p.asin) ?? null;
|
||||||
|
const spApi = spApiResults.get(p.asin) ?? {
|
||||||
|
fbaFee: 5.0,
|
||||||
|
fbmFee: 1.5,
|
||||||
|
referralFeePercent: 15,
|
||||||
|
estimatedSalePrice: 0,
|
||||||
|
canSell: null,
|
||||||
|
sellabilityStatus: "unknown" as const,
|
||||||
|
sellabilityReason: "SP-API data missing",
|
||||||
|
};
|
||||||
|
|
||||||
const product: EnrichedProduct = {
|
const product: EnrichedProduct = {
|
||||||
record: p,
|
record: p,
|
||||||
keepa,
|
keepa,
|
||||||
@@ -91,14 +221,18 @@ async function main() {
|
|||||||
enriched.push(product);
|
enriched.push(product);
|
||||||
|
|
||||||
if (keepa) {
|
if (keepa) {
|
||||||
console.log(` [enriched] ${p.asin} — price: $${keepa.currentPrice ?? "N/A"}, rank: ${keepa.salesRank ?? "N/A"}`);
|
console.log(
|
||||||
|
` [enriched] ${p.asin} — price: $${keepa.currentPrice ?? "N/A"}, rank: ${keepa.salesRank ?? "N/A"}`,
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
console.log(` [no keepa] ${p.asin} — using spreadsheet data only`);
|
console.log(` [no keepa] ${p.asin} — using spreadsheet data only`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Phase 4: LLM analysis in batches
|
// Phase 7: LLM analysis in batches — only for enriched (sellable + cached) products
|
||||||
console.log(`\nAnalyzing products via LLM (batch size: ${LLM_BATCH_SIZE})...\n`);
|
console.log(
|
||||||
|
`\nAnalyzing ${enriched.length} products via LLM (batch size: ${LLM_BATCH_SIZE})...\n`,
|
||||||
|
);
|
||||||
|
|
||||||
const results: AnalysisResult[] = [];
|
const results: AnalysisResult[] = [];
|
||||||
for (let i = 0; i < enriched.length; i += LLM_BATCH_SIZE) {
|
for (let i = 0; i < enriched.length; i += LLM_BATCH_SIZE) {
|
||||||
@@ -140,10 +274,13 @@ async function main() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
printResults(results);
|
// Merge: LLM-analyzed results + auto-skipped results
|
||||||
|
const allResults = [...results, ...autoSkipResults];
|
||||||
|
|
||||||
|
printResults(allResults);
|
||||||
|
|
||||||
if (outputFile) {
|
if (outputFile) {
|
||||||
writeResultsCsv(results, outputFile);
|
writeResultsCsv(allResults, outputFile);
|
||||||
}
|
}
|
||||||
|
|
||||||
await disconnectCache();
|
await disconnectCache();
|
||||||
|
|||||||
147
src/llm.ts
147
src/llm.ts
@@ -14,11 +14,20 @@ Given product data, evaluate each product's viability for selling on Amazon. Con
|
|||||||
7. **FBA vs FBM**: FBA preferred for fast-selling, small/light items. FBM for oversized, slow-moving, or high-margin items where fee savings matter.
|
7. **FBA vs FBM**: FBA preferred for fast-selling, small/light items. FBM for oversized, slow-moving, or high-margin items where fee savings matter.
|
||||||
8. **MOQ & Capital**: High MOQ with thin margins is risky.
|
8. **MOQ & Capital**: High MOQ with thin margins is risky.
|
||||||
9. **Supply Availability**: Total quantity available from supplier — low stock means limited runway.
|
9. **Supply Availability**: Total quantity available from supplier — low stock means limited runway.
|
||||||
|
10. **Seller Eligibility (critical)**:
|
||||||
|
- If sellerEligibility.status is "restricted" or "not_available", return verdict = "SKIP".
|
||||||
|
- If sellerEligibility.status is "unknown", treat as elevated risk and only allow FBA/FBM with clearly strong economics + demand.
|
||||||
|
- If canSell is false, return "SKIP" regardless of margin.
|
||||||
|
|
||||||
|
Decision policy:
|
||||||
|
- Do not recommend products that cannot be listed by this seller account.
|
||||||
|
- Prioritize profitable + high-velocity + listable products.
|
||||||
|
- Use "SKIP" when data quality is poor or risk is high.
|
||||||
|
|
||||||
Return ONLY a raw JSON array (no markdown, no code fences, no explanation before or after). One verdict per product:
|
Return ONLY a raw JSON array (no markdown, no code fences, no explanation before or after). One verdict per product:
|
||||||
[{ "asin": "B...", "verdict": "FBA" | "FBM" | "SKIP", "confidence": 0-100, "reasoning": "..." }]
|
[{ "asin": "B...", "verdict": "FBA" | "FBM" | "SKIP", "confidence": 0-100, "reasoning": "..." }]
|
||||||
|
|
||||||
Keep each reasoning under 100 characters to stay within output limits.`;
|
Keep each reasoning under 100 characters to stay within output limits and mention key blocker if skipped (e.g., restricted, low demand, thin margin).`;
|
||||||
|
|
||||||
export async function analyzeProducts(
|
export async function analyzeProducts(
|
||||||
products: EnrichedProduct[],
|
products: EnrichedProduct[],
|
||||||
@@ -148,6 +157,11 @@ function summarizeForLlm(p: EnrichedProduct) {
|
|||||||
referralFeePercent: p.spApi.referralFeePercent,
|
referralFeePercent: p.spApi.referralFeePercent,
|
||||||
referralFee: Math.round(referralFee * 100) / 100,
|
referralFee: Math.round(referralFee * 100) / 100,
|
||||||
},
|
},
|
||||||
|
sellerEligibility: {
|
||||||
|
canSell: p.spApi.canSell,
|
||||||
|
status: p.spApi.sellabilityStatus,
|
||||||
|
reason: clampText(p.spApi.sellabilityReason, 120),
|
||||||
|
},
|
||||||
estimatedProfit: {
|
estimatedProfit: {
|
||||||
fba: Math.round(fbaProfit * 100) / 100,
|
fba: Math.round(fbaProfit * 100) / 100,
|
||||||
fbm: Math.round(fbmProfit * 100) / 100,
|
fbm: Math.round(fbmProfit * 100) / 100,
|
||||||
@@ -195,6 +209,9 @@ function cleanLlmJson(text: string): string {
|
|||||||
// Fix malformed comma-quote before a closing bracket/brace: ,"} or ,"]
|
// Fix malformed comma-quote before a closing bracket/brace: ,"} or ,"]
|
||||||
cleaned = cleaned.replace(/,\s*"\s*([}\]])/g, "$1");
|
cleaned = cleaned.replace(/,\s*"\s*([}\]])/g, "$1");
|
||||||
|
|
||||||
|
// Fix malformed quote-comma before a closing bracket/brace: ",} or ",]
|
||||||
|
cleaned = cleaned.replace(/"\s*,\s*([}\]])/g, '"$1');
|
||||||
|
|
||||||
// Fix trailing commas before ] or }
|
// Fix trailing commas before ] or }
|
||||||
cleaned = cleaned.replace(/,\s*([}\]])/g, "$1");
|
cleaned = cleaned.replace(/,\s*([}\]])/g, "$1");
|
||||||
|
|
||||||
@@ -205,21 +222,20 @@ function parseVerdicts(
|
|||||||
content: string,
|
content: string,
|
||||||
products: EnrichedProduct[],
|
products: EnrichedProduct[],
|
||||||
): LlmVerdict[] {
|
): LlmVerdict[] {
|
||||||
try {
|
|
||||||
const cleaned = cleanLlmJson(content);
|
const cleaned = cleanLlmJson(content);
|
||||||
const parsed = JSON.parse(cleaned);
|
|
||||||
const arr = Array.isArray(parsed)
|
try {
|
||||||
? parsed
|
const parsed = JSON.parse(cleaned) as unknown;
|
||||||
: (parsed.verdicts ?? parsed.results ?? [parsed]);
|
return alignVerdicts(products, normalizeVerdicts(parsed));
|
||||||
return arr.map((v: Record<string, unknown>) => ({
|
|
||||||
asin: String(v.asin ?? ""),
|
|
||||||
verdict: (["FBA", "FBM", "SKIP"].includes(String(v.verdict))
|
|
||||||
? v.verdict
|
|
||||||
: "SKIP") as LlmVerdict["verdict"],
|
|
||||||
confidence: typeof v.confidence === "number" ? v.confidence : 0,
|
|
||||||
reasoning: String(v.reasoning ?? "No reasoning provided"),
|
|
||||||
}));
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
const salvaged = extractVerdictsLoosely(cleaned);
|
||||||
|
if (salvaged.length > 0) {
|
||||||
|
console.warn(
|
||||||
|
`LLM response was invalid JSON; salvaged ${salvaged.length} verdict(s) with loose parsing.`,
|
||||||
|
);
|
||||||
|
return alignVerdicts(products, salvaged);
|
||||||
|
}
|
||||||
|
|
||||||
console.warn(
|
console.warn(
|
||||||
"Failed to parse LLM response, marking all as ANALYSIS_FAILED",
|
"Failed to parse LLM response, marking all as ANALYSIS_FAILED",
|
||||||
);
|
);
|
||||||
@@ -232,3 +248,106 @@ function parseVerdicts(
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeVerdicts(parsed: unknown): LlmVerdict[] {
|
||||||
|
const container =
|
||||||
|
parsed && typeof parsed === "object"
|
||||||
|
? (parsed as Record<string, unknown>)
|
||||||
|
: undefined;
|
||||||
|
const nested = container?.verdicts ?? container?.results;
|
||||||
|
|
||||||
|
const arr: unknown[] = Array.isArray(parsed)
|
||||||
|
? parsed
|
||||||
|
: Array.isArray(nested)
|
||||||
|
? nested
|
||||||
|
: [parsed];
|
||||||
|
|
||||||
|
return arr
|
||||||
|
.filter((v): v is Record<string, unknown> => !!v && typeof v === "object")
|
||||||
|
.map((v) => ({
|
||||||
|
asin: String(v.asin ?? "")
|
||||||
|
.trim()
|
||||||
|
.toUpperCase(),
|
||||||
|
verdict: (String(v.verdict).toUpperCase() === "FBA" ||
|
||||||
|
String(v.verdict).toUpperCase() === "FBM" ||
|
||||||
|
String(v.verdict).toUpperCase() === "SKIP"
|
||||||
|
? String(v.verdict).toUpperCase()
|
||||||
|
: "SKIP") as LlmVerdict["verdict"],
|
||||||
|
confidence: clampConfidence(
|
||||||
|
typeof v.confidence === "number"
|
||||||
|
? v.confidence
|
||||||
|
: Number(v.confidence ?? 0),
|
||||||
|
),
|
||||||
|
reasoning: String(v.reasoning ?? "No reasoning provided"),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractVerdictsLoosely(text: string): LlmVerdict[] {
|
||||||
|
const objectMatches = text.match(/\{[\s\S]*?\}/g) ?? [];
|
||||||
|
const verdicts: LlmVerdict[] = [];
|
||||||
|
|
||||||
|
for (const chunk of objectMatches) {
|
||||||
|
const asin = extractField(chunk, /"asin"\s*:\s*"?([A-Z0-9]{10})"?/i) ?? "";
|
||||||
|
const verdictRaw =
|
||||||
|
extractField(chunk, /"verdict"\s*:\s*"?([A-Z]+)"?/i) ?? "SKIP";
|
||||||
|
const confidenceRaw =
|
||||||
|
extractField(chunk, /"confidence"\s*:\s*([0-9]+(?:\.[0-9]+)?)/i) ?? "0";
|
||||||
|
const reasoning =
|
||||||
|
extractField(chunk, /"reasoning"\s*:\s*"([\s\S]*?)"\s*(?:,|})/i) ??
|
||||||
|
"No reasoning provided";
|
||||||
|
|
||||||
|
const normalizedVerdict = verdictRaw.toUpperCase();
|
||||||
|
if (!asin) continue;
|
||||||
|
|
||||||
|
verdicts.push({
|
||||||
|
asin,
|
||||||
|
verdict: (normalizedVerdict === "FBA" ||
|
||||||
|
normalizedVerdict === "FBM" ||
|
||||||
|
normalizedVerdict === "SKIP"
|
||||||
|
? normalizedVerdict
|
||||||
|
: "SKIP") as LlmVerdict["verdict"],
|
||||||
|
confidence: clampConfidence(Number(confidenceRaw)),
|
||||||
|
reasoning,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return verdicts;
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractField(text: string, regex: RegExp): string | undefined {
|
||||||
|
const match = text.match(regex);
|
||||||
|
return match?.[1]?.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function clampConfidence(value: number): number {
|
||||||
|
if (!Number.isFinite(value)) return 0;
|
||||||
|
return Math.max(0, Math.min(100, Math.round(value)));
|
||||||
|
}
|
||||||
|
|
||||||
|
function alignVerdicts(
|
||||||
|
products: EnrichedProduct[],
|
||||||
|
verdicts: LlmVerdict[],
|
||||||
|
): LlmVerdict[] {
|
||||||
|
const byAsin = new Map<string, LlmVerdict>();
|
||||||
|
for (const verdict of verdicts) {
|
||||||
|
if (verdict.asin && !byAsin.has(verdict.asin)) {
|
||||||
|
byAsin.set(verdict.asin, verdict);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return products.map((product, index) => {
|
||||||
|
const asin = product.record.asin;
|
||||||
|
const byAsinVerdict = byAsin.get(asin);
|
||||||
|
if (byAsinVerdict) return { ...byAsinVerdict, asin };
|
||||||
|
|
||||||
|
const byIndexVerdict = verdicts[index];
|
||||||
|
if (byIndexVerdict) return { ...byIndexVerdict, asin };
|
||||||
|
|
||||||
|
return {
|
||||||
|
asin,
|
||||||
|
verdict: "SKIP" as const,
|
||||||
|
confidence: 0,
|
||||||
|
reasoning: "LLM returned no verdict for this product",
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
652
src/sp-api.ts
652
src/sp-api.ts
@@ -1,18 +1,650 @@
|
|||||||
import type { SpApiData } from "./types.ts";
|
import { SellingPartner } from "amazon-sp-api";
|
||||||
|
import { config } from "./config.ts";
|
||||||
|
import type { SpApiData, SellabilityInfo } from "./types.ts";
|
||||||
|
|
||||||
// TODO: Implement real SP-API integration with LWA OAuth
|
type RegionCode = "na" | "eu" | "fe";
|
||||||
// - LWA token endpoint: https://api.amazon.com/auth/o2/token
|
|
||||||
// - Catalog Items: GET /catalog/2022-04-01/items/{asin}
|
let client: SellingPartner | null = null;
|
||||||
// - Product Pricing: GET /products/pricing/v0/price
|
let loggedMissingCreds = false;
|
||||||
// - Product Fees: GET /products/fees/v0/items/{asin}/feesEstimate
|
let loggedSandboxMode = false;
|
||||||
|
|
||||||
|
function normalizeRegion(region: string): RegionCode {
|
||||||
|
const value = region.trim().toLowerCase();
|
||||||
|
if (value === "us") return "na";
|
||||||
|
if (value === "na" || value === "eu" || value === "fe") return value;
|
||||||
|
console.warn(`Unknown SP_API_REGION \"${region}\", defaulting to \"na\".`);
|
||||||
|
return "na";
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasSpApiCredentials(): boolean {
|
||||||
|
return !!(
|
||||||
|
config.spApiClientId &&
|
||||||
|
config.spApiClientSecret &&
|
||||||
|
config.spApiRefreshToken
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSpClient(): SellingPartner | null {
|
||||||
|
if (!hasSpApiCredentials()) {
|
||||||
|
if (!loggedMissingCreds) {
|
||||||
|
console.warn(
|
||||||
|
"SP-API credentials not configured; falling back to fee defaults. Set SP_API_CLIENT_ID, SP_API_CLIENT_SECRET, and SP_API_REFRESH_TOKEN.",
|
||||||
|
);
|
||||||
|
loggedMissingCreds = true;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (client) return client;
|
||||||
|
|
||||||
|
if (config.spApiUseSandbox && !loggedSandboxMode) {
|
||||||
|
console.warn(
|
||||||
|
"SP-API sandbox mode is enabled (SP_API_USE_SANDBOX=true). Production ASIN calls may be denied.",
|
||||||
|
);
|
||||||
|
loggedSandboxMode = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
client = new SellingPartner({
|
||||||
|
region: normalizeRegion(config.spApiRegion),
|
||||||
|
refresh_token: config.spApiRefreshToken!,
|
||||||
|
credentials: {
|
||||||
|
SELLING_PARTNER_APP_CLIENT_ID: config.spApiClientId!,
|
||||||
|
SELLING_PARTNER_APP_CLIENT_SECRET: config.spApiClientSecret!,
|
||||||
|
...(config.awsAccessKeyId && config.awsSecretAccessKey
|
||||||
|
? {
|
||||||
|
AWS_ACCESS_KEY_ID: config.awsAccessKeyId,
|
||||||
|
AWS_SECRET_ACCESS_KEY: config.awsSecretAccessKey,
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
...(config.awsSessionToken
|
||||||
|
? { AWS_SESSION_TOKEN: config.awsSessionToken }
|
||||||
|
: {}),
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
auto_request_tokens: true,
|
||||||
|
auto_request_throttled: true,
|
||||||
|
use_sandbox: config.spApiUseSandbox,
|
||||||
|
debug_log: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return client;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getAmount(value: unknown): number | undefined {
|
||||||
|
if (!value || typeof value !== "object") return undefined;
|
||||||
|
const amount = (value as { Amount?: unknown }).Amount;
|
||||||
|
return typeof amount === "number" && Number.isFinite(amount)
|
||||||
|
? amount
|
||||||
|
: undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractEstimatedSalePrice(pricing: any): number {
|
||||||
|
const buyBox = pricing?.Summary?.BuyBoxPrices?.[0];
|
||||||
|
const lowest = pricing?.Summary?.LowestPrices?.[0];
|
||||||
|
const buyBoxLanded = getAmount(buyBox?.LandedPrice);
|
||||||
|
if (buyBoxLanded != null) return buyBoxLanded;
|
||||||
|
|
||||||
|
const lowestLanded = getAmount(lowest?.LandedPrice);
|
||||||
|
if (lowestLanded != null) return lowestLanded;
|
||||||
|
|
||||||
|
const firstOffer = pricing?.Offers?.[0];
|
||||||
|
const listing = getAmount(firstOffer?.ListingPrice) ?? 0;
|
||||||
|
const shipping = getAmount(firstOffer?.Shipping) ?? 0;
|
||||||
|
return listing + shipping;
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractFeeResult(feesResponse: any): {
|
||||||
|
totalFee: number;
|
||||||
|
referralFee?: number;
|
||||||
|
} {
|
||||||
|
const result = feesResponse?.payload?.FeesEstimateResult;
|
||||||
|
const total = getAmount(result?.FeesEstimate?.TotalFeesEstimate) ?? 0;
|
||||||
|
const feeDetails = result?.FeesEstimate?.FeeDetailList;
|
||||||
|
|
||||||
|
const referralDetail = Array.isArray(feeDetails)
|
||||||
|
? feeDetails.find((f: any) =>
|
||||||
|
String(f?.FeeType ?? "")
|
||||||
|
.toLowerCase()
|
||||||
|
.includes("referral"),
|
||||||
|
)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
const referralFee = getAmount(referralDetail?.FinalFee);
|
||||||
|
return { totalFee: total, referralFee };
|
||||||
|
}
|
||||||
|
|
||||||
|
function round2(value: number): number {
|
||||||
|
return Math.round(value * 100) / 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SELLABILITY_CONCURRENCY = 5;
|
||||||
|
const PRICING_CONCURRENCY = 5;
|
||||||
|
|
||||||
|
function parseSellabilityResponse(response: any): SellabilityInfo {
|
||||||
|
const restrictions = Array.isArray(response?.restrictions)
|
||||||
|
? response.restrictions
|
||||||
|
: Array.isArray(response?.payload?.restrictions)
|
||||||
|
? response.payload.restrictions
|
||||||
|
: null;
|
||||||
|
|
||||||
|
if (!Array.isArray(restrictions)) {
|
||||||
|
return {
|
||||||
|
canSell: null,
|
||||||
|
sellabilityStatus: "unknown",
|
||||||
|
sellabilityReason: "Unexpected restrictions response shape",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (restrictions.length === 0) {
|
||||||
|
return {
|
||||||
|
canSell: true,
|
||||||
|
sellabilityStatus: "available",
|
||||||
|
sellabilityReason: "No listing restrictions reported",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const reasons = restrictions.flatMap((r: any) =>
|
||||||
|
Array.isArray(r?.reasons) ? r.reasons : [],
|
||||||
|
);
|
||||||
|
const reasonCodes = reasons
|
||||||
|
.map((r: any) => String(r?.reasonCode ?? "").trim())
|
||||||
|
.filter((r: string) => r.length > 0);
|
||||||
|
const reasonMessages = reasons
|
||||||
|
.map((r: any) => String(r?.message ?? "").trim())
|
||||||
|
.filter((m: string) => m.length > 0);
|
||||||
|
|
||||||
|
const allReasonText = [...reasonCodes, ...reasonMessages]
|
||||||
|
.join(" | ")
|
||||||
|
.toLowerCase();
|
||||||
|
const status =
|
||||||
|
allReasonText.includes("not_eligible") ||
|
||||||
|
allReasonText.includes("not eligible") ||
|
||||||
|
allReasonText.includes("not available")
|
||||||
|
? "not_available"
|
||||||
|
: "restricted";
|
||||||
|
|
||||||
|
return {
|
||||||
|
canSell: false,
|
||||||
|
sellabilityStatus: status,
|
||||||
|
sellabilityReason:
|
||||||
|
[...reasonCodes, ...reasonMessages].join(" | ") ||
|
||||||
|
"Listing restrictions reported",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchSellabilityInternal(
|
||||||
|
spClient: SellingPartner,
|
||||||
|
asin: string,
|
||||||
|
): Promise<SellabilityInfo> {
|
||||||
|
if (!config.spApiSellerId) {
|
||||||
|
return {
|
||||||
|
canSell: null,
|
||||||
|
sellabilityStatus: "unknown",
|
||||||
|
sellabilityReason: "Missing SP_API_SELLER_ID",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const restrictionResponse = await spClient.callAPI({
|
||||||
|
operation: "getListingsRestrictions",
|
||||||
|
endpoint: "listingsRestrictions",
|
||||||
|
query: {
|
||||||
|
asin,
|
||||||
|
sellerId: config.spApiSellerId,
|
||||||
|
marketplaceIds: [config.spApiMarketplaceId],
|
||||||
|
conditionType: "new_new",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return parseSellabilityResponse(restrictionResponse);
|
||||||
|
} catch (err) {
|
||||||
|
return {
|
||||||
|
canSell: null,
|
||||||
|
sellabilityStatus: "unknown",
|
||||||
|
sellabilityReason: `Restrictions check failed: ${extractErrorMessage(err)}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function missingSpApiEnvVars(): string[] {
|
||||||
|
const missing: string[] = [];
|
||||||
|
if (!config.spApiClientId) missing.push("SP_API_CLIENT_ID");
|
||||||
|
if (!config.spApiClientSecret) missing.push("SP_API_CLIENT_SECRET");
|
||||||
|
if (!config.spApiRefreshToken) missing.push("SP_API_REFRESH_TOKEN");
|
||||||
|
return missing;
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractErrorMessage(err: unknown): string {
|
||||||
|
if (err instanceof Error) return err.message;
|
||||||
|
return String(err);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isAccessDeniedMessage(message: string): boolean {
|
||||||
|
const value = message.toLowerCase();
|
||||||
|
return (
|
||||||
|
value.includes("access to requested resource is denied") ||
|
||||||
|
value.includes("access denied") ||
|
||||||
|
value.includes("forbidden") ||
|
||||||
|
value.includes("unauthorized")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function deniedHint(operation: string): string {
|
||||||
|
const sandboxHint = config.spApiUseSandbox
|
||||||
|
? " Sandbox mode is enabled; production data calls are often denied in sandbox."
|
||||||
|
: "";
|
||||||
|
|
||||||
|
if (operation === "sellers") {
|
||||||
|
return (
|
||||||
|
" Check app authorization, role grants, and that refresh token belongs to the intended seller account." +
|
||||||
|
sandboxHint
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
" Check that Product Pricing role is enabled and re-authorize app to mint a new refresh token." +
|
||||||
|
sandboxHint
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function testSpApiConnectivity(
|
||||||
|
asin?: string,
|
||||||
|
): Promise<{ ok: boolean; message: string }> {
|
||||||
|
const missingVars = missingSpApiEnvVars();
|
||||||
|
if (missingVars.length > 0) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
message: `Missing required SP-API env vars: ${missingVars.join(", ")}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const spClient = getSpClient();
|
||||||
|
if (!spClient) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
message: "SP-API client could not be initialized.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
let sellersRes: { payload?: unknown[] };
|
||||||
|
try {
|
||||||
|
sellersRes = (await spClient.callAPI({
|
||||||
|
operation: "getMarketplaceParticipations",
|
||||||
|
endpoint: "sellers",
|
||||||
|
})) as { payload?: unknown[] };
|
||||||
|
} catch (err) {
|
||||||
|
const message = extractErrorMessage(err);
|
||||||
|
const hint = isAccessDeniedMessage(message) ? deniedHint("sellers") : "";
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
message: `Auth probe failed (sellers.getMarketplaceParticipations): ${message}.${hint}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const participationCount = Array.isArray(sellersRes?.payload)
|
||||||
|
? sellersRes.payload.length
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
if (!asin) {
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
message: `SP-API auth OK. Seller participations returned: ${participationCount}.`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
let pricingRes: { status?: string; ASIN?: string };
|
||||||
|
try {
|
||||||
|
pricingRes = (await spClient.callAPI({
|
||||||
|
operation: "getItemOffers",
|
||||||
|
endpoint: "productPricing",
|
||||||
|
path: { Asin: asin },
|
||||||
|
query: {
|
||||||
|
MarketplaceId: config.spApiMarketplaceId,
|
||||||
|
ItemCondition: "New",
|
||||||
|
},
|
||||||
|
})) as { status?: string; ASIN?: string };
|
||||||
|
} catch (err) {
|
||||||
|
const message = extractErrorMessage(err);
|
||||||
|
const hint = isAccessDeniedMessage(message) ? deniedHint("pricing") : "";
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
message: `Pricing probe failed (productPricing.getItemOffers): ${message}.${hint}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
message:
|
||||||
|
`SP-API auth OK. Seller participations: ${participationCount}. ` +
|
||||||
|
`Pricing check for ${asin} returned status=${pricingRes?.status ?? "unknown"} asin=${pricingRes?.ASIN ?? "unknown"}.`,
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
message: `SP-API connectivity failed unexpectedly: ${extractErrorMessage(err)}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function testSpApiSellability(
|
||||||
|
asin: string,
|
||||||
|
): Promise<{ ok: boolean; message: string }> {
|
||||||
|
const missingVars = missingSpApiEnvVars();
|
||||||
|
if (missingVars.length > 0) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
message: `Missing required SP-API env vars: ${missingVars.join(", ")}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!config.spApiSellerId) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
message: "Missing required env var: SP_API_SELLER_ID",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const spClient = getSpClient();
|
||||||
|
if (!spClient) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
message: "SP-API client could not be initialized.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const sellability = await fetchSellabilityInternal(spClient, asin);
|
||||||
|
if (sellability.sellabilityStatus === "unknown") {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
message:
|
||||||
|
`Sellability probe failed for ${asin}: ${sellability.sellabilityReason ?? "unknown reason"}. ` +
|
||||||
|
"This usually means sellerId is missing/incorrect or the app lacks Listings Restrictions permission.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const canSell =
|
||||||
|
sellability.canSell == null
|
||||||
|
? "unknown"
|
||||||
|
: sellability.canSell
|
||||||
|
? "yes"
|
||||||
|
: "no";
|
||||||
|
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
message:
|
||||||
|
`Sellability probe OK for ${asin}: status=${sellability.sellabilityStatus}, canSell=${canSell}. ` +
|
||||||
|
`${sellability.sellabilityReason ?? ""}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export async function fetchSpApiData(asin: string): Promise<SpApiData> {
|
export async function fetchSpApiData(asin: string): Promise<SpApiData> {
|
||||||
// Stub: returns realistic mock fee estimates
|
const fallback: SpApiData = {
|
||||||
// Average FBA referral fee is ~15%, FBA fulfillment fee ~$3-5 for standard size
|
|
||||||
return {
|
|
||||||
fbaFee: 5.0,
|
fbaFee: 5.0,
|
||||||
fbmFee: 1.5,
|
fbmFee: 1.5,
|
||||||
referralFeePercent: 15,
|
referralFeePercent: 15,
|
||||||
estimatedSalePrice: 0, // Will be overridden by Keepa current price if available
|
estimatedSalePrice: 0,
|
||||||
|
canSell: null,
|
||||||
|
sellabilityStatus: "unknown",
|
||||||
|
sellabilityReason: "SP-API fallback values in use",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const spClient = getSpClient();
|
||||||
|
if (!spClient) {
|
||||||
|
console.log(` [sp-api:fallback] ${asin} reason=missing_credentials`);
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const pricing = (await spClient.callAPI({
|
||||||
|
operation: "getItemOffers",
|
||||||
|
endpoint: "productPricing",
|
||||||
|
path: { Asin: asin },
|
||||||
|
query: {
|
||||||
|
MarketplaceId: config.spApiMarketplaceId,
|
||||||
|
ItemCondition: "New",
|
||||||
|
},
|
||||||
|
})) as any;
|
||||||
|
const sellability = await fetchSellabilityInternal(spClient, asin);
|
||||||
|
|
||||||
|
const estimatedSalePrice = extractEstimatedSalePrice(pricing);
|
||||||
|
if (!Number.isFinite(estimatedSalePrice) || estimatedSalePrice <= 0) {
|
||||||
|
console.log(
|
||||||
|
` [sp-api:fallback] ${asin} reason=no_valid_price_from_offers`,
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
...fallback,
|
||||||
|
...sellability,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const [fbaFeesRes, fbmFeesRes] = await Promise.all([
|
||||||
|
spClient.callAPI({
|
||||||
|
operation: "getMyFeesEstimateForASIN",
|
||||||
|
endpoint: "productFees",
|
||||||
|
path: { Asin: asin },
|
||||||
|
body: {
|
||||||
|
FeesEstimateRequest: {
|
||||||
|
MarketplaceId: config.spApiMarketplaceId,
|
||||||
|
IsAmazonFulfilled: true,
|
||||||
|
Identifier: `${asin}-fba`,
|
||||||
|
PriceToEstimateFees: {
|
||||||
|
ListingPrice: {
|
||||||
|
CurrencyCode: "USD",
|
||||||
|
Amount: estimatedSalePrice,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
spClient.callAPI({
|
||||||
|
operation: "getMyFeesEstimateForASIN",
|
||||||
|
endpoint: "productFees",
|
||||||
|
path: { Asin: asin },
|
||||||
|
body: {
|
||||||
|
FeesEstimateRequest: {
|
||||||
|
MarketplaceId: config.spApiMarketplaceId,
|
||||||
|
IsAmazonFulfilled: false,
|
||||||
|
Identifier: `${asin}-fbm`,
|
||||||
|
PriceToEstimateFees: {
|
||||||
|
ListingPrice: {
|
||||||
|
CurrencyCode: "USD",
|
||||||
|
Amount: estimatedSalePrice,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const fba = extractFeeResult(fbaFeesRes);
|
||||||
|
const fbm = extractFeeResult(fbmFeesRes);
|
||||||
|
const referralFee =
|
||||||
|
fba.referralFee ?? fbm.referralFee ?? (estimatedSalePrice * 15) / 100;
|
||||||
|
const referralFeePercent = round2((referralFee / estimatedSalePrice) * 100);
|
||||||
|
|
||||||
|
const result: SpApiData = {
|
||||||
|
fbaFee: round2(fba.totalFee || fallback.fbaFee),
|
||||||
|
fbmFee: round2(fbm.totalFee || fallback.fbmFee),
|
||||||
|
referralFeePercent:
|
||||||
|
Number.isFinite(referralFeePercent) && referralFeePercent > 0
|
||||||
|
? referralFeePercent
|
||||||
|
: fallback.referralFeePercent,
|
||||||
|
estimatedSalePrice: round2(estimatedSalePrice),
|
||||||
|
...sellability,
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
` [sp-api:live] ${asin} price=$${result.estimatedSalePrice} fbaFee=$${result.fbaFee} fbmFee=$${result.fbmFee} referralPct=${result.referralFeePercent} sellability=${result.sellabilityStatus}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
return result;
|
||||||
|
} catch (err) {
|
||||||
|
console.warn(`SP-API fetch failed for ${asin}: ${String(err)}`);
|
||||||
|
console.log(` [sp-api:fallback] ${asin} reason=request_failed`);
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Public sellability + pricing/fees functions for the new pipeline
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export async function fetchSellability(asin: string): Promise<SellabilityInfo> {
|
||||||
|
const spClient = getSpClient();
|
||||||
|
if (!spClient) {
|
||||||
|
return {
|
||||||
|
canSell: null,
|
||||||
|
sellabilityStatus: "unknown",
|
||||||
|
sellabilityReason: "SP-API credentials not configured",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return fetchSellabilityInternal(spClient, asin);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchSellabilityBatch(
|
||||||
|
asins: string[],
|
||||||
|
): Promise<Map<string, SellabilityInfo>> {
|
||||||
|
const results = new Map<string, SellabilityInfo>();
|
||||||
|
const spClient = getSpClient();
|
||||||
|
|
||||||
|
if (!spClient) {
|
||||||
|
for (const asin of asins) {
|
||||||
|
results.set(asin, {
|
||||||
|
canSell: null,
|
||||||
|
sellabilityStatus: "unknown",
|
||||||
|
sellabilityReason: "SP-API credentials not configured",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
let completed = 0;
|
||||||
|
let running = 0;
|
||||||
|
const queue = [...asins];
|
||||||
|
|
||||||
|
async function next(): Promise<void> {
|
||||||
|
while (queue.length > 0) {
|
||||||
|
const asin = queue.shift()!;
|
||||||
|
const info = await fetchSellabilityInternal(spClient!, asin);
|
||||||
|
results.set(asin, info);
|
||||||
|
completed++;
|
||||||
|
if (completed % 10 === 0 || completed === asins.length) {
|
||||||
|
console.log(` [sellability] ${completed}/${asins.length} checked`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const workers = Array.from(
|
||||||
|
{ length: Math.min(SELLABILITY_CONCURRENCY, asins.length) },
|
||||||
|
() => next(),
|
||||||
|
);
|
||||||
|
await Promise.all(workers);
|
||||||
|
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchSpApiPricingAndFees(
|
||||||
|
asin: string,
|
||||||
|
sellability: SellabilityInfo,
|
||||||
|
): Promise<SpApiData> {
|
||||||
|
const fallback: SpApiData = {
|
||||||
|
fbaFee: 5.0,
|
||||||
|
fbmFee: 1.5,
|
||||||
|
referralFeePercent: 15,
|
||||||
|
estimatedSalePrice: 0,
|
||||||
|
...sellability,
|
||||||
|
};
|
||||||
|
|
||||||
|
const spClient = getSpClient();
|
||||||
|
if (!spClient) {
|
||||||
|
console.log(` [sp-api:fallback] ${asin} reason=missing_credentials`);
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const pricing = (await spClient.callAPI({
|
||||||
|
operation: "getItemOffers",
|
||||||
|
endpoint: "productPricing",
|
||||||
|
path: { Asin: asin },
|
||||||
|
query: {
|
||||||
|
MarketplaceId: config.spApiMarketplaceId,
|
||||||
|
ItemCondition: "New",
|
||||||
|
},
|
||||||
|
})) as any;
|
||||||
|
|
||||||
|
const estimatedSalePrice = extractEstimatedSalePrice(pricing);
|
||||||
|
if (!Number.isFinite(estimatedSalePrice) || estimatedSalePrice <= 0) {
|
||||||
|
console.log(
|
||||||
|
` [sp-api:fallback] ${asin} reason=no_valid_price_from_offers`,
|
||||||
|
);
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [fbaFeesRes, fbmFeesRes] = await Promise.all([
|
||||||
|
spClient.callAPI({
|
||||||
|
operation: "getMyFeesEstimateForASIN",
|
||||||
|
endpoint: "productFees",
|
||||||
|
path: { Asin: asin },
|
||||||
|
body: {
|
||||||
|
FeesEstimateRequest: {
|
||||||
|
MarketplaceId: config.spApiMarketplaceId,
|
||||||
|
IsAmazonFulfilled: true,
|
||||||
|
Identifier: `${asin}-fba`,
|
||||||
|
PriceToEstimateFees: {
|
||||||
|
ListingPrice: {
|
||||||
|
CurrencyCode: "USD",
|
||||||
|
Amount: estimatedSalePrice,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
spClient.callAPI({
|
||||||
|
operation: "getMyFeesEstimateForASIN",
|
||||||
|
endpoint: "productFees",
|
||||||
|
path: { Asin: asin },
|
||||||
|
body: {
|
||||||
|
FeesEstimateRequest: {
|
||||||
|
MarketplaceId: config.spApiMarketplaceId,
|
||||||
|
IsAmazonFulfilled: false,
|
||||||
|
Identifier: `${asin}-fbm`,
|
||||||
|
PriceToEstimateFees: {
|
||||||
|
ListingPrice: {
|
||||||
|
CurrencyCode: "USD",
|
||||||
|
Amount: estimatedSalePrice,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const fba = extractFeeResult(fbaFeesRes);
|
||||||
|
const fbm = extractFeeResult(fbmFeesRes);
|
||||||
|
const referralFee =
|
||||||
|
fba.referralFee ?? fbm.referralFee ?? (estimatedSalePrice * 15) / 100;
|
||||||
|
const referralFeePercent = round2((referralFee / estimatedSalePrice) * 100);
|
||||||
|
|
||||||
|
const result: SpApiData = {
|
||||||
|
fbaFee: round2(fba.totalFee || fallback.fbaFee),
|
||||||
|
fbmFee: round2(fbm.totalFee || fallback.fbmFee),
|
||||||
|
referralFeePercent:
|
||||||
|
Number.isFinite(referralFeePercent) && referralFeePercent > 0
|
||||||
|
? referralFeePercent
|
||||||
|
: fallback.referralFeePercent,
|
||||||
|
estimatedSalePrice: round2(estimatedSalePrice),
|
||||||
|
...sellability,
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
` [sp-api:live] ${asin} price=$${result.estimatedSalePrice} fbaFee=$${result.fbaFee} fbmFee=$${result.fbmFee} referralPct=${result.referralFeePercent}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
return result;
|
||||||
|
} catch (err) {
|
||||||
|
console.warn(`SP-API pricing/fees failed for ${asin}: ${String(err)}`);
|
||||||
|
console.log(` [sp-api:fallback] ${asin} reason=request_failed`);
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
48
src/sp-test.ts
Normal file
48
src/sp-test.ts
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
import { testSpApiConnectivity, testSpApiSellability } from "./sp-api.ts";
|
||||||
|
|
||||||
|
function parseArgs(): { asin?: string; sellabilityMode: boolean } {
|
||||||
|
const args = process.argv.slice(2);
|
||||||
|
const sellabilityMode = args.includes("--sellability");
|
||||||
|
const asin = args.find((arg) => !arg.startsWith("--"));
|
||||||
|
return { asin, sellabilityMode };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const { asin, sellabilityMode } = parseArgs();
|
||||||
|
|
||||||
|
console.log("Running SP-API connectivity test...");
|
||||||
|
|
||||||
|
if (sellabilityMode) {
|
||||||
|
if (!asin) {
|
||||||
|
console.error("Usage: bun run src/sp-test.ts --sellability <ASIN>");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Running sellability check for ASIN: ${asin}`);
|
||||||
|
const sellability = await testSpApiSellability(asin);
|
||||||
|
if (!sellability.ok) {
|
||||||
|
console.error(`SP-API sellability test failed: ${sellability.message}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`SP-API sellability test passed: ${sellability.message}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (asin) {
|
||||||
|
console.log(`Including pricing connectivity check for ASIN: ${asin}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await testSpApiConnectivity(asin);
|
||||||
|
if (!result.ok) {
|
||||||
|
console.error(`SP-API test failed: ${result.message}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`SP-API test passed: ${result.message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((err) => {
|
||||||
|
console.error(`SP-API test crashed: ${String(err)}`);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
@@ -42,7 +42,13 @@ export interface KeepaData {
|
|||||||
categoryTree: string[];
|
categoryTree: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SpApiData {
|
export type SellabilityInfo = {
|
||||||
|
canSell: boolean | null;
|
||||||
|
sellabilityStatus: "available" | "restricted" | "not_available" | "unknown";
|
||||||
|
sellabilityReason?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface SpApiData extends SellabilityInfo {
|
||||||
fbaFee: number;
|
fbaFee: number;
|
||||||
fbmFee: number;
|
fbmFee: number;
|
||||||
referralFeePercent: number;
|
referralFeePercent: number;
|
||||||
|
|||||||
@@ -44,6 +44,14 @@ function buildRow(r: AnalysisResult) {
|
|||||||
"FBA Fee": r.product.spApi.fbaFee,
|
"FBA Fee": r.product.spApi.fbaFee,
|
||||||
"FBM Fee": r.product.spApi.fbmFee,
|
"FBM Fee": r.product.spApi.fbmFee,
|
||||||
"Referral %": r.product.spApi.referralFeePercent,
|
"Referral %": r.product.spApi.referralFeePercent,
|
||||||
|
"Can Sell":
|
||||||
|
r.product.spApi.canSell == null
|
||||||
|
? "unknown"
|
||||||
|
: r.product.spApi.canSell
|
||||||
|
? "yes"
|
||||||
|
: "no",
|
||||||
|
Sellability: r.product.spApi.sellabilityStatus,
|
||||||
|
"Sellability Reason": r.product.spApi.sellabilityReason ?? "",
|
||||||
Verdict: r.verdict.verdict,
|
Verdict: r.verdict.verdict,
|
||||||
Confidence: r.verdict.confidence,
|
Confidence: r.verdict.confidence,
|
||||||
Reasoning: r.verdict.reasoning,
|
Reasoning: r.verdict.reasoning,
|
||||||
@@ -90,6 +98,16 @@ export function printResults(results: AnalysisResult[]): void {
|
|||||||
"Net Profit": netProfit,
|
"Net Profit": netProfit,
|
||||||
"Monthly Sold": r.product.keepa?.monthlySold ?? "",
|
"Monthly Sold": r.product.keepa?.monthlySold ?? "",
|
||||||
"Sold 90 Day": r.product.keepa?.salesRankDrops90 ?? "",
|
"Sold 90 Day": r.product.keepa?.salesRankDrops90 ?? "",
|
||||||
|
"Can Sell":
|
||||||
|
r.product.spApi.canSell == null
|
||||||
|
? "unknown"
|
||||||
|
: r.product.spApi.canSell
|
||||||
|
? "yes"
|
||||||
|
: "no",
|
||||||
|
Sellability: r.product.spApi.sellabilityStatus,
|
||||||
|
"Sellability Reason": String(
|
||||||
|
r.product.spApi.sellabilityReason ?? "",
|
||||||
|
).slice(0, 60),
|
||||||
Confidence: r.verdict.confidence,
|
Confidence: r.verdict.confidence,
|
||||||
Reasoning: r.verdict.reasoning.slice(0, 60),
|
Reasoning: r.verdict.reasoning.slice(0, 60),
|
||||||
};
|
};
|
||||||
@@ -106,10 +124,25 @@ export function printResults(results: AnalysisResult[]): void {
|
|||||||
FBA: results.filter((r) => r.verdict.verdict === "FBA").length,
|
FBA: results.filter((r) => r.verdict.verdict === "FBA").length,
|
||||||
FBM: results.filter((r) => r.verdict.verdict === "FBM").length,
|
FBM: results.filter((r) => r.verdict.verdict === "FBM").length,
|
||||||
SKIP: results.filter((r) => r.verdict.verdict === "SKIP").length,
|
SKIP: results.filter((r) => r.verdict.verdict === "SKIP").length,
|
||||||
|
Available: results.filter(
|
||||||
|
(r) => r.product.spApi.sellabilityStatus === "available",
|
||||||
|
).length,
|
||||||
|
Restricted: results.filter(
|
||||||
|
(r) => r.product.spApi.sellabilityStatus === "restricted",
|
||||||
|
).length,
|
||||||
|
NotAvailable: results.filter(
|
||||||
|
(r) => r.product.spApi.sellabilityStatus === "not_available",
|
||||||
|
).length,
|
||||||
|
Unknown: results.filter(
|
||||||
|
(r) => r.product.spApi.sellabilityStatus === "unknown",
|
||||||
|
).length,
|
||||||
};
|
};
|
||||||
console.log(
|
console.log(
|
||||||
`\nSummary: ${summary.FBA} FBA | ${summary.FBM} FBM | ${summary.SKIP} SKIP out of ${results.length} products\n`,
|
`\nSummary: ${summary.FBA} FBA | ${summary.FBM} FBM | ${summary.SKIP} SKIP out of ${results.length} products\n`,
|
||||||
);
|
);
|
||||||
|
console.log(
|
||||||
|
`Sellability: ${summary.Available} available | ${summary.Restricted} restricted | ${summary.NotAvailable} not_available | ${summary.Unknown} unknown\n`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function writeResultsCsv(
|
export function writeResultsCsv(
|
||||||
|
|||||||
Reference in New Issue
Block a user