Compare commits

..

18 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
58 changed files with 8017 additions and 4779 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,7 +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_API_KEY=your_google_api_key
GOOGLE_CSE_ID=your_google_programmable_search_engine_id GOOGLE_CSE_ID=your_google_programmable_search_engine_id
SERPAPI_API_KEY=your_serpapi_api_key_for_google_shopping 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

View File

@@ -12,8 +12,8 @@ Default to using Bun instead of Node.js.
## APIs ## APIs
- `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`.
- Prefer `Bun.file` over `node:fs`'s readFile/writeFile. - Prefer `Bun.file` over `node:fs`'s readFile/writeFile.
- `Bun.$\`cmd\`` instead of execa. - `Bun.$\`cmd\`` instead of execa.
@@ -24,13 +24,13 @@ Default to using Bun instead of Node.js.
bun test bun test
# Run a single test file # Run a single test file
bun test src/supplier-scoring.test.ts bun test src/supplier/supplier-scoring.test.ts
# Type-check (no emit) # Type-check (no emit)
./node_modules/.bin/tsc --noEmit ./node_modules/.bin/tsc --noEmit
# ASIN lead-list pipeline (LLM-based) # ASIN lead-list pipeline (LLM-based)
bun run src/index.ts input/leads.xlsx --out output/results.xlsx bun start leads.xlsx --out results.xlsx
# Supplier UPC pipeline (deterministic) # Supplier UPC pipeline (deterministic)
bun run upc-file --input input/supplier.xlsx --out output/supplier_ranked.xlsx bun run upc-file --input input/supplier.xlsx --out output/supplier_ranked.xlsx
@@ -40,6 +40,9 @@ bun run bestsellers
bun run monthly-sold bun run monthly-sold
bun run mid-range bun run mid-range
# Stalker pipeline
bun run stalker --input input/asins.xlsx
# Web API server # Web API server
bun run start:web # http://localhost:3000 bun run start:web # http://localhost:3000
@@ -47,29 +50,37 @@ bun run start:web # http://localhost:3000
bun run src/sp-test.ts bun run src/sp-test.ts
bun run src/sp-test.ts B07SN9BHVV bun run src/sp-test.ts B07SN9BHVV
bun run src/sp-test.ts --sellability B07SN9BHVV bun run src/sp-test.ts --sellability B07SN9BHVV
# Database migrations (Drizzle)
bun run db:generate
bun run db:migrate
``` ```
## Architecture ## Architecture
Two distinct analysis pipelines share infrastructure (Keepa, SP-API, Redis, SQLite) but diverge in how they produce verdicts. 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`) ### ASIN Lead-list Pipeline (`src/index.ts` → `src/analysis-pipeline.ts`)
For spreadsheets containing known ASINs. Verdict is LLM-based (FBA/FBM/SKIP via LM Studio). For spreadsheets containing known ASINs. Verdict is LLM-based (FBA/FBM/SKIP via LM Studio).
Flow: `reader.ts` parse → Redis cache check → `sp-api.ts` sellability gate (5 concurrent workers) → `keepa.ts` batch enrichment → `sp-api.ts` pricing + FBA fees (5 concurrent workers) → `llm.ts` batched analysis (5 products/batch) → `writer.ts` XLSX + SQLite. 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/upc-file-analysis.ts`) ### Supplier UPC Pipeline (`src/supplier/upc-file-analysis.ts`)
For supplier price lists containing UPC/EAN values. Verdict is deterministic (BUY/WATCH/SKIP); never calls LM Studio. For supplier price lists containing UPC/EAN values. Verdict is deterministic (BUY/WATCH/SKIP); never calls LM Studio.
Flow: `upc-file-reader.ts` streaming parse (`.xlsx`) or row-window parse (`.xls`) → SP-API catalog UPC lookup first, Keepa UPC lookup as fallback → `keepa.ts` demand enrichment → `sp-api.ts` sellability + FBA fees → `supplier-scoring.ts` deterministic score → `supplier-export.ts` Excel workbook (`Ranked Leads`, `Skipped`, `Summary` sheets) + SQLite. 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.
UPC resolution priority: SP-API catalog lookup → Keepa fallback (for no-match or request failure only). UPC resolution priority: SP-API catalog lookup → Keepa fallback (for no-match or request failure only).
### Category Pipelines ### Category Pipelines
`bestsellers-by-category.ts`, `top-monthly-sold-by-category.ts`, `mid-range-sellers-by-category.ts` — Keepa category browsing → SP-API sellability gate → LLM verdict. Each saves results to SQLite. Mid-range applies configurable filters (monthly sold, price, seller count, Amazon buy box share). `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`)
Tracks competitor sellers across ASINs. Fetches storefronts, checks sellability of inventory items, and persists matched seller data to Postgres.
### Shared Infrastructure ### Shared Infrastructure
@@ -77,18 +88,24 @@ UPC resolution priority: SP-API catalog lookup → Keepa fallback (for no-match
|--------|------| |--------|------|
| `src/types.ts` | All shared interfaces (`ProductRecord`, `KeepaData`, `SpApiData`, `SupplierScore`, etc.) | | `src/types.ts` | All shared interfaces (`ProductRecord`, `KeepaData`, `SpApiData`, `SupplierScore`, etc.) |
| `src/config.ts` | Env var loading via `Bun.env` | | `src/config.ts` | Env var loading via `Bun.env` |
| `src/keepa.ts` | Keepa API: batch ASIN fetch, UPC lookup, auto rate-limiting on token exhaustion | | `src/db/index.ts` | Drizzle Postgres connection (shared pool) |
| `src/sp-api.ts` | SP-API: sellability (`getListingsRestrictions`), pricing+fees, UPC catalog lookup | | `src/db/schema.ts` | Drizzle schema for all tables |
| `src/cache.ts` | Redis caching (24h TTL for lead-list; 12h for mid-range) | | `src/db/persistence.ts` | Product, observation, unified run-item, UPC resolution, and revision persistence |
| `src/database.ts` | SQLite `runs` + `results` tables; auto-creates `db/results.db` | | `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) |
| `src/integrations/llm.ts` | LLM integration (LM Studio / Claude) |
| `src/server.ts` | Bun HTTP server exposing REST endpoints for both pipelines | | `src/server.ts` | Bun HTTP server exposing REST endpoints for both pipelines |
### File Layout ### File Layout
- `src/integrations/` — external API clients (Keepa, SP-API, Redis cache, LLM, SearXNG)
- `src/categories/` — category discovery pipelines
- `src/stalker/` — competitor seller tracking pipeline
- `src/supplier/` — supplier UPC analysis pipeline
- `src/db/` — Drizzle schema and connection
- `input/` — source spreadsheets (git-ignored) - `input/` — source spreadsheets (git-ignored)
- `output/` — generated workbooks (git-ignored) - `output/` — generated workbooks (git-ignored)
- `db/` — SQLite files (git-ignored)
- `src/` — all source and test files
## Project Rules ## Project Rules
@@ -96,4 +113,6 @@ UPC resolution priority: SP-API catalog lookup → Keepa fallback (for no-match
- The supplier UPC pipeline must not call LM Studio. - 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 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. - 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`. - When changing UPC supplier behavior, cover SP-API UPC parsing, deterministic scoring, and workbook export with `bun test`.

View File

@@ -21,14 +21,19 @@ cp .env.example .env
## Usage ## Usage
```bash ```bash
bun run src/index.ts input/<input.csv|xlsx> [--out output/results.xlsx] 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 input/leads.xlsx bun start leads.xlsx
bun run src/index.ts input/leads.csv --out output/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:
@@ -55,6 +60,14 @@ bun run monthly-sold
bun run mid-range 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: Mid-range process:
- Script: `bun run mid-range` - Script: `bun run mid-range`
@@ -128,6 +141,12 @@ curl -X POST "http://localhost:3000/api/upc/lookup" \
-d '{"upcs":["012345678901","098765432109"]}' -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) ## Large UPC File Analysis (XLS/XLSX)
For supplier price lists that contain UPC/EAN values and unit cost, use the For supplier price lists that contain UPC/EAN values and unit cost, use the
@@ -138,7 +157,7 @@ ranked sourcing workbook:
2. Resolves UPCs to ASINs with SP-API catalog lookup first, then falls back to Keepa for no-match/request-failure cases. 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. 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. 4. Scores products with deterministic BUY/WATCH/SKIP logic; this path does not call LM Studio.
5. Writes a ranked Excel workbook and persists rows into the existing `runs` + `results` tables. 5. Writes a ranked Excel workbook and persists rows through unified runs, UPC resolution, product observation, and scoring-history tables.
CLI usage: CLI usage:
@@ -227,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 `db/results.db` by default. 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
@@ -248,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

183
bun.lock
View File

@@ -6,8 +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", "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",
@@ -16,11 +18,70 @@
"@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",
"typescript": "^6.0.3", "typescript": "^6.0.3",
}, },
}, },
}, },
"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/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=="], "@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=="],
@@ -63,6 +124,8 @@
"buffer-crc32": ["buffer-crc32@0.2.13", "", {}, "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ=="], "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=="], "buffer-indexof-polyfill": ["buffer-indexof-polyfill@1.0.2", "", {}, "sha512-I7wzHwA3t1/lwXQh+A5PbNvJxgfo5r3xulgpYDB5zckTu/Z9oUK9biouBKQUjEqzaz3HnAT6TYoovmE+GqSf7A=="],
"buffers": ["buffers@0.1.1", "", {}, "sha512-9q/rDEGSb/Qsvv2qvzIzdluL5k7AaJOTrw23z9reQthrbF7is4CtlT0DXyO1oei2DCp4uojjzQ7igaSHp1kAEQ=="], "buffers": ["buffers@0.1.1", "", {}, "sha512-9q/rDEGSb/Qsvv2qvzIzdluL5k7AaJOTrw23z9reQthrbF7is4CtlT0DXyO1oei2DCp4uojjzQ7igaSHp1kAEQ=="],
@@ -101,6 +164,10 @@
"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=="], "duplexer2": ["duplexer2@0.1.4", "", { "dependencies": { "readable-stream": "^2.0.2" } }, "sha512-asLFVfWWtJ90ZyOUHMqk7/S2w2guQKxUI2itj3d92ADHhxUSbCMGi1f1cBcJ7xM1To+pE/Khbwo1yuNbMEPKeA=="],
@@ -113,6 +180,8 @@
"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=="], "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-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=="],
@@ -127,6 +196,8 @@
"fs.realpath": ["fs.realpath@1.0.0", "", {}, "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw=="], "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=="], "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=="],
@@ -135,6 +206,8 @@
"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=="], "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=="],
@@ -217,6 +290,8 @@
"path-is-absolute": ["path-is-absolute@1.0.1", "", {}, "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg=="], "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=="], "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=="],
@@ -233,6 +308,8 @@
"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=="], "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=="], "safe-buffer": ["safe-buffer@5.1.2", "", {}, "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="],
@@ -253,6 +330,10 @@
"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=="],
@@ -267,6 +348,8 @@
"traverse": ["traverse@0.3.9", "", {}, "sha512-iawgk0hLP3SxGKDfnDJf8wTz4p2qImnyihM5Hh/sGvQ3K37dPi/w8sRhdNIxYA1TwFwc5mDhIJq+O0RsvXBKdQ=="], "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=="], "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=="],
@@ -289,6 +372,8 @@
"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=="], "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/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=="], "@fast-csv/parse/@types/node": ["@types/node@14.18.63", "", {}, "sha512-fAtCfv4jJg+ExtXhvCkCqUKZ+4ok/JQk01qDKhL5BDDoS3AxKXhV5/MAVUZyQnSEd2GT92fkgZl0pz0Q0AzcIQ=="],
@@ -305,10 +390,56 @@
"string_decoder/safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="], "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=="], "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=="], "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=="], "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=="], "duplexer2/readable-stream/string_decoder": ["string_decoder@1.1.1", "", { "dependencies": { "safe-buffer": "~5.1.0" } }, "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg=="],
@@ -319,6 +450,58 @@
"lazystream/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=="], "unzipper/readable-stream/string_decoder": ["string_decoder@1.1.1", "", { "dependencies": { "safe-buffer": "~5.1.0" } }, "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg=="],
} }
} }

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,28 +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/mid-range-sellers-by-category.ts", "mid-range": "bun run src/categories/mid-range-sellers-by-category.ts",
"stalker": "bun run src/stalker.ts", "stalker": "bun run src/stalker/stalker.ts",
"search-offers": "bun run src/asin-offer-search.ts", "search-offers": "bun run src/asin-offer-search.ts",
"upc": "bun run src/upc-lookup.ts", "upc": "bun run src/supplier/upc-lookup.ts",
"upc-file": "bun run src/upc-file-analysis.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",
"typescript": "^6.0.3" "typescript": "^6.0.3"
}, },
"dependencies": { "dependencies": {
"amazon-sp-api": "^1.2.1", "amazon-sp-api": "^1.2.1",
"drizzle-orm": "^0.45.2",
"exceljs": "^4.4.0", "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);
});

View File

