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:
Victor Noguera
2026-04-08 21:33:43 -04:00
parent 2e626ce1f3
commit 53901e4dde
11 changed files with 1133 additions and 53 deletions

View File

@@ -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

View File

@@ -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.

View File

@@ -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=="],

View File

@@ -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"
} }

View File

@@ -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;

View File

@@ -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();

View File

@@ -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",
};
});
}

View File

@@ -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
View 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);
});

View File

@@ -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;

View File

@@ -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(