Compare commits

...

40 Commits

Author SHA1 Message Date
Victor Noguera
a355359427 feat: implement filter presets and view state persistence across dashboard, run details, product list, and stalker explorer
- Added functionality to save, update, and apply filter presets for various views.
- Introduced local storage management for persisting view states across sessions.
- Enhanced dashboard, run details, product list, and stalker explorer components to utilize saved filter presets.
- Updated UI to include controls for managing filter presets.
2026-05-25 16:59:06 -04:00
Victor Noguera
31cf992e77 refactor: rename findLatestStalkerRunItemIdByAsin to findLatestRunItemIdByAsin and update references 2026-05-25 16:02:07 -04:00
Victor Noguera
506e2344b7 feat: implement reanalyze and distributor discovery endpoints for Stalker products by ASIN 2026-05-25 15:57:24 -04:00
Victor Noguera
313677692b feat: add distributor research functionality with detailed candidate information and outreach options 2026-05-25 15:30:41 -04:00
Victor Noguera
9b45546476 feat: enhance distributor candidate research with additional fields and improved prompt for API request 2026-05-25 15:01:22 -04:00
Victor Noguera
35087a5b2f feat: add product distributor research table and integrate distributor analysis in Stalker product workflow 2026-05-25 14:51:57 -04:00
Victor Noguera
5dbff33032 fix: correct ASIN query syntax and update script path in sellable analysis 2026-05-25 13:55:04 -04:00
Victor Noguera
517833413e feat: enhance Keepa API integration with additional query parameters and improve test coverage 2026-05-25 13:27:26 -04:00
Victor Noguera
b8280ef1a0 Merge branch 'postgres' 2026-05-25 12:49:25 -04:00
Victor Noguera
685cb3b2ed fix: set initial loading state to true and adjust effect dependencies in RunDetails component 2026-05-25 12:49:14 -04:00
Victor Noguera
55e3aef1e4 feat: update usage instructions and improve input/output handling in CLI 2026-05-25 12:42:20 -04:00
Victor Noguera
f512f1d3d5 Add initial journal file for PostgreSQL dialect version 7
- Created a new JSON file `_journal.json` to track changes and entries.
- Included metadata such as version, dialect, and a sample entry with breakpoints enabled.
2026-05-25 12:33:14 -04:00
Victor Noguera
923ebbaec5 Refactor supplier analysis and product handling
- Updated `SupplierAnalysisResult` to include a `product` field and modified related tests.
- Refactored `addRowsSheet` to accommodate changes in the product structure.
- Enhanced UPC file analysis to utilize a new `toSupplierInputRecord` function for cleaner record creation.
- Introduced new types for supplier input records and product observations.
- Updated frontend components to handle new product details and analysis history.
- Improved database writing functions to streamline run completion and error handling.
- Added new API endpoints for product details and adjusted routing in the frontend.
2026-05-25 12:27:41 -04:00
Victor Noguera
c006d87c54 feat: add supplier scoring and UPC file analysis functionality
- Implemented supplier scoring logic in `supplier-scoring.ts` with functions to compute demand score, competition penalty, and overall supplier product score.
- Created unit tests for supplier scoring in `supplier-scoring.test.ts` to validate scoring logic against various scenarios.
- Developed UPC file analysis tool in `upc-file-analysis.ts` to process UPCs in batches, fetch product data from Keepa and SP-API, and generate supplier results.
- Added UPC input reading functionality in `upc-file-reader.ts` to handle XLSX and XLS files, including validation for UPC formats.
- Introduced a command-line tool in `upc-lookup.ts` for looking up UPCs and displaying detailed results or mappings to ASINs.
- Enhanced error handling and logging throughout the new modules for better traceability and user feedback.
2026-05-25 00:53:47 -04:00
Victor Noguera
b982edd160 Refactor database interactions to use Drizzle ORM
- Replaced direct SQLite database calls with Drizzle ORM methods in `top-monthly-sold-by-category.ts`, `writer.ts`, and `upc-file-analysis.ts`.
- Updated test cases in `top-monthly-sold-by-category.test.ts` to mock the new database interactions.
- Removed unnecessary database initialization and cleanup code.
- Improved code readability and maintainability by using ORM features for inserting and updating records.
2026-05-25 00:08:30 -04:00
Victor Noguera
70e0e8a535 feat: Enhance LLM robustness with improved error handling and model resolution 2026-05-21 20:26:48 -04:00
Victor Noguera
0e03366534 Merge branch 'claude' 2026-05-21 19:58:01 -04:00
Victor Noguera
95cebaa27c feat: add support for Claude LLM integration across multiple modules
- Introduced `useClaude` option in `AnalysisPipelineOptions` to toggle Claude LLM usage.
- Updated `processProductChunk` and `analyzeProducts` functions to accept and handle `useClaude` parameter.
- Modified argument parsing in various scripts (`bestsellers-by-category`, `mid-range-sellers-by-category`, `top-monthly-sold-by-category`, etc.) to include `--claude` flag.
- Enhanced `analyzeProductsInternal` to differentiate between LLM providers and handle requests to Claude API.
- Added error handling for Claude API responses and ensured proper configuration for using Claude.
- Updated documentation and usage messages to reflect the new `--claude` flag.
2026-05-21 19:57:46 -04:00
Victor Noguera
0f256be2be Merge branch 'searxng' 2026-05-20 18:35:53 -04:00
Victor Noguera
5226eee760 feat: add ASIN offer search functionality
- Introduced a new script `asin-offer-search.ts` for searching product offers by ASIN.
- Updated `package.json` to include a new command for the ASIN offer search.
- Enhanced configuration in `config.ts` to support SearXNG URL and timeout settings.
- Added comprehensive tests for the new search functionality in `searxng.test.ts`.
- Implemented the core search logic in `searxng.ts`, supporting multiple providers and price detection.
2026-05-20 18:34:08 -04:00
Victor Noguera
1d2e92addb Merge branch 'jaime' 2026-05-20 16:24:17 -04:00
Victor Noguera
f8bc05685e feat: add XLSX export functionality and refactor argument parsing in main script 2026-05-20 16:18:12 -04:00
Victor Noguera
0c2e59771c feat: add XLSX export functionality for Stalker products and enhance UI for export link 2026-05-19 23:12:34 -04:00
Victor Noguera
90bfee8791 feat: add advanced filtering options for Stalker products including price, sales rank, and seller metrics 2026-05-19 23:01:28 -04:00
Victor Noguera
1f57900da2 feat: implement batch processing for product analysis with delay and error handling 2026-05-19 20:24:08 -04:00
Victor Noguera
7bda3710ed feat: update Keepa and Stalker functionalities with enhanced price extraction logic and test cases 2026-05-19 19:59:20 -04:00
Victor Noguera
0552d183b3 feat: enhance Stalker functionality with additional product details and analysis capabilities 2026-05-19 19:57:53 -04:00
Victor Noguera
f6178a665c feat: add Stalker products functionality with filtering, pagination, and purge option 2026-05-19 19:37:05 -04:00
Victor Noguera
aed0c11017 feat: enhance stalker functionality with inventory sellability checks and update frontend display 2026-05-19 18:35:55 -04:00
Victor Noguera
a7c0e44e3d feat: add Stalker results page with filtering and pagination
- Introduced StalkerResultItem and StalkerResultsResponse types for handling API responses.
- Implemented StalkerExplorer component for displaying Stalker results with search and filter options.
- Added sorting functionality for Stalker results table.
- Enhanced Dashboard to include a button for navigating to Stalker results.
- Updated routing to support Stalker results page.
- Improved styles for section headers and inventory columns in the results table.
2026-05-19 18:10:01 -04:00
Victor Noguera
0f9b785cce refactor: streamline CLAUDE.md for Bun usage and remove outdated instructions 2026-05-19 01:25:17 -04:00
Victor Noguera
f3e4d3ac52 feat: Implement supplier export functionality with workbook generation
- Add `writeSupplierWorkbook` function to create Excel workbooks for supplier analysis results.
- Introduce `SupplierExportSummary` type for summarizing export data.
- Create tests for `writeSupplierWorkbook` to ensure correct sheet creation and data population.
- Implement supplier scoring logic in `supplier-scoring.ts` to evaluate product profitability and demand.
- Add tests for supplier scoring to validate scoring logic and verdict determination.
- Enhance UPC file analysis to integrate supplier scoring and export results to Excel.
- Update database writing logic to accommodate new supplier analysis results.
- Refactor types to include supplier-specific data structures and scoring metrics.
- Ensure proper cleanup of temporary files after tests.
2026-05-19 01:19:48 -04:00
Victor Noguera
41ef57a7bc Refactor mid-range seller processing to enforce sellability gates and enhance command-line arguments
- Updated test case to reflect changes in processing mid-range matches based on sellability.
- Modified `processCategory` function to implement strict and soft sellability gates.
- Introduced new command-line arguments for category selection and sellability gate configuration.
- Enhanced error handling and validation for new arguments.
- Improved logging for category processing and budget usage.
2026-05-12 14:14:20 -04:00
Victor Noguera
f2c8a9728d feat: add mid-range sellers by category analysis pipeline
This new pipeline identifies products meeting specific monthly sold, price, seller count, and Amazon buy box share criteria across categories. It fetches comprehensive product data from Keepa and SP-API, analyzes it using an LLM, and persists the results.

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

View File

@@ -10,7 +10,28 @@
"KillShell", "KillShell",
"Bash(bunx *)", "Bash(bunx *)",
"Bash(git *)", "Bash(git *)",
"Bash(ls *)" "Bash(ls *)",
"Bash(bun run build:web 2>&1 || true)",
"Bash(bun run build:web 2>&1 || true)",
"Bash(bun run build:web 2>&1 || true)",
"Bash(bun run build:web 2>&1 || true)",
"Bash(bun run build:web 2>&1 || true)",
"Bash(bun run build:web 2>&1 || true)",
"Bash(bun run build:web 2>&1 || true)",
"Bash(bun run build:web 2>&1 || true)",
"Bash(bun run build:web 2>&1 || true)",
"Bash(bun run build:web 2>&1 || true)",
"Bash(bun run db:migrate 2>&1 || true)",
"Bash(git --no-pager diff -- src/web/frontend.tsx src/web/styles.css 2>&1 || true)",
"Bash(bun run build:web 2>&1 || true)",
"Bash(bun run build:web 2>&1 || true)",
"Bash(bun run build:web 2>&1 || true)",
"Bash(bun run build:web 2>&1 || true)",
"Bash(bun run build:web 2>&1 || true)",
"Bash(bun run build:web 2>&1 || true)"
] ]
} },
"additionalDirectories": [
"/Users/nvictor/.abacusai/tmp/codellm-prompt-djc6Bc"
]
} }

7
.claude/settings.json Normal file
View File

@@ -0,0 +1,7 @@
{
"permissions": {
"allow": [
"Bash(grep -v \"^$\")"
]
}
}

View File

@@ -12,4 +12,12 @@ AWS_SECRET_ACCESS_KEY=your_aws_secret_access_key
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
ANTHROPIC_API_KEY=your_anthropic_api_key
ANTHROPIC_MODEL=claude-3-5-sonnet-20241022
CACHE_TTL=86400 CACHE_TTL=86400
GOOGLE_API_KEY=your_google_api_key
GOOGLE_CSE_ID=your_google_programmable_search_engine_id
SERPAPI_API_KEY=your_serpapi_api_key_for_google_shopping
# Matches the default PostgreSQL service in docker-compose.yaml.
DB_CONNECTION_STRING=postgres://asin_check:asin_check@localhost:5432/asin_check

15
.gitignore vendored
View File

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

224
CLAUDE.md
View File

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

221
README.md
View File

@@ -21,21 +21,26 @@ cp .env.example .env
## Usage ## Usage
```bash ```bash
bun run src/index.ts <input.csv|xlsx> [--out results.csv] bun start <input.csv|xlsx> [--out results.xlsx]
``` ```
Add `--claude` to use Anthropic Claude instead of local LM Studio for LLM analysis.
Bare input and output filenames use the `input/` and `output/` directories. Pass a path containing a directory to override those defaults.
Examples: Examples:
```bash ```bash
bun run src/index.ts leads.xlsx bun start leads.xlsx
bun run src/index.ts leads.csv --out results.xlsx bun start leads.csv --out results.xlsx
bun start leads.xlsx --claude
bun start archive/leads.xlsx --out exports/results.xlsx
``` ```
Large-file behavior: Large-file behavior:
- If the input has more than 50 products, processing is done in chunks of 50. - If the input has more than 50 products, processing is done in chunks of 50.
- Each chunk is analyzed and written to a numbered output file, for example: `results_part_001.xlsx`, `results_part_002.xlsx`, ... - Each chunk is analyzed and written to a numbered output file under `output/`, for example: `output/results_part_001.xlsx`, `output/results_part_002.xlsx`, ...
- If `--out` is omitted for large files, the base output name defaults to `<input>_results.xlsx` and chunk files are still written with numbered suffixes. - If `--out` is omitted for large files, the base output name defaults to `output/<input>_results.xlsx` and chunk files are still written with numbered suffixes.
Quick SP-API connectivity tests: Quick SP-API connectivity tests:
@@ -45,6 +50,150 @@ bun run src/sp-test.ts B07SN9BHVV # Auth + sellers endpoint + pricing offer c
bun run src/sp-test.ts --sellability B07SN9BHVV # Standalone sellability check bun run src/sp-test.ts --sellability B07SN9BHVV # Standalone sellability check
``` ```
## Category Pipelines
Run category-focused discovery flows with Keepa + SP-API + LLM:
```bash
bun run bestsellers
bun run monthly-sold
bun run mid-range
```
Use Claude for category LLM analysis:
```bash
bun run bestsellers --claude
bun run monthly-sold --claude
bun run mid-range --claude
```
Mid-range process:
- Script: `bun run mid-range`
- Source: `src/mid-range-sellers-by-category.ts`
- Default filters:
- Monthly sold between `100` and `1000`
- Price between `$15` and `$200` (using Keepa current price, fallback avg 90d)
- Seller count between `3` and `20`
- If Amazon is a seller, Amazon buy box share must be between `15%` and `85%`
- Sellability behavior:
- Sellability is still fetched and saved (`can_sell`, `sellability_status`, `sellability_reason`)
- Matching products are persisted regardless of sellability status
- Caching behavior:
- Uses Redis to cache Keepa + SP-API API enrichment per ASIN
- Cache TTL is fixed at `12 hours`
Example:
```bash
bun run mid-range --category-limit 10 --per-category-top 50 --category-candidate-pool 250 --min-monthly-sold 100 --max-monthly-sold 1000 --min-price 15 --max-price 200 --min-seller-count 3 --max-seller-count 20 --min-amazon-buybox-share-pct 15 --max-amazon-buybox-share-pct 85
```
## UPC to ASIN Mapping
You can map UPCs to ASINs directly through the Keepa integration in `src/keepa.ts`.
```ts
import { mapUpcsToAsins, lookupKeepaUpcs } from "./src/keepa.ts";
const upcs = ["012345678901", "098765432109", "112233445566"];
// Simple map output (UPC -> ASIN) for clean one-to-one matches only.
const asinMap = await mapUpcsToAsins(upcs);
for (const [upc, asin] of asinMap.entries()) {
console.log(`UPC ${upc} -> ASIN ${asin}`);
}
// Rich output includes status for every UPC (invalid, not found, collisions, etc.).
const details = await lookupKeepaUpcs(upcs);
for (const [upc, detail] of details.entries()) {
console.log(upc, detail.status, detail.asin, detail.reason ?? "");
}
```
Behavior:
- Strict validation accepts only 12, 13, or 14 digit UPC values.
- If a UPC resolves to multiple ASINs, it is excluded from the simple map.
- The rich lookup returns all candidate ASINs and status per UPC.
CLI usage:
```bash
bun run upc 012345678901 098765432109
bun run upc 012345678901,098765432109 --detailed
bun run upc --file upcs.txt --detailed --json
```
API usage (when `bun run start:web` is running):
```bash
# Simple one-to-one mapping (GET)
curl "http://localhost:3000/api/upc/map?upc=012345678901&upc=098765432109"
# Detailed lookup with statuses (GET)
curl "http://localhost:3000/api/upc/lookup?upcs=012345678901,098765432109"
# Detailed lookup (POST JSON)
curl -X POST "http://localhost:3000/api/upc/lookup" \
-H "content-type: application/json" \
-d '{"upcs":["012345678901","098765432109"]}'
```
Run the web server with Claude-backed LLM calls:
```bash
bun run start:web -- --claude
```
## Large UPC File Analysis (XLS/XLSX)
For supplier price lists that contain UPC/EAN values and unit cost, use the
dedicated UPC-file process. It runs in batches and produces a deterministic
ranked sourcing workbook:
1. Reads UPC rows in batches (`.xlsx` uses streaming reader, `.xls` uses fallback row-window parsing).
2. Resolves UPCs to ASINs with SP-API catalog lookup first, then falls back to Keepa for no-match/request-failure cases.
3. Enriches resolved ASINs with Keepa demand/competition data and SP-API sellability + FBA fees.
4. Scores products with deterministic BUY/WATCH/SKIP logic; this path does not call LM Studio.
5. Writes a ranked Excel workbook and persists rows through unified runs, UPC resolution, product observation, and scoring-history tables.
CLI usage:
```bash
bun run upc-file --input input/huge-upcs.xlsx
bun run upc-file --input input/supplier.xlsx --out output/supplier_ranked.xlsx
bun run upc-file --input input/huge-upcs.xls --input-batch-size 500 --upc-lookup-batch-size 100 --max-rows 10000
```
Workbook output includes `Ranked Leads`, `Skipped`, and `Summary` sheets with
UPC, ASIN, cost, sale price, FBA fee, profit, margin, ROI, BSR, rank drops,
monthly sold, seller count, Amazon Buy Box share, sellability, score, verdict,
and reason columns.
API usage (when `bun run start:web` is running):
```bash
curl -X POST "http://localhost:3000/api/process/upc-file" \
-H "content-type: application/json" \
-d '{
"inputFile": "/absolute/path/to/input/huge-upcs.xlsx",
"inputBatchSize": 300,
"upcLookupBatchSize": 100
}'
```
Request body fields:
- `inputFile` (required): server-local path to `.xls` or `.xlsx` file.
- `outputFile` (optional): stored in run metadata.
- `inputBatchSize` (optional): number of input rows per processing batch (default `200`).
- `upcLookupBatchSize` (optional): UPC chunk size per Keepa lookup call (default `100`).
- `maxRows` (optional): cap processed valid UPC rows for dry runs.
Response includes run metadata and status counts, including unresolved UPC reasons and lead verdict totals.
## Input file format ## 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:
@@ -97,20 +246,28 @@ Numeric parsing accepts plain numbers as well as formatted values like `$12.50`,
4. **Keepa fetch** — batch the sellable (uncached) ASINs in a single API call (up to 100 per request) 4. **Keepa fetch** — batch the sellable (uncached) ASINs in a single API call (up to 100 per request)
5. **Enrich** — fetch SP-API pricing + FBA/FBM fees for sellable ASINs; combine with Keepa data and spreadsheet data 5. **Enrich** — fetch SP-API pricing + FBA/FBM fees for sellable ASINs; combine with Keepa data and spreadsheet data
6. **LLM analysis** — send batches of 5 sellable products to LM Studio for FBA/FBM/SKIP verdict; skipped ASINs get auto-SKIP verdict (confidence 100) and bypass LLM entirely 6. **LLM analysis** — send batches of 5 sellable products to LM Studio for FBA/FBM/SKIP verdict; skipped ASINs get auto-SKIP verdict (confidence 100) and bypass LLM entirely
7. **Output** — print results table to console (includes all ASINs), optionally write CSV/XLSX, and **persist results to a SQLite database**. 7. **Output** — print results table to console (includes all ASINs), optionally write CSV/XLSX, and persist products, observations, run items, and analysis revisions to PostgreSQL.
## Persistent Storage with SQLite ## Persistent Storage
Results from each run are now stored in a SQLite database named `results.db` in the project root. The SQLite implementation details are handled in `src/database.ts`. This allows you to: PostgreSQL persistence is managed with Drizzle in `src/db/schema.ts` and `src/db/persistence.ts`. ASINs are canonical product identities: all inputs normalize to uppercase 10-character alphanumeric keys before any product reference is stored.
- Revisit past analysis results. Core tables:
- Query and analyze historical data.
- Track product performance over time.
The database will automatically be created if it doesn't exist. Two tables are created: - `products`: one canonical row per ASIN with latest descriptive metadata.
- `product_observations`: append-only marketplace, pricing, fee, and sellability snapshots.
- `runs` and `run_items`: unified lifecycle/history for lead, category, supplier UPC, and stalker workflows.
- `analysis_revisions` and `supplier_scores`: append-only analysis results; reanalysis does not overwrite prior decisions.
- `sourcing_inputs`, `upc_resolutions`, and `product_identifiers`: source-row and confirmed identifier data kept separate from catalog products.
- `stalker_run_details`, `stalker_scans`, and `stalker_inventory_items`: seller workflow provenance linked back to products and observations.
- `runs`: Stores metadata about each analysis run (timestamp, input file, output file, and summary counts). Unresolved or ambiguous supplier UPCs stay on their run item and resolution records; a UPC is never stored as an ASIN.
- `results`: Stores detailed analysis results for each product from each run, linked to the `runs` table.
Web endpoints use unified identifiers:
- `GET /api/runs`, `GET /api/runs/:runId`, `GET /api/runs/:runId/items`
- `GET /api/products`, `GET /api/products/:asin`
- `POST /api/run-items/:itemId/reanalyze`
## Output columns ## Output columns
@@ -118,23 +275,25 @@ 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_ID` | — | LWA app client id from Solution Provider Portal |
| `SP_API_CLIENT_SECRET` | — | LWA app client secret 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_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_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_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_SELLER_ID` | — | Seller ID used for listing restrictions eligibility checks |
| `SP_API_USE_SANDBOX` | `false` | Enable SP-API sandbox mode (`true`/`false`) | | `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_ACCESS_KEY_ID` | — | AWS credentials for SigV4 signing (required in most private app setups) |
| `AWS_SECRET_ACCESS_KEY` | — | AWS credentials for SigV4 signing | | `AWS_SECRET_ACCESS_KEY` | — | AWS credentials for SigV4 signing |
| `AWS_SESSION_TOKEN` | — | Optional session token when using STS credentials | | `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 |
| `CACHE_TTL` | `86400` | Redis cache TTL in seconds | | `ANTHROPIC_API_KEY` | — | Required when running any LLM script with `--claude` |
| `ANTHROPIC_MODEL` | `claude-3-5-sonnet-20241022` | Claude model ID used with `--claude` |
| `CACHE_TTL` | `86400` | Redis cache TTL in seconds |
## Notes ## Notes

372
bun.lock
View File

@@ -6,7 +6,10 @@
"name": "asin-check", "name": "asin-check",
"dependencies": { "dependencies": {
"amazon-sp-api": "^1.2.1", "amazon-sp-api": "^1.2.1",
"drizzle-orm": "^0.45.2",
"exceljs": "^4.4.0",
"ioredis": "^5.10.1", "ioredis": "^5.10.1",
"postgres": "^3.4.9",
"react": "^19.2.0", "react": "^19.2.0",
"react-dom": "^19.2.0", "react-dom": "^19.2.0",
"xlsx": "^0.18.5", "xlsx": "^0.18.5",
@@ -15,13 +18,74 @@
"@types/bun": "latest", "@types/bun": "latest",
"@types/react": "^19.2.14", "@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.3",
}, "drizzle-kit": "^0.31.10",
"peerDependencies": { "typescript": "^6.0.3",
"typescript": "^5",
}, },
}, },
}, },
"packages": { "packages": {
"@drizzle-team/brocli": ["@drizzle-team/brocli@0.10.2", "", {}, "sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w=="],
"@esbuild-kit/core-utils": ["@esbuild-kit/core-utils@3.3.2", "", { "dependencies": { "esbuild": "~0.18.20", "source-map-support": "^0.5.21" } }, "sha512-sPRAnw9CdSsRmEtnsl2WXWdyquogVpB3yZ3dgwJfe8zrOzTsV7cJvmwrKVa+0ma5BoiGJ+BoqkMvawbayKUsqQ=="],
"@esbuild-kit/esm-loader": ["@esbuild-kit/esm-loader@2.6.5", "", { "dependencies": { "@esbuild-kit/core-utils": "^3.3.2", "get-tsconfig": "^4.7.0" } }, "sha512-FxEMIkJKnodyA1OaCUoEvbYRkoZlLZ4d/eXFu9Fh8CbBBgP5EmZxrfTRyN0qpXZ4vOvqnE5YdRdcrmUUXuU+dA=="],
"@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.12", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA=="],
"@esbuild/android-arm": ["@esbuild/android-arm@0.25.12", "", { "os": "android", "cpu": "arm" }, "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg=="],
"@esbuild/android-arm64": ["@esbuild/android-arm64@0.25.12", "", { "os": "android", "cpu": "arm64" }, "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg=="],
"@esbuild/android-x64": ["@esbuild/android-x64@0.25.12", "", { "os": "android", "cpu": "x64" }, "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg=="],
"@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.25.12", "", { "os": "darwin", "cpu": "arm64" }, "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg=="],
"@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.25.12", "", { "os": "darwin", "cpu": "x64" }, "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA=="],
"@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.25.12", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg=="],
"@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.25.12", "", { "os": "freebsd", "cpu": "x64" }, "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ=="],
"@esbuild/linux-arm": ["@esbuild/linux-arm@0.25.12", "", { "os": "linux", "cpu": "arm" }, "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw=="],
"@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.25.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ=="],
"@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.25.12", "", { "os": "linux", "cpu": "ia32" }, "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA=="],
"@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng=="],
"@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw=="],
"@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.25.12", "", { "os": "linux", "cpu": "ppc64" }, "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA=="],
"@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w=="],
"@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.25.12", "", { "os": "linux", "cpu": "s390x" }, "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg=="],
"@esbuild/linux-x64": ["@esbuild/linux-x64@0.25.12", "", { "os": "linux", "cpu": "x64" }, "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw=="],
"@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg=="],
"@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.25.12", "", { "os": "none", "cpu": "x64" }, "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ=="],
"@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.25.12", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A=="],
"@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.25.12", "", { "os": "openbsd", "cpu": "x64" }, "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw=="],
"@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg=="],
"@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.25.12", "", { "os": "sunos", "cpu": "x64" }, "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w=="],
"@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.25.12", "", { "os": "win32", "cpu": "arm64" }, "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg=="],
"@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.25.12", "", { "os": "win32", "cpu": "ia32" }, "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ=="],
"@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.12", "", { "os": "win32", "cpu": "x64" }, "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA=="],
"@fast-csv/format": ["@fast-csv/format@4.3.5", "", { "dependencies": { "@types/node": "^14.0.1", "lodash.escaperegexp": "^4.1.2", "lodash.isboolean": "^3.0.3", "lodash.isequal": "^4.5.0", "lodash.isfunction": "^3.0.9", "lodash.isnil": "^4.0.0" } }, "sha512-8iRn6QF3I8Ak78lNAa+Gdl5MJJBM5vRHivFtMRUWINdevNo00K7OXxS2PshawLKTejVwieIlPmK5YlLu6w4u8A=="],
"@fast-csv/parse": ["@fast-csv/parse@4.3.6", "", { "dependencies": { "@types/node": "^14.0.1", "lodash.escaperegexp": "^4.1.2", "lodash.groupby": "^4.6.0", "lodash.isfunction": "^3.0.9", "lodash.isnil": "^4.0.0", "lodash.isundefined": "^3.0.1", "lodash.uniq": "^4.5.0" } }, "sha512-uRsLYksqpbDmWaSmzvJcuApSEe38+6NQZBUsuAyMZKqHxH0g1wcJgsKUvN3WC8tewaqFjBMMGrkHmC+T7k8LvA=="],
"@ioredis/commands": ["@ioredis/commands@1.5.1", "", {}, "sha512-JH8ZL/ywcJyR9MmJ5BNqZllXNZQqQbnVZOqpPQqE1vHiFgAw4NHbvE0FOduNU8IX9babitBT46571OnPTT0Zcw=="], "@ioredis/commands": ["@ioredis/commands@1.5.1", "", {}, "sha512-JH8ZL/ywcJyR9MmJ5BNqZllXNZQqQbnVZOqpPQqE1vHiFgAw4NHbvE0FOduNU8IX9babitBT46571OnPTT0Zcw=="],
"@types/bun": ["@types/bun@1.3.11", "", { "dependencies": { "bun-types": "1.3.11" } }, "sha512-5vPne5QvtpjGpsGYXiFyycfpDF2ECyPcTSsFBMa0fraoxiQyMJ3SmuQIGhzPg2WJuWxVBoxWJ2kClYTcw/4fAg=="], "@types/bun": ["@types/bun@1.3.11", "", { "dependencies": { "bun-types": "1.3.11" } }, "sha512-5vPne5QvtpjGpsGYXiFyycfpDF2ECyPcTSsFBMa0fraoxiQyMJ3SmuQIGhzPg2WJuWxVBoxWJ2kClYTcw/4fAg=="],
@@ -36,6 +100,36 @@
"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=="], "amazon-sp-api": ["amazon-sp-api@1.2.1", "", { "dependencies": { "csvtojson": "^2.0.14", "fast-xml-parser": "^5.3.1", "iconv-lite": "^0.7.0", "qs": "^6.14.0" } }, "sha512-zxX3KtoCDx0wxkkBgFM6qew49JJoL1XZQgUnztfp+8Im2HLHBAt4beSiDo/AkH00Gr8paHBAjdcJY6LC6ISU7w=="],
"archiver": ["archiver@5.3.2", "", { "dependencies": { "archiver-utils": "^2.1.0", "async": "^3.2.4", "buffer-crc32": "^0.2.1", "readable-stream": "^3.6.0", "readdir-glob": "^1.1.2", "tar-stream": "^2.2.0", "zip-stream": "^4.1.0" } }, "sha512-+25nxyyznAXF7Nef3y0EbBeqmGZgeN/BxHX29Rs39djAfaFalmQ89SE6CWyDCHzGL0yt/ycBtNOmGTW0FyGWNw=="],
"archiver-utils": ["archiver-utils@2.1.0", "", { "dependencies": { "glob": "^7.1.4", "graceful-fs": "^4.2.0", "lazystream": "^1.0.0", "lodash.defaults": "^4.2.0", "lodash.difference": "^4.5.0", "lodash.flatten": "^4.4.0", "lodash.isplainobject": "^4.0.6", "lodash.union": "^4.6.0", "normalize-path": "^3.0.0", "readable-stream": "^2.0.0" } }, "sha512-bEL/yUb/fNNiNTuUz979Z0Yg5L+LzLxGJz8x79lYmR54fmTIb6ob/hNQgkQnIUDWIFjZVQwl9Xs356I6BAMHfw=="],
"async": ["async@3.2.6", "", {}, "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA=="],
"balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],
"base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="],
"big-integer": ["big-integer@1.6.52", "", {}, "sha512-QxD8cf2eVqJOOz63z6JIN9BzvVs/dlySa5HGSBH5xtR8dPteIRQnBxxKqkNTiT6jbDTF6jAfrd4oMcND9RGbQg=="],
"binary": ["binary@0.3.0", "", { "dependencies": { "buffers": "~0.1.1", "chainsaw": "~0.1.0" } }, "sha512-D4H1y5KYwpJgK8wk1Cue5LLPgmwHKYSChkbspQg5JtVuR5ulGckxfR62H3AE9UDkdMC8yyXlqYihuz3Aqg2XZg=="],
"bl": ["bl@4.1.0", "", { "dependencies": { "buffer": "^5.5.0", "inherits": "^2.0.4", "readable-stream": "^3.4.0" } }, "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w=="],
"bluebird": ["bluebird@3.4.7", "", {}, "sha512-iD3898SR7sWVRHbiQv+sHUtHnMvC1o3nW5rAcqnq3uOn07DSAppZYUkIGslDz6gXC7HfunPe7YVBgoEJASPcHA=="],
"brace-expansion": ["brace-expansion@2.1.0", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w=="],
"buffer": ["buffer@5.7.1", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.1.13" } }, "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ=="],
"buffer-crc32": ["buffer-crc32@0.2.13", "", {}, "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ=="],
"buffer-from": ["buffer-from@1.1.2", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="],
"buffer-indexof-polyfill": ["buffer-indexof-polyfill@1.0.2", "", {}, "sha512-I7wzHwA3t1/lwXQh+A5PbNvJxgfo5r3xulgpYDB5zckTu/Z9oUK9biouBKQUjEqzaz3HnAT6TYoovmE+GqSf7A=="],
"buffers": ["buffers@0.1.1", "", {}, "sha512-9q/rDEGSb/Qsvv2qvzIzdluL5k7AaJOTrw23z9reQthrbF7is4CtlT0DXyO1oei2DCp4uojjzQ7igaSHp1kAEQ=="],
"bun-types": ["bun-types@1.3.11", "", { "dependencies": { "@types/node": "*" } }, "sha512-1KGPpoxQWl9f6wcZh57LvrPIInQMn2TQ7jsgxqpRzg+l0QPOFvJVH7HmvHo/AiPgwXy+/Thf6Ov3EdVn1vOabg=="], "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-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="],
@@ -44,78 +138,190 @@
"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=="],
"chainsaw": ["chainsaw@0.1.0", "", { "dependencies": { "traverse": ">=0.3.0 <0.4" } }, "sha512-75kWfWt6MEKNC8xYXIdRpDehRYY/tNSgwKaJq+dbbDcxORuVrrQ+SEHoWsniVn9XPYfP4gmdWIeDk/4YNp1rNQ=="],
"cluster-key-slot": ["cluster-key-slot@1.1.2", "", {}, "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA=="], "cluster-key-slot": ["cluster-key-slot@1.1.2", "", {}, "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA=="],
"codepage": ["codepage@1.15.0", "", {}, "sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA=="], "codepage": ["codepage@1.15.0", "", {}, "sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA=="],
"compress-commons": ["compress-commons@4.1.2", "", { "dependencies": { "buffer-crc32": "^0.2.13", "crc32-stream": "^4.0.2", "normalize-path": "^3.0.0", "readable-stream": "^3.6.0" } }, "sha512-D3uMHtGc/fcO1Gt1/L7i1e33VOvD4A9hfQLP+6ewd+BvG/gQ84Yh4oftEhAdjSMgBgwGL+jsppT7JYNpo6MHHg=="],
"concat-map": ["concat-map@0.0.1", "", {}, "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg=="],
"core-util-is": ["core-util-is@1.0.3", "", {}, "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ=="],
"crc-32": ["crc-32@1.2.2", "", { "bin": { "crc32": "bin/crc32.njs" } }, "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ=="], "crc-32": ["crc-32@1.2.2", "", { "bin": { "crc32": "bin/crc32.njs" } }, "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ=="],
"crc32-stream": ["crc32-stream@4.0.3", "", { "dependencies": { "crc-32": "^1.2.0", "readable-stream": "^3.4.0" } }, "sha512-NT7w2JVU7DFroFdYkeq8cywxrgjPHWkdX1wjpRQXPX5Asews3tA+Ght6lddQO5Mkumffp3X7GEqku3epj2toIw=="],
"csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="], "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
"csvtojson": ["csvtojson@2.0.14", "", { "dependencies": { "lodash": "^4.17.21" }, "bin": { "csvtojson": "bin/csvtojson" } }, "sha512-F7NNvhhDyob7OsuEGRsH0FM1aqLs/WYITyza3l+hTEEmOK9sGPBlYQZwlVG0ezCojXYpE17lhS5qL6BCOZSPyA=="], "csvtojson": ["csvtojson@2.0.14", "", { "dependencies": { "lodash": "^4.17.21" }, "bin": { "csvtojson": "bin/csvtojson" } }, "sha512-F7NNvhhDyob7OsuEGRsH0FM1aqLs/WYITyza3l+hTEEmOK9sGPBlYQZwlVG0ezCojXYpE17lhS5qL6BCOZSPyA=="],
"dayjs": ["dayjs@1.11.20", "", {}, "sha512-YbwwqR/uYpeoP4pu043q+LTDLFBLApUP6VxRihdfNTqu4ubqMlGDLd6ErXhEgsyvY0K6nCs7nggYumAN+9uEuQ=="],
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], "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=="],
"drizzle-kit": ["drizzle-kit@0.31.10", "", { "dependencies": { "@drizzle-team/brocli": "^0.10.2", "@esbuild-kit/esm-loader": "^2.5.5", "esbuild": "^0.25.4", "tsx": "^4.21.0" }, "bin": { "drizzle-kit": "bin.cjs" } }, "sha512-7OZcmQUrdGI+DUNNsKBn1aW8qSoKuTH7d0mYgSP8bAzdFzKoovxEFnoGQp2dVs82EOJeYycqRtciopszwUf8bw=="],
"drizzle-orm": ["drizzle-orm@0.45.2", "", { "peerDependencies": { "@aws-sdk/client-rds-data": ">=3", "@cloudflare/workers-types": ">=4", "@electric-sql/pglite": ">=0.2.0", "@libsql/client": ">=0.10.0", "@libsql/client-wasm": ">=0.10.0", "@neondatabase/serverless": ">=0.10.0", "@op-engineering/op-sqlite": ">=2", "@opentelemetry/api": "^1.4.1", "@planetscale/database": ">=1.13", "@prisma/client": "*", "@tidbcloud/serverless": "*", "@types/better-sqlite3": "*", "@types/pg": "*", "@types/sql.js": "*", "@upstash/redis": ">=1.34.7", "@vercel/postgres": ">=0.8.0", "@xata.io/client": "*", "better-sqlite3": ">=7", "bun-types": "*", "expo-sqlite": ">=14.0.0", "gel": ">=2", "knex": "*", "kysely": "*", "mysql2": ">=2", "pg": ">=8", "postgres": ">=3", "prisma": "*", "sql.js": ">=1", "sqlite3": ">=5" }, "optionalPeers": ["@aws-sdk/client-rds-data", "@cloudflare/workers-types", "@electric-sql/pglite", "@libsql/client", "@libsql/client-wasm", "@neondatabase/serverless", "@op-engineering/op-sqlite", "@opentelemetry/api", "@planetscale/database", "@prisma/client", "@tidbcloud/serverless", "@types/better-sqlite3", "@types/pg", "@types/sql.js", "@upstash/redis", "@vercel/postgres", "@xata.io/client", "better-sqlite3", "bun-types", "expo-sqlite", "gel", "knex", "kysely", "mysql2", "pg", "postgres", "prisma", "sql.js", "sqlite3"] }, "sha512-kY0BSaTNYWnoDMVoyY8uxmyHjpJW1geOmBMdSSicKo9CIIWkSxMIj2rkeSR51b8KAPB7m+qysjuHme5nKP+E5Q=="],
"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=="], "dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="],
"duplexer2": ["duplexer2@0.1.4", "", { "dependencies": { "readable-stream": "^2.0.2" } }, "sha512-asLFVfWWtJ90ZyOUHMqk7/S2w2guQKxUI2itj3d92ADHhxUSbCMGi1f1cBcJ7xM1To+pE/Khbwo1yuNbMEPKeA=="],
"end-of-stream": ["end-of-stream@1.4.5", "", { "dependencies": { "once": "^1.4.0" } }, "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg=="],
"es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="], "es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="],
"es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="], "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=="], "es-object-atoms": ["es-object-atoms@1.1.1", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA=="],
"esbuild": ["esbuild@0.25.12", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.12", "@esbuild/android-arm": "0.25.12", "@esbuild/android-arm64": "0.25.12", "@esbuild/android-x64": "0.25.12", "@esbuild/darwin-arm64": "0.25.12", "@esbuild/darwin-x64": "0.25.12", "@esbuild/freebsd-arm64": "0.25.12", "@esbuild/freebsd-x64": "0.25.12", "@esbuild/linux-arm": "0.25.12", "@esbuild/linux-arm64": "0.25.12", "@esbuild/linux-ia32": "0.25.12", "@esbuild/linux-loong64": "0.25.12", "@esbuild/linux-mips64el": "0.25.12", "@esbuild/linux-ppc64": "0.25.12", "@esbuild/linux-riscv64": "0.25.12", "@esbuild/linux-s390x": "0.25.12", "@esbuild/linux-x64": "0.25.12", "@esbuild/netbsd-arm64": "0.25.12", "@esbuild/netbsd-x64": "0.25.12", "@esbuild/openbsd-arm64": "0.25.12", "@esbuild/openbsd-x64": "0.25.12", "@esbuild/openharmony-arm64": "0.25.12", "@esbuild/sunos-x64": "0.25.12", "@esbuild/win32-arm64": "0.25.12", "@esbuild/win32-ia32": "0.25.12", "@esbuild/win32-x64": "0.25.12" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg=="],
"exceljs": ["exceljs@4.4.0", "", { "dependencies": { "archiver": "^5.0.0", "dayjs": "^1.8.34", "fast-csv": "^4.3.1", "jszip": "^3.10.1", "readable-stream": "^3.6.0", "saxes": "^5.0.1", "tmp": "^0.2.0", "unzipper": "^0.10.11", "uuid": "^8.3.0" } }, "sha512-XctvKaEMaj1Ii9oDOqbW/6e1gXknSY4g/aLCDicOXqBE4M0nRWkUu0PTp++UPNzoFY12BNHMfs/VadKIS6llvg=="],
"fast-csv": ["fast-csv@4.3.6", "", { "dependencies": { "@fast-csv/format": "4.3.5", "@fast-csv/parse": "4.3.6" } }, "sha512-2RNSpuwwsJGP0frGsOmTb9oUF+VkFSM4SyLTDgwf2ciHWTarN0lQTC+F2f/t5J9QjW+c65VFIAAu85GsvMIusw=="],
"fast-xml-builder": ["fast-xml-builder@1.1.4", "", { "dependencies": { "path-expression-matcher": "^1.1.3" } }, "sha512-f2jhpN4Eccy0/Uz9csxh3Nu6q4ErKxf0XIsasomfOihuSUa3/xw6w8dnOtCDgEItQFJG8KyXPzQXzcODDrrbOg=="], "fast-xml-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=="], "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=="],
"fs-constants": ["fs-constants@1.0.0", "", {}, "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow=="],
"fs.realpath": ["fs.realpath@1.0.0", "", {}, "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw=="],
"fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
"fstream": ["fstream@1.0.12", "", { "dependencies": { "graceful-fs": "^4.1.2", "inherits": "~2.0.0", "mkdirp": ">=0.5 0", "rimraf": "2" } }, "sha512-WvJ193OHa0GHPEL+AycEJgxvBEwyfRkN1vhjca23OaPVMCaLCXTd5qAu82AjTcgP1UJmytkOKb63Ypde7raDIg=="],
"function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="], "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-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=="], "get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="],
"get-tsconfig": ["get-tsconfig@4.14.0", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA=="],
"glob": ["glob@7.2.3", "", { "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", "inherits": "2", "minimatch": "^3.1.1", "once": "^1.3.0", "path-is-absolute": "^1.0.0" } }, "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q=="],
"gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="], "gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="],
"graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="],
"has-symbols": ["has-symbols@1.1.0", "", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="], "has-symbols": ["has-symbols@1.1.0", "", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="],
"hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="], "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=="], "iconv-lite": ["iconv-lite@0.7.2", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw=="],
"ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="],
"immediate": ["immediate@3.0.6", "", {}, "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ=="],
"inflight": ["inflight@1.0.6", "", { "dependencies": { "once": "^1.3.0", "wrappy": "1" } }, "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA=="],
"inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="],
"ioredis": ["ioredis@5.10.1", "", { "dependencies": { "@ioredis/commands": "1.5.1", "cluster-key-slot": "^1.1.0", "debug": "^4.3.4", "denque": "^2.1.0", "lodash.defaults": "^4.2.0", "lodash.isarguments": "^3.1.0", "redis-errors": "^1.2.0", "redis-parser": "^3.0.0", "standard-as-callback": "^2.1.0" } }, "sha512-HuEDBTI70aYdx1v6U97SbNx9F1+svQKBDo30o0b9fw055LMepzpOOd0Ccg9Q6tbqmBSJaMuY0fB7yw9/vjBYCA=="], "ioredis": ["ioredis@5.10.1", "", { "dependencies": { "@ioredis/commands": "1.5.1", "cluster-key-slot": "^1.1.0", "debug": "^4.3.4", "denque": "^2.1.0", "lodash.defaults": "^4.2.0", "lodash.isarguments": "^3.1.0", "redis-errors": "^1.2.0", "redis-parser": "^3.0.0", "standard-as-callback": "^2.1.0" } }, "sha512-HuEDBTI70aYdx1v6U97SbNx9F1+svQKBDo30o0b9fw055LMepzpOOd0Ccg9Q6tbqmBSJaMuY0fB7yw9/vjBYCA=="],
"isarray": ["isarray@1.0.0", "", {}, "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ=="],
"jszip": ["jszip@3.10.1", "", { "dependencies": { "lie": "~3.3.0", "pako": "~1.0.2", "readable-stream": "~2.3.6", "setimmediate": "^1.0.5" } }, "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g=="],
"lazystream": ["lazystream@1.0.1", "", { "dependencies": { "readable-stream": "^2.0.5" } }, "sha512-b94GiNHQNy6JNTrt5w6zNyffMrNkXZb3KTkCZJb2V1xaEGCk093vkZ2jk3tpaeP33/OiXC+WvK9AxUebnf5nbw=="],
"lie": ["lie@3.3.0", "", { "dependencies": { "immediate": "~3.0.5" } }, "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ=="],
"listenercount": ["listenercount@1.0.1", "", {}, "sha512-3mk/Zag0+IJxeDrxSgaDPy4zZ3w05PRZeJNnlWhzFz5OkX49J4krc+A8X2d2M69vGMBEX0uyl8M+W+8gH+kBqQ=="],
"lodash": ["lodash@4.18.1", "", {}, "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q=="], "lodash": ["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.difference": ["lodash.difference@4.5.0", "", {}, "sha512-dS2j+W26TQ7taQBGN8Lbbq04ssV3emRw4NY58WErlTO29pIqS0HmoT5aJ9+TUQ1N3G+JOZSji4eugsWwGp9yPA=="],
"lodash.escaperegexp": ["lodash.escaperegexp@4.1.2", "", {}, "sha512-TM9YBvyC84ZxE3rgfefxUWiQKLilstD6k7PTGt6wfbtXF8ixIJLOL3VYyV/z+ZiPLsVxAsKAFVwWlWeb2Y8Yyw=="],
"lodash.flatten": ["lodash.flatten@4.4.0", "", {}, "sha512-C5N2Z3DgnnKr0LOpv/hKCgKdb7ZZwafIrsesve6lmzvZIRZRGaZ/l6Q8+2W7NaT+ZwO3fFlSCzCzrDCFdJfZ4g=="],
"lodash.groupby": ["lodash.groupby@4.6.0", "", {}, "sha512-5dcWxm23+VAoz+awKmBaiBvzox8+RqMgFhi7UvX9DHZr2HdxHXM/Wrf8cfKpsW37RNrvtPn6hSwNqurSILbmJw=="],
"lodash.isarguments": ["lodash.isarguments@3.1.0", "", {}, "sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg=="], "lodash.isarguments": ["lodash.isarguments@3.1.0", "", {}, "sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg=="],
"lodash.isboolean": ["lodash.isboolean@3.0.3", "", {}, "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg=="],
"lodash.isequal": ["lodash.isequal@4.5.0", "", {}, "sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ=="],
"lodash.isfunction": ["lodash.isfunction@3.0.9", "", {}, "sha512-AirXNj15uRIMMPihnkInB4i3NHeb4iBtNg9WRWuK2o31S+ePwwNmDPaTL3o7dTJ+VXNZim7rFs4rxN4YU1oUJw=="],
"lodash.isnil": ["lodash.isnil@4.0.0", "", {}, "sha512-up2Mzq3545mwVnMhTDMdfoG1OurpA/s5t88JmQX809eH3C8491iu2sfKhTfhQtKY78oPNhiaHJUpT/dUDAAtng=="],
"lodash.isplainobject": ["lodash.isplainobject@4.0.6", "", {}, "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA=="],
"lodash.isundefined": ["lodash.isundefined@3.0.1", "", {}, "sha512-MXB1is3s899/cD8jheYYE2V9qTHwKvt+npCwpD+1Sxm3Q3cECXCiYHjeHWXNwr6Q0SOBPrYUDxendrO6goVTEA=="],
"lodash.union": ["lodash.union@4.6.0", "", {}, "sha512-c4pB2CdGrGdjMKYLA+XiRDO7Y0PRQbm/Gzg8qMj+QH+pFVAoTp5sBpO0odL3FjoPCGjK96p6qsP+yQoiLoOBcw=="],
"lodash.uniq": ["lodash.uniq@4.5.0", "", {}, "sha512-xfBaXQd9ryd9dlSDvnvI0lvxfLJlYAZzXomUYzLKtUeOQvOP5piqAWuGtrhWeqaXK9hhoM/iyJc5AV+XfsX3HQ=="],
"math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="], "math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="],
"minimatch": ["minimatch@5.1.9", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw=="],
"mkdirp": ["mkdirp@3.0.1", "", { "bin": { "mkdirp": "dist/cjs/src/bin.js" } }, "sha512-+NsyUUAZDmo6YVHzL/stxSu3t9YS1iljliy3BSDrXJ/dkn1KYdmtZODGGjLcc9XLgVVpH4KshHB8XmZgMhaBXg=="],
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
"normalize-path": ["normalize-path@3.0.0", "", {}, "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA=="],
"object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="], "object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="],
"once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="],
"pako": ["pako@1.0.11", "", {}, "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw=="],
"path-expression-matcher": ["path-expression-matcher@1.4.0", "", {}, "sha512-s4DQMxIdhj3jLFWd9LxHOplj4p9yQ4ffMGowFf3cpEgrrJjEhN0V5nxw4Ye1EViAGDoL4/1AeO6qHpqYPOzE4Q=="], "path-expression-matcher": ["path-expression-matcher@1.4.0", "", {}, "sha512-s4DQMxIdhj3jLFWd9LxHOplj4p9yQ4ffMGowFf3cpEgrrJjEhN0V5nxw4Ye1EViAGDoL4/1AeO6qHpqYPOzE4Q=="],
"path-is-absolute": ["path-is-absolute@1.0.1", "", {}, "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg=="],
"postgres": ["postgres@3.4.9", "", {}, "sha512-GD3qdB0x1z9xgFI6cdRD6xu2Sp2WCOEoe3mtnyB5Ee0XrrL5Pe+e4CCnJrRMnL1zYtRDZmQQVbvOttLnKDLnaw=="],
"process-nextick-args": ["process-nextick-args@2.0.1", "", {}, "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag=="],
"qs": ["qs@6.15.1", "", { "dependencies": { "side-channel": "^1.1.0" } }, "sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg=="], "qs": ["qs@6.15.1", "", { "dependencies": { "side-channel": "^1.1.0" } }, "sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg=="],
"react": ["react@19.2.5", "", {}, "sha512-llUJLzz1zTUBrskt2pwZgLq59AemifIftw4aB7JxOqf1HY2FDaGDxgwpAPVzHU1kdWabH7FauP4i1oEeer2WCA=="], "react": ["react@19.2.5", "", {}, "sha512-llUJLzz1zTUBrskt2pwZgLq59AemifIftw4aB7JxOqf1HY2FDaGDxgwpAPVzHU1kdWabH7FauP4i1oEeer2WCA=="],
"react-dom": ["react-dom@19.2.5", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.5" } }, "sha512-J5bAZz+DXMMwW/wV3xzKke59Af6CHY7G4uYLN1OvBcKEsWOs4pQExj86BBKamxl/Ik5bx9whOrvBlSDfWzgSag=="], "react-dom": ["react-dom@19.2.5", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.5" } }, "sha512-J5bAZz+DXMMwW/wV3xzKke59Af6CHY7G4uYLN1OvBcKEsWOs4pQExj86BBKamxl/Ik5bx9whOrvBlSDfWzgSag=="],
"readable-stream": ["readable-stream@3.6.2", "", { "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", "util-deprecate": "^1.0.1" } }, "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA=="],
"readdir-glob": ["readdir-glob@1.1.3", "", { "dependencies": { "minimatch": "^5.1.0" } }, "sha512-v05I2k7xN8zXvPD9N+z/uhXPaj0sUFCe2rcWZIpBsqxfP7xXFQ0tipAd/wjj1YxWyWtUS5IDJpOG82JKt2EAVA=="],
"redis-errors": ["redis-errors@1.2.0", "", {}, "sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w=="], "redis-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=="],
"resolve-pkg-maps": ["resolve-pkg-maps@1.0.0", "", {}, "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw=="],
"rimraf": ["rimraf@2.7.1", "", { "dependencies": { "glob": "^7.1.3" }, "bin": { "rimraf": "./bin.js" } }, "sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w=="],
"safe-buffer": ["safe-buffer@5.1.2", "", {}, "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="],
"safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="], "safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="],
"saxes": ["saxes@5.0.1", "", { "dependencies": { "xmlchars": "^2.2.0" } }, "sha512-5LBh1Tls8c9xgGjw3QrMwETmTMVk0oFgvrFSvWx62llR2hcEInrKNZ2GZCCuuy2lvWrdl5jhbpeqc5hRYKFOcw=="],
"scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="], "scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="],
"setimmediate": ["setimmediate@1.0.5", "", {}, "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA=="],
"side-channel": ["side-channel@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3", "side-channel-list": "^1.0.0", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" } }, "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw=="], "side-channel": ["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-list": ["side-channel-list@1.0.1", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.4" } }, "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w=="],
@@ -124,20 +330,178 @@
"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=="], "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=="],
"source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="],
"source-map-support": ["source-map-support@0.5.21", "", { "dependencies": { "buffer-from": "^1.0.0", "source-map": "^0.6.0" } }, "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w=="],
"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=="],
"string_decoder": ["string_decoder@1.3.0", "", { "dependencies": { "safe-buffer": "~5.2.0" } }, "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA=="],
"strnum": ["strnum@2.2.3", "", {}, "sha512-oKx6RUCuHfT3oyVjtnrmn19H1SiCqgJSg+54XqURKp5aCMbrXrhLjRN9TjuwMjiYstZ0MzDrHqkGZ5dFTKd+zg=="], "strnum": ["strnum@2.2.3", "", {}, "sha512-oKx6RUCuHfT3oyVjtnrmn19H1SiCqgJSg+54XqURKp5aCMbrXrhLjRN9TjuwMjiYstZ0MzDrHqkGZ5dFTKd+zg=="],
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], "tar-stream": ["tar-stream@2.2.0", "", { "dependencies": { "bl": "^4.0.3", "end-of-stream": "^1.4.1", "fs-constants": "^1.0.0", "inherits": "^2.0.3", "readable-stream": "^3.1.1" } }, "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ=="],
"tmp": ["tmp@0.2.5", "", {}, "sha512-voyz6MApa1rQGUxT3E+BK7/ROe8itEx7vD8/HEvt4xwXucvQ5G5oeEiHkmHZJuBO21RpOf+YYm9MOivj709jow=="],
"traverse": ["traverse@0.3.9", "", {}, "sha512-iawgk0hLP3SxGKDfnDJf8wTz4p2qImnyihM5Hh/sGvQ3K37dPi/w8sRhdNIxYA1TwFwc5mDhIJq+O0RsvXBKdQ=="],
"tsx": ["tsx@4.22.3", "", { "dependencies": { "esbuild": "~0.28.0" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "bin": { "tsx": "dist/cli.mjs" } }, "sha512-mdoNxBC/cSQObGGVQ5Bpn5i+yv7j68gk3Nfm3wFjcJg3Z0Mix9jzAFfP12prmm5eVGmDKtp0yyArrs0Q+8gZHg=="],
"typescript": ["typescript@6.0.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw=="],
"undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="], "undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="],
"unzipper": ["unzipper@0.10.14", "", { "dependencies": { "big-integer": "^1.6.17", "binary": "~0.3.0", "bluebird": "~3.4.1", "buffer-indexof-polyfill": "~1.0.0", "duplexer2": "~0.1.4", "fstream": "^1.0.12", "graceful-fs": "^4.2.2", "listenercount": "~1.0.1", "readable-stream": "~2.3.6", "setimmediate": "~1.0.4" } }, "sha512-ti4wZj+0bQTiX2KmKWuwj7lhV+2n//uXEotUmGuQqrbVZSEGFMbI68+c6JCQ8aAmUWYvtHEz2A8K6wXvueR/6g=="],
"util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="],
"uuid": ["uuid@8.3.2", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg=="],
"wmf": ["wmf@1.0.2", "", {}, "sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw=="], "wmf": ["wmf@1.0.2", "", {}, "sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw=="],
"word": ["word@0.3.0", "", {}, "sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA=="], "word": ["word@0.3.0", "", {}, "sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA=="],
"wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="],
"xlsx": ["xlsx@0.18.5", "", { "dependencies": { "adler-32": "~1.3.0", "cfb": "~1.2.1", "codepage": "~1.15.0", "crc-32": "~1.2.1", "ssf": "~0.11.2", "wmf": "~1.0.1", "word": "~0.3.0" }, "bin": { "xlsx": "bin/xlsx.njs" } }, "sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ=="], "xlsx": ["xlsx@0.18.5", "", { "dependencies": { "adler-32": "~1.3.0", "cfb": "~1.2.1", "codepage": "~1.15.0", "crc-32": "~1.2.1", "ssf": "~0.11.2", "wmf": "~1.0.1", "word": "~0.3.0" }, "bin": { "xlsx": "bin/xlsx.njs" } }, "sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ=="],
"xmlchars": ["xmlchars@2.2.0", "", {}, "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw=="],
"zip-stream": ["zip-stream@4.1.1", "", { "dependencies": { "archiver-utils": "^3.0.4", "compress-commons": "^4.1.2", "readable-stream": "^3.6.0" } }, "sha512-9qv4rlDiopXg4E69k+vMHjNN63YFMe9sZMrdlvKnCjlCRWeCBswPPMPUfx+ipsAWq1LXHe70RcbaHdJJpS6hyQ=="],
"@esbuild-kit/core-utils/esbuild": ["esbuild@0.18.20", "", { "optionalDependencies": { "@esbuild/android-arm": "0.18.20", "@esbuild/android-arm64": "0.18.20", "@esbuild/android-x64": "0.18.20", "@esbuild/darwin-arm64": "0.18.20", "@esbuild/darwin-x64": "0.18.20", "@esbuild/freebsd-arm64": "0.18.20", "@esbuild/freebsd-x64": "0.18.20", "@esbuild/linux-arm": "0.18.20", "@esbuild/linux-arm64": "0.18.20", "@esbuild/linux-ia32": "0.18.20", "@esbuild/linux-loong64": "0.18.20", "@esbuild/linux-mips64el": "0.18.20", "@esbuild/linux-ppc64": "0.18.20", "@esbuild/linux-riscv64": "0.18.20", "@esbuild/linux-s390x": "0.18.20", "@esbuild/linux-x64": "0.18.20", "@esbuild/netbsd-x64": "0.18.20", "@esbuild/openbsd-x64": "0.18.20", "@esbuild/sunos-x64": "0.18.20", "@esbuild/win32-arm64": "0.18.20", "@esbuild/win32-ia32": "0.18.20", "@esbuild/win32-x64": "0.18.20" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA=="],
"@fast-csv/format/@types/node": ["@types/node@14.18.63", "", {}, "sha512-fAtCfv4jJg+ExtXhvCkCqUKZ+4ok/JQk01qDKhL5BDDoS3AxKXhV5/MAVUZyQnSEd2GT92fkgZl0pz0Q0AzcIQ=="],
"@fast-csv/parse/@types/node": ["@types/node@14.18.63", "", {}, "sha512-fAtCfv4jJg+ExtXhvCkCqUKZ+4ok/JQk01qDKhL5BDDoS3AxKXhV5/MAVUZyQnSEd2GT92fkgZl0pz0Q0AzcIQ=="],
"archiver-utils/readable-stream": ["readable-stream@2.3.8", "", { "dependencies": { "core-util-is": "~1.0.0", "inherits": "~2.0.3", "isarray": "~1.0.0", "process-nextick-args": "~2.0.0", "safe-buffer": "~5.1.1", "string_decoder": "~1.1.1", "util-deprecate": "~1.0.1" } }, "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA=="],
"duplexer2/readable-stream": ["readable-stream@2.3.8", "", { "dependencies": { "core-util-is": "~1.0.0", "inherits": "~2.0.3", "isarray": "~1.0.0", "process-nextick-args": "~2.0.0", "safe-buffer": "~5.1.1", "string_decoder": "~1.1.1", "util-deprecate": "~1.0.1" } }, "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA=="],
"glob/minimatch": ["minimatch@3.1.5", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w=="],
"jszip/readable-stream": ["readable-stream@2.3.8", "", { "dependencies": { "core-util-is": "~1.0.0", "inherits": "~2.0.3", "isarray": "~1.0.0", "process-nextick-args": "~2.0.0", "safe-buffer": "~5.1.1", "string_decoder": "~1.1.1", "util-deprecate": "~1.0.1" } }, "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA=="],
"lazystream/readable-stream": ["readable-stream@2.3.8", "", { "dependencies": { "core-util-is": "~1.0.0", "inherits": "~2.0.3", "isarray": "~1.0.0", "process-nextick-args": "~2.0.0", "safe-buffer": "~5.1.1", "string_decoder": "~1.1.1", "util-deprecate": "~1.0.1" } }, "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA=="],
"string_decoder/safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="],
"tsx/esbuild": ["esbuild@0.28.0", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.28.0", "@esbuild/android-arm": "0.28.0", "@esbuild/android-arm64": "0.28.0", "@esbuild/android-x64": "0.28.0", "@esbuild/darwin-arm64": "0.28.0", "@esbuild/darwin-x64": "0.28.0", "@esbuild/freebsd-arm64": "0.28.0", "@esbuild/freebsd-x64": "0.28.0", "@esbuild/linux-arm": "0.28.0", "@esbuild/linux-arm64": "0.28.0", "@esbuild/linux-ia32": "0.28.0", "@esbuild/linux-loong64": "0.28.0", "@esbuild/linux-mips64el": "0.28.0", "@esbuild/linux-ppc64": "0.28.0", "@esbuild/linux-riscv64": "0.28.0", "@esbuild/linux-s390x": "0.28.0", "@esbuild/linux-x64": "0.28.0", "@esbuild/netbsd-arm64": "0.28.0", "@esbuild/netbsd-x64": "0.28.0", "@esbuild/openbsd-arm64": "0.28.0", "@esbuild/openbsd-x64": "0.28.0", "@esbuild/openharmony-arm64": "0.28.0", "@esbuild/sunos-x64": "0.28.0", "@esbuild/win32-arm64": "0.28.0", "@esbuild/win32-ia32": "0.28.0", "@esbuild/win32-x64": "0.28.0" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-sNR9MHpXSUV/XB4zmsFKN+QgVG82Cc7+/aaxJ8Adi8hyOac+EXptIp45QBPaVyX3N70664wRbTcLTOemCAnyqw=="],
"unzipper/readable-stream": ["readable-stream@2.3.8", "", { "dependencies": { "core-util-is": "~1.0.0", "inherits": "~2.0.3", "isarray": "~1.0.0", "process-nextick-args": "~2.0.0", "safe-buffer": "~5.1.1", "string_decoder": "~1.1.1", "util-deprecate": "~1.0.1" } }, "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA=="],
"zip-stream/archiver-utils": ["archiver-utils@3.0.4", "", { "dependencies": { "glob": "^7.2.3", "graceful-fs": "^4.2.0", "lazystream": "^1.0.0", "lodash.defaults": "^4.2.0", "lodash.difference": "^4.5.0", "lodash.flatten": "^4.4.0", "lodash.isplainobject": "^4.0.6", "lodash.union": "^4.6.0", "normalize-path": "^3.0.0", "readable-stream": "^3.6.0" } }, "sha512-KVgf4XQVrTjhyWmx6cte4RxonPLR9onExufI1jhvw/MQ4BB6IsZD5gT8Lq+u/+pRkWna/6JoHpiQioaqFP5Rzw=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.18.20", "", { "os": "android", "cpu": "arm" }, "sha512-fyi7TDI/ijKKNZTUJAQqiG5T7YjJXgnzkURqmGj13C6dCqckZBLdl4h7bkhHt/t0WP+zO9/zwroDvANaOqO5Sw=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.18.20", "", { "os": "android", "cpu": "arm64" }, "sha512-Nz4rJcchGDtENV0eMKUNa6L12zz2zBDXuhj/Vjh18zGqB44Bi7MBMSXjgunJgjRhCmKOjnPuZp4Mb6OKqtMHLQ=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/android-x64": ["@esbuild/android-x64@0.18.20", "", { "os": "android", "cpu": "x64" }, "sha512-8GDdlePJA8D6zlZYJV/jnrRAi6rOiNaCC/JclcXpB+KIuvfBN4owLtgzY2bsxnx666XjJx2kDPUmnTtR8qKQUg=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.18.20", "", { "os": "darwin", "cpu": "arm64" }, "sha512-bxRHW5kHU38zS2lPTPOyuyTm+S+eobPUnTNkdJEfAddYgEcll4xkT8DB9d2008DtTbl7uJag2HuE5NZAZgnNEA=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.18.20", "", { "os": "darwin", "cpu": "x64" }, "sha512-pc5gxlMDxzm513qPGbCbDukOdsGtKhfxD1zJKXjCCcU7ju50O7MeAZ8c4krSJcOIJGFR+qx21yMMVYwiQvyTyQ=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.18.20", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-yqDQHy4QHevpMAaxhhIwYPMv1NECwOvIpGCZkECn8w2WFHXjEwrBn3CeNIYsibZ/iZEUemj++M26W3cNR5h+Tw=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.18.20", "", { "os": "freebsd", "cpu": "x64" }, "sha512-tgWRPPuQsd3RmBZwarGVHZQvtzfEBOreNuxEMKFcd5DaDn2PbBxfwLcj4+aenoh7ctXcbXmOQIn8HI6mCSw5MQ=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/linux-arm": ["@esbuild/linux-arm@0.18.20", "", { "os": "linux", "cpu": "arm" }, "sha512-/5bHkMWnq1EgKr1V+Ybz3s1hWXok7mDFUMQ4cG10AfW3wL02PSZi5kFpYKrptDsgb2WAJIvRcDm+qIvXf/apvg=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.18.20", "", { "os": "linux", "cpu": "arm64" }, "sha512-2YbscF+UL7SQAVIpnWvYwM+3LskyDmPhe31pE7/aoTMFKKzIc9lLbyGUpmmb8a8AixOL61sQ/mFh3jEjHYFvdA=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.18.20", "", { "os": "linux", "cpu": "ia32" }, "sha512-P4etWwq6IsReT0E1KHU40bOnzMHoH73aXp96Fs8TIT6z9Hu8G6+0SHSw9i2isWrD2nbx2qo5yUqACgdfVGx7TA=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.18.20", "", { "os": "linux", "cpu": "none" }, "sha512-nXW8nqBTrOpDLPgPY9uV+/1DjxoQ7DoB2N8eocyq8I9XuqJ7BiAMDMf9n1xZM9TgW0J8zrquIb/A7s3BJv7rjg=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.18.20", "", { "os": "linux", "cpu": "none" }, "sha512-d5NeaXZcHp8PzYy5VnXV3VSd2D328Zb+9dEq5HE6bw6+N86JVPExrA6O68OPwobntbNJ0pzCpUFZTo3w0GyetQ=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.18.20", "", { "os": "linux", "cpu": "ppc64" }, "sha512-WHPyeScRNcmANnLQkq6AfyXRFr5D6N2sKgkFo2FqguP44Nw2eyDlbTdZwd9GYk98DZG9QItIiTlFLHJHjxP3FA=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.18.20", "", { "os": "linux", "cpu": "none" }, "sha512-WSxo6h5ecI5XH34KC7w5veNnKkju3zBRLEQNY7mv5mtBmrP/MjNBCAlsM2u5hDBlS3NGcTQpoBvRzqBcRtpq1A=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.18.20", "", { "os": "linux", "cpu": "s390x" }, "sha512-+8231GMs3mAEth6Ja1iK0a1sQ3ohfcpzpRLH8uuc5/KVDFneH6jtAJLFGafpzpMRO6DzJ6AvXKze9LfFMrIHVQ=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/linux-x64": ["@esbuild/linux-x64@0.18.20", "", { "os": "linux", "cpu": "x64" }, "sha512-UYqiqemphJcNsFEskc73jQ7B9jgwjWrSayxawS6UVFZGWrAAtkzjxSqnoclCXxWtfwLdzU+vTpcNYhpn43uP1w=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.18.20", "", { "os": "none", "cpu": "x64" }, "sha512-iO1c++VP6xUBUmltHZoMtCUdPlnPGdBom6IrO4gyKPFFVBKioIImVooR5I83nTew5UOYrk3gIJhbZh8X44y06A=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.18.20", "", { "os": "openbsd", "cpu": "x64" }, "sha512-e5e4YSsuQfX4cxcygw/UCPIEP6wbIL+se3sxPdCiMbFLBWu0eiZOJ7WoD+ptCLrmjZBK1Wk7I6D/I3NglUGOxg=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.18.20", "", { "os": "sunos", "cpu": "x64" }, "sha512-kDbFRFp0YpTQVVrqUd5FTYmWo45zGaXe0X8E1G/LKFC0v8x0vWrhOWSLITcCn63lmZIxfOMXtCfti/RxN/0wnQ=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.18.20", "", { "os": "win32", "cpu": "arm64" }, "sha512-ddYFR6ItYgoaq4v4JmQQaAI5s7npztfV4Ag6NrhiaW0RrnOXqBkgwZLofVTlq1daVTQNhtI5oieTvkRPfZrePg=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.18.20", "", { "os": "win32", "cpu": "ia32" }, "sha512-Wv7QBi3ID/rROT08SABTS7eV4hX26sVduqDOTe1MvGMjNd3EjOz4b7zeexIR62GTIEKrfJXKL9LFxTYgkyeu7g=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.18.20", "", { "os": "win32", "cpu": "x64" }, "sha512-kTdfRcSiDfQca/y9QIkng02avJ+NCaQvrMejlsB3RRv5sE9rRoeBPISaZpKxHELzRxZyLvNts1P27W3wV+8geQ=="],
"archiver-utils/readable-stream/string_decoder": ["string_decoder@1.1.1", "", { "dependencies": { "safe-buffer": "~5.1.0" } }, "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg=="],
"duplexer2/readable-stream/string_decoder": ["string_decoder@1.1.1", "", { "dependencies": { "safe-buffer": "~5.1.0" } }, "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg=="],
"glob/minimatch/brace-expansion": ["brace-expansion@1.1.14", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g=="],
"jszip/readable-stream/string_decoder": ["string_decoder@1.1.1", "", { "dependencies": { "safe-buffer": "~5.1.0" } }, "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg=="],
"lazystream/readable-stream/string_decoder": ["string_decoder@1.1.1", "", { "dependencies": { "safe-buffer": "~5.1.0" } }, "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg=="],
"tsx/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.28.0", "", { "os": "aix", "cpu": "ppc64" }, "sha512-lhRUCeuOyJQURhTxl4WkpFTjIsbDayJHih5kZC1giwE+MhIzAb7mEsQMqMf18rHLsrb5qI1tafG20mLxEWcWlA=="],
"tsx/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.28.0", "", { "os": "android", "cpu": "arm" }, "sha512-wqh0ByljabXLKHeWXYLqoJ5jKC4XBaw6Hk08OfMrCRd2nP2ZQ5eleDZC41XHyCNgktBGYMbqnrJKq/K/lzPMSQ=="],
"tsx/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.28.0", "", { "os": "android", "cpu": "arm64" }, "sha512-+WzIXQOSaGs33tLEgYPYe/yQHf0WTU0X42Jca3y8NWMbUVhp7rUnw+vAsRC/QiDrdD31IszMrZy+qwPOPjd+rw=="],
"tsx/esbuild/@esbuild/android-x64": ["@esbuild/android-x64@0.28.0", "", { "os": "android", "cpu": "x64" }, "sha512-+VJggoaKhk2VNNqVL7f6S189UzShHC/mR9EE8rDdSkdpN0KflSwWY/gWjDrNxxisg8Fp1ZCD9jLMo4m0OUfeUA=="],
"tsx/esbuild/@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.28.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-0T+A9WZm+bZ84nZBtk1ckYsOvyA3x7e2Acj1KdVfV4/2tdG4fzUp91YHx+GArWLtwqp77pBXVCPn2We7Letr0Q=="],
"tsx/esbuild/@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.28.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-fyzLm/DLDl/84OCfp2f/XQ4flmORsjU7VKt8HLjvIXChJoFFOIL6pLJPH4Yhd1n1gGFF9mPwtlN5Wf82DZs+LQ=="],
"tsx/esbuild/@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.28.0", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-l9GeW5UZBT9k9brBYI+0WDffcRxgHQD8ShN2Ur4xWq/NFzUKm3k5lsH4PdaRgb2w7mI9u61nr2gI2mLI27Nh3Q=="],
"tsx/esbuild/@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.28.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-BXoQai/A0wPO6Es3yFJ7APCiKGc1tdAEOgeTNy3SsB491S3aHn4S4r3e976eUnPdU+NbdtmBuLncYir2tMU9Nw=="],
"tsx/esbuild/@esbuild/linux-arm": ["@esbuild/linux-arm@0.28.0", "", { "os": "linux", "cpu": "arm" }, "sha512-CjaaREJagqJp7iTaNQjjidaNbCKYcd4IDkzbwwxtSvjI7NZm79qiHc8HqciMddQ6CKvJT6aBd8lO9kN/ZudLlw=="],
"tsx/esbuild/@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.28.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-RVyzfb3FWsGA55n6WY0MEIEPURL1FcbhFE6BffZEMEekfCzCIMtB5yyDcFnVbTnwk+CLAgTujmV/Lgvih56W+A=="],
"tsx/esbuild/@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.28.0", "", { "os": "linux", "cpu": "ia32" }, "sha512-KBnSTt1kxl9x70q+ydterVdl+Cn0H18ngRMRCEQfrbqdUuntQQ0LoMZv47uB97NljZFzY6HcfqEZ2SAyIUTQBQ=="],
"tsx/esbuild/@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.28.0", "", { "os": "linux", "cpu": "none" }, "sha512-zpSlUce1mnxzgBADvxKXX5sl8aYQHo2ezvMNI8I0lbblJtp8V4odlm3Yzlj7gPyt3T8ReksE6bK+pT3WD+aJRg=="],
"tsx/esbuild/@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.28.0", "", { "os": "linux", "cpu": "none" }, "sha512-2jIfP6mmjkdmeTlsX/9vmdmhBmKADrWqN7zcdtHIeNSCH1SqIoNI63cYsjQR8J+wGa4Y5izRcSHSm8K3QWmk3w=="],
"tsx/esbuild/@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.28.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-bc0FE9wWeC0WBm49IQMPSPILRocGTQt3j5KPCA8os6VprfuJ7KD+5PzESSrJ6GmPIPJK965ZJHTUlSA6GNYEhg=="],
"tsx/esbuild/@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.28.0", "", { "os": "linux", "cpu": "none" }, "sha512-SQPZOwoTTT/HXFXQJG/vBX8sOFagGqvZyXcgLA3NhIqcBv1BJU1d46c0rGcrij2B56Z2rNiSLaZOYW5cUk7yLQ=="],
"tsx/esbuild/@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.28.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-SCfR0HN8CEEjnYnySJTd2cw0k9OHB/YFzt5zgJEwa+wL/T/raGWYMBqwDNAC6dqFKmJYZoQBRfHjgwLHGSrn3Q=="],
"tsx/esbuild/@esbuild/linux-x64": ["@esbuild/linux-x64@0.28.0", "", { "os": "linux", "cpu": "x64" }, "sha512-us0dSb9iFxIi8srnpl931Nvs65it/Jd2a2K3qs7fz2WfGPHqzfzZTfec7oxZJRNPXPnNYZtanmRc4AL/JwVzHQ=="],
"tsx/esbuild/@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.28.0", "", { "os": "none", "cpu": "arm64" }, "sha512-CR/RYotgtCKwtftMwJlUU7xCVNg3lMYZ0RzTmAHSfLCXw3NtZtNpswLEj/Kkf6kEL3Gw+BpOekRX0BYCtklhUw=="],
"tsx/esbuild/@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.28.0", "", { "os": "none", "cpu": "x64" }, "sha512-nU1yhmYutL+fQ71Kxnhg8uEOdC0pwEW9entHykTgEbna2pw2dkbFSMeqjjyHZoCmt8SBkOSvV+yNmm94aUrrqw=="],
"tsx/esbuild/@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.28.0", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-cXb5vApOsRsxsEl4mcZ1XY3D4DzcoMxR/nnc4IyqYs0rTI8ZKmW6kyyg+11Z8yvgMfAEldKzP7AdP64HnSC/6g=="],
"tsx/esbuild/@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.28.0", "", { "os": "openbsd", "cpu": "x64" }, "sha512-8wZM2qqtv9UP3mzy7HiGYNH/zjTA355mpeuA+859TyR+e+Tc08IHYpLJuMsfpDJwoLo1ikIJI8jC3GFjnRClzA=="],
"tsx/esbuild/@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.28.0", "", { "os": "none", "cpu": "arm64" }, "sha512-FLGfyizszcef5C3YtoyQDACyg95+dndv79i2EekILBofh5wpCa1KuBqOWKrEHZg3zrL3t5ouE5jgr94vA+Wb2w=="],
"tsx/esbuild/@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.28.0", "", { "os": "sunos", "cpu": "x64" }, "sha512-1ZgjUoEdHZZl/YlV76TSCz9Hqj9h9YmMGAgAPYd+q4SicWNX3G5GCyx9uhQWSLcbvPW8Ni7lj4gDa1T40akdlw=="],
"tsx/esbuild/@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.28.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-Q9StnDmQ/enxnpxCCLSg0oo4+34B9TdXpuyPeTedN/6+iXBJ4J+zwfQI28u/Jl40nOYAxGoNi7mFP40RUtkmUA=="],
"tsx/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.28.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-zF3ag/gfiCe6U2iczcRzSYJKH1DCI+ByzSENHlM2FcDbEeo5Zd2C86Aq0tKUYAJJ1obRP84ymxIAksZUcdztHA=="],
"tsx/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.28.0", "", { "os": "win32", "cpu": "x64" }, "sha512-pEl1bO9mfAmIC+tW5btTmrKaujg3zGtUmWNdCw/xs70FBjwAL3o9OEKNHvNmnyylD6ubxUERiEhdsL0xBQ9efw=="],
"unzipper/readable-stream/string_decoder": ["string_decoder@1.1.1", "", { "dependencies": { "safe-buffer": "~5.1.0" } }, "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg=="],
} }
} }

View File

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

35
docker-compose.yaml Normal file
View File

@@ -0,0 +1,35 @@
services:
postgres:
image: postgres:16-alpine
restart: unless-stopped
environment:
POSTGRES_DB: asin_check
POSTGRES_USER: asin_check
POSTGRES_PASSWORD: asin_check
ports:
- "5432:5432"
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}"]
interval: 5s
timeout: 5s
retries: 10
redis:
image: redis:7-alpine
restart: unless-stopped
command: ["redis-server", "--appendonly", "yes"]
ports:
- "6379:6379"
volumes:
- redis_data:/data
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 5s
timeout: 5s
retries: 10
volumes:
postgres_data:
redis_data:

10
drizzle.config.ts Normal file
View File

@@ -0,0 +1,10 @@
import { defineConfig } from "drizzle-kit";
export default defineConfig({
schema: "./src/db/schema.ts",
out: "./drizzle",
dialect: "postgresql",
dbCredentials: {
url: process.env.DB_CONNECTION_STRING!,
},
});

View File

@@ -0,0 +1,269 @@
CREATE TYPE "public"."analysis_decision" AS ENUM('FBA', 'FBM', 'BUY', 'WATCH', 'SKIP');--> statement-breakpoint
CREATE TYPE "public"."analysis_method" AS ENUM('llm', 'supplier_scoring');--> statement-breakpoint
CREATE TYPE "public"."run_status" AS ENUM('running', 'ok', 'empty', 'failed', 'completed');--> statement-breakpoint
CREATE TYPE "public"."run_type" AS ENUM('lead_analysis', 'category_analysis', 'supplier_upc', 'stalker', 'stalker_analysis');--> statement-breakpoint
CREATE TABLE "analysis_revisions" (
"id" serial PRIMARY KEY NOT NULL,
"run_item_id" integer NOT NULL,
"observation_id" integer,
"method" "analysis_method" NOT NULL,
"decision" "analysis_decision" NOT NULL,
"confidence" real,
"reasoning" text,
"analyzed_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "analysis_run_stats" (
"run_id" integer PRIMARY KEY NOT NULL,
"processed_count" integer DEFAULT 0 NOT NULL,
"analyzed_count" integer DEFAULT 0 NOT NULL,
"available_count" integer DEFAULT 0 NOT NULL,
"fba_count" integer DEFAULT 0 NOT NULL,
"fbm_count" integer DEFAULT 0 NOT NULL,
"buy_count" integer DEFAULT 0 NOT NULL,
"watch_count" integer DEFAULT 0 NOT NULL,
"skip_count" integer DEFAULT 0 NOT NULL
);
--> statement-breakpoint
CREATE TABLE "category_run_details" (
"run_id" integer PRIMARY KEY NOT NULL,
"category_id" integer NOT NULL,
"category_label" text NOT NULL,
"checked_asin_count" integer DEFAULT 0 NOT NULL,
"selection_parameters_json" text
);
--> statement-breakpoint
CREATE TABLE "product_identifiers" (
"id" serial PRIMARY KEY NOT NULL,
"product_asin" text NOT NULL,
"identifier_type" text NOT NULL,
"identifier_value" text NOT NULL,
"source" text NOT NULL,
"confirmed_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "uq_product_identifier_type_value" UNIQUE("identifier_type","identifier_value")
);
--> statement-breakpoint
CREATE TABLE "product_observations" (
"id" serial PRIMARY KEY NOT NULL,
"product_asin" text NOT NULL,
"run_id" integer NOT NULL,
"source" text NOT NULL,
"marketplace" text DEFAULT 'US' NOT NULL,
"current_price" real,
"avg_price_90d" real,
"sales_rank" integer,
"sales_rank_avg_90d" integer,
"monthly_sold" integer,
"rank_drops_30d" integer,
"rank_drops_90d" integer,
"seller_count" integer,
"amazon_is_seller" boolean,
"amazon_buybox_share_pct_90d" real,
"fba_fee" real,
"fbm_fee" real,
"referral_percent" real,
"can_sell" boolean,
"sellability_status" text,
"sellability_reason" text,
"raw_product_json" text,
"fetched_at" timestamp with time zone NOT NULL
);
--> statement-breakpoint
CREATE TABLE "products" (
"asin" text PRIMARY KEY NOT NULL,
"name" text,
"brand" text,
"category" text,
"metadata_fetched_at" timestamp with time zone,
"first_seen_at" timestamp with time zone DEFAULT now() NOT NULL,
"last_seen_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "ck_products_asin" CHECK ("products"."asin" ~ '^[A-Z0-9]{10}$')
);
--> statement-breakpoint
CREATE TABLE "run_items" (
"id" serial PRIMARY KEY NOT NULL,
"run_id" integer NOT NULL,
"product_asin" text,
"source_inventory_item_id" integer,
"ordinal" integer,
"source_row" integer,
"status" text DEFAULT 'completed' NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "runs" (
"id" serial PRIMARY KEY NOT NULL,
"type" "run_type" NOT NULL,
"parent_run_id" integer,
"input_file" text,
"output_file" text,
"status" "run_status" DEFAULT 'running' NOT NULL,
"error_message" text,
"started_at" timestamp with time zone DEFAULT now() NOT NULL,
"completed_at" timestamp with time zone
);
--> statement-breakpoint
CREATE TABLE "sellers" (
"seller_id" text PRIMARY KEY NOT NULL,
"seller_name" text,
"rating" real,
"rating_count" integer,
"storefront_asin_total" integer,
"persisted_inventory_sample_count" integer,
"last_updated_at" timestamp with time zone NOT NULL,
"raw_seller_json" text
);
--> statement-breakpoint
CREATE TABLE "sourcing_inputs" (
"run_item_id" integer PRIMARY KEY NOT NULL,
"supplied_name" text,
"supplied_brand" text,
"supplied_category" text,
"unit_cost" real,
"avg_price_90d_sheet" real,
"selling_price_sheet" real,
"fba_net_sheet" real,
"gross_profit_dollar" real,
"gross_profit_pct" real,
"net_profit_sheet" real,
"roi_sheet" real,
"moq" integer,
"moq_cost" real,
"qty_available" integer,
"supplier" text,
"source_url" text,
"asin_link" text,
"promo_coupon_code" text,
"notes" text,
"lead_date" text
);
--> statement-breakpoint
CREATE TABLE "stalker_inventory_items" (
"id" serial PRIMARY KEY NOT NULL,
"run_id" integer NOT NULL,
"seller_id" text NOT NULL,
"product_asin" text NOT NULL,
"observation_id" integer NOT NULL,
"last_seen_at" timestamp with time zone NOT NULL,
"raw_inventory_json" text,
CONSTRAINT "uq_stalker_inventory_items_run_seller_asin" UNIQUE("run_id","seller_id","product_asin")
);
--> statement-breakpoint
CREATE TABLE "stalker_run_details" (
"run_id" integer PRIMARY KEY NOT NULL,
"requested_asins" integer DEFAULT 0 NOT NULL,
"skipped_asins" integer DEFAULT 0 NOT NULL,
"scanned_asins" integer DEFAULT 0 NOT NULL,
"source_asins_with_matches" integer DEFAULT 0 NOT NULL,
"candidate_sellers" integer DEFAULT 0 NOT NULL,
"qualifying_sellers" integer DEFAULT 0 NOT NULL,
"matched_sellers" integer DEFAULT 0 NOT NULL,
"seller_metadata_requests" integer DEFAULT 0 NOT NULL,
"seller_storefront_requests" integer DEFAULT 0 NOT NULL,
"inventory_sellability_checked_asins" integer DEFAULT 0 NOT NULL,
"inventory_sellability_available_asins" integer DEFAULT 0 NOT NULL,
"inventory_sellability_excluded_asins" integer DEFAULT 0 NOT NULL,
"persisted_inventory_asins" integer DEFAULT 0 NOT NULL
);
--> statement-breakpoint
CREATE TABLE "stalker_scan_sellers" (
"id" serial PRIMARY KEY NOT NULL,
"scan_id" integer NOT NULL,
"seller_id" text NOT NULL,
"offer_price" real,
"condition" text,
"is_fba" boolean,
"stock" integer,
"seller_rating" real,
"seller_rating_count" integer,
"raw_offer_json" text,
CONSTRAINT "uq_stalker_scan_sellers_scan_seller" UNIQUE("scan_id","seller_id")
);
--> statement-breakpoint
CREATE TABLE "stalker_scans" (
"id" serial PRIMARY KEY NOT NULL,
"run_id" integer NOT NULL,
"source_product_asin" text NOT NULL,
"observation_id" integer,
"offer_count" integer DEFAULT 0 NOT NULL,
"candidate_seller_count" integer DEFAULT 0 NOT NULL,
"matched_seller_count" integer DEFAULT 0 NOT NULL,
"fetched_at" timestamp with time zone NOT NULL,
CONSTRAINT "uq_stalker_scans_run_source_product" UNIQUE("run_id","source_product_asin")
);
--> statement-breakpoint
CREATE TABLE "supplier_scores" (
"revision_id" integer PRIMARY KEY NOT NULL,
"score" real,
"sale_price" real,
"fba_fee" real,
"profit" real,
"margin" real,
"roi" real,
"reason" text
);
--> statement-breakpoint
CREATE TABLE "upc_resolution_candidates" (
"run_item_id" integer NOT NULL,
"product_asin" text NOT NULL,
CONSTRAINT "upc_resolution_candidates_run_item_id_product_asin_pk" PRIMARY KEY("run_item_id","product_asin")
);
--> statement-breakpoint
CREATE TABLE "upc_resolutions" (
"run_item_id" integer PRIMARY KEY NOT NULL,
"requested_upc" text NOT NULL,
"normalized_upc" text NOT NULL,
"provider" text NOT NULL,
"status" text NOT NULL,
"reason" text,
"resolved_product_asin" text,
"resolved_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
ALTER TABLE "analysis_revisions" ADD CONSTRAINT "analysis_revisions_run_item_id_run_items_id_fk" FOREIGN KEY ("run_item_id") REFERENCES "public"."run_items"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "analysis_revisions" ADD CONSTRAINT "analysis_revisions_observation_id_product_observations_id_fk" FOREIGN KEY ("observation_id") REFERENCES "public"."product_observations"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "analysis_run_stats" ADD CONSTRAINT "analysis_run_stats_run_id_runs_id_fk" FOREIGN KEY ("run_id") REFERENCES "public"."runs"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "category_run_details" ADD CONSTRAINT "category_run_details_run_id_runs_id_fk" FOREIGN KEY ("run_id") REFERENCES "public"."runs"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "product_identifiers" ADD CONSTRAINT "product_identifiers_product_asin_products_asin_fk" FOREIGN KEY ("product_asin") REFERENCES "public"."products"("asin") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "product_observations" ADD CONSTRAINT "product_observations_product_asin_products_asin_fk" FOREIGN KEY ("product_asin") REFERENCES "public"."products"("asin") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "product_observations" ADD CONSTRAINT "product_observations_run_id_runs_id_fk" FOREIGN KEY ("run_id") REFERENCES "public"."runs"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "run_items" ADD CONSTRAINT "run_items_run_id_runs_id_fk" FOREIGN KEY ("run_id") REFERENCES "public"."runs"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "run_items" ADD CONSTRAINT "run_items_product_asin_products_asin_fk" FOREIGN KEY ("product_asin") REFERENCES "public"."products"("asin") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "run_items" ADD CONSTRAINT "run_items_source_inventory_item_id_stalker_inventory_items_id_fk" FOREIGN KEY ("source_inventory_item_id") REFERENCES "public"."stalker_inventory_items"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "runs" ADD CONSTRAINT "runs_parent_run_id_runs_id_fk" FOREIGN KEY ("parent_run_id") REFERENCES "public"."runs"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "sourcing_inputs" ADD CONSTRAINT "sourcing_inputs_run_item_id_run_items_id_fk" FOREIGN KEY ("run_item_id") REFERENCES "public"."run_items"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "stalker_inventory_items" ADD CONSTRAINT "stalker_inventory_items_run_id_runs_id_fk" FOREIGN KEY ("run_id") REFERENCES "public"."runs"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "stalker_inventory_items" ADD CONSTRAINT "stalker_inventory_items_seller_id_sellers_seller_id_fk" FOREIGN KEY ("seller_id") REFERENCES "public"."sellers"("seller_id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "stalker_inventory_items" ADD CONSTRAINT "stalker_inventory_items_product_asin_products_asin_fk" FOREIGN KEY ("product_asin") REFERENCES "public"."products"("asin") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "stalker_inventory_items" ADD CONSTRAINT "stalker_inventory_items_observation_id_product_observations_id_fk" FOREIGN KEY ("observation_id") REFERENCES "public"."product_observations"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "stalker_run_details" ADD CONSTRAINT "stalker_run_details_run_id_runs_id_fk" FOREIGN KEY ("run_id") REFERENCES "public"."runs"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "stalker_scan_sellers" ADD CONSTRAINT "stalker_scan_sellers_scan_id_stalker_scans_id_fk" FOREIGN KEY ("scan_id") REFERENCES "public"."stalker_scans"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "stalker_scan_sellers" ADD CONSTRAINT "stalker_scan_sellers_seller_id_sellers_seller_id_fk" FOREIGN KEY ("seller_id") REFERENCES "public"."sellers"("seller_id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "stalker_scans" ADD CONSTRAINT "stalker_scans_run_id_runs_id_fk" FOREIGN KEY ("run_id") REFERENCES "public"."runs"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "stalker_scans" ADD CONSTRAINT "stalker_scans_source_product_asin_products_asin_fk" FOREIGN KEY ("source_product_asin") REFERENCES "public"."products"("asin") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "stalker_scans" ADD CONSTRAINT "stalker_scans_observation_id_product_observations_id_fk" FOREIGN KEY ("observation_id") REFERENCES "public"."product_observations"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "supplier_scores" ADD CONSTRAINT "supplier_scores_revision_id_analysis_revisions_id_fk" FOREIGN KEY ("revision_id") REFERENCES "public"."analysis_revisions"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "upc_resolution_candidates" ADD CONSTRAINT "upc_resolution_candidates_run_item_id_upc_resolutions_run_item_id_fk" FOREIGN KEY ("run_item_id") REFERENCES "public"."upc_resolutions"("run_item_id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "upc_resolution_candidates" ADD CONSTRAINT "upc_resolution_candidates_product_asin_products_asin_fk" FOREIGN KEY ("product_asin") REFERENCES "public"."products"("asin") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "upc_resolutions" ADD CONSTRAINT "upc_resolutions_run_item_id_run_items_id_fk" FOREIGN KEY ("run_item_id") REFERENCES "public"."run_items"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "upc_resolutions" ADD CONSTRAINT "upc_resolutions_resolved_product_asin_products_asin_fk" FOREIGN KEY ("resolved_product_asin") REFERENCES "public"."products"("asin") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "idx_analysis_revisions_run_item_time" ON "analysis_revisions" USING btree ("run_item_id","analyzed_at");--> statement-breakpoint
CREATE INDEX "idx_analysis_revisions_decision" ON "analysis_revisions" USING btree ("decision");--> statement-breakpoint
CREATE INDEX "idx_product_identifiers_asin" ON "product_identifiers" USING btree ("product_asin");--> statement-breakpoint
CREATE INDEX "idx_product_observations_product_time" ON "product_observations" USING btree ("product_asin","fetched_at" DESC NULLS LAST);--> statement-breakpoint
CREATE INDEX "idx_product_observations_run_id" ON "product_observations" USING btree ("run_id");--> statement-breakpoint
CREATE INDEX "idx_product_observations_sellability" ON "product_observations" USING btree ("sellability_status");--> statement-breakpoint
CREATE INDEX "idx_products_name" ON "products" USING btree ("name");--> statement-breakpoint
CREATE INDEX "idx_products_last_seen_at" ON "products" USING btree ("last_seen_at");--> statement-breakpoint
CREATE INDEX "idx_run_items_run_id" ON "run_items" USING btree ("run_id");--> statement-breakpoint
CREATE INDEX "idx_run_items_product_asin" ON "run_items" USING btree ("product_asin");--> statement-breakpoint
CREATE INDEX "idx_runs_started_at" ON "runs" USING btree ("started_at");--> statement-breakpoint
CREATE INDEX "idx_runs_type" ON "runs" USING btree ("type");--> statement-breakpoint
CREATE INDEX "idx_runs_status" ON "runs" USING btree ("status");--> statement-breakpoint
CREATE INDEX "idx_runs_parent_run_id" ON "runs" USING btree ("parent_run_id");--> statement-breakpoint
CREATE INDEX "idx_stalker_inventory_seller_id" ON "stalker_inventory_items" USING btree ("seller_id");--> statement-breakpoint
CREATE INDEX "idx_stalker_inventory_product_asin" ON "stalker_inventory_items" USING btree ("product_asin");--> statement-breakpoint
CREATE INDEX "idx_stalker_scans_run_id" ON "stalker_scans" USING btree ("run_id");--> statement-breakpoint
CREATE INDEX "idx_stalker_scans_source_asin" ON "stalker_scans" USING btree ("source_product_asin");--> statement-breakpoint
CREATE INDEX "idx_upc_candidates_product_asin" ON "upc_resolution_candidates" USING btree ("product_asin");--> statement-breakpoint
CREATE INDEX "idx_upc_resolutions_normalized_upc" ON "upc_resolutions" USING btree ("normalized_upc");

View File

@@ -0,0 +1,23 @@
CREATE TABLE "product_distributor_research" (
"id" serial PRIMARY KEY NOT NULL,
"product_asin" text NOT NULL,
"run_item_id" integer,
"inventory_item_id" integer,
"provider" text DEFAULT 'claude' NOT NULL,
"model" text NOT NULL,
"status" text DEFAULT 'completed' NOT NULL,
"query_context_json" text,
"distributors_json" text,
"raw_response" text,
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
ALTER TABLE "product_distributor_research" ADD CONSTRAINT "product_distributor_research_product_asin_products_asin_fk" FOREIGN KEY ("product_asin") REFERENCES "public"."products"("asin") ON DELETE cascade ON UPDATE no action;
--> statement-breakpoint
ALTER TABLE "product_distributor_research" ADD CONSTRAINT "product_distributor_research_run_item_id_run_items_id_fk" FOREIGN KEY ("run_item_id") REFERENCES "public"."run_items"("id") ON DELETE set null ON UPDATE no action;
--> statement-breakpoint
ALTER TABLE "product_distributor_research" ADD CONSTRAINT "product_distributor_research_inventory_item_id_stalker_inventory_items_id_fk" FOREIGN KEY ("inventory_item_id") REFERENCES "public"."stalker_inventory_items"("id") ON DELETE set null ON UPDATE no action;
--> statement-breakpoint
CREATE INDEX "idx_distributor_research_asin_time" ON "product_distributor_research" USING btree ("product_asin","created_at");
--> statement-breakpoint
CREATE INDEX "idx_distributor_research_run_item" ON "product_distributor_research" USING btree ("run_item_id");

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,20 @@
{
"version": "7",
"dialect": "postgresql",
"entries": [
{
"idx": 0,
"version": "7",
"when": 1779726518779,
"tag": "0000_adorable_shiver_man",
"breakpoints": true
},
{
"idx": 1,
"version": "7",
"when": 1780000000000,
"tag": "0001_product_distributor_research",
"breakpoints": true
}
]
}

View File

@@ -4,24 +4,33 @@
"type": "module", "type": "module",
"private": true, "private": true,
"scripts": { "scripts": {
"bestsellers": "bun run src/bestsellers-by-category.ts", "bestsellers": "bun run src/categories/bestsellers-by-category.ts",
"monthly-sold": "bun run src/top-monthly-sold-by-category.ts", "monthly-sold": "bun run src/categories/top-monthly-sold-by-category.ts",
"mid-range": "bun run src/categories/mid-range-sellers-by-category.ts",
"stalker": "bun run src/stalker/stalker.ts",
"search-offers": "bun run src/asin-offer-search.ts",
"upc": "bun run src/supplier/upc-lookup.ts",
"upc-file": "bun run src/supplier/upc-file-analysis.ts",
"start": "bun run src/index.ts", "start": "bun run src/index.ts",
"start:web": "bun --hot src/server.ts", "start:web": "bun --hot src/server.ts",
"build:web": "bun build src/web/index.html --outdir dist", "build:web": "bun build src/web/index.html --outdir dist",
"test": "bun test" "test": "bun test",
"db:generate": "drizzle-kit generate",
"db:migrate": "drizzle-kit migrate"
}, },
"devDependencies": { "devDependencies": {
"@types/bun": "latest", "@types/bun": "latest",
"@types/react": "^19.2.14", "@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3" "@types/react-dom": "^19.2.3",
}, "drizzle-kit": "^0.31.10",
"peerDependencies": { "typescript": "^6.0.3"
"typescript": "^5"
}, },
"dependencies": { "dependencies": {
"amazon-sp-api": "^1.2.1", "amazon-sp-api": "^1.2.1",
"drizzle-orm": "^0.45.2",
"exceljs": "^4.4.0",
"ioredis": "^5.10.1", "ioredis": "^5.10.1",
"postgres": "^3.4.9",
"react": "^19.2.0", "react": "^19.2.0",
"react-dom": "^19.2.0", "react-dom": "^19.2.0",
"xlsx": "^0.18.5" "xlsx": "^0.18.5"

View File

@@ -0,0 +1,114 @@
import { beforeEach, expect, mock, test } from "bun:test";
import { processProductChunk } from "./analysis-pipeline.ts";
import type { ProductRecord } from "./types.ts";
const fetchKeepaDataBatchMock = mock(async (asins: string[]) => {
return new Map(
asins.map((asin) => [
asin,
{
currentPrice: 20,
avgPrice90: 18,
minPrice90: null,
maxPrice90: null,
salesRank: 100,
salesRankAvg90: null,
salesRankDrops30: null,
salesRankDrops90: null,
sellerCount: 3,
amazonIsSeller: false,
amazonBuyboxSharePct90d: null,
buyBoxSeller: null,
buyBoxPrice: null,
monthlySold: 50,
categoryTree: [],
},
]),
);
});
const fetchSellabilityBatchMock = mock(async (asins: string[]) => {
return new Map(
asins.map((asin) => [
asin,
asin === "B000000002"
? {
canSell: false,
sellabilityStatus: "restricted" as const,
sellabilityReason: "Approval required",
}
: {
canSell: true,
sellabilityStatus: "available" as const,
sellabilityReason: "Available",
},
]),
);
});
const fetchSpApiPricingAndFeesMock = mock(async () => ({
fbaFee: 4,
fbmFee: 2,
referralFeePercent: 15,
estimatedSalePrice: 20,
canSell: true,
sellabilityStatus: "available" as const,
sellabilityReason: "Available",
}));
const analyzeProductsMock = mock(async (products: any[]) =>
products.map((product) => ({
asin: product.record.asin,
verdict: "FBA" as const,
confidence: 95,
reasoning: "Analyzed",
})),
);
const getCacheMock = mock(async () => null);
const setCacheMock = mock(async () => undefined);
beforeEach(() => {
fetchKeepaDataBatchMock.mockClear();
fetchSellabilityBatchMock.mockClear();
fetchSpApiPricingAndFeesMock.mockClear();
analyzeProductsMock.mockClear();
getCacheMock.mockClear();
setCacheMock.mockClear();
});
test("lead analysis retains restricted input rows as SKIP without LLM analysis", async () => {
const products: ProductRecord[] = [
{ asin: "B000000001", name: "Available", unitCost: 5 },
{ asin: "B000000002", name: "Restricted", unitCost: 6 },
];
const results = await processProductChunk(products, {
llmBatchDelayMs: 0,
llmRetryDelayMs: 0,
dependencies: {
fetchKeepaDataBatch: fetchKeepaDataBatchMock,
fetchSellabilityBatch: fetchSellabilityBatchMock,
fetchSpApiPricingAndFees: fetchSpApiPricingAndFeesMock,
analyzeProducts: analyzeProductsMock,
getCache: getCacheMock,
setCache: setCacheMock,
},
});
expect(results).toHaveLength(2);
expect(results.map((result) => result.product.record.asin)).toEqual([
"B000000001",
"B000000002",
]);
expect(results.find((result) => result.product.record.asin === "B000000002")?.verdict)
.toEqual({
asin: "B000000002",
verdict: "SKIP",
confidence: 100,
reasoning: "Approval required",
});
expect(fetchKeepaDataBatchMock.mock.calls[0]?.[0]).toEqual(["B000000001"]);
expect(fetchSpApiPricingAndFeesMock.mock.calls).toHaveLength(1);
expect(analyzeProductsMock.mock.calls[0]?.[0]).toHaveLength(1);
});

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

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

134
src/asin-offer-search.ts Normal file
View File

@@ -0,0 +1,134 @@
import { searchProductOffers, type SearxngOfferSearchResult } from "./integrations/searxng.ts";
type CliArgs = {
query: string;
json: boolean;
provider?: "serpapi" | "google-custom-search" | "searxng";
categories?: string;
engines?: string;
limit?: number;
};
function readFlagValue(args: string[], flag: string): string | undefined {
const equalsArg = args.find((arg) => arg.startsWith(`${flag}=`));
if (equalsArg) return equalsArg.slice(flag.length + 1);
const index = args.indexOf(flag);
return index === -1 ? undefined : args[index + 1];
}
function parseArgs(args: string[]): CliArgs {
const json = args.includes("--json");
const shopping = args.includes("--shopping");
const providerRaw = readFlagValue(args, "--provider");
const engineRaw = readFlagValue(args, "--engine");
const categoryRaw = readFlagValue(args, "--category");
const limitRaw = readFlagValue(args, "--limit");
const limit = limitRaw == null ? undefined : Number(limitRaw);
const categories = categoryRaw ?? (shopping ? "shopping" : undefined);
const provider = normalizeProvider(providerRaw);
const queryParts = args.filter((arg, index) => {
if (arg.startsWith("--")) return false;
const previous = args[index - 1];
return (
previous !== "--limit" &&
previous !== "--category" &&
previous !== "--engine" &&
previous !== "--provider"
);
});
const query = queryParts.join(" ").trim();
if (!query) {
console.error(
'Usage: bun run search-offers "product search terms" [--limit 10] [--provider serpapi|google-custom-search|searxng] [--json]',
);
process.exit(1);
}
if (
limitRaw != null &&
(limit == null || !Number.isInteger(limit) || limit <= 0)
) {
console.error("--limit must be a positive integer.");
process.exit(1);
}
return {
query,
json,
provider,
categories,
engines: engineRaw,
limit,
};
}
function printTable(results: SearxngOfferSearchResult[]): void {
if (results.length === 0) {
console.log("No offer results found.");
return;
}
console.table(
results.map((result) => ({
Rank: result.rank,
Score: result.score,
ASIN: result.matchedAsin ?? "",
Price: formatPrice(result),
"Price Label": result.detectedPriceLabel ?? "",
Domain: result.domain,
Title: result.title,
URL: result.url,
})),
);
}
async function main(): Promise<void> {
const args = parseArgs(process.argv.slice(2));
const options = {
maxResults: args.limit,
provider: args.provider,
categories: args.categories,
engines: args.engines,
};
const results = await searchProductOffers(args.query, options);
if (args.json) {
console.log(JSON.stringify(results, null, 2));
return;
}
printTable(results);
}
function normalizeProvider(
value: string | undefined,
): "serpapi" | "google-custom-search" | "searxng" | undefined {
if (value == null) return undefined;
const provider = value.trim().toLowerCase();
if (provider === "serpapi" || provider === "google-shopping") {
return "serpapi";
}
if (provider === "google-custom-search") {
return "google-custom-search";
}
if (provider === "searxng") return provider;
console.error("--provider must be one of: serpapi, google-custom-search, searxng");
process.exit(1);
}
function formatPrice(result: SearxngOfferSearchResult): string {
if (result.detectedPrice == null) return "";
if (result.detectedPriceText) return result.detectedPriceText;
const currency = result.detectedPriceCurrency ?? "USD";
return currency === "USD"
? `$${result.detectedPrice}`
: `${currency} ${result.detectedPrice}`;
}
main().catch((err) => {
console.error(`Search failed: ${err instanceof Error ? err.message : err}`);
process.exit(1);
});

13
src/asin.test.ts Normal file
View File

@@ -0,0 +1,13 @@
import { expect, test } from "bun:test";
import { normalizeAsin, requireAsin } from "./asin.ts";
test("normalizes any valid ten-character ASIN including ISBN-style values", () => {
expect(normalizeAsin(" b07sn9bhvv ")).toBe("B07SN9BHVV");
expect(normalizeAsin("0306406152")).toBe("0306406152");
});
test("rejects values that cannot be canonical product ASIN keys", () => {
expect(normalizeAsin("short")).toBeNull();
expect(normalizeAsin("B07SN9BHV!")).toBeNull();
expect(() => requireAsin("012345678901")).toThrow("Invalid ASIN");
});

14
src/asin.ts Normal file
View File

@@ -0,0 +1,14 @@
export const ASIN_PATTERN = /^[A-Z0-9]{10}$/;
export function normalizeAsin(value: unknown): string | null {
const asin = String(value ?? "").trim().toUpperCase();
return ASIN_PATTERN.test(asin) ? asin : null;
}
export function requireAsin(value: unknown): string {
const asin = normalizeAsin(value);
if (!asin) {
throw new Error(`Invalid ASIN: "${String(value ?? "").trim()}"`);
}
return asin;
}

View File

@@ -1,8 +1,41 @@
import { test, expect, beforeAll, afterAll, beforeEach, mock } from "bun:test"; import { test, expect, beforeEach, mock } from "bun:test";
import { Database } from "bun:sqlite";
import { getDb, initDb, closeDb } from "./database.ts"; let nextId = 0;
import path from "node:path";
import { rmSync, mkdirSync } from "node:fs"; function chainable(resolveWith: any[] = []): any {
const p: any = Promise.resolve(resolveWith);
p.limit = (_n: any) => chainable(resolveWith);
p.where = (_cond: any) => chainable(resolveWith);
p.from = (_table: any) => chainable(resolveWith);
return p;
}
const makeMockDb = (): any => ({
insert: (_table: any) => ({
values: (_vals: any) => ({
returning: (_sel: any) => Promise.resolve([{ id: ++nextId }]),
onConflictDoUpdate: (_conf: any) => Promise.resolve([]),
}),
}),
update: (_table: any) => ({
set: (_vals: any) => ({
where: (_cond: any) => Promise.resolve([]),
}),
}),
select: (_sel?: any) => ({
from: (_table: any) => ({
where: (_cond: any) => chainable(),
limit: (_n: any) => chainable(),
}),
}),
selectDistinct: (_sel: any) => ({
from: (_table: any) => chainable(),
}),
execute: (_query: any) => Promise.resolve([]),
transaction: async (fn: (tx: any) => Promise<any>) => fn(makeMockDb()),
});
mock.module("../db/index.ts", () => ({ db: makeMockDb(), client: {} }));
const fetchSellabilityBatchMock = mock(async (asins: string[]) => { const fetchSellabilityBatchMock = mock(async (asins: string[]) => {
return new Map( return new Map(
@@ -36,51 +69,28 @@ const analyzeProductsMock = mock(async (products: any[]) => {
})); }));
}); });
mock.module("./sp-api.ts", () => ({ mock.module("../integrations/sp-api.ts", () => ({
fetchSellabilityBatch: fetchSellabilityBatchMock, fetchSellabilityBatch: fetchSellabilityBatchMock,
fetchSpApiPricingAndFees: fetchSpApiPricingAndFeesMock, fetchSpApiPricingAndFees: fetchSpApiPricingAndFeesMock,
})); }));
mock.module("./llm.ts", () => ({ mock.module("../integrations/llm.ts", () => ({
analyzeProducts: analyzeProductsMock, analyzeProducts: analyzeProductsMock,
})); }));
const modulePromise = import("./bestsellers-by-category.ts"); const modulePromise = import("./bestsellers-by-category.ts");
const DB_TEST_PATH = path.join( let processCategory: (runId: number, category: any, perCategoryTop: number) => Promise<any>;
process.cwd(), let insertCategoryRunSummary: (summary: any, runTimestamp: string) => Promise<number>;
"test_output",
"test_analysis.sqlite",
);
let db: Database;
let processCategory: (db: Database, runId: number, category: any, perCategoryTop: number) => Promise<any>;
let insertCategoryRunSummary: (db: Database, summary: any, runTimestamp: string) => Promise<number>;
let originalFetch: typeof globalThis.fetch; let originalFetch: typeof globalThis.fetch;
beforeAll(async () => { const mod = await modulePromise;
const mod = await modulePromise; processCategory = mod.processCategory;
processCategory = mod.processCategory; insertCategoryRunSummary = mod.insertCategoryRunSummary;
insertCategoryRunSummary = mod.insertCategoryRunSummary; originalFetch = globalThis.fetch;
rmSync(path.dirname(DB_TEST_PATH), { recursive: true, force: true });
mkdirSync(path.dirname(DB_TEST_PATH), { recursive: true });
initDb(DB_TEST_PATH);
db = getDb(DB_TEST_PATH);
originalFetch = globalThis.fetch;
});
afterAll(() => {
globalThis.fetch = originalFetch;
closeDb();
rmSync(path.dirname(DB_TEST_PATH), { recursive: true, force: true });
});
beforeEach(() => { beforeEach(() => {
db.run("DELETE FROM product_analysis_results"); nextId = 0;
db.run("DELETE FROM category_analysis_runs");
globalThis.fetch = mock(async (input: string | URL | Request) => { globalThis.fetch = mock(async (input: string | URL | Request) => {
const rawUrl = const rawUrl =
typeof input === "string" typeof input === "string"
@@ -139,39 +149,34 @@ test("processCategory function test", async () => {
childCount: 0, childCount: 0,
}; };
const runId = await insertCategoryRunSummary(db, { const runId = await insertCategoryRunSummary(
categoryId: mockCategory.id, {
categoryLabel: mockCategory.label, categoryId: mockCategory.id,
topAsinsChecked: 0, categoryLabel: mockCategory.label,
availableAsins: 0, topAsinsChecked: 0,
fba: 0, availableAsins: 0,
fbm: 0, fba: 0,
skip: 0, fbm: 0,
status: "running", skip: 0,
error: "", status: "running",
results: [], error: "",
}, new Date().toISOString()); results: [],
const summary = await processCategory(db, runId, mockCategory, 2); },
new Date().toISOString(),
);
const categoryRun = db.query("SELECT * FROM category_analysis_runs").all() as any[]; const summary = await processCategory(runId, mockCategory, 2);
expect(categoryRun.length).toBe(1);
expect(categoryRun[0].category_label).toBe("Category 1");
expect(categoryRun[0].top_asins_checked).toBe(2);
expect(categoryRun[0].available_asins).toBe(2);
expect(categoryRun[0].fba_count).toBe(1);
expect(categoryRun[0].fbm_count).toBe(1);
expect(categoryRun[0].status).toBe("ok");
const productResults = db.query("SELECT * FROM product_analysis_results ORDER BY asin").all() as any[]; expect(summary.status).toBe("ok");
expect(productResults.length).toBe(2); expect(summary.topAsinsChecked).toBe(2);
expect(summary.availableAsins).toBe(2);
expect(summary.fba).toBe(1);
expect(summary.fbm).toBe(1);
expect(summary.results?.length).toBe(2);
expect(summary.results?.[0]?.product.record.asin).toBe("B000000001");
expect(summary.results?.[0]?.verdict.verdict).toBe("FBA");
expect(summary.results?.[1]?.product.record.asin).toBe("B000000002");
expect(summary.results?.[1]?.verdict.verdict).toBe("FBM");
expect(productResults[0].asin).toBe("B000000001"); globalThis.fetch = originalFetch;
expect(productResults[0].name).toBe("Product One");
expect(productResults[0].verdict).toBe("FBA");
expect(productResults[0].run_id).toBe(categoryRun[0].id);
expect(productResults[1].asin).toBe("B000000002");
expect(productResults[1].name).toBe("Product Two");
expect(productResults[1].verdict).toBe("FBM");
expect(productResults[1].run_id).toBe(categoryRun[0].id);
}); });

View File

@@ -1,9 +1,14 @@
import { existsSync, mkdirSync, readFileSync } from "node:fs"; import { existsSync, mkdirSync, readFileSync } from "node:fs";
import path from "node:path"; import path from "node:path";
import { type Database, getDb, initDb } from "./database.ts"; import { normalizeAsin } from "../asin.ts";
import { config } from "./config.ts"; import {
import { analyzeProducts } from "./llm.ts"; createCategoryRun,
import { fetchSellabilityBatch, fetchSpApiPricingAndFees } from "./sp-api.ts"; persistLlmResults,
updateCategoryRun,
} from "../db/persistence.ts";
import { config } from "../config.ts";
import { analyzeProducts } from "../integrations/llm.ts";
import { fetchSellabilityBatch, fetchSpApiPricingAndFees } from "../integrations/sp-api.ts";
import type { import type {
AnalysisResult, AnalysisResult,
EnrichedProduct, EnrichedProduct,
@@ -12,8 +17,7 @@ import type {
ProductRecord, ProductRecord,
SellabilityInfo, SellabilityInfo,
SpApiData, SpApiData,
} from "./types.ts"; } from "../types.ts";
type CategoryInfo = { type CategoryInfo = {
id: number; id: number;
@@ -27,6 +31,7 @@ type ParsedArgs = {
categoryLimit: number; categoryLimit: number;
perCategoryTop: number; perCategoryTop: number;
blacklistFile: string; blacklistFile: string;
useClaude: boolean;
}; };
type CategoryRunSummary = { type CategoryRunSummary = {
@@ -45,6 +50,8 @@ type CategoryRunSummary = {
const KEEPA_BASE = "https://api.keepa.com"; const KEEPA_BASE = "https://api.keepa.com";
const DOMAIN_US = 1; const DOMAIN_US = 1;
const AMAZON_US_SELLER_ID = "ATVPDKIKX0DER";
const KEEPA_MINUTES_OFFSET = 21_564_000;
const DEFAULT_CATEGORY_LIMIT = 32; const DEFAULT_CATEGORY_LIMIT = 32;
const DEFAULT_PER_CATEGORY_TOP = 100; const DEFAULT_PER_CATEGORY_TOP = 100;
const SELLABILITY_BATCH_SIZE = 60; const SELLABILITY_BATCH_SIZE = 60;
@@ -71,6 +78,7 @@ function log(
function parseArgs(): ParsedArgs { function parseArgs(): ParsedArgs {
const args = process.argv.slice(2); const args = process.argv.slice(2);
const useClaude = hasFlag(args, "--claude");
const outputDir = const outputDir =
readFlagValue(args, "--out-dir") ?? path.join(process.cwd(), "output"); readFlagValue(args, "--out-dir") ?? path.join(process.cwd(), "output");
const blacklistFile = const blacklistFile =
@@ -99,9 +107,14 @@ function parseArgs(): ParsedArgs {
categoryLimit, categoryLimit,
perCategoryTop, perCategoryTop,
blacklistFile, blacklistFile,
useClaude,
}; };
} }
function hasFlag(args: string[], flag: string): boolean {
return args.includes(flag);
}
function readFlagValue(args: string[], flag: string): string | undefined { function readFlagValue(args: string[], flag: string): string | undefined {
const idx = args.indexOf(flag); const idx = args.indexOf(flag);
if (idx === -1) return undefined; if (idx === -1) return undefined;
@@ -117,7 +130,7 @@ function printUsageAndExit(message: string): never {
"error", "error",
[ [
"Usage:", "Usage:",
" bun run src/bestsellers-by-category.ts [--category-limit 32] [--per-category-top 100] [--out-dir output] [--blacklist-file category-blacklist.csv]", " bun run src/bestsellers-by-category.ts [--category-limit 32] [--per-category-top 100] [--out-dir output] [--blacklist-file category-blacklist.csv] [--claude]",
"", "",
"Flow:", "Flow:",
" 1) Discover categories and round-robin selection.", " 1) Discover categories and round-robin selection.",
@@ -130,162 +143,39 @@ function printUsageAndExit(message: string): never {
process.exit(1); process.exit(1);
} }
export async function insertCategoryRunSummary( export async function insertCategoryRunSummary(
db: Database, summary: CategoryRunSummary,
summary: CategoryRunSummary, runTimestamp: string,
runTimestamp: string, ): Promise<number> {
): Promise<number> { return createCategoryRun(summary, runTimestamp);
const query = ` }
INSERT INTO category_analysis_runs (
category_id, category_label, run_timestamp,
top_asins_checked, available_asins,
fba_count, fbm_count, skip_count,
status, error_message
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?);
`;
const result = db.run(query, [
summary.categoryId,
summary.categoryLabel,
runTimestamp,
summary.topAsinsChecked,
summary.availableAsins,
summary.fba,
summary.fbm,
summary.skip,
summary.status,
summary.error,
]);
// Bun's SQLite client returns { changes: number, lastInsertRowid: number | bigint }
return Number(result.lastInsertRowid);
}
export async function updateCategoryRunSummary( export async function updateCategoryRunSummary(
db: Database,
runId: number, runId: number,
summary: Pick<CategoryRunSummary, "topAsinsChecked" | "availableAsins" | "fba" | "fbm" | "skip" | "status" | "error">, summary: Pick<
): Promise<void> { CategoryRunSummary,
db.run( | "topAsinsChecked"
` | "availableAsins"
UPDATE category_analysis_runs | "fba"
SET | "fbm"
top_asins_checked = ?, | "skip"
available_asins = ?, | "status"
fba_count = ?, | "error"
fbm_count = ?, >,
skip_count = ?, ): Promise<void> {
status = ?, await updateCategoryRun(runId, summary);
error_message = ? }
WHERE id = ?
`,
[
summary.topAsinsChecked,
summary.availableAsins,
summary.fba,
summary.fbm,
summary.skip,
summary.status,
summary.error,
runId,
],
);
}
export async function insertProductAnalysisResults( export async function insertProductAnalysisResults(
db: Database,
runId: number, runId: number,
results: AnalysisResult[], results: AnalysisResult[],
): Promise<void> { ): Promise<void> {
if (results.length === 0) { if (results.length === 0) return;
return; await persistLlmResults(runId, results, {
} source: "category_analysis",
metadataSource: "catalog",
const insertStmt = db.prepare(` });
INSERT INTO product_analysis_results ( }
asin, run_id, name, brand, category, unit_cost,
current_price, avg_price_90d, avg_price_90d_sheet,
selling_price_sheet, sales_rank, sales_rank_avg_90d,
seller_count, monthly_sold, rank_drops_30d, rank_drops_90d,
fba_fee, fbm_fee, referral_percent, can_sell,
sellability_status, sellability_reason,
verdict, confidence, reasoning, fetched_at
) VALUES (
?, ?, ?, ?, ?, ?, ?, ?, ?, ?,
?, ?, ?, ?, ?, ?, ?, ?, ?, ?,
?, ?, ?, ?, ?, ?
)
ON CONFLICT(asin) DO UPDATE SET
run_id = excluded.run_id,
name = excluded.name,
brand = excluded.brand,
category = excluded.category,
unit_cost = excluded.unit_cost,
current_price = excluded.current_price,
avg_price_90d = excluded.avg_price_90d,
avg_price_90d_sheet = excluded.avg_price_90d_sheet,
selling_price_sheet = excluded.selling_price_sheet,
sales_rank = excluded.sales_rank,
sales_rank_avg_90d = excluded.sales_rank_avg_90d,
seller_count = excluded.seller_count,
monthly_sold = excluded.monthly_sold,
rank_drops_30d = excluded.rank_drops_30d,
rank_drops_90d = excluded.rank_drops_90d,
fba_fee = excluded.fba_fee,
fbm_fee = excluded.fbm_fee,
referral_percent = excluded.referral_percent,
can_sell = excluded.can_sell,
sellability_status = excluded.sellability_status,
sellability_reason = excluded.sellability_reason,
verdict = excluded.verdict,
confidence = excluded.confidence,
reasoning = excluded.reasoning,
fetched_at = excluded.fetched_at;
`);
db.transaction((resultsBatch: AnalysisResult[]) => {
for (const r of resultsBatch) {
const price =
r.product.keepa?.currentPrice ??
r.product.record.sellingPriceFromSheet ??
r.product.spApi.estimatedSalePrice;
const rank = r.product.keepa?.salesRank ?? r.product.record.amazonRank;
insertStmt.run(
r.product.record.asin,
runId,
r.product.record.name,
r.product.record.brand ?? null,
r.product.record.category ??
r.product.keepa?.categoryTree?.join(" > ") ??
null,
r.product.record.unitCost ?? null,
price ?? null,
r.product.keepa?.avgPrice90 ?? null,
r.product.record.avgPrice90FromSheet ?? null,
r.product.record.sellingPriceFromSheet ?? null,
rank ?? null,
r.product.keepa?.salesRankAvg90 ?? null,
r.product.keepa?.sellerCount ?? null,
r.product.keepa?.monthlySold ?? null,
r.product.keepa?.salesRankDrops30 ?? null,
r.product.keepa?.salesRankDrops90 ?? null,
r.product.spApi.fbaFee ?? null,
r.product.spApi.fbmFee ?? null,
r.product.spApi.referralFeePercent ?? null,
r.product.spApi.canSell == null
? "unknown"
: r.product.spApi.canSell
? "yes"
: "no",
r.product.spApi.sellabilityStatus ?? null,
r.product.spApi.sellabilityReason ?? null,
r.verdict.verdict,
r.verdict.confidence,
r.verdict.reasoning ?? null,
r.product.fetchedAt,
);
}
})(results); // Execute the transaction with the results batch
}
function loadCategoryBlacklist(filePath: string): Set<number> { function loadCategoryBlacklist(filePath: string): Set<number> {
const blacklist = new Set<number>(); const blacklist = new Set<number>();
@@ -620,7 +510,7 @@ async function discoverCategories(
maxCategories: number, maxCategories: number,
): Promise<CategoryInfo[]> { ): Promise<CategoryInfo[]> {
const data = await keepaGetJson( const data = await keepaGetJson(
`/category?key=${encodeURIComponent(config.keepaApiKey)}&domain=${DOMAIN_US}&category=0`, `/category?key=${encodeURIComponent(config.keepaApiKey)}&domain=${DOMAIN_US}&category=0&parents=0`,
); );
const categories = normalizeCategoryList(data); const categories = normalizeCategoryList(data);
@@ -664,10 +554,14 @@ async function fetchCategoryBestSellerAsins(
]; ];
for (const value of candidates) { for (const value of candidates) {
if (Array.isArray(value)) { if (Array.isArray(value)) {
return [ return [
...new Set(value.map((v) => String(v).trim()).filter(Boolean)), ...new Set(
].slice(0, limit); value
.map((v) => normalizeAsin(v))
.filter((asin): asin is string => asin !== null),
),
].slice(0, limit);
} }
} }
@@ -776,6 +670,14 @@ function parseKeepaProduct(product: Record<string, any>): KeepaData {
const monthlySold = const monthlySold =
pickKeepaNumber(product.monthlySold, stats?.monthlySold) ?? pickKeepaNumber(product.monthlySold, stats?.monthlySold) ??
salesRankDrops30; salesRankDrops30;
const amazonIsSeller = resolveAmazonIsSeller(product, stats, csv);
const amazonBuyboxSharePct90d =
extractAmazonBuyboxSharePct90d(product, stats) ??
computeAmazonBuyBoxSharePctFromHistory(
product.buyBoxSellerIdHistory,
90,
new Set([AMAZON_US_SELLER_ID]),
);
return { return {
currentPrice: extractCurrentPrice(csv), currentPrice: extractCurrentPrice(csv),
@@ -787,6 +689,8 @@ function parseKeepaProduct(product: Record<string, any>): KeepaData {
salesRankDrops30, salesRankDrops30,
salesRankDrops90, salesRankDrops90,
sellerCount: stats?.current?.[11] ?? null, sellerCount: stats?.current?.[11] ?? null,
amazonIsSeller,
amazonBuyboxSharePct90d,
buyBoxSeller: product.buyBoxSellerId ?? null, buyBoxSeller: product.buyBoxSellerId ?? null,
buyBoxPrice: stats?.current?.[18] != null ? stats.current[18] / 100 : null, buyBoxPrice: stats?.current?.[18] != null ? stats.current[18] / 100 : null,
monthlySold, monthlySold,
@@ -795,6 +699,108 @@ function parseKeepaProduct(product: Record<string, any>): KeepaData {
}; };
} }
function resolveAmazonIsSeller(
product: Record<string, any>,
stats: Record<string, any> | undefined,
csv: number[][] | undefined,
): boolean | null {
if (typeof product.isAmazonSeller === "boolean")
return product.isAmazonSeller;
if (typeof product.availabilityAmazon === "number") {
if (product.availabilityAmazon >= 0) return true;
if (
product.availabilityAmazon === -1 ||
product.availabilityAmazon === -2
) {
return false;
}
}
if (stats?.buyBoxIsAmazon === true) return true;
if (typeof stats?.current?.[0] === "number") {
if (stats.current[0] > 0) return true;
if (stats.current[0] === -1 || stats.current[0] === -2) return false;
}
const latestAmazonPrice = extractLatestPositivePrice(csv?.[0]);
if (latestAmazonPrice != null) return true;
return null;
}
function extractAmazonBuyboxSharePct90d(
product: Record<string, any>,
stats: Record<string, any> | undefined,
): number | null {
const candidates: unknown[] = [
product.buyBoxStatsAmazon90,
stats?.buyBoxStatsAmazon90,
product.buyBoxStats?.amazon90,
product.buyBoxStats?.amazon?.[90],
product.buyBoxStats?.amazon?.["90"],
product.buyBoxStats?.[AMAZON_US_SELLER_ID]?.[90],
product.buyBoxStats?.[AMAZON_US_SELLER_ID]?.["90"],
];
for (const value of candidates) {
if (typeof value !== "number" || !Number.isFinite(value)) continue;
if (value < 0 || value > 100) continue;
return Math.round(value * 100) / 100;
}
return null;
}
function computeAmazonBuyBoxSharePctFromHistory(
history: unknown,
windowDays: number,
amazonSellerIds: Set<string>,
): number | null {
if (!Array.isArray(history) || history.length < 2) return null;
const nowKeepaMinutes =
Math.floor(Date.now() / 60_000) - KEEPA_MINUTES_OFFSET;
const windowStart = nowKeepaMinutes - windowDays * 24 * 60;
let qualifiedMinutes = 0;
let amazonMinutes = 0;
for (let i = 0; i < history.length - 1; i += 2) {
const startMinute = Number.parseInt(String(history[i]), 10);
const sellerId = String(history[i + 1] ?? "").toUpperCase();
const nextRaw = i + 2 < history.length ? history[i + 2] : nowKeepaMinutes;
const endMinute = Number.parseInt(String(nextRaw), 10);
if (!Number.isFinite(startMinute) || !Number.isFinite(endMinute)) continue;
if (endMinute <= startMinute) continue;
const intervalStart = Math.max(startMinute, windowStart);
const intervalEnd = Math.min(endMinute, nowKeepaMinutes);
if (intervalEnd <= intervalStart) continue;
if (sellerId === "-1" || sellerId === "-2") continue;
const minutes = intervalEnd - intervalStart;
qualifiedMinutes += minutes;
if (amazonSellerIds.has(sellerId)) {
amazonMinutes += minutes;
}
}
if (qualifiedMinutes === 0) return null;
return Math.round((amazonMinutes / qualifiedMinutes) * 10_000) / 100;
}
function extractLatestPositivePrice(series: unknown): number | null {
if (!Array.isArray(series) || series.length < 2) return null;
const last = series[series.length - 1];
if (typeof last !== "number" || !Number.isFinite(last) || last <= 0) {
return null;
}
return last / 100;
}
async function fetchKeepaEnrichmentMap( async function fetchKeepaEnrichmentMap(
asins: string[], asins: string[],
): Promise<Map<string, { keepa: KeepaData; title: string }>> { ): Promise<Map<string, { keepa: KeepaData; title: string }>> {
@@ -804,13 +810,13 @@ async function fetchKeepaEnrichmentMap(
const chunk = asins.slice(i, i + KEEPA_PRODUCT_CHUNK_SIZE); const chunk = asins.slice(i, i + KEEPA_PRODUCT_CHUNK_SIZE);
const asinParam = encodeURIComponent(chunk.join(",")); const asinParam = encodeURIComponent(chunk.join(","));
const data = await keepaGetJson( const data = await keepaGetJson(
`/product?key=${encodeURIComponent(config.keepaApiKey)}&domain=${DOMAIN_US}&asin=${asinParam}&stats=90`, `/product?key=${encodeURIComponent(config.keepaApiKey)}&domain=${DOMAIN_US}&asin=${asinParam}&stats=90&buybox=1&days=90`,
); );
const products = Array.isArray(data?.products) ? data.products : []; const products = Array.isArray(data?.products) ? data.products : [];
for (const product of products) { for (const product of products) {
const asin = String(product?.asin ?? "").trim(); const asin = normalizeAsin(product?.asin);
if (!asin) continue; if (!asin) continue;
out.set(asin, { out.set(asin, {
keepa: parseKeepaProduct(product), keepa: parseKeepaProduct(product),
title: String(product?.title ?? "").trim(), title: String(product?.title ?? "").trim(),
@@ -876,17 +882,17 @@ function buildEnrichedProducts(
} }
export async function processCategory( export async function processCategory(
db: Database,
runId: number, runId: number,
category: CategoryInfo, category: CategoryInfo,
perCategoryTop: number, perCategoryTop: number,
useClaude = false,
): Promise<CategoryRunSummary> { ): Promise<CategoryRunSummary> {
log("info", `\nCategory ${category.label} (${category.id})`); log("info", `\nCategory ${category.label} (${category.id})`);
const topAsins = await fetchCategoryBestSellerAsins(category, perCategoryTop); const topAsins = await fetchCategoryBestSellerAsins(category, perCategoryTop);
if (topAsins.length === 0) { if (topAsins.length === 0) {
log("info", " Keepa returned no ASINs for this category."); log("info", " Keepa returned no ASINs for this category.");
await updateCategoryRunSummary(db, runId, { await updateCategoryRunSummary(runId, {
topAsinsChecked: 0, topAsinsChecked: 0,
availableAsins: 0, availableAsins: 0,
fba: 0, fba: 0,
@@ -911,7 +917,10 @@ export async function processCategory(
const uniqueTopAsins = Array.from(new Set(topAsins)); const uniqueTopAsins = Array.from(new Set(topAsins));
if (uniqueTopAsins.length !== topAsins.length) { if (uniqueTopAsins.length !== topAsins.length) {
log("warn", ` Removed ${topAsins.length - uniqueTopAsins.length} duplicate ASINs before analysis.`); log(
"warn",
` Removed ${topAsins.length - uniqueTopAsins.length} duplicate ASINs before analysis.`,
);
} }
log("info", ` Top ASINs fetched: ${uniqueTopAsins.length}`); log("info", ` Top ASINs fetched: ${uniqueTopAsins.length}`);
@@ -922,9 +931,12 @@ export async function processCategory(
return info?.canSell === true && info.sellabilityStatus === "available"; return info?.canSell === true && info.sellabilityStatus === "available";
}); });
log("info", ` Sellable ASINs: ${availableAsins.length}/${uniqueTopAsins.length}`); log(
"info",
` Sellable ASINs: ${availableAsins.length}/${uniqueTopAsins.length}`,
);
if (availableAsins.length === 0) { if (availableAsins.length === 0) {
await updateCategoryRunSummary(db, runId, { await updateCategoryRunSummary(runId, {
topAsinsChecked: uniqueTopAsins.length, topAsinsChecked: uniqueTopAsins.length,
availableAsins: 0, availableAsins: 0,
fba: 0, fba: 0,
@@ -969,7 +981,7 @@ export async function processCategory(
let batchVerdicts: LlmVerdict[]; let batchVerdicts: LlmVerdict[];
try { try {
batchVerdicts = await analyzeProducts(batch); batchVerdicts = await analyzeProducts(batch, { useClaude });
} catch (err) { } catch (err) {
const message = err instanceof Error ? err.message : String(err); const message = err instanceof Error ? err.message : String(err);
log("warn", ` LLM batch failed: ${message}`); log("warn", ` LLM batch failed: ${message}`);
@@ -992,7 +1004,7 @@ export async function processCategory(
}, },
})); }));
await insertProductAnalysisResults(db, runId, batchResults); await insertProductAnalysisResults(runId, batchResults);
for (const result of batchResults) { for (const result of batchResults) {
results.push(result); results.push(result);
@@ -1005,7 +1017,7 @@ export async function processCategory(
} }
} }
await updateCategoryRunSummary(db, runId, { await updateCategoryRunSummary(runId, {
topAsinsChecked: uniqueTopAsins.length, topAsinsChecked: uniqueTopAsins.length,
availableAsins: availableAsins.length, availableAsins: availableAsins.length,
fba, fba,
@@ -1025,7 +1037,7 @@ export async function processCategory(
} }
} }
await updateCategoryRunSummary(db, runId, { await updateCategoryRunSummary(runId, {
topAsinsChecked: uniqueTopAsins.length, topAsinsChecked: uniqueTopAsins.length,
availableAsins: availableAsins.length, availableAsins: availableAsins.length,
fba, fba,
@@ -1054,9 +1066,6 @@ export async function main(): Promise<void> {
assertSpApiPrerequisites(); assertSpApiPrerequisites();
mkdirSync(args.outputDir, { recursive: true }); mkdirSync(args.outputDir, { recursive: true });
const DB_PATH = process.env.RESULTS_DB_PATH || path.join(process.cwd(), "results.db");
initDb(DB_PATH);
const db = getDb(DB_PATH);
log("info", "Starting per-category bestseller pipeline"); log("info", "Starting per-category bestseller pipeline");
log("info", `Marketplace: ${config.spApiMarketplaceId}`); log("info", `Marketplace: ${config.spApiMarketplaceId}`);
@@ -1090,7 +1099,6 @@ export async function main(): Promise<void> {
let runId: number | undefined; let runId: number | undefined;
try { try {
runId = await insertCategoryRunSummary( runId = await insertCategoryRunSummary(
db,
{ {
categoryId: category.id, categoryId: category.id,
categoryLabel: category.label, categoryLabel: category.label,
@@ -1107,10 +1115,10 @@ export async function main(): Promise<void> {
); );
categorySummary = await processCategory( categorySummary = await processCategory(
db,
runId, runId,
category, category,
args.perCategoryTop, args.perCategoryTop,
args.useClaude,
); );
totalInsertedAsins += categorySummary.results?.length ?? 0; totalInsertedAsins += categorySummary.results?.length ?? 0;
@@ -1136,7 +1144,7 @@ export async function main(): Promise<void> {
results: [], results: [],
}; };
if (runId) { if (runId) {
await updateCategoryRunSummary(db, runId, { await updateCategoryRunSummary(runId, {
topAsinsChecked: 0, topAsinsChecked: 0,
availableAsins: 0, availableAsins: 0,
fba: 0, fba: 0,

View File

@@ -0,0 +1,324 @@
import { test, expect, beforeEach, mock } from "bun:test";
let nextId = 0;
function chainable(resolveWith: any[] = []): any {
const p: any = Promise.resolve(resolveWith);
p.limit = (_n: any) => chainable(resolveWith);
p.where = (_cond: any) => chainable(resolveWith);
p.from = (_table: any) => chainable(resolveWith);
return p;
}
const makeMockDb = (): any => ({
insert: (_table: any) => ({
values: (_vals: any) => ({
returning: (_sel: any) => Promise.resolve([{ id: ++nextId }]),
onConflictDoUpdate: (_conf: any) => Promise.resolve([]),
}),
}),
update: (_table: any) => ({
set: (_vals: any) => ({
where: (_cond: any) => Promise.resolve([]),
}),
}),
select: (_sel?: any) => ({
from: (_table: any) => ({
where: (_cond: any) => chainable(),
limit: (_n: any) => chainable(),
}),
}),
selectDistinct: (_sel: any) => ({
from: (_table: any) => chainable(),
}),
execute: (_query: any) => Promise.resolve([]),
transaction: async (fn: (tx: any) => Promise<any>) => fn(makeMockDb()),
});
mock.module("../db/index.ts", () => ({ db: makeMockDb(), client: {} }));
const fetchSellabilityBatchMock = mock(async (asins: string[]) => {
return new Map<string, any>(
asins.map((asin) => {
if (asin === "B000000003") {
return [
asin,
{
canSell: false,
sellabilityStatus: "restricted" as const,
sellabilityReason: "restricted",
},
];
}
return [
asin,
{
canSell: true,
sellabilityStatus: "available" as const,
sellabilityReason: "ok",
},
];
}),
);
});
const fetchSpApiPricingAndFeesMock = mock(
async (_asin: string, sellability: any) => ({
fbaFee: 4,
fbmFee: 2,
referralFeePercent: 15,
estimatedSalePrice: 25,
canSell: sellability?.canSell ?? null,
sellabilityStatus: sellability?.sellabilityStatus ?? "unknown",
sellabilityReason: sellability?.sellabilityReason ?? "missing",
}),
);
const analyzeProductsMock = mock(async (products: any[]) => {
return products.map((p) => ({
asin: p.record.asin,
verdict: "FBA",
confidence: 90,
reasoning: "mocked",
}));
});
mock.module("../integrations/sp-api.ts", () => ({
fetchSellabilityBatch: fetchSellabilityBatchMock,
fetchSpApiPricingAndFees: fetchSpApiPricingAndFeesMock,
}));
mock.module("../integrations/llm.ts", () => ({
analyzeProducts: analyzeProductsMock,
}));
const modulePromise = import("./mid-range-sellers-by-category.ts");
let processCategory: any;
let insertCategoryRunSummary: (summary: any, runTimestamp: string) => Promise<number>;
let originalFetch: typeof globalThis.fetch;
const mod = await modulePromise;
processCategory = mod.processCategory;
insertCategoryRunSummary = mod.insertCategoryRunSummary;
originalFetch = globalThis.fetch;
beforeEach(() => {
nextId = 0;
globalThis.fetch = mock(async (input: string | URL | Request) => {
const rawUrl =
typeof input === "string"
? input
: input instanceof URL
? input.toString()
: input.url;
const url = new URL(rawUrl);
if (url.pathname === "/bestsellers") {
return new Response(
JSON.stringify({
bestSellersList: [
"B000000001",
"B000000002",
"B000000003",
"B000000004",
"B000000005",
],
tokensLeft: 10,
refillRate: 1,
}),
{ status: 200 },
);
}
if (url.pathname === "/product") {
return new Response(
JSON.stringify({
products: [
{
asin: "B000000001",
title: "Product One",
monthlySold: 600,
isAmazonSeller: true,
buyBoxStatsAmazon90: 40,
stats: {
current: [
null, null, null, 1000, null, null, null, null, null, null, null, 5,
null, null, null, null, null, null, 2599,
],
avg: [2400, null, null, 1200],
},
csv: [[1, 2599]],
categoryTree: [{ name: "Category 1" }],
},
{
asin: "B000000002",
title: "Product Two",
monthlySold: 250,
isAmazonSeller: true,
buyBoxStatsAmazon90: 50,
stats: {
current: [
null, null, null, 2000, null, null, null, null, null, null, null, 3,
null, null, null, null, null, null, 1999,
],
avg: [1800, null, null, 2200],
},
csv: [[1, 1200]],
categoryTree: [{ name: "Category 1" }],
},
{
asin: "B000000003",
title: "Product Three",
monthlySold: 800,
isAmazonSeller: true,
buyBoxStatsAmazon90: 50,
stats: {
current: [
null, null, null, 1500, null, null, null, null, null, null, null, 4,
null, null, null, null, null, null, 2099,
],
avg: [2000, null, null, 1800],
},
csv: [[1, 2099]],
categoryTree: [{ name: "Category 1" }],
},
{
asin: "B000000004",
title: "Product Four",
monthlySold: 400,
isAmazonSeller: true,
buyBoxStatsAmazon90: 95,
stats: {
current: [
null, null, null, 3000, null, null, null, null, null, null, null, 4,
null, null, null, null, null, null, 2899,
],
avg: [2600, null, null, 2800],
},
csv: [[1, 2899]],
categoryTree: [{ name: "Category 1" }],
},
{
asin: "B000000005",
title: "Product Five",
monthlySold: 450,
isAmazonSeller: false,
stats: {
current: [
null, null, null, 3200, null, null, null, null, null, null, null, 25,
null, null, null, null, null, null, 3500,
],
avg: [3200, null, null, 3200],
},
csv: [[1, 3500]],
categoryTree: [{ name: "Category 1" }],
},
],
tokensLeft: 10,
refillRate: 1,
}),
{ status: 200 },
);
}
return new Response("not found", { status: 404 });
}) as unknown as typeof globalThis.fetch;
});
test("processCategory only analyzes sellable mid-range matches", async () => {
const mockCategory = {
id: 1,
label: "Category 1",
parentId: 0,
childCount: 0,
};
const runId = await insertCategoryRunSummary(
{
categoryId: mockCategory.id,
categoryLabel: mockCategory.label,
topAsinsChecked: 0,
availableAsins: 0,
fba: 0,
fbm: 0,
skip: 0,
status: "running",
error: "",
results: [],
},
new Date().toISOString(),
);
const summary = await processCategory(
runId,
mockCategory,
3,
5,
100,
1000,
15,
200,
3,
20,
15,
85,
"strict",
);
expect(summary.status).toBe("ok");
expect(summary.topAsinsChecked).toBe(5);
expect(summary.availableAsins).toBe(1);
expect(summary.results?.length).toBe(1);
globalThis.fetch = originalFetch;
});
test("processCategory returns empty when no products match mid-range criteria", async () => {
const mockCategory = {
id: 2,
label: "Category 2",
parentId: 0,
childCount: 0,
};
const runId = await insertCategoryRunSummary(
{
categoryId: mockCategory.id,
categoryLabel: mockCategory.label,
topAsinsChecked: 0,
availableAsins: 0,
fba: 0,
fbm: 0,
skip: 0,
status: "running",
error: "",
results: [],
},
new Date().toISOString(),
);
const summary = await processCategory(
runId,
mockCategory,
3,
5,
100,
1000,
500,
600,
3,
20,
15,
85,
"strict",
);
expect(summary.status).toBe("empty");
expect(summary.topAsinsChecked).toBe(5);
expect(summary.availableAsins).toBe(0);
expect(summary.results?.length).toBe(0);
globalThis.fetch = originalFetch;
});

File diff suppressed because it is too large Load Diff

View File

@@ -1,11 +1,44 @@
import { test, expect, beforeAll, afterAll, beforeEach, mock } from "bun:test"; import { test, expect, beforeEach, mock } from "bun:test";
import { Database } from "bun:sqlite";
import { getDb, initDb, closeDb } from "./database.ts"; let nextId = 0;
import path from "node:path";
import { rmSync, mkdirSync } from "node:fs"; function chainable(resolveWith: any[] = []): any {
const p: any = Promise.resolve(resolveWith);
p.limit = (_n: any) => chainable(resolveWith);
p.where = (_cond: any) => chainable(resolveWith);
p.from = (_table: any) => chainable(resolveWith);
return p;
}
const makeMockDb = (): any => ({
insert: (_table: any) => ({
values: (_vals: any) => ({
returning: (_sel: any) => Promise.resolve([{ id: ++nextId }]),
onConflictDoUpdate: (_conf: any) => Promise.resolve([]),
}),
}),
update: (_table: any) => ({
set: (_vals: any) => ({
where: (_cond: any) => Promise.resolve([]),
}),
}),
select: (_sel?: any) => ({
from: (_table: any) => ({
where: (_cond: any) => chainable(),
limit: (_n: any) => chainable(),
}),
}),
selectDistinct: (_sel: any) => ({
from: (_table: any) => chainable(),
}),
execute: (_query: any) => Promise.resolve([]),
transaction: async (fn: (tx: any) => Promise<any>) => fn(makeMockDb()),
});
mock.module("../db/index.ts", () => ({ db: makeMockDb(), client: {} }));
const fetchSellabilityBatchMock = mock(async (asins: string[]) => { const fetchSellabilityBatchMock = mock(async (asins: string[]) => {
return new Map( return new Map<string, any>(
asins.map((asin) => { asins.map((asin) => {
if (asin === "B000000003") { if (asin === "B000000003") {
return [ return [
@@ -49,62 +82,34 @@ const analyzeProductsMock = mock(async (products: any[]) => {
})); }));
}); });
mock.module("./sp-api.ts", () => ({ mock.module("../integrations/sp-api.ts", () => ({
fetchSellabilityBatch: fetchSellabilityBatchMock, fetchSellabilityBatch: fetchSellabilityBatchMock,
fetchSpApiPricingAndFees: fetchSpApiPricingAndFeesMock, fetchSpApiPricingAndFees: fetchSpApiPricingAndFeesMock,
})); }));
mock.module("./llm.ts", () => ({ mock.module("../integrations/llm.ts", () => ({
analyzeProducts: analyzeProductsMock, analyzeProducts: analyzeProductsMock,
})); }));
const modulePromise = import("./top-monthly-sold-by-category.ts"); const modulePromise = import("./top-monthly-sold-by-category.ts");
const DB_TEST_PATH = path.join(
process.cwd(),
"test_output",
"test_monthly_sold_analysis.sqlite",
);
let db: Database;
let processCategory: ( let processCategory: (
db: Database,
runId: number, runId: number,
category: any, category: any,
perCategoryTop: number, perCategoryTop: number,
categoryCandidatePool: number, categoryCandidatePool: number,
minMonthlySold: number, minMonthlySold: number,
) => Promise<any>; ) => Promise<any>;
let insertCategoryRunSummary: ( let insertCategoryRunSummary: (summary: any, runTimestamp: string) => Promise<number>;
db: Database,
summary: any,
runTimestamp: string,
) => Promise<number>;
let originalFetch: typeof globalThis.fetch; let originalFetch: typeof globalThis.fetch;
beforeAll(async () => { const mod = await modulePromise;
const mod = await modulePromise; processCategory = mod.processCategory;
processCategory = mod.processCategory; insertCategoryRunSummary = mod.insertCategoryRunSummary;
insertCategoryRunSummary = mod.insertCategoryRunSummary; originalFetch = globalThis.fetch;
rmSync(path.dirname(DB_TEST_PATH), { recursive: true, force: true });
mkdirSync(path.dirname(DB_TEST_PATH), { recursive: true });
initDb(DB_TEST_PATH);
db = getDb(DB_TEST_PATH);
originalFetch = globalThis.fetch;
});
afterAll(() => {
globalThis.fetch = originalFetch;
closeDb();
rmSync(path.dirname(DB_TEST_PATH), { recursive: true, force: true });
});
beforeEach(() => { beforeEach(() => {
db.run("DELETE FROM product_analysis_results"); nextId = 0;
db.run("DELETE FROM category_analysis_runs");
globalThis.fetch = mock(async (input: string | URL | Request) => { globalThis.fetch = mock(async (input: string | URL | Request) => {
const rawUrl = const rawUrl =
typeof input === "string" typeof input === "string"
@@ -140,25 +145,8 @@ beforeEach(() => {
monthlySold: 600, monthlySold: 600,
stats: { stats: {
current: [ current: [
null, null, null, null, 1000, null, null, null, null, null, null, null, 2,
null, null, null, null, null, null, null, 2599,
null,
1000,
null,
null,
null,
null,
null,
null,
null,
2,
null,
null,
null,
null,
null,
null,
2599,
], ],
avg: [2400, null, null, 1200], avg: [2400, null, null, 1200],
}, },
@@ -171,25 +159,8 @@ beforeEach(() => {
monthlySold: 250, monthlySold: 250,
stats: { stats: {
current: [ current: [
null, null, null, null, 2000, null, null, null, null, null, null, null, 3,
null, null, null, null, null, null, null, 1999,
null,
2000,
null,
null,
null,
null,
null,
null,
null,
3,
null,
null,
null,
null,
null,
null,
1999,
], ],
avg: [1800, null, null, 2200], avg: [1800, null, null, 2200],
}, },
@@ -202,25 +173,8 @@ beforeEach(() => {
monthlySold: 800, monthlySold: 800,
stats: { stats: {
current: [ current: [
null, null, null, null, 1500, null, null, null, null, null, null, null, 1,
null, null, null, null, null, null, null, 2099,
null,
1500,
null,
null,
null,
null,
null,
null,
null,
1,
null,
null,
null,
null,
null,
null,
2099,
], ],
avg: [2000, null, null, 1800], avg: [2000, null, null, 1800],
}, },
@@ -233,25 +187,8 @@ beforeEach(() => {
monthlySold: 400, monthlySold: 400,
stats: { stats: {
current: [ current: [
null, null, null, null, 3000, null, null, null, null, null, null, null, 4,
null, null, null, null, null, null, null, 2899,
null,
3000,
null,
null,
null,
null,
null,
null,
null,
4,
null,
null,
null,
null,
null,
null,
2899,
], ],
avg: [2600, null, null, 2800], avg: [2600, null, null, 2800],
}, },
@@ -279,7 +216,6 @@ test("processCategory filters to sellable ASINs with monthly sold >= threshold a
}; };
const runId = await insertCategoryRunSummary( const runId = await insertCategoryRunSummary(
db,
{ {
categoryId: mockCategory.id, categoryId: mockCategory.id,
categoryLabel: mockCategory.label, categoryLabel: mockCategory.label,
@@ -295,22 +231,16 @@ test("processCategory filters to sellable ASINs with monthly sold >= threshold a
new Date().toISOString(), new Date().toISOString(),
); );
const summary = await processCategory(db, runId, mockCategory, 2, 4, 300); const summary = await processCategory(runId, mockCategory, 2, 4, 300);
expect(summary.status).toBe("ok"); expect(summary.status).toBe("ok");
expect(summary.topAsinsChecked).toBe(4); expect(summary.topAsinsChecked).toBe(4);
expect(summary.availableAsins).toBe(2); expect(summary.availableAsins).toBe(2);
expect(summary.results?.length).toBe(2); expect(summary.results?.length).toBe(2);
const productResults = db const asins = summary.results?.map((r: any) => r.product.record.asin) ?? [];
.query( expect(asins).toContain("B000000001");
"SELECT asin, monthly_sold FROM product_analysis_results ORDER BY monthly_sold DESC", expect(asins).toContain("B000000004");
)
.all() as Array<{ asin: string; monthly_sold: number }>;
expect(productResults.length).toBe(2); globalThis.fetch = originalFetch;
expect(productResults[0]?.asin).toBe("B000000001");
expect(productResults[0]?.monthly_sold).toBe(600);
expect(productResults[1]?.asin).toBe("B000000004");
expect(productResults[1]?.monthly_sold).toBe(400);
}); });

View File

@@ -1,9 +1,14 @@
import { existsSync, mkdirSync, readFileSync } from "node:fs"; import { existsSync, mkdirSync, readFileSync } from "node:fs";
import path from "node:path"; import path from "node:path";
import { type Database, getDb, initDb } from "./database.ts"; import { normalizeAsin } from "../asin.ts";
import { config } from "./config.ts"; import {
import { analyzeProducts } from "./llm.ts"; createCategoryRun,
import { fetchSellabilityBatch, fetchSpApiPricingAndFees } from "./sp-api.ts"; persistLlmResults,
updateCategoryRun,
} from "../db/persistence.ts";
import { config } from "../config.ts";
import { analyzeProducts } from "../integrations/llm.ts";
import { fetchSellabilityBatch, fetchSpApiPricingAndFees } from "../integrations/sp-api.ts";
import type { import type {
AnalysisResult, AnalysisResult,
EnrichedProduct, EnrichedProduct,
@@ -12,7 +17,7 @@ import type {
ProductRecord, ProductRecord,
SellabilityInfo, SellabilityInfo,
SpApiData, SpApiData,
} from "./types.ts"; } from "../types.ts";
type CategoryInfo = { type CategoryInfo = {
id: number; id: number;
@@ -28,6 +33,7 @@ type ParsedArgs = {
categoryCandidatePool: number; categoryCandidatePool: number;
minMonthlySold: number; minMonthlySold: number;
blacklistFile: string; blacklistFile: string;
useClaude: boolean;
}; };
type CategoryRunSummary = { type CategoryRunSummary = {
@@ -46,6 +52,8 @@ type CategoryRunSummary = {
const KEEPA_BASE = "https://api.keepa.com"; const KEEPA_BASE = "https://api.keepa.com";
const DOMAIN_US = 1; const DOMAIN_US = 1;
const AMAZON_US_SELLER_ID = "ATVPDKIKX0DER";
const KEEPA_MINUTES_OFFSET = 21_564_000;
const DEFAULT_CATEGORY_LIMIT = 32; const DEFAULT_CATEGORY_LIMIT = 32;
const DEFAULT_PER_CATEGORY_TOP = 100; const DEFAULT_PER_CATEGORY_TOP = 100;
const DEFAULT_CATEGORY_CANDIDATE_POOL = 500; const DEFAULT_CATEGORY_CANDIDATE_POOL = 500;
@@ -74,6 +82,7 @@ function log(
function parseArgs(): ParsedArgs { function parseArgs(): ParsedArgs {
const args = process.argv.slice(2); const args = process.argv.slice(2);
const useClaude = hasFlag(args, "--claude");
const outputDir = const outputDir =
readFlagValue(args, "--out-dir") ?? path.join(process.cwd(), "output"); readFlagValue(args, "--out-dir") ?? path.join(process.cwd(), "output");
const blacklistFile = const blacklistFile =
@@ -129,9 +138,14 @@ function parseArgs(): ParsedArgs {
categoryCandidatePool, categoryCandidatePool,
minMonthlySold, minMonthlySold,
blacklistFile, blacklistFile,
useClaude,
}; };
} }
function hasFlag(args: string[], flag: string): boolean {
return args.includes(flag);
}
function readFlagValue(args: string[], flag: string): string | undefined { function readFlagValue(args: string[], flag: string): string | undefined {
const idx = args.indexOf(flag); const idx = args.indexOf(flag);
if (idx === -1) return undefined; if (idx === -1) return undefined;
@@ -147,7 +161,7 @@ function printUsageAndExit(message: string): never {
"error", "error",
[ [
"Usage:", "Usage:",
" bun run src/top-monthly-sold-by-category.ts [--category-limit 32] [--per-category-top 100] [--category-candidate-pool 500] [--min-monthly-sold 300] [--out-dir output] [--blacklist-file category-blacklist.csv]", " bun run src/top-monthly-sold-by-category.ts [--category-limit 32] [--per-category-top 100] [--category-candidate-pool 500] [--min-monthly-sold 300] [--out-dir output] [--blacklist-file category-blacklist.csv] [--claude]",
"", "",
"Flow:", "Flow:",
" 1) Discover categories and round-robin selection.", " 1) Discover categories and round-robin selection.",
@@ -161,37 +175,14 @@ function printUsageAndExit(message: string): never {
process.exit(1); process.exit(1);
} }
export async function insertCategoryRunSummary( export async function insertCategoryRunSummary(
db: Database, summary: CategoryRunSummary,
summary: CategoryRunSummary, runTimestamp: string,
runTimestamp: string, ): Promise<number> {
): Promise<number> { return createCategoryRun(summary, runTimestamp);
const query = ` }
INSERT INTO category_analysis_runs (
category_id, category_label, run_timestamp,
top_asins_checked, available_asins,
fba_count, fbm_count, skip_count,
status, error_message
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?);
`;
const result = db.run(query, [
summary.categoryId,
summary.categoryLabel,
runTimestamp,
summary.topAsinsChecked,
summary.availableAsins,
summary.fba,
summary.fbm,
summary.skip,
summary.status,
summary.error,
]);
// Bun's SQLite client returns { changes: number, lastInsertRowid: number | bigint }
return Number(result.lastInsertRowid);
}
export async function updateCategoryRunSummary( export async function updateCategoryRunSummary(
db: Database,
runId: number, runId: number,
summary: Pick< summary: Pick<
CategoryRunSummary, CategoryRunSummary,
@@ -203,129 +194,20 @@ export async function updateCategoryRunSummary(
| "status" | "status"
| "error" | "error"
>, >,
): Promise<void> { ): Promise<void> {
db.run( await updateCategoryRun(runId, summary);
` }
UPDATE category_analysis_runs
SET
top_asins_checked = ?,
available_asins = ?,
fba_count = ?,
fbm_count = ?,
skip_count = ?,
status = ?,
error_message = ?
WHERE id = ?
`,
[
summary.topAsinsChecked,
summary.availableAsins,
summary.fba,
summary.fbm,
summary.skip,
summary.status,
summary.error,
runId,
],
);
}
export async function insertProductAnalysisResults( export async function insertProductAnalysisResults(
db: Database,
runId: number, runId: number,
results: AnalysisResult[], results: AnalysisResult[],
): Promise<void> { ): Promise<void> {
if (results.length === 0) { if (results.length === 0) return;
return; await persistLlmResults(runId, results, {
} source: "category_analysis",
metadataSource: "catalog",
const insertStmt = db.prepare(` });
INSERT INTO product_analysis_results ( }
asin, run_id, name, brand, category, unit_cost,
current_price, avg_price_90d, avg_price_90d_sheet,
selling_price_sheet, sales_rank, sales_rank_avg_90d,
seller_count, monthly_sold, rank_drops_30d, rank_drops_90d,
fba_fee, fbm_fee, referral_percent, can_sell,
sellability_status, sellability_reason,
verdict, confidence, reasoning, fetched_at
) VALUES (
?, ?, ?, ?, ?, ?, ?, ?, ?, ?,
?, ?, ?, ?, ?, ?, ?, ?, ?, ?,
?, ?, ?, ?, ?, ?
)
ON CONFLICT(asin) DO UPDATE SET
run_id = excluded.run_id,
name = excluded.name,
brand = excluded.brand,
category = excluded.category,
unit_cost = excluded.unit_cost,
current_price = excluded.current_price,
avg_price_90d = excluded.avg_price_90d,
avg_price_90d_sheet = excluded.avg_price_90d_sheet,
selling_price_sheet = excluded.selling_price_sheet,
sales_rank = excluded.sales_rank,
sales_rank_avg_90d = excluded.sales_rank_avg_90d,
seller_count = excluded.seller_count,
monthly_sold = excluded.monthly_sold,
rank_drops_30d = excluded.rank_drops_30d,
rank_drops_90d = excluded.rank_drops_90d,
fba_fee = excluded.fba_fee,
fbm_fee = excluded.fbm_fee,
referral_percent = excluded.referral_percent,
can_sell = excluded.can_sell,
sellability_status = excluded.sellability_status,
sellability_reason = excluded.sellability_reason,
verdict = excluded.verdict,
confidence = excluded.confidence,
reasoning = excluded.reasoning,
fetched_at = excluded.fetched_at;
`);
db.transaction((resultsBatch: AnalysisResult[]) => {
for (const r of resultsBatch) {
const price =
r.product.keepa?.currentPrice ??
r.product.record.sellingPriceFromSheet ??
r.product.spApi.estimatedSalePrice;
const rank = r.product.keepa?.salesRank ?? r.product.record.amazonRank;
insertStmt.run(
r.product.record.asin,
runId,
r.product.record.name,
r.product.record.brand ?? null,
r.product.record.category ??
r.product.keepa?.categoryTree?.join(" > ") ??
null,
r.product.record.unitCost ?? null,
price ?? null,
r.product.keepa?.avgPrice90 ?? null,
r.product.record.avgPrice90FromSheet ?? null,
r.product.record.sellingPriceFromSheet ?? null,
rank ?? null,
r.product.keepa?.salesRankAvg90 ?? null,
r.product.keepa?.sellerCount ?? null,
r.product.keepa?.monthlySold ?? null,
r.product.keepa?.salesRankDrops30 ?? null,
r.product.keepa?.salesRankDrops90 ?? null,
r.product.spApi.fbaFee ?? null,
r.product.spApi.fbmFee ?? null,
r.product.spApi.referralFeePercent ?? null,
r.product.spApi.canSell == null
? "unknown"
: r.product.spApi.canSell
? "yes"
: "no",
r.product.spApi.sellabilityStatus ?? null,
r.product.spApi.sellabilityReason ?? null,
r.verdict.verdict,
r.verdict.confidence,
r.verdict.reasoning ?? null,
r.product.fetchedAt,
);
}
})(results); // Execute the transaction with the results batch
}
function loadCategoryBlacklist(filePath: string): Set<number> { function loadCategoryBlacklist(filePath: string): Set<number> {
const blacklist = new Set<number>(); const blacklist = new Set<number>();
@@ -660,7 +542,7 @@ async function discoverCategories(
maxCategories: number, maxCategories: number,
): Promise<CategoryInfo[]> { ): Promise<CategoryInfo[]> {
const data = await keepaGetJson( const data = await keepaGetJson(
`/category?key=${encodeURIComponent(config.keepaApiKey)}&domain=${DOMAIN_US}&category=0`, `/category?key=${encodeURIComponent(config.keepaApiKey)}&domain=${DOMAIN_US}&category=0&parents=0`,
); );
const categories = normalizeCategoryList(data); const categories = normalizeCategoryList(data);
@@ -704,10 +586,14 @@ async function fetchCategoryBestSellerAsins(
]; ];
for (const value of candidates) { for (const value of candidates) {
if (Array.isArray(value)) { if (Array.isArray(value)) {
return [ return [
...new Set(value.map((v) => String(v).trim()).filter(Boolean)), ...new Set(
].slice(0, limit); value
.map((v) => normalizeAsin(v))
.filter((asin): asin is string => asin !== null),
),
].slice(0, limit);
} }
} }
@@ -816,6 +702,14 @@ function parseKeepaProduct(product: Record<string, any>): KeepaData {
const monthlySold = const monthlySold =
pickKeepaNumber(product.monthlySold, stats?.monthlySold) ?? pickKeepaNumber(product.monthlySold, stats?.monthlySold) ??
salesRankDrops30; salesRankDrops30;
const amazonIsSeller = resolveAmazonIsSeller(product, stats, csv);
const amazonBuyboxSharePct90d =
extractAmazonBuyboxSharePct90d(product, stats) ??
computeAmazonBuyBoxSharePctFromHistory(
product.buyBoxSellerIdHistory,
90,
new Set([AMAZON_US_SELLER_ID]),
);
return { return {
currentPrice: extractCurrentPrice(csv), currentPrice: extractCurrentPrice(csv),
@@ -827,6 +721,8 @@ function parseKeepaProduct(product: Record<string, any>): KeepaData {
salesRankDrops30, salesRankDrops30,
salesRankDrops90, salesRankDrops90,
sellerCount: stats?.current?.[11] ?? null, sellerCount: stats?.current?.[11] ?? null,
amazonIsSeller,
amazonBuyboxSharePct90d,
buyBoxSeller: product.buyBoxSellerId ?? null, buyBoxSeller: product.buyBoxSellerId ?? null,
buyBoxPrice: stats?.current?.[18] != null ? stats.current[18] / 100 : null, buyBoxPrice: stats?.current?.[18] != null ? stats.current[18] / 100 : null,
monthlySold, monthlySold,
@@ -835,6 +731,108 @@ function parseKeepaProduct(product: Record<string, any>): KeepaData {
}; };
} }
function resolveAmazonIsSeller(
product: Record<string, any>,
stats: Record<string, any> | undefined,
csv: number[][] | undefined,
): boolean | null {
if (typeof product.isAmazonSeller === "boolean")
return product.isAmazonSeller;
if (typeof product.availabilityAmazon === "number") {
if (product.availabilityAmazon >= 0) return true;
if (
product.availabilityAmazon === -1 ||
product.availabilityAmazon === -2
) {
return false;
}
}
if (stats?.buyBoxIsAmazon === true) return true;
if (typeof stats?.current?.[0] === "number") {
if (stats.current[0] > 0) return true;
if (stats.current[0] === -1 || stats.current[0] === -2) return false;
}
const latestAmazonPrice = extractLatestPositivePrice(csv?.[0]);
if (latestAmazonPrice != null) return true;
return null;
}
function extractAmazonBuyboxSharePct90d(
product: Record<string, any>,
stats: Record<string, any> | undefined,
): number | null {
const candidates: unknown[] = [
product.buyBoxStatsAmazon90,
stats?.buyBoxStatsAmazon90,
product.buyBoxStats?.amazon90,
product.buyBoxStats?.amazon?.[90],
product.buyBoxStats?.amazon?.["90"],
product.buyBoxStats?.[AMAZON_US_SELLER_ID]?.[90],
product.buyBoxStats?.[AMAZON_US_SELLER_ID]?.["90"],
];
for (const value of candidates) {
if (typeof value !== "number" || !Number.isFinite(value)) continue;
if (value < 0 || value > 100) continue;
return Math.round(value * 100) / 100;
}
return null;
}
function computeAmazonBuyBoxSharePctFromHistory(
history: unknown,
windowDays: number,
amazonSellerIds: Set<string>,
): number | null {
if (!Array.isArray(history) || history.length < 2) return null;
const nowKeepaMinutes =
Math.floor(Date.now() / 60_000) - KEEPA_MINUTES_OFFSET;
const windowStart = nowKeepaMinutes - windowDays * 24 * 60;
let qualifiedMinutes = 0;
let amazonMinutes = 0;
for (let i = 0; i < history.length - 1; i += 2) {
const startMinute = Number.parseInt(String(history[i]), 10);
const sellerId = String(history[i + 1] ?? "").toUpperCase();
const nextRaw = i + 2 < history.length ? history[i + 2] : nowKeepaMinutes;
const endMinute = Number.parseInt(String(nextRaw), 10);
if (!Number.isFinite(startMinute) || !Number.isFinite(endMinute)) continue;
if (endMinute <= startMinute) continue;
const intervalStart = Math.max(startMinute, windowStart);
const intervalEnd = Math.min(endMinute, nowKeepaMinutes);
if (intervalEnd <= intervalStart) continue;
if (sellerId === "-1" || sellerId === "-2") continue;
const minutes = intervalEnd - intervalStart;
qualifiedMinutes += minutes;
if (amazonSellerIds.has(sellerId)) {
amazonMinutes += minutes;
}
}
if (qualifiedMinutes === 0) return null;
return Math.round((amazonMinutes / qualifiedMinutes) * 10_000) / 100;
}
function extractLatestPositivePrice(series: unknown): number | null {
if (!Array.isArray(series) || series.length < 2) return null;
const last = series[series.length - 1];
if (typeof last !== "number" || !Number.isFinite(last) || last <= 0) {
return null;
}
return last / 100;
}
async function fetchKeepaEnrichmentMap( async function fetchKeepaEnrichmentMap(
asins: string[], asins: string[],
): Promise<Map<string, { keepa: KeepaData; title: string }>> { ): Promise<Map<string, { keepa: KeepaData; title: string }>> {
@@ -844,13 +842,13 @@ async function fetchKeepaEnrichmentMap(
const chunk = asins.slice(i, i + KEEPA_PRODUCT_CHUNK_SIZE); const chunk = asins.slice(i, i + KEEPA_PRODUCT_CHUNK_SIZE);
const asinParam = encodeURIComponent(chunk.join(",")); const asinParam = encodeURIComponent(chunk.join(","));
const data = await keepaGetJson( const data = await keepaGetJson(
`/product?key=${encodeURIComponent(config.keepaApiKey)}&domain=${DOMAIN_US}&asin=${asinParam}&stats=90`, `/product?key=${encodeURIComponent(config.keepaApiKey)}&domain=${DOMAIN_US}&asin=${asinParam}&stats=90&buybox=1&days=90`,
); );
const products = Array.isArray(data?.products) ? data.products : []; const products = Array.isArray(data?.products) ? data.products : [];
for (const product of products) { for (const product of products) {
const asin = String(product?.asin ?? "").trim(); const asin = normalizeAsin(product?.asin);
if (!asin) continue; if (!asin) continue;
out.set(asin, { out.set(asin, {
keepa: parseKeepaProduct(product), keepa: parseKeepaProduct(product),
title: String(product?.title ?? "").trim(), title: String(product?.title ?? "").trim(),
@@ -937,12 +935,12 @@ function buildEnrichedProducts(
} }
export async function processCategory( export async function processCategory(
db: Database,
runId: number, runId: number,
category: CategoryInfo, category: CategoryInfo,
perCategoryTop: number, perCategoryTop: number,
categoryCandidatePool: number, categoryCandidatePool: number,
minMonthlySold: number, minMonthlySold: number,
useClaude = false,
): Promise<CategoryRunSummary> { ): Promise<CategoryRunSummary> {
log("info", `\nCategory ${category.label} (${category.id})`); log("info", `\nCategory ${category.label} (${category.id})`);
@@ -952,7 +950,7 @@ export async function processCategory(
); );
if (topAsins.length === 0) { if (topAsins.length === 0) {
log("info", " Keepa returned no ASINs for this category."); log("info", " Keepa returned no ASINs for this category.");
await updateCategoryRunSummary(db, runId, { await updateCategoryRunSummary(runId, {
topAsinsChecked: 0, topAsinsChecked: 0,
availableAsins: 0, availableAsins: 0,
fba: 0, fba: 0,
@@ -996,7 +994,7 @@ export async function processCategory(
` Sellable ASINs: ${availableAsins.length}/${uniqueTopAsins.length}`, ` Sellable ASINs: ${availableAsins.length}/${uniqueTopAsins.length}`,
); );
if (availableAsins.length === 0) { if (availableAsins.length === 0) {
await updateCategoryRunSummary(db, runId, { await updateCategoryRunSummary(runId, {
topAsinsChecked: uniqueTopAsins.length, topAsinsChecked: uniqueTopAsins.length,
availableAsins: 0, availableAsins: 0,
fba: 0, fba: 0,
@@ -1033,7 +1031,7 @@ export async function processCategory(
); );
if (selectedAsins.length === 0) { if (selectedAsins.length === 0) {
await updateCategoryRunSummary(db, runId, { await updateCategoryRunSummary(runId, {
topAsinsChecked: uniqueTopAsins.length, topAsinsChecked: uniqueTopAsins.length,
availableAsins: 0, availableAsins: 0,
fba: 0, fba: 0,
@@ -1077,7 +1075,7 @@ export async function processCategory(
let batchVerdicts: LlmVerdict[]; let batchVerdicts: LlmVerdict[];
try { try {
batchVerdicts = await analyzeProducts(batch); batchVerdicts = await analyzeProducts(batch, { useClaude });
} catch (err) { } catch (err) {
const message = err instanceof Error ? err.message : String(err); const message = err instanceof Error ? err.message : String(err);
log("warn", ` LLM batch failed: ${message}`); log("warn", ` LLM batch failed: ${message}`);
@@ -1100,7 +1098,7 @@ export async function processCategory(
}, },
})); }));
await insertProductAnalysisResults(db, runId, batchResults); await insertProductAnalysisResults(runId, batchResults);
for (const result of batchResults) { for (const result of batchResults) {
results.push(result); results.push(result);
@@ -1113,7 +1111,7 @@ export async function processCategory(
} }
} }
await updateCategoryRunSummary(db, runId, { await updateCategoryRunSummary(runId, {
topAsinsChecked: uniqueTopAsins.length, topAsinsChecked: uniqueTopAsins.length,
availableAsins: selectedAsins.length, availableAsins: selectedAsins.length,
fba, fba,
@@ -1133,7 +1131,7 @@ export async function processCategory(
} }
} }
await updateCategoryRunSummary(db, runId, { await updateCategoryRunSummary(runId, {
topAsinsChecked: uniqueTopAsins.length, topAsinsChecked: uniqueTopAsins.length,
availableAsins: selectedAsins.length, availableAsins: selectedAsins.length,
fba, fba,
@@ -1162,10 +1160,6 @@ export async function main(): Promise<void> {
assertSpApiPrerequisites(); assertSpApiPrerequisites();
mkdirSync(args.outputDir, { recursive: true }); mkdirSync(args.outputDir, { recursive: true });
const DB_PATH =
process.env.RESULTS_DB_PATH || path.join(process.cwd(), "results.db");
initDb(DB_PATH);
const db = getDb(DB_PATH);
log("info", "Starting per-category monthly-sold pipeline"); log("info", "Starting per-category monthly-sold pipeline");
log("info", `Marketplace: ${config.spApiMarketplaceId}`); log("info", `Marketplace: ${config.spApiMarketplaceId}`);
@@ -1202,7 +1196,6 @@ export async function main(): Promise<void> {
let runId: number | undefined; let runId: number | undefined;
try { try {
runId = await insertCategoryRunSummary( runId = await insertCategoryRunSummary(
db,
{ {
categoryId: category.id, categoryId: category.id,
categoryLabel: category.label, categoryLabel: category.label,
@@ -1219,12 +1212,12 @@ export async function main(): Promise<void> {
); );
categorySummary = await processCategory( categorySummary = await processCategory(
db,
runId, runId,
category, category,
args.perCategoryTop, args.perCategoryTop,
args.categoryCandidatePool, args.categoryCandidatePool,
args.minMonthlySold, args.minMonthlySold,
args.useClaude,
); );
totalInsertedAsins += categorySummary.results?.length ?? 0; totalInsertedAsins += categorySummary.results?.length ?? 0;
@@ -1250,7 +1243,7 @@ export async function main(): Promise<void> {
results: [], results: [],
}; };
if (runId) { if (runId) {
await updateCategoryRunSummary(db, runId, { await updateCategoryRunSummary(runId, {
topAsinsChecked: 0, topAsinsChecked: 0,
availableAsins: 0, availableAsins: 0,
fba: 0, fba: 0,

View File

@@ -1,20 +1,16 @@
import { getDb } from "./database.ts"; import { db } from "./db/index.ts";
import path from "node:path"; import { runs } from "./db/schema.ts";
import { eq } from "drizzle-orm";
async function checkDb() { async function checkDb() {
const DB_PATH = path.join(process.cwd(), "temp_output", "analysis.sqlite");
const db = getDb(DB_PATH);
try { try {
const query = db.query( const result = await db
"SELECT * FROM category_analysis_runs WHERE category_id = ?", .select()
); .from(runs)
const result = query.all(19419898011); .where(eq(runs.type, "category_analysis"));
console.log(JSON.stringify(result, null, 2)); console.log(JSON.stringify(result, null, 2));
} catch (error) { } catch (error) {
console.error("Database query failed:", error); console.error("Database query failed:", error);
} finally {
db.close();
} }
} }

View File

@@ -20,7 +20,12 @@ export const config = {
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"),
anthropicApiKey: Bun.env.ANTHROPIC_API_KEY,
anthropicModel: Bun.env.ANTHROPIC_MODEL,
cacheTtl: parseInt(optional("CACHE_TTL", "86400"), 10), cacheTtl: parseInt(optional("CACHE_TTL", "86400"), 10),
searxngUrl: optional("SEARXNG_URL", "https://searxng.nvictor.me/"),
searxngTimeoutMs: parseInt(optional("SEARXNG_TIMEOUT_MS", "10000"), 10),
searxngMaxResults: parseInt(optional("SEARXNG_MAX_RESULTS", "10"), 10),
spApiClientId: Bun.env.SP_API_CLIENT_ID, spApiClientId: Bun.env.SP_API_CLIENT_ID,
spApiClientSecret: Bun.env.SP_API_CLIENT_SECRET, spApiClientSecret: Bun.env.SP_API_CLIENT_SECRET,
spApiRefreshToken: Bun.env.SP_API_REFRESH_TOKEN, spApiRefreshToken: Bun.env.SP_API_REFRESH_TOKEN,

View File

@@ -1,277 +0,0 @@
import { Database } from "bun:sqlite";
export { Database } from "bun:sqlite";
let db: Database | null = null;
export function getDb(dbPath: string): Database {
if (!db) {
db = new Database(dbPath);
db.run("PRAGMA journal_mode = WAL;"); // Enable WAL mode for better performance
db.run("PRAGMA foreign_keys = ON;"); // Enforce foreign key constraints
}
return db;
}
export function closeDb(): void {
if (db) {
db.close();
db = null;
}
}
function createProductAnalysisResultsTable(database: Database): void {
database.run(`
CREATE TABLE IF NOT EXISTS product_analysis_results (
id INTEGER PRIMARY KEY AUTOINCREMENT,
asin TEXT NOT NULL,
run_id INTEGER NOT NULL,
name TEXT NOT NULL,
brand TEXT,
category TEXT,
unit_cost REAL,
current_price REAL,
avg_price_90d REAL,
avg_price_90d_sheet REAL,
selling_price_sheet REAL,
sales_rank INTEGER,
sales_rank_avg_90d INTEGER,
seller_count INTEGER,
monthly_sold INTEGER,
rank_drops_30d INTEGER,
rank_drops_90d INTEGER,
fba_fee REAL,
fbm_fee REAL,
referral_percent REAL,
can_sell TEXT,
sellability_status TEXT,
sellability_reason TEXT,
verdict TEXT NOT NULL,
confidence REAL NOT NULL,
reasoning TEXT,
fetched_at TEXT NOT NULL,
UNIQUE(asin),
FOREIGN KEY (run_id) REFERENCES category_analysis_runs(id)
);
`);
}
function ensureProductAnalysisResultsTable(database: Database): void {
const tableInfo = database
.query("PRAGMA table_info(product_analysis_results)")
.all() as Array<{ name: string; pk: number }>;
if (tableInfo.length === 0) {
createProductAnalysisResultsTable(database);
return;
}
const hasIdColumn = tableInfo.some((col) => col.name === "id");
const hasAsinPrimaryKey = tableInfo.some(
(col) => col.name === "asin" && col.pk === 1,
);
const indexList = database
.query("PRAGMA index_list(product_analysis_results)")
.all() as Array<{ name: string; unique: number }>;
const hasUniqueAsinConstraint = indexList.some((idx) => {
if (idx.unique !== 1) return false;
const columns = database
.query(`PRAGMA index_info(${JSON.stringify(idx.name)})`)
.all() as Array<{ name: string }>;
return columns.length === 1 && columns[0]?.name === "asin";
});
if (!hasIdColumn || hasAsinPrimaryKey || !hasUniqueAsinConstraint) {
database.run(
"ALTER TABLE product_analysis_results RENAME TO product_analysis_results_legacy",
);
createProductAnalysisResultsTable(database);
database.run(`
WITH ranked AS (
SELECT
asin, run_id, name, brand, category, unit_cost,
current_price, avg_price_90d, avg_price_90d_sheet,
selling_price_sheet, sales_rank, sales_rank_avg_90d,
seller_count, monthly_sold, rank_drops_30d, rank_drops_90d,
fba_fee, fbm_fee, referral_percent, can_sell,
sellability_status, sellability_reason,
verdict, confidence, reasoning, fetched_at,
ROW_NUMBER() OVER (
PARTITION BY asin
ORDER BY datetime(fetched_at) DESC, run_id DESC, id DESC
) AS row_num
FROM product_analysis_results_legacy
)
INSERT INTO product_analysis_results (
asin, run_id, name, brand, category, unit_cost,
current_price, avg_price_90d, avg_price_90d_sheet,
selling_price_sheet, sales_rank, sales_rank_avg_90d,
seller_count, monthly_sold, rank_drops_30d, rank_drops_90d,
fba_fee, fbm_fee, referral_percent, can_sell,
sellability_status, sellability_reason,
verdict, confidence, reasoning, fetched_at
)
SELECT
asin, run_id, name, brand, category, unit_cost,
current_price, avg_price_90d, avg_price_90d_sheet,
selling_price_sheet, sales_rank, sales_rank_avg_90d,
seller_count, monthly_sold, rank_drops_30d, rank_drops_90d,
fba_fee, fbm_fee, referral_percent, can_sell,
sellability_status, sellability_reason,
verdict, confidence, reasoning, fetched_at
FROM ranked
WHERE row_num = 1
`);
database.run("DROP TABLE product_analysis_results_legacy");
}
}
function ensureResultsTableColumns(database: Database): void {
const tableInfo = database
.query("PRAGMA table_info(results)")
.all() as Array<{ name: string }>;
if (tableInfo.length === 0) {
return;
}
const existingColumns = new Set(tableInfo.map((col) => col.name));
const requiredColumns: Array<{ name: string; type: string }> = [
{ name: "fba_net_sheet", type: "REAL" },
{ name: "gross_profit_dollar", type: "REAL" },
{ name: "gross_profit_pct", type: "REAL" },
{ name: "net_profit_sheet", type: "REAL" },
{ name: "roi_sheet", type: "REAL" },
{ name: "moq", type: "INTEGER" },
{ name: "moq_cost", type: "REAL" },
{ name: "qty_available", type: "INTEGER" },
{ name: "supplier", type: "TEXT" },
{ name: "source_url", type: "TEXT" },
{ name: "asin_link", type: "TEXT" },
{ name: "promo_coupon_code", type: "TEXT" },
{ name: "notes", type: "TEXT" },
{ name: "lead_date", type: "TEXT" },
];
for (const column of requiredColumns) {
if (!existingColumns.has(column.name)) {
database.run(
`ALTER TABLE results ADD COLUMN ${column.name} ${column.type}`,
);
}
}
}
export function initDb(dbPath: string): void {
const database = getDb(dbPath);
database.run(`
CREATE TABLE IF NOT EXISTS runs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
timestamp TEXT NOT NULL,
input_file TEXT NOT NULL,
output_file TEXT,
total_products INTEGER,
fba_count INTEGER,
fbm_count INTEGER,
skip_count INTEGER
);
`);
database.run(`
CREATE TABLE IF NOT EXISTS results (
id INTEGER PRIMARY KEY AUTOINCREMENT,
run_id INTEGER NOT NULL,
asin TEXT NOT NULL,
product_name TEXT,
brand TEXT,
category TEXT,
unit_cost REAL,
current_price REAL,
avg_price_90d REAL,
avg_price_90d_sheet REAL,
selling_price_sheet REAL,
sales_rank INTEGER,
rank_avg_90d INTEGER,
sellers INTEGER,
monthly_sold INTEGER,
rank_drops_30d INTEGER,
rank_drops_90d INTEGER,
fba_net_sheet REAL,
gross_profit_dollar REAL,
gross_profit_pct REAL,
net_profit_sheet REAL,
roi_sheet REAL,
moq INTEGER,
moq_cost REAL,
qty_available INTEGER,
supplier TEXT,
source_url TEXT,
asin_link TEXT,
promo_coupon_code TEXT,
notes TEXT,
lead_date TEXT,
fba_fee REAL,
fbm_fee REAL,
referral_percent REAL,
can_sell TEXT,
sellability_status TEXT,
sellability_reason TEXT,
verdict TEXT NOT NULL,
confidence INTEGER,
reasoning TEXT,
fetched_at TEXT NOT NULL,
FOREIGN KEY (run_id) REFERENCES runs(id)
);
`);
ensureResultsTableColumns(database);
database.run(`
CREATE TABLE IF NOT EXISTS category_analysis_runs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
category_id INTEGER NOT NULL,
category_label TEXT NOT NULL,
run_timestamp TEXT NOT NULL,
top_asins_checked INTEGER NOT NULL,
available_asins INTEGER NOT NULL,
fba_count INTEGER NOT NULL,
fbm_count INTEGER NOT NULL,
skip_count INTEGER NOT NULL,
status TEXT NOT NULL,
error_message TEXT
);
`);
ensureProductAnalysisResultsTable(database);
database.run(
`CREATE INDEX IF NOT EXISTS idx_runs_timestamp ON runs(timestamp DESC);`,
);
database.run(
`CREATE INDEX IF NOT EXISTS idx_results_run_id ON results(run_id);`,
);
database.run(
`CREATE INDEX IF NOT EXISTS idx_results_verdict ON results(verdict);`,
);
database.run(
`CREATE INDEX IF NOT EXISTS idx_results_sellability_status ON results(sellability_status);`,
);
database.run(
`CREATE INDEX IF NOT EXISTS idx_results_fetched_at ON results(fetched_at DESC);`,
);
database.run(`CREATE INDEX IF NOT EXISTS idx_results_asin ON results(asin);`);
database.run(
`CREATE INDEX IF NOT EXISTS idx_category_runs_timestamp ON category_analysis_runs(run_timestamp DESC);`,
);
database.run(
`CREATE INDEX IF NOT EXISTS idx_category_runs_status ON category_analysis_runs(status);`,
);
database.run(
`CREATE INDEX IF NOT EXISTS idx_product_results_run_id ON product_analysis_results(run_id);`,
);
database.run(
`CREATE INDEX IF NOT EXISTS idx_product_results_verdict ON product_analysis_results(verdict);`,
);
database.run(
`CREATE INDEX IF NOT EXISTS idx_product_results_sellability_status ON product_analysis_results(sellability_status);`,
);
database.run(
`CREATE INDEX IF NOT EXISTS idx_product_results_fetched_at ON product_analysis_results(fetched_at DESC);`,
);
}

15
src/db/index.ts Normal file
View File

@@ -0,0 +1,15 @@
import { drizzle } from "drizzle-orm/postgres-js";
import postgres from "postgres";
import * as schema from "./schema.ts";
const url = Bun.env.DB_CONNECTION_STRING;
if (!url) {
throw new Error("Missing required env var: DB_CONNECTION_STRING");
}
// Shared connection pool — imported once and reused across the process.
export const client = postgres(url);
export const db = drizzle(client, { schema });
export type Db = typeof db;

541
src/db/persistence.ts Normal file
View File

@@ -0,0 +1,541 @@
import { sql } from "drizzle-orm";
import { requireAsin, normalizeAsin } from "../asin.ts";
import type {
AnalysisResult,
ProductRecord,
SupplierAnalysisResult,
} from "../types.ts";
import { db } from "./index.ts";
import {
analysisRevisions,
analysisRunStats,
categoryRunDetails,
productIdentifiers,
productObservations,
products,
runItems,
runs,
sourcingInputs,
supplierScores,
upcResolutionCandidates,
upcResolutions,
} from "./schema.ts";
type Executor = any;
type MetadataSource = "input" | "catalog";
type ProductSeed = {
asin: string;
name?: string | null;
brand?: string | null;
category?: string | null;
metadataSource?: MetadataSource;
fetchedAt?: Date;
};
export type CategoryRunSummaryInput = {
categoryId: number;
categoryLabel: string;
topAsinsChecked: number;
availableAsins: number;
fba: number;
fbm: number;
skip: number;
status: "running" | "ok" | "empty" | "failed";
error: string;
};
export type RunCounts = {
totalProducts: number;
fbaCount: number;
fbmCount: number;
skipCount: number;
};
function emptyToNull(value: string | undefined | null): string | null {
const trimmed = value?.trim();
return trimmed ? trimmed : null;
}
function productCategory(record: ProductRecord, result: AnalysisResult): string | null {
return emptyToNull(
record.category ?? result.product.keepa?.categoryTree?.join(" > "),
);
}
export async function upsertProduct(
seed: ProductSeed,
executor: Executor = db,
): Promise<string> {
const asin = requireAsin(seed.asin);
const now = seed.fetchedAt ?? new Date();
const isCatalog = seed.metadataSource === "catalog";
await executor
.insert(products)
.values({
asin,
name: emptyToNull(seed.name),
brand: emptyToNull(seed.brand),
category: emptyToNull(seed.category),
metadataFetchedAt: isCatalog ? now : null,
firstSeenAt: now,
lastSeenAt: now,
})
.onConflictDoUpdate({
target: products.asin,
set: {
lastSeenAt: sql`GREATEST(${products.lastSeenAt}, EXCLUDED.last_seen_at)`,
name: isCatalog
? sql`CASE WHEN EXCLUDED.metadata_fetched_at >= COALESCE(${products.metadataFetchedAt}, '-infinity'::timestamptz) AND NULLIF(EXCLUDED.name, '') IS NOT NULL THEN EXCLUDED.name ELSE ${products.name} END`
: sql`COALESCE(${products.name}, NULLIF(EXCLUDED.name, ''))`,
brand: isCatalog
? sql`CASE WHEN EXCLUDED.metadata_fetched_at >= COALESCE(${products.metadataFetchedAt}, '-infinity'::timestamptz) AND NULLIF(EXCLUDED.brand, '') IS NOT NULL THEN EXCLUDED.brand ELSE ${products.brand} END`
: sql`COALESCE(${products.brand}, NULLIF(EXCLUDED.brand, ''))`,
category: isCatalog
? sql`CASE WHEN EXCLUDED.metadata_fetched_at >= COALESCE(${products.metadataFetchedAt}, '-infinity'::timestamptz) AND NULLIF(EXCLUDED.category, '') IS NOT NULL THEN EXCLUDED.category ELSE ${products.category} END`
: sql`COALESCE(${products.category}, NULLIF(EXCLUDED.category, ''))`,
metadataFetchedAt: isCatalog
? sql`GREATEST(COALESCE(${products.metadataFetchedAt}, '-infinity'::timestamptz), EXCLUDED.metadata_fetched_at)`
: products.metadataFetchedAt,
},
});
return asin;
}
export async function insertObservation(
runId: number,
result: AnalysisResult,
source: string,
executor: Executor = db,
): Promise<number> {
const fetchedAt = new Date(result.product.fetchedAt);
const record = result.product.record;
const keepa = result.product.keepa;
const spApi = result.product.spApi;
const asin = requireAsin(record.asin);
const [observation] = await executor
.insert(productObservations)
.values({
productAsin: asin,
runId,
source,
currentPrice:
keepa?.currentPrice ??
record.sellingPriceFromSheet ??
spApi.estimatedSalePrice ??
null,
avgPrice90d: keepa?.avgPrice90 ?? null,
salesRank: keepa?.salesRank ?? record.amazonRank ?? null,
salesRankAvg90d: keepa?.salesRankAvg90 ?? null,
monthlySold: keepa?.monthlySold ?? null,
rankDrops30d: keepa?.salesRankDrops30 ?? null,
rankDrops90d: keepa?.salesRankDrops90 ?? null,
sellerCount: keepa?.sellerCount ?? null,
amazonIsSeller: keepa?.amazonIsSeller ?? null,
amazonBuyboxSharePct90d: keepa?.amazonBuyboxSharePct90d ?? null,
fbaFee: spApi.fbaFee ?? null,
fbmFee: spApi.fbmFee ?? null,
referralPercent: spApi.referralFeePercent ?? null,
canSell: spApi.canSell,
sellabilityStatus: spApi.sellabilityStatus,
sellabilityReason: spApi.sellabilityReason ?? null,
fetchedAt,
})
.returning({ id: productObservations.id });
if (!observation) throw new Error(`Failed to insert observation for ${asin}`);
return observation.id;
}
function sourcingInputValues(runItemId: number, record: ProductRecord) {
return {
runItemId,
suppliedName: emptyToNull(record.name),
suppliedBrand: emptyToNull(record.brand),
suppliedCategory: emptyToNull(record.category),
unitCost: record.unitCost ?? null,
avgPrice90dSheet: record.avgPrice90FromSheet ?? null,
sellingPriceSheet: record.sellingPriceFromSheet ?? null,
fbaNetSheet: record.fbaNet ?? null,
grossProfitDollar: record.grossProfit ?? null,
grossProfitPct: record.grossProfitPct ?? null,
netProfitSheet: record.netProfitFromSheet ?? null,
roiSheet: record.roiFromSheet ?? null,
moq: record.moq ?? null,
moqCost: record.moqCost ?? null,
qtyAvailable: record.totalQtyAvail ?? null,
supplier: emptyToNull(record.supplier),
sourceUrl: emptyToNull(record.sourceUrl),
asinLink: emptyToNull(record.asinLink),
promoCouponCode: emptyToNull(record.promoCouponCode),
notes: emptyToNull(record.notes),
leadDate: emptyToNull(record.leadDate),
};
}
export async function persistLlmResults(
runId: number,
results: AnalysisResult[],
options: {
source: string;
metadataSource?: MetadataSource;
preserveSourcingInput?: boolean;
sourceInventoryIds?: Map<string, number>;
},
): Promise<void> {
for (const result of results) {
const record = result.product.record;
const fetchedAt = new Date(result.product.fetchedAt);
const asin = await upsertProduct({
asin: record.asin,
name: record.name,
brand: record.brand,
category: productCategory(record, result),
metadataSource: options.metadataSource ?? "input",
fetchedAt,
});
const [item] = await db
.insert(runItems)
.values({
runId,
productAsin: asin,
sourceInventoryItemId: options.sourceInventoryIds?.get(asin) ?? null,
})
.returning({ id: runItems.id });
if (!item) throw new Error(`Failed to insert run item for ${asin}`);
if (options.preserveSourcingInput) {
await db.insert(sourcingInputs).values(sourcingInputValues(item.id, record));
}
const observationId = await insertObservation(runId, result, options.source);
await db.insert(analysisRevisions).values({
runItemId: item.id,
observationId,
method: "llm",
decision: result.verdict.verdict,
confidence: result.verdict.confidence,
reasoning: result.verdict.reasoning ?? null,
analyzedAt: fetchedAt,
});
}
}
function supplierSourcingValues(runItemId: number, result: SupplierAnalysisResult) {
return {
runItemId,
suppliedName: emptyToNull(result.record.name),
suppliedBrand: emptyToNull(result.record.brand),
suppliedCategory: emptyToNull(result.record.category),
unitCost: result.record.unitCost ?? null,
};
}
async function insertSupplierObservation(
runId: number,
productAsin: string,
result: SupplierAnalysisResult,
): Promise<number | null> {
const keepa = result.keepa;
const spApi = result.spApi;
if (!spApi && !keepa) return null;
const [row] = await db
.insert(productObservations)
.values({
productAsin,
runId,
source: "supplier_upc",
currentPrice: result.score.salePrice,
avgPrice90d: keepa?.avgPrice90 ?? null,
salesRank: keepa?.salesRank ?? null,
salesRankAvg90d: keepa?.salesRankAvg90 ?? null,
monthlySold: keepa?.monthlySold ?? null,
rankDrops30d: keepa?.salesRankDrops30 ?? null,
rankDrops90d: keepa?.salesRankDrops90 ?? null,
sellerCount: keepa?.sellerCount ?? null,
amazonIsSeller: keepa?.amazonIsSeller ?? null,
amazonBuyboxSharePct90d: keepa?.amazonBuyboxSharePct90d ?? null,
fbaFee: spApi?.fbaFee ?? null,
fbmFee: spApi?.fbmFee ?? null,
referralPercent: spApi?.referralFeePercent ?? null,
canSell: spApi?.canSell ?? null,
sellabilityStatus: spApi?.sellabilityStatus ?? null,
sellabilityReason: spApi?.sellabilityReason ?? null,
fetchedAt: new Date(result.fetchedAt),
})
.returning({ id: productObservations.id });
return row?.id ?? null;
}
export async function persistSupplierResults(
runId: number,
results: SupplierAnalysisResult[],
): Promise<void> {
for (const result of results) {
const resolvedAsin = normalizeAsin(result.lookup.asin);
if (resolvedAsin) {
await upsertProduct({
asin: resolvedAsin,
name: result.record.name,
brand: result.record.brand,
category: result.record.category,
metadataSource: "input",
fetchedAt: new Date(result.fetchedAt),
});
if (result.keepa?.categoryTree?.length) {
await upsertProduct({
asin: resolvedAsin,
category: result.keepa.categoryTree.join(" > "),
metadataSource: "catalog",
fetchedAt: new Date(result.fetchedAt),
});
}
}
const [item] = await db
.insert(runItems)
.values({
runId,
productAsin: resolvedAsin,
sourceRow: result.rowNumber ?? null,
})
.returning({ id: runItems.id });
if (!item) throw new Error("Failed to insert supplier run item");
await db.insert(sourcingInputs).values(supplierSourcingValues(item.id, result));
await db.insert(upcResolutions).values({
runItemId: item.id,
requestedUpc: result.upc,
normalizedUpc: result.lookup.normalizedUpc,
provider: result.lookup.provider ?? "unknown",
status: result.lookup.status,
reason: result.lookup.reason ?? null,
resolvedProductAsin: resolvedAsin,
resolvedAt: new Date(result.fetchedAt),
});
for (const candidate of result.lookup.candidateAsins) {
const candidateAsin = normalizeAsin(candidate);
if (!candidateAsin) continue;
await upsertProduct({ asin: candidateAsin, fetchedAt: new Date(result.fetchedAt) });
await db
.insert(upcResolutionCandidates)
.values({ runItemId: item.id, productAsin: candidateAsin })
.onConflictDoUpdate({
target: [
upcResolutionCandidates.runItemId,
upcResolutionCandidates.productAsin,
],
set: { productAsin: sql`EXCLUDED.product_asin` },
});
}
if (resolvedAsin) {
await db
.insert(productIdentifiers)
.values({
productAsin: resolvedAsin,
identifierType:
result.lookup.normalizedUpc.length === 12
? "upc"
: result.lookup.normalizedUpc.length === 13
? "ean"
: "gtin",
identifierValue: result.lookup.normalizedUpc,
source: "supplier_upc",
confirmedAt: new Date(result.fetchedAt),
})
.onConflictDoUpdate({
target: [
productIdentifiers.identifierType,
productIdentifiers.identifierValue,
],
set: {
productAsin: resolvedAsin,
source: "supplier_upc",
confirmedAt: new Date(result.fetchedAt),
},
});
}
const observationId = resolvedAsin
? await insertSupplierObservation(runId, resolvedAsin, result)
: null;
const [revision] = await db
.insert(analysisRevisions)
.values({
runItemId: item.id,
observationId,
method: "supplier_scoring",
decision: result.score.verdict,
confidence: result.score.score,
reasoning: result.score.reason,
analyzedAt: new Date(result.fetchedAt),
})
.returning({ id: analysisRevisions.id });
if (!revision) throw new Error("Failed to insert supplier analysis revision");
await db.insert(supplierScores).values({
revisionId: revision.id,
score: result.score.score,
salePrice: result.score.salePrice,
fbaFee: result.score.fbaFee,
profit: result.score.profit,
margin: result.score.margin,
roi: result.score.roi,
reason: result.score.reason,
});
}
}
export async function createCategoryRun(
summary: CategoryRunSummaryInput,
runTimestamp: string,
): Promise<number> {
const [row] = await db
.insert(runs)
.values({
type: "category_analysis",
status: summary.status,
errorMessage: summary.error || null,
startedAt: new Date(runTimestamp),
})
.returning({ id: runs.id });
if (!row) throw new Error("Failed to insert category run.");
await db.insert(categoryRunDetails).values({
runId: row.id,
categoryId: summary.categoryId,
categoryLabel: summary.categoryLabel,
checkedAsinCount: summary.topAsinsChecked,
});
await db.insert(analysisRunStats).values({
runId: row.id,
processedCount: summary.topAsinsChecked,
availableCount: summary.availableAsins,
analyzedCount: summary.fba + summary.fbm + summary.skip,
fbaCount: summary.fba,
fbmCount: summary.fbm,
skipCount: summary.skip,
});
return row.id;
}
export async function updateCategoryRun(
runId: number,
summary: Pick<
CategoryRunSummaryInput,
| "topAsinsChecked"
| "availableAsins"
| "fba"
| "fbm"
| "skip"
| "status"
| "error"
>,
): Promise<void> {
await db
.update(runs)
.set({
status: summary.status,
errorMessage: summary.error || null,
...(summary.status !== "running" ? { completedAt: new Date() } : {}),
})
.where(sql`${runs.id} = ${runId}`);
await db
.insert(categoryRunDetails)
.values({
runId,
categoryId: 0,
categoryLabel: "",
checkedAsinCount: summary.topAsinsChecked,
})
.onConflictDoUpdate({
target: categoryRunDetails.runId,
set: { checkedAsinCount: summary.topAsinsChecked },
});
await db
.insert(analysisRunStats)
.values({
runId,
processedCount: summary.topAsinsChecked,
availableCount: summary.availableAsins,
analyzedCount: summary.fba + summary.fbm + summary.skip,
fbaCount: summary.fba,
fbmCount: summary.fbm,
skipCount: summary.skip,
})
.onConflictDoUpdate({
target: analysisRunStats.runId,
set: {
processedCount: summary.topAsinsChecked,
availableCount: summary.availableAsins,
analyzedCount: summary.fba + summary.fbm + summary.skip,
fbaCount: summary.fba,
fbmCount: summary.fbm,
skipCount: summary.skip,
},
});
}
export async function refreshRunStats(runId: number): Promise<RunCounts> {
const [stats] = await db.execute(
sql<{
total: string;
fba: string | null;
fbm: string | null;
buy: string | null;
watch: string | null;
skip: string | null;
}>`WITH latest AS (
SELECT DISTINCT ON (ri.id) ar.decision
FROM run_items ri
JOIN analysis_revisions ar ON ar.run_item_id = ri.id
WHERE ri.run_id = ${runId}
ORDER BY ri.id, ar.analyzed_at DESC, ar.id DESC
)
SELECT
COUNT(*) AS total,
SUM(CASE WHEN decision = 'FBA' THEN 1 ELSE 0 END) AS fba,
SUM(CASE WHEN decision = 'FBM' THEN 1 ELSE 0 END) AS fbm,
SUM(CASE WHEN decision = 'BUY' THEN 1 ELSE 0 END) AS buy,
SUM(CASE WHEN decision = 'WATCH' THEN 1 ELSE 0 END) AS watch,
SUM(CASE WHEN decision = 'SKIP' THEN 1 ELSE 0 END) AS skip
FROM latest`,
);
const counts = {
totalProducts: Number(stats?.total ?? 0),
fbaCount: Number(stats?.fba ?? 0),
fbmCount: Number(stats?.fbm ?? 0),
skipCount: Number(stats?.skip ?? 0),
};
await db
.insert(analysisRunStats)
.values({
runId,
processedCount: counts.totalProducts,
analyzedCount: counts.totalProducts,
fbaCount: counts.fbaCount,
fbmCount: counts.fbmCount,
buyCount: Number(stats?.buy ?? 0),
watchCount: Number(stats?.watch ?? 0),
skipCount: counts.skipCount,
})
.onConflictDoUpdate({
target: analysisRunStats.runId,
set: {
processedCount: counts.totalProducts,
analyzedCount: counts.totalProducts,
fbaCount: counts.fbaCount,
fbmCount: counts.fbmCount,
buyCount: Number(stats?.buy ?? 0),
watchCount: Number(stats?.watch ?? 0),
skipCount: counts.skipCount,
},
});
return counts;
}

472
src/db/schema.ts Normal file
View File

@@ -0,0 +1,472 @@
import { sql } from "drizzle-orm";
import {
type AnyPgColumn,
boolean,
check,
index,
integer,
pgEnum,
pgTable,
primaryKey,
real,
serial,
text,
timestamp,
unique,
} from "drizzle-orm/pg-core";
export const runTypeEnum = pgEnum("run_type", [
"lead_analysis",
"category_analysis",
"supplier_upc",
"stalker",
"stalker_analysis",
]);
export const runStatusEnum = pgEnum("run_status", [
"running",
"ok",
"empty",
"failed",
"completed",
]);
export const analysisMethodEnum = pgEnum("analysis_method", [
"llm",
"supplier_scoring",
]);
export const analysisDecisionEnum = pgEnum("analysis_decision", [
"FBA",
"FBM",
"BUY",
"WATCH",
"SKIP",
]);
export const products = pgTable(
"products",
{
asin: text("asin").primaryKey(),
name: text("name"),
brand: text("brand"),
category: text("category"),
metadataFetchedAt: timestamp("metadata_fetched_at", { withTimezone: true }),
firstSeenAt: timestamp("first_seen_at", { withTimezone: true })
.notNull()
.defaultNow(),
lastSeenAt: timestamp("last_seen_at", { withTimezone: true })
.notNull()
.defaultNow(),
},
(t) => [
check("ck_products_asin", sql`${t.asin} ~ '^[A-Z0-9]{10}$'`),
index("idx_products_name").on(t.name),
index("idx_products_last_seen_at").on(t.lastSeenAt),
],
);
export const runs = pgTable(
"runs",
{
id: serial("id").primaryKey(),
type: runTypeEnum("type").notNull(),
parentRunId: integer("parent_run_id").references(
(): AnyPgColumn => runs.id,
{ onDelete: "cascade" },
),
inputFile: text("input_file"),
outputFile: text("output_file"),
status: runStatusEnum("status").notNull().default("running"),
errorMessage: text("error_message"),
startedAt: timestamp("started_at", { withTimezone: true })
.notNull()
.defaultNow(),
completedAt: timestamp("completed_at", { withTimezone: true }),
},
(t) => [
index("idx_runs_started_at").on(t.startedAt),
index("idx_runs_type").on(t.type),
index("idx_runs_status").on(t.status),
index("idx_runs_parent_run_id").on(t.parentRunId),
],
);
export const analysisRunStats = pgTable("analysis_run_stats", {
runId: integer("run_id")
.primaryKey()
.references(() => runs.id, { onDelete: "cascade" }),
processedCount: integer("processed_count").notNull().default(0),
analyzedCount: integer("analyzed_count").notNull().default(0),
availableCount: integer("available_count").notNull().default(0),
fbaCount: integer("fba_count").notNull().default(0),
fbmCount: integer("fbm_count").notNull().default(0),
buyCount: integer("buy_count").notNull().default(0),
watchCount: integer("watch_count").notNull().default(0),
skipCount: integer("skip_count").notNull().default(0),
});
export const categoryRunDetails = pgTable("category_run_details", {
runId: integer("run_id")
.primaryKey()
.references(() => runs.id, { onDelete: "cascade" }),
categoryId: integer("category_id").notNull(),
categoryLabel: text("category_label").notNull(),
checkedAsinCount: integer("checked_asin_count").notNull().default(0),
selectionParametersJson: text("selection_parameters_json"),
});
export const stalkerRunDetails = pgTable("stalker_run_details", {
runId: integer("run_id")
.primaryKey()
.references(() => runs.id, { onDelete: "cascade" }),
requestedAsins: integer("requested_asins").notNull().default(0),
skippedAsins: integer("skipped_asins").notNull().default(0),
scannedAsins: integer("scanned_asins").notNull().default(0),
sourceAsinsWithMatches: integer("source_asins_with_matches")
.notNull()
.default(0),
candidateSellers: integer("candidate_sellers").notNull().default(0),
qualifyingSellers: integer("qualifying_sellers").notNull().default(0),
matchedSellers: integer("matched_sellers").notNull().default(0),
sellerMetadataRequests: integer("seller_metadata_requests")
.notNull()
.default(0),
sellerStorefrontRequests: integer("seller_storefront_requests")
.notNull()
.default(0),
inventorySellabilityCheckedAsins: integer(
"inventory_sellability_checked_asins",
)
.notNull()
.default(0),
inventorySellabilityAvailableAsins: integer(
"inventory_sellability_available_asins",
)
.notNull()
.default(0),
inventorySellabilityExcludedAsins: integer(
"inventory_sellability_excluded_asins",
)
.notNull()
.default(0),
persistedInventoryAsins: integer("persisted_inventory_asins")
.notNull()
.default(0),
});
export const productIdentifiers = pgTable(
"product_identifiers",
{
id: serial("id").primaryKey(),
productAsin: text("product_asin")
.notNull()
.references(() => products.asin),
identifierType: text("identifier_type").notNull(),
identifierValue: text("identifier_value").notNull(),
source: text("source").notNull(),
confirmedAt: timestamp("confirmed_at", { withTimezone: true })
.notNull()
.defaultNow(),
},
(t) => [
unique("uq_product_identifier_type_value").on(
t.identifierType,
t.identifierValue,
),
index("idx_product_identifiers_asin").on(t.productAsin),
],
);
export const productObservations = pgTable(
"product_observations",
{
id: serial("id").primaryKey(),
productAsin: text("product_asin")
.notNull()
.references(() => products.asin),
runId: integer("run_id")
.notNull()
.references(() => runs.id, { onDelete: "cascade" }),
source: text("source").notNull(),
marketplace: text("marketplace").notNull().default("US"),
currentPrice: real("current_price"),
avgPrice90d: real("avg_price_90d"),
salesRank: integer("sales_rank"),
salesRankAvg90d: integer("sales_rank_avg_90d"),
monthlySold: integer("monthly_sold"),
rankDrops30d: integer("rank_drops_30d"),
rankDrops90d: integer("rank_drops_90d"),
sellerCount: integer("seller_count"),
amazonIsSeller: boolean("amazon_is_seller"),
amazonBuyboxSharePct90d: real("amazon_buybox_share_pct_90d"),
fbaFee: real("fba_fee"),
fbmFee: real("fbm_fee"),
referralPercent: real("referral_percent"),
canSell: boolean("can_sell"),
sellabilityStatus: text("sellability_status"),
sellabilityReason: text("sellability_reason"),
rawProductJson: text("raw_product_json"),
fetchedAt: timestamp("fetched_at", { withTimezone: true }).notNull(),
},
(t) => [
index("idx_product_observations_product_time").on(
t.productAsin,
t.fetchedAt.desc(),
),
index("idx_product_observations_run_id").on(t.runId),
index("idx_product_observations_sellability").on(t.sellabilityStatus),
],
);
export const runItems = pgTable(
"run_items",
{
id: serial("id").primaryKey(),
runId: integer("run_id")
.notNull()
.references(() => runs.id, { onDelete: "cascade" }),
productAsin: text("product_asin").references(() => products.asin),
sourceInventoryItemId: integer("source_inventory_item_id").references(
(): AnyPgColumn => stalkerInventoryItems.id,
{ onDelete: "set null" },
),
ordinal: integer("ordinal"),
sourceRow: integer("source_row"),
status: text("status").notNull().default("completed"),
createdAt: timestamp("created_at", { withTimezone: true })
.notNull()
.defaultNow(),
},
(t) => [
index("idx_run_items_run_id").on(t.runId),
index("idx_run_items_product_asin").on(t.productAsin),
],
);
export const sourcingInputs = pgTable("sourcing_inputs", {
runItemId: integer("run_item_id")
.primaryKey()
.references(() => runItems.id, { onDelete: "cascade" }),
suppliedName: text("supplied_name"),
suppliedBrand: text("supplied_brand"),
suppliedCategory: text("supplied_category"),
unitCost: real("unit_cost"),
avgPrice90dSheet: real("avg_price_90d_sheet"),
sellingPriceSheet: real("selling_price_sheet"),
fbaNetSheet: real("fba_net_sheet"),
grossProfitDollar: real("gross_profit_dollar"),
grossProfitPct: real("gross_profit_pct"),
netProfitSheet: real("net_profit_sheet"),
roiSheet: real("roi_sheet"),
moq: integer("moq"),
moqCost: real("moq_cost"),
qtyAvailable: integer("qty_available"),
supplier: text("supplier"),
sourceUrl: text("source_url"),
asinLink: text("asin_link"),
promoCouponCode: text("promo_coupon_code"),
notes: text("notes"),
leadDate: text("lead_date"),
});
export const upcResolutions = pgTable(
"upc_resolutions",
{
runItemId: integer("run_item_id")
.primaryKey()
.references(() => runItems.id, { onDelete: "cascade" }),
requestedUpc: text("requested_upc").notNull(),
normalizedUpc: text("normalized_upc").notNull(),
provider: text("provider").notNull(),
status: text("status").notNull(),
reason: text("reason"),
resolvedProductAsin: text("resolved_product_asin").references(
() => products.asin,
),
resolvedAt: timestamp("resolved_at", { withTimezone: true })
.notNull()
.defaultNow(),
},
(t) => [index("idx_upc_resolutions_normalized_upc").on(t.normalizedUpc)],
);
export const upcResolutionCandidates = pgTable(
"upc_resolution_candidates",
{
runItemId: integer("run_item_id")
.notNull()
.references(() => upcResolutions.runItemId, { onDelete: "cascade" }),
productAsin: text("product_asin")
.notNull()
.references(() => products.asin),
},
(t) => [
primaryKey({ columns: [t.runItemId, t.productAsin] }),
index("idx_upc_candidates_product_asin").on(t.productAsin),
],
);
export const analysisRevisions = pgTable(
"analysis_revisions",
{
id: serial("id").primaryKey(),
runItemId: integer("run_item_id")
.notNull()
.references(() => runItems.id, { onDelete: "cascade" }),
observationId: integer("observation_id").references(
() => productObservations.id,
{ onDelete: "set null" },
),
method: analysisMethodEnum("method").notNull(),
decision: analysisDecisionEnum("decision").notNull(),
confidence: real("confidence"),
reasoning: text("reasoning"),
analyzedAt: timestamp("analyzed_at", { withTimezone: true })
.notNull()
.defaultNow(),
},
(t) => [
index("idx_analysis_revisions_run_item_time").on(t.runItemId, t.analyzedAt),
index("idx_analysis_revisions_decision").on(t.decision),
],
);
export const supplierScores = pgTable("supplier_scores", {
revisionId: integer("revision_id")
.primaryKey()
.references(() => analysisRevisions.id, { onDelete: "cascade" }),
score: real("score"),
salePrice: real("sale_price"),
fbaFee: real("fba_fee"),
profit: real("profit"),
margin: real("margin"),
roi: real("roi"),
reason: text("reason"),
});
export const sellers = pgTable("sellers", {
sellerId: text("seller_id").primaryKey(),
sellerName: text("seller_name"),
rating: real("rating"),
ratingCount: integer("rating_count"),
storefrontAsinTotal: integer("storefront_asin_total"),
persistedInventorySampleCount: integer("persisted_inventory_sample_count"),
lastUpdatedAt: timestamp("last_updated_at", { withTimezone: true }).notNull(),
rawSellerJson: text("raw_seller_json"),
});
export const stalkerScans = pgTable(
"stalker_scans",
{
id: serial("id").primaryKey(),
runId: integer("run_id")
.notNull()
.references(() => runs.id, { onDelete: "cascade" }),
sourceProductAsin: text("source_product_asin")
.notNull()
.references(() => products.asin),
observationId: integer("observation_id").references(
() => productObservations.id,
{ onDelete: "set null" },
),
offerCount: integer("offer_count").notNull().default(0),
candidateSellerCount: integer("candidate_seller_count")
.notNull()
.default(0),
matchedSellerCount: integer("matched_seller_count").notNull().default(0),
fetchedAt: timestamp("fetched_at", { withTimezone: true }).notNull(),
},
(t) => [
unique("uq_stalker_scans_run_source_product").on(
t.runId,
t.sourceProductAsin,
),
index("idx_stalker_scans_run_id").on(t.runId),
index("idx_stalker_scans_source_asin").on(t.sourceProductAsin),
],
);
export const stalkerScanSellers = pgTable(
"stalker_scan_sellers",
{
id: serial("id").primaryKey(),
scanId: integer("scan_id")
.notNull()
.references(() => stalkerScans.id, { onDelete: "cascade" }),
sellerId: text("seller_id")
.notNull()
.references(() => sellers.sellerId),
offerPrice: real("offer_price"),
condition: text("condition"),
isFba: boolean("is_fba"),
stock: integer("stock"),
sellerRating: real("seller_rating"),
sellerRatingCount: integer("seller_rating_count"),
rawOfferJson: text("raw_offer_json"),
},
(t) => [
unique("uq_stalker_scan_sellers_scan_seller").on(t.scanId, t.sellerId),
],
);
export const stalkerInventoryItems = pgTable(
"stalker_inventory_items",
{
id: serial("id").primaryKey(),
runId: integer("run_id")
.notNull()
.references(() => runs.id, { onDelete: "cascade" }),
sellerId: text("seller_id")
.notNull()
.references(() => sellers.sellerId),
productAsin: text("product_asin")
.notNull()
.references(() => products.asin),
observationId: integer("observation_id")
.notNull()
.references(() => productObservations.id, { onDelete: "cascade" }),
lastSeenAt: timestamp("last_seen_at", { withTimezone: true }).notNull(),
rawInventoryJson: text("raw_inventory_json"),
},
(t) => [
unique("uq_stalker_inventory_items_run_seller_asin").on(
t.runId,
t.sellerId,
t.productAsin,
),
index("idx_stalker_inventory_seller_id").on(t.sellerId),
index("idx_stalker_inventory_product_asin").on(t.productAsin),
],
);
export const productDistributorResearch = pgTable(
"product_distributor_research",
{
id: serial("id").primaryKey(),
productAsin: text("product_asin")
.notNull()
.references(() => products.asin, { onDelete: "cascade" }),
runItemId: integer("run_item_id").references(
(): AnyPgColumn => runItems.id,
{ onDelete: "set null" },
),
inventoryItemId: integer("inventory_item_id").references(
(): AnyPgColumn => stalkerInventoryItems.id,
{ onDelete: "set null" },
),
provider: text("provider").notNull().default("claude"),
model: text("model").notNull(),
status: text("status").notNull().default("completed"),
queryContextJson: text("query_context_json"),
distributorsJson: text("distributors_json"),
rawResponse: text("raw_response"),
createdAt: timestamp("created_at", { withTimezone: true })
.notNull()
.defaultNow(),
},
(t) => [
index("idx_distributor_research_asin_time").on(t.productAsin, t.createdAt),
index("idx_distributor_research_run_item").on(t.runItemId),
],
);

View File

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

View File

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

View File

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

580
src/integrations/keepa.ts Normal file
View File

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

View File

@@ -1,7 +1,7 @@
import { config } from "./config.ts"; import { config } from "../config.ts";
import type { EnrichedProduct, LlmVerdict } from "./types.ts"; import type { EnrichedProduct, LlmVerdict } from "../types.ts";
const SYSTEM_PROMPT = `You are an expert Amazon product analyst specializing in FBA and FBM fulfillment strategy. const SYSTEM_PROMPT_STRICT = `You are an expert Amazon product analyst specializing in FBA and FBM fulfillment strategy.
Given product data, evaluate each product's viability for selling on Amazon. Consider: Given product data, evaluate each product's viability for selling on Amazon. Consider:
@@ -29,14 +29,61 @@ Return ONLY a raw JSON array (no markdown, no code fences, no explanation before
Keep each reasoning under 100 characters to stay within output limits and mention key blocker if skipped (e.g., restricted, low demand, thin margin).`; Keep each reasoning under 100 characters to stay within output limits and mention key blocker if skipped (e.g., restricted, low demand, thin margin).`;
const SYSTEM_PROMPT_ASSUME_LISTABLE = `You are an expert Amazon product analyst specializing in FBA and FBM fulfillment strategy.
Given product data, evaluate each product's viability for selling on Amazon. Consider:
1. **Sales Velocity**: monthlySold and salesRankDrops30 are the most important signals. A product that doesn't sell is worthless regardless of margin. salesRankDrops30 = approximate units sold in 30 days. monthlySold is Keepa's estimate.
2. **Margin Analysis**: Sale price minus unit cost minus fees (FBA or FBM). Aim for >30% ROI minimum. The spreadsheet may include FBA NET and gross profit estimates cross-check against Keepa pricing data.
3. **Sales Rank (BSR)**: Lower rank = higher demand. Rank <50,000 is good, <1,000 is excellent.
4. **Sales Rank Trend**: Compare current rank vs 90d average. Lower current = improving demand.
5. **Competition**: Number of sellers and Buy Box dynamics. Fewer sellers = easier entry.
6. **Price Stability**: Large price swings (high max vs low min over 90d) = volatile/risky.
7. **FBA vs FBM**: FBA preferred for fast-selling, small/light items. FBM for oversized, slow-moving, or high-margin items where fee savings matter.
8. **MOQ & Capital**: High MOQ with thin margins is risky.
9. **Supply Availability**: Total quantity available from supplier low stock means limited runway.
Decision policy:
- Ignore seller eligibility restrictions/status in this run.
- Assume all products are listable by this seller account.
- Prioritize profitable + high-velocity products.
- Use "SKIP" when data quality is poor or risk is high.
Return ONLY a raw JSON array (no markdown, no code fences, no explanation before or after). One verdict per product:
[{ "asin": "B...", "verdict": "FBA" | "FBM" | "SKIP", "confidence": 0-100, "reasoning": "..." }]
Keep each reasoning under 100 characters to stay within output limits and mention key blocker if skipped (e.g., low demand, thin margin).`;
type AnalyzeProductsOptions = {
ignoreSellability?: boolean;
useClaude?: boolean;
};
type LlmProvider = "lm-studio" | "claude";
type LmStudioResponse = {
choices?: { message?: { content?: string } }[];
};
type ClaudeResponse = {
content?: Array<{ type?: string; text?: string }>;
};
function getSystemPrompt(options: AnalyzeProductsOptions): string {
if (options.ignoreSellability) {
return SYSTEM_PROMPT_ASSUME_LISTABLE;
}
return SYSTEM_PROMPT_STRICT;
}
export async function analyzeProducts( export async function analyzeProducts(
products: EnrichedProduct[], products: EnrichedProduct[],
options: AnalyzeProductsOptions = {},
): Promise<LlmVerdict[]> { ): Promise<LlmVerdict[]> {
try { try {
return await analyzeProductsInternal(products); return await analyzeProductsInternal(products, options);
} catch (err) { } catch (err) {
const msg = String(err); if (products.length > 1 && isContextOverflowError(err)) {
if (products.length > 1 && msg.includes("Context size has been exceeded")) {
console.warn( console.warn(
`LLM context exceeded for batch of ${products.length}, retrying one product at a time...`, `LLM context exceeded for batch of ${products.length}, retrying one product at a time...`,
); );
@@ -44,7 +91,7 @@ export async function analyzeProducts(
const fallback: LlmVerdict[] = []; const fallback: LlmVerdict[] = [];
for (const product of products) { for (const product of products) {
try { try {
const single = await analyzeProductsInternal([product]); const single = await analyzeProductsInternal([product], options);
fallback.push( fallback.push(
single[0] ?? { single[0] ?? {
asin: product.record.asin, asin: product.record.asin,
@@ -64,15 +111,65 @@ export async function analyzeProducts(
} }
return fallback; return fallback;
} }
throw err;
const errorReason = formatErrorReason(err);
console.warn(
`LLM request failed for ${products.length} product(s): ${errorReason}`,
);
return products.map((product) => ({
asin: product.record.asin,
verdict: "SKIP" as const,
confidence: 0,
reasoning: `LLM analysis failed: ${errorReason}`,
}));
} }
} }
async function analyzeProductsInternal( async function analyzeProductsInternal(
products: EnrichedProduct[], products: EnrichedProduct[],
options: AnalyzeProductsOptions,
): Promise<LlmVerdict[]> { ): Promise<LlmVerdict[]> {
const productSummaries = products.map(summarizeForLlm); const productSummaries = products.map((p) =>
summarizeForLlm(p, options.ignoreSellability === true),
);
const systemPrompt = getSystemPrompt(options);
const provider = options.useClaude ? "claude" : "lm-studio";
const content = await requestLlmContent(
provider,
systemPrompt,
productSummaries,
);
return parseVerdicts(content, products);
}
function isContextOverflowError(err: unknown): boolean {
const msg = String(err).toLowerCase();
return (
msg.includes("context size has been exceeded") ||
msg.includes("prompt is too long") ||
msg.includes("too many tokens") ||
msg.includes("maximum context") ||
msg.includes("context length") ||
msg.includes("max_tokens")
);
}
async function requestLlmContent(
provider: LlmProvider,
systemPrompt: string,
productSummaries: ReturnType<typeof summarizeForLlm>[],
): Promise<string> {
if (provider === "claude") {
return requestClaudeContent(systemPrompt, productSummaries);
}
return requestLmStudioContent(systemPrompt, productSummaries);
}
async function requestLmStudioContent(
systemPrompt: string,
productSummaries: ReturnType<typeof summarizeForLlm>[],
): Promise<string> {
const res = await fetch(`${config.llmUrl}/chat/completions`, { const res = await fetch(`${config.llmUrl}/chat/completions`, {
method: "POST", method: "POST",
headers: { headers: {
@@ -82,7 +179,7 @@ async function analyzeProductsInternal(
body: JSON.stringify({ body: JSON.stringify({
model: config.llmModel, model: config.llmModel,
messages: [ messages: [
{ role: "system", content: SYSTEM_PROMPT }, { role: "system", content: systemPrompt },
{ role: "user", content: JSON.stringify(productSummaries, null, 2) }, { role: "user", content: JSON.stringify(productSummaries, null, 2) },
], ],
temperature: 0.3, temperature: 0.3,
@@ -91,18 +188,111 @@ async function analyzeProductsInternal(
}); });
if (!res.ok) { if (!res.ok) {
throw new Error(`LLM API error ${res.status}: ${await res.text()}`); throw new Error(`LLM API error ${res.status}: ${await readErrorBody(res)}`);
} }
const data = (await res.json()) as { const data = (await res.json()) as LmStudioResponse;
choices?: { message?: { content?: string } }[]; return data.choices?.[0]?.message?.content ?? "";
};
const content = data.choices?.[0]?.message?.content ?? "";
return parseVerdicts(content, products);
} }
function summarizeForLlm(p: EnrichedProduct) { async function requestClaudeContent(
systemPrompt: string,
productSummaries: ReturnType<typeof summarizeForLlm>[],
): Promise<string> {
if (!config.anthropicApiKey) {
throw new Error(
"Missing required env var for --claude mode: ANTHROPIC_API_KEY",
);
}
const model = resolveAnthropicModel(
config.anthropicModel ?? "claude-sonnet-4-6",
);
const res = await fetch("https://api.anthropic.com/v1/messages", {
method: "POST",
headers: {
"Content-Type": "application/json",
"x-api-key": config.anthropicApiKey,
"anthropic-version": "2023-06-01",
},
body: JSON.stringify({
model,
system: systemPrompt,
messages: [
{ role: "user", content: JSON.stringify(productSummaries, null, 2) },
],
temperature: 0.3,
max_tokens: 2048,
}),
});
if (!res.ok) {
throw new Error(
`Claude API error ${res.status}: ${await readErrorBody(res)}`,
);
}
const data = (await res.json()) as ClaudeResponse;
if (!Array.isArray(data.content)) {
return "";
}
return data.content
.filter((block) => block?.type === "text" && typeof block.text === "string")
.map((block) => block.text ?? "")
.join("\n");
}
function resolveAnthropicModel(rawModel: string): string {
const normalized = rawModel.trim().toLowerCase();
const aliases: Record<string, string> = {
"claude-4-6-sonnet": "claude-sonnet-4-6",
"claude-4-6-haiku": "claude-haiku-4-5",
"claude-4-7-opus": "claude-opus-4-7",
};
const mapped = aliases[normalized];
if (mapped && mapped !== rawModel) {
console.warn(
`ANTHROPIC_MODEL '${rawModel}' is not an official API ID. Using '${mapped}' instead.`,
);
return mapped;
}
return rawModel;
}
function formatErrorReason(err: unknown): string {
const message = String(err).replace(/\s+/g, " ").trim();
if (!message) return "Unknown LLM error";
return message.length > 140 ? `${message.slice(0, 137)}...` : message;
}
async function readErrorBody(response: Response): Promise<string> {
const text = await response.text();
if (!text.trim()) return "No response body";
try {
const parsed = JSON.parse(text) as {
error?: { message?: string; type?: string };
};
const type = parsed.error?.type?.trim();
const message = parsed.error?.message?.trim();
if (type && message) {
return `${type}: ${message}`;
}
if (message) {
return message;
}
} catch {
// Response was plain text.
}
return text;
}
function summarizeForLlm(p: EnrichedProduct, ignoreSellability: boolean) {
const salePrice = const salePrice =
p.keepa?.currentPrice ?? p.keepa?.currentPrice ??
p.record.sellingPriceFromSheet ?? p.record.sellingPriceFromSheet ??
@@ -169,9 +359,11 @@ function summarizeForLlm(p: EnrichedProduct) {
referralFee != null ? Math.round(referralFee * 100) / 100 : null, referralFee != null ? Math.round(referralFee * 100) / 100 : null,
}, },
sellerEligibility: { sellerEligibility: {
canSell: p.spApi.canSell, canSell: ignoreSellability ? true : p.spApi.canSell,
status: p.spApi.sellabilityStatus, status: ignoreSellability ? "available" : p.spApi.sellabilityStatus,
reason: clampText(p.spApi.sellabilityReason, 120), reason: ignoreSellability
? "Assumed listable by sellability=all"
: clampText(p.spApi.sellabilityReason, 120),
}, },
estimatedProfit: estimatedProfit:
fbaProfit != null && fbmProfit != null fbaProfit != null && fbmProfit != null

View File

@@ -0,0 +1,351 @@
import { afterAll, beforeEach, expect, mock, test } from "bun:test";
import { normalizeAsin, searchProductOffers } from "./searxng.ts";
const originalFetch = globalThis.fetch;
beforeEach(() => {
globalThis.fetch = originalFetch;
});
afterAll(() => {
globalThis.fetch = originalFetch;
});
test("normalizeAsin uppercases and validates ASINs", () => {
expect(normalizeAsin(" b07sn9bhvv ")).toBe("B07SN9BHVV");
expect(normalizeAsin("0306406152")).toBe("0306406152");
expect(() => normalizeAsin("not-an-asin")).toThrow("Invalid ASIN");
});
test("searchProductOffers derives ASIN search behavior for ASIN-only queries", async () => {
const fetchMock = mock(async (input: string | URL | Request) => {
const url = input instanceof URL ? input : new URL(String(input));
expect(url.pathname).toBe("/search");
expect(url.searchParams.get("format")).toBe("json");
expect(url.searchParams.get("q")).toBe("B07SN9BHVV price sale offer buy online");
return Response.json({
results: [
{
title: "Amazon listing B07SN9BHVV",
url: "https://www.amazon.com/dp/B07SN9BHVV",
content: "Official marketplace listing.",
engines: ["duckduckgo"],
},
{
title: "Romand palette offer",
url: "https://example-shop.com/item",
content: "Buy product ASIN B07SN9BHVV. Offer price: $12.99 today.",
engines: ["brave"],
},
],
});
});
const results = await searchProductOffers("B07SN9BHVV", {
provider: "searxng",
baseUrl: "https://searxng.test/",
fetchImpl: fetchMock as unknown as typeof fetch,
maxResults: 10,
});
expect(results).toHaveLength(2);
expect(results[0]?.domain).toBe("example-shop.com");
expect(results[0]?.matchedAsin).toBe("B07SN9BHVV");
expect(results[0]?.detectedPrice).toBe(12.99);
expect(results[0]?.detectedPriceCurrency).toBe("USD");
expect(results[0]?.detectedPriceLabel).toBe("offer price");
expect(results[0]?.detectedPriceText).toBe("$12.99");
expect(results[0]?.engines).toEqual(["brave"]);
expect(fetchMock).toHaveBeenCalledTimes(1);
});
test("searchProductOffers falls back to HTML when JSON is unavailable", async () => {
const html = `
<article class="result result-default category-general">
<a class="url_header" href="https://supplier.example/products/romand"></a>
<h3><a href="https://supplier.example/products/romand">Supplier offer B07SN9BHVV</a></h3>
<p class="content">Wholesale product sale price: USD 9.50 with ASIN B07SN9BHVV.</p>
<div class="engines"><span>duckduckgo</span></div>
</article>
`;
const fetchMock = mock(async (input: string | URL | Request) => {
const url = input instanceof URL ? input : new URL(String(input));
if (url.searchParams.get("format") === "json") {
return new Response("forbidden", { status: 403 });
}
return new Response(html, {
status: 200,
headers: { "content-type": "text/html" },
});
});
const results = await searchProductOffers("B07SN9BHVV", {
provider: "searxng",
baseUrl: "https://searxng.test/",
fetchImpl: fetchMock as unknown as typeof fetch,
});
expect(results).toHaveLength(1);
expect(results[0]?.title).toBe("Supplier offer B07SN9BHVV");
expect(results[0]?.domain).toBe("supplier.example");
expect(results[0]?.detectedPrice).toBe(9.5);
expect(results[0]?.detectedPriceLabel).toBe("sale price");
expect(results[0]?.detectedPriceText).toBe("USD 9.50");
expect(results[0]?.matchedAsin).toBe("B07SN9BHVV");
expect(results[0]?.engines).toEqual(["duckduckgo"]);
expect(fetchMock).toHaveBeenCalledTimes(2);
});
test("searchProductOffers detects common selling and sale price formats", async () => {
const fetchMock = mock(async () =>
Response.json({
results: [
{
title: "Supplier page",
url: "https://supplier.example/item",
content: "Selling price is €18.75 and list price is $24.00.",
},
{
title: "Backup page",
url: "https://backup.example/item",
content: "Available now for 22.10 USD.",
},
],
}),
);
const results = await searchProductOffers("romand palette price", {
provider: "searxng",
baseUrl: "https://searxng.test/",
fetchImpl: fetchMock as unknown as typeof fetch,
maxResults: 2,
});
expect(results[0]?.detectedPrice).toBe(18.75);
expect(results[0]?.detectedPriceCurrency).toBe("EUR");
expect(results[0]?.detectedPriceLabel).toBe("selling price");
expect(results[1]?.detectedPrice).toBe(22.1);
expect(results[1]?.detectedPriceCurrency).toBe("USD");
});
test("searchProductOffers filters unrelated priced results for ASIN-only queries", async () => {
const fetchMock = mock(async () =>
Response.json({
results: [
{
title: "Unrelated deal",
url: "https://deals.example/phones",
content: "This price is $449 but it is for another product.",
},
{
title: "Amazon listing B07SN9BHVV",
url: "https://www.amazon.in/dp/B07SN9BHVV",
content: "1 offer from ₹550.00 · Buying options.",
},
],
}),
);
const results = await searchProductOffers("B07SN9BHVV", {
provider: "searxng",
baseUrl: "https://searxng.test/",
fetchImpl: fetchMock as unknown as typeof fetch,
});
expect(results).toHaveLength(1);
expect(results[0]?.matchedAsin).toBe("B07SN9BHVV");
expect(results[0]?.detectedPrice).toBe(550);
expect(results[0]?.detectedPriceCurrency).toBe("INR");
expect(results[0]?.detectedPriceText).toBe("₹550.00");
});
test("searchProductOffers keeps arbitrary query strings generic", async () => {
const fetchMock = mock(async (input: string | URL | Request) => {
const url = input instanceof URL ? input : new URL(String(input));
expect(url.searchParams.get("q")).toBe("romand dry mango tulip price");
return Response.json({
results: [
{
title: "Generic result",
url: "https://shop.example/romand",
content: "Sale price: $14.25",
},
],
});
});
const results = await searchProductOffers("romand dry mango tulip price", {
provider: "searxng",
baseUrl: "https://searxng.test/",
fetchImpl: fetchMock as unknown as typeof fetch,
});
expect(results).toHaveLength(1);
expect(results[0]?.asin).toBeUndefined();
expect(results[0]?.detectedPrice).toBe(14.25);
});
test("searchProductOffers sends configured categories", async () => {
const fetchMock = mock(async (input: string | URL | Request) => {
const url = input instanceof URL ? input : new URL(String(input));
expect(url.searchParams.get("categories")).toBe("shopping");
return Response.json({
results: [
{
title: "Shopping result",
url: "https://shop.example/item",
content: "Offer price: $10.00",
},
],
});
});
const results = await searchProductOffers("romand price", {
provider: "searxng",
baseUrl: "https://searxng.test/",
categories: "shopping",
fetchImpl: fetchMock as unknown as typeof fetch,
});
expect(results[0]?.detectedPrice).toBe(10);
});
test("searchProductOffers sends configured SearXNG engines", async () => {
const fetchMock = mock(async (input: string | URL | Request) => {
const url = input instanceof URL ? input : new URL(String(input));
expect(url.searchParams.get("engines")).toBe("google");
expect(url.searchParams.get("q")).toBe("!go romand price");
return Response.json({
results: [
{
title: "Google-backed result",
url: "https://shop.example/item",
content: "Offer price: $11.00",
engine: "google",
},
],
});
});
const results = await searchProductOffers("romand price", {
provider: "searxng",
baseUrl: "https://searxng.test/",
engines: "google",
fetchImpl: fetchMock as unknown as typeof fetch,
});
expect(results[0]?.detectedPrice).toBe(11);
expect(results[0]?.engines).toEqual(["google"]);
});
test("searchProductOffers uses Google Custom Search API and pagemap offer prices", async () => {
const fetchMock = mock(async (input: string | URL | Request) => {
const url = input instanceof URL ? input : new URL(String(input));
expect(url.hostname).toBe("googleapis.test");
expect(url.searchParams.get("key")).toBe("test-key");
expect(url.searchParams.get("cx")).toBe("test-cx");
expect(url.searchParams.get("num")).toBe("5");
expect(url.searchParams.get("q")).toBe("romand dry mango tulip");
return Response.json({
items: [
{
title: "Romand Dry Mango Tulip",
link: "https://store.example/romand",
snippet: "Buy from Store Example.",
pagemap: {
offer: [{ price: "12.50", pricecurrency: "USD" }],
},
},
],
});
});
const results = await searchProductOffers("romand dry mango tulip", {
provider: "google-custom-search",
baseUrl: "https://googleapis.test/customsearch/v1",
googleApiKey: "test-key",
googleCx: "test-cx",
maxResults: 5,
fetchImpl: fetchMock as unknown as typeof fetch,
});
expect(results).toHaveLength(1);
expect(results[0]?.title).toContain("Romand Dry Mango Tulip");
expect(results[0]?.domain).toBe("store.example");
expect(results[0]?.detectedPrice).toBe(12.5);
expect(results[0]?.detectedPriceLabel).toBe("offer price");
expect(results[0]?.engines).toEqual(["google custom search"]);
});
test("searchProductOffers defaults to SerpApi Google Shopping results", async () => {
const fetchMock = mock(async (input: string | URL | Request) => {
const url = input instanceof URL ? input : new URL(String(input));
expect(url.hostname).toBe("serpapi.test");
expect(url.searchParams.get("engine")).toBe("google_shopping");
expect(url.searchParams.get("q")).toBe("dry mango tulip price");
expect(url.searchParams.get("api_key")).toBe("serpapi-key");
expect(url.searchParams.get("gl")).toBe("us");
expect(url.searchParams.get("hl")).toBe("en");
return Response.json({
shopping_results: [
{
position: 1,
title: "Romand Better Than Eyes Dry Mango Tulip",
source: "K-Beauty Store",
link: "https://store.example/products/romand",
price: "$13.40",
extracted_price: 13.4,
delivery: "$4.99 delivery",
rating: 4.7,
reviews: 128,
},
],
});
});
const results = await searchProductOffers("dry mango tulip price", {
baseUrl: "https://serpapi.test/search.json",
serpapiApiKey: "serpapi-key",
fetchImpl: fetchMock as unknown as typeof fetch,
});
expect(results).toHaveLength(1);
expect(results[0]?.domain).toBe("store.example");
expect(results[0]?.detectedPrice).toBe(13.4);
expect(results[0]?.detectedPriceText).toBe("$13.40");
expect(results[0]?.engines).toEqual(["serpapi google shopping"]);
});
test("searchProductOffers applies result limits and handles empty results", async () => {
const fetchMock = mock(async () =>
Response.json({
results: [
{ title: "One", url: "https://one.example", content: "No price" },
{ title: "Two", url: "https://two.example", content: "$20.00" },
],
}),
);
const limited = await searchProductOffers("romand palette", {
provider: "searxng",
baseUrl: "https://searxng.test/",
fetchImpl: fetchMock as unknown as typeof fetch,
maxResults: 1,
});
expect(limited).toHaveLength(1);
expect(limited[0]?.domain).toBe("two.example");
const emptyFetch = mock(async () => Response.json({ results: [] }));
const empty = await searchProductOffers("missing product", {
provider: "searxng",
baseUrl: "https://searxng.test/",
fetchImpl: emptyFetch as unknown as typeof fetch,
});
expect(empty).toEqual([]);
});

777
src/integrations/searxng.ts Normal file
View File

@@ -0,0 +1,777 @@
import { normalizeAsin as normalizeCanonicalAsin } from "../asin.ts";
const DEFAULT_SEARXNG_URL = "https://searxng.nvictor.me/";
const DEFAULT_GOOGLE_CUSTOM_SEARCH_URL =
"https://www.googleapis.com/customsearch/v1";
const DEFAULT_SERPAPI_URL = "https://serpapi.com/search.json";
const DEFAULT_TIMEOUT_MS = 10_000;
const DEFAULT_MAX_RESULTS = 10;
const ASIN_MATCH_REGEX = /\bB[0-9A-Z]{9}\b/gi;
const PRICE_LABELS = [
"selling price",
"sale price",
"offer price",
"current price",
"our price",
"list price",
"price",
] as const;
const CURRENCY_CODES = "USD|US\\$|EUR|GBP|INR|CAD|AUD";
const CURRENCY_SYMBOLS = "$€£₹";
const LABELED_PRICE_REGEX =
new RegExp(
`\\b(selling price|sale price|offer price|current price|our price|list price|price)\\b[^${escapeForCharClass(CURRENCY_SYMBOLS)}0-9]{0,24}((?:${CURRENCY_CODES})?\\s*[${escapeForCharClass(CURRENCY_SYMBOLS)}]\\s*[0-9]{1,5}(?:,[0-9]{3})*(?:\\.[0-9]{2})?|(?:${CURRENCY_CODES})\\s*[0-9]{1,5}(?:,[0-9]{3})*(?:\\.[0-9]{2})?)`,
"gi",
);
const PRICE_REGEX = new RegExp(
`((?:${CURRENCY_CODES})?\\s*[${escapeForCharClass(CURRENCY_SYMBOLS)}]\\s*[0-9]{1,5}(?:,[0-9]{3})*(?:\\.[0-9]{2})?|(?:${CURRENCY_CODES})\\s*[0-9]{1,5}(?:,[0-9]{3})*(?:\\.[0-9]{2})?|[0-9]{1,5}(?:,[0-9]{3})*(?:\\.[0-9]{2})?\\s*(?:${CURRENCY_CODES}))`,
"gi",
);
export type SearxngOfferSearchResult = {
asin?: string;
query: string;
title: string;
url: string;
domain: string;
snippet: string;
rank: number;
score: number;
matchedAsin?: string;
detectedPrice?: number;
detectedPriceCurrency?: string;
detectedPriceLabel?: string;
detectedPriceText?: string;
engines: string[];
};
export type SearxngSearchOptions = {
provider?: "serpapi" | "google-custom-search" | "searxng";
baseUrl?: string;
googleApiKey?: string;
googleCx?: string;
serpapiApiKey?: string;
timeoutMs?: number;
maxResults?: number;
page?: number;
categories?: string;
engines?: string;
includeUnmatchedAsinResults?: boolean;
fetchImpl?: typeof fetch;
};
type RawSearchResult = {
title: string;
url: string;
snippet: string;
engines: string[];
rank: number;
};
type JsonSearchResponse = {
results?: Array<Record<string, unknown>>;
};
type PriceDetection = {
amount: number;
currency: string;
text: string;
label?: string;
};
export async function searchAsinOffers(
asin: string,
options: SearxngSearchOptions = {},
): Promise<SearxngOfferSearchResult[]> {
return searchProductOffers(normalizeAsin(asin), options);
}
export async function searchProductOffers(
query: string,
options: SearxngSearchOptions = {},
): Promise<SearxngOfferSearchResult[]> {
const normalizedQuery = query.trim();
if (!normalizedQuery) {
throw new Error("Search query is required.");
}
const inferredAsin = getAsinQuery(normalizedQuery);
const searxngQuery = inferredAsin
? `${inferredAsin} price sale offer buy online`
: normalizedQuery;
const maxResults = positiveInteger(
options.maxResults ?? readEnvInt("SEARXNG_MAX_RESULTS", DEFAULT_MAX_RESULTS),
DEFAULT_MAX_RESULTS,
);
const rawResults =
options.provider === "searxng"
? await fetchSearxngResults(searxngQuery, options)
: options.provider === "google-custom-search"
? await fetchGoogleCustomSearchResults(searxngQuery, {
...options,
maxResults,
})
: await fetchSerpApiGoogleShoppingResults(searxngQuery, {
...options,
provider: "serpapi",
maxResults,
});
return rawResults
.map((result) => normalizeResult(result, searxngQuery, inferredAsin))
.filter((result) => {
if (!result.url) return false;
if (!inferredAsin || options.includeUnmatchedAsinResults) return true;
return result.matchedAsin === inferredAsin;
})
.sort((a, b) => b.score - a.score || a.rank - b.rank)
.slice(0, maxResults);
}
export function normalizeAsin(value: string): string {
const asin = normalizeCanonicalAsin(value);
if (!asin) {
throw new Error(`Invalid ASIN: ${value}`);
}
return asin;
}
function getAsinQuery(value: string): string | undefined {
return normalizeCanonicalAsin(value) ?? undefined;
}
async function fetchSearxngResults(
query: string,
options: SearxngSearchOptions,
): Promise<RawSearchResult[]> {
const baseUrl = normalizeBaseUrl(
options.baseUrl ?? Bun.env.SEARXNG_URL ?? DEFAULT_SEARXNG_URL,
);
const timeoutMs = positiveInteger(
options.timeoutMs ?? readEnvInt("SEARXNG_TIMEOUT_MS", DEFAULT_TIMEOUT_MS),
DEFAULT_TIMEOUT_MS,
);
const page = positiveInteger(options.page ?? 1, 1);
const categories = options.categories ?? "general";
const fetchImpl = options.fetchImpl ?? fetch;
const requestQuery = applySearxngEngineBang(query, options.engines);
const jsonUrl = buildSearchUrl(baseUrl, requestQuery, {
categories,
engines: options.engines,
page,
format: "json",
});
const jsonResponse = await fetchWithTimeout(fetchImpl, jsonUrl, timeoutMs);
if (isJsonResponse(jsonResponse)) {
const json = (await jsonResponse.json()) as JsonSearchResponse;
return parseJsonResults(json);
}
const htmlUrl = buildSearchUrl(baseUrl, requestQuery, {
categories,
engines: options.engines,
page,
});
const htmlResponse = await fetchWithTimeout(fetchImpl, htmlUrl, timeoutMs);
if (!htmlResponse.ok) {
throw new Error(
`SearXNG search failed: status=${htmlResponse.status} url=${htmlUrl.toString()}`,
);
}
return parseHtmlResults(await htmlResponse.text());
}
function applySearxngEngineBang(query: string, engines: string | undefined): string {
if (!engines || query.trim().startsWith("!")) return query;
const engineList = engines
.split(",")
.map((engine) => engine.trim().toLowerCase())
.filter(Boolean);
if (engineList.length !== 1) return query;
const shortcut = searxngEngineShortcut(engineList[0]!);
return shortcut ? `!${shortcut} ${query}` : query;
}
function searxngEngineShortcut(engine: string): string | undefined {
if (engine === "google") return "go";
return undefined;
}
function isJsonResponse(response: Response): boolean {
const contentType = response.headers.get("content-type") ?? "";
return response.ok && contentType.toLowerCase().includes("application/json");
}
async function fetchWithTimeout(
fetchImpl: typeof fetch,
url: URL,
timeoutMs: number,
): Promise<Response> {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), timeoutMs);
try {
return await fetchImpl(url, {
signal: controller.signal,
headers: {
accept: "application/json,text/html;q=0.9,*/*;q=0.8",
"user-agent": "asin-check/1.0 (+https://searxng.nvictor.me/)",
},
});
} finally {
clearTimeout(timeout);
}
}
function buildSearchUrl(
baseUrl: URL,
query: string,
params: { categories: string; engines?: string; page: number; format?: string },
): URL {
const url = new URL("search", baseUrl);
url.searchParams.set("q", query);
url.searchParams.set("categories", params.categories);
if (params.engines) {
url.searchParams.set("engines", params.engines);
}
url.searchParams.set("pageno", String(params.page));
if (params.format) {
url.searchParams.set("format", params.format);
}
return url;
}
async function fetchGoogleCustomSearchResults(
query: string,
options: SearxngSearchOptions,
): Promise<RawSearchResult[]> {
const apiKey = options.googleApiKey ?? Bun.env.GOOGLE_API_KEY;
const cx =
options.googleCx ??
Bun.env.GOOGLE_CSE_ID ??
Bun.env.GOOGLE_CX ??
Bun.env.GOOGLE_SEARCH_ENGINE_ID;
if (!apiKey) {
throw new Error("Missing GOOGLE_API_KEY for Google Custom Search.");
}
if (!cx) {
throw new Error(
"Missing Google Custom Search engine id. Set GOOGLE_CSE_ID, GOOGLE_CX, or GOOGLE_SEARCH_ENGINE_ID.",
);
}
const timeoutMs = positiveInteger(
options.timeoutMs ?? readEnvInt("SEARXNG_TIMEOUT_MS", DEFAULT_TIMEOUT_MS),
DEFAULT_TIMEOUT_MS,
);
const page = positiveInteger(options.page ?? 1, 1);
const num = Math.min(
10,
positiveInteger(options.maxResults ?? DEFAULT_MAX_RESULTS, DEFAULT_MAX_RESULTS),
);
const fetchImpl = options.fetchImpl ?? fetch;
const url = new URL(options.baseUrl ?? DEFAULT_GOOGLE_CUSTOM_SEARCH_URL);
url.searchParams.set("key", apiKey);
url.searchParams.set("cx", cx);
url.searchParams.set("q", query);
url.searchParams.set("num", String(num));
url.searchParams.set("start", String((page - 1) * num + 1));
const response = await fetchWithTimeout(fetchImpl, url, timeoutMs);
if (!response.ok) {
const body = await response.text().catch(() => "");
throw new Error(
`Google Custom Search failed: status=${response.status} ${body.slice(0, 300)}`,
);
}
const json = (await response.json()) as GoogleCustomSearchResponse;
return parseGoogleCustomSearchResults(json);
}
type GoogleCustomSearchResponse = {
items?: GoogleCustomSearchItem[];
};
type GoogleCustomSearchItem = {
title?: string;
link?: string;
snippet?: string;
displayLink?: string;
pagemap?: Record<string, unknown>;
};
type SerpApiShoppingResponse = {
shopping_results?: SerpApiShoppingResult[];
inline_shopping_results?: SerpApiShoppingResult[];
categorized_shopping_results?: Array<{
shopping_results?: SerpApiShoppingResult[];
}>;
error?: string;
};
type SerpApiShoppingResult = {
position?: number;
title?: string;
source?: string;
link?: string;
product_link?: string;
serpapi_product_api?: string;
price?: string;
extracted_price?: number;
old_price?: string;
extracted_old_price?: number;
delivery?: string;
rating?: number;
reviews?: number;
snippet?: string;
};
async function fetchSerpApiGoogleShoppingResults(
query: string,
options: SearxngSearchOptions,
): Promise<RawSearchResult[]> {
const apiKey = options.serpapiApiKey ?? Bun.env.SERPAPI_API_KEY;
if (!apiKey) {
throw new Error(
"Missing SERPAPI_API_KEY. Google does not provide an official public Shopping-tab search API; use SerpApi's google_shopping API or another SERP provider.",
);
}
const timeoutMs = positiveInteger(
options.timeoutMs ?? readEnvInt("SEARXNG_TIMEOUT_MS", DEFAULT_TIMEOUT_MS),
DEFAULT_TIMEOUT_MS,
);
const page = positiveInteger(options.page ?? 1, 1);
const fetchImpl = options.fetchImpl ?? fetch;
const url = new URL(options.baseUrl ?? DEFAULT_SERPAPI_URL);
url.searchParams.set("engine", "google_shopping");
url.searchParams.set("q", query);
url.searchParams.set("api_key", apiKey);
url.searchParams.set("google_domain", "google.com");
url.searchParams.set("gl", "us");
url.searchParams.set("hl", "en");
url.searchParams.set("start", String((page - 1) * 60));
const response = await fetchWithTimeout(fetchImpl, url, timeoutMs);
if (!response.ok) {
const body = await response.text().catch(() => "");
throw new Error(
`SerpApi Google Shopping failed: status=${response.status} ${body.slice(0, 300)}`,
);
}
const json = (await response.json()) as SerpApiShoppingResponse;
if (json.error) {
throw new Error(`SerpApi Google Shopping failed: ${json.error}`);
}
return parseSerpApiShoppingResults(json);
}
function parseSerpApiShoppingResults(
json: SerpApiShoppingResponse,
): RawSearchResult[] {
const results = [
...(json.shopping_results ?? []),
...(json.inline_shopping_results ?? []),
...(json.categorized_shopping_results ?? []).flatMap(
(category) => category.shopping_results ?? [],
),
];
return results.flatMap((item, index) => {
const url =
optionalString(item.link) ??
optionalString(item.product_link) ??
optionalString(item.serpapi_product_api);
if (!url) return [];
const priceText = optionalString(item.price);
const snippet = [
priceText ? `offer price: ${priceText}` : undefined,
optionalString(item.old_price)
? `list price: ${item.old_price}`
: undefined,
optionalString(item.source) ? `merchant: ${item.source}` : undefined,
optionalString(item.delivery),
optionalString(item.snippet),
typeof item.rating === "number" ? `rating: ${item.rating}` : undefined,
typeof item.reviews === "number" ? `reviews: ${item.reviews}` : undefined,
]
.filter((value): value is string => !!value)
.join(" ");
return [
{
title: optionalString(item.title) ?? "",
url,
snippet,
engines: ["serpapi google shopping"],
rank: item.position ?? index + 1,
},
];
});
}
function parseGoogleCustomSearchResults(
json: GoogleCustomSearchResponse,
): RawSearchResult[] {
return (json.items ?? []).flatMap((item, index) => {
const url = optionalString(item.link);
if (!url) return [];
const metadataText = extractGoogleCustomSearchMetadataText(item);
return [
{
title: optionalString(item.title) ?? "",
url,
snippet: [optionalString(item.snippet), metadataText]
.filter((value): value is string => !!value)
.join(" "),
engines: ["google custom search"],
rank: index + 1,
},
];
});
}
function extractGoogleCustomSearchMetadataText(
item: GoogleCustomSearchItem,
): string {
const pagemap = item.pagemap ?? {};
const chunks: string[] = [];
for (const offer of readPagemapObjects(pagemap.offer)) {
appendPriceMetadata(chunks, offer);
}
for (const product of readPagemapObjects(pagemap.product)) {
appendPriceMetadata(chunks, product);
}
for (const metatag of readPagemapObjects(pagemap.metatags)) {
appendPriceMetadata(chunks, metatag);
}
return chunks.join(" ");
}
function appendPriceMetadata(chunks: string[], value: Record<string, unknown>): void {
const price =
optionalString(value.price) ??
optionalString(value.lowprice) ??
optionalString(value.highprice) ??
optionalString(value["product:price:amount"]) ??
optionalString(value["og:price:amount"]) ??
optionalString(value["twitter:data1"]);
if (!price) return;
const currency =
optionalString(value.pricecurrency) ??
optionalString(value.priceCurrency) ??
optionalString(value["product:price:currency"]) ??
optionalString(value["og:price:currency"]);
chunks.push(currency ? `offer price: ${currency} ${price}` : `offer price: ${price}`);
}
function readPagemapObjects(value: unknown): Array<Record<string, unknown>> {
if (!Array.isArray(value)) return [];
return value.filter(
(item): item is Record<string, unknown> =>
item != null && typeof item === "object" && !Array.isArray(item),
);
}
function parseJsonResults(json: JsonSearchResponse): RawSearchResult[] {
return (json.results ?? []).flatMap((result, index) => {
const url = optionalString(result.url);
if (!url) return [];
return [
{
title: optionalString(result.title) ?? "",
url,
snippet: optionalString(result.content) ?? "",
engines: normalizeEngines(result.engines ?? result.engine),
rank: index + 1,
},
];
});
}
async function parseHtmlResults(html: string): Promise<RawSearchResult[]> {
type Draft = {
title: string;
url: string;
snippet: string;
engines: string[];
};
const results: RawSearchResult[] = [];
let current: Draft | null = null;
let currentTextTarget: "title" | "snippet" | "engine" | null = null;
const appendText = (text: string) => {
if (!current || !currentTextTarget) return;
const normalized = text.replace(/\s+/g, " ").trim();
if (!normalized) return;
if (currentTextTarget === "engine") {
current.engines.push(normalized);
return;
}
current[currentTextTarget] = appendWithSpace(
current[currentTextTarget],
normalized,
);
};
const response = new HTMLRewriter()
.on("article.result", {
element(element) {
current = { title: "", url: "", snippet: "", engines: [] };
const onEndTag = (element as unknown as {
onEndTag?: (handler: () => void) => void;
}).onEndTag;
onEndTag?.call(element, () => {
if (current?.url) {
results.push({ ...current, rank: results.length + 1 });
}
current = null;
currentTextTarget = null;
});
},
})
.on("article.result a.url_header", {
element(element) {
if (current && !current.url) {
current.url = element.getAttribute("href") ?? "";
}
},
})
.on("article.result h3 a", {
element(element) {
if (current && !current.url) {
current.url = element.getAttribute("href") ?? "";
}
currentTextTarget = "title";
},
text(text) {
appendText(text.text);
if (text.lastInTextNode) currentTextTarget = null;
},
})
.on("article.result p.content", {
text(text) {
currentTextTarget = "snippet";
appendText(text.text);
if (text.lastInTextNode) currentTextTarget = null;
},
})
.on("article.result .engines span", {
text(text) {
currentTextTarget = "engine";
appendText(text.text);
if (text.lastInTextNode) currentTextTarget = null;
},
})
.transform(new Response(html));
await response.text();
return results;
}
function normalizeResult(
raw: RawSearchResult,
query: string,
asin?: string,
): SearxngOfferSearchResult {
const url = normalizeUrl(raw.url);
const domain = extractDomain(url);
const title = normalizeText(raw.title);
const snippet = normalizeText(raw.snippet);
const matchedAsin = findMatchedAsin(`${title} ${snippet} ${url}`);
const detectedPrice = detectPrice(`${title} ${snippet}`);
const score = scoreResult({
asin,
matchedAsin,
detectedPrice: detectedPrice?.amount,
domain,
rank: raw.rank,
});
return {
...(asin ? { asin } : {}),
query,
title,
url,
domain,
snippet,
rank: raw.rank,
score,
...(matchedAsin ? { matchedAsin } : {}),
...(detectedPrice
? {
detectedPrice: detectedPrice.amount,
detectedPriceCurrency: detectedPrice.currency,
...(detectedPrice.label
? { detectedPriceLabel: detectedPrice.label }
: {}),
detectedPriceText: detectedPrice.text,
}
: {}),
engines: dedupe(raw.engines.map(normalizeText).filter(Boolean)),
};
}
function scoreResult(input: {
asin?: string;
matchedAsin?: string;
detectedPrice?: number;
domain: string;
rank: number;
}): number {
let score = 100 - input.rank;
if (input.asin && input.matchedAsin === input.asin) score += 80;
if (input.matchedAsin && !input.asin) score += 40;
if (input.detectedPrice != null) score += 30;
if (input.domain && !isAmazonDomain(input.domain)) score += 20;
if (isAmazonDomain(input.domain)) score -= 15;
return score;
}
function normalizeBaseUrl(value: string): URL {
const url = new URL(value);
if (!url.pathname.endsWith("/")) {
url.pathname = `${url.pathname}/`;
}
return url;
}
function normalizeUrl(value: string): string {
try {
return new URL(value).toString();
} catch {
return value.trim();
}
}
function extractDomain(value: string): string {
try {
return new URL(value).hostname.replace(/^www\./i, "").toLowerCase();
} catch {
return "";
}
}
function isAmazonDomain(domain: string): boolean {
return /(^|\.)amazon\./i.test(domain);
}
function findMatchedAsin(value: string): string | undefined {
const match = value.toUpperCase().match(ASIN_MATCH_REGEX);
return match?.[0];
}
function detectPrice(value: string): PriceDetection | undefined {
const labeledCandidates = Array.from(value.matchAll(LABELED_PRICE_REGEX))
.map((match) => parsePriceMatch(match[2], match[1]))
.filter((price): price is PriceDetection => !!price)
.sort(comparePriceDetections);
if (labeledCandidates[0]) return labeledCandidates[0];
const candidates = Array.from(value.matchAll(PRICE_REGEX))
.map((match) => parsePriceMatch(match[1]))
.filter((price): price is PriceDetection => !!price);
return candidates[0];
}
function parsePriceMatch(
rawPrice: string | undefined,
rawLabel?: string,
): PriceDetection | undefined {
if (!rawPrice) return undefined;
const text = normalizeText(rawPrice);
const currency = detectCurrency(text);
const amountMatch = text.match(/[0-9]{1,5}(?:,[0-9]{3})*(?:\.[0-9]{2})?/);
if (!amountMatch?.[0]) return undefined;
const amount = Number(amountMatch[0].replace(/,/g, ""));
if (!Number.isFinite(amount) || amount <= 0) return undefined;
const label = rawLabel ? normalizeText(rawLabel).toLowerCase() : undefined;
return {
amount,
currency,
text,
...(label ? { label } : {}),
};
}
function comparePriceDetections(a: PriceDetection, b: PriceDetection): number {
return priceLabelRank(a.label) - priceLabelRank(b.label);
}
function priceLabelRank(label: string | undefined): number {
if (!label) return PRICE_LABELS.length;
const index = PRICE_LABELS.indexOf(label as (typeof PRICE_LABELS)[number]);
return index === -1 ? PRICE_LABELS.length : index;
}
function detectCurrency(value: string): string {
if (/\b(EUR)\b|€/i.test(value)) return "EUR";
if (/\b(GBP)\b|£/i.test(value)) return "GBP";
if (/\b(INR)\b|₹/i.test(value)) return "INR";
if (/\b(CAD)\b/i.test(value)) return "CAD";
if (/\b(AUD)\b/i.test(value)) return "AUD";
return "USD";
}
function escapeForCharClass(value: string): string {
return value.replace(/[-\\\]^]/g, "\\$&");
}
function normalizeEngines(value: unknown): string[] {
if (Array.isArray(value)) {
return value.map(String).filter(Boolean);
}
const engine = optionalString(value);
return engine ? [engine] : [];
}
function optionalString(value: unknown): string | undefined {
if (value == null) return undefined;
const text = String(value).trim();
return text ? text : undefined;
}
function normalizeText(value: string): string {
return decodeHtmlEntities(value).replace(/\s+/g, " ").trim();
}
function appendWithSpace(left: string, right: string): string {
return left ? `${left} ${right}` : right;
}
function decodeHtmlEntities(value: string): string {
return value
.replace(/&amp;/g, "&")
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'")
.replace(/&lt;/g, "<")
.replace(/&gt;/g, ">")
.replace(/&nbsp;/g, " ");
}
function dedupe(values: string[]): string[] {
return Array.from(new Set(values));
}
function readEnvInt(key: string, fallback: number): number {
const parsed = Number(Bun.env[key]);
return Number.isFinite(parsed) ? parsed : fallback;
}
function positiveInteger(value: number, fallback: number): number {
return Number.isInteger(value) && value > 0 ? value : fallback;
}

View File

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

View File

@@ -1,6 +1,12 @@
import { SellingPartner } from "amazon-sp-api"; import { SellingPartner } from "amazon-sp-api";
import { config } from "./config.ts"; import { normalizeAsin } from "../asin.ts";
import type { SpApiData, SellabilityInfo } from "./types.ts"; import { config } from "../config.ts";
import type {
KeepaUpcLookupStatus,
SpApiData,
SellabilityInfo,
UpcLookupDetail,
} from "../types.ts";
type RegionCode = "na" | "eu" | "fe"; type RegionCode = "na" | "eu" | "fe";
@@ -118,10 +124,14 @@ function round2(value: number): number {
return Math.round(value * 100) / 100; return Math.round(value * 100) / 100;
} }
const SELLABILITY_CONCURRENCY = 5; const LISTINGS_RESTRICTIONS_RATE_LIMIT_PER_SECOND = 5;
const PRICING_CONCURRENCY = 5; const LISTINGS_RESTRICTIONS_BURST_REQUESTS = 10;
const SELLABILITY_CONCURRENCY = LISTINGS_RESTRICTIONS_RATE_LIMIT_PER_SECOND;
const SELLABILITY_PROGRESS_INTERVAL = LISTINGS_RESTRICTIONS_BURST_REQUESTS;
const PRICING_CONCURRENCY = 5;
const UPC_PATTERN = /^\d{12,14}$/;
function parseSellabilityResponse(response: any): SellabilityInfo { function parseSellabilityResponse(response: any): SellabilityInfo {
const restrictions = Array.isArray(response?.restrictions) const restrictions = Array.isArray(response?.restrictions)
? response.restrictions ? response.restrictions
: Array.isArray(response?.payload?.restrictions) : Array.isArray(response?.payload?.restrictions)
@@ -171,7 +181,101 @@ function parseSellabilityResponse(response: any): SellabilityInfo {
[...reasonCodes, ...reasonMessages].join(" | ") || [...reasonCodes, ...reasonMessages].join(" | ") ||
"Listing restrictions reported", "Listing restrictions reported",
}; };
} }
function buildUpcLookupDetail(
upc: string,
status: KeepaUpcLookupStatus,
reason: string,
candidateAsins: string[] = [],
): UpcLookupDetail {
const asin = status === "found" ? candidateAsins[0] ?? null : null;
return {
requestedUpc: upc,
normalizedUpc: upc,
status,
asin,
candidateAsins,
keepaData: null,
reason,
};
}
function collectCatalogItems(response: any): any[] | null {
const candidates = [
response?.items,
response?.payload?.items,
response?.payload,
response?.Items,
];
for (const candidate of candidates) {
if (Array.isArray(candidate)) return candidate;
}
return null;
}
function extractCatalogAsin(item: any): string | null {
const raw =
item?.asin ??
item?.ASIN ??
item?.identifiers?.marketplaceASIN?.asin ??
item?.Identifiers?.MarketplaceASIN?.ASIN;
if (typeof raw !== "string") return null;
return normalizeAsin(raw);
}
export function parseCatalogUpcLookupResponse(
upc: string,
response: unknown,
): UpcLookupDetail {
const normalizedUpc = upc.trim();
if (!UPC_PATTERN.test(normalizedUpc)) {
return buildUpcLookupDetail(
normalizedUpc,
"invalid_upc",
"UPC must be 12, 13, or 14 digits",
);
}
const items = collectCatalogItems(response);
if (!items) {
return buildUpcLookupDetail(
normalizedUpc,
"request_failed",
"Unexpected catalog response shape",
);
}
const candidateAsins = Array.from(
new Set(items.map(extractCatalogAsin).filter((asin): asin is string => !!asin)),
);
if (candidateAsins.length === 0) {
return buildUpcLookupDetail(
normalizedUpc,
"not_found",
"No SP-API catalog item matched this UPC",
);
}
if (candidateAsins.length > 1) {
return buildUpcLookupDetail(
normalizedUpc,
"multiple_asins",
`UPC matched multiple ASINs (${candidateAsins.length})`,
candidateAsins,
);
}
return buildUpcLookupDetail(
normalizedUpc,
"found",
"Matched by SP-API catalog",
candidateAsins,
);
}
async function fetchSellabilityInternal( async function fetchSellabilityInternal(
spClient: SellingPartner, spClient: SellingPartner,
@@ -502,9 +606,9 @@ export async function fetchSellability(asin: string): Promise<SellabilityInfo> {
return fetchSellabilityInternal(spClient, asin); return fetchSellabilityInternal(spClient, asin);
} }
export async function fetchSellabilityBatch( export async function fetchSellabilityBatch(
asins: string[], asins: string[],
): Promise<Map<string, SellabilityInfo>> { ): Promise<Map<string, SellabilityInfo>> {
const results = new Map<string, SellabilityInfo>(); const results = new Map<string, SellabilityInfo>();
const spClient = getSpClient(); const spClient = getSpClient();
@@ -520,8 +624,7 @@ export async function fetchSellabilityBatch(
} }
let completed = 0; let completed = 0;
let running = 0; const queue = [...asins];
const queue = [...asins];
async function next(): Promise<void> { async function next(): Promise<void> {
while (queue.length > 0) { while (queue.length > 0) {
@@ -529,9 +632,12 @@ export async function fetchSellabilityBatch(
const info = await fetchSellabilityInternal(spClient!, asin); const info = await fetchSellabilityInternal(spClient!, asin);
results.set(asin, info); results.set(asin, info);
completed++; completed++;
if (completed % 10 === 0 || completed === asins.length) { if (
console.log(` [sellability] ${completed}/${asins.length} checked`); completed % SELLABILITY_PROGRESS_INTERVAL === 0 ||
} completed === asins.length
) {
console.log(` [sellability] ${completed}/${asins.length} checked`);
}
} }
} }
@@ -540,14 +646,74 @@ export async function fetchSellabilityBatch(
() => next(), () => next(),
); );
await Promise.all(workers); await Promise.all(workers);
return results; return results;
} }
export async function fetchSpApiPricingAndFees( export async function lookupSpApiUpc(upc: string): Promise<UpcLookupDetail> {
asin: string, const normalizedUpc = upc.trim();
sellability: SellabilityInfo, if (!UPC_PATTERN.test(normalizedUpc)) {
): Promise<SpApiData> { return buildUpcLookupDetail(
normalizedUpc,
"invalid_upc",
"UPC must be 12, 13, or 14 digits",
);
}
const spClient = getSpClient();
if (!spClient) {
return buildUpcLookupDetail(
normalizedUpc,
"request_failed",
"SP-API credentials not configured",
);
}
try {
const response = await spClient.callAPI({
operation: "searchCatalogItems",
endpoint: "catalogItems",
query: {
marketplaceIds: [config.spApiMarketplaceId],
identifiers: [normalizedUpc],
identifiersType: "UPC",
includedData: ["identifiers", "summaries"],
},
});
return parseCatalogUpcLookupResponse(normalizedUpc, response);
} catch (err) {
return buildUpcLookupDetail(
normalizedUpc,
"request_failed",
`SP-API catalog lookup failed: ${extractErrorMessage(err)}`,
);
}
}
export async function lookupSpApiUpcs(
upcs: string[],
): Promise<Map<string, UpcLookupDetail>> {
const results = new Map<string, UpcLookupDetail>();
const uniqueUpcs = Array.from(new Set(upcs.map((upc) => upc.trim())));
let completed = 0;
for (const upc of uniqueUpcs) {
const detail = await lookupSpApiUpc(upc);
results.set(upc, detail);
completed++;
if (completed % 10 === 0 || completed === uniqueUpcs.length) {
console.log(` [sp-api:catalog] ${completed}/${uniqueUpcs.length} UPCs checked`);
}
}
return results;
}
export async function fetchSpApiPricingAndFees(
asin: string,
sellability: SellabilityInfo,
priceOverride?: number | null,
): Promise<SpApiData> {
const fallback: SpApiData = { const fallback: SpApiData = {
fbaFee: 5.0, fbaFee: 5.0,
fbmFee: 1.5, fbmFee: 1.5,
@@ -561,22 +727,28 @@ export async function fetchSpApiPricingAndFees(
console.log(` [sp-api:fallback] ${asin} reason=missing_credentials`); console.log(` [sp-api:fallback] ${asin} reason=missing_credentials`);
return fallback; return fallback;
} }
try { try {
const pricing = (await spClient.callAPI({ let estimatedSalePrice =
operation: "getItemOffers", typeof priceOverride === "number" && Number.isFinite(priceOverride)
endpoint: "productPricing", ? priceOverride
path: { Asin: asin }, : 0;
query: { if (estimatedSalePrice <= 0) {
MarketplaceId: config.spApiMarketplaceId, const pricing = (await spClient.callAPI({
ItemCondition: "New", operation: "getItemOffers",
}, endpoint: "productPricing",
})) as any; path: { Asin: asin },
query: {
const estimatedSalePrice = extractEstimatedSalePrice(pricing); MarketplaceId: config.spApiMarketplaceId,
if (!Number.isFinite(estimatedSalePrice) || estimatedSalePrice <= 0) { ItemCondition: "New",
console.log( },
` [sp-api:fallback] ${asin} reason=no_valid_price_from_offers`, })) as any;
estimatedSalePrice = extractEstimatedSalePrice(pricing);
}
if (!Number.isFinite(estimatedSalePrice) || estimatedSalePrice <= 0) {
console.log(
` [sp-api:fallback] ${asin} reason=no_valid_price_from_offers`,
); );
return fallback; return fallback;
} }

View File

@@ -1,141 +0,0 @@
import { config } from "./config.ts";
import type { KeepaData } from "./types.ts";
const KEEPA_BASE = "https://api.keepa.com";
const MAX_ASINS_PER_REQUEST = 100;
// Token-based rate limiting: Keepa Pro = 1 token/min regeneration.
// Each product request costs 1 token regardless of ASIN count (up to 100).
// The API response includes tokensLeft and refillRate — we use those to pace.
let tokensLeft = 1; // Conservative start; updated from API response
let refillRate = 1; // tokens per minute, updated from API response
let lastRequestTime = 0;
async function waitForToken(): Promise<void> {
if (tokensLeft > 0) return;
const elapsed = (Date.now() - lastRequestTime) / 60_000; // minutes
const regenerated = Math.floor(elapsed * refillRate);
if (regenerated > 0) {
tokensLeft += regenerated;
return;
}
// Wait until we regenerate at least 1 token
const waitMs =
Math.ceil((1 / refillRate) * 60_000) - (Date.now() - lastRequestTime);
if (waitMs > 0) {
console.log(
`Keepa tokens exhausted. Waiting ${Math.ceil(waitMs / 1000)}s for token regeneration...`,
);
await new Promise((r) => setTimeout(r, waitMs));
}
tokensLeft = 1;
}
export async function fetchKeepaDataBatch(
asins: string[],
): Promise<Map<string, KeepaData>> {
const results = new Map<string, KeepaData>();
// Split into chunks of MAX_ASINS_PER_REQUEST
for (let i = 0; i < asins.length; i += MAX_ASINS_PER_REQUEST) {
const chunk = asins.slice(i, i + MAX_ASINS_PER_REQUEST);
await waitForToken();
const asinParam = chunk.join(",");
const url = `${KEEPA_BASE}/product?key=${config.keepaApiKey}&domain=1&asin=${asinParam}&stats=90`;
console.log(
`Keepa: fetching ${chunk.length} ASINs (tokens left: ${tokensLeft})...`,
);
const res = await fetch(url);
lastRequestTime = Date.now();
if (!res.ok) {
const text = await res.text();
throw new Error(`Keepa API error ${res.status}: ${text}`);
}
const data = (await res.json()) as {
products?: Record<string, any>[];
tokensLeft?: number;
refillRate?: number;
};
// Update token state from API response
if (data.tokensLeft != null) tokensLeft = data.tokensLeft;
if (data.refillRate != null) refillRate = data.refillRate;
console.log(
`Keepa: ${data.products?.length ?? 0} products returned, ${tokensLeft} tokens remaining (refill: ${refillRate}/min)`,
);
if (data.products) {
for (const product of data.products) {
const asin = product.asin;
if (!asin) continue;
results.set(asin, parseKeepaProduct(product));
}
}
}
return results;
}
function parseKeepaProduct(product: Record<string, any>): KeepaData {
const stats = product.stats;
const csv = product.csv;
const salesRankDrops30 = pickKeepaNumber(
product.salesRankDrops30,
stats?.salesRankDrops30,
);
const salesRankDrops90 =
pickKeepaNumber(product.salesRankDrops90, stats?.salesRankDrops90) ??
(salesRankDrops30 != null ? salesRankDrops30 * 3 : null);
const monthlySold =
pickKeepaNumber(product.monthlySold, stats?.monthlySold) ??
salesRankDrops30;
return {
currentPrice: extractCurrentPrice(csv),
avgPrice90: stats?.avg?.[0] != null ? stats.avg[0] / 100 : null,
minPrice90: stats?.min?.[0] != null ? stats.min[0] / 100 : null,
maxPrice90: stats?.max?.[0] != null ? stats.max[0] / 100 : null,
salesRank: stats?.current?.[3] ?? null,
salesRankAvg90: stats?.avg?.[3] ?? null,
salesRankDrops30,
salesRankDrops90,
sellerCount: stats?.current?.[11] ?? null,
buyBoxSeller: product.buyBoxSellerId ?? null,
buyBoxPrice: stats?.current?.[18] != null ? stats.current[18] / 100 : null,
monthlySold,
categoryTree:
product.categoryTree?.map((c: { name: string }) => c.name) ?? [],
};
}
function pickKeepaNumber(...values: unknown[]): number | null {
for (const value of values) {
if (typeof value !== "number" || !Number.isFinite(value)) continue;
// Keepa often uses -1 as "not available".
if (value < 0) continue;
return value;
}
return null;
}
function extractCurrentPrice(csv: number[][] | undefined): number | null {
if (!csv) return null;
// csv[0] = Amazon price history, csv[1] = Marketplace new price history
// Each is [time, price, time, price, ...] — last value is most recent
for (const series of [csv[0], csv[1]]) {
if (series && series.length >= 2) {
const lastPrice = series[series.length - 1]!;
if (lastPrice > 0) return lastPrice / 100;
}
}
return null;
}

View File

@@ -1,8 +1,7 @@
import * as XLSX from "xlsx"; import * as XLSX from "xlsx";
import type { ProductRecord } from "./types.ts"; import { normalizeAsin } from "./asin.ts";
import type { ProductRecord } from "./types.ts";
const ASIN_REGEX = /^B[0-9A-Z]{9}$/;
const COLUMN_CANDIDATES = { const COLUMN_CANDIDATES = {
asin: ["asin"], asin: ["asin"],
name: ["name", "product name", "title", "product title"], name: ["name", "product name", "title", "product title"],
@@ -132,14 +131,12 @@ function getKnownColumns(columns: ColumnMap): Set<string> {
return new Set(Object.values(columns).filter((column): column is string => !!column)); return new Set(Object.values(columns).filter((column): column is string => !!column));
} }
function parseAsin(value: unknown): string | undefined { function parseAsin(value: unknown): string | undefined {
const asin = String(value ?? "") const asin = normalizeAsin(value);
.trim() if (!asin) {
.toUpperCase(); console.warn(`Skipping invalid ASIN: "${String(value ?? "").trim()}"`);
if (!asin || !ASIN_REGEX.test(asin)) { return undefined;
console.warn(`Skipping invalid ASIN: "${asin}"`); }
return undefined;
}
return asin; return asin;
} }

File diff suppressed because it is too large Load Diff

View File

@@ -1,4 +1,4 @@
import { testSpApiConnectivity, testSpApiSellability } from "./sp-api.ts"; import { testSpApiConnectivity, testSpApiSellability } from "./integrations/sp-api.ts";
function parseArgs(): { asin?: string; sellabilityMode: boolean } { function parseArgs(): { asin?: string; sellabilityMode: boolean } {
const args = process.argv.slice(2); const args = process.argv.slice(2);

View File

@@ -0,0 +1,275 @@
import { client, db } from "../db/index.ts";
import { persistLlmResults, refreshRunStats } from "../db/persistence.ts";
import { sql } from "drizzle-orm";
import { normalizeAsin } from "../asin.ts";
import { analyzeProducts } from "../integrations/llm.ts";
import { fetchSpApiPricingAndFees } from "../integrations/sp-api.ts";
import type {
AnalysisResult,
EnrichedProduct,
KeepaData,
ProductRecord,
SellabilityInfo,
} from "../types.ts";
const LLM_BATCH_SIZE = 5;
const LLM_BATCH_DELAY_MS = 5_000;
type Args = {
stalkerRunId: number;
analysisRunId: number;
asins: string[];
useClaude: boolean;
};
type InventoryRow = {
inventoryItemId: number;
asin: string;
productTitle: string | null;
brand: string | null;
categoryTree: string | null;
currentPrice: number | null;
avgPrice90d: number | null;
salesRank: number | null;
monthlySold: number | null;
sellerCount: number | null;
amazonIsSeller: boolean | null;
canSell: boolean | null;
sellabilityStatus: SellabilityInfo["sellabilityStatus"] | null;
sellabilityReason: string | null;
};
function readFlagValue(args: string[], flag: string): string | undefined {
const index = args.indexOf(flag);
if (index === -1) return undefined;
return args[index + 1];
}
function parseArgs(argv = process.argv.slice(2)): Args {
const stalkerRunId = Number(readFlagValue(argv, "--stalker-run-id"));
const analysisRunId = Number(readFlagValue(argv, "--analysis-run-id"));
const useClaude = argv.includes("--claude");
const asins = (readFlagValue(argv, "--asins") ?? "")
.split(",")
.map((asin) => normalizeAsin(asin))
.filter((asin): asin is string => asin !== null);
if (!Number.isInteger(stalkerRunId) || stalkerRunId <= 0) {
throw new Error("--stalker-run-id must be a positive integer");
}
if (!Number.isInteger(analysisRunId) || analysisRunId <= 0) {
throw new Error("--analysis-run-id must be a positive integer");
}
if (asins.length === 0) throw new Error("Missing --asins");
return { stalkerRunId, analysisRunId, asins, useClaude };
}
function wait(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function parseCategoryTree(value: string | null): string[] {
return value ? value.split(" > ").filter(Boolean) : [];
}
function toProductRecord(row: InventoryRow): ProductRecord {
const categoryTree = parseCategoryTree(row.categoryTree);
return {
asin: row.asin,
name: row.productTitle ?? row.asin,
brand: row.brand ?? undefined,
category: categoryTree.join(" > ") || undefined,
unitCost: 0,
amazonRank: row.salesRank ?? undefined,
sellingPriceFromSheet: row.currentPrice ?? undefined,
avgPrice90FromSheet: row.avgPrice90d ?? undefined,
};
}
function toKeepaData(row: InventoryRow): KeepaData {
return {
currentPrice: row.currentPrice,
avgPrice90: row.avgPrice90d,
minPrice90: null,
maxPrice90: null,
salesRank: row.salesRank,
salesRankAvg90: null,
salesRankDrops30: null,
salesRankDrops90: null,
sellerCount: row.sellerCount,
amazonIsSeller: row.amazonIsSeller,
amazonBuyboxSharePct90d: null,
buyBoxSeller: null,
buyBoxPrice: null,
buyBoxAvg90: null,
monthlySold: row.monthlySold,
categoryTree: parseCategoryTree(row.categoryTree),
};
}
function toSellability(row: InventoryRow): SellabilityInfo {
return {
canSell: row.canSell,
sellabilityStatus: row.sellabilityStatus ?? "unknown",
sellabilityReason: row.sellabilityReason ?? undefined,
};
}
async function loadInventoryRows(
stalkerRunId: number,
asins: string[],
): Promise<InventoryRow[]> {
if (asins.length === 0) return [];
return db.execute(
sql<InventoryRow>`SELECT DISTINCT ON (inventory.product_asin)
inventory.id AS "inventoryItemId",
inventory.product_asin AS asin,
product.name AS "productTitle",
product.brand,
product.category AS "categoryTree",
observation.current_price AS "currentPrice",
observation.avg_price_90d AS "avgPrice90d",
observation.sales_rank AS "salesRank",
observation.monthly_sold AS "monthlySold",
observation.seller_count AS "sellerCount",
observation.amazon_is_seller AS "amazonIsSeller",
observation.can_sell AS "canSell",
observation.sellability_status AS "sellabilityStatus",
observation.sellability_reason AS "sellabilityReason"
FROM stalker_inventory_items inventory
JOIN products product ON product.asin = inventory.product_asin
JOIN product_observations observation ON observation.id = inventory.observation_id
WHERE inventory.run_id = ${stalkerRunId}
AND observation.can_sell = true
AND observation.sellability_status = 'available'
AND inventory.product_asin = ANY(ARRAY[${sql.join(asins.map((asin) => sql`${asin}`), sql`, `)}])
ORDER BY inventory.product_asin, observation.fetched_at DESC`,
);
}
async function buildEnrichedProducts(
rows: InventoryRow[],
): Promise<EnrichedProduct[]> {
const enriched: EnrichedProduct[] = [];
for (const row of rows) {
const sellability = toSellability(row);
const spApi = await fetchSpApiPricingAndFees(
row.asin,
sellability,
row.currentPrice,
);
enriched.push({
record: toProductRecord(row),
keepa: toKeepaData(row),
spApi,
fetchedAt: new Date().toISOString(),
});
}
return enriched;
}
async function insertProductAnalysisResults(
runId: number,
results: AnalysisResult[],
sourceInventoryIds: Map<string, number>,
): Promise<void> {
if (results.length === 0) return;
await persistLlmResults(runId, results, {
source: "stalker_analysis",
metadataSource: "catalog",
sourceInventoryIds,
});
}
async function refreshAnalysisRun(runId: number): Promise<void> {
await refreshRunStats(runId);
}
async function analyzeInBatches(
products: EnrichedProduct[],
useClaude: boolean,
): Promise<AnalysisResult[]> {
const results: AnalysisResult[] = [];
for (let i = 0; i < products.length; i += LLM_BATCH_SIZE) {
const batch = products.slice(i, i + LLM_BATCH_SIZE);
const batchNumber = Math.floor(i / LLM_BATCH_SIZE) + 1;
const totalBatches = Math.ceil(products.length / LLM_BATCH_SIZE);
console.log(
`Stalker analysis: LLM batch ${batchNumber}/${totalBatches} (${batch.length} product(s)).`,
);
if (i > 0) {
await wait(LLM_BATCH_DELAY_MS);
}
let verdicts;
try {
verdicts = await analyzeProducts(batch, { useClaude });
} catch (error) {
console.warn(
`Stalker analysis: LLM batch ${batchNumber} failed: ${
error instanceof Error ? error.message : String(error)
}`,
);
verdicts = null;
}
for (let j = 0; j < batch.length; j++) {
const product = batch[j];
if (!product) continue;
results.push({
product,
verdict: verdicts?.[j] ?? {
asin: product.record.asin,
verdict: "SKIP",
confidence: 0,
reasoning: "LLM analysis failed or returned no verdict",
},
});
}
}
return results;
}
async function main(): Promise<void> {
const args = parseArgs();
const rows = await loadInventoryRows(args.stalkerRunId, args.asins);
if (rows.length === 0) {
console.log("Stalker analysis: no sellable inventory rows to analyze.");
return;
}
console.log(`Stalker analysis: analyzing ${rows.length} sellable ASIN(s).`);
const enriched = await buildEnrichedProducts(rows);
const results = await analyzeInBatches(enriched, args.useClaude);
const sourceInventoryIds = new Map(
rows.map((row) => [row.asin, row.inventoryItemId]),
);
await insertProductAnalysisResults(
args.analysisRunId,
results,
sourceInventoryIds,
);
await refreshAnalysisRun(args.analysisRunId);
}
if (import.meta.main) {
main()
.catch((error) => {
console.error(error instanceof Error ? error.message : String(error));
process.exitCode = 1;
})
.finally(async () => {
try {
await client.end({ timeout: 5 });
} catch {
}
});
}

View File

@@ -0,0 +1,221 @@
import { afterAll, beforeEach, expect, mock, test } from "bun:test";
import { mkdirSync, rmSync } from "node:fs";
import path from "node:path";
import * as XLSX from "xlsx";
let nextId = 0;
function chainable(resolveWith: any[] = []): any {
const p: any = Promise.resolve(resolveWith);
p.limit = (_n: any) => chainable(resolveWith);
p.where = (_cond: any) => chainable(resolveWith);
p.from = (_table: any) => chainable(resolveWith);
return p;
}
const makeMockTx = (): any => ({
insert: (_table: any) => ({
values: (_vals: any) => ({
returning: (_sel: any) => Promise.resolve([{ id: ++nextId }]),
onConflictDoUpdate: (_conf: any) => Promise.resolve([]),
}),
}),
update: (_table: any) => ({
set: (_vals: any) => ({
where: (_cond: any) => Promise.resolve([]),
}),
}),
select: (_sel?: any) => ({
from: (_table: any) => ({
where: (_cond: any) => chainable([{ id: ++nextId }]),
limit: (_n: any) => chainable([{ id: nextId }]),
}),
}),
selectDistinct: (_sel: any) => ({
from: (_table: any) => chainable([]),
}),
execute: (_query: any) => Promise.resolve([]),
});
const makeMockDb = (): any => ({
insert: (_table: any) => ({
values: (_vals: any) => ({
returning: (_sel: any) => Promise.resolve([{ id: ++nextId }]),
onConflictDoUpdate: (_conf: any) => Promise.resolve([]),
}),
}),
update: (_table: any) => ({
set: (_vals: any) => ({
where: (_cond: any) => Promise.resolve([]),
}),
}),
select: (_sel?: any) => ({
from: (_table: any) => ({
where: (_cond: any) => chainable(),
limit: (_n: any) => chainable(),
}),
}),
selectDistinct: (_sel: any) => ({
from: (_table: any) => chainable(),
}),
execute: (_query: any) => Promise.resolve([]),
transaction: async (fn: (tx: any) => Promise<any>) => fn(makeMockTx()),
});
mock.module("../db/index.ts", () => ({ db: makeMockDb(), client: {} }));
const TEST_DIR = path.join(process.cwd(), "test_output", "stalker-sellability");
const originalFetch = globalThis.fetch;
const originalKeepaKey = Bun.env.KEEPA_API_KEY;
const fetchSellabilityBatchMock = mock(async (asins: string[]) => {
return new Map(
asins.map((asin) => [
asin,
asin === "B111111111"
? {
canSell: true,
sellabilityStatus: "available" as const,
sellabilityReason: "No listing restrictions reported",
}
: {
canSell: false,
sellabilityStatus: "restricted" as const,
sellabilityReason: "approval required",
},
]),
);
});
const modulePromise = import("./stalker.ts");
beforeEach(() => {
nextId = 0;
rmSync(TEST_DIR, { recursive: true, force: true });
mkdirSync(TEST_DIR, { recursive: true });
globalThis.fetch = originalFetch;
Bun.env.KEEPA_API_KEY = "test-keepa-key";
fetchSellabilityBatchMock.mockClear();
});
afterAll(() => {
globalThis.fetch = originalFetch;
if (originalKeepaKey == null) {
delete Bun.env.KEEPA_API_KEY;
} else {
Bun.env.KEEPA_API_KEY = originalKeepaKey;
}
rmSync(TEST_DIR, { recursive: true, force: true });
});
test("sellability checks matched seller inventory, not the source ASIN", async () => {
const { runStalker } = await modulePromise;
const inputPath = path.join(TEST_DIR, "input.xlsx");
const workbook = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(
workbook,
XLSX.utils.json_to_sheet([{ asin: "B000000001" }]),
"Input",
);
XLSX.writeFile(workbook, inputPath);
globalThis.fetch = mock(async (input: string | URL | Request) => {
const rawUrl =
typeof input === "string"
? input
: input instanceof URL
? input.toString()
: input.url;
const url = new URL(rawUrl);
if (url.pathname === "/product") {
if (url.searchParams.get("asin") === "B111111111") {
return new Response(
JSON.stringify({
products: [
{
asin: "B111111111",
title: "Sellable Storefront Product",
brand: "Good Brand",
categoryTree: [{ name: "Kitchen" }, { name: "Storage" }],
monthlySold: 42,
stats: {
current: [null, null, null, 12345, null, null, null, null, null, null, null, 7],
avg: [2500],
},
csv: [[5000000, 1999, 5000100]],
},
],
tokensLeft: 10,
refillRate: 10,
}),
{ status: 200 },
);
}
return new Response(
JSON.stringify({
products: [
{
asin: "B000000001",
title: "Source Product",
offers: [{ sellerId: "AQUALIFIED", price: 1999 }],
},
],
tokensLeft: 10,
refillRate: 10,
}),
{ status: 200 },
);
}
if (url.pathname === "/seller") {
const wantsStorefront = url.searchParams.get("storefront") === "1";
return new Response(
JSON.stringify({
sellers: {
AQUALIFIED: {
sellerName: "New Seller",
currentRatingCount: 12,
asinList: wantsStorefront ? ["B111111111", "B222222222"] : [],
},
},
tokensLeft: 10,
refillRate: 10,
}),
{ status: 200 },
);
}
return new Response("not found", { status: 404 });
}) as unknown as typeof globalThis.fetch;
const stats = await runStalker(
{
input: inputPath,
maxAsins: null,
offerLimit: 20,
sellerLimit: 30,
inventoryLimit: 200,
sellerCacheHours: 168,
includeStock: false,
dryRun: false,
resume: true,
maxSellerRequests: null,
sellability: true,
analyzeSellable: false,
useClaude: false,
},
{ fetchSellabilityBatch: fetchSellabilityBatchMock },
);
expect(fetchSellabilityBatchMock.mock.calls.length).toBe(1);
expect(fetchSellabilityBatchMock.mock.calls[0]?.[0]).toEqual([
"B111111111",
"B222222222",
]);
expect(stats.inventorySellabilityCheckedAsins).toBe(2);
expect(stats.inventorySellabilityAvailableAsins).toBe(1);
expect(stats.inventorySellabilityExcludedAsins).toBe(1);
expect(stats.persistedInventoryAsins).toBe(1);
});

280
src/stalker/stalker.test.ts Normal file
View File

@@ -0,0 +1,280 @@
import { afterAll, beforeEach, expect, mock, test } from "bun:test";
import { mkdirSync, rmSync } from "node:fs";
import path from "node:path";
import * as XLSX from "xlsx";
import {
extractLiveOfferSellerCandidates,
isQualifyingSeller,
readAsinsFromXlsx,
runStalker,
} from "./stalker.ts";
let nextId = 0;
function chainable(resolveWith: any[] = []): any {
const p: any = Promise.resolve(resolveWith);
p.limit = (_n: any) => chainable(resolveWith);
p.where = (_cond: any) => chainable(resolveWith);
p.from = (_table: any) => chainable(resolveWith);
return p;
}
// Transaction mock returns rows for selects (needed for upsert-then-select patterns).
const makeMockTx = (): any => ({
insert: (_table: any) => ({
values: (_vals: any) => ({
returning: (_sel: any) => Promise.resolve([{ id: ++nextId }]),
onConflictDoUpdate: (_conf: any) => Promise.resolve([]),
}),
}),
update: (_table: any) => ({
set: (_vals: any) => ({
where: (_cond: any) => Promise.resolve([]),
}),
}),
select: (_sel?: any) => ({
from: (_table: any) => ({
where: (_cond: any) => chainable([{ id: ++nextId }]),
limit: (_n: any) => chainable([{ id: nextId }]),
}),
}),
selectDistinct: (_sel: any) => ({
from: (_table: any) => chainable([]),
}),
execute: (_query: any) => Promise.resolve([]),
});
const makeMockDb = (): any => ({
insert: (_table: any) => ({
values: (_vals: any) => ({
returning: (_sel: any) => Promise.resolve([{ id: ++nextId }]),
onConflictDoUpdate: (_conf: any) => Promise.resolve([]),
}),
}),
update: (_table: any) => ({
set: (_vals: any) => ({
where: (_cond: any) => Promise.resolve([]),
}),
}),
select: (_sel?: any) => ({
from: (_table: any) => ({
where: (_cond: any) => chainable(),
limit: (_n: any) => chainable(),
}),
}),
selectDistinct: (_sel: any) => ({
from: (_table: any) => chainable(),
}),
execute: (_query: any) => Promise.resolve([]),
transaction: async (fn: (tx: any) => Promise<any>) => fn(makeMockTx()),
});
mock.module("../db/index.ts", () => ({ db: makeMockDb(), client: {} }));
const TEST_DIR = path.join(process.cwd(), "test_output", "stalker");
const originalFetch = globalThis.fetch;
const originalKeepaKey = Bun.env.KEEPA_API_KEY;
beforeEach(() => {
nextId = 0;
rmSync(TEST_DIR, { recursive: true, force: true });
mkdirSync(TEST_DIR, { recursive: true });
globalThis.fetch = originalFetch;
Bun.env.KEEPA_API_KEY = "test-keepa-key";
});
afterAll(() => {
globalThis.fetch = originalFetch;
if (originalKeepaKey == null) {
delete Bun.env.KEEPA_API_KEY;
} else {
Bun.env.KEEPA_API_KEY = originalKeepaKey;
}
rmSync(TEST_DIR, { recursive: true, force: true });
});
test("readAsinsFromXlsx extracts valid ASINs and deduplicates in order", () => {
const filePath = path.join(TEST_DIR, "asins.xlsx");
const workbook = XLSX.utils.book_new();
const sheet = XLSX.utils.json_to_sheet([
{ ASIN: "b000000001" },
{ ASIN: "invalid" },
{ ASIN: "B000000002" },
{ ASIN: "B000000001" },
{ ASIN: "0306406152" },
{ ASIN: "" },
]);
XLSX.utils.book_append_sheet(workbook, sheet, "Input");
XLSX.writeFile(workbook, filePath);
expect(readAsinsFromXlsx(filePath)).toEqual([
"B000000001",
"B000000002",
"0306406152",
]);
});
test("isQualifyingSeller accepts rating counts from 1 to 30 only", () => {
expect(isQualifyingSeller({ ratingCount: null })).toBe(false);
expect(isQualifyingSeller({ ratingCount: 0 })).toBe(false);
expect(isQualifyingSeller({ ratingCount: 1 })).toBe(true);
expect(isQualifyingSeller({ ratingCount: 30 })).toBe(true);
expect(isQualifyingSeller({ ratingCount: 31 })).toBe(false);
});
test("extractLiveOfferSellerCandidates ignores Amazon, missing seller IDs, and duplicates", () => {
const offers = extractLiveOfferSellerCandidates({
offers: [
{ sellerId: "ATVPDKIKX0DER", price: 1999 },
{ price: 1899 },
{ sellerId: "A1SELLER", price: 1599, isFBA: true, stock: 4 },
{ sellerId: "A1SELLER", price: 1499 },
{ sellerID: "A2SELLER", currentPrice: 2499, isFba: false },
],
});
expect(offers.map((offer) => offer.sellerId)).toEqual([
"A1SELLER",
"A2SELLER",
]);
expect(offers[0]?.offerPrice).toBe(15.99);
expect(offers[0]?.isFba).toBe(true);
expect(offers[0]?.stock).toBe(4);
});
test("runStalker fetches product offers, filters sellers, and tracks stats", async () => {
const inputPath = path.join(TEST_DIR, "input.xlsx");
const workbook = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(
workbook,
XLSX.utils.json_to_sheet([{ asin: "B000000001" }]),
"Input",
);
XLSX.writeFile(workbook, inputPath);
const fetchMock = mock(async (input: string | URL | Request) => {
const rawUrl =
typeof input === "string"
? input
: input instanceof URL
? input.toString()
: input.url;
const url = new URL(rawUrl);
if (url.pathname === "/product") {
expect(url.searchParams.get("asin")).toBe("B000000001");
expect(url.searchParams.get("offers")).toBe("20");
expect(url.searchParams.get("only-live-offers")).toBe("1");
expect(url.searchParams.has("stock")).toBe(false);
return new Response(
JSON.stringify({
products: [
{
asin: "B000000001",
title: "Tracked Product",
offers: [
{
sellerId: "AQUALIFIED",
price: 1999,
condition: "New",
isFBA: true,
stock: 3,
},
{
sellerId: "AOLDSELLER",
price: 2099,
},
],
},
],
tokensLeft: 10,
refillRate: 10,
}),
{ status: 200 },
);
}
if (url.pathname === "/seller") {
const wantsStorefront = url.searchParams.get("storefront") === "1";
if (wantsStorefront) {
expect(url.searchParams.has("update")).toBeFalse();
}
const sellerId = url.searchParams.get("seller");
return new Response(
JSON.stringify({
sellers: {
...(!wantsStorefront && sellerId === "AQUALIFIED,AOLDSELLER"
? {
AQUALIFIED: {
sellerName: "New Seller",
currentRating: 96,
currentRatingCount: 12,
},
AOLDSELLER: {
sellerName: "Old Seller",
currentRating: 99,
currentRatingCount: 120,
},
}
: {}),
...(wantsStorefront && sellerId === "AQUALIFIED"
? {
AQUALIFIED: {
sellerName: "New Seller",
currentRating: 96,
currentRatingCount: 12,
asinList: ["B111111111", "B222222222"],
},
}
: {}),
},
tokensLeft: 10,
refillRate: 10,
}),
{ status: 200 },
);
}
return new Response("not found", { status: 404 });
});
globalThis.fetch = fetchMock as unknown as typeof globalThis.fetch;
const stats = await runStalker({
input: inputPath,
maxAsins: null,
offerLimit: 20,
sellerLimit: 30,
inventoryLimit: 200,
sellerCacheHours: 168,
includeStock: false,
dryRun: false,
resume: true,
maxSellerRequests: null,
sellability: false,
analyzeSellable: false,
useClaude: false,
});
expect(stats.scannedAsins).toBe(1);
expect(stats.sourceAsinsWithMatches).toBe(1);
expect(stats.matchedSellers).toBe(1);
expect(stats.persistedInventoryAsins).toBe(0);
expect(stats.failedAsins).toBe(0);
expect(stats.candidateSellers).toBe(2);
expect(stats.qualifyingSellers).toBe(1);
expect(stats.sellerMetadataRequests).toBe(1);
expect(stats.sellerStorefrontRequests).toBe(1);
const sellerCalls = fetchMock.mock.calls.filter((call) => {
const rawUrl =
typeof call[0] === "string"
? call[0]
: call[0] instanceof URL
? call[0].toString()
: (call[0] as Request).url;
return new URL(rawUrl).pathname === "/seller";
});
expect(sellerCalls.length).toBe(2);
});

1630
src/stalker/stalker.ts Normal file

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

@@ -26,21 +26,44 @@ export interface ProductRecord {
[key: string]: unknown; [key: string]: unknown;
} }
export interface KeepaData { export interface KeepaData {
currentPrice: number | null; currentPrice: number | null;
avgPrice90: number | null; avgPrice90: number | null;
minPrice90: number | null; minPrice90: number | null;
maxPrice90: number | null; maxPrice90: number | null;
salesRank: number | null; salesRank: number | null;
salesRankAvg90: number | null; salesRankAvg90: number | null;
salesRankDrops30: number | null; salesRankDrops30: number | null;
salesRankDrops90: number | null; salesRankDrops90: number | null;
sellerCount: number | null; sellerCount: number | null;
buyBoxSeller: string | null; amazonIsSeller: boolean | null;
buyBoxPrice: number | null; amazonBuyboxSharePct90d: number | null;
monthlySold: number | null; buyBoxSeller: string | null;
categoryTree: string[]; buyBoxPrice: number | null;
} buyBoxAvg90?: number | null;
monthlySold: number | null;
categoryTree: string[];
}
export type KeepaUpcLookupStatus =
| "found"
| "invalid_upc"
| "not_found"
| "multiple_asins"
| "request_failed";
export interface KeepaUpcLookupDetail {
requestedUpc: string;
normalizedUpc: string;
status: KeepaUpcLookupStatus;
asin: string | null;
candidateAsins: string[];
keepaData: KeepaData | null;
provider?: "sp_api" | "keepa";
reason?: string;
}
export type UpcLookupDetail = KeepaUpcLookupDetail;
export type SellabilityInfo = { export type SellabilityInfo = {
canSell: boolean | null; canSell: boolean | null;
@@ -69,10 +92,89 @@ export interface LlmVerdict {
reasoning: string; reasoning: string;
} }
export interface AnalysisResult { export interface AnalysisResult {
product: EnrichedProduct; product: EnrichedProduct;
verdict: LlmVerdict; verdict: LlmVerdict;
} }
export type SupplierVerdict = "BUY" | "WATCH" | "SKIP";
export interface SupplierScore {
salePrice: number | null;
fbaFee: number | null;
profit: number | null;
margin: number | null;
roi: number | null;
demandScore: number;
competitionPenalty: number;
score: number;
verdict: SupplierVerdict;
reason: string;
}
export interface SupplierAnalysisResult {
upc: string;
rowNumber?: number;
record: SupplierInputRecord;
product: ProductRecord | null;
lookup: UpcLookupDetail;
keepa: KeepaData | null;
spApi: SpApiData | null;
score: SupplierScore;
fetchedAt: string;
}
export interface SupplierInputRecord {
name: string;
unitCost: number;
brand?: string;
category?: string;
}
export interface Product {
asin: string;
name: string | null;
brand: string | null;
category: string | null;
firstSeenAt: string;
lastSeenAt: string;
}
export interface ProductObservation {
id: number;
productAsin: string;
runId: number;
source: string;
fetchedAt: string;
}
export interface Run {
id: number;
type:
| "lead_analysis"
| "category_analysis"
| "supplier_upc"
| "stalker"
| "stalker_analysis";
parentRunId?: number | null;
status: string;
}
export interface RunItem {
id: number;
runId: number;
productAsin: string | null;
sourceRow?: number | null;
}
export interface AnalysisRevision {
id: number;
runItemId: number;
decision: "FBA" | "FBM" | "BUY" | "WATCH" | "SKIP";
confidence: number | null;
reasoning: string | null;
analyzedAt: string;
}
export interface CategoryRunSummaryDb { export interface CategoryRunSummaryDb {
categoryId: number; categoryId: number;

File diff suppressed because it is too large Load Diff

View File

@@ -41,9 +41,24 @@ p {
gap: 10px; gap: 10px;
} }
.section-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.button-row {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.toolbar input, .toolbar input,
.toolbar select, .toolbar select,
button { button,
.button-link {
height: 36px; height: 36px;
border-radius: 8px; border-radius: 8px;
border: 1px solid #d8dce0; border: 1px solid #d8dce0;
@@ -52,10 +67,29 @@ button {
font-size: 14px; font-size: 14px;
} }
.button-link {
display: inline-flex;
align-items: center;
justify-content: center;
color: inherit;
text-decoration: none;
}
button { button {
cursor: pointer; cursor: pointer;
} }
button.danger {
border-color: #efb8b8;
color: #9f1c1c;
background: #fff6f6;
}
button:disabled {
cursor: not-allowed;
opacity: 0.58;
}
.table-wrap { .table-wrap {
overflow: auto; overflow: auto;
border: 1px solid #eceef0; border: 1px solid #eceef0;
@@ -91,6 +125,110 @@ td {
overflow-wrap: anywhere; overflow-wrap: anywhere;
} }
.inventory-col {
min-width: 360px;
max-width: 520px;
white-space: normal;
overflow-wrap: anywhere;
}
.inventory-col a {
display: inline-block;
margin-right: 8px;
margin-bottom: 4px;
}
.stalker-table {
min-width: 1320px;
}
.stalker-actions {
display: flex;
gap: 6px;
align-items: center;
}
.dist-research-entry {
padding: 16px 0;
border-top: 1px solid #eceef0;
}
.dist-research-entry:first-child {
border-top: none;
padding-top: 8px;
}
.dist-entry-meta {
display: flex;
align-items: center;
gap: 10px;
font-size: 13px;
color: #5f6b7a;
margin-bottom: 12px;
}
.dist-entry-run {
font-size: 12px;
color: #8a95a0;
}
.dist-candidates {
display: flex;
flex-direction: column;
gap: 12px;
}
.dist-candidate-card {
border: 1px solid #e7e8ea;
border-radius: 10px;
padding: 14px 16px;
display: flex;
flex-direction: column;
gap: 8px;
}
.dist-candidate-header {
display: flex;
align-items: center;
gap: 10px;
font-size: 15px;
}
.dist-field {
display: flex;
gap: 8px;
font-size: 13px;
line-height: 1.5;
}
.dist-field-block {
flex-direction: column;
gap: 4px;
}
.dist-label {
font-weight: 600;
color: #445060;
white-space: nowrap;
min-width: 120px;
}
.dist-field-block .dist-label {
min-width: unset;
}
.dist-outreach {
background: #f7f8fa;
border: 1px solid #e7e8ea;
border-radius: 8px;
padding: 12px 14px;
font-family: inherit;
font-size: 13px;
line-height: 1.6;
white-space: pre-wrap;
margin: 0;
}
th { th {
background: #fafafb; background: #fafafb;
font-weight: 600; font-weight: 600;
@@ -262,4 +400,9 @@ th button {
.spark-grid { .spark-grid {
grid-template-columns: repeat(2, minmax(0, 1fr)); grid-template-columns: repeat(2, minmax(0, 1fr));
} }
.section-header {
align-items: flex-start;
flex-direction: column;
}
} }

View File

@@ -1,5 +1,22 @@
import { getDb } from "./database.ts"; import { eq } from "drizzle-orm";
import type { AnalysisResult } from "./types.ts"; import { db } from "./db/index.ts";
import { analysisRunStats, runs } from "./db/schema.ts";
import {
persistLlmResults,
persistSupplierResults,
refreshRunStats,
} from "./db/persistence.ts";
import type { AnalysisResult, SupplierAnalysisResult } from "./types.ts";
import { mkdirSync } from "node:fs";
import path from "node:path";
import * as XLSX from "xlsx";
export type RunCounts = {
totalProducts: number;
fbaCount: number;
fbmCount: number;
skipCount: number;
};
function buildRow(r: AnalysisResult) { function buildRow(r: AnalysisResult) {
const price = const price =
@@ -30,6 +47,9 @@ function buildRow(r: AnalysisResult) {
"Sales Rank": rank ?? "", "Sales Rank": rank ?? "",
"Rank Avg 90d": r.product.keepa?.salesRankAvg90 ?? "", "Rank Avg 90d": r.product.keepa?.salesRankAvg90 ?? "",
Sellers: r.product.keepa?.sellerCount ?? "", Sellers: r.product.keepa?.sellerCount ?? "",
"Amazon Is Seller": r.product.keepa?.amazonIsSeller ?? null,
"Amazon Buy Box Share 90d %":
r.product.keepa?.amazonBuyboxSharePct90d ?? "",
"Monthly Sold": r.product.keepa?.monthlySold ?? "", "Monthly Sold": r.product.keepa?.monthlySold ?? "",
"Rank Drops 30d": r.product.keepa?.salesRankDrops30 ?? "", "Rank Drops 30d": r.product.keepa?.salesRankDrops30 ?? "",
"Rank Drops 90d": r.product.keepa?.salesRankDrops90 ?? "", "Rank Drops 90d": r.product.keepa?.salesRankDrops90 ?? "",
@@ -59,113 +79,115 @@ function buildRow(r: AnalysisResult) {
}; };
} }
export function writeResultsToDb( export async function writeResultsToDb(
results: AnalysisResult[], results: AnalysisResult[],
dbPath: string,
inputFile: string, inputFile: string,
outputFile: string | undefined, outputFile: string | undefined,
): Promise<void> {
const runId = await startRunInDb(inputFile, outputFile);
try {
await appendResultsToRun(runId, results);
await refreshRunCountsInDb(runId);
await completeRunInDb(runId);
} catch (error) {
await failRunInDb(runId, error);
throw error;
}
console.log(`Results written to database for run_id: ${runId}`);
}
export function writeResultsWorkbook(
results: AnalysisResult[],
outputFile: string,
): void { ): void {
const database = getDb(dbPath); const outputDir = path.dirname(outputFile);
if (outputDir && outputDir !== ".") {
const timestamp = new Date().toISOString(); mkdirSync(outputDir, { recursive: true });
const fbaCount = results.filter((r) => r.verdict.verdict === "FBA").length;
const fbmCount = results.filter((r) => r.verdict.verdict === "FBM").length;
const skipCount = results.filter((r) => r.verdict.verdict === "SKIP").length;
const insertRun = database.prepare(
`INSERT INTO runs (
timestamp,
input_file,
output_file,
total_products,
fba_count,
fbm_count,
skip_count
) VALUES (?, ?, ?, ?, ?, ?, ?)`,
);
const runInfo = insertRun.run(
timestamp,
inputFile,
outputFile ?? null,
results.length,
fbaCount,
fbmCount,
skipCount,
);
const runId =
(runInfo.changes as number) > 0
? (runInfo.lastInsertRowid as number)
: null;
if (runId === null) {
console.error("Failed to insert run record into SQLite.");
return;
} }
const insertResult = database.prepare( const workbook = XLSX.utils.book_new();
`INSERT INTO results ( const worksheet = XLSX.utils.json_to_sheet(results.map(buildRow));
run_id, asin, product_name, brand, category, unit_cost, current_price, XLSX.utils.book_append_sheet(workbook, worksheet, "Results");
avg_price_90d, avg_price_90d_sheet, selling_price_sheet, sales_rank, rank_avg_90d, XLSX.writeFile(workbook, outputFile);
sellers, monthly_sold, rank_drops_30d, rank_drops_90d, fba_net_sheet, console.log(`Results workbook written: ${outputFile}`);
gross_profit_dollar, gross_profit_pct, net_profit_sheet, roi_sheet, moq, moq_cost,
qty_available, supplier, source_url, asin_link, promo_coupon_code, notes, lead_date,
fba_fee, fbm_fee, referral_percent, can_sell, sellability_status, sellability_reason,
verdict, confidence, reasoning, fetched_at
) VALUES (
?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?,
?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?
)`,
);
database.transaction(() => {
for (const r of results) {
const row = buildRow(r);
insertResult.run(
runId,
row.ASIN,
row.Name,
row.Brand,
row.Category,
row["Unit Cost"] ?? null,
row["Current Price"] ?? null,
row["Avg Price 90d"] ?? null,
row["Avg Price 90d (sheet)"] ?? null,
row["Selling Price (sheet)"] ?? null,
row["Sales Rank"] ?? null,
row["Rank Avg 90d"] ?? null,
row.Sellers ?? null,
row["Monthly Sold"] ?? null,
row["Rank Drops 30d"] ?? null,
row["Rank Drops 90d"] ?? null,
row["FBA Net (sheet)"] ?? null,
row["Gross Profit $"] ?? null,
row["Gross Profit %"] ?? null,
row["Net Profit (sheet)"] ?? null,
row["ROI (sheet)"] ?? null,
row.MOQ ?? null,
row["MOQ Cost"] ?? null,
row["Qty Available"] ?? null,
row.Supplier ?? null,
row["Source URL"] ?? null,
row["ASIN Link"] ?? null,
row["Promo/Coupon Code"] ?? null,
row.Notes ?? null,
row["Lead Date"] ?? null,
row["FBA Fee"] ?? null,
row["FBM Fee"] ?? null,
row["Referral %"] ?? null,
row["Can Sell"],
row.Sellability,
row["Sellability Reason"] ?? null,
row.Verdict,
row.Confidence ?? null,
row.Reasoning,
r.product.fetchedAt,
);
}
})();
console.log(`Results written to SQLite database for run_id: ${runId}`);
} }
export async function startRunInDb(
inputFile: string,
outputFile: string | undefined,
counts: RunCounts = {
totalProducts: 0,
fbaCount: 0,
fbmCount: 0,
skipCount: 0,
},
type: "lead_analysis" | "supplier_upc" = "lead_analysis",
): Promise<number> {
const [row] = await db
.insert(runs)
.values({
type,
inputFile,
outputFile: outputFile ?? null,
status: "running",
startedAt: new Date(),
})
.returning({ id: runs.id });
if (!row) throw new Error("Failed to insert run record.");
await db.insert(analysisRunStats).values({
runId: row.id,
processedCount: counts.totalProducts,
analyzedCount: counts.totalProducts,
fbaCount: counts.fbaCount,
fbmCount: counts.fbmCount,
skipCount: counts.skipCount,
});
return row.id;
}
export async function appendResultsToRun(
runId: number,
results: AnalysisResult[],
): Promise<void> {
if (results.length === 0) return;
await persistLlmResults(runId, results, {
source: "lead_analysis",
metadataSource: "input",
preserveSourcingInput: true,
});
}
export async function appendSupplierResultsToRun(
runId: number,
results: SupplierAnalysisResult[],
): Promise<void> {
if (results.length === 0) return;
await persistSupplierResults(runId, results);
}
export async function refreshRunCountsInDb(runId: number): Promise<RunCounts> {
return refreshRunStats(runId);
}
export async function completeRunInDb(runId: number): Promise<void> {
await db
.update(runs)
.set({ status: "completed", completedAt: new Date(), errorMessage: null })
.where(eq(runs.id, runId));
}
export async function failRunInDb(
runId: number,
error: unknown,
): Promise<void> {
const errorMessage = error instanceof Error ? error.message : String(error);
await db
.update(runs)
.set({ status: "failed", completedAt: new Date(), errorMessage })
.where(eq(runs.id, runId));
}
export function printResults(results: AnalysisResult[]): void { export function printResults(results: AnalysisResult[]): void {
const rows = results const rows = results
.filter((r) => r.verdict.verdict === "FBA" || r.verdict.verdict === "FBM") .filter((r) => r.verdict.verdict === "FBA" || r.verdict.verdict === "FBM")