@@ -1,7 +1,7 @@
import { fetchKeepaDataBatch } from "./keepa.ts"; import { fetchKeepaDataBatch } from "./integrations/keepa.ts";
import { fetchSellabilityBatch, fetchSpApiPricingAndFees } from "./sp-api.ts"; import { fetchSellabilityBatch, fetchSpApiPricingAndFees } from "./integrations/sp-api.ts";
import { getCache, setCache } from "./cache.ts"; import { getCache, setCache } from "./integrations/cache.ts";
import { analyzeProducts } from "./llm.ts"; import { analyzeProducts } from "./integrations/llm.ts";
import type { import type {
AnalysisResult, AnalysisResult,
EnrichedProduct, EnrichedProduct,
@@ -16,12 +16,23 @@ export const DEFAULT_PRICING_CONCURRENCY = 5;
export type SellabilityFilter = "available" | "all"; 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 = { export type AnalysisPipelineOptions = {
llmBatchSize?: number; llmBatchSize?: number;
pricingConcurrency?: number; pricingConcurrency?: number;
llmBatchDelayMs?: number; llmBatchDelayMs?: number;
llmRetryDelayMs?: number; llmRetryDelayMs?: number;
sellability?: SellabilityFilter; sellability?: SellabilityFilter;
useClaude?: boolean;
dependencies?: Partial<AnalysisPipelineDependencies>;
}; };
export function chunkArray<T>(items: T[], chunkSize: number): T[][] { export function chunkArray<T>(items: T[], chunkSize: number): T[][] {
@@ -60,23 +71,34 @@ export async function processProductChunk(
const llmBatchDelayMs = Math.max(0, options.llmBatchDelayMs ?? 5_000); const llmBatchDelayMs = Math.max(0, options.llmBatchDelayMs ?? 5_000);
const llmRetryDelayMs = Math.max(0, options.llmRetryDelayMs ?? 10_000); const llmRetryDelayMs = Math.max(0, options.llmRetryDelayMs ?? 10_000);
const sellabilityFilter = options.sellability ?? "available"; 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...`); console.log(`\nChecking cache for ${products.length} products...`);
const cached = new Map<string, EnrichedProduct>(); const cached = new Map<string, EnrichedProduct>();
const excludedCachedAsins = new Set<string>(); const excludedCached = new Map<string, EnrichedProduct>();
const uncachedProducts: ProductRecord[] = []; const uncachedProducts: ProductRecord[] = [];
for (const p of products) { for (const p of products) {
const hit = await getCache(p.asin); const hit = await dependencies.getCache(p.asin);
if (hit) { if (hit) {
const currentSourceProduct = { ...hit, record: p };
if ( if (
sellabilityFilter === "all" || sellabilityFilter === "all" ||
hit.spApi.sellabilityStatus === "available" hit.spApi.sellabilityStatus === "available"
) { ) {
console.log(` [cache hit] ${p.asin}`); console.log(` [cache hit] ${p.asin}`);
cached.set(p.asin, hit); cached.set(p.asin, currentSourceProduct);
} else { } else {
excludedCachedAsins.add(p.asin); excludedCached.set(p.asin, currentSourceProduct);
console.log( console.log(
` [exclude cached] ${p.asin} - status=${hit.spApi.sellabilityStatus}`, ` [exclude cached] ${p.asin} - status=${hit.spApi.sellabilityStatus}`,
); );
@@ -87,7 +109,7 @@ export async function processProductChunk(
} }
console.log( console.log(
`${cached.size} cached available, ${excludedCachedAsins.size} cached excluded, ${uncachedProducts.length} to fetch`, `${cached.size} cached available, ${excludedCached.size} cached excluded, ${uncachedProducts.length} to fetch`,
); );
const sellabilityMap = new Map<string, SellabilityInfo>(); const sellabilityMap = new Map<string, SellabilityInfo>();
@@ -98,7 +120,7 @@ export async function processProductChunk(
console.log( console.log(
`\nChecking sellability for ${uncachedProducts.length} ASINs...`, `\nChecking sellability for ${uncachedProducts.length} ASINs...`,
); );
const sellResults = await fetchSellabilityBatch( const sellResults = await dependencies.fetchSellabilityBatch(
uncachedProducts.map((p) => p.asin), uncachedProducts.map((p) => p.asin),
); );
@@ -141,7 +163,7 @@ export async function processProductChunk(
if (availableProducts.length > 0) { if (availableProducts.length > 0) {
console.log(`\nFetching ${availableProducts.length} ASINs from Keepa...`); console.log(`\nFetching ${availableProducts.length} ASINs from Keepa...`);
try { try {
keepaResults = await fetchKeepaDataBatch( keepaResults = await dependencies.fetchKeepaDataBatch(
availableProducts.map((p) => p.asin), availableProducts.map((p) => p.asin),
); );
} catch (err) { } catch (err) {
@@ -166,7 +188,10 @@ export async function processProductChunk(
sellabilityStatus: "unknown" as const, sellabilityStatus: "unknown" as const,
sellabilityReason: "Sellability check returned no result", sellabilityReason: "Sellability check returned no result",
}; };
const spApi = await fetchSpApiPricingAndFees(p.asin, sellability); const spApi = await dependencies.fetchSpApiPricingAndFees(
p.asin,
sellability,
);
const keepa = keepaResults.get(p.asin); const keepa = keepaResults.get(p.asin);
if (keepa?.currentPrice && spApi.estimatedSalePrice === 0) { if (keepa?.currentPrice && spApi.estimatedSalePrice === 0) {
@@ -194,17 +219,33 @@ export async function processProductChunk(
const availableAsins = new Set(availableProducts.map((ap) => ap.asin)); const availableAsins = new Set(availableProducts.map((ap) => ap.asin));
for (const p of products) { for (const p of products) {
if (excludedCachedAsins.has(p.asin)) { const excludedCachedProduct = excludedCached.get(p.asin);
if (excludedCachedProduct) {
enriched.push({ ...excludedCachedProduct, record: p });
continue; continue;
} }
const cachedProduct = cached.get(p.asin); const cachedProduct = cached.get(p.asin);
if (cachedProduct) { if (cachedProduct) {
enriched.push(cachedProduct); enriched.push({ ...cachedProduct, record: p });
continue; continue;
} }
if (!availableAsins.has(p.asin)) { 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; continue;
} }
@@ -219,19 +260,41 @@ export async function processProductChunk(
fetchedAt: new Date().toISOString(), fetchedAt: new Date().toISOString(),
}; };
await setCache(p.asin, product); await dependencies.setCache(p.asin, product);
enriched.push(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( console.log(
`\nAnalyzing ${enriched.length} products via LLM (batch size: ${llmBatchSize})...\n`, `\nAnalyzing ${llmProducts.length} products via LLM (batch size: ${llmBatchSize})...\n`,
); );
const results: AnalysisResult[] = []; for (let i = 0; i < llmProducts.length; i += llmBatchSize) {
for (let i = 0; i < enriched.length; i += llmBatchSize) { const batch = llmProducts.slice(i, i + llmBatchSize);
const batch = enriched.slice(i, i + llmBatchSize);
const batchNum = Math.floor(i / llmBatchSize) + 1; const batchNum = Math.floor(i / llmBatchSize) + 1;
const totalBatches = Math.ceil(enriched.length / llmBatchSize); const totalBatches = Math.ceil(llmProducts.length / llmBatchSize);
console.log(` LLM batch ${batchNum}/${totalBatches}...`); console.log(` LLM batch ${batchNum}/${totalBatches}...`);
if (i > 0 && llmBatchDelayMs > 0) { if (i > 0 && llmBatchDelayMs > 0) {
@@ -240,16 +303,18 @@ export async function processProductChunk(
let verdicts; let verdicts;
try { try {
verdicts = await analyzeProducts(batch, { verdicts = await dependencies.analyzeProducts(batch, {
ignoreSellability: sellabilityFilter === "all", ignoreSellability: sellabilityFilter === "all",
useClaude,
}); });
} catch { } catch {
if (llmRetryDelayMs > 0) { if (llmRetryDelayMs > 0) {
await wait(llmRetryDelayMs); await wait(llmRetryDelayMs);
} }
try { try {
verdicts = await analyzeProducts(batch, { verdicts = await dependencies.analyzeProducts(batch, {
ignoreSellability: sellabilityFilter === "all", ignoreSellability: sellabilityFilter === "all",
useClaude,
}); });
} catch { } catch {
verdicts = null; verdicts = null;
@@ -260,7 +325,7 @@ export async function processProductChunk(
const enrichedProduct = batch[j]; const enrichedProduct = batch[j];
if (!enrichedProduct) continue; if (!enrichedProduct) continue;
results.push({ resultsByProduct.set(enrichedProduct, {
product: enrichedProduct, product: enrichedProduct,
verdict: verdicts?.[j] ?? { verdict: verdicts?.[j] ?? {
asin: enrichedProduct.record.asin, asin: enrichedProduct.record.asin,
@@ -272,5 +337,7 @@ export async function processProductChunk(
} }
} }
return results; return enriched
.map((product) => resultsByProduct.get(product))
.filter((result): result is AnalysisResult => result !== undefined);
} }

View File

@@ -1,4 +1,4 @@
import { searchProductOffers, type SearxngOfferSearchResult } from "./searxng.ts"; import { searchProductOffers, type SearxngOfferSearchResult } from "./integrations/searxng.ts";
type CliArgs = { type CliArgs = {
query: string; query: string;

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,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;
@@ -26,6 +31,7 @@ type ParsedArgs = {
categoryLimit: number; categoryLimit: number;
perCategoryTop: number; perCategoryTop: number;
blacklistFile: string; blacklistFile: string;
useClaude: boolean;
}; };
type CategoryRunSummary = { type CategoryRunSummary = {
@@ -72,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 =
@@ -100,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;
@@ -118,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.",
@@ -132,36 +144,13 @@ function printUsageAndExit(message: string): never {
} }
export async function insertCategoryRunSummary( export async function insertCategoryRunSummary(
db: Database,
summary: CategoryRunSummary, summary: CategoryRunSummary,
runTimestamp: string, runTimestamp: string,
): Promise<number> { ): Promise<number> {
const query = ` return createCategoryRun(summary, runTimestamp);
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,
@@ -174,136 +163,18 @@ export async function updateCategoryRunSummary(
| "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, amazon_is_seller, amazon_buybox_share_pct_90d,
monthly_sold, rank_drops_30d, rank_drops_90d,
fba_fee, fbm_fee, referral_percent, can_sell,
sellability_status, sellability_reason,
verdict, confidence, reasoning, fetched_at
) VALUES (
?, ?, ?, ?, ?, ?, ?, ?, ?, ?,
?, ?, ?, ?, ?, ?, ?, ?, ?, ?,
?, ?, ?, ?, ?, ?, ?, ?
)
ON CONFLICT(asin) DO UPDATE SET
run_id = excluded.run_id,
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,
amazon_is_seller = excluded.amazon_is_seller,
amazon_buybox_share_pct_90d = excluded.amazon_buybox_share_pct_90d,
monthly_sold = excluded.monthly_sold,
rank_drops_30d = excluded.rank_drops_30d,
rank_drops_90d = excluded.rank_drops_90d,
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?.amazonIsSeller == null
? null
: r.product.keepa.amazonIsSeller
? 1
: 0,
r.product.keepa?.amazonBuyboxSharePct90d ?? null,
r.product.keepa?.monthlySold ?? null,
r.product.keepa?.salesRankDrops30 ?? null,
r.product.keepa?.salesRankDrops90 ?? null,
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> {
@@ -639,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);
@@ -685,7 +556,11 @@ 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(
value
.map((v) => normalizeAsin(v))
.filter((asin): asin is string => asin !== null),
),
].slice(0, limit); ].slice(0, limit);
} }
} }
@@ -940,7 +815,7 @@ async function fetchKeepaEnrichmentMap(
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),
@@ -1007,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,
@@ -1061,7 +936,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,
@@ -1106,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}`);
@@ -1129,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);
@@ -1142,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,
@@ -1162,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,
@@ -1191,10 +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(), "db", "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}`);
@@ -1228,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,
@@ -1245,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;
@@ -1274,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

