From 53901e4dde5dd8e1d5af6d4c11deb0ecdc5c0c92 Mon Sep 17 00:00:00 2001 From: Victor Noguera Date: Wed, 8 Apr 2026 21:33:43 -0400 Subject: [PATCH] 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. --- .env.example | 10 + README.md | 38 ++- bun.lock | 57 +++++ package.json | 1 + src/config.ts | 17 ++ src/index.ts | 175 +++++++++++-- src/llm.ts | 147 +++++++++-- src/sp-api.ts | 652 ++++++++++++++++++++++++++++++++++++++++++++++++- src/sp-test.ts | 48 ++++ src/types.ts | 8 +- src/writer.ts | 33 +++ 11 files changed, 1133 insertions(+), 53 deletions(-) create mode 100644 src/sp-test.ts diff --git a/.env.example b/.env.example index c8470c4..e6a3e8c 100644 --- a/.env.example +++ b/.env.example @@ -1,4 +1,14 @@ 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 LLM_URL=http://localhost:1234/v1 LLM_MODEL=default diff --git a/README.md b/README.md index e089902..dd3d864 100644 --- a/README.md +++ b/README.md @@ -8,13 +8,14 @@ Amazon product analysis and lead finder agent. Reads product leads from a CSV/XL - Redis (local or Docker) - [LM Studio](https://lmstudio.ai) running locally with a model loaded - Keepa API key ([keepa.com](https://keepa.com)) +- Amazon SP-API private app credentials (LWA + refresh token + IAM) ## Setup ```bash bun install 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 @@ -30,6 +31,14 @@ bun run src/index.ts leads.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 Accepts `.csv` or `.xlsx` files. Column names are matched case-insensitively. Required column: @@ -89,16 +98,27 @@ ASIN, Name, Brand, Category, Unit Cost, Current Price, Avg Price 90d, Sales Rank ## Environment variables -| Variable | Default | Description | -| --------------- | -------------------------- | ------------------------------- | -| `KEEPA_API_KEY` | — | **Required.** Keepa API key | -| `REDIS_URL` | `redis://localhost:6379` | Redis connection URL | -| `LLM_URL` | `http://localhost:1234/v1` | LM Studio API base URL | -| `LLM_MODEL` | `default` | Model name to pass to LM Studio | -| `CACHE_TTL` | `86400` | Redis cache TTL in seconds | +| Variable | Default | Description | +| ----------------------- | -------------------------- | ----------------------------------------------------------------------- | +| `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 | +| `LLM_URL` | `http://localhost:1234/v1` | LM Studio API base URL | +| `LLM_MODEL` | `default` | Model name to pass to LM Studio | +| `CACHE_TTL` | `86400` | Redis cache TTL in seconds | ## Notes - **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. -- **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. diff --git a/bun.lock b/bun.lock index 466e3dd..537ab31 100644 --- a/bun.lock +++ b/bun.lock @@ -5,6 +5,7 @@ "": { "name": "asin-check", "dependencies": { + "amazon-sp-api": "^1.2.1", "ioredis": "^5.10.1", "xlsx": "^0.18.5", }, @@ -25,8 +26,14 @@ "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=="], + "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=="], "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=="], + "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=="], "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=="], + "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=="], + "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.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=="], + "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-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=="], "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=="], "undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="], diff --git a/package.json b/package.json index 1889ba3..f6fcded 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,7 @@ "typescript": "^5" }, "dependencies": { + "amazon-sp-api": "^1.2.1", "ioredis": "^5.10.1", "xlsx": "^0.18.5" } diff --git a/src/config.ts b/src/config.ts index 3b229d9..c658af0 100644 --- a/src/config.ts +++ b/src/config.ts @@ -8,10 +8,27 @@ function optional(key: string, fallback: string): string { 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 = { keepaApiKey: required("KEEPA_API_KEY"), redisUrl: optional("REDIS_URL", "redis://localhost:6379"), llmUrl: optional("LLM_URL", "http://localhost:1234/v1"), llmModel: optional("LLM_MODEL", "default"), 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; diff --git a/src/index.ts b/src/index.ts index 9d34f07..5f7d25b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,10 +1,16 @@ import { readProducts } from "./reader.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 { analyzeProducts } from "./llm.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; @@ -15,7 +21,9 @@ function parseArgs(): { inputFile: string; outputFile?: string } { const outputFile = outIdx !== -1 ? args[outIdx + 1] : undefined; if (!inputFile) { - console.error("Usage: bun run src/index.ts [--out results.csv]"); + console.error( + "Usage: bun run src/index.ts [--out results.csv]", + ); process.exit(1); } return { inputFile, outputFile }; @@ -27,6 +35,7 @@ async function main() { console.log("Connecting to Redis..."); await connectCache(); + // Phase 1: Read input file console.log(`\nReading ${inputFile}...`); const products = readProducts(inputFile); @@ -35,7 +44,7 @@ async function main() { 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...`); const cached = new Map(); const uncachedProducts: ProductRecord[] = []; @@ -51,35 +60,156 @@ async function main() { } console.log(`${cached.size} cached, ${uncachedProducts.length} to fetch`); - // Phase 2: Batch fetch from Keepa (all uncached ASINs in one request if ≤100) - let keepaResults = new Map(); + // Phase 3: Sellability gate — check all uncached ASINs before anything else + const sellabilityMap = new Map(); + const sellableProducts: ProductRecord[] = []; + const skippedProducts: ProductRecord[] = []; + 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(); + if (sellableProducts.length > 0) { + console.log(`\nFetching ${sellableProducts.length} ASINs from Keepa...`); try { - keepaResults = await fetchKeepaDataBatch(uncachedProducts.map((p) => p.asin)); + keepaResults = await fetchKeepaDataBatch( + sellableProducts.map((p) => p.asin), + ); } catch (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(); + + // Concurrency-limited pricing+fees fetches + const pricingQueue = [...sellableProducts]; + let pricingDone = 0; + + async function fetchNextPricing(): Promise { + 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...`); const enriched: EnrichedProduct[] = []; + const autoSkipResults: AnalysisResult[] = []; for (const p of products) { + // Cached products — already enriched const cachedProduct = cached.get(p.asin); if (cachedProduct) { enriched.push(cachedProduct); continue; } - const keepa = keepaResults.get(p.asin) ?? null; - const spApi = await fetchSpApiData(p.asin); - - if (keepa?.currentPrice && spApi.estimatedSalePrice === 0) { - spApi.estimatedSalePrice = keepa.currentPrice; + // Skipped products — not sellable, auto-SKIP + if (skippedProducts.some((sp) => sp.asin === p.asin)) { + const sellability = sellabilityMap.get(p.asin)!; + const product: EnrichedProduct = { + 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 = { record: p, keepa, @@ -91,14 +221,18 @@ async function main() { enriched.push(product); 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 { console.log(` [no keepa] ${p.asin} — using spreadsheet data only`); } } - // Phase 4: LLM analysis in batches - console.log(`\nAnalyzing products via LLM (batch size: ${LLM_BATCH_SIZE})...\n`); + // Phase 7: LLM analysis in batches — only for enriched (sellable + cached) products + console.log( + `\nAnalyzing ${enriched.length} products via LLM (batch size: ${LLM_BATCH_SIZE})...\n`, + ); const results: AnalysisResult[] = []; for (let i = 0; i < enriched.length; i += LLM_BATCH_SIZE) { @@ -140,10 +274,13 @@ async function main() { } } - printResults(results); + // Merge: LLM-analyzed results + auto-skipped results + const allResults = [...results, ...autoSkipResults]; + + printResults(allResults); if (outputFile) { - writeResultsCsv(results, outputFile); + writeResultsCsv(allResults, outputFile); } await disconnectCache(); diff --git a/src/llm.ts b/src/llm.ts index 9c67720..a3dfd45 100644 --- a/src/llm.ts +++ b/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. 8. **MOQ & Capital**: High MOQ with thin margins is risky. 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: [{ "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( products: EnrichedProduct[], @@ -148,6 +157,11 @@ function summarizeForLlm(p: EnrichedProduct) { referralFeePercent: p.spApi.referralFeePercent, referralFee: Math.round(referralFee * 100) / 100, }, + sellerEligibility: { + canSell: p.spApi.canSell, + status: p.spApi.sellabilityStatus, + reason: clampText(p.spApi.sellabilityReason, 120), + }, estimatedProfit: { fba: Math.round(fbaProfit * 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 ,"] 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 } cleaned = cleaned.replace(/,\s*([}\]])/g, "$1"); @@ -205,21 +222,20 @@ function parseVerdicts( content: string, products: EnrichedProduct[], ): LlmVerdict[] { + const cleaned = cleanLlmJson(content); + try { - const cleaned = cleanLlmJson(content); - const parsed = JSON.parse(cleaned); - const arr = Array.isArray(parsed) - ? parsed - : (parsed.verdicts ?? parsed.results ?? [parsed]); - return arr.map((v: Record) => ({ - 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"), - })); + const parsed = JSON.parse(cleaned) as unknown; + return alignVerdicts(products, normalizeVerdicts(parsed)); } 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( "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) + : 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 => !!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(); + 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", + }; + }); +} diff --git a/src/sp-api.ts b/src/sp-api.ts index d9438f5..7c9d9ed 100644 --- a/src/sp-api.ts +++ b/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 -// - LWA token endpoint: https://api.amazon.com/auth/o2/token -// - Catalog Items: GET /catalog/2022-04-01/items/{asin} -// - Product Pricing: GET /products/pricing/v0/price -// - Product Fees: GET /products/fees/v0/items/{asin}/feesEstimate +type RegionCode = "na" | "eu" | "fe"; + +let client: SellingPartner | null = null; +let loggedMissingCreds = false; +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 { + 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 { - // Stub: returns realistic mock fee estimates - // Average FBA referral fee is ~15%, FBA fulfillment fee ~$3-5 for standard size - return { + const fallback: SpApiData = { fbaFee: 5.0, fbmFee: 1.5, 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 { + 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> { + const results = new Map(); + 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 { + 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 { + 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; + } } diff --git a/src/sp-test.ts b/src/sp-test.ts new file mode 100644 index 0000000..2d94e37 --- /dev/null +++ b/src/sp-test.ts @@ -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 "); + 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); +}); diff --git a/src/types.ts b/src/types.ts index 95afd4f..b142a1b 100644 --- a/src/types.ts +++ b/src/types.ts @@ -42,7 +42,13 @@ export interface KeepaData { 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; fbmFee: number; referralFeePercent: number; diff --git a/src/writer.ts b/src/writer.ts index 977b823..2172af9 100644 --- a/src/writer.ts +++ b/src/writer.ts @@ -44,6 +44,14 @@ function buildRow(r: AnalysisResult) { "FBA Fee": r.product.spApi.fbaFee, "FBM Fee": r.product.spApi.fbmFee, "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, Confidence: r.verdict.confidence, Reasoning: r.verdict.reasoning, @@ -90,6 +98,16 @@ export function printResults(results: AnalysisResult[]): void { "Net Profit": netProfit, "Monthly Sold": r.product.keepa?.monthlySold ?? "", "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, 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, FBM: results.filter((r) => r.verdict.verdict === "FBM").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( `\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(