@@ -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<string, any>( return new Map<string, any>(
@@ -51,55 +84,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("./mid-range-sellers-by-category.ts"); const modulePromise = import("./mid-range-sellers-by-category.ts");
const DB_TEST_PATH = path.join(
process.cwd(),
"test_output",
"test_mid_range_analysis.sqlite",
);
let db: Database;
let processCategory: any; let processCategory: 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"
@@ -138,25 +144,8 @@ beforeEach(() => {
buyBoxStatsAmazon90: 40, buyBoxStatsAmazon90: 40,
stats: { stats: {
current: [ current: [
null, null, null, null, 1000, null, null, null, null, null, null, null, 5,
null, null, null, null, null, null, null, 2599,
null,
1000,
null,
null,
null,
null,
null,
null,
null,
5,
null,
null,
null,
null,
null,
null,
2599,
], ],
avg: [2400, null, null, 1200], avg: [2400, null, null, 1200],
}, },
@@ -171,25 +160,8 @@ beforeEach(() => {
buyBoxStatsAmazon90: 50, buyBoxStatsAmazon90: 50,
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],
}, },
@@ -204,25 +176,8 @@ beforeEach(() => {
buyBoxStatsAmazon90: 50, buyBoxStatsAmazon90: 50,
stats: { stats: {
current: [ current: [
null, null, null, null, 1500, null, null, null, null, null, null, null, 4,
null, null, null, null, null, null, null, 2099,
null,
1500,
null,
null,
null,
null,
null,
null,
null,
4,
null,
null,
null,
null,
null,
null,
2099,
], ],
avg: [2000, null, null, 1800], avg: [2000, null, null, 1800],
}, },
@@ -237,25 +192,8 @@ beforeEach(() => {
buyBoxStatsAmazon90: 95, buyBoxStatsAmazon90: 95,
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],
}, },
@@ -269,25 +207,8 @@ beforeEach(() => {
isAmazonSeller: false, isAmazonSeller: false,
stats: { stats: {
current: [ current: [
null, null, null, null, 3200, null, null, null, null, null, null, null, 25,
null, null, null, null, null, null, null, 3500,
null,
3200,
null,
null,
null,
null,
null,
null,
null,
25,
null,
null,
null,
null,
null,
null,
3500,
], ],
avg: [3200, null, null, 3200], avg: [3200, null, null, 3200],
}, },
@@ -315,7 +236,6 @@ test("processCategory only analyzes sellable mid-range matches", async () => {
}; };
const runId = await insertCategoryRunSummary( const runId = await insertCategoryRunSummary(
db,
{ {
categoryId: mockCategory.id, categoryId: mockCategory.id,
categoryLabel: mockCategory.label, categoryLabel: mockCategory.label,
@@ -332,7 +252,6 @@ test("processCategory only analyzes sellable mid-range matches", async () => {
); );
const summary = await processCategory( const summary = await processCategory(
db,
runId, runId,
mockCategory, mockCategory,
3, 3,
@@ -345,6 +264,7 @@ test("processCategory only analyzes sellable mid-range matches", async () => {
20, 20,
15, 15,
85, 85,
"strict",
); );
expect(summary.status).toBe("ok"); expect(summary.status).toBe("ok");
@@ -352,23 +272,7 @@ test("processCategory only analyzes sellable mid-range matches", async () => {
expect(summary.availableAsins).toBe(1); expect(summary.availableAsins).toBe(1);
expect(summary.results?.length).toBe(1); expect(summary.results?.length).toBe(1);
const productResults = db globalThis.fetch = originalFetch;
.query(
"SELECT asin, monthly_sold, can_sell, sellability_status FROM product_analysis_results ORDER BY monthly_sold DESC",
)
.all() as Array<{
asin: string;
monthly_sold: number;
can_sell: string;
sellability_status: string;
}>;
expect(productResults.length).toBe(1);
expect(productResults.map((row) => row.asin)).toEqual(["B000000001"]);
const sellable = productResults.find((row) => row.asin === "B000000001");
expect(sellable?.can_sell).toBe("yes");
expect(sellable?.sellability_status).toBe("available");
}); });
test("processCategory returns empty when no products match mid-range criteria", async () => { test("processCategory returns empty when no products match mid-range criteria", async () => {
@@ -380,7 +284,6 @@ test("processCategory returns empty when no products match mid-range criteria",
}; };
const runId = await insertCategoryRunSummary( const runId = await insertCategoryRunSummary(
db,
{ {
categoryId: mockCategory.id, categoryId: mockCategory.id,
categoryLabel: mockCategory.label, categoryLabel: mockCategory.label,
@@ -397,7 +300,6 @@ test("processCategory returns empty when no products match mid-range criteria",
); );
const summary = await processCategory( const summary = await processCategory(
db,
runId, runId,
mockCategory, mockCategory,
3, 3,
@@ -410,6 +312,7 @@ test("processCategory returns empty when no products match mid-range criteria",
20, 20,
15, 15,
85, 85,
"strict",
); );
expect(summary.status).toBe("empty"); expect(summary.status).toBe("empty");
@@ -417,8 +320,5 @@ test("processCategory returns empty when no products match mid-range criteria",
expect(summary.availableAsins).toBe(0); expect(summary.availableAsins).toBe(0);
expect(summary.results?.length).toBe(0); expect(summary.results?.length).toBe(0);
const rows = db globalThis.fetch = originalFetch;
.query("SELECT COUNT(*) as c FROM product_analysis_results")
.all() as Array<{ c: number }>;
expect(rows[0]?.c).toBe(0);
}); });

View File

@@ -2,16 +2,21 @@ import { existsSync, mkdirSync, readFileSync } from "node:fs";
import path from "node:path"; import path from "node:path";
import { createInterface } from "node:readline/promises"; import { createInterface } from "node:readline/promises";
import { stdin as input, stdout as output } from "node:process"; import { stdin as input, stdout as output } from "node:process";
import { type Database, getDb, initDb } from "./database.ts"; import { normalizeAsin } from "../asin.ts";
import { config } from "./config.ts"; import {
createCategoryRun,
persistLlmResults,
updateCategoryRun,
} from "../db/persistence.ts";
import { config } from "../config.ts";
import { import {
connectCache, connectCache,
disconnectCache, disconnectCache,
getApiCache, getApiCache,
setApiCache, setApiCache,
} from "./cache.ts"; } from "../integrations/cache.ts";
import { analyzeProducts } from "./llm.ts"; import { analyzeProducts } from "../integrations/llm.ts";
import { fetchSellabilityBatch, fetchSpApiPricingAndFees } from "./sp-api.ts"; import { fetchSellabilityBatch, fetchSpApiPricingAndFees } from "../integrations/sp-api.ts";
import type { import type {
AnalysisResult, AnalysisResult,
EnrichedProduct, EnrichedProduct,
@@ -20,7 +25,7 @@ import type {
ProductRecord, ProductRecord,
SellabilityInfo, SellabilityInfo,
SpApiData, SpApiData,
} from "./types.ts"; } from "../types.ts";
type CategoryInfo = { type CategoryInfo = {
id: number; id: number;
@@ -34,6 +39,7 @@ type ParsedArgs = {
selectCategories: boolean; selectCategories: boolean;
categoryIds: number[]; categoryIds: number[];
sellabilityGate: "strict" | "soft" | "off"; sellabilityGate: "strict" | "soft" | "off";
useClaude: boolean;
outputDir: string; outputDir: string;
categoryLimit: number; categoryLimit: number;
perCategoryTop: number; perCategoryTop: number;
@@ -118,6 +124,7 @@ function parseArgs(): ParsedArgs {
const args = process.argv.slice(2); const args = process.argv.slice(2);
const listCategories = hasFlag(args, "--list-categories"); const listCategories = hasFlag(args, "--list-categories");
const selectCategories = hasFlag(args, "--select-categories"); const selectCategories = hasFlag(args, "--select-categories");
const useClaude = hasFlag(args, "--claude");
const categoryIdsRaw = readFlagValue(args, "--category-ids"); const categoryIdsRaw = readFlagValue(args, "--category-ids");
const sellabilityGateRaw = readFlagValue(args, "--sellability-gate"); const sellabilityGateRaw = readFlagValue(args, "--sellability-gate");
const outputDir = const outputDir =
@@ -312,6 +319,7 @@ function parseArgs(): ParsedArgs {
selectCategories, selectCategories,
categoryIds, categoryIds,
sellabilityGate, sellabilityGate,
useClaude,
outputDir, outputDir,
categoryLimit, categoryLimit,
perCategoryTop, perCategoryTop,
@@ -370,7 +378,7 @@ function printUsageAndExit(message: string): never {
"error", "error",
[ [
"Usage:", "Usage:",
" bun run src/mid-range-sellers-by-category.ts [--category-limit 32] [--list-categories] [--select-categories] [--category-ids 281053,172282] [--sellability-gate soft] [--per-category-top 100] [--category-candidate-pool 500] [--candidate-batch-size 60] [--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] [--max-asins-analyzed 250] [--max-keepa-products-fetched 500] [--out-dir output] [--blacklist-file category-blacklist.csv]", " bun run src/mid-range-sellers-by-category.ts [--category-limit 32] [--list-categories] [--select-categories] [--category-ids 281053,172282] [--sellability-gate soft] [--per-category-top 100] [--category-candidate-pool 500] [--candidate-batch-size 60] [--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] [--max-asins-analyzed 250] [--max-keepa-products-fetched 500] [--out-dir output] [--blacklist-file category-blacklist.csv] [--claude]",
"", "",
"Selection:", "Selection:",
" --list-categories Discover and print runnable categories, then exit.", " --list-categories Discover and print runnable categories, then exit.",
@@ -471,36 +479,13 @@ async function promptCategoryIds(
} }
export async function insertCategoryRunSummary( export async function insertCategoryRunSummary(
db: Database,
summary: CategoryRunSummary, summary: CategoryRunSummary,
runTimestamp: string, runTimestamp: string,
): Promise<number> { ): Promise<number> {
const query = ` return createCategoryRun(summary, runTimestamp);
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,
@@ -513,136 +498,18 @@ export async function updateCategoryRunSummary(
| "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, amazon_is_seller, amazon_buybox_share_pct_90d,
monthly_sold, rank_drops_30d, rank_drops_90d,
fba_fee, fbm_fee, referral_percent, can_sell,
sellability_status, sellability_reason,
verdict, confidence, reasoning, fetched_at
) VALUES (
?, ?, ?, ?, ?, ?, ?, ?, ?, ?,
?, ?, ?, ?, ?, ?, ?, ?, ?, ?,
?, ?, ?, ?, ?, ?, ?, ?
)
ON CONFLICT(asin) DO UPDATE SET
run_id = excluded.run_id,
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,
amazon_is_seller = excluded.amazon_is_seller,
amazon_buybox_share_pct_90d = excluded.amazon_buybox_share_pct_90d,
monthly_sold = excluded.monthly_sold,
rank_drops_30d = excluded.rank_drops_30d,
rank_drops_90d = excluded.rank_drops_90d,
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?.amazonIsSeller == null
? null
: r.product.keepa.amazonIsSeller
? 1
: 0,
r.product.keepa?.amazonBuyboxSharePct90d ?? null,
r.product.keepa?.monthlySold ?? null,
r.product.keepa?.salesRankDrops30 ?? null,
r.product.keepa?.salesRankDrops90 ?? null,
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> {
@@ -978,7 +845,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);
@@ -1024,7 +891,11 @@ 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(
value
.map((v) => normalizeAsin(v))
.filter((asin): asin is string => asin !== null),
),
].slice(0, limit); ].slice(0, limit);
} }
} }
@@ -1283,7 +1154,7 @@ async function fetchKeepaEnrichmentMap(
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;
const parsed = { const parsed = {
keepa: parseKeepaProduct(product), keepa: parseKeepaProduct(product),
@@ -1468,7 +1339,6 @@ function shouldKeepCandidateBySellability(
} }
export async function processCategory( export async function processCategory(
db: Database,
runId: number, runId: number,
category: CategoryInfo, category: CategoryInfo,
perCategoryTop: number, perCategoryTop: number,
@@ -1482,6 +1352,7 @@ export async function processCategory(
minAmazonBuyboxSharePct: number, minAmazonBuyboxSharePct: number,
maxAmazonBuyboxSharePct: number, maxAmazonBuyboxSharePct: number,
sellabilityGate: "strict" | "soft" | "off", sellabilityGate: "strict" | "soft" | "off",
useClaude = false,
runtimeBudget?: RuntimeBudget, runtimeBudget?: RuntimeBudget,
candidateBatchSize = DEFAULT_CANDIDATE_BATCH_SIZE, candidateBatchSize = DEFAULT_CANDIDATE_BATCH_SIZE,
): Promise<CategoryRunSummary> { ): Promise<CategoryRunSummary> {
@@ -1501,7 +1372,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,
@@ -1739,7 +1610,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}`);
@@ -1762,7 +1633,7 @@ export async function processCategory(
}, },
})); }));
await insertProductAnalysisResults(db, runId, batchResults); await insertProductAnalysisResults(runId, batchResults);
for (const result of batchResults) { for (const result of batchResults) {
if (result.verdict.verdict === "FBA") { if (result.verdict.verdict === "FBA") {
@@ -1777,7 +1648,7 @@ export async function processCategory(
budget.analyzedAsins += batchResults.length; budget.analyzedAsins += batchResults.length;
results.push(...batchResults); results.push(...batchResults);
await updateCategoryRunSummary(db, runId, { await updateCategoryRunSummary(runId, {
topAsinsChecked: checkedAsins, topAsinsChecked: checkedAsins,
availableAsins: results.length, availableAsins: results.length,
fba, fba,
@@ -1798,7 +1669,7 @@ export async function processCategory(
const emptyReason = const emptyReason =
budget.stopReason || budget.stopReason ||
"No sellable ASINs matched the configured mid-range criteria"; "No sellable ASINs matched the configured mid-range criteria";
await updateCategoryRunSummary(db, runId, { await updateCategoryRunSummary(runId, {
topAsinsChecked: checkedAsins, topAsinsChecked: checkedAsins,
availableAsins: 0, availableAsins: 0,
fba, fba,
@@ -1826,7 +1697,7 @@ export async function processCategory(
` Category stream totals: checked=${checkedAsins}, sellable=${sellableAsins}, keepaEnriched=${keepaEnrichedAsins}, analyzed=${results.length}`, ` Category stream totals: checked=${checkedAsins}, sellable=${sellableAsins}, keepaEnriched=${keepaEnrichedAsins}, analyzed=${results.length}`,
); );
await updateCategoryRunSummary(db, runId, { await updateCategoryRunSummary(runId, {
topAsinsChecked: checkedAsins, topAsinsChecked: checkedAsins,
availableAsins: results.length, availableAsins: results.length,
fba, fba,
@@ -1919,11 +1790,6 @@ export async function main(): Promise<void> {
await connectCache(); await connectCache();
try { try {
mkdirSync(args.outputDir, { recursive: true }); mkdirSync(args.outputDir, { recursive: true });
const DB_PATH =
process.env.RESULTS_DB_PATH ||
path.join(process.cwd(), "db", "results.db");
initDb(DB_PATH);
const db = getDb(DB_PATH);
log("info", "Starting per-category mid-range pipeline"); log("info", "Starting per-category mid-range pipeline");
log("info", `Marketplace: ${config.spApiMarketplaceId}`); log("info", `Marketplace: ${config.spApiMarketplaceId}`);
@@ -1983,7 +1849,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,
@@ -2000,7 +1865,6 @@ export async function main(): Promise<void> {
); );
categorySummary = await processCategory( categorySummary = await processCategory(
db,
runId, runId,
category, category,
args.perCategoryTop, args.perCategoryTop,
@@ -2014,6 +1878,7 @@ export async function main(): Promise<void> {
args.minAmazonBuyboxSharePct, args.minAmazonBuyboxSharePct,
args.maxAmazonBuyboxSharePct, args.maxAmazonBuyboxSharePct,
args.sellabilityGate, args.sellabilityGate,
args.useClaude,
runtimeBudget, runtimeBudget,
args.candidateBatchSize, args.candidateBatchSize,
); );
@@ -2041,7 +1906,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,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<string, any>( return new Map<string, any>(
@@ -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 = {
@@ -76,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 =
@@ -131,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;
@@ -149,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.",
@@ -164,36 +176,13 @@ function printUsageAndExit(message: string): never {
} }
export async function insertCategoryRunSummary( export async function insertCategoryRunSummary(
db: Database,
summary: CategoryRunSummary, summary: CategoryRunSummary,
runTimestamp: string, runTimestamp: string,
): Promise<number> { ): Promise<number> {
const query = ` return createCategoryRun(summary, runTimestamp);
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,
@@ -206,136 +195,18 @@ export async function updateCategoryRunSummary(
| "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, amazon_is_seller, amazon_buybox_share_pct_90d,
monthly_sold, rank_drops_30d, rank_drops_90d,
fba_fee, fbm_fee, referral_percent, can_sell,
sellability_status, sellability_reason,
verdict, confidence, reasoning, fetched_at
) VALUES (
?, ?, ?, ?, ?, ?, ?, ?, ?, ?,
?, ?, ?, ?, ?, ?, ?, ?, ?, ?,
?, ?, ?, ?, ?, ?, ?, ?
)
ON CONFLICT(asin) DO UPDATE SET
run_id = excluded.run_id,
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,
amazon_is_seller = excluded.amazon_is_seller,
amazon_buybox_share_pct_90d = excluded.amazon_buybox_share_pct_90d,
monthly_sold = excluded.monthly_sold,
rank_drops_30d = excluded.rank_drops_30d,
rank_drops_90d = excluded.rank_drops_90d,
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?.amazonIsSeller == null
? null
: r.product.keepa.amazonIsSeller
? 1
: 0,
r.product.keepa?.amazonBuyboxSharePct90d ?? null,
r.product.keepa?.monthlySold ?? null,
r.product.keepa?.salesRankDrops30 ?? null,
r.product.keepa?.salesRankDrops90 ?? null,
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> {
@@ -671,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);
@@ -717,7 +588,11 @@ 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(
value
.map((v) => normalizeAsin(v))
.filter((asin): asin is string => asin !== null),
),
].slice(0, limit); ].slice(0, limit);
} }
} }
@@ -972,7 +847,7 @@ async function fetchKeepaEnrichmentMap(
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),
@@ -1060,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})`);
@@ -1075,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,
@@ -1119,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,
@@ -1156,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,
@@ -1200,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}`);
@@ -1223,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);
@@ -1236,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,
@@ -1256,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,
@@ -1285,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(), "db", "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}`);
@@ -1325,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,
@@ -1342,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;
@@ -1373,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,21 +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 =
process.env.RESULTS_DB_PATH || path.join(process.cwd(), "db", "results.db");
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,6 +20,8 @@ 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/"), searxngUrl: optional("SEARXNG_URL", "https://searxng.nvictor.me/"),
searxngTimeoutMs: parseInt(optional("SEARXNG_TIMEOUT_MS", "10000"), 10), searxngTimeoutMs: parseInt(optional("SEARXNG_TIMEOUT_MS", "10000"), 10),

View File

@@ -1,494 +0,0 @@
import { Database } from "bun:sqlite";
import { dirname } from "node:path";
import { mkdirSync } from "node:fs";
export { Database } from "bun:sqlite";
let db: Database | null = null;
export function getDb(dbPath: string): Database {
if (!db) {
const dbDir = dirname(dbPath);
if (dbDir && dbDir !== ".") {
mkdirSync(dbDir, { recursive: true });
}
db = new Database(dbPath);
db.run("PRAGMA journal_mode = WAL;"); // Enable WAL mode for better performance
db.run("PRAGMA foreign_keys = ON;"); // Enforce foreign key constraints
}
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,
amazon_is_seller INTEGER,
amazon_buybox_share_pct_90d REAL,
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, NULL AS amazon_is_seller,
NULL AS amazon_buybox_share_pct_90d,
monthly_sold, rank_drops_30d, rank_drops_90d,
fba_fee, fbm_fee, referral_percent, can_sell,
sellability_status, sellability_reason,
verdict, confidence, reasoning, fetched_at,
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, amazon_is_seller, amazon_buybox_share_pct_90d,
monthly_sold, rank_drops_30d, rank_drops_90d,
fba_fee, fbm_fee, referral_percent, can_sell,
sellability_status, sellability_reason,
verdict, confidence, reasoning, fetched_at
)
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, amazon_is_seller, amazon_buybox_share_pct_90d,
monthly_sold, rank_drops_30d, rank_drops_90d,
fba_fee, fbm_fee, referral_percent, can_sell,
sellability_status, sellability_reason,
verdict, confidence, reasoning, fetched_at
FROM ranked
WHERE row_num = 1
`);
database.run("DROP TABLE product_analysis_results_legacy");
}
}
function ensureProductAnalysisResultsColumns(database: Database): void {
const tableInfo = database
.query("PRAGMA table_info(product_analysis_results)")
.all() as Array<{ name: string }>;
if (tableInfo.length === 0) {
return;
}
const existingColumns = new Set(tableInfo.map((col) => col.name));
const requiredColumns: Array<{ name: string; type: string }> = [
{ name: "amazon_is_seller", type: "INTEGER" },
{ name: "amazon_buybox_share_pct_90d", type: "REAL" },
];
for (const column of requiredColumns) {
if (!existingColumns.has(column.name)) {
database.run(
`ALTER TABLE product_analysis_results ADD COLUMN ${column.name} ${column.type}`,
);
}
}
}
function ensureResultsTableColumns(database: Database): void {
const tableInfo = database
.query("PRAGMA table_info(results)")
.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" },
{ name: "amazon_is_seller", type: "INTEGER" },
{ name: "amazon_buybox_share_pct_90d", type: "REAL" },
{ name: "upc", type: "TEXT" },
{ name: "supplier_score", type: "REAL" },
{ name: "supplier_profit", type: "REAL" },
{ name: "supplier_margin", type: "REAL" },
{ name: "supplier_roi", type: "REAL" },
{ name: "supplier_reason", type: "TEXT" },
{ name: "upc_lookup_status", type: "TEXT" },
{ name: "upc_lookup_reason", type: "TEXT" },
{ name: "candidate_asins", type: "TEXT" },
];
for (const column of requiredColumns) {
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,
amazon_is_seller INTEGER,
amazon_buybox_share_pct_90d REAL,
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,
upc TEXT,
fba_fee REAL,
fbm_fee REAL,
referral_percent REAL,
supplier_score REAL,
supplier_profit REAL,
supplier_margin REAL,
supplier_roi REAL,
supplier_reason TEXT,
upc_lookup_status TEXT,
upc_lookup_reason TEXT,
candidate_asins TEXT,
can_sell TEXT,
sellability_status TEXT,
sellability_reason TEXT,
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);
ensureProductAnalysisResultsColumns(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);`,
);
initStalkerDb(database);
}
export function initStalkerDb(database: Database): void {
resetLegacyStalkerSchema(database);
database.run(`
CREATE TABLE IF NOT EXISTS stalker_runs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
input_file TEXT NOT NULL,
started_at TEXT NOT NULL,
completed_at TEXT,
requested_asins INTEGER NOT NULL DEFAULT 0,
skipped_asins INTEGER NOT NULL DEFAULT 0,
scanned_asins INTEGER NOT NULL DEFAULT 0,
source_asins_with_matches INTEGER NOT NULL DEFAULT 0,
candidate_sellers INTEGER NOT NULL DEFAULT 0,
qualifying_sellers INTEGER NOT NULL DEFAULT 0,
matched_sellers INTEGER NOT NULL DEFAULT 0,
seller_metadata_requests INTEGER NOT NULL DEFAULT 0,
seller_storefront_requests INTEGER NOT NULL DEFAULT 0,
inventory_sellability_checked_asins INTEGER NOT NULL DEFAULT 0,
inventory_sellability_available_asins INTEGER NOT NULL DEFAULT 0,
inventory_sellability_excluded_asins INTEGER NOT NULL DEFAULT 0,
persisted_inventory_asins INTEGER NOT NULL DEFAULT 0,
status TEXT NOT NULL,
error_message TEXT
);
`);
database.run(`
CREATE TABLE IF NOT EXISTS stalker_asin_scans (
id INTEGER PRIMARY KEY AUTOINCREMENT,
run_id INTEGER NOT NULL,
source_asin TEXT NOT NULL,
title TEXT,
offer_count INTEGER NOT NULL DEFAULT 0,
candidate_seller_count INTEGER NOT NULL DEFAULT 0,
matched_seller_count INTEGER NOT NULL DEFAULT 0,
fetched_at TEXT NOT NULL,
raw_product_json TEXT,
UNIQUE(run_id, source_asin),
FOREIGN KEY (run_id) REFERENCES stalker_runs(id) ON DELETE CASCADE
);
`);
database.run(`
CREATE TABLE IF NOT EXISTS stalker_sellers (
seller_id TEXT PRIMARY KEY,
seller_name TEXT,
rating REAL,
rating_count INTEGER,
storefront_asin_total INTEGER,
persisted_inventory_sample_count INTEGER,
last_updated_at TEXT NOT NULL,
raw_seller_json TEXT
);
`);
database.run(`
CREATE TABLE IF NOT EXISTS stalker_asin_sellers (
id INTEGER PRIMARY KEY AUTOINCREMENT,
scan_id INTEGER NOT NULL,
seller_id TEXT NOT NULL,
offer_price REAL,
condition TEXT,
is_fba INTEGER,
stock INTEGER,
seller_rating REAL,
seller_rating_count INTEGER,
raw_offer_json TEXT,
UNIQUE(scan_id, seller_id),
FOREIGN KEY (scan_id) REFERENCES stalker_asin_scans(id) ON DELETE CASCADE,
FOREIGN KEY (seller_id) REFERENCES stalker_sellers(seller_id)
);
`);
database.run(`
CREATE TABLE IF NOT EXISTS stalker_seller_inventory (
id INTEGER PRIMARY KEY AUTOINCREMENT,
run_id INTEGER NOT NULL,
seller_id TEXT NOT NULL,
asin TEXT NOT NULL,
can_sell INTEGER,
sellability_status TEXT,
sellability_reason TEXT,
product_title TEXT,
brand TEXT,
category_tree TEXT,
current_price REAL,
avg_price_90d REAL,
sales_rank INTEGER,
monthly_sold INTEGER,
seller_count INTEGER,
amazon_is_seller INTEGER,
raw_product_json TEXT,
last_seen_at TEXT NOT NULL,
raw_inventory_json TEXT,
UNIQUE(run_id, seller_id, asin),
FOREIGN KEY (run_id) REFERENCES stalker_runs(id) ON DELETE CASCADE,
FOREIGN KEY (seller_id) REFERENCES stalker_sellers(seller_id)
);
`);
database.run(
`CREATE INDEX IF NOT EXISTS idx_stalker_runs_started_at ON stalker_runs(started_at DESC);`,
);
database.run(
`CREATE INDEX IF NOT EXISTS idx_stalker_scans_run_id ON stalker_asin_scans(run_id);`,
);
database.run(
`CREATE INDEX IF NOT EXISTS idx_stalker_scans_source_asin ON stalker_asin_scans(source_asin);`,
);
database.run(
`CREATE INDEX IF NOT EXISTS idx_stalker_asin_sellers_seller_id ON stalker_asin_sellers(seller_id);`,
);
database.run(
`CREATE INDEX IF NOT EXISTS idx_stalker_inventory_seller_id ON stalker_seller_inventory(seller_id);`,
);
database.run(
`CREATE INDEX IF NOT EXISTS idx_stalker_inventory_asin ON stalker_seller_inventory(asin);`,
);
database.run(
`CREATE INDEX IF NOT EXISTS idx_stalker_inventory_product_title ON stalker_seller_inventory(product_title);`,
);
}
function resetLegacyStalkerSchema(database: Database): void {
const runColumns = database
.query("PRAGMA table_info(stalker_runs)")
.all() as Array<{ name: string }>;
if (runColumns.length === 0) return;
const columnNames = new Set(runColumns.map((column) => column.name));
if (
columnNames.has("scanned_asins") &&
columnNames.has("inventory_sellability_checked_asins") &&
inventoryColumnsHaveSellability(database)
) {
return;
}
database.run("DROP TABLE IF EXISTS stalker_seller_inventory");
database.run("DROP TABLE IF EXISTS stalker_asin_sellers");
database.run("DROP TABLE IF EXISTS stalker_sellers");
database.run("DROP TABLE IF EXISTS stalker_asin_scans");
database.run("DROP TABLE IF EXISTS stalker_runs");
}
function inventoryColumnsHaveSellability(database: Database): boolean {
const inventoryColumns = database
.query("PRAGMA table_info(stalker_seller_inventory)")
.all() as Array<{ name: string }>;
const columnNames = new Set(inventoryColumns.map((column) => column.name));
return (
columnNames.has("sellability_status") &&
columnNames.has("product_title")
);
}

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,11 +1,10 @@
import { readProducts } from "./reader.ts"; import { readProducts } from "./reader.ts";
import { connectCache, disconnectCache } from "./cache.ts"; import { connectCache, disconnectCache } from "./integrations/cache.ts";
import { import {
printResults, printResults,
writeResultsToDb, writeResultsToDb,
writeResultsWorkbook, writeResultsWorkbook,
} from "./writer.ts"; } from "./writer.ts";
import { initDb, closeDb } from "./database.ts";
import { import {
chunkArray, chunkArray,
processProductChunk, processProductChunk,
@@ -14,8 +13,9 @@ import {
import path from "node:path"; import path from "node:path";
import type { AnalysisResult } from "./types.ts"; import type { AnalysisResult } from "./types.ts";
const DB_PATH = process.env.RESULTS_DB_PATH || path.join("db", "results.db");
const INPUT_BATCH_SIZE = 50; const INPUT_BATCH_SIZE = 50;
const INPUT_DIR = "input";
const OUTPUT_DIR = "output";
function parseSellabilityArg(args: string[]): SellabilityFilter { function parseSellabilityArg(args: string[]): SellabilityFilter {
const sellabilityArg = args.find((a) => a.startsWith("--sellability=")); const sellabilityArg = args.find((a) => a.startsWith("--sellability="));
@@ -42,10 +42,12 @@ function parseArgs(): {
inputFile: string; inputFile: string;
outputFile?: string; outputFile?: string;
sellability: SellabilityFilter; sellability: SellabilityFilter;
useClaude: boolean;
} { } {
const args = process.argv.slice(2); const args = process.argv.slice(2);
const outputFile = readFlagValue(args, "--out", "--output"); const outputFile = readFlagValue(args, "--out", "--output");
const inputFile = readInputFileArg( const useClaude = args.includes("--claude");
const inputFileArg = readInputFileArg(
args, args,
"--out", "--out",
"--output", "--output",
@@ -53,14 +55,19 @@ function parseArgs(): {
); );
const sellability = parseSellabilityArg(args); const sellability = parseSellabilityArg(args);
if (!inputFile) { if (!inputFileArg) {
console.error( console.error(
"Usage: bun run src/index.ts <input.csv|xlsx> [--out results.xlsx|--output results.xlsx] [--sellability available|all]", "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, sellability }; return {
inputFile: resolveInputPath(inputFileArg),
outputFile,
sellability,
useClaude,
};
} }
function readFlagValue(args: string[], ...flags: string[]): string | undefined { function readFlagValue(args: string[], ...flags: string[]): string | undefined {
@@ -101,24 +108,36 @@ function readInputFileArg(
return undefined; 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("output", `${parsedInput.name}_results.xlsx`); return path.join(OUTPUT_DIR, `${parsedInput.name}_results.xlsx`);
} }
async function main() { async function main() {
const { inputFile, outputFile, sellability } = parseArgs(); const { inputFile, outputFile, sellability, useClaude } = parseArgs();
console.log(`Sellability filter: ${sellability}`); 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);
@@ -144,16 +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, { sellability }); const chunkResults = await processProductChunk(chunk, {
sellability,
useClaude,
});
allResults.push(...chunkResults); allResults.push(...chunkResults);
} }
printResults(allResults); printResults(allResults);
writeResultsWorkbook(allResults, resolvedBaseOutputPath); writeResultsWorkbook(allResults, resolvedBaseOutputPath);
writeResultsToDb(allResults, DB_PATH, inputFile, resolvedBaseOutputPath); await writeResultsToDb(allResults, inputFile, resolvedBaseOutputPath);
} finally { } finally {
await disconnectCache(); await disconnectCache();
closeDb();
} }
} }

View File

@@ -1,6 +1,6 @@
import Redis from "ioredis"; import Redis from "ioredis";
import { config } from "./config.ts"; import { config } from "../config.ts";
import type { EnrichedProduct, KeepaData, SpApiData } 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;

View File

@@ -1,5 +1,5 @@
import { afterAll, beforeEach, expect, mock, test } from "bun:test"; import { afterAll, beforeEach, expect, mock, test } from "bun:test";
import { lookupKeepaUpcs, mapUpcsToAsins } from "./keepa.ts"; import { fetchKeepaDataBatch, lookupKeepaUpcs, mapUpcsToAsins } from "./keepa.ts";
const originalFetch = globalThis.fetch; const originalFetch = globalThis.fetch;
@@ -42,7 +42,7 @@ test("lookupKeepaUpcs returns found, not_found, and multiple_asins outcomes", as
JSON.stringify({ JSON.stringify({
products: [ products: [
{ {
asin: "B000FOUND01", asin: "B000FND001",
upcList: ["012345678901"], upcList: ["012345678901"],
stats: { stats: {
current: [null, null, null, 1234], current: [null, null, null, 1234],
@@ -51,7 +51,7 @@ test("lookupKeepaUpcs returns found, not_found, and multiple_asins outcomes", as
csv: [[5000000, 2999, 5000100]], csv: [[5000000, 2999, 5000100]],
}, },
{ {
asin: "B000MULTI01", asin: "B000MUL001",
upcList: ["098765432109"], upcList: ["098765432109"],
stats: { stats: {
current: [null, null, null, 2000], current: [null, null, null, 2000],
@@ -60,7 +60,7 @@ test("lookupKeepaUpcs returns found, not_found, and multiple_asins outcomes", as
csv: [[1, 1999]], csv: [[1, 1999]],
}, },
{ {
asin: "B000MULTI02", asin: "B000MUL002",
upcList: ["098765432109"], upcList: ["098765432109"],
stats: { stats: {
current: [null, null, null, 2100], current: [null, null, null, 2100],
@@ -83,14 +83,14 @@ test("lookupKeepaUpcs returns found, not_found, and multiple_asins outcomes", as
]); ]);
expect(details.get("012345678901")?.status).toBe("found"); expect(details.get("012345678901")?.status).toBe("found");
expect(details.get("012345678901")?.asin).toBe("B000FOUND01"); 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("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")?.status).toBe("multiple_asins");
expect(details.get("098765432109")?.candidateAsins).toEqual([ expect(details.get("098765432109")?.candidateAsins).toEqual([
"B000MULTI01", "B000MUL001",
"B000MULTI02", "B000MUL002",
]); ]);
expect(details.get("111111111111")?.status).toBe("not_found"); expect(details.get("111111111111")?.status).toBe("not_found");
@@ -100,7 +100,7 @@ test("lookupKeepaUpcs returns found, not_found, and multiple_asins outcomes", as
"098765432109", "098765432109",
"111111111111", "111111111111",
]); ]);
expect(simpleMap.get("012345678901")).toBe("B000FOUND01"); expect(simpleMap.get("012345678901")).toBe("B000FND001");
expect(simpleMap.has("098765432109")).toBe(false); expect(simpleMap.has("098765432109")).toBe(false);
expect(simpleMap.has("111111111111")).toBe(false); expect(simpleMap.has("111111111111")).toBe(false);
}); });
@@ -128,7 +128,7 @@ test("lookupKeepaUpcs keeps partial success when one chunk fails", async () => {
JSON.stringify({ JSON.stringify({
products: [ products: [
{ {
asin: "B000LAST001", asin: "B000LST001",
upcList: [secondChunkUpc], upcList: [secondChunkUpc],
stats: { stats: {
current: [null, null, null, 1000], current: [null, null, null, 1000],
@@ -148,11 +148,11 @@ test("lookupKeepaUpcs keeps partial success when one chunk fails", async () => {
expect(details.get(firstChunkFirstUpc)?.status).toBe("request_failed"); expect(details.get(firstChunkFirstUpc)?.status).toBe("request_failed");
expect(details.get(secondChunkUpc)?.status).toBe("found"); expect(details.get(secondChunkUpc)?.status).toBe("found");
expect(details.get(secondChunkUpc)?.asin).toBe("B000LAST001"); expect(details.get(secondChunkUpc)?.asin).toBe("B000LST001");
const simpleMap = await mapUpcsToAsins(upcs); const simpleMap = await mapUpcsToAsins(upcs);
expect(simpleMap.has(firstChunkFirstUpc)).toBe(false); expect(simpleMap.has(firstChunkFirstUpc)).toBe(false);
expect(simpleMap.get(secondChunkUpc)).toBe("B000LAST001"); expect(simpleMap.get(secondChunkUpc)).toBe("B000LST001");
}); });
test("lookupKeepaUpcs retries on 429 and succeeds after refill wait", async () => { test("lookupKeepaUpcs retries on 429 and succeeds after refill wait", async () => {
@@ -175,7 +175,7 @@ test("lookupKeepaUpcs retries on 429 and succeeds after refill wait", async () =
JSON.stringify({ JSON.stringify({
products: [ products: [
{ {
asin: "B000RETRY01", asin: "B000RTY001",
upcList: [targetUpc], upcList: [targetUpc],
stats: { stats: {
current: [null, null, null, 1111], current: [null, null, null, 1111],
@@ -197,7 +197,7 @@ test("lookupKeepaUpcs retries on 429 and succeeds after refill wait", async () =
expect(fetchMock.mock.calls.length).toBe(2); expect(fetchMock.mock.calls.length).toBe(2);
expect(details.get(targetUpc)?.status).toBe("found"); expect(details.get(targetUpc)?.status).toBe("found");
expect(details.get(targetUpc)?.asin).toBe("B000RETRY01"); expect(details.get(targetUpc)?.asin).toBe("B000RTY001");
}); });
test("lookupKeepaUpcs uses lightweight query params for code mapping", async () => { test("lookupKeepaUpcs uses lightweight query params for code mapping", async () => {
@@ -215,12 +215,13 @@ test("lookupKeepaUpcs uses lightweight query params for code mapping", async ()
expect(url.searchParams.has("stats")).toBe(false); expect(url.searchParams.has("stats")).toBe(false);
expect(url.searchParams.has("buybox")).toBe(false); expect(url.searchParams.has("buybox")).toBe(false);
expect(url.searchParams.has("days")).toBe(false); expect(url.searchParams.has("days")).toBe(false);
expect(url.searchParams.get("history")).toBe("0");
return new Response( return new Response(
JSON.stringify({ JSON.stringify({
products: [ products: [
{ {
asin: "B000LIGHT01", asin: "B000LGT001",
upcList: [targetUpc], upcList: [targetUpc],
categoryTree: [{ name: "Test Category" }], categoryTree: [{ name: "Test Category" }],
}, },
@@ -238,5 +239,51 @@ test("lookupKeepaUpcs uses lightweight query params for code mapping", async ()
expect(fetchMock.mock.calls.length).toBe(1); expect(fetchMock.mock.calls.length).toBe(1);
expect(details.get(targetUpc)?.status).toBe("found"); expect(details.get(targetUpc)?.status).toBe("found");
expect(details.get(targetUpc)?.asin).toBe("B000LIGHT01"); 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);
}); });

View File

@@ -1,5 +1,6 @@
import { config } from "./config.ts"; import { config } from "../config.ts";
import type { KeepaData, KeepaUpcLookupDetail } from "./types.ts"; import { normalizeAsin } from "../asin.ts";
import type { KeepaData, KeepaUpcLookupDetail } from "../types.ts";
const KEEPA_BASE = "https://api.keepa.com"; const KEEPA_BASE = "https://api.keepa.com";
const MAX_ASINS_PER_REQUEST = 100; const MAX_ASINS_PER_REQUEST = 100;
@@ -56,11 +57,13 @@ function buildProductUrl(
options?: { options?: {
includeStats?: boolean; includeStats?: boolean;
includeBuybox?: boolean; includeBuybox?: boolean;
includeHistory?: boolean;
days?: number; days?: number;
}, },
): string { ): string {
const includeStats = options?.includeStats ?? true; const includeStats = options?.includeStats ?? true;
const includeBuybox = options?.includeBuybox ?? true; const includeBuybox = options?.includeBuybox ?? true;
const includeHistory = options?.includeHistory ?? true;
const days = options?.days ?? 90; const days = options?.days ?? 90;
const params = new URLSearchParams({ const params = new URLSearchParams({
@@ -77,6 +80,10 @@ function buildProductUrl(
params.set("buybox", "1"); params.set("buybox", "1");
} }
if (!includeHistory) {
params.set("history", "0");
}
params.set(queryParam, values.join(",")); params.set(queryParam, values.join(","));
return `${KEEPA_BASE}/product?${params.toString()}`; return `${KEEPA_BASE}/product?${params.toString()}`;
} }
@@ -228,13 +235,21 @@ export async function fetchKeepaDataBatch(
asins: string[], asins: string[],
): Promise<Map<string, KeepaData>> { ): Promise<Map<string, KeepaData>> {
const results = new 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 // Split into chunks of MAX_ASINS_PER_REQUEST
for (let i = 0; i < asins.length; i += MAX_ASINS_PER_REQUEST) { for (let i = 0; i < canonicalAsins.length; i += MAX_ASINS_PER_REQUEST) {
const chunk = asins.slice(i, i + MAX_ASINS_PER_REQUEST); const chunk = canonicalAsins.slice(i, i + MAX_ASINS_PER_REQUEST);
const url = buildProductUrl("asin", chunk, { const url = buildProductUrl("asin", chunk, {
includeStats: true, includeStats: true,
includeBuybox: true, includeBuybox: false,
includeHistory: false,
days: 90, days: 90,
}); });
@@ -250,7 +265,7 @@ export async function fetchKeepaDataBatch(
if (data.products) { if (data.products) {
for (const product of data.products) { for (const product of data.products) {
const asin = product.asin; const asin = normalizeAsin(product.asin);
if (!asin) continue; if (!asin) continue;
results.set(asin, parseKeepaProduct(product)); results.set(asin, parseKeepaProduct(product));
} }
@@ -294,6 +309,7 @@ export async function lookupKeepaUpcs(
const url = buildProductUrl("code", chunk, { const url = buildProductUrl("code", chunk, {
includeStats: false, includeStats: false,
includeBuybox: false, includeBuybox: false,
includeHistory: false,
}); });
console.log( console.log(
@@ -309,7 +325,7 @@ export async function lookupKeepaUpcs(
const byUpc = new Map<string, Map<string, KeepaData>>(); const byUpc = new Map<string, Map<string, KeepaData>>();
for (const product of data.products ?? []) { for (const product of data.products ?? []) {
const asin = String(product.asin ?? "").trim(); const asin = normalizeAsin(product.asin);
if (!asin) continue; if (!asin) continue;
const keepaData = parseKeepaProduct(product); const keepaData = parseKeepaProduct(product);

View File

@@ -1,5 +1,5 @@
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_STRICT = `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.
@@ -56,6 +56,17 @@ Keep each reasoning under 100 characters to stay within output limits and mentio
type AnalyzeProductsOptions = { type AnalyzeProductsOptions = {
ignoreSellability?: boolean; 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 { function getSystemPrompt(options: AnalyzeProductsOptions): string {
@@ -72,8 +83,7 @@ export async function analyzeProducts(
try { try {
return await analyzeProductsInternal(products, options); 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...`,
); );
@@ -101,7 +111,17 @@ 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}`,
}));
} }
} }
@@ -113,7 +133,43 @@ async function analyzeProductsInternal(
summarizeForLlm(p, options.ignoreSellability === true), summarizeForLlm(p, options.ignoreSellability === true),
); );
const systemPrompt = getSystemPrompt(options); 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: {
@@ -132,15 +188,108 @@ 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); 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) { function summarizeForLlm(p: EnrichedProduct, ignoreSellability: boolean) {

View File

@@ -13,6 +13,7 @@ afterAll(() => {
test("normalizeAsin uppercases and validates ASINs", () => { test("normalizeAsin uppercases and validates ASINs", () => {
expect(normalizeAsin(" b07sn9bhvv ")).toBe("B07SN9BHVV"); expect(normalizeAsin(" b07sn9bhvv ")).toBe("B07SN9BHVV");
expect(normalizeAsin("0306406152")).toBe("0306406152");
expect(() => normalizeAsin("not-an-asin")).toThrow("Invalid ASIN"); expect(() => normalizeAsin("not-an-asin")).toThrow("Invalid ASIN");
}); });

View File

@@ -1,10 +1,11 @@
import { normalizeAsin as normalizeCanonicalAsin } from "../asin.ts";
const DEFAULT_SEARXNG_URL = "https://searxng.nvictor.me/"; const DEFAULT_SEARXNG_URL = "https://searxng.nvictor.me/";
const DEFAULT_GOOGLE_CUSTOM_SEARCH_URL = const DEFAULT_GOOGLE_CUSTOM_SEARCH_URL =
"https://www.googleapis.com/customsearch/v1"; "https://www.googleapis.com/customsearch/v1";
const DEFAULT_SERPAPI_URL = "https://serpapi.com/search.json"; const DEFAULT_SERPAPI_URL = "https://serpapi.com/search.json";
const DEFAULT_TIMEOUT_MS = 10_000; const DEFAULT_TIMEOUT_MS = 10_000;
const DEFAULT_MAX_RESULTS = 10; const DEFAULT_MAX_RESULTS = 10;
const ASIN_REGEX = /^B[0-9A-Z]{9}$/;
const ASIN_MATCH_REGEX = /\bB[0-9A-Z]{9}\b/gi; const ASIN_MATCH_REGEX = /\bB[0-9A-Z]{9}\b/gi;
const PRICE_LABELS = [ const PRICE_LABELS = [
"selling price", "selling price",
@@ -127,16 +128,15 @@ export async function searchProductOffers(
} }
export function normalizeAsin(value: string): string { export function normalizeAsin(value: string): string {
const asin = value.trim().toUpperCase(); const asin = normalizeCanonicalAsin(value);
if (!ASIN_REGEX.test(asin)) { if (!asin) {
throw new Error(`Invalid ASIN: ${value}`); throw new Error(`Invalid ASIN: ${value}`);
} }
return asin; return asin;
} }
function getAsinQuery(value: string): string | undefined { function getAsinQuery(value: string): string | undefined {
const normalized = value.trim().toUpperCase(); return normalizeCanonicalAsin(value) ?? undefined;
return ASIN_REGEX.test(normalized) ? normalized : undefined;
} }
async function fetchSearxngResults( async function fetchSearxngResults(

View File

@@ -20,6 +20,15 @@ test("parseCatalogUpcLookupResponse marks no match", () => {
expect(detail.asin).toBeNull(); 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", () => { test("parseCatalogUpcLookupResponse marks multiple ASINs", () => {
const detail = parseCatalogUpcLookupResponse("012345678901", { const detail = parseCatalogUpcLookupResponse("012345678901", {
payload: { payload: {

View File

@@ -1,11 +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 { config } from "../config.ts";
import type { import type {
KeepaUpcLookupStatus, KeepaUpcLookupStatus,
SpApiData, SpApiData,
SellabilityInfo, SellabilityInfo,
UpcLookupDetail, UpcLookupDetail,
} from "./types.ts"; } from "../types.ts";
type RegionCode = "na" | "eu" | "fe"; type RegionCode = "na" | "eu" | "fe";
@@ -222,8 +223,7 @@ function extractCatalogAsin(item: any): string | null {
item?.identifiers?.marketplaceASIN?.asin ?? item?.identifiers?.marketplaceASIN?.asin ??
item?.Identifiers?.MarketplaceASIN?.ASIN; item?.Identifiers?.MarketplaceASIN?.ASIN;
if (typeof raw !== "string") return null; if (typeof raw !== "string") return null;
const asin = raw.trim().toUpperCase(); return normalizeAsin(raw);
return asin ? asin : null;
} }
export function parseCatalogUpcLookupResponse( export function parseCatalogUpcLookupResponse(

View File

@@ -1,8 +1,7 @@
import * as XLSX from "xlsx"; import * as XLSX from "xlsx";
import { normalizeAsin } from "./asin.ts";
import type { ProductRecord } from "./types.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"],
@@ -133,11 +132,9 @@ function getKnownColumns(columns: ColumnMap): Set<string> {
} }
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)) {
console.warn(`Skipping invalid ASIN: "${asin}"`);
return undefined; 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

@@ -1,374 +0,0 @@
import { type Database, closeDb, getDb, initDb } from "./database.ts";
import { analyzeProducts } from "./llm.ts";
import { fetchSpApiPricingAndFees } from "./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 = {
dbPath: string;
stalkerRunId: number;
analysisRunId: number;
asins: string[];
};
type InventoryRow = {
asin: string;
product_title: string | null;
brand: string | null;
category_tree: string | null;
current_price: number | null;
avg_price_90d: number | null;
sales_rank: number | null;
monthly_sold: number | null;
seller_count: number | null;
amazon_is_seller: number | null;
can_sell: number | null;
sellability_status: SellabilityInfo["sellabilityStatus"] | null;
sellability_reason: 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 dbPath = readFlagValue(argv, "--db");
const stalkerRunId = Number(readFlagValue(argv, "--stalker-run-id"));
const analysisRunId = Number(readFlagValue(argv, "--analysis-run-id"));
const asins = (readFlagValue(argv, "--asins") ?? "")
.split(",")
.map((asin) => asin.trim().toUpperCase())
.filter(Boolean);
if (!dbPath) throw new Error("Missing --db");
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 { dbPath, stalkerRunId, analysisRunId, asins };
}
function wait(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function parseCategoryTree(value: string | null): string[] {
if (!value) return [];
try {
const parsed = JSON.parse(value);
return Array.isArray(parsed)
? parsed.filter((item): item is string => typeof item === "string")
: [];
} catch {
return [];
}
}
function toProductRecord(row: InventoryRow): ProductRecord {
const categoryTree = parseCategoryTree(row.category_tree);
return {
asin: row.asin,
name: row.product_title ?? row.asin,
brand: row.brand ?? undefined,
category: categoryTree.join(" > ") || undefined,
unitCost: 0,
amazonRank: row.sales_rank ?? undefined,
sellingPriceFromSheet: row.current_price ?? undefined,
avgPrice90FromSheet: row.avg_price_90d ?? undefined,
};
}
function toKeepaData(row: InventoryRow): KeepaData {
return {
currentPrice: row.current_price,
avgPrice90: row.avg_price_90d,
minPrice90: null,
maxPrice90: null,
salesRank: row.sales_rank,
salesRankAvg90: null,
salesRankDrops30: null,
salesRankDrops90: null,
sellerCount: row.seller_count,
amazonIsSeller:
row.amazon_is_seller == null ? null : row.amazon_is_seller === 1,
amazonBuyboxSharePct90d: null,
buyBoxSeller: null,
buyBoxPrice: null,
buyBoxAvg90: null,
monthlySold: row.monthly_sold,
categoryTree: parseCategoryTree(row.category_tree),
};
}
function toSellability(row: InventoryRow): SellabilityInfo {
return {
canSell: row.can_sell == null ? null : row.can_sell === 1,
sellabilityStatus: row.sellability_status ?? "unknown",
sellabilityReason: row.sellability_reason ?? undefined,
};
}
function loadInventoryRows(
database: Database,
stalkerRunId: number,
asins: string[],
): InventoryRow[] {
const placeholders = asins.map(() => "?").join(",");
return database
.query(
`SELECT
asin, product_title, brand, category_tree, current_price, avg_price_90d,
sales_rank, monthly_sold, seller_count, amazon_is_seller, can_sell,
sellability_status, sellability_reason
FROM stalker_seller_inventory
WHERE run_id = ?
AND can_sell = 1
AND sellability_status = 'available'
AND asin IN (${placeholders})
GROUP BY asin`,
)
.all(stalkerRunId, ...asins) as InventoryRow[];
}
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.current_price,
);
enriched.push({
record: toProductRecord(row),
keepa: toKeepaData(row),
spApi,
fetchedAt: new Date().toISOString(),
});
}
return enriched;
}
function insertProductAnalysisResults(
database: Database,
runId: number,
results: AnalysisResult[],
): void {
if (results.length === 0) return;
const insert = database.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, amazon_is_seller, amazon_buybox_share_pct_90d,
monthly_sold, rank_drops_30d, rank_drops_90d,
fba_fee, fbm_fee, referral_percent, can_sell,
sellability_status, sellability_reason,
verdict, confidence, reasoning, fetched_at
) VALUES (
?, ?, ?, ?, ?, ?, ?, ?, ?, ?,
?, ?, ?, ?, ?, ?, ?, ?, ?, ?,
?, ?, ?, ?, ?, ?, ?, ?
)
ON CONFLICT(asin) DO UPDATE SET
run_id = excluded.run_id,
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,
amazon_is_seller = excluded.amazon_is_seller,
amazon_buybox_share_pct_90d = excluded.amazon_buybox_share_pct_90d,
monthly_sold = excluded.monthly_sold,
rank_drops_30d = excluded.rank_drops_30d,
rank_drops_90d = excluded.rank_drops_90d,
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
`);
database.transaction((batch: AnalysisResult[]) => {
for (const result of batch) {
const keepa = result.product.keepa;
const record = result.product.record;
const spApi = result.product.spApi;
insert.run(
record.asin,
runId,
record.name,
record.brand ?? null,
record.category ?? keepa?.categoryTree.join(" > ") ?? null,
record.unitCost ?? null,
keepa?.currentPrice ?? spApi.estimatedSalePrice ?? null,
keepa?.avgPrice90 ?? null,
record.avgPrice90FromSheet ?? null,
record.sellingPriceFromSheet ?? null,
keepa?.salesRank ?? record.amazonRank ?? null,
keepa?.salesRankAvg90 ?? null,
keepa?.sellerCount ?? null,
keepa?.amazonIsSeller == null ? null : keepa.amazonIsSeller ? 1 : 0,
keepa?.amazonBuyboxSharePct90d ?? null,
keepa?.monthlySold ?? null,
keepa?.salesRankDrops30 ?? null,
keepa?.salesRankDrops90 ?? null,
spApi.fbaFee ?? null,
spApi.fbmFee ?? null,
spApi.referralFeePercent ?? null,
spApi.canSell == null ? "unknown" : spApi.canSell ? "yes" : "no",
spApi.sellabilityStatus ?? null,
spApi.sellabilityReason ?? null,
result.verdict.verdict,
result.verdict.confidence,
result.verdict.reasoning ?? null,
result.product.fetchedAt,
);
}
})(results);
}
function refreshAnalysisRun(database: Database, runId: number): void {
const stats = database
.query(
`SELECT
COUNT(*) AS total,
SUM(CASE WHEN verdict = 'FBA' THEN 1 ELSE 0 END) AS fba,
SUM(CASE WHEN verdict = 'FBM' THEN 1 ELSE 0 END) AS fbm,
SUM(CASE WHEN verdict = 'SKIP' THEN 1 ELSE 0 END) AS skip
FROM product_analysis_results
WHERE run_id = ?`,
)
.get(runId) as {
total: number;
fba: number | null;
fbm: number | null;
skip: number | null;
};
database
.prepare(
`UPDATE category_analysis_runs
SET top_asins_checked = ?,
available_asins = ?,
fba_count = ?,
fbm_count = ?,
skip_count = ?
WHERE id = ?`,
)
.run(
stats.total ?? 0,
stats.total ?? 0,
stats.fba ?? 0,
stats.fbm ?? 0,
stats.skip ?? 0,
runId,
);
}
async function analyzeInBatches(
products: EnrichedProduct[],
): 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);
} 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();
initDb(args.dbPath);
const database = getDb(args.dbPath);
try {
const rows = loadInventoryRows(database, 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);
insertProductAnalysisResults(database, args.analysisRunId, results);
refreshAnalysisRun(database, args.analysisRunId);
} finally {
closeDb();
}
}
if (import.meta.main) {
main().catch((error) => {
console.error(error instanceof Error ? error.message : String(error));
process.exit(1);
});
}

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

@@ -2,7 +2,67 @@ import { afterAll, beforeEach, expect, mock, test } from "bun:test";
import { mkdirSync, rmSync } from "node:fs"; import { mkdirSync, rmSync } from "node:fs";
import path from "node:path"; import path from "node:path";
import * as XLSX from "xlsx"; import * as XLSX from "xlsx";
import { closeDb, getDb } from "./database.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;
}
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 TEST_DIR = path.join(process.cwd(), "test_output", "stalker-sellability");
const originalFetch = globalThis.fetch; const originalFetch = globalThis.fetch;
@@ -27,14 +87,10 @@ const fetchSellabilityBatchMock = mock(async (asins: string[]) => {
); );
}); });
mock.module("./sp-api.ts", () => ({
fetchSellabilityBatch: fetchSellabilityBatchMock,
}));
const modulePromise = import("./stalker.ts"); const modulePromise = import("./stalker.ts");
beforeEach(() => { beforeEach(() => {
closeDb(); nextId = 0;
rmSync(TEST_DIR, { recursive: true, force: true }); rmSync(TEST_DIR, { recursive: true, force: true });
mkdirSync(TEST_DIR, { recursive: true }); mkdirSync(TEST_DIR, { recursive: true });
globalThis.fetch = originalFetch; globalThis.fetch = originalFetch;
@@ -49,14 +105,12 @@ afterAll(() => {
} else { } else {
Bun.env.KEEPA_API_KEY = originalKeepaKey; Bun.env.KEEPA_API_KEY = originalKeepaKey;
} }
closeDb();
rmSync(TEST_DIR, { recursive: true, force: true }); rmSync(TEST_DIR, { recursive: true, force: true });
}); });
test("sellability checks matched seller inventory, not the source ASIN", async () => { test("sellability checks matched seller inventory, not the source ASIN", async () => {
const { runStalker } = await modulePromise; const { runStalker } = await modulePromise;
const inputPath = path.join(TEST_DIR, "input.xlsx"); const inputPath = path.join(TEST_DIR, "input.xlsx");
const dbPath = path.join(TEST_DIR, "stalker.sqlite");
const workbook = XLSX.utils.book_new(); const workbook = XLSX.utils.book_new();
XLSX.utils.book_append_sheet( XLSX.utils.book_append_sheet(
workbook, workbook,
@@ -136,22 +190,24 @@ test("sellability checks matched seller inventory, not the source ASIN", async (
return new Response("not found", { status: 404 }); return new Response("not found", { status: 404 });
}) as unknown as typeof globalThis.fetch; }) as unknown as typeof globalThis.fetch;
const stats = await runStalker({ const stats = await runStalker(
input: inputPath, {
dbPath, input: inputPath,
maxAsins: null, maxAsins: null,
storefrontUpdateHours: 168, offerLimit: 20,
offerLimit: 20, sellerLimit: 30,
sellerLimit: 30, inventoryLimit: 200,
inventoryLimit: 200, sellerCacheHours: 168,
sellerCacheHours: 168, includeStock: false,
includeStock: false, dryRun: false,
dryRun: false, resume: true,
resume: true, maxSellerRequests: null,
maxSellerRequests: null, sellability: true,
sellability: true, analyzeSellable: false,
analyzeSellable: false, useClaude: false,
}); },
{ fetchSellabilityBatch: fetchSellabilityBatchMock },
);
expect(fetchSellabilityBatchMock.mock.calls.length).toBe(1); expect(fetchSellabilityBatchMock.mock.calls.length).toBe(1);
expect(fetchSellabilityBatchMock.mock.calls[0]?.[0]).toEqual([ expect(fetchSellabilityBatchMock.mock.calls[0]?.[0]).toEqual([
@@ -162,46 +218,4 @@ test("sellability checks matched seller inventory, not the source ASIN", async (
expect(stats.inventorySellabilityAvailableAsins).toBe(1); expect(stats.inventorySellabilityAvailableAsins).toBe(1);
expect(stats.inventorySellabilityExcludedAsins).toBe(1); expect(stats.inventorySellabilityExcludedAsins).toBe(1);
expect(stats.persistedInventoryAsins).toBe(1); expect(stats.persistedInventoryAsins).toBe(1);
const db = getDb(dbPath);
const scan = db.query("SELECT source_asin FROM stalker_asin_scans").get() as {
source_asin: string;
};
expect(scan.source_asin).toBe("B000000001");
const inventory = db
.query(
`SELECT asin, can_sell, sellability_status, product_title, brand,
category_tree, current_price, avg_price_90d, sales_rank, monthly_sold,
seller_count
FROM stalker_seller_inventory ORDER BY asin`,
)
.all() as Array<{
asin: string;
can_sell: number | null;
sellability_status: string | null;
product_title: string | null;
brand: string | null;
category_tree: string | null;
current_price: number | null;
avg_price_90d: number | null;
sales_rank: number | null;
monthly_sold: number | null;
seller_count: number | null;
}>;
expect(inventory).toEqual([
{
asin: "B111111111",
can_sell: 1,
sellability_status: "available",
product_title: "Sellable Storefront Product",
brand: "Good Brand",
category_tree: JSON.stringify(["Kitchen", "Storage"]),
current_price: 19.99,
avg_price_90d: 25,
sales_rank: 12345,
monthly_sold: 42,
seller_count: 7,
},
]);
}); });

View File

@@ -2,7 +2,6 @@ import { afterAll, beforeEach, expect, mock, test } from "bun:test";
import { mkdirSync, rmSync } from "node:fs"; import { mkdirSync, rmSync } from "node:fs";
import path from "node:path"; import path from "node:path";
import * as XLSX from "xlsx"; import * as XLSX from "xlsx";
import { closeDb, getDb, initDb } from "./database.ts";
import { import {
extractLiveOfferSellerCandidates, extractLiveOfferSellerCandidates,
isQualifyingSeller, isQualifyingSeller,
@@ -10,12 +9,74 @@ import {
runStalker, runStalker,
} from "./stalker.ts"; } 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 TEST_DIR = path.join(process.cwd(), "test_output", "stalker");
const originalFetch = globalThis.fetch; const originalFetch = globalThis.fetch;
const originalKeepaKey = Bun.env.KEEPA_API_KEY; const originalKeepaKey = Bun.env.KEEPA_API_KEY;
beforeEach(() => { beforeEach(() => {
closeDb(); nextId = 0;
rmSync(TEST_DIR, { recursive: true, force: true }); rmSync(TEST_DIR, { recursive: true, force: true });
mkdirSync(TEST_DIR, { recursive: true }); mkdirSync(TEST_DIR, { recursive: true });
globalThis.fetch = originalFetch; globalThis.fetch = originalFetch;
@@ -29,7 +90,6 @@ afterAll(() => {
} else { } else {
Bun.env.KEEPA_API_KEY = originalKeepaKey; Bun.env.KEEPA_API_KEY = originalKeepaKey;
} }
closeDb();
rmSync(TEST_DIR, { recursive: true, force: true }); rmSync(TEST_DIR, { recursive: true, force: true });
}); });
@@ -41,12 +101,17 @@ test("readAsinsFromXlsx extracts valid ASINs and deduplicates in order", () => {
{ ASIN: "invalid" }, { ASIN: "invalid" },
{ ASIN: "B000000002" }, { ASIN: "B000000002" },
{ ASIN: "B000000001" }, { ASIN: "B000000001" },
{ ASIN: "0306406152" },
{ ASIN: "" }, { ASIN: "" },
]); ]);
XLSX.utils.book_append_sheet(workbook, sheet, "Input"); XLSX.utils.book_append_sheet(workbook, sheet, "Input");
XLSX.writeFile(workbook, filePath); XLSX.writeFile(workbook, filePath);
expect(readAsinsFromXlsx(filePath)).toEqual(["B000000001", "B000000002"]); expect(readAsinsFromXlsx(filePath)).toEqual([
"B000000001",
"B000000002",
"0306406152",
]);
}); });
test("isQualifyingSeller accepts rating counts from 1 to 30 only", () => { test("isQualifyingSeller accepts rating counts from 1 to 30 only", () => {
@@ -77,35 +142,8 @@ test("extractLiveOfferSellerCandidates ignores Amazon, missing seller IDs, and d
expect(offers[0]?.stock).toBe(4); expect(offers[0]?.stock).toBe(4);
}); });
test("initDb creates stalker tables and indexes", () => { test("runStalker fetches product offers, filters sellers, and tracks stats", async () => {
const dbPath = path.join(TEST_DIR, "schema.sqlite");
initDb(dbPath);
const db = getDb(dbPath);
const tables = db
.query(
`SELECT name FROM sqlite_master WHERE type = 'table' AND name LIKE 'stalker_%' ORDER BY name`,
)
.all() as Array<{ name: string }>;
expect(tables.map((row) => row.name)).toEqual([
"stalker_asin_scans",
"stalker_asin_sellers",
"stalker_runs",
"stalker_seller_inventory",
"stalker_sellers",
]);
const indexes = db
.query(
`SELECT name FROM sqlite_master WHERE type = 'index' AND name LIKE 'idx_stalker_%' ORDER BY name`,
)
.all() as Array<{ name: string }>;
expect(indexes.length).toBeGreaterThanOrEqual(6);
});
test("runStalker fetches product offers, filters sellers, and persists storefront inventory", async () => {
const inputPath = path.join(TEST_DIR, "input.xlsx"); const inputPath = path.join(TEST_DIR, "input.xlsx");
const dbPath = path.join(TEST_DIR, "stalker.sqlite");
const workbook = XLSX.utils.book_new(); const workbook = XLSX.utils.book_new();
XLSX.utils.book_append_sheet( XLSX.utils.book_append_sheet(
workbook, workbook,
@@ -160,7 +198,7 @@ test("runStalker fetches product offers, filters sellers, and persists storefron
if (url.pathname === "/seller") { if (url.pathname === "/seller") {
const wantsStorefront = url.searchParams.get("storefront") === "1"; const wantsStorefront = url.searchParams.get("storefront") === "1";
if (wantsStorefront) { if (wantsStorefront) {
expect(url.searchParams.get("update")).toBe("168"); expect(url.searchParams.has("update")).toBeFalse();
} }
const sellerId = url.searchParams.get("seller"); const sellerId = url.searchParams.get("seller");
@@ -205,9 +243,7 @@ test("runStalker fetches product offers, filters sellers, and persists storefron
const stats = await runStalker({ const stats = await runStalker({
input: inputPath, input: inputPath,
dbPath,
maxAsins: null, maxAsins: null,
storefrontUpdateHours: 168,
offerLimit: 20, offerLimit: 20,
sellerLimit: 30, sellerLimit: 30,
inventoryLimit: 200, inventoryLimit: 200,
@@ -218,6 +254,7 @@ test("runStalker fetches product offers, filters sellers, and persists storefron
maxSellerRequests: null, maxSellerRequests: null,
sellability: false, sellability: false,
analyzeSellable: false, analyzeSellable: false,
useClaude: false,
}); });
expect(stats.scannedAsins).toBe(1); expect(stats.scannedAsins).toBe(1);
@@ -229,6 +266,7 @@ test("runStalker fetches product offers, filters sellers, and persists storefron
expect(stats.qualifyingSellers).toBe(1); expect(stats.qualifyingSellers).toBe(1);
expect(stats.sellerMetadataRequests).toBe(1); expect(stats.sellerMetadataRequests).toBe(1);
expect(stats.sellerStorefrontRequests).toBe(1); expect(stats.sellerStorefrontRequests).toBe(1);
const sellerCalls = fetchMock.mock.calls.filter((call) => { const sellerCalls = fetchMock.mock.calls.filter((call) => {
const rawUrl = const rawUrl =
typeof call[0] === "string" typeof call[0] === "string"
@@ -239,45 +277,4 @@ test("runStalker fetches product offers, filters sellers, and persists storefron
return new URL(rawUrl).pathname === "/seller"; return new URL(rawUrl).pathname === "/seller";
}); });
expect(sellerCalls.length).toBe(2); expect(sellerCalls.length).toBe(2);
const db = getDb(dbPath);
const run = db.query("SELECT * FROM stalker_runs").get() as any;
expect(run.status).toBe("completed");
expect(run.requested_asins).toBe(1);
expect(run.scanned_asins).toBe(1);
expect(run.source_asins_with_matches).toBe(1);
expect(run.candidate_sellers).toBe(2);
expect(run.qualifying_sellers).toBe(1);
expect(run.matched_sellers).toBe(1);
expect(run.seller_metadata_requests).toBe(1);
expect(run.seller_storefront_requests).toBe(1);
expect(run.inventory_sellability_checked_asins).toBe(0);
expect(run.inventory_sellability_available_asins).toBe(0);
expect(run.inventory_sellability_excluded_asins).toBe(0);
expect(run.persisted_inventory_asins).toBe(0);
const scan = db.query("SELECT * FROM stalker_asin_scans").get() as any;
expect(scan.source_asin).toBe("B000000001");
expect(scan.title).toBe("Tracked Product");
expect(scan.offer_count).toBe(2);
expect(scan.candidate_seller_count).toBe(2);
expect(scan.matched_seller_count).toBe(1);
const sellers = db.query("SELECT * FROM stalker_sellers").all() as any[];
expect(sellers.length).toBe(1);
expect(sellers[0].seller_id).toBe("AQUALIFIED");
expect(sellers[0].rating_count).toBe(12);
expect(sellers[0].storefront_asin_total).toBe(2);
expect(sellers[0].persisted_inventory_sample_count).toBe(0);
const asinSellers = db.query("SELECT * FROM stalker_asin_sellers").all() as any[];
expect(asinSellers.length).toBe(1);
expect(asinSellers[0].offer_price).toBe(19.99);
expect(asinSellers[0].is_fba).toBe(1);
expect(asinSellers[0].stock).toBe(3);
const inventory = db
.query("SELECT asin FROM stalker_seller_inventory ORDER BY asin")
.all() as Array<{ asin: string }>;
expect(inventory.map((row) => row.asin)).toEqual([]);
}); });

File diff suppressed because it is too large Load Diff

View File

@@ -3,7 +3,7 @@ import path from "node:path";
import { rmSync } from "node:fs"; import { rmSync } from "node:fs";
import ExcelJS from "exceljs"; import ExcelJS from "exceljs";
import { writeSupplierWorkbook } from "./supplier-export.ts"; import { writeSupplierWorkbook } from "./supplier-export.ts";
import type { SupplierAnalysisResult } from "./types.ts"; import type { SupplierAnalysisResult } from "../types.ts";
const OUTPUT_FILE = path.join("/private/tmp", "asin-check-supplier-export-test.xlsx"); const OUTPUT_FILE = path.join("/private/tmp", "asin-check-supplier-export-test.xlsx");
@@ -16,12 +16,12 @@ function result(overrides: Partial<SupplierAnalysisResult> = {}): SupplierAnalys
upc: "012345678901", upc: "012345678901",
rowNumber: 2, rowNumber: 2,
record: { record: {
asin: "B000000001",
name: "Test Product", name: "Test Product",
unitCost: 10, unitCost: 10,
brand: "Brand", brand: "Brand",
category: "Grocery", category: "Grocery",
}, },
product: { asin: "B000000001", name: "Test Product", unitCost: 10 },
lookup: { lookup: {
requestedUpc: "012345678901", requestedUpc: "012345678901",
normalizedUpc: "012345678901", normalizedUpc: "012345678901",
@@ -81,7 +81,8 @@ test("writeSupplierWorkbook writes ranked, skipped, and summary sheets", async (
result(), result(),
result({ result({
upc: "111111111111", upc: "111111111111",
record: { asin: "111111111111", name: "Missing", unitCost: 0 }, record: { name: "Missing", unitCost: 0 },
product: null,
lookup: { lookup: {
requestedUpc: "111111111111", requestedUpc: "111111111111",
normalizedUpc: "111111111111", normalizedUpc: "111111111111",

View File

@@ -5,7 +5,7 @@ import type {
KeepaUpcLookupStatus, KeepaUpcLookupStatus,
SupplierAnalysisResult, SupplierAnalysisResult,
SupplierVerdict, SupplierVerdict,
} from "./types.ts"; } from "../types.ts";
export type SupplierExportSummary = { export type SupplierExportSummary = {
processedRows: number; processedRows: number;
@@ -63,7 +63,8 @@ function addRowsSheet(
const sheet = workbook.addWorksheet(name); const sheet = workbook.addWorksheet(name);
const headers = rows[0] ? Object.keys(rows[0]) : Object.keys(rowForResult({ const headers = rows[0] ? Object.keys(rows[0]) : Object.keys(rowForResult({
upc: "", upc: "",
record: { asin: "", name: "", unitCost: 0 }, record: { name: "", unitCost: 0 },
product: null,
lookup: { lookup: {
requestedUpc: "", requestedUpc: "",
normalizedUpc: "", normalizedUpc: "",

View File

@@ -1,6 +1,6 @@
import { expect, test } from "bun:test"; import { expect, test } from "bun:test";
import { scoreSupplierProduct } from "./supplier-scoring.ts"; import { scoreSupplierProduct } from "./supplier-scoring.ts";
import type { KeepaData, ProductRecord, SpApiData } from "./types.ts"; import type { KeepaData, ProductRecord, SpApiData } from "../types.ts";
function record(overrides: Partial<ProductRecord> = {}): ProductRecord { function record(overrides: Partial<ProductRecord> = {}): ProductRecord {
return { return {

View File

@@ -3,7 +3,7 @@ import type {
ProductRecord, ProductRecord,
SpApiData, SpApiData,
SupplierScore, SupplierScore,
} from "./types.ts"; } from "../types.ts";
function round2(value: number): number { function round2(value: number): number {
return Math.round(value * 100) / 100; return Math.round(value * 100) / 100;

View File

@@ -1,22 +1,24 @@
import path from "node:path"; import path from "node:path";
import { fetchKeepaDataBatch, lookupKeepaUpcs } from "./keepa.ts"; import { requireAsin } from "../asin.ts";
import { fetchKeepaDataBatch, lookupKeepaUpcs } from "../integrations/keepa.ts";
import { import {
fetchSellabilityBatch, fetchSellabilityBatch,
fetchSpApiPricingAndFees, fetchSpApiPricingAndFees,
lookupSpApiUpcs, lookupSpApiUpcs,
} from "./sp-api.ts"; } from "../integrations/sp-api.ts";
import { import {
processUpcFileInBatches, processUpcFileInBatches,
type UpcInputRow, type UpcInputRow,
} from "./upc-file-reader.ts"; } from "./upc-file-reader.ts";
import { import {
appendSupplierResultsToRun, appendSupplierResultsToRun,
completeRunInDb,
failRunInDb,
refreshRunCountsInDb, refreshRunCountsInDb,
startRunInDb, startRunInDb,
type RunCounts, type RunCounts,
} from "./writer.ts"; } from "../writer.ts";
import { initDb, closeDb } from "./database.ts"; import { connectCache, disconnectCache } from "../integrations/cache.ts";
import { connectCache, disconnectCache } from "./cache.ts";
import { scoreSupplierProduct, resolveSupplierSalePrice } from "./supplier-scoring.ts"; import { scoreSupplierProduct, resolveSupplierSalePrice } from "./supplier-scoring.ts";
import { import {
writeSupplierWorkbook, writeSupplierWorkbook,
@@ -29,9 +31,8 @@ import type {
SupplierAnalysisResult, SupplierAnalysisResult,
SupplierScore, SupplierScore,
UpcLookupDetail, UpcLookupDetail,
} from "./types.ts"; } from "../types.ts";
const DB_PATH = process.env.RESULTS_DB_PATH || path.join("db", "results.db");
const DEFAULT_INPUT_BATCH_SIZE = 200; const DEFAULT_INPUT_BATCH_SIZE = 200;
const DEFAULT_UPC_LOOKUP_BATCH_SIZE = 100; const DEFAULT_UPC_LOOKUP_BATCH_SIZE = 100;
const DEFAULT_PRICING_CONCURRENCY = 5; const DEFAULT_PRICING_CONCURRENCY = 5;
@@ -48,7 +49,6 @@ export type UpcFileAnalysisOptions = {
export type UpcFileAnalysisSummary = { export type UpcFileAnalysisSummary = {
runId: number; runId: number;
dbPath: string;
inputFile: string; inputFile: string;
outputFile?: string; outputFile?: string;
processedRows: number; processedRows: number;
@@ -242,8 +242,8 @@ async function lookupUpcsWithChunking(
chunkDetails.set( chunkDetails.set(
upc, upc,
fallbackDetail && fallbackDetail.status !== "request_failed" fallbackDetail && fallbackDetail.status !== "request_failed"
? fallbackDetail ? { ...fallbackDetail, provider: "keepa" }
: spDetail!, : { ...spDetail!, provider: "sp_api" },
); );
} }
@@ -269,7 +269,7 @@ function toProductRecord(
const keepaCategory = detail.keepaData?.categoryTree?.[0]; const keepaCategory = detail.keepaData?.categoryTree?.[0];
return { return {
asin: detail.asin ?? row.upc, asin: requireAsin(detail.asin),
name: row.name ?? detail.asin ?? row.upc, name: row.name ?? detail.asin ?? row.upc,
unitCost: row.unitCost ?? 0, unitCost: row.unitCost ?? 0,
brand: row.brand, brand: row.brand,
@@ -277,6 +277,15 @@ function toProductRecord(
}; };
} }
function toSupplierInputRecord(row: UpcInputRow) {
return {
name: row.name ?? row.upc,
unitCost: row.unitCost ?? 0,
brand: row.brand,
category: row.category,
};
}
async function fetchFeesForProducts( async function fetchFeesForProducts(
products: ProductRecord[], products: ProductRecord[],
keepaResults: Map<string, NonNullable<SupplierAnalysisResult["keepa"]>>, keepaResults: Map<string, NonNullable<SupplierAnalysisResult["keepa"]>>,
@@ -339,7 +348,6 @@ function summarizeSupplierResults(
export async function runUpcFileAnalysis( export async function runUpcFileAnalysis(
options: UpcFileAnalysisOptions, options: UpcFileAnalysisOptions,
): Promise<UpcFileAnalysisSummary> { ): Promise<UpcFileAnalysisSummary> {
const dbPath = options.dbPath ?? DB_PATH;
const inputBatchSize = Math.max( const inputBatchSize = Math.max(
1, 1,
options.inputBatchSize ?? DEFAULT_INPUT_BATCH_SIZE, options.inputBatchSize ?? DEFAULT_INPUT_BATCH_SIZE,
@@ -355,8 +363,6 @@ export async function runUpcFileAnalysis(
if (manageResources) { if (manageResources) {
console.log("Connecting to Redis..."); console.log("Connecting to Redis...");
await connectCache(); await connectCache();
console.log("Initializing SQLite database...");
initDb(dbPath);
} }
const unresolvedByStatus = createStatusCounter(); const unresolvedByStatus = createStatusCounter();
@@ -365,7 +371,7 @@ export async function runUpcFileAnalysis(
let processedRows = 0; let processedRows = 0;
let matchedRows = 0; let matchedRows = 0;
const runId = startRunInDb(dbPath, options.inputFile, outputFile); const runId = await startRunInDb(options.inputFile, outputFile, undefined, "supplier_upc");
try { try {
const readerSummary = await processUpcFileInBatches( const readerSummary = await processUpcFileInBatches(
@@ -388,12 +394,19 @@ export async function runUpcFileAnalysis(
product: ProductRecord; product: ProductRecord;
}> = []; }> = [];
for (const row of rows) { for (const row of rows) {
const detail = detailMap.get(row.upc); const detail =
if (!detail) { detailMap.get(row.upc) ??
unresolvedByStatus.request_failed += 1; ({
continue; 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; unresolvedByStatus[detail.status] += 1;
if (detail.status === "found" && detail.asin) { if (detail.status === "found" && detail.asin) {
@@ -413,30 +426,15 @@ export async function runUpcFileAnalysis(
const batchResults: SupplierAnalysisResult[] = []; const batchResults: SupplierAnalysisResult[] = [];
for (const row of rows) { for (const row of rows) {
const detail = detailMap.get(row.upc); const detail = detailMap.get(row.upc)!;
if (!detail || detail.status === "found") continue; if (detail.status === "found") continue;
batchResults.push({ batchResults.push({
upc: row.upc, upc: row.upc,
rowNumber: row.rowNumber, rowNumber: row.rowNumber,
record: { record: toSupplierInputRecord(row),
asin: detail?.asin ?? row.upc, product: null,
name: row.name ?? row.upc, lookup: detail,
unitCost: row.unitCost ?? 0,
brand: row.brand,
category: row.category,
},
lookup:
detail ??
({
requestedUpc: row.upc,
normalizedUpc: row.upc,
status: "request_failed",
asin: null,
candidateAsins: [],
keepaData: null,
reason: "UPC lookup returned no result",
} satisfies UpcLookupDetail),
keepa: null, keepa: null,
spApi: null, spApi: null,
score: skippedScore(detail?.reason ?? "UPC unresolved"), score: skippedScore(detail?.reason ?? "UPC unresolved"),
@@ -471,7 +469,8 @@ export async function runUpcFileAnalysis(
batchResults.push({ batchResults.push({
upc: entry.detail.normalizedUpc, upc: entry.detail.normalizedUpc,
rowNumber: entry.row.rowNumber, rowNumber: entry.row.rowNumber,
record: entry.product, record: toSupplierInputRecord(entry.row),
product: entry.product,
lookup: entry.detail, lookup: entry.detail,
keepa, keepa,
spApi, spApi,
@@ -481,7 +480,7 @@ export async function runUpcFileAnalysis(
} }
} }
appendSupplierResultsToRun(dbPath, runId, batchResults); await appendSupplierResultsToRun(runId, batchResults);
allResults.push(...batchResults); allResults.push(...batchResults);
}, },
{ {
@@ -490,10 +489,11 @@ export async function runUpcFileAnalysis(
}, },
); );
const runCounts = refreshRunCountsInDb(dbPath, runId); const runCounts = await refreshRunCountsInDb(runId);
const exportSummary = summarizeSupplierResults(allResults, unresolvedByStatus); const exportSummary = summarizeSupplierResults(allResults, unresolvedByStatus);
await writeSupplierWorkbook(outputFile, allResults, exportSummary); await writeSupplierWorkbook(outputFile, allResults, exportSummary);
await completeRunInDb(runId);
if (allResults.length > 0) { if (allResults.length > 0) {
const ranked = allResults const ranked = allResults
@@ -522,7 +522,6 @@ export async function runUpcFileAnalysis(
return { return {
runId, runId,
dbPath,
inputFile: options.inputFile, inputFile: options.inputFile,
outputFile, outputFile,
processedRows, processedRows,
@@ -537,10 +536,12 @@ export async function runUpcFileAnalysis(
skippedInvalidUpc: readerSummary.skippedInvalidUpc, skippedInvalidUpc: readerSummary.skippedInvalidUpc,
}, },
}; };
} catch (error) {
await failRunInDb(runId, error);
throw error;
} finally { } finally {
if (manageResources) { if (manageResources) {
await disconnectCache(); await disconnectCache();
closeDb();
} }
} }
} }

View File

@@ -1,4 +1,4 @@
import { lookupKeepaUpcs, mapUpcsToAsins } from "./keepa.ts"; import { lookupKeepaUpcs, mapUpcsToAsins } from "../integrations/keepa.ts";
function printUsage(): void { function printUsage(): void {
console.log("Usage:"); console.log("Usage:");

View File

@@ -59,6 +59,7 @@ export interface KeepaUpcLookupDetail {
asin: string | null; asin: string | null;
candidateAsins: string[]; candidateAsins: string[];
keepaData: KeepaData | null; keepaData: KeepaData | null;
provider?: "sp_api" | "keepa";
reason?: string; reason?: string;
} }
@@ -114,7 +115,8 @@ export interface SupplierScore {
export interface SupplierAnalysisResult { export interface SupplierAnalysisResult {
upc: string; upc: string;
rowNumber?: number; rowNumber?: number;
record: ProductRecord; record: SupplierInputRecord;
product: ProductRecord | null;
lookup: UpcLookupDetail; lookup: UpcLookupDetail;
keepa: KeepaData | null; keepa: KeepaData | null;
spApi: SpApiData | null; spApi: SpApiData | null;
@@ -122,6 +124,58 @@ export interface SupplierAnalysisResult {
fetchedAt: string; 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;
categoryLabel: string; categoryLabel: string;

File diff suppressed because it is too large Load Diff

View File

@@ -142,6 +142,93 @@ td {
min-width: 1320px; 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;

View File

@@ -1,4 +1,11 @@
import { getDb } from "./database.ts"; import { eq } from "drizzle-orm";
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 type { AnalysisResult, SupplierAnalysisResult } from "./types.ts";
import { mkdirSync } from "node:fs"; import { mkdirSync } from "node:fs";
import path from "node:path"; import path from "node:path";
@@ -11,18 +18,6 @@ export type RunCounts = {
skipCount: number; skipCount: number;
}; };
function computeRunCountsFromResults(results: AnalysisResult[]): RunCounts {
const fbaCount = results.filter((r) => r.verdict.verdict === "FBA").length;
const fbmCount = results.filter((r) => r.verdict.verdict === "FBM").length;
const skipCount = results.filter((r) => r.verdict.verdict === "SKIP").length;
return {
totalProducts: results.length,
fbaCount,
fbmCount,
skipCount,
};
}
function buildRow(r: AnalysisResult) { function buildRow(r: AnalysisResult) {
const price = const price =
r.product.keepa?.currentPrice ?? r.product.keepa?.currentPrice ??
@@ -84,16 +79,21 @@ 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,
): void { ): Promise<void> {
const runCounts = computeRunCountsFromResults(results); const runId = await startRunInDb(inputFile, outputFile);
const runId = startRunInDb(dbPath, inputFile, outputFile, runCounts); try {
appendResultsToRun(dbPath, runId, results); await appendResultsToRun(runId, results);
console.log(`Results written to SQLite database for run_id: ${runId}`); 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( export function writeResultsWorkbook(
@@ -112,8 +112,7 @@ export function writeResultsWorkbook(
console.log(`Results workbook written: ${outputFile}`); console.log(`Results workbook written: ${outputFile}`);
} }
export function startRunInDb( export async function startRunInDb(
dbPath: string,
inputFile: string, inputFile: string,
outputFile: string | undefined, outputFile: string | undefined,
counts: RunCounts = { counts: RunCounts = {
@@ -122,244 +121,73 @@ export function startRunInDb(
fbmCount: 0, fbmCount: 0,
skipCount: 0, skipCount: 0,
}, },
): number { type: "lead_analysis" | "supplier_upc" = "lead_analysis",
const database = getDb(dbPath); ): Promise<number> {
const timestamp = new Date().toISOString(); const [row] = await db
.insert(runs)
.values({
type,
inputFile,
outputFile: outputFile ?? null,
status: "running",
startedAt: new Date(),
})
.returning({ id: runs.id });
const insertRun = database.prepare( if (!row) throw new Error("Failed to insert run record.");
`INSERT INTO runs ( await db.insert(analysisRunStats).values({
timestamp, runId: row.id,
input_file, processedCount: counts.totalProducts,
output_file, analyzedCount: counts.totalProducts,
total_products, fbaCount: counts.fbaCount,
fba_count, fbmCount: counts.fbmCount,
fbm_count, skipCount: counts.skipCount,
skip_count });
) VALUES (?, ?, ?, ?, ?, ?, ?)`, return row.id;
);
const runInfo = insertRun.run(
timestamp,
inputFile,
outputFile ?? null,
counts.totalProducts,
counts.fbaCount,
counts.fbmCount,
counts.skipCount,
);
const runId =
(runInfo.changes as number) > 0
? (runInfo.lastInsertRowid as number)
: null;
if (runId === null) {
throw new Error("Failed to insert run record into SQLite.");
}
return runId;
} }
export function appendResultsToRun( export async function appendResultsToRun(
dbPath: string,
runId: number, runId: number,
results: AnalysisResult[], results: AnalysisResult[],
): void { ): Promise<void> {
if (results.length === 0) { if (results.length === 0) return;
return; await persistLlmResults(runId, results, {
} source: "lead_analysis",
metadataSource: "input",
const database = getDb(dbPath); preserveSourcingInput: true,
const insertResult = database.prepare( });
`INSERT INTO results (
run_id, asin, product_name, brand, category, unit_cost, current_price,
avg_price_90d, avg_price_90d_sheet, selling_price_sheet, sales_rank, rank_avg_90d,
sellers, amazon_is_seller, amazon_buybox_share_pct_90d,
monthly_sold, rank_drops_30d, rank_drops_90d, fba_net_sheet,
gross_profit_dollar, gross_profit_pct, net_profit_sheet, roi_sheet, moq, moq_cost,
qty_available, supplier, source_url, asin_link, promo_coupon_code, notes, lead_date,
fba_fee, fbm_fee, referral_percent, can_sell, sellability_status, sellability_reason,
verdict, confidence, reasoning, fetched_at
) VALUES (
?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?,
?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?
)`,
);
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["Amazon Is Seller"] == null
? null
: row["Amazon Is Seller"]
? 1
: 0,
row["Amazon Buy Box Share 90d %"] ?? null,
row["Monthly Sold"] ?? null,
row["Rank Drops 30d"] ?? null,
row["Rank Drops 90d"] ?? null,
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,
);
}
})();
} }
export function appendSupplierResultsToRun( export async function appendSupplierResultsToRun(
dbPath: string,
runId: number, runId: number,
results: SupplierAnalysisResult[], results: SupplierAnalysisResult[],
): void { ): Promise<void> {
if (results.length === 0) { if (results.length === 0) return;
return; await persistSupplierResults(runId, results);
}
const database = getDb(dbPath);
const insertResult = database.prepare(
`INSERT INTO results (
run_id, asin, product_name, brand, category, unit_cost, current_price,
avg_price_90d, sales_rank, rank_avg_90d, sellers,
amazon_is_seller, amazon_buybox_share_pct_90d, monthly_sold,
rank_drops_30d, rank_drops_90d, upc, fba_fee, fbm_fee,
referral_percent, supplier_score, supplier_profit, supplier_margin,
supplier_roi, supplier_reason, upc_lookup_status, upc_lookup_reason,
candidate_asins, can_sell, sellability_status, sellability_reason,
verdict, confidence, reasoning, fetched_at
) VALUES (
?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?,
?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?
)`,
);
database.transaction(() => {
for (const result of results) {
const keepa = result.keepa;
const spApi = result.spApi;
const asin = result.lookup.asin ?? result.record.asin ?? result.upc;
const category =
result.record.category ?? keepa?.categoryTree?.join(" > ") ?? null;
const canSell =
spApi?.canSell == null ? null : spApi.canSell ? "yes" : "no";
insertResult.run(
runId,
asin,
result.record.name,
result.record.brand ?? null,
category,
result.record.unitCost || null,
result.score.salePrice,
keepa?.avgPrice90 ?? null,
keepa?.salesRank ?? null,
keepa?.salesRankAvg90 ?? null,
keepa?.sellerCount ?? null,
keepa?.amazonIsSeller == null ? null : keepa.amazonIsSeller ? 1 : 0,
keepa?.amazonBuyboxSharePct90d ?? null,
keepa?.monthlySold ?? null,
keepa?.salesRankDrops30 ?? null,
keepa?.salesRankDrops90 ?? null,
result.upc,
result.score.fbaFee,
spApi?.fbmFee ?? null,
spApi?.referralFeePercent ?? null,
result.score.score,
result.score.profit,
result.score.margin,
result.score.roi,
result.score.reason,
result.lookup.status,
result.lookup.reason ?? null,
result.lookup.candidateAsins.join(","),
canSell,
spApi?.sellabilityStatus ?? null,
spApi?.sellabilityReason ?? null,
result.score.verdict,
Math.round(result.score.score),
result.score.reason,
result.fetchedAt,
);
}
})();
} }
export function refreshRunCountsInDb(dbPath: string, runId: number): RunCounts { export async function refreshRunCountsInDb(runId: number): Promise<RunCounts> {
const database = getDb(dbPath); return refreshRunStats(runId);
const stats = database
.query(
`SELECT
COUNT(*) AS total,
SUM(CASE WHEN verdict = 'FBA' THEN 1 ELSE 0 END) AS fba,
SUM(CASE WHEN verdict = 'FBM' THEN 1 ELSE 0 END) AS fbm,
SUM(CASE WHEN verdict = 'SKIP' THEN 1 ELSE 0 END) AS skip
FROM results
WHERE run_id = ?`,
)
.get(runId) as {
total: number;
fba: number | null;
fbm: number | null;
skip: number | null;
};
const counts: RunCounts = {
totalProducts: stats.total ?? 0,
fbaCount: stats.fba ?? 0,
fbmCount: stats.fbm ?? 0,
skipCount: stats.skip ?? 0,
};
database
.query(
`UPDATE runs
SET total_products = ?, fba_count = ?, fbm_count = ?, skip_count = ?
WHERE id = ?`,
)
.run(
counts.totalProducts,
counts.fbaCount,
counts.fbmCount,
counts.skipCount,
runId,
);
return counts;
} }
export 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")