Compare commits
40 Commits
4eff4a4a2a
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a355359427 | ||
|
|
31cf992e77 | ||
|
|
506e2344b7 | ||
|
|
313677692b | ||
|
|
9b45546476 | ||
|
|
35087a5b2f | ||
|
|
5dbff33032 | ||
|
|
517833413e | ||
|
|
b8280ef1a0 | ||
|
|
685cb3b2ed | ||
|
|
55e3aef1e4 | ||
|
|
f512f1d3d5 | ||
|
|
923ebbaec5 | ||
|
|
c006d87c54 | ||
|
|
b982edd160 | ||
|
|
70e0e8a535 | ||
|
|
0e03366534 | ||
|
|
95cebaa27c | ||
|
|
0f256be2be | ||
|
|
5226eee760 | ||
|
|
1d2e92addb | ||
|
|
f8bc05685e | ||
|
|
0c2e59771c | ||
|
|
90bfee8791 | ||
|
|
1f57900da2 | ||
|
|
7bda3710ed | ||
|
|
0552d183b3 | ||
|
|
f6178a665c | ||
|
|
aed0c11017 | ||
|
|
a7c0e44e3d | ||
|
|
0f9b785cce | ||
|
|
f3e4d3ac52 | ||
|
|
41ef57a7bc | ||
|
|
f2c8a9728d | ||
|
|
9b832b7839 | ||
|
|
072a501102 | ||
|
|
32e7b0c485 | ||
|
|
d25cf5d5ec | ||
|
|
b52cdc7f2b | ||
|
|
8d6b0f9e0f |
@@ -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
7
.claude/settings.json
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"permissions": {
|
||||||
|
"allow": [
|
||||||
|
"Bash(grep -v \"^$\")"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,4 +12,12 @@ AWS_SECRET_ACCESS_KEY=your_aws_secret_access_key
|
|||||||
REDIS_URL=redis://localhost:6379
|
REDIS_URL=redis://localhost:6379
|
||||||
LLM_URL=http://localhost:1234/v1
|
LLM_URL=http://localhost:1234/v1
|
||||||
LLM_MODEL=default
|
LLM_MODEL=default
|
||||||
|
ANTHROPIC_API_KEY=your_anthropic_api_key
|
||||||
|
ANTHROPIC_MODEL=claude-3-5-sonnet-20241022
|
||||||
CACHE_TTL=86400
|
CACHE_TTL=86400
|
||||||
|
GOOGLE_API_KEY=your_google_api_key
|
||||||
|
GOOGLE_CSE_ID=your_google_programmable_search_engine_id
|
||||||
|
SERPAPI_API_KEY=your_serpapi_api_key_for_google_shopping
|
||||||
|
|
||||||
|
# Matches the default PostgreSQL service in docker-compose.yaml.
|
||||||
|
DB_CONNECTION_STRING=postgres://asin_check:asin_check@localhost:5432/asin_check
|
||||||
|
|||||||
15
.gitignore
vendored
15
.gitignore
vendored
@@ -6,6 +6,11 @@ out
|
|||||||
dist
|
dist
|
||||||
*.tgz
|
*.tgz
|
||||||
|
|
||||||
|
# local data directories
|
||||||
|
input/*
|
||||||
|
output/*
|
||||||
|
db/*
|
||||||
|
|
||||||
# code coverage
|
# code coverage
|
||||||
coverage
|
coverage
|
||||||
*.lcov
|
*.lcov
|
||||||
@@ -32,16 +37,6 @@ report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
|
|||||||
|
|
||||||
# Finder (MacOS) folder config
|
# Finder (MacOS) folder config
|
||||||
.DS_Store
|
.DS_Store
|
||||||
*.xlsx
|
|
||||||
|
|
||||||
results.db
|
|
||||||
|
|
||||||
results.db-shm
|
|
||||||
|
|
||||||
results.db-wal
|
|
||||||
|
|
||||||
output/
|
|
||||||
|
|
||||||
temp_output/
|
temp_output/
|
||||||
|
|
||||||
dist-server/
|
dist-server/
|
||||||
|
|||||||
194
CLAUDE.md
194
CLAUDE.md
@@ -1,106 +1,118 @@
|
|||||||
|
# CLAUDE.md
|
||||||
|
|
||||||
|
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||||
|
|
||||||
Default to using Bun instead of Node.js.
|
Default to using Bun instead of Node.js.
|
||||||
|
|
||||||
- Use `bun <file>` instead of `node <file>` or `ts-node <file>`
|
- Use `bun <file>` instead of `node <file>` or `ts-node <file>`
|
||||||
- Use `bun test` instead of `jest` or `vitest`
|
- Use `bun test` instead of `jest` or `vitest`
|
||||||
- Use `bun build <file.html|file.ts|file.css>` instead of `webpack` or `esbuild`
|
- Use `bun install` instead of `npm install` or `yarn install`
|
||||||
- Use `bun install` instead of `npm install` or `yarn install` or `pnpm install`
|
- Use `bun run <script>` instead of `npm run <script>`
|
||||||
- Use `bun run <script>` instead of `npm run <script>` or `yarn run <script>` or `pnpm run <script>`
|
|
||||||
- Use `bunx <package> <command>` instead of `npx <package> <command>`
|
|
||||||
- Bun automatically loads .env, so don't use dotenv.
|
- Bun automatically loads .env, so don't use dotenv.
|
||||||
|
|
||||||
## APIs
|
## APIs
|
||||||
|
|
||||||
- `Bun.serve()` supports WebSockets, HTTPS, and routes. Don't use `express`.
|
|
||||||
- `bun:sqlite` for SQLite. Don't use `better-sqlite3`.
|
|
||||||
- `Bun.redis` for Redis. Don't use `ioredis`.
|
- `Bun.redis` for Redis. Don't use `ioredis`.
|
||||||
- `Bun.sql` for Postgres. Don't use `pg` or `postgres.js`.
|
- Use Drizzle ORM with `postgres` driver for Postgres. Connection is in `src/db/index.ts`.
|
||||||
- `WebSocket` is built-in. Don't use `ws`.
|
- Prefer `Bun.file` over `node:fs`'s readFile/writeFile.
|
||||||
- Prefer `Bun.file` over `node:fs`'s readFile/writeFile
|
- `Bun.$\`cmd\`` instead of execa.
|
||||||
- Bun.$`ls` instead of execa.
|
|
||||||
|
|
||||||
## Testing
|
## Commands
|
||||||
|
|
||||||
Use `bun test` to run tests.
|
|
||||||
|
|
||||||
```ts#index.test.ts
|
|
||||||
import { test, expect } from "bun:test";
|
|
||||||
|
|
||||||
test("hello world", () => {
|
|
||||||
expect(1).toBe(1);
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
## Frontend
|
|
||||||
|
|
||||||
Use HTML imports with `Bun.serve()`. Don't use `vite`. HTML imports fully support React, CSS, Tailwind.
|
|
||||||
|
|
||||||
Server:
|
|
||||||
|
|
||||||
```ts#index.ts
|
|
||||||
import index from "./index.html"
|
|
||||||
|
|
||||||
Bun.serve({
|
|
||||||
routes: {
|
|
||||||
"/": index,
|
|
||||||
"/api/users/:id": {
|
|
||||||
GET: (req) => {
|
|
||||||
return new Response(JSON.stringify({ id: req.params.id }));
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
// optional websocket support
|
|
||||||
websocket: {
|
|
||||||
open: (ws) => {
|
|
||||||
ws.send("Hello, world!");
|
|
||||||
},
|
|
||||||
message: (ws, message) => {
|
|
||||||
ws.send(message);
|
|
||||||
},
|
|
||||||
close: (ws) => {
|
|
||||||
// handle close
|
|
||||||
}
|
|
||||||
},
|
|
||||||
development: {
|
|
||||||
hmr: true,
|
|
||||||
console: true,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
```
|
|
||||||
|
|
||||||
HTML files can import .tsx, .jsx or .js files directly and Bun's bundler will transpile & bundle automatically. `<link>` tags can point to stylesheets and Bun's CSS bundler will bundle.
|
|
||||||
|
|
||||||
```html#index.html
|
|
||||||
<html>
|
|
||||||
<body>
|
|
||||||
<h1>Hello, world!</h1>
|
|
||||||
<script type="module" src="./frontend.tsx"></script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
```
|
|
||||||
|
|
||||||
With the following `frontend.tsx`:
|
|
||||||
|
|
||||||
```tsx#frontend.tsx
|
|
||||||
import React from "react";
|
|
||||||
import { createRoot } from "react-dom/client";
|
|
||||||
|
|
||||||
// import .css files directly and it works
|
|
||||||
import './index.css';
|
|
||||||
|
|
||||||
const root = createRoot(document.body);
|
|
||||||
|
|
||||||
export default function Frontend() {
|
|
||||||
return <h1>Hello, world!</h1>;
|
|
||||||
}
|
|
||||||
|
|
||||||
root.render(<Frontend />);
|
|
||||||
```
|
|
||||||
|
|
||||||
Then, run index.ts
|
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
bun --hot ./index.ts
|
# Run all tests
|
||||||
|
bun test
|
||||||
|
|
||||||
|
# Run a single test file
|
||||||
|
bun test src/supplier/supplier-scoring.test.ts
|
||||||
|
|
||||||
|
# Type-check (no emit)
|
||||||
|
./node_modules/.bin/tsc --noEmit
|
||||||
|
|
||||||
|
# ASIN lead-list pipeline (LLM-based)
|
||||||
|
bun start leads.xlsx --out results.xlsx
|
||||||
|
|
||||||
|
# Supplier UPC pipeline (deterministic)
|
||||||
|
bun run upc-file --input input/supplier.xlsx --out output/supplier_ranked.xlsx
|
||||||
|
|
||||||
|
# Category discovery pipelines
|
||||||
|
bun run bestsellers
|
||||||
|
bun run monthly-sold
|
||||||
|
bun run mid-range
|
||||||
|
|
||||||
|
# Stalker pipeline
|
||||||
|
bun run stalker --input input/asins.xlsx
|
||||||
|
|
||||||
|
# Web API server
|
||||||
|
bun run start:web # http://localhost:3000
|
||||||
|
|
||||||
|
# SP-API connectivity tests
|
||||||
|
bun run src/sp-test.ts
|
||||||
|
bun run src/sp-test.ts B07SN9BHVV
|
||||||
|
bun run src/sp-test.ts --sellability B07SN9BHVV
|
||||||
|
|
||||||
|
# Database migrations (Drizzle)
|
||||||
|
bun run db:generate
|
||||||
|
bun run db:migrate
|
||||||
```
|
```
|
||||||
|
|
||||||
For more information, read the Bun API docs in `node_modules/bun-types/docs/**.mdx`.
|
## Architecture
|
||||||
|
|
||||||
|
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`)
|
||||||
|
|
||||||
|
For spreadsheets containing known ASINs. Verdict is LLM-based (FBA/FBM/SKIP via LM Studio).
|
||||||
|
|
||||||
|
Flow: `reader.ts` parse → Redis cache check → `integrations/sp-api.ts` sellability gate (5 concurrent workers) → `integrations/keepa.ts` batch enrichment → `integrations/sp-api.ts` pricing + FBA fees (5 concurrent workers) → `integrations/llm.ts` batched analysis (5 products/batch) → `writer.ts` XLSX + Postgres.
|
||||||
|
|
||||||
|
### Supplier UPC Pipeline (`src/supplier/upc-file-analysis.ts`)
|
||||||
|
|
||||||
|
For supplier price lists containing UPC/EAN values. Verdict is deterministic (BUY/WATCH/SKIP); never calls LM Studio.
|
||||||
|
|
||||||
|
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).
|
||||||
|
|
||||||
|
### Category Pipelines
|
||||||
|
|
||||||
|
`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
|
||||||
|
|
||||||
|
| Module | Role |
|
||||||
|
|--------|------|
|
||||||
|
| `src/types.ts` | All shared interfaces (`ProductRecord`, `KeepaData`, `SpApiData`, `SupplierScore`, etc.) |
|
||||||
|
| `src/config.ts` | Env var loading via `Bun.env` |
|
||||||
|
| `src/db/index.ts` | Drizzle Postgres connection (shared pool) |
|
||||||
|
| `src/db/schema.ts` | Drizzle schema for all tables |
|
||||||
|
| `src/db/persistence.ts` | Product, observation, unified run-item, UPC resolution, and revision persistence |
|
||||||
|
| `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 |
|
||||||
|
|
||||||
|
### 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)
|
||||||
|
- `output/` — generated workbooks (git-ignored)
|
||||||
|
|
||||||
|
## Project Rules
|
||||||
|
|
||||||
|
- Keep the ASIN lead-list and category flows compatible with their current LLM-based FBA/FBM/SKIP analysis.
|
||||||
|
- The supplier UPC pipeline must not call LM Studio.
|
||||||
|
- Supplier UPC files resolve UPC/EAN through SP-API catalog lookup first; Keepa UPC lookup is fallback only (no-match or request-failure cases).
|
||||||
|
- Supplier workbook output must keep `Ranked Leads`, `Skipped`, and `Summary` sheets.
|
||||||
|
- Treat `products.asin` as the canonical normalized product identity; UPC values belong only in identifier and resolution records.
|
||||||
|
- Store time-varying data in observations or revisions and retain run history rather than overwriting prior analysis.
|
||||||
|
- When changing UPC supplier behavior, cover SP-API UPC parsing, deterministic scoring, and workbook export with `bun test`.
|
||||||
|
|||||||
189
README.md
189
README.md
@@ -21,21 +21,26 @@ cp .env.example .env
|
|||||||
## Usage
|
## Usage
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
bun run src/index.ts <input.csv|xlsx> [--out results.csv]
|
bun start <input.csv|xlsx> [--out results.xlsx]
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Add `--claude` to use Anthropic Claude instead of local LM Studio for LLM analysis.
|
||||||
|
Bare input and output filenames use the `input/` and `output/` directories. Pass a path containing a directory to override those defaults.
|
||||||
|
|
||||||
Examples:
|
Examples:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
bun run src/index.ts leads.xlsx
|
bun start leads.xlsx
|
||||||
bun run src/index.ts leads.csv --out results.xlsx
|
bun start leads.csv --out results.xlsx
|
||||||
|
bun start leads.xlsx --claude
|
||||||
|
bun start archive/leads.xlsx --out exports/results.xlsx
|
||||||
```
|
```
|
||||||
|
|
||||||
Large-file behavior:
|
Large-file behavior:
|
||||||
|
|
||||||
- If the input has more than 50 products, processing is done in chunks of 50.
|
- If the input has more than 50 products, processing is done in chunks of 50.
|
||||||
- Each chunk is analyzed and written to a numbered output file, for example: `results_part_001.xlsx`, `results_part_002.xlsx`, ...
|
- Each chunk is analyzed and written to a numbered output file under `output/`, for example: `output/results_part_001.xlsx`, `output/results_part_002.xlsx`, ...
|
||||||
- If `--out` is omitted for large files, the base output name defaults to `<input>_results.xlsx` and chunk files are still written with numbered suffixes.
|
- If `--out` is omitted for large files, the base output name defaults to `output/<input>_results.xlsx` and chunk files are still written with numbered suffixes.
|
||||||
|
|
||||||
Quick SP-API connectivity tests:
|
Quick SP-API connectivity tests:
|
||||||
|
|
||||||
@@ -45,6 +50,150 @@ bun run src/sp-test.ts B07SN9BHVV # Auth + sellers endpoint + pricing offer c
|
|||||||
bun run src/sp-test.ts --sellability B07SN9BHVV # Standalone sellability check
|
bun run src/sp-test.ts --sellability B07SN9BHVV # Standalone sellability check
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Category Pipelines
|
||||||
|
|
||||||
|
Run category-focused discovery flows with Keepa + SP-API + LLM:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bun run bestsellers
|
||||||
|
bun run monthly-sold
|
||||||
|
bun run mid-range
|
||||||
|
```
|
||||||
|
|
||||||
|
Use Claude for category LLM analysis:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bun run bestsellers --claude
|
||||||
|
bun run monthly-sold --claude
|
||||||
|
bun run mid-range --claude
|
||||||
|
```
|
||||||
|
|
||||||
|
Mid-range process:
|
||||||
|
|
||||||
|
- Script: `bun run mid-range`
|
||||||
|
- Source: `src/mid-range-sellers-by-category.ts`
|
||||||
|
- Default filters:
|
||||||
|
- Monthly sold between `100` and `1000`
|
||||||
|
- Price between `$15` and `$200` (using Keepa current price, fallback avg 90d)
|
||||||
|
- Seller count between `3` and `20`
|
||||||
|
- If Amazon is a seller, Amazon buy box share must be between `15%` and `85%`
|
||||||
|
- Sellability behavior:
|
||||||
|
- Sellability is still fetched and saved (`can_sell`, `sellability_status`, `sellability_reason`)
|
||||||
|
- Matching products are persisted regardless of sellability status
|
||||||
|
- Caching behavior:
|
||||||
|
- Uses Redis to cache Keepa + SP-API API enrichment per ASIN
|
||||||
|
- Cache TTL is fixed at `12 hours`
|
||||||
|
|
||||||
|
Example:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bun run mid-range --category-limit 10 --per-category-top 50 --category-candidate-pool 250 --min-monthly-sold 100 --max-monthly-sold 1000 --min-price 15 --max-price 200 --min-seller-count 3 --max-seller-count 20 --min-amazon-buybox-share-pct 15 --max-amazon-buybox-share-pct 85
|
||||||
|
```
|
||||||
|
|
||||||
|
## UPC to ASIN Mapping
|
||||||
|
|
||||||
|
You can map UPCs to ASINs directly through the Keepa integration in `src/keepa.ts`.
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { mapUpcsToAsins, lookupKeepaUpcs } from "./src/keepa.ts";
|
||||||
|
|
||||||
|
const upcs = ["012345678901", "098765432109", "112233445566"];
|
||||||
|
|
||||||
|
// Simple map output (UPC -> ASIN) for clean one-to-one matches only.
|
||||||
|
const asinMap = await mapUpcsToAsins(upcs);
|
||||||
|
for (const [upc, asin] of asinMap.entries()) {
|
||||||
|
console.log(`UPC ${upc} -> ASIN ${asin}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rich output includes status for every UPC (invalid, not found, collisions, etc.).
|
||||||
|
const details = await lookupKeepaUpcs(upcs);
|
||||||
|
for (const [upc, detail] of details.entries()) {
|
||||||
|
console.log(upc, detail.status, detail.asin, detail.reason ?? "");
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Behavior:
|
||||||
|
|
||||||
|
- Strict validation accepts only 12, 13, or 14 digit UPC values.
|
||||||
|
- If a UPC resolves to multiple ASINs, it is excluded from the simple map.
|
||||||
|
- The rich lookup returns all candidate ASINs and status per UPC.
|
||||||
|
|
||||||
|
CLI usage:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bun run upc 012345678901 098765432109
|
||||||
|
bun run upc 012345678901,098765432109 --detailed
|
||||||
|
bun run upc --file upcs.txt --detailed --json
|
||||||
|
```
|
||||||
|
|
||||||
|
API usage (when `bun run start:web` is running):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Simple one-to-one mapping (GET)
|
||||||
|
curl "http://localhost:3000/api/upc/map?upc=012345678901&upc=098765432109"
|
||||||
|
|
||||||
|
# Detailed lookup with statuses (GET)
|
||||||
|
curl "http://localhost:3000/api/upc/lookup?upcs=012345678901,098765432109"
|
||||||
|
|
||||||
|
# Detailed lookup (POST JSON)
|
||||||
|
curl -X POST "http://localhost:3000/api/upc/lookup" \
|
||||||
|
-H "content-type: application/json" \
|
||||||
|
-d '{"upcs":["012345678901","098765432109"]}'
|
||||||
|
```
|
||||||
|
|
||||||
|
Run the web server with Claude-backed LLM calls:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bun run start:web -- --claude
|
||||||
|
```
|
||||||
|
|
||||||
|
## Large UPC File Analysis (XLS/XLSX)
|
||||||
|
|
||||||
|
For supplier price lists that contain UPC/EAN values and unit cost, use the
|
||||||
|
dedicated UPC-file process. It runs in batches and produces a deterministic
|
||||||
|
ranked sourcing workbook:
|
||||||
|
|
||||||
|
1. Reads UPC rows in batches (`.xlsx` uses streaming reader, `.xls` uses fallback row-window parsing).
|
||||||
|
2. Resolves UPCs to ASINs with SP-API catalog lookup first, then falls back to Keepa for no-match/request-failure cases.
|
||||||
|
3. Enriches resolved ASINs with Keepa demand/competition data and SP-API sellability + FBA fees.
|
||||||
|
4. Scores products with deterministic BUY/WATCH/SKIP logic; this path does not call LM Studio.
|
||||||
|
5. Writes a ranked Excel workbook and persists rows through unified runs, UPC resolution, product observation, and scoring-history tables.
|
||||||
|
|
||||||
|
CLI usage:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bun run upc-file --input input/huge-upcs.xlsx
|
||||||
|
bun run upc-file --input input/supplier.xlsx --out output/supplier_ranked.xlsx
|
||||||
|
bun run upc-file --input input/huge-upcs.xls --input-batch-size 500 --upc-lookup-batch-size 100 --max-rows 10000
|
||||||
|
```
|
||||||
|
|
||||||
|
Workbook output includes `Ranked Leads`, `Skipped`, and `Summary` sheets with
|
||||||
|
UPC, ASIN, cost, sale price, FBA fee, profit, margin, ROI, BSR, rank drops,
|
||||||
|
monthly sold, seller count, Amazon Buy Box share, sellability, score, verdict,
|
||||||
|
and reason columns.
|
||||||
|
|
||||||
|
API usage (when `bun run start:web` is running):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X POST "http://localhost:3000/api/process/upc-file" \
|
||||||
|
-H "content-type: application/json" \
|
||||||
|
-d '{
|
||||||
|
"inputFile": "/absolute/path/to/input/huge-upcs.xlsx",
|
||||||
|
"inputBatchSize": 300,
|
||||||
|
"upcLookupBatchSize": 100
|
||||||
|
}'
|
||||||
|
```
|
||||||
|
|
||||||
|
Request body fields:
|
||||||
|
|
||||||
|
- `inputFile` (required): server-local path to `.xls` or `.xlsx` file.
|
||||||
|
- `outputFile` (optional): stored in run metadata.
|
||||||
|
- `inputBatchSize` (optional): number of input rows per processing batch (default `200`).
|
||||||
|
- `upcLookupBatchSize` (optional): UPC chunk size per Keepa lookup call (default `100`).
|
||||||
|
- `maxRows` (optional): cap processed valid UPC rows for dry runs.
|
||||||
|
|
||||||
|
Response includes run metadata and status counts, including unresolved UPC reasons and lead verdict totals.
|
||||||
|
|
||||||
## Input file format
|
## Input file format
|
||||||
|
|
||||||
Accepts `.csv` or `.xlsx` files. Column names are matched case-insensitively. Required column:
|
Accepts `.csv` or `.xlsx` files. Column names are matched case-insensitively. Required column:
|
||||||
@@ -97,20 +246,28 @@ Numeric parsing accepts plain numbers as well as formatted values like `$12.50`,
|
|||||||
4. **Keepa fetch** — batch the sellable (uncached) ASINs in a single API call (up to 100 per request)
|
4. **Keepa fetch** — batch the sellable (uncached) ASINs in a single API call (up to 100 per request)
|
||||||
5. **Enrich** — fetch SP-API pricing + FBA/FBM fees for sellable ASINs; combine with Keepa data and spreadsheet data
|
5. **Enrich** — fetch SP-API pricing + FBA/FBM fees for sellable ASINs; combine with Keepa data and spreadsheet data
|
||||||
6. **LLM analysis** — send batches of 5 sellable products to LM Studio for FBA/FBM/SKIP verdict; skipped ASINs get auto-SKIP verdict (confidence 100) and bypass LLM entirely
|
6. **LLM analysis** — send batches of 5 sellable products to LM Studio for FBA/FBM/SKIP verdict; skipped ASINs get auto-SKIP verdict (confidence 100) and bypass LLM entirely
|
||||||
7. **Output** — print results table to console (includes all ASINs), optionally write CSV/XLSX, and **persist results to a SQLite database**.
|
7. **Output** — print results table to console (includes all ASINs), optionally write CSV/XLSX, and persist products, observations, run items, and analysis revisions to PostgreSQL.
|
||||||
|
|
||||||
## Persistent Storage with SQLite
|
## Persistent Storage
|
||||||
|
|
||||||
Results from each run are now stored in a SQLite database named `results.db` in the project root. The SQLite implementation details are handled in `src/database.ts`. This allows you to:
|
PostgreSQL persistence is managed with Drizzle in `src/db/schema.ts` and `src/db/persistence.ts`. ASINs are canonical product identities: all inputs normalize to uppercase 10-character alphanumeric keys before any product reference is stored.
|
||||||
|
|
||||||
- Revisit past analysis results.
|
Core tables:
|
||||||
- Query and analyze historical data.
|
|
||||||
- Track product performance over time.
|
|
||||||
|
|
||||||
The database will automatically be created if it doesn't exist. Two tables are created:
|
- `products`: one canonical row per ASIN with latest descriptive metadata.
|
||||||
|
- `product_observations`: append-only marketplace, pricing, fee, and sellability snapshots.
|
||||||
|
- `runs` and `run_items`: unified lifecycle/history for lead, category, supplier UPC, and stalker workflows.
|
||||||
|
- `analysis_revisions` and `supplier_scores`: append-only analysis results; reanalysis does not overwrite prior decisions.
|
||||||
|
- `sourcing_inputs`, `upc_resolutions`, and `product_identifiers`: source-row and confirmed identifier data kept separate from catalog products.
|
||||||
|
- `stalker_run_details`, `stalker_scans`, and `stalker_inventory_items`: seller workflow provenance linked back to products and observations.
|
||||||
|
|
||||||
- `runs`: Stores metadata about each analysis run (timestamp, input file, output file, and summary counts).
|
Unresolved or ambiguous supplier UPCs stay on their run item and resolution records; a UPC is never stored as an ASIN.
|
||||||
- `results`: Stores detailed analysis results for each product from each run, linked to the `runs` table.
|
|
||||||
|
Web endpoints use unified identifiers:
|
||||||
|
|
||||||
|
- `GET /api/runs`, `GET /api/runs/:runId`, `GET /api/runs/:runId/items`
|
||||||
|
- `GET /api/products`, `GET /api/products/:asin`
|
||||||
|
- `POST /api/run-items/:itemId/reanalyze`
|
||||||
|
|
||||||
## Output columns
|
## Output columns
|
||||||
|
|
||||||
@@ -119,7 +276,7 @@ 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 |
|
||||||
@@ -134,6 +291,8 @@ ASIN, Name, Brand, Category, Unit Cost, Current Price, Avg Price 90d, Sales Rank
|
|||||||
| `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 |
|
||||||
|
| `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 |
|
| `CACHE_TTL` | `86400` | Redis cache TTL in seconds |
|
||||||
|
|
||||||
## Notes
|
## Notes
|
||||||
|
|||||||
372
bun.lock
372
bun.lock
@@ -6,7 +6,10 @@
|
|||||||
"name": "asin-check",
|
"name": "asin-check",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"amazon-sp-api": "^1.2.1",
|
"amazon-sp-api": "^1.2.1",
|
||||||
|
"drizzle-orm": "^0.45.2",
|
||||||
|
"exceljs": "^4.4.0",
|
||||||
"ioredis": "^5.10.1",
|
"ioredis": "^5.10.1",
|
||||||
|
"postgres": "^3.4.9",
|
||||||
"react": "^19.2.0",
|
"react": "^19.2.0",
|
||||||
"react-dom": "^19.2.0",
|
"react-dom": "^19.2.0",
|
||||||
"xlsx": "^0.18.5",
|
"xlsx": "^0.18.5",
|
||||||
@@ -15,13 +18,74 @@
|
|||||||
"@types/bun": "latest",
|
"@types/bun": "latest",
|
||||||
"@types/react": "^19.2.14",
|
"@types/react": "^19.2.14",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
},
|
"drizzle-kit": "^0.31.10",
|
||||||
"peerDependencies": {
|
"typescript": "^6.0.3",
|
||||||
"typescript": "^5",
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"packages": {
|
"packages": {
|
||||||
|
"@drizzle-team/brocli": ["@drizzle-team/brocli@0.10.2", "", {}, "sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w=="],
|
||||||
|
|
||||||
|
"@esbuild-kit/core-utils": ["@esbuild-kit/core-utils@3.3.2", "", { "dependencies": { "esbuild": "~0.18.20", "source-map-support": "^0.5.21" } }, "sha512-sPRAnw9CdSsRmEtnsl2WXWdyquogVpB3yZ3dgwJfe8zrOzTsV7cJvmwrKVa+0ma5BoiGJ+BoqkMvawbayKUsqQ=="],
|
||||||
|
|
||||||
|
"@esbuild-kit/esm-loader": ["@esbuild-kit/esm-loader@2.6.5", "", { "dependencies": { "@esbuild-kit/core-utils": "^3.3.2", "get-tsconfig": "^4.7.0" } }, "sha512-FxEMIkJKnodyA1OaCUoEvbYRkoZlLZ4d/eXFu9Fh8CbBBgP5EmZxrfTRyN0qpXZ4vOvqnE5YdRdcrmUUXuU+dA=="],
|
||||||
|
|
||||||
|
"@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.12", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA=="],
|
||||||
|
|
||||||
|
"@esbuild/android-arm": ["@esbuild/android-arm@0.25.12", "", { "os": "android", "cpu": "arm" }, "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg=="],
|
||||||
|
|
||||||
|
"@esbuild/android-arm64": ["@esbuild/android-arm64@0.25.12", "", { "os": "android", "cpu": "arm64" }, "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg=="],
|
||||||
|
|
||||||
|
"@esbuild/android-x64": ["@esbuild/android-x64@0.25.12", "", { "os": "android", "cpu": "x64" }, "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg=="],
|
||||||
|
|
||||||
|
"@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.25.12", "", { "os": "darwin", "cpu": "arm64" }, "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg=="],
|
||||||
|
|
||||||
|
"@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.25.12", "", { "os": "darwin", "cpu": "x64" }, "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA=="],
|
||||||
|
|
||||||
|
"@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.25.12", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg=="],
|
||||||
|
|
||||||
|
"@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.25.12", "", { "os": "freebsd", "cpu": "x64" }, "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ=="],
|
||||||
|
|
||||||
|
"@esbuild/linux-arm": ["@esbuild/linux-arm@0.25.12", "", { "os": "linux", "cpu": "arm" }, "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw=="],
|
||||||
|
|
||||||
|
"@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.25.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ=="],
|
||||||
|
|
||||||
|
"@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.25.12", "", { "os": "linux", "cpu": "ia32" }, "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA=="],
|
||||||
|
|
||||||
|
"@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng=="],
|
||||||
|
|
||||||
|
"@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw=="],
|
||||||
|
|
||||||
|
"@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.25.12", "", { "os": "linux", "cpu": "ppc64" }, "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA=="],
|
||||||
|
|
||||||
|
"@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w=="],
|
||||||
|
|
||||||
|
"@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.25.12", "", { "os": "linux", "cpu": "s390x" }, "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg=="],
|
||||||
|
|
||||||
|
"@esbuild/linux-x64": ["@esbuild/linux-x64@0.25.12", "", { "os": "linux", "cpu": "x64" }, "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw=="],
|
||||||
|
|
||||||
|
"@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg=="],
|
||||||
|
|
||||||
|
"@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.25.12", "", { "os": "none", "cpu": "x64" }, "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ=="],
|
||||||
|
|
||||||
|
"@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.25.12", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A=="],
|
||||||
|
|
||||||
|
"@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.25.12", "", { "os": "openbsd", "cpu": "x64" }, "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw=="],
|
||||||
|
|
||||||
|
"@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg=="],
|
||||||
|
|
||||||
|
"@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.25.12", "", { "os": "sunos", "cpu": "x64" }, "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w=="],
|
||||||
|
|
||||||
|
"@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.25.12", "", { "os": "win32", "cpu": "arm64" }, "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg=="],
|
||||||
|
|
||||||
|
"@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.25.12", "", { "os": "win32", "cpu": "ia32" }, "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ=="],
|
||||||
|
|
||||||
|
"@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.12", "", { "os": "win32", "cpu": "x64" }, "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA=="],
|
||||||
|
|
||||||
|
"@fast-csv/format": ["@fast-csv/format@4.3.5", "", { "dependencies": { "@types/node": "^14.0.1", "lodash.escaperegexp": "^4.1.2", "lodash.isboolean": "^3.0.3", "lodash.isequal": "^4.5.0", "lodash.isfunction": "^3.0.9", "lodash.isnil": "^4.0.0" } }, "sha512-8iRn6QF3I8Ak78lNAa+Gdl5MJJBM5vRHivFtMRUWINdevNo00K7OXxS2PshawLKTejVwieIlPmK5YlLu6w4u8A=="],
|
||||||
|
|
||||||
|
"@fast-csv/parse": ["@fast-csv/parse@4.3.6", "", { "dependencies": { "@types/node": "^14.0.1", "lodash.escaperegexp": "^4.1.2", "lodash.groupby": "^4.6.0", "lodash.isfunction": "^3.0.9", "lodash.isnil": "^4.0.0", "lodash.isundefined": "^3.0.1", "lodash.uniq": "^4.5.0" } }, "sha512-uRsLYksqpbDmWaSmzvJcuApSEe38+6NQZBUsuAyMZKqHxH0g1wcJgsKUvN3WC8tewaqFjBMMGrkHmC+T7k8LvA=="],
|
||||||
|
|
||||||
"@ioredis/commands": ["@ioredis/commands@1.5.1", "", {}, "sha512-JH8ZL/ywcJyR9MmJ5BNqZllXNZQqQbnVZOqpPQqE1vHiFgAw4NHbvE0FOduNU8IX9babitBT46571OnPTT0Zcw=="],
|
"@ioredis/commands": ["@ioredis/commands@1.5.1", "", {}, "sha512-JH8ZL/ywcJyR9MmJ5BNqZllXNZQqQbnVZOqpPQqE1vHiFgAw4NHbvE0FOduNU8IX9babitBT46571OnPTT0Zcw=="],
|
||||||
|
|
||||||
"@types/bun": ["@types/bun@1.3.11", "", { "dependencies": { "bun-types": "1.3.11" } }, "sha512-5vPne5QvtpjGpsGYXiFyycfpDF2ECyPcTSsFBMa0fraoxiQyMJ3SmuQIGhzPg2WJuWxVBoxWJ2kClYTcw/4fAg=="],
|
"@types/bun": ["@types/bun@1.3.11", "", { "dependencies": { "bun-types": "1.3.11" } }, "sha512-5vPne5QvtpjGpsGYXiFyycfpDF2ECyPcTSsFBMa0fraoxiQyMJ3SmuQIGhzPg2WJuWxVBoxWJ2kClYTcw/4fAg=="],
|
||||||
@@ -36,6 +100,36 @@
|
|||||||
|
|
||||||
"amazon-sp-api": ["amazon-sp-api@1.2.1", "", { "dependencies": { "csvtojson": "^2.0.14", "fast-xml-parser": "^5.3.1", "iconv-lite": "^0.7.0", "qs": "^6.14.0" } }, "sha512-zxX3KtoCDx0wxkkBgFM6qew49JJoL1XZQgUnztfp+8Im2HLHBAt4beSiDo/AkH00Gr8paHBAjdcJY6LC6ISU7w=="],
|
"amazon-sp-api": ["amazon-sp-api@1.2.1", "", { "dependencies": { "csvtojson": "^2.0.14", "fast-xml-parser": "^5.3.1", "iconv-lite": "^0.7.0", "qs": "^6.14.0" } }, "sha512-zxX3KtoCDx0wxkkBgFM6qew49JJoL1XZQgUnztfp+8Im2HLHBAt4beSiDo/AkH00Gr8paHBAjdcJY6LC6ISU7w=="],
|
||||||
|
|
||||||
|
"archiver": ["archiver@5.3.2", "", { "dependencies": { "archiver-utils": "^2.1.0", "async": "^3.2.4", "buffer-crc32": "^0.2.1", "readable-stream": "^3.6.0", "readdir-glob": "^1.1.2", "tar-stream": "^2.2.0", "zip-stream": "^4.1.0" } }, "sha512-+25nxyyznAXF7Nef3y0EbBeqmGZgeN/BxHX29Rs39djAfaFalmQ89SE6CWyDCHzGL0yt/ycBtNOmGTW0FyGWNw=="],
|
||||||
|
|
||||||
|
"archiver-utils": ["archiver-utils@2.1.0", "", { "dependencies": { "glob": "^7.1.4", "graceful-fs": "^4.2.0", "lazystream": "^1.0.0", "lodash.defaults": "^4.2.0", "lodash.difference": "^4.5.0", "lodash.flatten": "^4.4.0", "lodash.isplainobject": "^4.0.6", "lodash.union": "^4.6.0", "normalize-path": "^3.0.0", "readable-stream": "^2.0.0" } }, "sha512-bEL/yUb/fNNiNTuUz979Z0Yg5L+LzLxGJz8x79lYmR54fmTIb6ob/hNQgkQnIUDWIFjZVQwl9Xs356I6BAMHfw=="],
|
||||||
|
|
||||||
|
"async": ["async@3.2.6", "", {}, "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA=="],
|
||||||
|
|
||||||
|
"balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],
|
||||||
|
|
||||||
|
"base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="],
|
||||||
|
|
||||||
|
"big-integer": ["big-integer@1.6.52", "", {}, "sha512-QxD8cf2eVqJOOz63z6JIN9BzvVs/dlySa5HGSBH5xtR8dPteIRQnBxxKqkNTiT6jbDTF6jAfrd4oMcND9RGbQg=="],
|
||||||
|
|
||||||
|
"binary": ["binary@0.3.0", "", { "dependencies": { "buffers": "~0.1.1", "chainsaw": "~0.1.0" } }, "sha512-D4H1y5KYwpJgK8wk1Cue5LLPgmwHKYSChkbspQg5JtVuR5ulGckxfR62H3AE9UDkdMC8yyXlqYihuz3Aqg2XZg=="],
|
||||||
|
|
||||||
|
"bl": ["bl@4.1.0", "", { "dependencies": { "buffer": "^5.5.0", "inherits": "^2.0.4", "readable-stream": "^3.4.0" } }, "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w=="],
|
||||||
|
|
||||||
|
"bluebird": ["bluebird@3.4.7", "", {}, "sha512-iD3898SR7sWVRHbiQv+sHUtHnMvC1o3nW5rAcqnq3uOn07DSAppZYUkIGslDz6gXC7HfunPe7YVBgoEJASPcHA=="],
|
||||||
|
|
||||||
|
"brace-expansion": ["brace-expansion@2.1.0", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w=="],
|
||||||
|
|
||||||
|
"buffer": ["buffer@5.7.1", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.1.13" } }, "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ=="],
|
||||||
|
|
||||||
|
"buffer-crc32": ["buffer-crc32@0.2.13", "", {}, "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ=="],
|
||||||
|
|
||||||
|
"buffer-from": ["buffer-from@1.1.2", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="],
|
||||||
|
|
||||||
|
"buffer-indexof-polyfill": ["buffer-indexof-polyfill@1.0.2", "", {}, "sha512-I7wzHwA3t1/lwXQh+A5PbNvJxgfo5r3xulgpYDB5zckTu/Z9oUK9biouBKQUjEqzaz3HnAT6TYoovmE+GqSf7A=="],
|
||||||
|
|
||||||
|
"buffers": ["buffers@0.1.1", "", {}, "sha512-9q/rDEGSb/Qsvv2qvzIzdluL5k7AaJOTrw23z9reQthrbF7is4CtlT0DXyO1oei2DCp4uojjzQ7igaSHp1kAEQ=="],
|
||||||
|
|
||||||
"bun-types": ["bun-types@1.3.11", "", { "dependencies": { "@types/node": "*" } }, "sha512-1KGPpoxQWl9f6wcZh57LvrPIInQMn2TQ7jsgxqpRzg+l0QPOFvJVH7HmvHo/AiPgwXy+/Thf6Ov3EdVn1vOabg=="],
|
"bun-types": ["bun-types@1.3.11", "", { "dependencies": { "@types/node": "*" } }, "sha512-1KGPpoxQWl9f6wcZh57LvrPIInQMn2TQ7jsgxqpRzg+l0QPOFvJVH7HmvHo/AiPgwXy+/Thf6Ov3EdVn1vOabg=="],
|
||||||
|
|
||||||
"call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="],
|
"call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="],
|
||||||
@@ -44,78 +138,190 @@
|
|||||||
|
|
||||||
"cfb": ["cfb@1.2.2", "", { "dependencies": { "adler-32": "~1.3.0", "crc-32": "~1.2.0" } }, "sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA=="],
|
"cfb": ["cfb@1.2.2", "", { "dependencies": { "adler-32": "~1.3.0", "crc-32": "~1.2.0" } }, "sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA=="],
|
||||||
|
|
||||||
|
"chainsaw": ["chainsaw@0.1.0", "", { "dependencies": { "traverse": ">=0.3.0 <0.4" } }, "sha512-75kWfWt6MEKNC8xYXIdRpDehRYY/tNSgwKaJq+dbbDcxORuVrrQ+SEHoWsniVn9XPYfP4gmdWIeDk/4YNp1rNQ=="],
|
||||||
|
|
||||||
"cluster-key-slot": ["cluster-key-slot@1.1.2", "", {}, "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA=="],
|
"cluster-key-slot": ["cluster-key-slot@1.1.2", "", {}, "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA=="],
|
||||||
|
|
||||||
"codepage": ["codepage@1.15.0", "", {}, "sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA=="],
|
"codepage": ["codepage@1.15.0", "", {}, "sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA=="],
|
||||||
|
|
||||||
|
"compress-commons": ["compress-commons@4.1.2", "", { "dependencies": { "buffer-crc32": "^0.2.13", "crc32-stream": "^4.0.2", "normalize-path": "^3.0.0", "readable-stream": "^3.6.0" } }, "sha512-D3uMHtGc/fcO1Gt1/L7i1e33VOvD4A9hfQLP+6ewd+BvG/gQ84Yh4oftEhAdjSMgBgwGL+jsppT7JYNpo6MHHg=="],
|
||||||
|
|
||||||
|
"concat-map": ["concat-map@0.0.1", "", {}, "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg=="],
|
||||||
|
|
||||||
|
"core-util-is": ["core-util-is@1.0.3", "", {}, "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ=="],
|
||||||
|
|
||||||
"crc-32": ["crc-32@1.2.2", "", { "bin": { "crc32": "bin/crc32.njs" } }, "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ=="],
|
"crc-32": ["crc-32@1.2.2", "", { "bin": { "crc32": "bin/crc32.njs" } }, "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ=="],
|
||||||
|
|
||||||
|
"crc32-stream": ["crc32-stream@4.0.3", "", { "dependencies": { "crc-32": "^1.2.0", "readable-stream": "^3.4.0" } }, "sha512-NT7w2JVU7DFroFdYkeq8cywxrgjPHWkdX1wjpRQXPX5Asews3tA+Ght6lddQO5Mkumffp3X7GEqku3epj2toIw=="],
|
||||||
|
|
||||||
"csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
|
"csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
|
||||||
|
|
||||||
"csvtojson": ["csvtojson@2.0.14", "", { "dependencies": { "lodash": "^4.17.21" }, "bin": { "csvtojson": "bin/csvtojson" } }, "sha512-F7NNvhhDyob7OsuEGRsH0FM1aqLs/WYITyza3l+hTEEmOK9sGPBlYQZwlVG0ezCojXYpE17lhS5qL6BCOZSPyA=="],
|
"csvtojson": ["csvtojson@2.0.14", "", { "dependencies": { "lodash": "^4.17.21" }, "bin": { "csvtojson": "bin/csvtojson" } }, "sha512-F7NNvhhDyob7OsuEGRsH0FM1aqLs/WYITyza3l+hTEEmOK9sGPBlYQZwlVG0ezCojXYpE17lhS5qL6BCOZSPyA=="],
|
||||||
|
|
||||||
|
"dayjs": ["dayjs@1.11.20", "", {}, "sha512-YbwwqR/uYpeoP4pu043q+LTDLFBLApUP6VxRihdfNTqu4ubqMlGDLd6ErXhEgsyvY0K6nCs7nggYumAN+9uEuQ=="],
|
||||||
|
|
||||||
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
|
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
|
||||||
|
|
||||||
"denque": ["denque@2.1.0", "", {}, "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw=="],
|
"denque": ["denque@2.1.0", "", {}, "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw=="],
|
||||||
|
|
||||||
|
"drizzle-kit": ["drizzle-kit@0.31.10", "", { "dependencies": { "@drizzle-team/brocli": "^0.10.2", "@esbuild-kit/esm-loader": "^2.5.5", "esbuild": "^0.25.4", "tsx": "^4.21.0" }, "bin": { "drizzle-kit": "bin.cjs" } }, "sha512-7OZcmQUrdGI+DUNNsKBn1aW8qSoKuTH7d0mYgSP8bAzdFzKoovxEFnoGQp2dVs82EOJeYycqRtciopszwUf8bw=="],
|
||||||
|
|
||||||
|
"drizzle-orm": ["drizzle-orm@0.45.2", "", { "peerDependencies": { "@aws-sdk/client-rds-data": ">=3", "@cloudflare/workers-types": ">=4", "@electric-sql/pglite": ">=0.2.0", "@libsql/client": ">=0.10.0", "@libsql/client-wasm": ">=0.10.0", "@neondatabase/serverless": ">=0.10.0", "@op-engineering/op-sqlite": ">=2", "@opentelemetry/api": "^1.4.1", "@planetscale/database": ">=1.13", "@prisma/client": "*", "@tidbcloud/serverless": "*", "@types/better-sqlite3": "*", "@types/pg": "*", "@types/sql.js": "*", "@upstash/redis": ">=1.34.7", "@vercel/postgres": ">=0.8.0", "@xata.io/client": "*", "better-sqlite3": ">=7", "bun-types": "*", "expo-sqlite": ">=14.0.0", "gel": ">=2", "knex": "*", "kysely": "*", "mysql2": ">=2", "pg": ">=8", "postgres": ">=3", "prisma": "*", "sql.js": ">=1", "sqlite3": ">=5" }, "optionalPeers": ["@aws-sdk/client-rds-data", "@cloudflare/workers-types", "@electric-sql/pglite", "@libsql/client", "@libsql/client-wasm", "@neondatabase/serverless", "@op-engineering/op-sqlite", "@opentelemetry/api", "@planetscale/database", "@prisma/client", "@tidbcloud/serverless", "@types/better-sqlite3", "@types/pg", "@types/sql.js", "@upstash/redis", "@vercel/postgres", "@xata.io/client", "better-sqlite3", "bun-types", "expo-sqlite", "gel", "knex", "kysely", "mysql2", "pg", "postgres", "prisma", "sql.js", "sqlite3"] }, "sha512-kY0BSaTNYWnoDMVoyY8uxmyHjpJW1geOmBMdSSicKo9CIIWkSxMIj2rkeSR51b8KAPB7m+qysjuHme5nKP+E5Q=="],
|
||||||
|
|
||||||
"dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="],
|
"dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="],
|
||||||
|
|
||||||
|
"duplexer2": ["duplexer2@0.1.4", "", { "dependencies": { "readable-stream": "^2.0.2" } }, "sha512-asLFVfWWtJ90ZyOUHMqk7/S2w2guQKxUI2itj3d92ADHhxUSbCMGi1f1cBcJ7xM1To+pE/Khbwo1yuNbMEPKeA=="],
|
||||||
|
|
||||||
|
"end-of-stream": ["end-of-stream@1.4.5", "", { "dependencies": { "once": "^1.4.0" } }, "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg=="],
|
||||||
|
|
||||||
"es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="],
|
"es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="],
|
||||||
|
|
||||||
"es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="],
|
"es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="],
|
||||||
|
|
||||||
"es-object-atoms": ["es-object-atoms@1.1.1", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA=="],
|
"es-object-atoms": ["es-object-atoms@1.1.1", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA=="],
|
||||||
|
|
||||||
|
"esbuild": ["esbuild@0.25.12", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.12", "@esbuild/android-arm": "0.25.12", "@esbuild/android-arm64": "0.25.12", "@esbuild/android-x64": "0.25.12", "@esbuild/darwin-arm64": "0.25.12", "@esbuild/darwin-x64": "0.25.12", "@esbuild/freebsd-arm64": "0.25.12", "@esbuild/freebsd-x64": "0.25.12", "@esbuild/linux-arm": "0.25.12", "@esbuild/linux-arm64": "0.25.12", "@esbuild/linux-ia32": "0.25.12", "@esbuild/linux-loong64": "0.25.12", "@esbuild/linux-mips64el": "0.25.12", "@esbuild/linux-ppc64": "0.25.12", "@esbuild/linux-riscv64": "0.25.12", "@esbuild/linux-s390x": "0.25.12", "@esbuild/linux-x64": "0.25.12", "@esbuild/netbsd-arm64": "0.25.12", "@esbuild/netbsd-x64": "0.25.12", "@esbuild/openbsd-arm64": "0.25.12", "@esbuild/openbsd-x64": "0.25.12", "@esbuild/openharmony-arm64": "0.25.12", "@esbuild/sunos-x64": "0.25.12", "@esbuild/win32-arm64": "0.25.12", "@esbuild/win32-ia32": "0.25.12", "@esbuild/win32-x64": "0.25.12" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg=="],
|
||||||
|
|
||||||
|
"exceljs": ["exceljs@4.4.0", "", { "dependencies": { "archiver": "^5.0.0", "dayjs": "^1.8.34", "fast-csv": "^4.3.1", "jszip": "^3.10.1", "readable-stream": "^3.6.0", "saxes": "^5.0.1", "tmp": "^0.2.0", "unzipper": "^0.10.11", "uuid": "^8.3.0" } }, "sha512-XctvKaEMaj1Ii9oDOqbW/6e1gXknSY4g/aLCDicOXqBE4M0nRWkUu0PTp++UPNzoFY12BNHMfs/VadKIS6llvg=="],
|
||||||
|
|
||||||
|
"fast-csv": ["fast-csv@4.3.6", "", { "dependencies": { "@fast-csv/format": "4.3.5", "@fast-csv/parse": "4.3.6" } }, "sha512-2RNSpuwwsJGP0frGsOmTb9oUF+VkFSM4SyLTDgwf2ciHWTarN0lQTC+F2f/t5J9QjW+c65VFIAAu85GsvMIusw=="],
|
||||||
|
|
||||||
"fast-xml-builder": ["fast-xml-builder@1.1.4", "", { "dependencies": { "path-expression-matcher": "^1.1.3" } }, "sha512-f2jhpN4Eccy0/Uz9csxh3Nu6q4ErKxf0XIsasomfOihuSUa3/xw6w8dnOtCDgEItQFJG8KyXPzQXzcODDrrbOg=="],
|
"fast-xml-builder": ["fast-xml-builder@1.1.4", "", { "dependencies": { "path-expression-matcher": "^1.1.3" } }, "sha512-f2jhpN4Eccy0/Uz9csxh3Nu6q4ErKxf0XIsasomfOihuSUa3/xw6w8dnOtCDgEItQFJG8KyXPzQXzcODDrrbOg=="],
|
||||||
|
|
||||||
"fast-xml-parser": ["fast-xml-parser@5.5.11", "", { "dependencies": { "fast-xml-builder": "^1.1.4", "path-expression-matcher": "^1.4.0", "strnum": "^2.2.3" }, "bin": { "fxparser": "src/cli/cli.js" } }, "sha512-QL0eb0YbSTVWF6tTf1+LEMSgtCEjBYPpnAjoLC8SscESlAjXEIRJ7cHtLG0pLeDFaZLa4VKZLArtA/60ZS7vyA=="],
|
"fast-xml-parser": ["fast-xml-parser@5.5.11", "", { "dependencies": { "fast-xml-builder": "^1.1.4", "path-expression-matcher": "^1.4.0", "strnum": "^2.2.3" }, "bin": { "fxparser": "src/cli/cli.js" } }, "sha512-QL0eb0YbSTVWF6tTf1+LEMSgtCEjBYPpnAjoLC8SscESlAjXEIRJ7cHtLG0pLeDFaZLa4VKZLArtA/60ZS7vyA=="],
|
||||||
|
|
||||||
"frac": ["frac@1.1.2", "", {}, "sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA=="],
|
"frac": ["frac@1.1.2", "", {}, "sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA=="],
|
||||||
|
|
||||||
|
"fs-constants": ["fs-constants@1.0.0", "", {}, "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow=="],
|
||||||
|
|
||||||
|
"fs.realpath": ["fs.realpath@1.0.0", "", {}, "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw=="],
|
||||||
|
|
||||||
|
"fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
|
||||||
|
|
||||||
|
"fstream": ["fstream@1.0.12", "", { "dependencies": { "graceful-fs": "^4.1.2", "inherits": "~2.0.0", "mkdirp": ">=0.5 0", "rimraf": "2" } }, "sha512-WvJ193OHa0GHPEL+AycEJgxvBEwyfRkN1vhjca23OaPVMCaLCXTd5qAu82AjTcgP1UJmytkOKb63Ypde7raDIg=="],
|
||||||
|
|
||||||
"function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="],
|
"function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="],
|
||||||
|
|
||||||
"get-intrinsic": ["get-intrinsic@1.3.0", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="],
|
"get-intrinsic": ["get-intrinsic@1.3.0", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="],
|
||||||
|
|
||||||
"get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="],
|
"get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="],
|
||||||
|
|
||||||
|
"get-tsconfig": ["get-tsconfig@4.14.0", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA=="],
|
||||||
|
|
||||||
|
"glob": ["glob@7.2.3", "", { "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", "inherits": "2", "minimatch": "^3.1.1", "once": "^1.3.0", "path-is-absolute": "^1.0.0" } }, "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q=="],
|
||||||
|
|
||||||
"gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="],
|
"gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="],
|
||||||
|
|
||||||
|
"graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="],
|
||||||
|
|
||||||
"has-symbols": ["has-symbols@1.1.0", "", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="],
|
"has-symbols": ["has-symbols@1.1.0", "", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="],
|
||||||
|
|
||||||
"hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="],
|
"hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="],
|
||||||
|
|
||||||
"iconv-lite": ["iconv-lite@0.7.2", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw=="],
|
"iconv-lite": ["iconv-lite@0.7.2", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw=="],
|
||||||
|
|
||||||
|
"ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="],
|
||||||
|
|
||||||
|
"immediate": ["immediate@3.0.6", "", {}, "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ=="],
|
||||||
|
|
||||||
|
"inflight": ["inflight@1.0.6", "", { "dependencies": { "once": "^1.3.0", "wrappy": "1" } }, "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA=="],
|
||||||
|
|
||||||
|
"inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="],
|
||||||
|
|
||||||
"ioredis": ["ioredis@5.10.1", "", { "dependencies": { "@ioredis/commands": "1.5.1", "cluster-key-slot": "^1.1.0", "debug": "^4.3.4", "denque": "^2.1.0", "lodash.defaults": "^4.2.0", "lodash.isarguments": "^3.1.0", "redis-errors": "^1.2.0", "redis-parser": "^3.0.0", "standard-as-callback": "^2.1.0" } }, "sha512-HuEDBTI70aYdx1v6U97SbNx9F1+svQKBDo30o0b9fw055LMepzpOOd0Ccg9Q6tbqmBSJaMuY0fB7yw9/vjBYCA=="],
|
"ioredis": ["ioredis@5.10.1", "", { "dependencies": { "@ioredis/commands": "1.5.1", "cluster-key-slot": "^1.1.0", "debug": "^4.3.4", "denque": "^2.1.0", "lodash.defaults": "^4.2.0", "lodash.isarguments": "^3.1.0", "redis-errors": "^1.2.0", "redis-parser": "^3.0.0", "standard-as-callback": "^2.1.0" } }, "sha512-HuEDBTI70aYdx1v6U97SbNx9F1+svQKBDo30o0b9fw055LMepzpOOd0Ccg9Q6tbqmBSJaMuY0fB7yw9/vjBYCA=="],
|
||||||
|
|
||||||
|
"isarray": ["isarray@1.0.0", "", {}, "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ=="],
|
||||||
|
|
||||||
|
"jszip": ["jszip@3.10.1", "", { "dependencies": { "lie": "~3.3.0", "pako": "~1.0.2", "readable-stream": "~2.3.6", "setimmediate": "^1.0.5" } }, "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g=="],
|
||||||
|
|
||||||
|
"lazystream": ["lazystream@1.0.1", "", { "dependencies": { "readable-stream": "^2.0.5" } }, "sha512-b94GiNHQNy6JNTrt5w6zNyffMrNkXZb3KTkCZJb2V1xaEGCk093vkZ2jk3tpaeP33/OiXC+WvK9AxUebnf5nbw=="],
|
||||||
|
|
||||||
|
"lie": ["lie@3.3.0", "", { "dependencies": { "immediate": "~3.0.5" } }, "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ=="],
|
||||||
|
|
||||||
|
"listenercount": ["listenercount@1.0.1", "", {}, "sha512-3mk/Zag0+IJxeDrxSgaDPy4zZ3w05PRZeJNnlWhzFz5OkX49J4krc+A8X2d2M69vGMBEX0uyl8M+W+8gH+kBqQ=="],
|
||||||
|
|
||||||
"lodash": ["lodash@4.18.1", "", {}, "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q=="],
|
"lodash": ["lodash@4.18.1", "", {}, "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q=="],
|
||||||
|
|
||||||
"lodash.defaults": ["lodash.defaults@4.2.0", "", {}, "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ=="],
|
"lodash.defaults": ["lodash.defaults@4.2.0", "", {}, "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ=="],
|
||||||
|
|
||||||
|
"lodash.difference": ["lodash.difference@4.5.0", "", {}, "sha512-dS2j+W26TQ7taQBGN8Lbbq04ssV3emRw4NY58WErlTO29pIqS0HmoT5aJ9+TUQ1N3G+JOZSji4eugsWwGp9yPA=="],
|
||||||
|
|
||||||
|
"lodash.escaperegexp": ["lodash.escaperegexp@4.1.2", "", {}, "sha512-TM9YBvyC84ZxE3rgfefxUWiQKLilstD6k7PTGt6wfbtXF8ixIJLOL3VYyV/z+ZiPLsVxAsKAFVwWlWeb2Y8Yyw=="],
|
||||||
|
|
||||||
|
"lodash.flatten": ["lodash.flatten@4.4.0", "", {}, "sha512-C5N2Z3DgnnKr0LOpv/hKCgKdb7ZZwafIrsesve6lmzvZIRZRGaZ/l6Q8+2W7NaT+ZwO3fFlSCzCzrDCFdJfZ4g=="],
|
||||||
|
|
||||||
|
"lodash.groupby": ["lodash.groupby@4.6.0", "", {}, "sha512-5dcWxm23+VAoz+awKmBaiBvzox8+RqMgFhi7UvX9DHZr2HdxHXM/Wrf8cfKpsW37RNrvtPn6hSwNqurSILbmJw=="],
|
||||||
|
|
||||||
"lodash.isarguments": ["lodash.isarguments@3.1.0", "", {}, "sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg=="],
|
"lodash.isarguments": ["lodash.isarguments@3.1.0", "", {}, "sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg=="],
|
||||||
|
|
||||||
|
"lodash.isboolean": ["lodash.isboolean@3.0.3", "", {}, "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg=="],
|
||||||
|
|
||||||
|
"lodash.isequal": ["lodash.isequal@4.5.0", "", {}, "sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ=="],
|
||||||
|
|
||||||
|
"lodash.isfunction": ["lodash.isfunction@3.0.9", "", {}, "sha512-AirXNj15uRIMMPihnkInB4i3NHeb4iBtNg9WRWuK2o31S+ePwwNmDPaTL3o7dTJ+VXNZim7rFs4rxN4YU1oUJw=="],
|
||||||
|
|
||||||
|
"lodash.isnil": ["lodash.isnil@4.0.0", "", {}, "sha512-up2Mzq3545mwVnMhTDMdfoG1OurpA/s5t88JmQX809eH3C8491iu2sfKhTfhQtKY78oPNhiaHJUpT/dUDAAtng=="],
|
||||||
|
|
||||||
|
"lodash.isplainobject": ["lodash.isplainobject@4.0.6", "", {}, "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA=="],
|
||||||
|
|
||||||
|
"lodash.isundefined": ["lodash.isundefined@3.0.1", "", {}, "sha512-MXB1is3s899/cD8jheYYE2V9qTHwKvt+npCwpD+1Sxm3Q3cECXCiYHjeHWXNwr6Q0SOBPrYUDxendrO6goVTEA=="],
|
||||||
|
|
||||||
|
"lodash.union": ["lodash.union@4.6.0", "", {}, "sha512-c4pB2CdGrGdjMKYLA+XiRDO7Y0PRQbm/Gzg8qMj+QH+pFVAoTp5sBpO0odL3FjoPCGjK96p6qsP+yQoiLoOBcw=="],
|
||||||
|
|
||||||
|
"lodash.uniq": ["lodash.uniq@4.5.0", "", {}, "sha512-xfBaXQd9ryd9dlSDvnvI0lvxfLJlYAZzXomUYzLKtUeOQvOP5piqAWuGtrhWeqaXK9hhoM/iyJc5AV+XfsX3HQ=="],
|
||||||
|
|
||||||
"math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="],
|
"math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="],
|
||||||
|
|
||||||
|
"minimatch": ["minimatch@5.1.9", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw=="],
|
||||||
|
|
||||||
|
"mkdirp": ["mkdirp@3.0.1", "", { "bin": { "mkdirp": "dist/cjs/src/bin.js" } }, "sha512-+NsyUUAZDmo6YVHzL/stxSu3t9YS1iljliy3BSDrXJ/dkn1KYdmtZODGGjLcc9XLgVVpH4KshHB8XmZgMhaBXg=="],
|
||||||
|
|
||||||
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
|
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
|
||||||
|
|
||||||
|
"normalize-path": ["normalize-path@3.0.0", "", {}, "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA=="],
|
||||||
|
|
||||||
"object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="],
|
"object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="],
|
||||||
|
|
||||||
|
"once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="],
|
||||||
|
|
||||||
|
"pako": ["pako@1.0.11", "", {}, "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw=="],
|
||||||
|
|
||||||
"path-expression-matcher": ["path-expression-matcher@1.4.0", "", {}, "sha512-s4DQMxIdhj3jLFWd9LxHOplj4p9yQ4ffMGowFf3cpEgrrJjEhN0V5nxw4Ye1EViAGDoL4/1AeO6qHpqYPOzE4Q=="],
|
"path-expression-matcher": ["path-expression-matcher@1.4.0", "", {}, "sha512-s4DQMxIdhj3jLFWd9LxHOplj4p9yQ4ffMGowFf3cpEgrrJjEhN0V5nxw4Ye1EViAGDoL4/1AeO6qHpqYPOzE4Q=="],
|
||||||
|
|
||||||
|
"path-is-absolute": ["path-is-absolute@1.0.1", "", {}, "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg=="],
|
||||||
|
|
||||||
|
"postgres": ["postgres@3.4.9", "", {}, "sha512-GD3qdB0x1z9xgFI6cdRD6xu2Sp2WCOEoe3mtnyB5Ee0XrrL5Pe+e4CCnJrRMnL1zYtRDZmQQVbvOttLnKDLnaw=="],
|
||||||
|
|
||||||
|
"process-nextick-args": ["process-nextick-args@2.0.1", "", {}, "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag=="],
|
||||||
|
|
||||||
"qs": ["qs@6.15.1", "", { "dependencies": { "side-channel": "^1.1.0" } }, "sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg=="],
|
"qs": ["qs@6.15.1", "", { "dependencies": { "side-channel": "^1.1.0" } }, "sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg=="],
|
||||||
|
|
||||||
"react": ["react@19.2.5", "", {}, "sha512-llUJLzz1zTUBrskt2pwZgLq59AemifIftw4aB7JxOqf1HY2FDaGDxgwpAPVzHU1kdWabH7FauP4i1oEeer2WCA=="],
|
"react": ["react@19.2.5", "", {}, "sha512-llUJLzz1zTUBrskt2pwZgLq59AemifIftw4aB7JxOqf1HY2FDaGDxgwpAPVzHU1kdWabH7FauP4i1oEeer2WCA=="],
|
||||||
|
|
||||||
"react-dom": ["react-dom@19.2.5", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.5" } }, "sha512-J5bAZz+DXMMwW/wV3xzKke59Af6CHY7G4uYLN1OvBcKEsWOs4pQExj86BBKamxl/Ik5bx9whOrvBlSDfWzgSag=="],
|
"react-dom": ["react-dom@19.2.5", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.5" } }, "sha512-J5bAZz+DXMMwW/wV3xzKke59Af6CHY7G4uYLN1OvBcKEsWOs4pQExj86BBKamxl/Ik5bx9whOrvBlSDfWzgSag=="],
|
||||||
|
|
||||||
|
"readable-stream": ["readable-stream@3.6.2", "", { "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", "util-deprecate": "^1.0.1" } }, "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA=="],
|
||||||
|
|
||||||
|
"readdir-glob": ["readdir-glob@1.1.3", "", { "dependencies": { "minimatch": "^5.1.0" } }, "sha512-v05I2k7xN8zXvPD9N+z/uhXPaj0sUFCe2rcWZIpBsqxfP7xXFQ0tipAd/wjj1YxWyWtUS5IDJpOG82JKt2EAVA=="],
|
||||||
|
|
||||||
"redis-errors": ["redis-errors@1.2.0", "", {}, "sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w=="],
|
"redis-errors": ["redis-errors@1.2.0", "", {}, "sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w=="],
|
||||||
|
|
||||||
"redis-parser": ["redis-parser@3.0.0", "", { "dependencies": { "redis-errors": "^1.0.0" } }, "sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A=="],
|
"redis-parser": ["redis-parser@3.0.0", "", { "dependencies": { "redis-errors": "^1.0.0" } }, "sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A=="],
|
||||||
|
|
||||||
|
"resolve-pkg-maps": ["resolve-pkg-maps@1.0.0", "", {}, "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw=="],
|
||||||
|
|
||||||
|
"rimraf": ["rimraf@2.7.1", "", { "dependencies": { "glob": "^7.1.3" }, "bin": { "rimraf": "./bin.js" } }, "sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w=="],
|
||||||
|
|
||||||
|
"safe-buffer": ["safe-buffer@5.1.2", "", {}, "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="],
|
||||||
|
|
||||||
"safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="],
|
"safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="],
|
||||||
|
|
||||||
|
"saxes": ["saxes@5.0.1", "", { "dependencies": { "xmlchars": "^2.2.0" } }, "sha512-5LBh1Tls8c9xgGjw3QrMwETmTMVk0oFgvrFSvWx62llR2hcEInrKNZ2GZCCuuy2lvWrdl5jhbpeqc5hRYKFOcw=="],
|
||||||
|
|
||||||
"scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="],
|
"scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="],
|
||||||
|
|
||||||
|
"setimmediate": ["setimmediate@1.0.5", "", {}, "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA=="],
|
||||||
|
|
||||||
"side-channel": ["side-channel@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3", "side-channel-list": "^1.0.0", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" } }, "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw=="],
|
"side-channel": ["side-channel@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3", "side-channel-list": "^1.0.0", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" } }, "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw=="],
|
||||||
|
|
||||||
"side-channel-list": ["side-channel-list@1.0.1", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.4" } }, "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w=="],
|
"side-channel-list": ["side-channel-list@1.0.1", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.4" } }, "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w=="],
|
||||||
@@ -124,20 +330,178 @@
|
|||||||
|
|
||||||
"side-channel-weakmap": ["side-channel-weakmap@1.0.2", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3", "side-channel-map": "^1.0.1" } }, "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A=="],
|
"side-channel-weakmap": ["side-channel-weakmap@1.0.2", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3", "side-channel-map": "^1.0.1" } }, "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A=="],
|
||||||
|
|
||||||
|
"source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="],
|
||||||
|
|
||||||
|
"source-map-support": ["source-map-support@0.5.21", "", { "dependencies": { "buffer-from": "^1.0.0", "source-map": "^0.6.0" } }, "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w=="],
|
||||||
|
|
||||||
"ssf": ["ssf@0.11.2", "", { "dependencies": { "frac": "~1.1.2" } }, "sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g=="],
|
"ssf": ["ssf@0.11.2", "", { "dependencies": { "frac": "~1.1.2" } }, "sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g=="],
|
||||||
|
|
||||||
"standard-as-callback": ["standard-as-callback@2.1.0", "", {}, "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A=="],
|
"standard-as-callback": ["standard-as-callback@2.1.0", "", {}, "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A=="],
|
||||||
|
|
||||||
|
"string_decoder": ["string_decoder@1.3.0", "", { "dependencies": { "safe-buffer": "~5.2.0" } }, "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA=="],
|
||||||
|
|
||||||
"strnum": ["strnum@2.2.3", "", {}, "sha512-oKx6RUCuHfT3oyVjtnrmn19H1SiCqgJSg+54XqURKp5aCMbrXrhLjRN9TjuwMjiYstZ0MzDrHqkGZ5dFTKd+zg=="],
|
"strnum": ["strnum@2.2.3", "", {}, "sha512-oKx6RUCuHfT3oyVjtnrmn19H1SiCqgJSg+54XqURKp5aCMbrXrhLjRN9TjuwMjiYstZ0MzDrHqkGZ5dFTKd+zg=="],
|
||||||
|
|
||||||
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
|
"tar-stream": ["tar-stream@2.2.0", "", { "dependencies": { "bl": "^4.0.3", "end-of-stream": "^1.4.1", "fs-constants": "^1.0.0", "inherits": "^2.0.3", "readable-stream": "^3.1.1" } }, "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ=="],
|
||||||
|
|
||||||
|
"tmp": ["tmp@0.2.5", "", {}, "sha512-voyz6MApa1rQGUxT3E+BK7/ROe8itEx7vD8/HEvt4xwXucvQ5G5oeEiHkmHZJuBO21RpOf+YYm9MOivj709jow=="],
|
||||||
|
|
||||||
|
"traverse": ["traverse@0.3.9", "", {}, "sha512-iawgk0hLP3SxGKDfnDJf8wTz4p2qImnyihM5Hh/sGvQ3K37dPi/w8sRhdNIxYA1TwFwc5mDhIJq+O0RsvXBKdQ=="],
|
||||||
|
|
||||||
|
"tsx": ["tsx@4.22.3", "", { "dependencies": { "esbuild": "~0.28.0" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "bin": { "tsx": "dist/cli.mjs" } }, "sha512-mdoNxBC/cSQObGGVQ5Bpn5i+yv7j68gk3Nfm3wFjcJg3Z0Mix9jzAFfP12prmm5eVGmDKtp0yyArrs0Q+8gZHg=="],
|
||||||
|
|
||||||
|
"typescript": ["typescript@6.0.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw=="],
|
||||||
|
|
||||||
"undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="],
|
"undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="],
|
||||||
|
|
||||||
|
"unzipper": ["unzipper@0.10.14", "", { "dependencies": { "big-integer": "^1.6.17", "binary": "~0.3.0", "bluebird": "~3.4.1", "buffer-indexof-polyfill": "~1.0.0", "duplexer2": "~0.1.4", "fstream": "^1.0.12", "graceful-fs": "^4.2.2", "listenercount": "~1.0.1", "readable-stream": "~2.3.6", "setimmediate": "~1.0.4" } }, "sha512-ti4wZj+0bQTiX2KmKWuwj7lhV+2n//uXEotUmGuQqrbVZSEGFMbI68+c6JCQ8aAmUWYvtHEz2A8K6wXvueR/6g=="],
|
||||||
|
|
||||||
|
"util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="],
|
||||||
|
|
||||||
|
"uuid": ["uuid@8.3.2", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg=="],
|
||||||
|
|
||||||
"wmf": ["wmf@1.0.2", "", {}, "sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw=="],
|
"wmf": ["wmf@1.0.2", "", {}, "sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw=="],
|
||||||
|
|
||||||
"word": ["word@0.3.0", "", {}, "sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA=="],
|
"word": ["word@0.3.0", "", {}, "sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA=="],
|
||||||
|
|
||||||
|
"wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="],
|
||||||
|
|
||||||
"xlsx": ["xlsx@0.18.5", "", { "dependencies": { "adler-32": "~1.3.0", "cfb": "~1.2.1", "codepage": "~1.15.0", "crc-32": "~1.2.1", "ssf": "~0.11.2", "wmf": "~1.0.1", "word": "~0.3.0" }, "bin": { "xlsx": "bin/xlsx.njs" } }, "sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ=="],
|
"xlsx": ["xlsx@0.18.5", "", { "dependencies": { "adler-32": "~1.3.0", "cfb": "~1.2.1", "codepage": "~1.15.0", "crc-32": "~1.2.1", "ssf": "~0.11.2", "wmf": "~1.0.1", "word": "~0.3.0" }, "bin": { "xlsx": "bin/xlsx.njs" } }, "sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ=="],
|
||||||
|
|
||||||
|
"xmlchars": ["xmlchars@2.2.0", "", {}, "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw=="],
|
||||||
|
|
||||||
|
"zip-stream": ["zip-stream@4.1.1", "", { "dependencies": { "archiver-utils": "^3.0.4", "compress-commons": "^4.1.2", "readable-stream": "^3.6.0" } }, "sha512-9qv4rlDiopXg4E69k+vMHjNN63YFMe9sZMrdlvKnCjlCRWeCBswPPMPUfx+ipsAWq1LXHe70RcbaHdJJpS6hyQ=="],
|
||||||
|
|
||||||
|
"@esbuild-kit/core-utils/esbuild": ["esbuild@0.18.20", "", { "optionalDependencies": { "@esbuild/android-arm": "0.18.20", "@esbuild/android-arm64": "0.18.20", "@esbuild/android-x64": "0.18.20", "@esbuild/darwin-arm64": "0.18.20", "@esbuild/darwin-x64": "0.18.20", "@esbuild/freebsd-arm64": "0.18.20", "@esbuild/freebsd-x64": "0.18.20", "@esbuild/linux-arm": "0.18.20", "@esbuild/linux-arm64": "0.18.20", "@esbuild/linux-ia32": "0.18.20", "@esbuild/linux-loong64": "0.18.20", "@esbuild/linux-mips64el": "0.18.20", "@esbuild/linux-ppc64": "0.18.20", "@esbuild/linux-riscv64": "0.18.20", "@esbuild/linux-s390x": "0.18.20", "@esbuild/linux-x64": "0.18.20", "@esbuild/netbsd-x64": "0.18.20", "@esbuild/openbsd-x64": "0.18.20", "@esbuild/sunos-x64": "0.18.20", "@esbuild/win32-arm64": "0.18.20", "@esbuild/win32-ia32": "0.18.20", "@esbuild/win32-x64": "0.18.20" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA=="],
|
||||||
|
|
||||||
|
"@fast-csv/format/@types/node": ["@types/node@14.18.63", "", {}, "sha512-fAtCfv4jJg+ExtXhvCkCqUKZ+4ok/JQk01qDKhL5BDDoS3AxKXhV5/MAVUZyQnSEd2GT92fkgZl0pz0Q0AzcIQ=="],
|
||||||
|
|
||||||
|
"@fast-csv/parse/@types/node": ["@types/node@14.18.63", "", {}, "sha512-fAtCfv4jJg+ExtXhvCkCqUKZ+4ok/JQk01qDKhL5BDDoS3AxKXhV5/MAVUZyQnSEd2GT92fkgZl0pz0Q0AzcIQ=="],
|
||||||
|
|
||||||
|
"archiver-utils/readable-stream": ["readable-stream@2.3.8", "", { "dependencies": { "core-util-is": "~1.0.0", "inherits": "~2.0.3", "isarray": "~1.0.0", "process-nextick-args": "~2.0.0", "safe-buffer": "~5.1.1", "string_decoder": "~1.1.1", "util-deprecate": "~1.0.1" } }, "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA=="],
|
||||||
|
|
||||||
|
"duplexer2/readable-stream": ["readable-stream@2.3.8", "", { "dependencies": { "core-util-is": "~1.0.0", "inherits": "~2.0.3", "isarray": "~1.0.0", "process-nextick-args": "~2.0.0", "safe-buffer": "~5.1.1", "string_decoder": "~1.1.1", "util-deprecate": "~1.0.1" } }, "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA=="],
|
||||||
|
|
||||||
|
"glob/minimatch": ["minimatch@3.1.5", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w=="],
|
||||||
|
|
||||||
|
"jszip/readable-stream": ["readable-stream@2.3.8", "", { "dependencies": { "core-util-is": "~1.0.0", "inherits": "~2.0.3", "isarray": "~1.0.0", "process-nextick-args": "~2.0.0", "safe-buffer": "~5.1.1", "string_decoder": "~1.1.1", "util-deprecate": "~1.0.1" } }, "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA=="],
|
||||||
|
|
||||||
|
"lazystream/readable-stream": ["readable-stream@2.3.8", "", { "dependencies": { "core-util-is": "~1.0.0", "inherits": "~2.0.3", "isarray": "~1.0.0", "process-nextick-args": "~2.0.0", "safe-buffer": "~5.1.1", "string_decoder": "~1.1.1", "util-deprecate": "~1.0.1" } }, "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA=="],
|
||||||
|
|
||||||
|
"string_decoder/safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="],
|
||||||
|
|
||||||
|
"tsx/esbuild": ["esbuild@0.28.0", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.28.0", "@esbuild/android-arm": "0.28.0", "@esbuild/android-arm64": "0.28.0", "@esbuild/android-x64": "0.28.0", "@esbuild/darwin-arm64": "0.28.0", "@esbuild/darwin-x64": "0.28.0", "@esbuild/freebsd-arm64": "0.28.0", "@esbuild/freebsd-x64": "0.28.0", "@esbuild/linux-arm": "0.28.0", "@esbuild/linux-arm64": "0.28.0", "@esbuild/linux-ia32": "0.28.0", "@esbuild/linux-loong64": "0.28.0", "@esbuild/linux-mips64el": "0.28.0", "@esbuild/linux-ppc64": "0.28.0", "@esbuild/linux-riscv64": "0.28.0", "@esbuild/linux-s390x": "0.28.0", "@esbuild/linux-x64": "0.28.0", "@esbuild/netbsd-arm64": "0.28.0", "@esbuild/netbsd-x64": "0.28.0", "@esbuild/openbsd-arm64": "0.28.0", "@esbuild/openbsd-x64": "0.28.0", "@esbuild/openharmony-arm64": "0.28.0", "@esbuild/sunos-x64": "0.28.0", "@esbuild/win32-arm64": "0.28.0", "@esbuild/win32-ia32": "0.28.0", "@esbuild/win32-x64": "0.28.0" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-sNR9MHpXSUV/XB4zmsFKN+QgVG82Cc7+/aaxJ8Adi8hyOac+EXptIp45QBPaVyX3N70664wRbTcLTOemCAnyqw=="],
|
||||||
|
|
||||||
|
"unzipper/readable-stream": ["readable-stream@2.3.8", "", { "dependencies": { "core-util-is": "~1.0.0", "inherits": "~2.0.3", "isarray": "~1.0.0", "process-nextick-args": "~2.0.0", "safe-buffer": "~5.1.1", "string_decoder": "~1.1.1", "util-deprecate": "~1.0.1" } }, "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA=="],
|
||||||
|
|
||||||
|
"zip-stream/archiver-utils": ["archiver-utils@3.0.4", "", { "dependencies": { "glob": "^7.2.3", "graceful-fs": "^4.2.0", "lazystream": "^1.0.0", "lodash.defaults": "^4.2.0", "lodash.difference": "^4.5.0", "lodash.flatten": "^4.4.0", "lodash.isplainobject": "^4.0.6", "lodash.union": "^4.6.0", "normalize-path": "^3.0.0", "readable-stream": "^3.6.0" } }, "sha512-KVgf4XQVrTjhyWmx6cte4RxonPLR9onExufI1jhvw/MQ4BB6IsZD5gT8Lq+u/+pRkWna/6JoHpiQioaqFP5Rzw=="],
|
||||||
|
|
||||||
|
"@esbuild-kit/core-utils/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.18.20", "", { "os": "android", "cpu": "arm" }, "sha512-fyi7TDI/ijKKNZTUJAQqiG5T7YjJXgnzkURqmGj13C6dCqckZBLdl4h7bkhHt/t0WP+zO9/zwroDvANaOqO5Sw=="],
|
||||||
|
|
||||||
|
"@esbuild-kit/core-utils/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.18.20", "", { "os": "android", "cpu": "arm64" }, "sha512-Nz4rJcchGDtENV0eMKUNa6L12zz2zBDXuhj/Vjh18zGqB44Bi7MBMSXjgunJgjRhCmKOjnPuZp4Mb6OKqtMHLQ=="],
|
||||||
|
|
||||||
|
"@esbuild-kit/core-utils/esbuild/@esbuild/android-x64": ["@esbuild/android-x64@0.18.20", "", { "os": "android", "cpu": "x64" }, "sha512-8GDdlePJA8D6zlZYJV/jnrRAi6rOiNaCC/JclcXpB+KIuvfBN4owLtgzY2bsxnx666XjJx2kDPUmnTtR8qKQUg=="],
|
||||||
|
|
||||||
|
"@esbuild-kit/core-utils/esbuild/@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.18.20", "", { "os": "darwin", "cpu": "arm64" }, "sha512-bxRHW5kHU38zS2lPTPOyuyTm+S+eobPUnTNkdJEfAddYgEcll4xkT8DB9d2008DtTbl7uJag2HuE5NZAZgnNEA=="],
|
||||||
|
|
||||||
|
"@esbuild-kit/core-utils/esbuild/@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.18.20", "", { "os": "darwin", "cpu": "x64" }, "sha512-pc5gxlMDxzm513qPGbCbDukOdsGtKhfxD1zJKXjCCcU7ju50O7MeAZ8c4krSJcOIJGFR+qx21yMMVYwiQvyTyQ=="],
|
||||||
|
|
||||||
|
"@esbuild-kit/core-utils/esbuild/@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.18.20", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-yqDQHy4QHevpMAaxhhIwYPMv1NECwOvIpGCZkECn8w2WFHXjEwrBn3CeNIYsibZ/iZEUemj++M26W3cNR5h+Tw=="],
|
||||||
|
|
||||||
|
"@esbuild-kit/core-utils/esbuild/@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.18.20", "", { "os": "freebsd", "cpu": "x64" }, "sha512-tgWRPPuQsd3RmBZwarGVHZQvtzfEBOreNuxEMKFcd5DaDn2PbBxfwLcj4+aenoh7ctXcbXmOQIn8HI6mCSw5MQ=="],
|
||||||
|
|
||||||
|
"@esbuild-kit/core-utils/esbuild/@esbuild/linux-arm": ["@esbuild/linux-arm@0.18.20", "", { "os": "linux", "cpu": "arm" }, "sha512-/5bHkMWnq1EgKr1V+Ybz3s1hWXok7mDFUMQ4cG10AfW3wL02PSZi5kFpYKrptDsgb2WAJIvRcDm+qIvXf/apvg=="],
|
||||||
|
|
||||||
|
"@esbuild-kit/core-utils/esbuild/@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.18.20", "", { "os": "linux", "cpu": "arm64" }, "sha512-2YbscF+UL7SQAVIpnWvYwM+3LskyDmPhe31pE7/aoTMFKKzIc9lLbyGUpmmb8a8AixOL61sQ/mFh3jEjHYFvdA=="],
|
||||||
|
|
||||||
|
"@esbuild-kit/core-utils/esbuild/@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.18.20", "", { "os": "linux", "cpu": "ia32" }, "sha512-P4etWwq6IsReT0E1KHU40bOnzMHoH73aXp96Fs8TIT6z9Hu8G6+0SHSw9i2isWrD2nbx2qo5yUqACgdfVGx7TA=="],
|
||||||
|
|
||||||
|
"@esbuild-kit/core-utils/esbuild/@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.18.20", "", { "os": "linux", "cpu": "none" }, "sha512-nXW8nqBTrOpDLPgPY9uV+/1DjxoQ7DoB2N8eocyq8I9XuqJ7BiAMDMf9n1xZM9TgW0J8zrquIb/A7s3BJv7rjg=="],
|
||||||
|
|
||||||
|
"@esbuild-kit/core-utils/esbuild/@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.18.20", "", { "os": "linux", "cpu": "none" }, "sha512-d5NeaXZcHp8PzYy5VnXV3VSd2D328Zb+9dEq5HE6bw6+N86JVPExrA6O68OPwobntbNJ0pzCpUFZTo3w0GyetQ=="],
|
||||||
|
|
||||||
|
"@esbuild-kit/core-utils/esbuild/@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.18.20", "", { "os": "linux", "cpu": "ppc64" }, "sha512-WHPyeScRNcmANnLQkq6AfyXRFr5D6N2sKgkFo2FqguP44Nw2eyDlbTdZwd9GYk98DZG9QItIiTlFLHJHjxP3FA=="],
|
||||||
|
|
||||||
|
"@esbuild-kit/core-utils/esbuild/@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.18.20", "", { "os": "linux", "cpu": "none" }, "sha512-WSxo6h5ecI5XH34KC7w5veNnKkju3zBRLEQNY7mv5mtBmrP/MjNBCAlsM2u5hDBlS3NGcTQpoBvRzqBcRtpq1A=="],
|
||||||
|
|
||||||
|
"@esbuild-kit/core-utils/esbuild/@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.18.20", "", { "os": "linux", "cpu": "s390x" }, "sha512-+8231GMs3mAEth6Ja1iK0a1sQ3ohfcpzpRLH8uuc5/KVDFneH6jtAJLFGafpzpMRO6DzJ6AvXKze9LfFMrIHVQ=="],
|
||||||
|
|
||||||
|
"@esbuild-kit/core-utils/esbuild/@esbuild/linux-x64": ["@esbuild/linux-x64@0.18.20", "", { "os": "linux", "cpu": "x64" }, "sha512-UYqiqemphJcNsFEskc73jQ7B9jgwjWrSayxawS6UVFZGWrAAtkzjxSqnoclCXxWtfwLdzU+vTpcNYhpn43uP1w=="],
|
||||||
|
|
||||||
|
"@esbuild-kit/core-utils/esbuild/@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.18.20", "", { "os": "none", "cpu": "x64" }, "sha512-iO1c++VP6xUBUmltHZoMtCUdPlnPGdBom6IrO4gyKPFFVBKioIImVooR5I83nTew5UOYrk3gIJhbZh8X44y06A=="],
|
||||||
|
|
||||||
|
"@esbuild-kit/core-utils/esbuild/@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.18.20", "", { "os": "openbsd", "cpu": "x64" }, "sha512-e5e4YSsuQfX4cxcygw/UCPIEP6wbIL+se3sxPdCiMbFLBWu0eiZOJ7WoD+ptCLrmjZBK1Wk7I6D/I3NglUGOxg=="],
|
||||||
|
|
||||||
|
"@esbuild-kit/core-utils/esbuild/@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.18.20", "", { "os": "sunos", "cpu": "x64" }, "sha512-kDbFRFp0YpTQVVrqUd5FTYmWo45zGaXe0X8E1G/LKFC0v8x0vWrhOWSLITcCn63lmZIxfOMXtCfti/RxN/0wnQ=="],
|
||||||
|
|
||||||
|
"@esbuild-kit/core-utils/esbuild/@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.18.20", "", { "os": "win32", "cpu": "arm64" }, "sha512-ddYFR6ItYgoaq4v4JmQQaAI5s7npztfV4Ag6NrhiaW0RrnOXqBkgwZLofVTlq1daVTQNhtI5oieTvkRPfZrePg=="],
|
||||||
|
|
||||||
|
"@esbuild-kit/core-utils/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.18.20", "", { "os": "win32", "cpu": "ia32" }, "sha512-Wv7QBi3ID/rROT08SABTS7eV4hX26sVduqDOTe1MvGMjNd3EjOz4b7zeexIR62GTIEKrfJXKL9LFxTYgkyeu7g=="],
|
||||||
|
|
||||||
|
"@esbuild-kit/core-utils/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.18.20", "", { "os": "win32", "cpu": "x64" }, "sha512-kTdfRcSiDfQca/y9QIkng02avJ+NCaQvrMejlsB3RRv5sE9rRoeBPISaZpKxHELzRxZyLvNts1P27W3wV+8geQ=="],
|
||||||
|
|
||||||
|
"archiver-utils/readable-stream/string_decoder": ["string_decoder@1.1.1", "", { "dependencies": { "safe-buffer": "~5.1.0" } }, "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg=="],
|
||||||
|
|
||||||
|
"duplexer2/readable-stream/string_decoder": ["string_decoder@1.1.1", "", { "dependencies": { "safe-buffer": "~5.1.0" } }, "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg=="],
|
||||||
|
|
||||||
|
"glob/minimatch/brace-expansion": ["brace-expansion@1.1.14", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g=="],
|
||||||
|
|
||||||
|
"jszip/readable-stream/string_decoder": ["string_decoder@1.1.1", "", { "dependencies": { "safe-buffer": "~5.1.0" } }, "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg=="],
|
||||||
|
|
||||||
|
"lazystream/readable-stream/string_decoder": ["string_decoder@1.1.1", "", { "dependencies": { "safe-buffer": "~5.1.0" } }, "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg=="],
|
||||||
|
|
||||||
|
"tsx/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.28.0", "", { "os": "aix", "cpu": "ppc64" }, "sha512-lhRUCeuOyJQURhTxl4WkpFTjIsbDayJHih5kZC1giwE+MhIzAb7mEsQMqMf18rHLsrb5qI1tafG20mLxEWcWlA=="],
|
||||||
|
|
||||||
|
"tsx/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.28.0", "", { "os": "android", "cpu": "arm" }, "sha512-wqh0ByljabXLKHeWXYLqoJ5jKC4XBaw6Hk08OfMrCRd2nP2ZQ5eleDZC41XHyCNgktBGYMbqnrJKq/K/lzPMSQ=="],
|
||||||
|
|
||||||
|
"tsx/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.28.0", "", { "os": "android", "cpu": "arm64" }, "sha512-+WzIXQOSaGs33tLEgYPYe/yQHf0WTU0X42Jca3y8NWMbUVhp7rUnw+vAsRC/QiDrdD31IszMrZy+qwPOPjd+rw=="],
|
||||||
|
|
||||||
|
"tsx/esbuild/@esbuild/android-x64": ["@esbuild/android-x64@0.28.0", "", { "os": "android", "cpu": "x64" }, "sha512-+VJggoaKhk2VNNqVL7f6S189UzShHC/mR9EE8rDdSkdpN0KflSwWY/gWjDrNxxisg8Fp1ZCD9jLMo4m0OUfeUA=="],
|
||||||
|
|
||||||
|
"tsx/esbuild/@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.28.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-0T+A9WZm+bZ84nZBtk1ckYsOvyA3x7e2Acj1KdVfV4/2tdG4fzUp91YHx+GArWLtwqp77pBXVCPn2We7Letr0Q=="],
|
||||||
|
|
||||||
|
"tsx/esbuild/@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.28.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-fyzLm/DLDl/84OCfp2f/XQ4flmORsjU7VKt8HLjvIXChJoFFOIL6pLJPH4Yhd1n1gGFF9mPwtlN5Wf82DZs+LQ=="],
|
||||||
|
|
||||||
|
"tsx/esbuild/@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.28.0", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-l9GeW5UZBT9k9brBYI+0WDffcRxgHQD8ShN2Ur4xWq/NFzUKm3k5lsH4PdaRgb2w7mI9u61nr2gI2mLI27Nh3Q=="],
|
||||||
|
|
||||||
|
"tsx/esbuild/@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.28.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-BXoQai/A0wPO6Es3yFJ7APCiKGc1tdAEOgeTNy3SsB491S3aHn4S4r3e976eUnPdU+NbdtmBuLncYir2tMU9Nw=="],
|
||||||
|
|
||||||
|
"tsx/esbuild/@esbuild/linux-arm": ["@esbuild/linux-arm@0.28.0", "", { "os": "linux", "cpu": "arm" }, "sha512-CjaaREJagqJp7iTaNQjjidaNbCKYcd4IDkzbwwxtSvjI7NZm79qiHc8HqciMddQ6CKvJT6aBd8lO9kN/ZudLlw=="],
|
||||||
|
|
||||||
|
"tsx/esbuild/@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.28.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-RVyzfb3FWsGA55n6WY0MEIEPURL1FcbhFE6BffZEMEekfCzCIMtB5yyDcFnVbTnwk+CLAgTujmV/Lgvih56W+A=="],
|
||||||
|
|
||||||
|
"tsx/esbuild/@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.28.0", "", { "os": "linux", "cpu": "ia32" }, "sha512-KBnSTt1kxl9x70q+ydterVdl+Cn0H18ngRMRCEQfrbqdUuntQQ0LoMZv47uB97NljZFzY6HcfqEZ2SAyIUTQBQ=="],
|
||||||
|
|
||||||
|
"tsx/esbuild/@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.28.0", "", { "os": "linux", "cpu": "none" }, "sha512-zpSlUce1mnxzgBADvxKXX5sl8aYQHo2ezvMNI8I0lbblJtp8V4odlm3Yzlj7gPyt3T8ReksE6bK+pT3WD+aJRg=="],
|
||||||
|
|
||||||
|
"tsx/esbuild/@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.28.0", "", { "os": "linux", "cpu": "none" }, "sha512-2jIfP6mmjkdmeTlsX/9vmdmhBmKADrWqN7zcdtHIeNSCH1SqIoNI63cYsjQR8J+wGa4Y5izRcSHSm8K3QWmk3w=="],
|
||||||
|
|
||||||
|
"tsx/esbuild/@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.28.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-bc0FE9wWeC0WBm49IQMPSPILRocGTQt3j5KPCA8os6VprfuJ7KD+5PzESSrJ6GmPIPJK965ZJHTUlSA6GNYEhg=="],
|
||||||
|
|
||||||
|
"tsx/esbuild/@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.28.0", "", { "os": "linux", "cpu": "none" }, "sha512-SQPZOwoTTT/HXFXQJG/vBX8sOFagGqvZyXcgLA3NhIqcBv1BJU1d46c0rGcrij2B56Z2rNiSLaZOYW5cUk7yLQ=="],
|
||||||
|
|
||||||
|
"tsx/esbuild/@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.28.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-SCfR0HN8CEEjnYnySJTd2cw0k9OHB/YFzt5zgJEwa+wL/T/raGWYMBqwDNAC6dqFKmJYZoQBRfHjgwLHGSrn3Q=="],
|
||||||
|
|
||||||
|
"tsx/esbuild/@esbuild/linux-x64": ["@esbuild/linux-x64@0.28.0", "", { "os": "linux", "cpu": "x64" }, "sha512-us0dSb9iFxIi8srnpl931Nvs65it/Jd2a2K3qs7fz2WfGPHqzfzZTfec7oxZJRNPXPnNYZtanmRc4AL/JwVzHQ=="],
|
||||||
|
|
||||||
|
"tsx/esbuild/@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.28.0", "", { "os": "none", "cpu": "arm64" }, "sha512-CR/RYotgtCKwtftMwJlUU7xCVNg3lMYZ0RzTmAHSfLCXw3NtZtNpswLEj/Kkf6kEL3Gw+BpOekRX0BYCtklhUw=="],
|
||||||
|
|
||||||
|
"tsx/esbuild/@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.28.0", "", { "os": "none", "cpu": "x64" }, "sha512-nU1yhmYutL+fQ71Kxnhg8uEOdC0pwEW9entHykTgEbna2pw2dkbFSMeqjjyHZoCmt8SBkOSvV+yNmm94aUrrqw=="],
|
||||||
|
|
||||||
|
"tsx/esbuild/@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.28.0", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-cXb5vApOsRsxsEl4mcZ1XY3D4DzcoMxR/nnc4IyqYs0rTI8ZKmW6kyyg+11Z8yvgMfAEldKzP7AdP64HnSC/6g=="],
|
||||||
|
|
||||||
|
"tsx/esbuild/@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.28.0", "", { "os": "openbsd", "cpu": "x64" }, "sha512-8wZM2qqtv9UP3mzy7HiGYNH/zjTA355mpeuA+859TyR+e+Tc08IHYpLJuMsfpDJwoLo1ikIJI8jC3GFjnRClzA=="],
|
||||||
|
|
||||||
|
"tsx/esbuild/@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.28.0", "", { "os": "none", "cpu": "arm64" }, "sha512-FLGfyizszcef5C3YtoyQDACyg95+dndv79i2EekILBofh5wpCa1KuBqOWKrEHZg3zrL3t5ouE5jgr94vA+Wb2w=="],
|
||||||
|
|
||||||
|
"tsx/esbuild/@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.28.0", "", { "os": "sunos", "cpu": "x64" }, "sha512-1ZgjUoEdHZZl/YlV76TSCz9Hqj9h9YmMGAgAPYd+q4SicWNX3G5GCyx9uhQWSLcbvPW8Ni7lj4gDa1T40akdlw=="],
|
||||||
|
|
||||||
|
"tsx/esbuild/@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.28.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-Q9StnDmQ/enxnpxCCLSg0oo4+34B9TdXpuyPeTedN/6+iXBJ4J+zwfQI28u/Jl40nOYAxGoNi7mFP40RUtkmUA=="],
|
||||||
|
|
||||||
|
"tsx/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.28.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-zF3ag/gfiCe6U2iczcRzSYJKH1DCI+ByzSENHlM2FcDbEeo5Zd2C86Aq0tKUYAJJ1obRP84ymxIAksZUcdztHA=="],
|
||||||
|
|
||||||
|
"tsx/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.28.0", "", { "os": "win32", "cpu": "x64" }, "sha512-pEl1bO9mfAmIC+tW5btTmrKaujg3zGtUmWNdCw/xs70FBjwAL3o9OEKNHvNmnyylD6ubxUERiEhdsL0xBQ9efw=="],
|
||||||
|
|
||||||
|
"unzipper/readable-stream/string_decoder": ["string_decoder@1.1.1", "", { "dependencies": { "safe-buffer": "~5.1.0" } }, "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg=="],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,3 +5,4 @@ id,name
|
|||||||
283155,Books
|
283155,Books
|
||||||
16310101,Grocery Gourmet Food
|
16310101,Grocery Gourmet Food
|
||||||
599858,Magazine Subscriptions
|
599858,Magazine Subscriptions
|
||||||
|
5174,CDs & Vinyl
|
||||||
|
35
docker-compose.yaml
Normal file
35
docker-compose.yaml
Normal 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
10
drizzle.config.ts
Normal 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!,
|
||||||
|
},
|
||||||
|
});
|
||||||
269
drizzle/0000_adorable_shiver_man.sql
Normal file
269
drizzle/0000_adorable_shiver_man.sql
Normal 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");
|
||||||
23
drizzle/0001_product_distributor_research.sql
Normal file
23
drizzle/0001_product_distributor_research.sql
Normal 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");
|
||||||
2025
drizzle/meta/0000_snapshot.json
Normal file
2025
drizzle/meta/0000_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
20
drizzle/meta/_journal.json
Normal file
20
drizzle/meta/_journal.json
Normal 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
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
23
package.json
23
package.json
@@ -4,24 +4,33 @@
|
|||||||
"type": "module",
|
"type": "module",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"bestsellers": "bun run src/bestsellers-by-category.ts",
|
"bestsellers": "bun run src/categories/bestsellers-by-category.ts",
|
||||||
"monthly-sold": "bun run src/top-monthly-sold-by-category.ts",
|
"monthly-sold": "bun run src/categories/top-monthly-sold-by-category.ts",
|
||||||
|
"mid-range": "bun run src/categories/mid-range-sellers-by-category.ts",
|
||||||
|
"stalker": "bun run src/stalker/stalker.ts",
|
||||||
|
"search-offers": "bun run src/asin-offer-search.ts",
|
||||||
|
"upc": "bun run src/supplier/upc-lookup.ts",
|
||||||
|
"upc-file": "bun run src/supplier/upc-file-analysis.ts",
|
||||||
"start": "bun run src/index.ts",
|
"start": "bun run src/index.ts",
|
||||||
"start:web": "bun --hot src/server.ts",
|
"start:web": "bun --hot src/server.ts",
|
||||||
"build:web": "bun build src/web/index.html --outdir dist",
|
"build:web": "bun build src/web/index.html --outdir dist",
|
||||||
"test": "bun test"
|
"test": "bun test",
|
||||||
|
"db:generate": "drizzle-kit generate",
|
||||||
|
"db:migrate": "drizzle-kit migrate"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/bun": "latest",
|
"@types/bun": "latest",
|
||||||
"@types/react": "^19.2.14",
|
"@types/react": "^19.2.14",
|
||||||
"@types/react-dom": "^19.2.3"
|
"@types/react-dom": "^19.2.3",
|
||||||
},
|
"drizzle-kit": "^0.31.10",
|
||||||
"peerDependencies": {
|
"typescript": "^6.0.3"
|
||||||
"typescript": "^5"
|
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"amazon-sp-api": "^1.2.1",
|
"amazon-sp-api": "^1.2.1",
|
||||||
|
"drizzle-orm": "^0.45.2",
|
||||||
|
"exceljs": "^4.4.0",
|
||||||
"ioredis": "^5.10.1",
|
"ioredis": "^5.10.1",
|
||||||
|
"postgres": "^3.4.9",
|
||||||
"react": "^19.2.0",
|
"react": "^19.2.0",
|
||||||
"react-dom": "^19.2.0",
|
"react-dom": "^19.2.0",
|
||||||
"xlsx": "^0.18.5"
|
"xlsx": "^0.18.5"
|
||||||
|
|||||||
114
src/analysis-pipeline.test.ts
Normal file
114
src/analysis-pipeline.test.ts
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
import { beforeEach, expect, mock, test } from "bun:test";
|
||||||
|
import { processProductChunk } from "./analysis-pipeline.ts";
|
||||||
|
import type { ProductRecord } from "./types.ts";
|
||||||
|
|
||||||
|
const fetchKeepaDataBatchMock = mock(async (asins: string[]) => {
|
||||||
|
return new Map(
|
||||||
|
asins.map((asin) => [
|
||||||
|
asin,
|
||||||
|
{
|
||||||
|
currentPrice: 20,
|
||||||
|
avgPrice90: 18,
|
||||||
|
minPrice90: null,
|
||||||
|
maxPrice90: null,
|
||||||
|
salesRank: 100,
|
||||||
|
salesRankAvg90: null,
|
||||||
|
salesRankDrops30: null,
|
||||||
|
salesRankDrops90: null,
|
||||||
|
sellerCount: 3,
|
||||||
|
amazonIsSeller: false,
|
||||||
|
amazonBuyboxSharePct90d: null,
|
||||||
|
buyBoxSeller: null,
|
||||||
|
buyBoxPrice: null,
|
||||||
|
monthlySold: 50,
|
||||||
|
categoryTree: [],
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const fetchSellabilityBatchMock = mock(async (asins: string[]) => {
|
||||||
|
return new Map(
|
||||||
|
asins.map((asin) => [
|
||||||
|
asin,
|
||||||
|
asin === "B000000002"
|
||||||
|
? {
|
||||||
|
canSell: false,
|
||||||
|
sellabilityStatus: "restricted" as const,
|
||||||
|
sellabilityReason: "Approval required",
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
canSell: true,
|
||||||
|
sellabilityStatus: "available" as const,
|
||||||
|
sellabilityReason: "Available",
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const fetchSpApiPricingAndFeesMock = mock(async () => ({
|
||||||
|
fbaFee: 4,
|
||||||
|
fbmFee: 2,
|
||||||
|
referralFeePercent: 15,
|
||||||
|
estimatedSalePrice: 20,
|
||||||
|
canSell: true,
|
||||||
|
sellabilityStatus: "available" as const,
|
||||||
|
sellabilityReason: "Available",
|
||||||
|
}));
|
||||||
|
|
||||||
|
const analyzeProductsMock = mock(async (products: any[]) =>
|
||||||
|
products.map((product) => ({
|
||||||
|
asin: product.record.asin,
|
||||||
|
verdict: "FBA" as const,
|
||||||
|
confidence: 95,
|
||||||
|
reasoning: "Analyzed",
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
|
||||||
|
const getCacheMock = mock(async () => null);
|
||||||
|
const setCacheMock = mock(async () => undefined);
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
fetchKeepaDataBatchMock.mockClear();
|
||||||
|
fetchSellabilityBatchMock.mockClear();
|
||||||
|
fetchSpApiPricingAndFeesMock.mockClear();
|
||||||
|
analyzeProductsMock.mockClear();
|
||||||
|
getCacheMock.mockClear();
|
||||||
|
setCacheMock.mockClear();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("lead analysis retains restricted input rows as SKIP without LLM analysis", async () => {
|
||||||
|
const products: ProductRecord[] = [
|
||||||
|
{ asin: "B000000001", name: "Available", unitCost: 5 },
|
||||||
|
{ asin: "B000000002", name: "Restricted", unitCost: 6 },
|
||||||
|
];
|
||||||
|
|
||||||
|
const results = await processProductChunk(products, {
|
||||||
|
llmBatchDelayMs: 0,
|
||||||
|
llmRetryDelayMs: 0,
|
||||||
|
dependencies: {
|
||||||
|
fetchKeepaDataBatch: fetchKeepaDataBatchMock,
|
||||||
|
fetchSellabilityBatch: fetchSellabilityBatchMock,
|
||||||
|
fetchSpApiPricingAndFees: fetchSpApiPricingAndFeesMock,
|
||||||
|
analyzeProducts: analyzeProductsMock,
|
||||||
|
getCache: getCacheMock,
|
||||||
|
setCache: setCacheMock,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(results).toHaveLength(2);
|
||||||
|
expect(results.map((result) => result.product.record.asin)).toEqual([
|
||||||
|
"B000000001",
|
||||||
|
"B000000002",
|
||||||
|
]);
|
||||||
|
expect(results.find((result) => result.product.record.asin === "B000000002")?.verdict)
|
||||||
|
.toEqual({
|
||||||
|
asin: "B000000002",
|
||||||
|
verdict: "SKIP",
|
||||||
|
confidence: 100,
|
||||||
|
reasoning: "Approval required",
|
||||||
|
});
|
||||||
|
expect(fetchKeepaDataBatchMock.mock.calls[0]?.[0]).toEqual(["B000000001"]);
|
||||||
|
expect(fetchSpApiPricingAndFeesMock.mock.calls).toHaveLength(1);
|
||||||
|
expect(analyzeProductsMock.mock.calls[0]?.[0]).toHaveLength(1);
|
||||||
|
});
|
||||||
343
src/analysis-pipeline.ts
Normal file
343
src/analysis-pipeline.ts
Normal file
@@ -0,0 +1,343 @@
|
|||||||
|
import { fetchKeepaDataBatch } from "./integrations/keepa.ts";
|
||||||
|
import { fetchSellabilityBatch, fetchSpApiPricingAndFees } from "./integrations/sp-api.ts";
|
||||||
|
import { getCache, setCache } from "./integrations/cache.ts";
|
||||||
|
import { analyzeProducts } from "./integrations/llm.ts";
|
||||||
|
import type {
|
||||||
|
AnalysisResult,
|
||||||
|
EnrichedProduct,
|
||||||
|
KeepaData,
|
||||||
|
ProductRecord,
|
||||||
|
SellabilityInfo,
|
||||||
|
SpApiData,
|
||||||
|
} from "./types.ts";
|
||||||
|
|
||||||
|
export const DEFAULT_LLM_BATCH_SIZE = 5;
|
||||||
|
export const DEFAULT_PRICING_CONCURRENCY = 5;
|
||||||
|
|
||||||
|
export type SellabilityFilter = "available" | "all";
|
||||||
|
|
||||||
|
type AnalysisPipelineDependencies = {
|
||||||
|
fetchKeepaDataBatch: typeof fetchKeepaDataBatch;
|
||||||
|
fetchSellabilityBatch: typeof fetchSellabilityBatch;
|
||||||
|
fetchSpApiPricingAndFees: typeof fetchSpApiPricingAndFees;
|
||||||
|
getCache: typeof getCache;
|
||||||
|
setCache: typeof setCache;
|
||||||
|
analyzeProducts: typeof analyzeProducts;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AnalysisPipelineOptions = {
|
||||||
|
llmBatchSize?: number;
|
||||||
|
pricingConcurrency?: number;
|
||||||
|
llmBatchDelayMs?: number;
|
||||||
|
llmRetryDelayMs?: number;
|
||||||
|
sellability?: SellabilityFilter;
|
||||||
|
useClaude?: boolean;
|
||||||
|
dependencies?: Partial<AnalysisPipelineDependencies>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function chunkArray<T>(items: T[], chunkSize: number): T[][] {
|
||||||
|
const chunks: T[][] = [];
|
||||||
|
for (let i = 0; i < items.length; i += chunkSize) {
|
||||||
|
chunks.push(items.slice(i, i + chunkSize));
|
||||||
|
}
|
||||||
|
return chunks;
|
||||||
|
}
|
||||||
|
|
||||||
|
function wait(ms: number): Promise<void> {
|
||||||
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
|
}
|
||||||
|
|
||||||
|
function unknownSpApiData(reason: string): SpApiData {
|
||||||
|
return {
|
||||||
|
fbaFee: 5.0,
|
||||||
|
fbmFee: 1.5,
|
||||||
|
referralFeePercent: 15,
|
||||||
|
estimatedSalePrice: 0,
|
||||||
|
canSell: null,
|
||||||
|
sellabilityStatus: "unknown",
|
||||||
|
sellabilityReason: reason,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function processProductChunk(
|
||||||
|
products: ProductRecord[],
|
||||||
|
options: AnalysisPipelineOptions = {},
|
||||||
|
): Promise<AnalysisResult[]> {
|
||||||
|
const llmBatchSize = options.llmBatchSize ?? DEFAULT_LLM_BATCH_SIZE;
|
||||||
|
const pricingConcurrency = Math.max(
|
||||||
|
1,
|
||||||
|
options.pricingConcurrency ?? DEFAULT_PRICING_CONCURRENCY,
|
||||||
|
);
|
||||||
|
const llmBatchDelayMs = Math.max(0, options.llmBatchDelayMs ?? 5_000);
|
||||||
|
const llmRetryDelayMs = Math.max(0, options.llmRetryDelayMs ?? 10_000);
|
||||||
|
const sellabilityFilter = options.sellability ?? "available";
|
||||||
|
const useClaude = options.useClaude === true;
|
||||||
|
const dependencies: AnalysisPipelineDependencies = {
|
||||||
|
fetchKeepaDataBatch,
|
||||||
|
fetchSellabilityBatch,
|
||||||
|
fetchSpApiPricingAndFees,
|
||||||
|
getCache,
|
||||||
|
setCache,
|
||||||
|
analyzeProducts,
|
||||||
|
...options.dependencies,
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log(`\nChecking cache for ${products.length} products...`);
|
||||||
|
const cached = new Map<string, EnrichedProduct>();
|
||||||
|
const excludedCached = new Map<string, EnrichedProduct>();
|
||||||
|
const uncachedProducts: ProductRecord[] = [];
|
||||||
|
|
||||||
|
for (const p of products) {
|
||||||
|
const hit = await dependencies.getCache(p.asin);
|
||||||
|
if (hit) {
|
||||||
|
const currentSourceProduct = { ...hit, record: p };
|
||||||
|
if (
|
||||||
|
sellabilityFilter === "all" ||
|
||||||
|
hit.spApi.sellabilityStatus === "available"
|
||||||
|
) {
|
||||||
|
console.log(` [cache hit] ${p.asin}`);
|
||||||
|
cached.set(p.asin, currentSourceProduct);
|
||||||
|
} else {
|
||||||
|
excludedCached.set(p.asin, currentSourceProduct);
|
||||||
|
console.log(
|
||||||
|
` [exclude cached] ${p.asin} - status=${hit.spApi.sellabilityStatus}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
uncachedProducts.push(p);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`${cached.size} cached available, ${excludedCached.size} cached excluded, ${uncachedProducts.length} to fetch`,
|
||||||
|
);
|
||||||
|
|
||||||
|
const sellabilityMap = new Map<string, SellabilityInfo>();
|
||||||
|
const availableProducts: ProductRecord[] = [];
|
||||||
|
const unavailableProducts: ProductRecord[] = [];
|
||||||
|
|
||||||
|
if (uncachedProducts.length > 0) {
|
||||||
|
console.log(
|
||||||
|
`\nChecking sellability for ${uncachedProducts.length} ASINs...`,
|
||||||
|
);
|
||||||
|
const sellResults = await dependencies.fetchSellabilityBatch(
|
||||||
|
uncachedProducts.map((p) => p.asin),
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const p of uncachedProducts) {
|
||||||
|
const info = sellResults.get(p.asin) ?? {
|
||||||
|
canSell: null,
|
||||||
|
sellabilityStatus: "unknown" as const,
|
||||||
|
sellabilityReason: "Sellability check returned no result",
|
||||||
|
};
|
||||||
|
sellabilityMap.set(p.asin, info);
|
||||||
|
|
||||||
|
if (
|
||||||
|
sellabilityFilter === "all" ||
|
||||||
|
info.sellabilityStatus === "available"
|
||||||
|
) {
|
||||||
|
availableProducts.push(p);
|
||||||
|
console.log(
|
||||||
|
` [available] ${p.asin} - status=${info.sellabilityStatus}`,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
unavailableProducts.push(p);
|
||||||
|
console.log(
|
||||||
|
` [exclude] ${p.asin} - status=${info.sellabilityStatus}, reason=${info.sellabilityReason ?? "n/a"}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sellabilityFilter === "all") {
|
||||||
|
console.log(
|
||||||
|
`\nSellability gate disabled: including all ${availableProducts.length} products`,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
console.log(
|
||||||
|
`\nSellability gate: ${availableProducts.length} available, ${unavailableProducts.length} excluded`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let keepaResults = new Map<string, KeepaData>();
|
||||||
|
if (availableProducts.length > 0) {
|
||||||
|
console.log(`\nFetching ${availableProducts.length} ASINs from Keepa...`);
|
||||||
|
try {
|
||||||
|
keepaResults = await dependencies.fetchKeepaDataBatch(
|
||||||
|
availableProducts.map((p) => p.asin),
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
console.warn(`Keepa batch fetch failed: ${err}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`\nFetching pricing & fees for ${availableProducts.length} ASINs...`,
|
||||||
|
);
|
||||||
|
const spApiResults = new Map<string, SpApiData>();
|
||||||
|
const pricingQueue = [...availableProducts];
|
||||||
|
let pricingDone = 0;
|
||||||
|
|
||||||
|
async function fetchNextPricing(): Promise<void> {
|
||||||
|
while (pricingQueue.length > 0) {
|
||||||
|
const p = pricingQueue.shift();
|
||||||
|
if (!p) return;
|
||||||
|
|
||||||
|
const sellability = sellabilityMap.get(p.asin) ?? {
|
||||||
|
canSell: null,
|
||||||
|
sellabilityStatus: "unknown" as const,
|
||||||
|
sellabilityReason: "Sellability check returned no result",
|
||||||
|
};
|
||||||
|
const spApi = await dependencies.fetchSpApiPricingAndFees(
|
||||||
|
p.asin,
|
||||||
|
sellability,
|
||||||
|
);
|
||||||
|
|
||||||
|
const keepa = keepaResults.get(p.asin);
|
||||||
|
if (keepa?.currentPrice && spApi.estimatedSalePrice === 0) {
|
||||||
|
spApi.estimatedSalePrice = keepa.currentPrice;
|
||||||
|
}
|
||||||
|
|
||||||
|
spApiResults.set(p.asin, spApi);
|
||||||
|
pricingDone++;
|
||||||
|
if (pricingDone % 10 === 0 || pricingDone === availableProducts.length) {
|
||||||
|
console.log(
|
||||||
|
` [pricing] ${pricingDone}/${availableProducts.length} fetched`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const pricingWorkers = Array.from(
|
||||||
|
{ length: Math.min(pricingConcurrency, availableProducts.length || 1) },
|
||||||
|
() => fetchNextPricing(),
|
||||||
|
);
|
||||||
|
await Promise.all(pricingWorkers);
|
||||||
|
|
||||||
|
console.log(`\nEnriching products...`);
|
||||||
|
const enriched: EnrichedProduct[] = [];
|
||||||
|
const availableAsins = new Set(availableProducts.map((ap) => ap.asin));
|
||||||
|
|
||||||
|
for (const p of products) {
|
||||||
|
const excludedCachedProduct = excludedCached.get(p.asin);
|
||||||
|
if (excludedCachedProduct) {
|
||||||
|
enriched.push({ ...excludedCachedProduct, record: p });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const cachedProduct = cached.get(p.asin);
|
||||||
|
if (cachedProduct) {
|
||||||
|
enriched.push({ ...cachedProduct, record: p });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!availableAsins.has(p.asin)) {
|
||||||
|
const sellability = sellabilityMap.get(p.asin);
|
||||||
|
if (sellability) {
|
||||||
|
enriched.push({
|
||||||
|
record: p,
|
||||||
|
keepa: null,
|
||||||
|
spApi: {
|
||||||
|
...unknownSpApiData(
|
||||||
|
sellability.sellabilityReason ?? "Product is not available",
|
||||||
|
),
|
||||||
|
...sellability,
|
||||||
|
},
|
||||||
|
fetchedAt: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const keepa = keepaResults.get(p.asin) ?? null;
|
||||||
|
const spApi =
|
||||||
|
spApiResults.get(p.asin) ?? unknownSpApiData("SP-API data missing");
|
||||||
|
|
||||||
|
const product: EnrichedProduct = {
|
||||||
|
record: p,
|
||||||
|
keepa,
|
||||||
|
spApi,
|
||||||
|
fetchedAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
|
||||||
|
await dependencies.setCache(p.asin, product);
|
||||||
|
enriched.push(product);
|
||||||
|
}
|
||||||
|
|
||||||
|
const resultsByProduct = new Map<EnrichedProduct, AnalysisResult>();
|
||||||
|
const llmProducts: EnrichedProduct[] = [];
|
||||||
|
for (const product of enriched) {
|
||||||
|
if (
|
||||||
|
sellabilityFilter !== "all" &&
|
||||||
|
product.spApi.sellabilityStatus !== "available"
|
||||||
|
) {
|
||||||
|
resultsByProduct.set(product, {
|
||||||
|
product,
|
||||||
|
verdict: {
|
||||||
|
asin: product.record.asin,
|
||||||
|
verdict: "SKIP",
|
||||||
|
confidence: 100,
|
||||||
|
reasoning:
|
||||||
|
product.spApi.sellabilityReason ??
|
||||||
|
`Sellability status: ${product.spApi.sellabilityStatus}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
llmProducts.push(product);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`\nAnalyzing ${llmProducts.length} products via LLM (batch size: ${llmBatchSize})...\n`,
|
||||||
|
);
|
||||||
|
|
||||||
|
for (let i = 0; i < llmProducts.length; i += llmBatchSize) {
|
||||||
|
const batch = llmProducts.slice(i, i + llmBatchSize);
|
||||||
|
const batchNum = Math.floor(i / llmBatchSize) + 1;
|
||||||
|
const totalBatches = Math.ceil(llmProducts.length / llmBatchSize);
|
||||||
|
console.log(` LLM batch ${batchNum}/${totalBatches}...`);
|
||||||
|
|
||||||
|
if (i > 0 && llmBatchDelayMs > 0) {
|
||||||
|
await wait(llmBatchDelayMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
let verdicts;
|
||||||
|
try {
|
||||||
|
verdicts = await dependencies.analyzeProducts(batch, {
|
||||||
|
ignoreSellability: sellabilityFilter === "all",
|
||||||
|
useClaude,
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
if (llmRetryDelayMs > 0) {
|
||||||
|
await wait(llmRetryDelayMs);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
verdicts = await dependencies.analyzeProducts(batch, {
|
||||||
|
ignoreSellability: sellabilityFilter === "all",
|
||||||
|
useClaude,
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
verdicts = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let j = 0; j < batch.length; j++) {
|
||||||
|
const enrichedProduct = batch[j];
|
||||||
|
if (!enrichedProduct) continue;
|
||||||
|
|
||||||
|
resultsByProduct.set(enrichedProduct, {
|
||||||
|
product: enrichedProduct,
|
||||||
|
verdict: verdicts?.[j] ?? {
|
||||||
|
asin: enrichedProduct.record.asin,
|
||||||
|
verdict: "SKIP",
|
||||||
|
confidence: 0,
|
||||||
|
reasoning: "LLM analysis failed",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return enriched
|
||||||
|
.map((product) => resultsByProduct.get(product))
|
||||||
|
.filter((result): result is AnalysisResult => result !== undefined);
|
||||||
|
}
|
||||||
134
src/asin-offer-search.ts
Normal file
134
src/asin-offer-search.ts
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
import { searchProductOffers, type SearxngOfferSearchResult } from "./integrations/searxng.ts";
|
||||||
|
|
||||||
|
type CliArgs = {
|
||||||
|
query: string;
|
||||||
|
json: boolean;
|
||||||
|
provider?: "serpapi" | "google-custom-search" | "searxng";
|
||||||
|
categories?: string;
|
||||||
|
engines?: string;
|
||||||
|
limit?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
function readFlagValue(args: string[], flag: string): string | undefined {
|
||||||
|
const equalsArg = args.find((arg) => arg.startsWith(`${flag}=`));
|
||||||
|
if (equalsArg) return equalsArg.slice(flag.length + 1);
|
||||||
|
|
||||||
|
const index = args.indexOf(flag);
|
||||||
|
return index === -1 ? undefined : args[index + 1];
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseArgs(args: string[]): CliArgs {
|
||||||
|
const json = args.includes("--json");
|
||||||
|
const shopping = args.includes("--shopping");
|
||||||
|
const providerRaw = readFlagValue(args, "--provider");
|
||||||
|
const engineRaw = readFlagValue(args, "--engine");
|
||||||
|
const categoryRaw = readFlagValue(args, "--category");
|
||||||
|
const limitRaw = readFlagValue(args, "--limit");
|
||||||
|
const limit = limitRaw == null ? undefined : Number(limitRaw);
|
||||||
|
const categories = categoryRaw ?? (shopping ? "shopping" : undefined);
|
||||||
|
const provider = normalizeProvider(providerRaw);
|
||||||
|
|
||||||
|
const queryParts = args.filter((arg, index) => {
|
||||||
|
if (arg.startsWith("--")) return false;
|
||||||
|
const previous = args[index - 1];
|
||||||
|
return (
|
||||||
|
previous !== "--limit" &&
|
||||||
|
previous !== "--category" &&
|
||||||
|
previous !== "--engine" &&
|
||||||
|
previous !== "--provider"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
const query = queryParts.join(" ").trim();
|
||||||
|
|
||||||
|
if (!query) {
|
||||||
|
console.error(
|
||||||
|
'Usage: bun run search-offers "product search terms" [--limit 10] [--provider serpapi|google-custom-search|searxng] [--json]',
|
||||||
|
);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
limitRaw != null &&
|
||||||
|
(limit == null || !Number.isInteger(limit) || limit <= 0)
|
||||||
|
) {
|
||||||
|
console.error("--limit must be a positive integer.");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
query,
|
||||||
|
json,
|
||||||
|
provider,
|
||||||
|
categories,
|
||||||
|
engines: engineRaw,
|
||||||
|
limit,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function printTable(results: SearxngOfferSearchResult[]): void {
|
||||||
|
if (results.length === 0) {
|
||||||
|
console.log("No offer results found.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.table(
|
||||||
|
results.map((result) => ({
|
||||||
|
Rank: result.rank,
|
||||||
|
Score: result.score,
|
||||||
|
ASIN: result.matchedAsin ?? "",
|
||||||
|
Price: formatPrice(result),
|
||||||
|
"Price Label": result.detectedPriceLabel ?? "",
|
||||||
|
Domain: result.domain,
|
||||||
|
Title: result.title,
|
||||||
|
URL: result.url,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main(): Promise<void> {
|
||||||
|
const args = parseArgs(process.argv.slice(2));
|
||||||
|
const options = {
|
||||||
|
maxResults: args.limit,
|
||||||
|
provider: args.provider,
|
||||||
|
categories: args.categories,
|
||||||
|
engines: args.engines,
|
||||||
|
};
|
||||||
|
const results = await searchProductOffers(args.query, options);
|
||||||
|
|
||||||
|
if (args.json) {
|
||||||
|
console.log(JSON.stringify(results, null, 2));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
printTable(results);
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeProvider(
|
||||||
|
value: string | undefined,
|
||||||
|
): "serpapi" | "google-custom-search" | "searxng" | undefined {
|
||||||
|
if (value == null) return undefined;
|
||||||
|
const provider = value.trim().toLowerCase();
|
||||||
|
if (provider === "serpapi" || provider === "google-shopping") {
|
||||||
|
return "serpapi";
|
||||||
|
}
|
||||||
|
if (provider === "google-custom-search") {
|
||||||
|
return "google-custom-search";
|
||||||
|
}
|
||||||
|
if (provider === "searxng") return provider;
|
||||||
|
console.error("--provider must be one of: serpapi, google-custom-search, searxng");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatPrice(result: SearxngOfferSearchResult): string {
|
||||||
|
if (result.detectedPrice == null) return "";
|
||||||
|
if (result.detectedPriceText) return result.detectedPriceText;
|
||||||
|
const currency = result.detectedPriceCurrency ?? "USD";
|
||||||
|
return currency === "USD"
|
||||||
|
? `$${result.detectedPrice}`
|
||||||
|
: `${currency} ${result.detectedPrice}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((err) => {
|
||||||
|
console.error(`Search failed: ${err instanceof Error ? err.message : err}`);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
13
src/asin.test.ts
Normal file
13
src/asin.test.ts
Normal 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
14
src/asin.ts
Normal 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;
|
||||||
|
}
|
||||||
@@ -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,7 +149,8 @@ test("processCategory function test", async () => {
|
|||||||
childCount: 0,
|
childCount: 0,
|
||||||
};
|
};
|
||||||
|
|
||||||
const runId = await insertCategoryRunSummary(db, {
|
const runId = await insertCategoryRunSummary(
|
||||||
|
{
|
||||||
categoryId: mockCategory.id,
|
categoryId: mockCategory.id,
|
||||||
categoryLabel: mockCategory.label,
|
categoryLabel: mockCategory.label,
|
||||||
topAsinsChecked: 0,
|
topAsinsChecked: 0,
|
||||||
@@ -150,28 +161,22 @@ test("processCategory function test", async () => {
|
|||||||
status: "running",
|
status: "running",
|
||||||
error: "",
|
error: "",
|
||||||
results: [],
|
results: [],
|
||||||
}, new Date().toISOString());
|
},
|
||||||
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);
|
|
||||||
});
|
});
|
||||||
@@ -1,9 +1,14 @@
|
|||||||
import { existsSync, mkdirSync, readFileSync } from "node:fs";
|
import { existsSync, mkdirSync, readFileSync } from "node:fs";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { type Database, getDb, initDb } from "./database.ts";
|
import { normalizeAsin } from "../asin.ts";
|
||||||
import { config } from "./config.ts";
|
import {
|
||||||
import { analyzeProducts } from "./llm.ts";
|
createCategoryRun,
|
||||||
import { fetchSellabilityBatch, fetchSpApiPricingAndFees } from "./sp-api.ts";
|
persistLlmResults,
|
||||||
|
updateCategoryRun,
|
||||||
|
} from "../db/persistence.ts";
|
||||||
|
import { config } from "../config.ts";
|
||||||
|
import { analyzeProducts } from "../integrations/llm.ts";
|
||||||
|
import { fetchSellabilityBatch, fetchSpApiPricingAndFees } from "../integrations/sp-api.ts";
|
||||||
import type {
|
import type {
|
||||||
AnalysisResult,
|
AnalysisResult,
|
||||||
EnrichedProduct,
|
EnrichedProduct,
|
||||||
@@ -12,8 +17,7 @@ import type {
|
|||||||
ProductRecord,
|
ProductRecord,
|
||||||
SellabilityInfo,
|
SellabilityInfo,
|
||||||
SpApiData,
|
SpApiData,
|
||||||
} from "./types.ts";
|
} from "../types.ts";
|
||||||
|
|
||||||
|
|
||||||
type CategoryInfo = {
|
type CategoryInfo = {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -27,6 +31,7 @@ type ParsedArgs = {
|
|||||||
categoryLimit: number;
|
categoryLimit: number;
|
||||||
perCategoryTop: number;
|
perCategoryTop: number;
|
||||||
blacklistFile: string;
|
blacklistFile: string;
|
||||||
|
useClaude: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
type CategoryRunSummary = {
|
type CategoryRunSummary = {
|
||||||
@@ -45,6 +50,8 @@ type CategoryRunSummary = {
|
|||||||
|
|
||||||
const KEEPA_BASE = "https://api.keepa.com";
|
const KEEPA_BASE = "https://api.keepa.com";
|
||||||
const DOMAIN_US = 1;
|
const DOMAIN_US = 1;
|
||||||
|
const AMAZON_US_SELLER_ID = "ATVPDKIKX0DER";
|
||||||
|
const KEEPA_MINUTES_OFFSET = 21_564_000;
|
||||||
const DEFAULT_CATEGORY_LIMIT = 32;
|
const DEFAULT_CATEGORY_LIMIT = 32;
|
||||||
const DEFAULT_PER_CATEGORY_TOP = 100;
|
const DEFAULT_PER_CATEGORY_TOP = 100;
|
||||||
const SELLABILITY_BATCH_SIZE = 60;
|
const SELLABILITY_BATCH_SIZE = 60;
|
||||||
@@ -71,6 +78,7 @@ function log(
|
|||||||
|
|
||||||
function parseArgs(): ParsedArgs {
|
function parseArgs(): ParsedArgs {
|
||||||
const args = process.argv.slice(2);
|
const args = process.argv.slice(2);
|
||||||
|
const useClaude = hasFlag(args, "--claude");
|
||||||
const outputDir =
|
const outputDir =
|
||||||
readFlagValue(args, "--out-dir") ?? path.join(process.cwd(), "output");
|
readFlagValue(args, "--out-dir") ?? path.join(process.cwd(), "output");
|
||||||
const blacklistFile =
|
const blacklistFile =
|
||||||
@@ -99,9 +107,14 @@ function parseArgs(): ParsedArgs {
|
|||||||
categoryLimit,
|
categoryLimit,
|
||||||
perCategoryTop,
|
perCategoryTop,
|
||||||
blacklistFile,
|
blacklistFile,
|
||||||
|
useClaude,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function hasFlag(args: string[], flag: string): boolean {
|
||||||
|
return args.includes(flag);
|
||||||
|
}
|
||||||
|
|
||||||
function readFlagValue(args: string[], flag: string): string | undefined {
|
function readFlagValue(args: string[], flag: string): string | undefined {
|
||||||
const idx = args.indexOf(flag);
|
const idx = args.indexOf(flag);
|
||||||
if (idx === -1) return undefined;
|
if (idx === -1) return undefined;
|
||||||
@@ -117,7 +130,7 @@ function printUsageAndExit(message: string): never {
|
|||||||
"error",
|
"error",
|
||||||
[
|
[
|
||||||
"Usage:",
|
"Usage:",
|
||||||
" bun run src/bestsellers-by-category.ts [--category-limit 32] [--per-category-top 100] [--out-dir output] [--blacklist-file category-blacklist.csv]",
|
" bun run src/bestsellers-by-category.ts [--category-limit 32] [--per-category-top 100] [--out-dir output] [--blacklist-file category-blacklist.csv] [--claude]",
|
||||||
"",
|
"",
|
||||||
"Flow:",
|
"Flow:",
|
||||||
" 1) Discover categories and round-robin selection.",
|
" 1) Discover categories and round-robin selection.",
|
||||||
@@ -131,160 +144,37 @@ 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<CategoryRunSummary, "topAsinsChecked" | "availableAsins" | "fba" | "fbm" | "skip" | "status" | "error">,
|
summary: Pick<
|
||||||
|
CategoryRunSummary,
|
||||||
|
| "topAsinsChecked"
|
||||||
|
| "availableAsins"
|
||||||
|
| "fba"
|
||||||
|
| "fbm"
|
||||||
|
| "skip"
|
||||||
|
| "status"
|
||||||
|
| "error"
|
||||||
|
>,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
db.run(
|
await updateCategoryRun(runId, summary);
|
||||||
`
|
|
||||||
UPDATE category_analysis_runs
|
|
||||||
SET
|
|
||||||
top_asins_checked = ?,
|
|
||||||
available_asins = ?,
|
|
||||||
fba_count = ?,
|
|
||||||
fbm_count = ?,
|
|
||||||
skip_count = ?,
|
|
||||||
status = ?,
|
|
||||||
error_message = ?
|
|
||||||
WHERE id = ?
|
|
||||||
`,
|
|
||||||
[
|
|
||||||
summary.topAsinsChecked,
|
|
||||||
summary.availableAsins,
|
|
||||||
summary.fba,
|
|
||||||
summary.fbm,
|
|
||||||
summary.skip,
|
|
||||||
summary.status,
|
|
||||||
summary.error,
|
|
||||||
runId,
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function insertProductAnalysisResults(
|
export async function insertProductAnalysisResults(
|
||||||
db: Database,
|
|
||||||
runId: number,
|
runId: number,
|
||||||
results: AnalysisResult[],
|
results: AnalysisResult[],
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
if (results.length === 0) {
|
if (results.length === 0) return;
|
||||||
return;
|
await persistLlmResults(runId, results, {
|
||||||
}
|
source: "category_analysis",
|
||||||
|
metadataSource: "catalog",
|
||||||
const insertStmt = db.prepare(`
|
});
|
||||||
INSERT INTO product_analysis_results (
|
|
||||||
asin, run_id, name, brand, category, unit_cost,
|
|
||||||
current_price, avg_price_90d, avg_price_90d_sheet,
|
|
||||||
selling_price_sheet, sales_rank, sales_rank_avg_90d,
|
|
||||||
seller_count, monthly_sold, rank_drops_30d, rank_drops_90d,
|
|
||||||
fba_fee, fbm_fee, referral_percent, can_sell,
|
|
||||||
sellability_status, sellability_reason,
|
|
||||||
verdict, confidence, reasoning, fetched_at
|
|
||||||
) VALUES (
|
|
||||||
?, ?, ?, ?, ?, ?, ?, ?, ?, ?,
|
|
||||||
?, ?, ?, ?, ?, ?, ?, ?, ?, ?,
|
|
||||||
?, ?, ?, ?, ?, ?
|
|
||||||
)
|
|
||||||
ON CONFLICT(asin) DO UPDATE SET
|
|
||||||
run_id = excluded.run_id,
|
|
||||||
name = excluded.name,
|
|
||||||
brand = excluded.brand,
|
|
||||||
category = excluded.category,
|
|
||||||
unit_cost = excluded.unit_cost,
|
|
||||||
current_price = excluded.current_price,
|
|
||||||
avg_price_90d = excluded.avg_price_90d,
|
|
||||||
avg_price_90d_sheet = excluded.avg_price_90d_sheet,
|
|
||||||
selling_price_sheet = excluded.selling_price_sheet,
|
|
||||||
sales_rank = excluded.sales_rank,
|
|
||||||
sales_rank_avg_90d = excluded.sales_rank_avg_90d,
|
|
||||||
seller_count = excluded.seller_count,
|
|
||||||
monthly_sold = excluded.monthly_sold,
|
|
||||||
rank_drops_30d = excluded.rank_drops_30d,
|
|
||||||
rank_drops_90d = excluded.rank_drops_90d,
|
|
||||||
fba_fee = excluded.fba_fee,
|
|
||||||
fbm_fee = excluded.fbm_fee,
|
|
||||||
referral_percent = excluded.referral_percent,
|
|
||||||
can_sell = excluded.can_sell,
|
|
||||||
sellability_status = excluded.sellability_status,
|
|
||||||
sellability_reason = excluded.sellability_reason,
|
|
||||||
verdict = excluded.verdict,
|
|
||||||
confidence = excluded.confidence,
|
|
||||||
reasoning = excluded.reasoning,
|
|
||||||
fetched_at = excluded.fetched_at;
|
|
||||||
`);
|
|
||||||
|
|
||||||
db.transaction((resultsBatch: AnalysisResult[]) => {
|
|
||||||
for (const r of resultsBatch) {
|
|
||||||
const price =
|
|
||||||
r.product.keepa?.currentPrice ??
|
|
||||||
r.product.record.sellingPriceFromSheet ??
|
|
||||||
r.product.spApi.estimatedSalePrice;
|
|
||||||
const rank = r.product.keepa?.salesRank ?? r.product.record.amazonRank;
|
|
||||||
|
|
||||||
insertStmt.run(
|
|
||||||
r.product.record.asin,
|
|
||||||
runId,
|
|
||||||
r.product.record.name,
|
|
||||||
r.product.record.brand ?? null,
|
|
||||||
r.product.record.category ??
|
|
||||||
r.product.keepa?.categoryTree?.join(" > ") ??
|
|
||||||
null,
|
|
||||||
r.product.record.unitCost ?? null,
|
|
||||||
price ?? null,
|
|
||||||
r.product.keepa?.avgPrice90 ?? null,
|
|
||||||
r.product.record.avgPrice90FromSheet ?? null,
|
|
||||||
r.product.record.sellingPriceFromSheet ?? null,
|
|
||||||
rank ?? null,
|
|
||||||
r.product.keepa?.salesRankAvg90 ?? null,
|
|
||||||
r.product.keepa?.sellerCount ?? null,
|
|
||||||
r.product.keepa?.monthlySold ?? null,
|
|
||||||
r.product.keepa?.salesRankDrops30 ?? null,
|
|
||||||
r.product.keepa?.salesRankDrops90 ?? null,
|
|
||||||
r.product.spApi.fbaFee ?? null,
|
|
||||||
r.product.spApi.fbmFee ?? null,
|
|
||||||
r.product.spApi.referralFeePercent ?? null,
|
|
||||||
r.product.spApi.canSell == null
|
|
||||||
? "unknown"
|
|
||||||
: r.product.spApi.canSell
|
|
||||||
? "yes"
|
|
||||||
: "no",
|
|
||||||
r.product.spApi.sellabilityStatus ?? null,
|
|
||||||
r.product.spApi.sellabilityReason ?? null,
|
|
||||||
r.verdict.verdict,
|
|
||||||
r.verdict.confidence,
|
|
||||||
r.verdict.reasoning ?? null,
|
|
||||||
r.product.fetchedAt,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
})(results); // Execute the transaction with the results batch
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function loadCategoryBlacklist(filePath: string): Set<number> {
|
function loadCategoryBlacklist(filePath: string): Set<number> {
|
||||||
@@ -620,7 +510,7 @@ async function discoverCategories(
|
|||||||
maxCategories: number,
|
maxCategories: number,
|
||||||
): Promise<CategoryInfo[]> {
|
): Promise<CategoryInfo[]> {
|
||||||
const data = await keepaGetJson(
|
const data = await keepaGetJson(
|
||||||
`/category?key=${encodeURIComponent(config.keepaApiKey)}&domain=${DOMAIN_US}&category=0`,
|
`/category?key=${encodeURIComponent(config.keepaApiKey)}&domain=${DOMAIN_US}&category=0&parents=0`,
|
||||||
);
|
);
|
||||||
|
|
||||||
const categories = normalizeCategoryList(data);
|
const categories = normalizeCategoryList(data);
|
||||||
@@ -666,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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -776,6 +670,14 @@ function parseKeepaProduct(product: Record<string, any>): KeepaData {
|
|||||||
const monthlySold =
|
const monthlySold =
|
||||||
pickKeepaNumber(product.monthlySold, stats?.monthlySold) ??
|
pickKeepaNumber(product.monthlySold, stats?.monthlySold) ??
|
||||||
salesRankDrops30;
|
salesRankDrops30;
|
||||||
|
const amazonIsSeller = resolveAmazonIsSeller(product, stats, csv);
|
||||||
|
const amazonBuyboxSharePct90d =
|
||||||
|
extractAmazonBuyboxSharePct90d(product, stats) ??
|
||||||
|
computeAmazonBuyBoxSharePctFromHistory(
|
||||||
|
product.buyBoxSellerIdHistory,
|
||||||
|
90,
|
||||||
|
new Set([AMAZON_US_SELLER_ID]),
|
||||||
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
currentPrice: extractCurrentPrice(csv),
|
currentPrice: extractCurrentPrice(csv),
|
||||||
@@ -787,6 +689,8 @@ function parseKeepaProduct(product: Record<string, any>): KeepaData {
|
|||||||
salesRankDrops30,
|
salesRankDrops30,
|
||||||
salesRankDrops90,
|
salesRankDrops90,
|
||||||
sellerCount: stats?.current?.[11] ?? null,
|
sellerCount: stats?.current?.[11] ?? null,
|
||||||
|
amazonIsSeller,
|
||||||
|
amazonBuyboxSharePct90d,
|
||||||
buyBoxSeller: product.buyBoxSellerId ?? null,
|
buyBoxSeller: product.buyBoxSellerId ?? null,
|
||||||
buyBoxPrice: stats?.current?.[18] != null ? stats.current[18] / 100 : null,
|
buyBoxPrice: stats?.current?.[18] != null ? stats.current[18] / 100 : null,
|
||||||
monthlySold,
|
monthlySold,
|
||||||
@@ -795,6 +699,108 @@ function parseKeepaProduct(product: Record<string, any>): KeepaData {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resolveAmazonIsSeller(
|
||||||
|
product: Record<string, any>,
|
||||||
|
stats: Record<string, any> | undefined,
|
||||||
|
csv: number[][] | undefined,
|
||||||
|
): boolean | null {
|
||||||
|
if (typeof product.isAmazonSeller === "boolean")
|
||||||
|
return product.isAmazonSeller;
|
||||||
|
|
||||||
|
if (typeof product.availabilityAmazon === "number") {
|
||||||
|
if (product.availabilityAmazon >= 0) return true;
|
||||||
|
if (
|
||||||
|
product.availabilityAmazon === -1 ||
|
||||||
|
product.availabilityAmazon === -2
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (stats?.buyBoxIsAmazon === true) return true;
|
||||||
|
|
||||||
|
if (typeof stats?.current?.[0] === "number") {
|
||||||
|
if (stats.current[0] > 0) return true;
|
||||||
|
if (stats.current[0] === -1 || stats.current[0] === -2) return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const latestAmazonPrice = extractLatestPositivePrice(csv?.[0]);
|
||||||
|
if (latestAmazonPrice != null) return true;
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractAmazonBuyboxSharePct90d(
|
||||||
|
product: Record<string, any>,
|
||||||
|
stats: Record<string, any> | undefined,
|
||||||
|
): number | null {
|
||||||
|
const candidates: unknown[] = [
|
||||||
|
product.buyBoxStatsAmazon90,
|
||||||
|
stats?.buyBoxStatsAmazon90,
|
||||||
|
product.buyBoxStats?.amazon90,
|
||||||
|
product.buyBoxStats?.amazon?.[90],
|
||||||
|
product.buyBoxStats?.amazon?.["90"],
|
||||||
|
product.buyBoxStats?.[AMAZON_US_SELLER_ID]?.[90],
|
||||||
|
product.buyBoxStats?.[AMAZON_US_SELLER_ID]?.["90"],
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const value of candidates) {
|
||||||
|
if (typeof value !== "number" || !Number.isFinite(value)) continue;
|
||||||
|
if (value < 0 || value > 100) continue;
|
||||||
|
return Math.round(value * 100) / 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function computeAmazonBuyBoxSharePctFromHistory(
|
||||||
|
history: unknown,
|
||||||
|
windowDays: number,
|
||||||
|
amazonSellerIds: Set<string>,
|
||||||
|
): number | null {
|
||||||
|
if (!Array.isArray(history) || history.length < 2) return null;
|
||||||
|
|
||||||
|
const nowKeepaMinutes =
|
||||||
|
Math.floor(Date.now() / 60_000) - KEEPA_MINUTES_OFFSET;
|
||||||
|
const windowStart = nowKeepaMinutes - windowDays * 24 * 60;
|
||||||
|
let qualifiedMinutes = 0;
|
||||||
|
let amazonMinutes = 0;
|
||||||
|
|
||||||
|
for (let i = 0; i < history.length - 1; i += 2) {
|
||||||
|
const startMinute = Number.parseInt(String(history[i]), 10);
|
||||||
|
const sellerId = String(history[i + 1] ?? "").toUpperCase();
|
||||||
|
const nextRaw = i + 2 < history.length ? history[i + 2] : nowKeepaMinutes;
|
||||||
|
const endMinute = Number.parseInt(String(nextRaw), 10);
|
||||||
|
|
||||||
|
if (!Number.isFinite(startMinute) || !Number.isFinite(endMinute)) continue;
|
||||||
|
if (endMinute <= startMinute) continue;
|
||||||
|
|
||||||
|
const intervalStart = Math.max(startMinute, windowStart);
|
||||||
|
const intervalEnd = Math.min(endMinute, nowKeepaMinutes);
|
||||||
|
if (intervalEnd <= intervalStart) continue;
|
||||||
|
|
||||||
|
if (sellerId === "-1" || sellerId === "-2") continue;
|
||||||
|
|
||||||
|
const minutes = intervalEnd - intervalStart;
|
||||||
|
qualifiedMinutes += minutes;
|
||||||
|
if (amazonSellerIds.has(sellerId)) {
|
||||||
|
amazonMinutes += minutes;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (qualifiedMinutes === 0) return null;
|
||||||
|
return Math.round((amazonMinutes / qualifiedMinutes) * 10_000) / 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractLatestPositivePrice(series: unknown): number | null {
|
||||||
|
if (!Array.isArray(series) || series.length < 2) return null;
|
||||||
|
const last = series[series.length - 1];
|
||||||
|
if (typeof last !== "number" || !Number.isFinite(last) || last <= 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return last / 100;
|
||||||
|
}
|
||||||
|
|
||||||
async function fetchKeepaEnrichmentMap(
|
async function fetchKeepaEnrichmentMap(
|
||||||
asins: string[],
|
asins: string[],
|
||||||
): Promise<Map<string, { keepa: KeepaData; title: string }>> {
|
): Promise<Map<string, { keepa: KeepaData; title: string }>> {
|
||||||
@@ -804,12 +810,12 @@ async function fetchKeepaEnrichmentMap(
|
|||||||
const chunk = asins.slice(i, i + KEEPA_PRODUCT_CHUNK_SIZE);
|
const chunk = asins.slice(i, i + KEEPA_PRODUCT_CHUNK_SIZE);
|
||||||
const asinParam = encodeURIComponent(chunk.join(","));
|
const asinParam = encodeURIComponent(chunk.join(","));
|
||||||
const data = await keepaGetJson(
|
const data = await keepaGetJson(
|
||||||
`/product?key=${encodeURIComponent(config.keepaApiKey)}&domain=${DOMAIN_US}&asin=${asinParam}&stats=90`,
|
`/product?key=${encodeURIComponent(config.keepaApiKey)}&domain=${DOMAIN_US}&asin=${asinParam}&stats=90&buybox=1&days=90`,
|
||||||
);
|
);
|
||||||
|
|
||||||
const products = Array.isArray(data?.products) ? data.products : [];
|
const products = Array.isArray(data?.products) ? data.products : [];
|
||||||
for (const product of products) {
|
for (const product of products) {
|
||||||
const asin = String(product?.asin ?? "").trim();
|
const asin = normalizeAsin(product?.asin);
|
||||||
if (!asin) continue;
|
if (!asin) continue;
|
||||||
out.set(asin, {
|
out.set(asin, {
|
||||||
keepa: parseKeepaProduct(product),
|
keepa: parseKeepaProduct(product),
|
||||||
@@ -876,17 +882,17 @@ function buildEnrichedProducts(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function processCategory(
|
export async function processCategory(
|
||||||
db: Database,
|
|
||||||
runId: number,
|
runId: number,
|
||||||
category: CategoryInfo,
|
category: CategoryInfo,
|
||||||
perCategoryTop: number,
|
perCategoryTop: number,
|
||||||
|
useClaude = false,
|
||||||
): Promise<CategoryRunSummary> {
|
): Promise<CategoryRunSummary> {
|
||||||
log("info", `\nCategory ${category.label} (${category.id})`);
|
log("info", `\nCategory ${category.label} (${category.id})`);
|
||||||
|
|
||||||
const topAsins = await fetchCategoryBestSellerAsins(category, perCategoryTop);
|
const topAsins = await fetchCategoryBestSellerAsins(category, perCategoryTop);
|
||||||
if (topAsins.length === 0) {
|
if (topAsins.length === 0) {
|
||||||
log("info", " Keepa returned no ASINs for this category.");
|
log("info", " Keepa returned no ASINs for this category.");
|
||||||
await updateCategoryRunSummary(db, runId, {
|
await updateCategoryRunSummary(runId, {
|
||||||
topAsinsChecked: 0,
|
topAsinsChecked: 0,
|
||||||
availableAsins: 0,
|
availableAsins: 0,
|
||||||
fba: 0,
|
fba: 0,
|
||||||
@@ -911,7 +917,10 @@ export async function processCategory(
|
|||||||
|
|
||||||
const uniqueTopAsins = Array.from(new Set(topAsins));
|
const uniqueTopAsins = Array.from(new Set(topAsins));
|
||||||
if (uniqueTopAsins.length !== topAsins.length) {
|
if (uniqueTopAsins.length !== topAsins.length) {
|
||||||
log("warn", ` Removed ${topAsins.length - uniqueTopAsins.length} duplicate ASINs before analysis.`);
|
log(
|
||||||
|
"warn",
|
||||||
|
` Removed ${topAsins.length - uniqueTopAsins.length} duplicate ASINs before analysis.`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
log("info", ` Top ASINs fetched: ${uniqueTopAsins.length}`);
|
log("info", ` Top ASINs fetched: ${uniqueTopAsins.length}`);
|
||||||
@@ -922,9 +931,12 @@ export async function processCategory(
|
|||||||
return info?.canSell === true && info.sellabilityStatus === "available";
|
return info?.canSell === true && info.sellabilityStatus === "available";
|
||||||
});
|
});
|
||||||
|
|
||||||
log("info", ` Sellable ASINs: ${availableAsins.length}/${uniqueTopAsins.length}`);
|
log(
|
||||||
|
"info",
|
||||||
|
` Sellable ASINs: ${availableAsins.length}/${uniqueTopAsins.length}`,
|
||||||
|
);
|
||||||
if (availableAsins.length === 0) {
|
if (availableAsins.length === 0) {
|
||||||
await updateCategoryRunSummary(db, runId, {
|
await updateCategoryRunSummary(runId, {
|
||||||
topAsinsChecked: uniqueTopAsins.length,
|
topAsinsChecked: uniqueTopAsins.length,
|
||||||
availableAsins: 0,
|
availableAsins: 0,
|
||||||
fba: 0,
|
fba: 0,
|
||||||
@@ -969,7 +981,7 @@ export async function processCategory(
|
|||||||
|
|
||||||
let batchVerdicts: LlmVerdict[];
|
let batchVerdicts: LlmVerdict[];
|
||||||
try {
|
try {
|
||||||
batchVerdicts = await analyzeProducts(batch);
|
batchVerdicts = await analyzeProducts(batch, { useClaude });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const message = err instanceof Error ? err.message : String(err);
|
const message = err instanceof Error ? err.message : String(err);
|
||||||
log("warn", ` LLM batch failed: ${message}`);
|
log("warn", ` LLM batch failed: ${message}`);
|
||||||
@@ -992,7 +1004,7 @@ export async function processCategory(
|
|||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
await insertProductAnalysisResults(db, runId, batchResults);
|
await insertProductAnalysisResults(runId, batchResults);
|
||||||
|
|
||||||
for (const result of batchResults) {
|
for (const result of batchResults) {
|
||||||
results.push(result);
|
results.push(result);
|
||||||
@@ -1005,7 +1017,7 @@ export async function processCategory(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
await updateCategoryRunSummary(db, runId, {
|
await updateCategoryRunSummary(runId, {
|
||||||
topAsinsChecked: uniqueTopAsins.length,
|
topAsinsChecked: uniqueTopAsins.length,
|
||||||
availableAsins: availableAsins.length,
|
availableAsins: availableAsins.length,
|
||||||
fba,
|
fba,
|
||||||
@@ -1025,7 +1037,7 @@ export async function processCategory(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
await updateCategoryRunSummary(db, runId, {
|
await updateCategoryRunSummary(runId, {
|
||||||
topAsinsChecked: uniqueTopAsins.length,
|
topAsinsChecked: uniqueTopAsins.length,
|
||||||
availableAsins: availableAsins.length,
|
availableAsins: availableAsins.length,
|
||||||
fba,
|
fba,
|
||||||
@@ -1054,9 +1066,6 @@ export async function main(): Promise<void> {
|
|||||||
assertSpApiPrerequisites();
|
assertSpApiPrerequisites();
|
||||||
|
|
||||||
mkdirSync(args.outputDir, { recursive: true });
|
mkdirSync(args.outputDir, { recursive: true });
|
||||||
const DB_PATH = process.env.RESULTS_DB_PATH || path.join(process.cwd(), "results.db");
|
|
||||||
initDb(DB_PATH);
|
|
||||||
const db = getDb(DB_PATH);
|
|
||||||
|
|
||||||
log("info", "Starting per-category bestseller pipeline");
|
log("info", "Starting per-category bestseller pipeline");
|
||||||
log("info", `Marketplace: ${config.spApiMarketplaceId}`);
|
log("info", `Marketplace: ${config.spApiMarketplaceId}`);
|
||||||
@@ -1090,7 +1099,6 @@ export async function main(): Promise<void> {
|
|||||||
let runId: number | undefined;
|
let runId: number | undefined;
|
||||||
try {
|
try {
|
||||||
runId = await insertCategoryRunSummary(
|
runId = await insertCategoryRunSummary(
|
||||||
db,
|
|
||||||
{
|
{
|
||||||
categoryId: category.id,
|
categoryId: category.id,
|
||||||
categoryLabel: category.label,
|
categoryLabel: category.label,
|
||||||
@@ -1107,10 +1115,10 @@ export async function main(): Promise<void> {
|
|||||||
);
|
);
|
||||||
|
|
||||||
categorySummary = await processCategory(
|
categorySummary = await processCategory(
|
||||||
db,
|
|
||||||
runId,
|
runId,
|
||||||
category,
|
category,
|
||||||
args.perCategoryTop,
|
args.perCategoryTop,
|
||||||
|
args.useClaude,
|
||||||
);
|
);
|
||||||
|
|
||||||
totalInsertedAsins += categorySummary.results?.length ?? 0;
|
totalInsertedAsins += categorySummary.results?.length ?? 0;
|
||||||
@@ -1136,7 +1144,7 @@ export async function main(): Promise<void> {
|
|||||||
results: [],
|
results: [],
|
||||||
};
|
};
|
||||||
if (runId) {
|
if (runId) {
|
||||||
await updateCategoryRunSummary(db, runId, {
|
await updateCategoryRunSummary(runId, {
|
||||||
topAsinsChecked: 0,
|
topAsinsChecked: 0,
|
||||||
availableAsins: 0,
|
availableAsins: 0,
|
||||||
fba: 0,
|
fba: 0,
|
||||||
324
src/categories/mid-range-sellers-by-category.test.ts
Normal file
324
src/categories/mid-range-sellers-by-category.test.ts
Normal file
@@ -0,0 +1,324 @@
|
|||||||
|
import { test, expect, beforeEach, mock } from "bun:test";
|
||||||
|
|
||||||
|
let nextId = 0;
|
||||||
|
|
||||||
|
function chainable(resolveWith: any[] = []): any {
|
||||||
|
const p: any = Promise.resolve(resolveWith);
|
||||||
|
p.limit = (_n: any) => chainable(resolveWith);
|
||||||
|
p.where = (_cond: any) => chainable(resolveWith);
|
||||||
|
p.from = (_table: any) => chainable(resolveWith);
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
|
||||||
|
const makeMockDb = (): any => ({
|
||||||
|
insert: (_table: any) => ({
|
||||||
|
values: (_vals: any) => ({
|
||||||
|
returning: (_sel: any) => Promise.resolve([{ id: ++nextId }]),
|
||||||
|
onConflictDoUpdate: (_conf: any) => Promise.resolve([]),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
update: (_table: any) => ({
|
||||||
|
set: (_vals: any) => ({
|
||||||
|
where: (_cond: any) => Promise.resolve([]),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
select: (_sel?: any) => ({
|
||||||
|
from: (_table: any) => ({
|
||||||
|
where: (_cond: any) => chainable(),
|
||||||
|
limit: (_n: any) => chainable(),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
selectDistinct: (_sel: any) => ({
|
||||||
|
from: (_table: any) => chainable(),
|
||||||
|
}),
|
||||||
|
execute: (_query: any) => Promise.resolve([]),
|
||||||
|
transaction: async (fn: (tx: any) => Promise<any>) => fn(makeMockDb()),
|
||||||
|
});
|
||||||
|
|
||||||
|
mock.module("../db/index.ts", () => ({ db: makeMockDb(), client: {} }));
|
||||||
|
|
||||||
|
const fetchSellabilityBatchMock = mock(async (asins: string[]) => {
|
||||||
|
return new Map<string, any>(
|
||||||
|
asins.map((asin) => {
|
||||||
|
if (asin === "B000000003") {
|
||||||
|
return [
|
||||||
|
asin,
|
||||||
|
{
|
||||||
|
canSell: false,
|
||||||
|
sellabilityStatus: "restricted" as const,
|
||||||
|
sellabilityReason: "restricted",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
asin,
|
||||||
|
{
|
||||||
|
canSell: true,
|
||||||
|
sellabilityStatus: "available" as const,
|
||||||
|
sellabilityReason: "ok",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const fetchSpApiPricingAndFeesMock = mock(
|
||||||
|
async (_asin: string, sellability: any) => ({
|
||||||
|
fbaFee: 4,
|
||||||
|
fbmFee: 2,
|
||||||
|
referralFeePercent: 15,
|
||||||
|
estimatedSalePrice: 25,
|
||||||
|
canSell: sellability?.canSell ?? null,
|
||||||
|
sellabilityStatus: sellability?.sellabilityStatus ?? "unknown",
|
||||||
|
sellabilityReason: sellability?.sellabilityReason ?? "missing",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const analyzeProductsMock = mock(async (products: any[]) => {
|
||||||
|
return products.map((p) => ({
|
||||||
|
asin: p.record.asin,
|
||||||
|
verdict: "FBA",
|
||||||
|
confidence: 90,
|
||||||
|
reasoning: "mocked",
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
mock.module("../integrations/sp-api.ts", () => ({
|
||||||
|
fetchSellabilityBatch: fetchSellabilityBatchMock,
|
||||||
|
fetchSpApiPricingAndFees: fetchSpApiPricingAndFeesMock,
|
||||||
|
}));
|
||||||
|
|
||||||
|
mock.module("../integrations/llm.ts", () => ({
|
||||||
|
analyzeProducts: analyzeProductsMock,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const modulePromise = import("./mid-range-sellers-by-category.ts");
|
||||||
|
|
||||||
|
let processCategory: any;
|
||||||
|
let insertCategoryRunSummary: (summary: any, runTimestamp: string) => Promise<number>;
|
||||||
|
let originalFetch: typeof globalThis.fetch;
|
||||||
|
|
||||||
|
const mod = await modulePromise;
|
||||||
|
processCategory = mod.processCategory;
|
||||||
|
insertCategoryRunSummary = mod.insertCategoryRunSummary;
|
||||||
|
originalFetch = globalThis.fetch;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
nextId = 0;
|
||||||
|
globalThis.fetch = mock(async (input: string | URL | Request) => {
|
||||||
|
const rawUrl =
|
||||||
|
typeof input === "string"
|
||||||
|
? input
|
||||||
|
: input instanceof URL
|
||||||
|
? input.toString()
|
||||||
|
: input.url;
|
||||||
|
const url = new URL(rawUrl);
|
||||||
|
|
||||||
|
if (url.pathname === "/bestsellers") {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
bestSellersList: [
|
||||||
|
"B000000001",
|
||||||
|
"B000000002",
|
||||||
|
"B000000003",
|
||||||
|
"B000000004",
|
||||||
|
"B000000005",
|
||||||
|
],
|
||||||
|
tokensLeft: 10,
|
||||||
|
refillRate: 1,
|
||||||
|
}),
|
||||||
|
{ status: 200 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url.pathname === "/product") {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
products: [
|
||||||
|
{
|
||||||
|
asin: "B000000001",
|
||||||
|
title: "Product One",
|
||||||
|
monthlySold: 600,
|
||||||
|
isAmazonSeller: true,
|
||||||
|
buyBoxStatsAmazon90: 40,
|
||||||
|
stats: {
|
||||||
|
current: [
|
||||||
|
null, null, null, 1000, null, null, null, null, null, null, null, 5,
|
||||||
|
null, null, null, null, null, null, 2599,
|
||||||
|
],
|
||||||
|
avg: [2400, null, null, 1200],
|
||||||
|
},
|
||||||
|
csv: [[1, 2599]],
|
||||||
|
categoryTree: [{ name: "Category 1" }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
asin: "B000000002",
|
||||||
|
title: "Product Two",
|
||||||
|
monthlySold: 250,
|
||||||
|
isAmazonSeller: true,
|
||||||
|
buyBoxStatsAmazon90: 50,
|
||||||
|
stats: {
|
||||||
|
current: [
|
||||||
|
null, null, null, 2000, null, null, null, null, null, null, null, 3,
|
||||||
|
null, null, null, null, null, null, 1999,
|
||||||
|
],
|
||||||
|
avg: [1800, null, null, 2200],
|
||||||
|
},
|
||||||
|
csv: [[1, 1200]],
|
||||||
|
categoryTree: [{ name: "Category 1" }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
asin: "B000000003",
|
||||||
|
title: "Product Three",
|
||||||
|
monthlySold: 800,
|
||||||
|
isAmazonSeller: true,
|
||||||
|
buyBoxStatsAmazon90: 50,
|
||||||
|
stats: {
|
||||||
|
current: [
|
||||||
|
null, null, null, 1500, null, null, null, null, null, null, null, 4,
|
||||||
|
null, null, null, null, null, null, 2099,
|
||||||
|
],
|
||||||
|
avg: [2000, null, null, 1800],
|
||||||
|
},
|
||||||
|
csv: [[1, 2099]],
|
||||||
|
categoryTree: [{ name: "Category 1" }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
asin: "B000000004",
|
||||||
|
title: "Product Four",
|
||||||
|
monthlySold: 400,
|
||||||
|
isAmazonSeller: true,
|
||||||
|
buyBoxStatsAmazon90: 95,
|
||||||
|
stats: {
|
||||||
|
current: [
|
||||||
|
null, null, null, 3000, null, null, null, null, null, null, null, 4,
|
||||||
|
null, null, null, null, null, null, 2899,
|
||||||
|
],
|
||||||
|
avg: [2600, null, null, 2800],
|
||||||
|
},
|
||||||
|
csv: [[1, 2899]],
|
||||||
|
categoryTree: [{ name: "Category 1" }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
asin: "B000000005",
|
||||||
|
title: "Product Five",
|
||||||
|
monthlySold: 450,
|
||||||
|
isAmazonSeller: false,
|
||||||
|
stats: {
|
||||||
|
current: [
|
||||||
|
null, null, null, 3200, null, null, null, null, null, null, null, 25,
|
||||||
|
null, null, null, null, null, null, 3500,
|
||||||
|
],
|
||||||
|
avg: [3200, null, null, 3200],
|
||||||
|
},
|
||||||
|
csv: [[1, 3500]],
|
||||||
|
categoryTree: [{ name: "Category 1" }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
tokensLeft: 10,
|
||||||
|
refillRate: 1,
|
||||||
|
}),
|
||||||
|
{ status: 200 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Response("not found", { status: 404 });
|
||||||
|
}) as unknown as typeof globalThis.fetch;
|
||||||
|
});
|
||||||
|
|
||||||
|
test("processCategory only analyzes sellable mid-range matches", async () => {
|
||||||
|
const mockCategory = {
|
||||||
|
id: 1,
|
||||||
|
label: "Category 1",
|
||||||
|
parentId: 0,
|
||||||
|
childCount: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
const runId = await insertCategoryRunSummary(
|
||||||
|
{
|
||||||
|
categoryId: mockCategory.id,
|
||||||
|
categoryLabel: mockCategory.label,
|
||||||
|
topAsinsChecked: 0,
|
||||||
|
availableAsins: 0,
|
||||||
|
fba: 0,
|
||||||
|
fbm: 0,
|
||||||
|
skip: 0,
|
||||||
|
status: "running",
|
||||||
|
error: "",
|
||||||
|
results: [],
|
||||||
|
},
|
||||||
|
new Date().toISOString(),
|
||||||
|
);
|
||||||
|
|
||||||
|
const summary = await processCategory(
|
||||||
|
runId,
|
||||||
|
mockCategory,
|
||||||
|
3,
|
||||||
|
5,
|
||||||
|
100,
|
||||||
|
1000,
|
||||||
|
15,
|
||||||
|
200,
|
||||||
|
3,
|
||||||
|
20,
|
||||||
|
15,
|
||||||
|
85,
|
||||||
|
"strict",
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(summary.status).toBe("ok");
|
||||||
|
expect(summary.topAsinsChecked).toBe(5);
|
||||||
|
expect(summary.availableAsins).toBe(1);
|
||||||
|
expect(summary.results?.length).toBe(1);
|
||||||
|
|
||||||
|
globalThis.fetch = originalFetch;
|
||||||
|
});
|
||||||
|
|
||||||
|
test("processCategory returns empty when no products match mid-range criteria", async () => {
|
||||||
|
const mockCategory = {
|
||||||
|
id: 2,
|
||||||
|
label: "Category 2",
|
||||||
|
parentId: 0,
|
||||||
|
childCount: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
const runId = await insertCategoryRunSummary(
|
||||||
|
{
|
||||||
|
categoryId: mockCategory.id,
|
||||||
|
categoryLabel: mockCategory.label,
|
||||||
|
topAsinsChecked: 0,
|
||||||
|
availableAsins: 0,
|
||||||
|
fba: 0,
|
||||||
|
fbm: 0,
|
||||||
|
skip: 0,
|
||||||
|
status: "running",
|
||||||
|
error: "",
|
||||||
|
results: [],
|
||||||
|
},
|
||||||
|
new Date().toISOString(),
|
||||||
|
);
|
||||||
|
|
||||||
|
const summary = await processCategory(
|
||||||
|
runId,
|
||||||
|
mockCategory,
|
||||||
|
3,
|
||||||
|
5,
|
||||||
|
100,
|
||||||
|
1000,
|
||||||
|
500,
|
||||||
|
600,
|
||||||
|
3,
|
||||||
|
20,
|
||||||
|
15,
|
||||||
|
85,
|
||||||
|
"strict",
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(summary.status).toBe("empty");
|
||||||
|
expect(summary.topAsinsChecked).toBe(5);
|
||||||
|
expect(summary.availableAsins).toBe(0);
|
||||||
|
expect(summary.results?.length).toBe(0);
|
||||||
|
|
||||||
|
globalThis.fetch = originalFetch;
|
||||||
|
});
|
||||||
1945
src/categories/mid-range-sellers-by-category.ts
Normal file
1945
src/categories/mid-range-sellers-by-category.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,11 +1,44 @@
|
|||||||
import { test, expect, beforeAll, afterAll, beforeEach, mock } from "bun:test";
|
import { test, expect, beforeEach, mock } from "bun:test";
|
||||||
import { Database } from "bun:sqlite";
|
|
||||||
import { getDb, initDb, closeDb } from "./database.ts";
|
let nextId = 0;
|
||||||
import path from "node:path";
|
|
||||||
import { rmSync, mkdirSync } from "node:fs";
|
function chainable(resolveWith: any[] = []): any {
|
||||||
|
const p: any = Promise.resolve(resolveWith);
|
||||||
|
p.limit = (_n: any) => chainable(resolveWith);
|
||||||
|
p.where = (_cond: any) => chainable(resolveWith);
|
||||||
|
p.from = (_table: any) => chainable(resolveWith);
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
|
||||||
|
const makeMockDb = (): any => ({
|
||||||
|
insert: (_table: any) => ({
|
||||||
|
values: (_vals: any) => ({
|
||||||
|
returning: (_sel: any) => Promise.resolve([{ id: ++nextId }]),
|
||||||
|
onConflictDoUpdate: (_conf: any) => Promise.resolve([]),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
update: (_table: any) => ({
|
||||||
|
set: (_vals: any) => ({
|
||||||
|
where: (_cond: any) => Promise.resolve([]),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
select: (_sel?: any) => ({
|
||||||
|
from: (_table: any) => ({
|
||||||
|
where: (_cond: any) => chainable(),
|
||||||
|
limit: (_n: any) => chainable(),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
selectDistinct: (_sel: any) => ({
|
||||||
|
from: (_table: any) => chainable(),
|
||||||
|
}),
|
||||||
|
execute: (_query: any) => Promise.resolve([]),
|
||||||
|
transaction: async (fn: (tx: any) => Promise<any>) => fn(makeMockDb()),
|
||||||
|
});
|
||||||
|
|
||||||
|
mock.module("../db/index.ts", () => ({ db: makeMockDb(), client: {} }));
|
||||||
|
|
||||||
const fetchSellabilityBatchMock = mock(async (asins: string[]) => {
|
const fetchSellabilityBatchMock = mock(async (asins: string[]) => {
|
||||||
return new Map(
|
return new Map<string, any>(
|
||||||
asins.map((asin) => {
|
asins.map((asin) => {
|
||||||
if (asin === "B000000003") {
|
if (asin === "B000000003") {
|
||||||
return [
|
return [
|
||||||
@@ -49,62 +82,34 @@ const analyzeProductsMock = mock(async (products: any[]) => {
|
|||||||
}));
|
}));
|
||||||
});
|
});
|
||||||
|
|
||||||
mock.module("./sp-api.ts", () => ({
|
mock.module("../integrations/sp-api.ts", () => ({
|
||||||
fetchSellabilityBatch: fetchSellabilityBatchMock,
|
fetchSellabilityBatch: fetchSellabilityBatchMock,
|
||||||
fetchSpApiPricingAndFees: fetchSpApiPricingAndFeesMock,
|
fetchSpApiPricingAndFees: fetchSpApiPricingAndFeesMock,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
mock.module("./llm.ts", () => ({
|
mock.module("../integrations/llm.ts", () => ({
|
||||||
analyzeProducts: analyzeProductsMock,
|
analyzeProducts: analyzeProductsMock,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const modulePromise = import("./top-monthly-sold-by-category.ts");
|
const modulePromise = import("./top-monthly-sold-by-category.ts");
|
||||||
|
|
||||||
const DB_TEST_PATH = path.join(
|
|
||||||
process.cwd(),
|
|
||||||
"test_output",
|
|
||||||
"test_monthly_sold_analysis.sqlite",
|
|
||||||
);
|
|
||||||
|
|
||||||
let db: Database;
|
|
||||||
let processCategory: (
|
let processCategory: (
|
||||||
db: Database,
|
|
||||||
runId: number,
|
runId: number,
|
||||||
category: any,
|
category: any,
|
||||||
perCategoryTop: number,
|
perCategoryTop: number,
|
||||||
categoryCandidatePool: number,
|
categoryCandidatePool: number,
|
||||||
minMonthlySold: number,
|
minMonthlySold: number,
|
||||||
) => Promise<any>;
|
) => Promise<any>;
|
||||||
let insertCategoryRunSummary: (
|
let insertCategoryRunSummary: (summary: any, runTimestamp: string) => Promise<number>;
|
||||||
db: Database,
|
|
||||||
summary: any,
|
|
||||||
runTimestamp: string,
|
|
||||||
) => Promise<number>;
|
|
||||||
let originalFetch: typeof globalThis.fetch;
|
let originalFetch: typeof globalThis.fetch;
|
||||||
|
|
||||||
beforeAll(async () => {
|
const mod = await modulePromise;
|
||||||
const mod = await modulePromise;
|
processCategory = mod.processCategory;
|
||||||
processCategory = mod.processCategory;
|
insertCategoryRunSummary = mod.insertCategoryRunSummary;
|
||||||
insertCategoryRunSummary = mod.insertCategoryRunSummary;
|
originalFetch = globalThis.fetch;
|
||||||
|
|
||||||
rmSync(path.dirname(DB_TEST_PATH), { recursive: true, force: true });
|
|
||||||
mkdirSync(path.dirname(DB_TEST_PATH), { recursive: true });
|
|
||||||
initDb(DB_TEST_PATH);
|
|
||||||
db = getDb(DB_TEST_PATH);
|
|
||||||
|
|
||||||
originalFetch = globalThis.fetch;
|
|
||||||
});
|
|
||||||
|
|
||||||
afterAll(() => {
|
|
||||||
globalThis.fetch = originalFetch;
|
|
||||||
closeDb();
|
|
||||||
rmSync(path.dirname(DB_TEST_PATH), { recursive: true, force: true });
|
|
||||||
});
|
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
db.run("DELETE FROM product_analysis_results");
|
nextId = 0;
|
||||||
db.run("DELETE FROM category_analysis_runs");
|
|
||||||
|
|
||||||
globalThis.fetch = mock(async (input: string | URL | Request) => {
|
globalThis.fetch = mock(async (input: string | URL | Request) => {
|
||||||
const rawUrl =
|
const rawUrl =
|
||||||
typeof input === "string"
|
typeof input === "string"
|
||||||
@@ -140,25 +145,8 @@ beforeEach(() => {
|
|||||||
monthlySold: 600,
|
monthlySold: 600,
|
||||||
stats: {
|
stats: {
|
||||||
current: [
|
current: [
|
||||||
null,
|
null, null, null, 1000, null, null, null, null, null, null, null, 2,
|
||||||
null,
|
null, null, null, null, null, null, 2599,
|
||||||
null,
|
|
||||||
1000,
|
|
||||||
null,
|
|
||||||
null,
|
|
||||||
null,
|
|
||||||
null,
|
|
||||||
null,
|
|
||||||
null,
|
|
||||||
null,
|
|
||||||
2,
|
|
||||||
null,
|
|
||||||
null,
|
|
||||||
null,
|
|
||||||
null,
|
|
||||||
null,
|
|
||||||
null,
|
|
||||||
2599,
|
|
||||||
],
|
],
|
||||||
avg: [2400, null, null, 1200],
|
avg: [2400, null, null, 1200],
|
||||||
},
|
},
|
||||||
@@ -171,25 +159,8 @@ beforeEach(() => {
|
|||||||
monthlySold: 250,
|
monthlySold: 250,
|
||||||
stats: {
|
stats: {
|
||||||
current: [
|
current: [
|
||||||
null,
|
null, null, null, 2000, null, null, null, null, null, null, null, 3,
|
||||||
null,
|
null, null, null, null, null, null, 1999,
|
||||||
null,
|
|
||||||
2000,
|
|
||||||
null,
|
|
||||||
null,
|
|
||||||
null,
|
|
||||||
null,
|
|
||||||
null,
|
|
||||||
null,
|
|
||||||
null,
|
|
||||||
3,
|
|
||||||
null,
|
|
||||||
null,
|
|
||||||
null,
|
|
||||||
null,
|
|
||||||
null,
|
|
||||||
null,
|
|
||||||
1999,
|
|
||||||
],
|
],
|
||||||
avg: [1800, null, null, 2200],
|
avg: [1800, null, null, 2200],
|
||||||
},
|
},
|
||||||
@@ -202,25 +173,8 @@ beforeEach(() => {
|
|||||||
monthlySold: 800,
|
monthlySold: 800,
|
||||||
stats: {
|
stats: {
|
||||||
current: [
|
current: [
|
||||||
null,
|
null, null, null, 1500, null, null, null, null, null, null, null, 1,
|
||||||
null,
|
null, null, null, null, null, null, 2099,
|
||||||
null,
|
|
||||||
1500,
|
|
||||||
null,
|
|
||||||
null,
|
|
||||||
null,
|
|
||||||
null,
|
|
||||||
null,
|
|
||||||
null,
|
|
||||||
null,
|
|
||||||
1,
|
|
||||||
null,
|
|
||||||
null,
|
|
||||||
null,
|
|
||||||
null,
|
|
||||||
null,
|
|
||||||
null,
|
|
||||||
2099,
|
|
||||||
],
|
],
|
||||||
avg: [2000, null, null, 1800],
|
avg: [2000, null, null, 1800],
|
||||||
},
|
},
|
||||||
@@ -233,25 +187,8 @@ beforeEach(() => {
|
|||||||
monthlySold: 400,
|
monthlySold: 400,
|
||||||
stats: {
|
stats: {
|
||||||
current: [
|
current: [
|
||||||
null,
|
null, null, null, 3000, null, null, null, null, null, null, null, 4,
|
||||||
null,
|
null, null, null, null, null, null, 2899,
|
||||||
null,
|
|
||||||
3000,
|
|
||||||
null,
|
|
||||||
null,
|
|
||||||
null,
|
|
||||||
null,
|
|
||||||
null,
|
|
||||||
null,
|
|
||||||
null,
|
|
||||||
4,
|
|
||||||
null,
|
|
||||||
null,
|
|
||||||
null,
|
|
||||||
null,
|
|
||||||
null,
|
|
||||||
null,
|
|
||||||
2899,
|
|
||||||
],
|
],
|
||||||
avg: [2600, null, null, 2800],
|
avg: [2600, null, null, 2800],
|
||||||
},
|
},
|
||||||
@@ -279,7 +216,6 @@ test("processCategory filters to sellable ASINs with monthly sold >= threshold a
|
|||||||
};
|
};
|
||||||
|
|
||||||
const runId = await insertCategoryRunSummary(
|
const runId = await insertCategoryRunSummary(
|
||||||
db,
|
|
||||||
{
|
{
|
||||||
categoryId: mockCategory.id,
|
categoryId: mockCategory.id,
|
||||||
categoryLabel: mockCategory.label,
|
categoryLabel: mockCategory.label,
|
||||||
@@ -295,22 +231,16 @@ test("processCategory filters to sellable ASINs with monthly sold >= threshold a
|
|||||||
new Date().toISOString(),
|
new Date().toISOString(),
|
||||||
);
|
);
|
||||||
|
|
||||||
const summary = await processCategory(db, runId, mockCategory, 2, 4, 300);
|
const summary = await processCategory(runId, mockCategory, 2, 4, 300);
|
||||||
|
|
||||||
expect(summary.status).toBe("ok");
|
expect(summary.status).toBe("ok");
|
||||||
expect(summary.topAsinsChecked).toBe(4);
|
expect(summary.topAsinsChecked).toBe(4);
|
||||||
expect(summary.availableAsins).toBe(2);
|
expect(summary.availableAsins).toBe(2);
|
||||||
expect(summary.results?.length).toBe(2);
|
expect(summary.results?.length).toBe(2);
|
||||||
|
|
||||||
const productResults = db
|
const asins = summary.results?.map((r: any) => r.product.record.asin) ?? [];
|
||||||
.query(
|
expect(asins).toContain("B000000001");
|
||||||
"SELECT asin, monthly_sold FROM product_analysis_results ORDER BY monthly_sold DESC",
|
expect(asins).toContain("B000000004");
|
||||||
)
|
|
||||||
.all() as Array<{ asin: string; monthly_sold: number }>;
|
|
||||||
|
|
||||||
expect(productResults.length).toBe(2);
|
globalThis.fetch = originalFetch;
|
||||||
expect(productResults[0]?.asin).toBe("B000000001");
|
|
||||||
expect(productResults[0]?.monthly_sold).toBe(600);
|
|
||||||
expect(productResults[1]?.asin).toBe("B000000004");
|
|
||||||
expect(productResults[1]?.monthly_sold).toBe(400);
|
|
||||||
});
|
});
|
||||||
@@ -1,9 +1,14 @@
|
|||||||
import { existsSync, mkdirSync, readFileSync } from "node:fs";
|
import { existsSync, mkdirSync, readFileSync } from "node:fs";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { type Database, getDb, initDb } from "./database.ts";
|
import { normalizeAsin } from "../asin.ts";
|
||||||
import { config } from "./config.ts";
|
import {
|
||||||
import { analyzeProducts } from "./llm.ts";
|
createCategoryRun,
|
||||||
import { fetchSellabilityBatch, fetchSpApiPricingAndFees } from "./sp-api.ts";
|
persistLlmResults,
|
||||||
|
updateCategoryRun,
|
||||||
|
} from "../db/persistence.ts";
|
||||||
|
import { config } from "../config.ts";
|
||||||
|
import { analyzeProducts } from "../integrations/llm.ts";
|
||||||
|
import { fetchSellabilityBatch, fetchSpApiPricingAndFees } from "../integrations/sp-api.ts";
|
||||||
import type {
|
import type {
|
||||||
AnalysisResult,
|
AnalysisResult,
|
||||||
EnrichedProduct,
|
EnrichedProduct,
|
||||||
@@ -12,7 +17,7 @@ import type {
|
|||||||
ProductRecord,
|
ProductRecord,
|
||||||
SellabilityInfo,
|
SellabilityInfo,
|
||||||
SpApiData,
|
SpApiData,
|
||||||
} from "./types.ts";
|
} from "../types.ts";
|
||||||
|
|
||||||
type CategoryInfo = {
|
type CategoryInfo = {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -28,6 +33,7 @@ type ParsedArgs = {
|
|||||||
categoryCandidatePool: number;
|
categoryCandidatePool: number;
|
||||||
minMonthlySold: number;
|
minMonthlySold: number;
|
||||||
blacklistFile: string;
|
blacklistFile: string;
|
||||||
|
useClaude: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
type CategoryRunSummary = {
|
type CategoryRunSummary = {
|
||||||
@@ -46,6 +52,8 @@ type CategoryRunSummary = {
|
|||||||
|
|
||||||
const KEEPA_BASE = "https://api.keepa.com";
|
const KEEPA_BASE = "https://api.keepa.com";
|
||||||
const DOMAIN_US = 1;
|
const DOMAIN_US = 1;
|
||||||
|
const AMAZON_US_SELLER_ID = "ATVPDKIKX0DER";
|
||||||
|
const KEEPA_MINUTES_OFFSET = 21_564_000;
|
||||||
const DEFAULT_CATEGORY_LIMIT = 32;
|
const DEFAULT_CATEGORY_LIMIT = 32;
|
||||||
const DEFAULT_PER_CATEGORY_TOP = 100;
|
const DEFAULT_PER_CATEGORY_TOP = 100;
|
||||||
const DEFAULT_CATEGORY_CANDIDATE_POOL = 500;
|
const DEFAULT_CATEGORY_CANDIDATE_POOL = 500;
|
||||||
@@ -74,6 +82,7 @@ function log(
|
|||||||
|
|
||||||
function parseArgs(): ParsedArgs {
|
function parseArgs(): ParsedArgs {
|
||||||
const args = process.argv.slice(2);
|
const args = process.argv.slice(2);
|
||||||
|
const useClaude = hasFlag(args, "--claude");
|
||||||
const outputDir =
|
const outputDir =
|
||||||
readFlagValue(args, "--out-dir") ?? path.join(process.cwd(), "output");
|
readFlagValue(args, "--out-dir") ?? path.join(process.cwd(), "output");
|
||||||
const blacklistFile =
|
const blacklistFile =
|
||||||
@@ -129,9 +138,14 @@ function parseArgs(): ParsedArgs {
|
|||||||
categoryCandidatePool,
|
categoryCandidatePool,
|
||||||
minMonthlySold,
|
minMonthlySold,
|
||||||
blacklistFile,
|
blacklistFile,
|
||||||
|
useClaude,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function hasFlag(args: string[], flag: string): boolean {
|
||||||
|
return args.includes(flag);
|
||||||
|
}
|
||||||
|
|
||||||
function readFlagValue(args: string[], flag: string): string | undefined {
|
function readFlagValue(args: string[], flag: string): string | undefined {
|
||||||
const idx = args.indexOf(flag);
|
const idx = args.indexOf(flag);
|
||||||
if (idx === -1) return undefined;
|
if (idx === -1) return undefined;
|
||||||
@@ -147,7 +161,7 @@ function printUsageAndExit(message: string): never {
|
|||||||
"error",
|
"error",
|
||||||
[
|
[
|
||||||
"Usage:",
|
"Usage:",
|
||||||
" bun run src/top-monthly-sold-by-category.ts [--category-limit 32] [--per-category-top 100] [--category-candidate-pool 500] [--min-monthly-sold 300] [--out-dir output] [--blacklist-file category-blacklist.csv]",
|
" bun run src/top-monthly-sold-by-category.ts [--category-limit 32] [--per-category-top 100] [--category-candidate-pool 500] [--min-monthly-sold 300] [--out-dir output] [--blacklist-file category-blacklist.csv] [--claude]",
|
||||||
"",
|
"",
|
||||||
"Flow:",
|
"Flow:",
|
||||||
" 1) Discover categories and round-robin selection.",
|
" 1) Discover categories and round-robin selection.",
|
||||||
@@ -162,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,
|
||||||
@@ -204,127 +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, monthly_sold, rank_drops_30d, rank_drops_90d,
|
|
||||||
fba_fee, fbm_fee, referral_percent, can_sell,
|
|
||||||
sellability_status, sellability_reason,
|
|
||||||
verdict, confidence, reasoning, fetched_at
|
|
||||||
) VALUES (
|
|
||||||
?, ?, ?, ?, ?, ?, ?, ?, ?, ?,
|
|
||||||
?, ?, ?, ?, ?, ?, ?, ?, ?, ?,
|
|
||||||
?, ?, ?, ?, ?, ?
|
|
||||||
)
|
|
||||||
ON CONFLICT(asin) DO UPDATE SET
|
|
||||||
run_id = excluded.run_id,
|
|
||||||
name = excluded.name,
|
|
||||||
brand = excluded.brand,
|
|
||||||
category = excluded.category,
|
|
||||||
unit_cost = excluded.unit_cost,
|
|
||||||
current_price = excluded.current_price,
|
|
||||||
avg_price_90d = excluded.avg_price_90d,
|
|
||||||
avg_price_90d_sheet = excluded.avg_price_90d_sheet,
|
|
||||||
selling_price_sheet = excluded.selling_price_sheet,
|
|
||||||
sales_rank = excluded.sales_rank,
|
|
||||||
sales_rank_avg_90d = excluded.sales_rank_avg_90d,
|
|
||||||
seller_count = excluded.seller_count,
|
|
||||||
monthly_sold = excluded.monthly_sold,
|
|
||||||
rank_drops_30d = excluded.rank_drops_30d,
|
|
||||||
rank_drops_90d = excluded.rank_drops_90d,
|
|
||||||
fba_fee = excluded.fba_fee,
|
|
||||||
fbm_fee = excluded.fbm_fee,
|
|
||||||
referral_percent = excluded.referral_percent,
|
|
||||||
can_sell = excluded.can_sell,
|
|
||||||
sellability_status = excluded.sellability_status,
|
|
||||||
sellability_reason = excluded.sellability_reason,
|
|
||||||
verdict = excluded.verdict,
|
|
||||||
confidence = excluded.confidence,
|
|
||||||
reasoning = excluded.reasoning,
|
|
||||||
fetched_at = excluded.fetched_at;
|
|
||||||
`);
|
|
||||||
|
|
||||||
db.transaction((resultsBatch: AnalysisResult[]) => {
|
|
||||||
for (const r of resultsBatch) {
|
|
||||||
const price =
|
|
||||||
r.product.keepa?.currentPrice ??
|
|
||||||
r.product.record.sellingPriceFromSheet ??
|
|
||||||
r.product.spApi.estimatedSalePrice;
|
|
||||||
const rank = r.product.keepa?.salesRank ?? r.product.record.amazonRank;
|
|
||||||
|
|
||||||
insertStmt.run(
|
|
||||||
r.product.record.asin,
|
|
||||||
runId,
|
|
||||||
r.product.record.name,
|
|
||||||
r.product.record.brand ?? null,
|
|
||||||
r.product.record.category ??
|
|
||||||
r.product.keepa?.categoryTree?.join(" > ") ??
|
|
||||||
null,
|
|
||||||
r.product.record.unitCost ?? null,
|
|
||||||
price ?? null,
|
|
||||||
r.product.keepa?.avgPrice90 ?? null,
|
|
||||||
r.product.record.avgPrice90FromSheet ?? null,
|
|
||||||
r.product.record.sellingPriceFromSheet ?? null,
|
|
||||||
rank ?? null,
|
|
||||||
r.product.keepa?.salesRankAvg90 ?? null,
|
|
||||||
r.product.keepa?.sellerCount ?? null,
|
|
||||||
r.product.keepa?.monthlySold ?? null,
|
|
||||||
r.product.keepa?.salesRankDrops30 ?? null,
|
|
||||||
r.product.keepa?.salesRankDrops90 ?? null,
|
|
||||||
r.product.spApi.fbaFee ?? null,
|
|
||||||
r.product.spApi.fbmFee ?? null,
|
|
||||||
r.product.spApi.referralFeePercent ?? null,
|
|
||||||
r.product.spApi.canSell == null
|
|
||||||
? "unknown"
|
|
||||||
: r.product.spApi.canSell
|
|
||||||
? "yes"
|
|
||||||
: "no",
|
|
||||||
r.product.spApi.sellabilityStatus ?? null,
|
|
||||||
r.product.spApi.sellabilityReason ?? null,
|
|
||||||
r.verdict.verdict,
|
|
||||||
r.verdict.confidence,
|
|
||||||
r.verdict.reasoning ?? null,
|
|
||||||
r.product.fetchedAt,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
})(results); // Execute the transaction with the results batch
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function loadCategoryBlacklist(filePath: string): Set<number> {
|
function loadCategoryBlacklist(filePath: string): Set<number> {
|
||||||
@@ -660,7 +542,7 @@ async function discoverCategories(
|
|||||||
maxCategories: number,
|
maxCategories: number,
|
||||||
): Promise<CategoryInfo[]> {
|
): Promise<CategoryInfo[]> {
|
||||||
const data = await keepaGetJson(
|
const data = await keepaGetJson(
|
||||||
`/category?key=${encodeURIComponent(config.keepaApiKey)}&domain=${DOMAIN_US}&category=0`,
|
`/category?key=${encodeURIComponent(config.keepaApiKey)}&domain=${DOMAIN_US}&category=0&parents=0`,
|
||||||
);
|
);
|
||||||
|
|
||||||
const categories = normalizeCategoryList(data);
|
const categories = normalizeCategoryList(data);
|
||||||
@@ -706,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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -816,6 +702,14 @@ function parseKeepaProduct(product: Record<string, any>): KeepaData {
|
|||||||
const monthlySold =
|
const monthlySold =
|
||||||
pickKeepaNumber(product.monthlySold, stats?.monthlySold) ??
|
pickKeepaNumber(product.monthlySold, stats?.monthlySold) ??
|
||||||
salesRankDrops30;
|
salesRankDrops30;
|
||||||
|
const amazonIsSeller = resolveAmazonIsSeller(product, stats, csv);
|
||||||
|
const amazonBuyboxSharePct90d =
|
||||||
|
extractAmazonBuyboxSharePct90d(product, stats) ??
|
||||||
|
computeAmazonBuyBoxSharePctFromHistory(
|
||||||
|
product.buyBoxSellerIdHistory,
|
||||||
|
90,
|
||||||
|
new Set([AMAZON_US_SELLER_ID]),
|
||||||
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
currentPrice: extractCurrentPrice(csv),
|
currentPrice: extractCurrentPrice(csv),
|
||||||
@@ -827,6 +721,8 @@ function parseKeepaProduct(product: Record<string, any>): KeepaData {
|
|||||||
salesRankDrops30,
|
salesRankDrops30,
|
||||||
salesRankDrops90,
|
salesRankDrops90,
|
||||||
sellerCount: stats?.current?.[11] ?? null,
|
sellerCount: stats?.current?.[11] ?? null,
|
||||||
|
amazonIsSeller,
|
||||||
|
amazonBuyboxSharePct90d,
|
||||||
buyBoxSeller: product.buyBoxSellerId ?? null,
|
buyBoxSeller: product.buyBoxSellerId ?? null,
|
||||||
buyBoxPrice: stats?.current?.[18] != null ? stats.current[18] / 100 : null,
|
buyBoxPrice: stats?.current?.[18] != null ? stats.current[18] / 100 : null,
|
||||||
monthlySold,
|
monthlySold,
|
||||||
@@ -835,6 +731,108 @@ function parseKeepaProduct(product: Record<string, any>): KeepaData {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resolveAmazonIsSeller(
|
||||||
|
product: Record<string, any>,
|
||||||
|
stats: Record<string, any> | undefined,
|
||||||
|
csv: number[][] | undefined,
|
||||||
|
): boolean | null {
|
||||||
|
if (typeof product.isAmazonSeller === "boolean")
|
||||||
|
return product.isAmazonSeller;
|
||||||
|
|
||||||
|
if (typeof product.availabilityAmazon === "number") {
|
||||||
|
if (product.availabilityAmazon >= 0) return true;
|
||||||
|
if (
|
||||||
|
product.availabilityAmazon === -1 ||
|
||||||
|
product.availabilityAmazon === -2
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (stats?.buyBoxIsAmazon === true) return true;
|
||||||
|
|
||||||
|
if (typeof stats?.current?.[0] === "number") {
|
||||||
|
if (stats.current[0] > 0) return true;
|
||||||
|
if (stats.current[0] === -1 || stats.current[0] === -2) return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const latestAmazonPrice = extractLatestPositivePrice(csv?.[0]);
|
||||||
|
if (latestAmazonPrice != null) return true;
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractAmazonBuyboxSharePct90d(
|
||||||
|
product: Record<string, any>,
|
||||||
|
stats: Record<string, any> | undefined,
|
||||||
|
): number | null {
|
||||||
|
const candidates: unknown[] = [
|
||||||
|
product.buyBoxStatsAmazon90,
|
||||||
|
stats?.buyBoxStatsAmazon90,
|
||||||
|
product.buyBoxStats?.amazon90,
|
||||||
|
product.buyBoxStats?.amazon?.[90],
|
||||||
|
product.buyBoxStats?.amazon?.["90"],
|
||||||
|
product.buyBoxStats?.[AMAZON_US_SELLER_ID]?.[90],
|
||||||
|
product.buyBoxStats?.[AMAZON_US_SELLER_ID]?.["90"],
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const value of candidates) {
|
||||||
|
if (typeof value !== "number" || !Number.isFinite(value)) continue;
|
||||||
|
if (value < 0 || value > 100) continue;
|
||||||
|
return Math.round(value * 100) / 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function computeAmazonBuyBoxSharePctFromHistory(
|
||||||
|
history: unknown,
|
||||||
|
windowDays: number,
|
||||||
|
amazonSellerIds: Set<string>,
|
||||||
|
): number | null {
|
||||||
|
if (!Array.isArray(history) || history.length < 2) return null;
|
||||||
|
|
||||||
|
const nowKeepaMinutes =
|
||||||
|
Math.floor(Date.now() / 60_000) - KEEPA_MINUTES_OFFSET;
|
||||||
|
const windowStart = nowKeepaMinutes - windowDays * 24 * 60;
|
||||||
|
let qualifiedMinutes = 0;
|
||||||
|
let amazonMinutes = 0;
|
||||||
|
|
||||||
|
for (let i = 0; i < history.length - 1; i += 2) {
|
||||||
|
const startMinute = Number.parseInt(String(history[i]), 10);
|
||||||
|
const sellerId = String(history[i + 1] ?? "").toUpperCase();
|
||||||
|
const nextRaw = i + 2 < history.length ? history[i + 2] : nowKeepaMinutes;
|
||||||
|
const endMinute = Number.parseInt(String(nextRaw), 10);
|
||||||
|
|
||||||
|
if (!Number.isFinite(startMinute) || !Number.isFinite(endMinute)) continue;
|
||||||
|
if (endMinute <= startMinute) continue;
|
||||||
|
|
||||||
|
const intervalStart = Math.max(startMinute, windowStart);
|
||||||
|
const intervalEnd = Math.min(endMinute, nowKeepaMinutes);
|
||||||
|
if (intervalEnd <= intervalStart) continue;
|
||||||
|
|
||||||
|
if (sellerId === "-1" || sellerId === "-2") continue;
|
||||||
|
|
||||||
|
const minutes = intervalEnd - intervalStart;
|
||||||
|
qualifiedMinutes += minutes;
|
||||||
|
if (amazonSellerIds.has(sellerId)) {
|
||||||
|
amazonMinutes += minutes;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (qualifiedMinutes === 0) return null;
|
||||||
|
return Math.round((amazonMinutes / qualifiedMinutes) * 10_000) / 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractLatestPositivePrice(series: unknown): number | null {
|
||||||
|
if (!Array.isArray(series) || series.length < 2) return null;
|
||||||
|
const last = series[series.length - 1];
|
||||||
|
if (typeof last !== "number" || !Number.isFinite(last) || last <= 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return last / 100;
|
||||||
|
}
|
||||||
|
|
||||||
async function fetchKeepaEnrichmentMap(
|
async function fetchKeepaEnrichmentMap(
|
||||||
asins: string[],
|
asins: string[],
|
||||||
): Promise<Map<string, { keepa: KeepaData; title: string }>> {
|
): Promise<Map<string, { keepa: KeepaData; title: string }>> {
|
||||||
@@ -844,12 +842,12 @@ async function fetchKeepaEnrichmentMap(
|
|||||||
const chunk = asins.slice(i, i + KEEPA_PRODUCT_CHUNK_SIZE);
|
const chunk = asins.slice(i, i + KEEPA_PRODUCT_CHUNK_SIZE);
|
||||||
const asinParam = encodeURIComponent(chunk.join(","));
|
const asinParam = encodeURIComponent(chunk.join(","));
|
||||||
const data = await keepaGetJson(
|
const data = await keepaGetJson(
|
||||||
`/product?key=${encodeURIComponent(config.keepaApiKey)}&domain=${DOMAIN_US}&asin=${asinParam}&stats=90`,
|
`/product?key=${encodeURIComponent(config.keepaApiKey)}&domain=${DOMAIN_US}&asin=${asinParam}&stats=90&buybox=1&days=90`,
|
||||||
);
|
);
|
||||||
|
|
||||||
const products = Array.isArray(data?.products) ? data.products : [];
|
const products = Array.isArray(data?.products) ? data.products : [];
|
||||||
for (const product of products) {
|
for (const product of products) {
|
||||||
const asin = String(product?.asin ?? "").trim();
|
const asin = normalizeAsin(product?.asin);
|
||||||
if (!asin) continue;
|
if (!asin) continue;
|
||||||
out.set(asin, {
|
out.set(asin, {
|
||||||
keepa: parseKeepaProduct(product),
|
keepa: parseKeepaProduct(product),
|
||||||
@@ -937,12 +935,12 @@ function buildEnrichedProducts(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function processCategory(
|
export async function processCategory(
|
||||||
db: Database,
|
|
||||||
runId: number,
|
runId: number,
|
||||||
category: CategoryInfo,
|
category: CategoryInfo,
|
||||||
perCategoryTop: number,
|
perCategoryTop: number,
|
||||||
categoryCandidatePool: number,
|
categoryCandidatePool: number,
|
||||||
minMonthlySold: number,
|
minMonthlySold: number,
|
||||||
|
useClaude = false,
|
||||||
): Promise<CategoryRunSummary> {
|
): Promise<CategoryRunSummary> {
|
||||||
log("info", `\nCategory ${category.label} (${category.id})`);
|
log("info", `\nCategory ${category.label} (${category.id})`);
|
||||||
|
|
||||||
@@ -952,7 +950,7 @@ export async function processCategory(
|
|||||||
);
|
);
|
||||||
if (topAsins.length === 0) {
|
if (topAsins.length === 0) {
|
||||||
log("info", " Keepa returned no ASINs for this category.");
|
log("info", " Keepa returned no ASINs for this category.");
|
||||||
await updateCategoryRunSummary(db, runId, {
|
await updateCategoryRunSummary(runId, {
|
||||||
topAsinsChecked: 0,
|
topAsinsChecked: 0,
|
||||||
availableAsins: 0,
|
availableAsins: 0,
|
||||||
fba: 0,
|
fba: 0,
|
||||||
@@ -996,7 +994,7 @@ export async function processCategory(
|
|||||||
` Sellable ASINs: ${availableAsins.length}/${uniqueTopAsins.length}`,
|
` Sellable ASINs: ${availableAsins.length}/${uniqueTopAsins.length}`,
|
||||||
);
|
);
|
||||||
if (availableAsins.length === 0) {
|
if (availableAsins.length === 0) {
|
||||||
await updateCategoryRunSummary(db, runId, {
|
await updateCategoryRunSummary(runId, {
|
||||||
topAsinsChecked: uniqueTopAsins.length,
|
topAsinsChecked: uniqueTopAsins.length,
|
||||||
availableAsins: 0,
|
availableAsins: 0,
|
||||||
fba: 0,
|
fba: 0,
|
||||||
@@ -1033,7 +1031,7 @@ export async function processCategory(
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (selectedAsins.length === 0) {
|
if (selectedAsins.length === 0) {
|
||||||
await updateCategoryRunSummary(db, runId, {
|
await updateCategoryRunSummary(runId, {
|
||||||
topAsinsChecked: uniqueTopAsins.length,
|
topAsinsChecked: uniqueTopAsins.length,
|
||||||
availableAsins: 0,
|
availableAsins: 0,
|
||||||
fba: 0,
|
fba: 0,
|
||||||
@@ -1077,7 +1075,7 @@ export async function processCategory(
|
|||||||
|
|
||||||
let batchVerdicts: LlmVerdict[];
|
let batchVerdicts: LlmVerdict[];
|
||||||
try {
|
try {
|
||||||
batchVerdicts = await analyzeProducts(batch);
|
batchVerdicts = await analyzeProducts(batch, { useClaude });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const message = err instanceof Error ? err.message : String(err);
|
const message = err instanceof Error ? err.message : String(err);
|
||||||
log("warn", ` LLM batch failed: ${message}`);
|
log("warn", ` LLM batch failed: ${message}`);
|
||||||
@@ -1100,7 +1098,7 @@ export async function processCategory(
|
|||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
await insertProductAnalysisResults(db, runId, batchResults);
|
await insertProductAnalysisResults(runId, batchResults);
|
||||||
|
|
||||||
for (const result of batchResults) {
|
for (const result of batchResults) {
|
||||||
results.push(result);
|
results.push(result);
|
||||||
@@ -1113,7 +1111,7 @@ export async function processCategory(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
await updateCategoryRunSummary(db, runId, {
|
await updateCategoryRunSummary(runId, {
|
||||||
topAsinsChecked: uniqueTopAsins.length,
|
topAsinsChecked: uniqueTopAsins.length,
|
||||||
availableAsins: selectedAsins.length,
|
availableAsins: selectedAsins.length,
|
||||||
fba,
|
fba,
|
||||||
@@ -1133,7 +1131,7 @@ export async function processCategory(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
await updateCategoryRunSummary(db, runId, {
|
await updateCategoryRunSummary(runId, {
|
||||||
topAsinsChecked: uniqueTopAsins.length,
|
topAsinsChecked: uniqueTopAsins.length,
|
||||||
availableAsins: selectedAsins.length,
|
availableAsins: selectedAsins.length,
|
||||||
fba,
|
fba,
|
||||||
@@ -1162,10 +1160,6 @@ export async function main(): Promise<void> {
|
|||||||
assertSpApiPrerequisites();
|
assertSpApiPrerequisites();
|
||||||
|
|
||||||
mkdirSync(args.outputDir, { recursive: true });
|
mkdirSync(args.outputDir, { recursive: true });
|
||||||
const DB_PATH =
|
|
||||||
process.env.RESULTS_DB_PATH || path.join(process.cwd(), "results.db");
|
|
||||||
initDb(DB_PATH);
|
|
||||||
const db = getDb(DB_PATH);
|
|
||||||
|
|
||||||
log("info", "Starting per-category monthly-sold pipeline");
|
log("info", "Starting per-category monthly-sold pipeline");
|
||||||
log("info", `Marketplace: ${config.spApiMarketplaceId}`);
|
log("info", `Marketplace: ${config.spApiMarketplaceId}`);
|
||||||
@@ -1202,7 +1196,6 @@ export async function main(): Promise<void> {
|
|||||||
let runId: number | undefined;
|
let runId: number | undefined;
|
||||||
try {
|
try {
|
||||||
runId = await insertCategoryRunSummary(
|
runId = await insertCategoryRunSummary(
|
||||||
db,
|
|
||||||
{
|
{
|
||||||
categoryId: category.id,
|
categoryId: category.id,
|
||||||
categoryLabel: category.label,
|
categoryLabel: category.label,
|
||||||
@@ -1219,12 +1212,12 @@ export async function main(): Promise<void> {
|
|||||||
);
|
);
|
||||||
|
|
||||||
categorySummary = await processCategory(
|
categorySummary = await processCategory(
|
||||||
db,
|
|
||||||
runId,
|
runId,
|
||||||
category,
|
category,
|
||||||
args.perCategoryTop,
|
args.perCategoryTop,
|
||||||
args.categoryCandidatePool,
|
args.categoryCandidatePool,
|
||||||
args.minMonthlySold,
|
args.minMonthlySold,
|
||||||
|
args.useClaude,
|
||||||
);
|
);
|
||||||
|
|
||||||
totalInsertedAsins += categorySummary.results?.length ?? 0;
|
totalInsertedAsins += categorySummary.results?.length ?? 0;
|
||||||
@@ -1250,7 +1243,7 @@ export async function main(): Promise<void> {
|
|||||||
results: [],
|
results: [],
|
||||||
};
|
};
|
||||||
if (runId) {
|
if (runId) {
|
||||||
await updateCategoryRunSummary(db, runId, {
|
await updateCategoryRunSummary(runId, {
|
||||||
topAsinsChecked: 0,
|
topAsinsChecked: 0,
|
||||||
availableAsins: 0,
|
availableAsins: 0,
|
||||||
fba: 0,
|
fba: 0,
|
||||||
@@ -1,20 +1,16 @@
|
|||||||
import { getDb } from "./database.ts";
|
import { db } from "./db/index.ts";
|
||||||
import path from "node:path";
|
import { runs } from "./db/schema.ts";
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
|
||||||
async function checkDb() {
|
async function checkDb() {
|
||||||
const DB_PATH = path.join(process.cwd(), "temp_output", "analysis.sqlite");
|
|
||||||
const db = getDb(DB_PATH);
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const query = db.query(
|
const result = await db
|
||||||
"SELECT * FROM category_analysis_runs WHERE category_id = ?",
|
.select()
|
||||||
);
|
.from(runs)
|
||||||
const result = query.all(19419898011);
|
.where(eq(runs.type, "category_analysis"));
|
||||||
console.log(JSON.stringify(result, null, 2));
|
console.log(JSON.stringify(result, null, 2));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Database query failed:", error);
|
console.error("Database query failed:", error);
|
||||||
} finally {
|
|
||||||
db.close();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -20,7 +20,12 @@ export const config = {
|
|||||||
redisUrl: optional("REDIS_URL", "redis://localhost:6379"),
|
redisUrl: optional("REDIS_URL", "redis://localhost:6379"),
|
||||||
llmUrl: optional("LLM_URL", "http://localhost:1234/v1"),
|
llmUrl: optional("LLM_URL", "http://localhost:1234/v1"),
|
||||||
llmModel: optional("LLM_MODEL", "default"),
|
llmModel: optional("LLM_MODEL", "default"),
|
||||||
|
anthropicApiKey: Bun.env.ANTHROPIC_API_KEY,
|
||||||
|
anthropicModel: Bun.env.ANTHROPIC_MODEL,
|
||||||
cacheTtl: parseInt(optional("CACHE_TTL", "86400"), 10),
|
cacheTtl: parseInt(optional("CACHE_TTL", "86400"), 10),
|
||||||
|
searxngUrl: optional("SEARXNG_URL", "https://searxng.nvictor.me/"),
|
||||||
|
searxngTimeoutMs: parseInt(optional("SEARXNG_TIMEOUT_MS", "10000"), 10),
|
||||||
|
searxngMaxResults: parseInt(optional("SEARXNG_MAX_RESULTS", "10"), 10),
|
||||||
spApiClientId: Bun.env.SP_API_CLIENT_ID,
|
spApiClientId: Bun.env.SP_API_CLIENT_ID,
|
||||||
spApiClientSecret: Bun.env.SP_API_CLIENT_SECRET,
|
spApiClientSecret: Bun.env.SP_API_CLIENT_SECRET,
|
||||||
spApiRefreshToken: Bun.env.SP_API_REFRESH_TOKEN,
|
spApiRefreshToken: Bun.env.SP_API_REFRESH_TOKEN,
|
||||||
|
|||||||
277
src/database.ts
277
src/database.ts
@@ -1,277 +0,0 @@
|
|||||||
import { Database } from "bun:sqlite";
|
|
||||||
export { Database } from "bun:sqlite";
|
|
||||||
|
|
||||||
let db: Database | null = null;
|
|
||||||
|
|
||||||
export function getDb(dbPath: string): Database {
|
|
||||||
if (!db) {
|
|
||||||
db = new Database(dbPath);
|
|
||||||
db.run("PRAGMA journal_mode = WAL;"); // Enable WAL mode for better performance
|
|
||||||
db.run("PRAGMA foreign_keys = ON;"); // Enforce foreign key constraints
|
|
||||||
}
|
|
||||||
return db;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function closeDb(): void {
|
|
||||||
if (db) {
|
|
||||||
db.close();
|
|
||||||
db = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function createProductAnalysisResultsTable(database: Database): void {
|
|
||||||
database.run(`
|
|
||||||
CREATE TABLE IF NOT EXISTS product_analysis_results (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
asin TEXT NOT NULL,
|
|
||||||
run_id INTEGER NOT NULL,
|
|
||||||
name TEXT NOT NULL,
|
|
||||||
brand TEXT,
|
|
||||||
category TEXT,
|
|
||||||
unit_cost REAL,
|
|
||||||
current_price REAL,
|
|
||||||
avg_price_90d REAL,
|
|
||||||
avg_price_90d_sheet REAL,
|
|
||||||
selling_price_sheet REAL,
|
|
||||||
sales_rank INTEGER,
|
|
||||||
sales_rank_avg_90d INTEGER,
|
|
||||||
seller_count INTEGER,
|
|
||||||
monthly_sold INTEGER,
|
|
||||||
rank_drops_30d INTEGER,
|
|
||||||
rank_drops_90d INTEGER,
|
|
||||||
fba_fee REAL,
|
|
||||||
fbm_fee REAL,
|
|
||||||
referral_percent REAL,
|
|
||||||
can_sell TEXT,
|
|
||||||
sellability_status TEXT,
|
|
||||||
sellability_reason TEXT,
|
|
||||||
verdict TEXT NOT NULL,
|
|
||||||
confidence REAL NOT NULL,
|
|
||||||
reasoning TEXT,
|
|
||||||
fetched_at TEXT NOT NULL,
|
|
||||||
UNIQUE(asin),
|
|
||||||
FOREIGN KEY (run_id) REFERENCES category_analysis_runs(id)
|
|
||||||
);
|
|
||||||
`);
|
|
||||||
}
|
|
||||||
|
|
||||||
function ensureProductAnalysisResultsTable(database: Database): void {
|
|
||||||
const tableInfo = database
|
|
||||||
.query("PRAGMA table_info(product_analysis_results)")
|
|
||||||
.all() as Array<{ name: string; pk: number }>;
|
|
||||||
|
|
||||||
if (tableInfo.length === 0) {
|
|
||||||
createProductAnalysisResultsTable(database);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const hasIdColumn = tableInfo.some((col) => col.name === "id");
|
|
||||||
const hasAsinPrimaryKey = tableInfo.some(
|
|
||||||
(col) => col.name === "asin" && col.pk === 1,
|
|
||||||
);
|
|
||||||
|
|
||||||
const indexList = database
|
|
||||||
.query("PRAGMA index_list(product_analysis_results)")
|
|
||||||
.all() as Array<{ name: string; unique: number }>;
|
|
||||||
const hasUniqueAsinConstraint = indexList.some((idx) => {
|
|
||||||
if (idx.unique !== 1) return false;
|
|
||||||
const columns = database
|
|
||||||
.query(`PRAGMA index_info(${JSON.stringify(idx.name)})`)
|
|
||||||
.all() as Array<{ name: string }>;
|
|
||||||
return columns.length === 1 && columns[0]?.name === "asin";
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!hasIdColumn || hasAsinPrimaryKey || !hasUniqueAsinConstraint) {
|
|
||||||
database.run(
|
|
||||||
"ALTER TABLE product_analysis_results RENAME TO product_analysis_results_legacy",
|
|
||||||
);
|
|
||||||
createProductAnalysisResultsTable(database);
|
|
||||||
database.run(`
|
|
||||||
WITH ranked AS (
|
|
||||||
SELECT
|
|
||||||
asin, run_id, name, brand, category, unit_cost,
|
|
||||||
current_price, avg_price_90d, avg_price_90d_sheet,
|
|
||||||
selling_price_sheet, sales_rank, sales_rank_avg_90d,
|
|
||||||
seller_count, monthly_sold, rank_drops_30d, rank_drops_90d,
|
|
||||||
fba_fee, fbm_fee, referral_percent, can_sell,
|
|
||||||
sellability_status, sellability_reason,
|
|
||||||
verdict, confidence, reasoning, fetched_at,
|
|
||||||
ROW_NUMBER() OVER (
|
|
||||||
PARTITION BY asin
|
|
||||||
ORDER BY datetime(fetched_at) DESC, run_id DESC, id DESC
|
|
||||||
) AS row_num
|
|
||||||
FROM product_analysis_results_legacy
|
|
||||||
)
|
|
||||||
INSERT INTO product_analysis_results (
|
|
||||||
asin, run_id, name, brand, category, unit_cost,
|
|
||||||
current_price, avg_price_90d, avg_price_90d_sheet,
|
|
||||||
selling_price_sheet, sales_rank, sales_rank_avg_90d,
|
|
||||||
seller_count, monthly_sold, rank_drops_30d, rank_drops_90d,
|
|
||||||
fba_fee, fbm_fee, referral_percent, can_sell,
|
|
||||||
sellability_status, sellability_reason,
|
|
||||||
verdict, confidence, reasoning, fetched_at
|
|
||||||
)
|
|
||||||
SELECT
|
|
||||||
asin, run_id, name, brand, category, unit_cost,
|
|
||||||
current_price, avg_price_90d, avg_price_90d_sheet,
|
|
||||||
selling_price_sheet, sales_rank, sales_rank_avg_90d,
|
|
||||||
seller_count, monthly_sold, rank_drops_30d, rank_drops_90d,
|
|
||||||
fba_fee, fbm_fee, referral_percent, can_sell,
|
|
||||||
sellability_status, sellability_reason,
|
|
||||||
verdict, confidence, reasoning, fetched_at
|
|
||||||
FROM ranked
|
|
||||||
WHERE row_num = 1
|
|
||||||
`);
|
|
||||||
database.run("DROP TABLE product_analysis_results_legacy");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function ensureResultsTableColumns(database: Database): void {
|
|
||||||
const tableInfo = database
|
|
||||||
.query("PRAGMA table_info(results)")
|
|
||||||
.all() as Array<{ name: string }>;
|
|
||||||
|
|
||||||
if (tableInfo.length === 0) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const existingColumns = new Set(tableInfo.map((col) => col.name));
|
|
||||||
const requiredColumns: Array<{ name: string; type: string }> = [
|
|
||||||
{ name: "fba_net_sheet", type: "REAL" },
|
|
||||||
{ name: "gross_profit_dollar", type: "REAL" },
|
|
||||||
{ name: "gross_profit_pct", type: "REAL" },
|
|
||||||
{ name: "net_profit_sheet", type: "REAL" },
|
|
||||||
{ name: "roi_sheet", type: "REAL" },
|
|
||||||
{ name: "moq", type: "INTEGER" },
|
|
||||||
{ name: "moq_cost", type: "REAL" },
|
|
||||||
{ name: "qty_available", type: "INTEGER" },
|
|
||||||
{ name: "supplier", type: "TEXT" },
|
|
||||||
{ name: "source_url", type: "TEXT" },
|
|
||||||
{ name: "asin_link", type: "TEXT" },
|
|
||||||
{ name: "promo_coupon_code", type: "TEXT" },
|
|
||||||
{ name: "notes", type: "TEXT" },
|
|
||||||
{ name: "lead_date", type: "TEXT" },
|
|
||||||
];
|
|
||||||
|
|
||||||
for (const column of requiredColumns) {
|
|
||||||
if (!existingColumns.has(column.name)) {
|
|
||||||
database.run(
|
|
||||||
`ALTER TABLE results ADD COLUMN ${column.name} ${column.type}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function initDb(dbPath: string): void {
|
|
||||||
const database = getDb(dbPath);
|
|
||||||
database.run(`
|
|
||||||
CREATE TABLE IF NOT EXISTS runs (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
timestamp TEXT NOT NULL,
|
|
||||||
input_file TEXT NOT NULL,
|
|
||||||
output_file TEXT,
|
|
||||||
total_products INTEGER,
|
|
||||||
fba_count INTEGER,
|
|
||||||
fbm_count INTEGER,
|
|
||||||
skip_count INTEGER
|
|
||||||
);
|
|
||||||
`);
|
|
||||||
database.run(`
|
|
||||||
CREATE TABLE IF NOT EXISTS results (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
run_id INTEGER NOT NULL,
|
|
||||||
asin TEXT NOT NULL,
|
|
||||||
product_name TEXT,
|
|
||||||
brand TEXT,
|
|
||||||
category TEXT,
|
|
||||||
unit_cost REAL,
|
|
||||||
current_price REAL,
|
|
||||||
avg_price_90d REAL,
|
|
||||||
avg_price_90d_sheet REAL,
|
|
||||||
selling_price_sheet REAL,
|
|
||||||
sales_rank INTEGER,
|
|
||||||
rank_avg_90d INTEGER,
|
|
||||||
sellers INTEGER,
|
|
||||||
monthly_sold INTEGER,
|
|
||||||
rank_drops_30d INTEGER,
|
|
||||||
rank_drops_90d INTEGER,
|
|
||||||
fba_net_sheet REAL,
|
|
||||||
gross_profit_dollar REAL,
|
|
||||||
gross_profit_pct REAL,
|
|
||||||
net_profit_sheet REAL,
|
|
||||||
roi_sheet REAL,
|
|
||||||
moq INTEGER,
|
|
||||||
moq_cost REAL,
|
|
||||||
qty_available INTEGER,
|
|
||||||
supplier TEXT,
|
|
||||||
source_url TEXT,
|
|
||||||
asin_link TEXT,
|
|
||||||
promo_coupon_code TEXT,
|
|
||||||
notes TEXT,
|
|
||||||
lead_date TEXT,
|
|
||||||
fba_fee REAL,
|
|
||||||
fbm_fee REAL,
|
|
||||||
referral_percent REAL,
|
|
||||||
can_sell TEXT,
|
|
||||||
sellability_status TEXT,
|
|
||||||
sellability_reason TEXT,
|
|
||||||
verdict TEXT NOT NULL,
|
|
||||||
confidence INTEGER,
|
|
||||||
reasoning TEXT,
|
|
||||||
fetched_at TEXT NOT NULL,
|
|
||||||
FOREIGN KEY (run_id) REFERENCES runs(id)
|
|
||||||
);
|
|
||||||
`);
|
|
||||||
ensureResultsTableColumns(database);
|
|
||||||
database.run(`
|
|
||||||
CREATE TABLE IF NOT EXISTS category_analysis_runs (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
category_id INTEGER NOT NULL,
|
|
||||||
category_label TEXT NOT NULL,
|
|
||||||
run_timestamp TEXT NOT NULL,
|
|
||||||
top_asins_checked INTEGER NOT NULL,
|
|
||||||
available_asins INTEGER NOT NULL,
|
|
||||||
fba_count INTEGER NOT NULL,
|
|
||||||
fbm_count INTEGER NOT NULL,
|
|
||||||
skip_count INTEGER NOT NULL,
|
|
||||||
status TEXT NOT NULL,
|
|
||||||
error_message TEXT
|
|
||||||
);
|
|
||||||
`);
|
|
||||||
ensureProductAnalysisResultsTable(database);
|
|
||||||
|
|
||||||
database.run(
|
|
||||||
`CREATE INDEX IF NOT EXISTS idx_runs_timestamp ON runs(timestamp DESC);`,
|
|
||||||
);
|
|
||||||
database.run(
|
|
||||||
`CREATE INDEX IF NOT EXISTS idx_results_run_id ON results(run_id);`,
|
|
||||||
);
|
|
||||||
database.run(
|
|
||||||
`CREATE INDEX IF NOT EXISTS idx_results_verdict ON results(verdict);`,
|
|
||||||
);
|
|
||||||
database.run(
|
|
||||||
`CREATE INDEX IF NOT EXISTS idx_results_sellability_status ON results(sellability_status);`,
|
|
||||||
);
|
|
||||||
database.run(
|
|
||||||
`CREATE INDEX IF NOT EXISTS idx_results_fetched_at ON results(fetched_at DESC);`,
|
|
||||||
);
|
|
||||||
database.run(`CREATE INDEX IF NOT EXISTS idx_results_asin ON results(asin);`);
|
|
||||||
database.run(
|
|
||||||
`CREATE INDEX IF NOT EXISTS idx_category_runs_timestamp ON category_analysis_runs(run_timestamp DESC);`,
|
|
||||||
);
|
|
||||||
database.run(
|
|
||||||
`CREATE INDEX IF NOT EXISTS idx_category_runs_status ON category_analysis_runs(status);`,
|
|
||||||
);
|
|
||||||
database.run(
|
|
||||||
`CREATE INDEX IF NOT EXISTS idx_product_results_run_id ON product_analysis_results(run_id);`,
|
|
||||||
);
|
|
||||||
database.run(
|
|
||||||
`CREATE INDEX IF NOT EXISTS idx_product_results_verdict ON product_analysis_results(verdict);`,
|
|
||||||
);
|
|
||||||
database.run(
|
|
||||||
`CREATE INDEX IF NOT EXISTS idx_product_results_sellability_status ON product_analysis_results(sellability_status);`,
|
|
||||||
);
|
|
||||||
database.run(
|
|
||||||
`CREATE INDEX IF NOT EXISTS idx_product_results_fetched_at ON product_analysis_results(fetched_at DESC);`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
15
src/db/index.ts
Normal file
15
src/db/index.ts
Normal 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
541
src/db/persistence.ts
Normal 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
472
src/db/schema.ts
Normal 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),
|
||||||
|
],
|
||||||
|
);
|
||||||
353
src/index.ts
353
src/index.ts
@@ -1,259 +1,143 @@
|
|||||||
import { readProducts } from "./reader.ts";
|
import { readProducts } from "./reader.ts";
|
||||||
import { fetchKeepaDataBatch } from "./keepa.ts";
|
import { connectCache, disconnectCache } from "./integrations/cache.ts";
|
||||||
import { fetchSellabilityBatch, fetchSpApiPricingAndFees } from "./sp-api.ts";
|
import {
|
||||||
import { connectCache, getCache, setCache, disconnectCache } from "./cache.ts";
|
printResults,
|
||||||
import { analyzeProducts } from "./llm.ts";
|
writeResultsToDb,
|
||||||
import { printResults, writeResultsToDb } from "./writer.ts";
|
writeResultsWorkbook,
|
||||||
import { initDb, closeDb } from "./database.ts";
|
} from "./writer.ts";
|
||||||
|
import {
|
||||||
|
chunkArray,
|
||||||
|
processProductChunk,
|
||||||
|
type SellabilityFilter,
|
||||||
|
} from "./analysis-pipeline.ts";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import type {
|
import type { AnalysisResult } from "./types.ts";
|
||||||
EnrichedProduct,
|
|
||||||
AnalysisResult,
|
|
||||||
KeepaData,
|
|
||||||
ProductRecord,
|
|
||||||
SellabilityInfo,
|
|
||||||
SpApiData,
|
|
||||||
} from "./types.ts";
|
|
||||||
|
|
||||||
const DB_PATH = "./results.db";
|
|
||||||
const LLM_BATCH_SIZE = 5;
|
|
||||||
const INPUT_BATCH_SIZE = 50;
|
const INPUT_BATCH_SIZE = 50;
|
||||||
|
const INPUT_DIR = "input";
|
||||||
|
const OUTPUT_DIR = "output";
|
||||||
|
|
||||||
function parseArgs(): { inputFile: string; outputFile?: string } {
|
function parseSellabilityArg(args: string[]): SellabilityFilter {
|
||||||
const args = process.argv.slice(2);
|
const sellabilityArg = args.find((a) => a.startsWith("--sellability="));
|
||||||
const inputFile = args.find((a) => !a.startsWith("--"));
|
const sellabilityValueFromEquals = sellabilityArg?.split("=")[1];
|
||||||
const outIdx = args.indexOf("--out");
|
const sellabilityIdx = args.indexOf("--sellability");
|
||||||
const outputFile = outIdx !== -1 ? args[outIdx + 1] : undefined;
|
const sellabilityValueFromNext =
|
||||||
|
sellabilityIdx !== -1 ? args[sellabilityIdx + 1] : undefined;
|
||||||
|
const rawSellability = sellabilityValueFromEquals ?? sellabilityValueFromNext;
|
||||||
|
|
||||||
|
if (!rawSellability) return "available";
|
||||||
|
|
||||||
|
const normalized = rawSellability.toLowerCase();
|
||||||
|
if (normalized === "available" || normalized === "all") {
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
if (!inputFile) {
|
|
||||||
console.error(
|
console.error(
|
||||||
"Usage: bun run src/index.ts <input.csv|xlsx> [--out results.csv]",
|
`Invalid --sellability value: \"${rawSellability}\". Use \"available\" or \"all\".`,
|
||||||
|
);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseArgs(): {
|
||||||
|
inputFile: string;
|
||||||
|
outputFile?: string;
|
||||||
|
sellability: SellabilityFilter;
|
||||||
|
useClaude: boolean;
|
||||||
|
} {
|
||||||
|
const args = process.argv.slice(2);
|
||||||
|
const outputFile = readFlagValue(args, "--out", "--output");
|
||||||
|
const useClaude = args.includes("--claude");
|
||||||
|
const inputFileArg = readInputFileArg(
|
||||||
|
args,
|
||||||
|
"--out",
|
||||||
|
"--output",
|
||||||
|
"--sellability",
|
||||||
|
);
|
||||||
|
const sellability = parseSellabilityArg(args);
|
||||||
|
|
||||||
|
if (!inputFileArg) {
|
||||||
|
console.error(
|
||||||
|
"Usage: bun run src/index.ts <input.csv|xlsx> [--out results.xlsx|--output results.xlsx] [--sellability available|all] [--claude]\nBare filenames are read from input/ and written to output/.",
|
||||||
);
|
);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
return { inputFile, outputFile };
|
return {
|
||||||
|
inputFile: resolveInputPath(inputFileArg),
|
||||||
|
outputFile,
|
||||||
|
sellability,
|
||||||
|
useClaude,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function chunkArray<T>(items: T[], chunkSize: number): T[][] {
|
function readFlagValue(args: string[], ...flags: string[]): string | undefined {
|
||||||
const chunks: T[][] = [];
|
for (const flag of flags) {
|
||||||
for (let i = 0; i < items.length; i += chunkSize) {
|
const equalsArg = args.find((arg) => arg.startsWith(`${flag}=`));
|
||||||
chunks.push(items.slice(i, i + chunkSize));
|
if (equalsArg) {
|
||||||
|
const value = equalsArg.slice(flag.length + 1);
|
||||||
|
if (value) return value;
|
||||||
}
|
}
|
||||||
return chunks;
|
|
||||||
|
const flagIdx = args.indexOf(flag);
|
||||||
|
if (flagIdx !== -1) {
|
||||||
|
return args[flagIdx + 1];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readInputFileArg(
|
||||||
|
args: string[],
|
||||||
|
...flagsWithValues: string[]
|
||||||
|
): string | undefined {
|
||||||
|
for (let i = 0; i < args.length; i++) {
|
||||||
|
const arg = args[i]!;
|
||||||
|
if (flagsWithValues.includes(arg)) {
|
||||||
|
i++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (flagsWithValues.some((flag) => arg.startsWith(`${flag}=`))) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!arg.startsWith("--")) {
|
||||||
|
return arg;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isBareFilename(filePath: string): boolean {
|
||||||
|
return !path.isAbsolute(filePath) && !/[\\/]/.test(filePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveInputPath(inputFile: string): string {
|
||||||
|
return isBareFilename(inputFile)
|
||||||
|
? path.join(INPUT_DIR, inputFile)
|
||||||
|
: inputFile;
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveBaseOutputPath(inputFile: string, outputFile?: string): string {
|
function resolveBaseOutputPath(inputFile: string, outputFile?: string): string {
|
||||||
if (outputFile) return outputFile;
|
if (outputFile) {
|
||||||
|
return isBareFilename(outputFile)
|
||||||
|
? path.join(OUTPUT_DIR, outputFile)
|
||||||
|
: outputFile;
|
||||||
|
}
|
||||||
|
|
||||||
const parsedInput = path.parse(inputFile);
|
const parsedInput = path.parse(inputFile);
|
||||||
return path.join(parsedInput.dir, `${parsedInput.name}_results.xlsx`);
|
return path.join(OUTPUT_DIR, `${parsedInput.name}_results.xlsx`);
|
||||||
}
|
|
||||||
|
|
||||||
async function processProductChunk(
|
|
||||||
products: ProductRecord[],
|
|
||||||
): Promise<AnalysisResult[]> {
|
|
||||||
console.log(`\nChecking cache for ${products.length} products...`);
|
|
||||||
const cached = new Map<string, EnrichedProduct>();
|
|
||||||
const excludedCachedAsins = new Set<string>();
|
|
||||||
const uncachedProducts: ProductRecord[] = [];
|
|
||||||
|
|
||||||
for (const p of products) {
|
|
||||||
const hit = await getCache(p.asin);
|
|
||||||
if (hit) {
|
|
||||||
if (hit.spApi.sellabilityStatus === "available") {
|
|
||||||
console.log(` [cache hit] ${p.asin}`);
|
|
||||||
cached.set(p.asin, hit);
|
|
||||||
} else {
|
|
||||||
excludedCachedAsins.add(p.asin);
|
|
||||||
console.log(
|
|
||||||
` [exclude cached] ${p.asin} — status=${hit.spApi.sellabilityStatus}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
uncachedProducts.push(p);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(
|
|
||||||
`${cached.size} cached available, ${excludedCachedAsins.size} cached excluded, ${uncachedProducts.length} to fetch`,
|
|
||||||
);
|
|
||||||
|
|
||||||
const sellabilityMap = new Map<string, SellabilityInfo>();
|
|
||||||
const availableProducts: ProductRecord[] = [];
|
|
||||||
const unavailableProducts: ProductRecord[] = [];
|
|
||||||
|
|
||||||
if (uncachedProducts.length > 0) {
|
|
||||||
console.log(
|
|
||||||
`\nChecking sellability for ${uncachedProducts.length} ASINs...`,
|
|
||||||
);
|
|
||||||
const sellResults = await fetchSellabilityBatch(
|
|
||||||
uncachedProducts.map((p) => p.asin),
|
|
||||||
);
|
|
||||||
|
|
||||||
for (const p of uncachedProducts) {
|
|
||||||
const info = sellResults.get(p.asin) ?? {
|
|
||||||
canSell: null,
|
|
||||||
sellabilityStatus: "unknown" as const,
|
|
||||||
sellabilityReason: "Sellability check returned no result",
|
|
||||||
};
|
|
||||||
sellabilityMap.set(p.asin, info);
|
|
||||||
|
|
||||||
if (info.sellabilityStatus === "available") {
|
|
||||||
availableProducts.push(p);
|
|
||||||
console.log(` [available] ${p.asin} — status=${info.sellabilityStatus}`);
|
|
||||||
} else {
|
|
||||||
unavailableProducts.push(p);
|
|
||||||
console.log(
|
|
||||||
` [exclude] ${p.asin} — status=${info.sellabilityStatus}, reason=${info.sellabilityReason ?? "n/a"}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(
|
|
||||||
`\nSellability gate: ${availableProducts.length} available, ${unavailableProducts.length} excluded`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
let keepaResults = new Map<string, KeepaData>();
|
|
||||||
if (availableProducts.length > 0) {
|
|
||||||
console.log(`\nFetching ${availableProducts.length} ASINs from Keepa...`);
|
|
||||||
try {
|
|
||||||
keepaResults = await fetchKeepaDataBatch(
|
|
||||||
availableProducts.map((p) => p.asin),
|
|
||||||
);
|
|
||||||
} catch (err) {
|
|
||||||
console.warn(`Keepa batch fetch failed: ${err}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(
|
|
||||||
`\nFetching pricing & fees for ${availableProducts.length} ASINs...`,
|
|
||||||
);
|
|
||||||
const spApiResults = new Map<string, SpApiData>();
|
|
||||||
const pricingQueue = [...availableProducts];
|
|
||||||
let pricingDone = 0;
|
|
||||||
|
|
||||||
async function fetchNextPricing(): Promise<void> {
|
|
||||||
while (pricingQueue.length > 0) {
|
|
||||||
const p = pricingQueue.shift()!;
|
|
||||||
const sellability = sellabilityMap.get(p.asin)!;
|
|
||||||
const spApi = await fetchSpApiPricingAndFees(p.asin, sellability);
|
|
||||||
|
|
||||||
const keepa = keepaResults.get(p.asin);
|
|
||||||
if (keepa?.currentPrice && spApi.estimatedSalePrice === 0) {
|
|
||||||
spApi.estimatedSalePrice = keepa.currentPrice;
|
|
||||||
}
|
|
||||||
|
|
||||||
spApiResults.set(p.asin, spApi);
|
|
||||||
pricingDone++;
|
|
||||||
if (pricingDone % 10 === 0 || pricingDone === availableProducts.length) {
|
|
||||||
console.log(
|
|
||||||
` [pricing] ${pricingDone}/${availableProducts.length} fetched`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const pricingWorkers = Array.from(
|
|
||||||
{ length: Math.min(5, availableProducts.length || 1) },
|
|
||||||
() => fetchNextPricing(),
|
|
||||||
);
|
|
||||||
await Promise.all(pricingWorkers);
|
|
||||||
|
|
||||||
console.log(`\nEnriching products...`);
|
|
||||||
const enriched: EnrichedProduct[] = [];
|
|
||||||
const availableAsins = new Set(availableProducts.map((ap) => ap.asin));
|
|
||||||
|
|
||||||
for (const p of products) {
|
|
||||||
if (excludedCachedAsins.has(p.asin)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const cachedProduct = cached.get(p.asin);
|
|
||||||
if (cachedProduct) {
|
|
||||||
enriched.push(cachedProduct);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!availableAsins.has(p.asin)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const keepa = keepaResults.get(p.asin) ?? null;
|
|
||||||
const spApi = spApiResults.get(p.asin) ?? {
|
|
||||||
fbaFee: 5.0,
|
|
||||||
fbmFee: 1.5,
|
|
||||||
referralFeePercent: 15,
|
|
||||||
estimatedSalePrice: 0,
|
|
||||||
canSell: null,
|
|
||||||
sellabilityStatus: "unknown" as const,
|
|
||||||
sellabilityReason: "SP-API data missing",
|
|
||||||
};
|
|
||||||
|
|
||||||
const product: EnrichedProduct = {
|
|
||||||
record: p,
|
|
||||||
keepa,
|
|
||||||
spApi,
|
|
||||||
fetchedAt: new Date().toISOString(),
|
|
||||||
};
|
|
||||||
|
|
||||||
await setCache(p.asin, product);
|
|
||||||
enriched.push(product);
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(
|
|
||||||
`\nAnalyzing ${enriched.length} products via LLM (batch size: ${LLM_BATCH_SIZE})...\n`,
|
|
||||||
);
|
|
||||||
|
|
||||||
const results: AnalysisResult[] = [];
|
|
||||||
for (let i = 0; i < enriched.length; i += LLM_BATCH_SIZE) {
|
|
||||||
const batch = enriched.slice(i, i + LLM_BATCH_SIZE);
|
|
||||||
const batchNum = Math.floor(i / LLM_BATCH_SIZE) + 1;
|
|
||||||
const totalBatches = Math.ceil(enriched.length / LLM_BATCH_SIZE);
|
|
||||||
console.log(` LLM batch ${batchNum}/${totalBatches}...`);
|
|
||||||
|
|
||||||
if (i > 0) {
|
|
||||||
await new Promise((r) => setTimeout(r, 5000));
|
|
||||||
}
|
|
||||||
|
|
||||||
let verdicts;
|
|
||||||
try {
|
|
||||||
verdicts = await analyzeProducts(batch);
|
|
||||||
} catch {
|
|
||||||
await new Promise((r) => setTimeout(r, 10_000));
|
|
||||||
try {
|
|
||||||
verdicts = await analyzeProducts(batch);
|
|
||||||
} catch {
|
|
||||||
verdicts = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for (let j = 0; j < batch.length; j++) {
|
|
||||||
results.push({
|
|
||||||
product: batch[j]!,
|
|
||||||
verdict: verdicts?.[j] ?? {
|
|
||||||
asin: batch[j]!.record.asin,
|
|
||||||
verdict: "SKIP",
|
|
||||||
confidence: 0,
|
|
||||||
reasoning: "LLM analysis failed",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return results;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
const { inputFile, outputFile } = parseArgs();
|
const { inputFile, outputFile, sellability, useClaude } = parseArgs();
|
||||||
|
|
||||||
|
console.log(`Sellability filter: ${sellability}`);
|
||||||
|
console.log(`LLM provider: ${useClaude ? "claude" : "local"}`);
|
||||||
|
|
||||||
console.log("Connecting to Redis...");
|
console.log("Connecting to Redis...");
|
||||||
await connectCache();
|
await connectCache();
|
||||||
|
|
||||||
console.log("Initializing SQLite database...");
|
|
||||||
initDb(DB_PATH);
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
console.log(`\nReading ${inputFile}...`);
|
console.log(`\nReading ${inputFile}...`);
|
||||||
const products = readProducts(inputFile);
|
const products = readProducts(inputFile);
|
||||||
@@ -279,15 +163,18 @@ async function main() {
|
|||||||
console.log(
|
console.log(
|
||||||
`\n=== Input chunk ${chunkIndex + 1}/${productChunks.length} (${chunk.length} products) ===`,
|
`\n=== Input chunk ${chunkIndex + 1}/${productChunks.length} (${chunk.length} products) ===`,
|
||||||
);
|
);
|
||||||
const chunkResults = await processProductChunk(chunk);
|
const chunkResults = await processProductChunk(chunk, {
|
||||||
|
sellability,
|
||||||
|
useClaude,
|
||||||
|
});
|
||||||
allResults.push(...chunkResults);
|
allResults.push(...chunkResults);
|
||||||
}
|
}
|
||||||
|
|
||||||
printResults(allResults);
|
printResults(allResults);
|
||||||
writeResultsToDb(allResults, DB_PATH, inputFile, outputFile);
|
writeResultsWorkbook(allResults, resolvedBaseOutputPath);
|
||||||
|
await writeResultsToDb(allResults, inputFile, resolvedBaseOutputPath);
|
||||||
} finally {
|
} finally {
|
||||||
await disconnectCache();
|
await disconnectCache();
|
||||||
closeDb();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,21 @@
|
|||||||
import Redis from "ioredis";
|
import Redis from "ioredis";
|
||||||
import { config } from "./config.ts";
|
import { config } from "../config.ts";
|
||||||
import type { EnrichedProduct } from "./types.ts";
|
import type { EnrichedProduct, KeepaData, SpApiData } from "../types.ts";
|
||||||
|
|
||||||
let redis: Redis | null = null;
|
let redis: Redis | null = null;
|
||||||
let disabled = false;
|
let disabled = false;
|
||||||
|
|
||||||
|
export type ApiCacheEntry = {
|
||||||
|
title: string;
|
||||||
|
keepa: KeepaData | null;
|
||||||
|
spApi: SpApiData;
|
||||||
|
fetchedAt: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function getApiCacheKey(asin: string): string {
|
||||||
|
return `api:asin:${asin}`;
|
||||||
|
}
|
||||||
|
|
||||||
export async function connectCache(): Promise<void> {
|
export async function connectCache(): Promise<void> {
|
||||||
if (disabled) return;
|
if (disabled) return;
|
||||||
try {
|
try {
|
||||||
@@ -58,6 +69,35 @@ export async function setCache(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getApiCache(asin: string): Promise<ApiCacheEntry | null> {
|
||||||
|
if (!redis) return null;
|
||||||
|
try {
|
||||||
|
const raw = await redis.get(getApiCacheKey(asin));
|
||||||
|
if (!raw) return null;
|
||||||
|
return JSON.parse(raw) as ApiCacheEntry;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setApiCache(
|
||||||
|
asin: string,
|
||||||
|
data: ApiCacheEntry,
|
||||||
|
ttlSeconds: number,
|
||||||
|
): Promise<void> {
|
||||||
|
if (!redis) return;
|
||||||
|
try {
|
||||||
|
await redis.set(
|
||||||
|
getApiCacheKey(asin),
|
||||||
|
JSON.stringify(data),
|
||||||
|
"EX",
|
||||||
|
ttlSeconds,
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
// Non-critical, continue without caching
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function disconnectCache(): Promise<void> {
|
export async function disconnectCache(): Promise<void> {
|
||||||
if (redis) {
|
if (redis) {
|
||||||
await redis.quit();
|
await redis.quit();
|
||||||
289
src/integrations/keepa.test.ts
Normal file
289
src/integrations/keepa.test.ts
Normal file
@@ -0,0 +1,289 @@
|
|||||||
|
import { afterAll, beforeEach, expect, mock, test } from "bun:test";
|
||||||
|
import { fetchKeepaDataBatch, lookupKeepaUpcs, mapUpcsToAsins } from "./keepa.ts";
|
||||||
|
|
||||||
|
const originalFetch = globalThis.fetch;
|
||||||
|
|
||||||
|
function makeUpc(index: number): string {
|
||||||
|
return String(index).padStart(12, "0");
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
globalThis.fetch = originalFetch;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
globalThis.fetch = originalFetch;
|
||||||
|
});
|
||||||
|
|
||||||
|
test("lookupKeepaUpcs marks invalid UPCs and skips API calls", async () => {
|
||||||
|
const fetchMock = mock(async () => {
|
||||||
|
return new Response("should not be called", { status: 500 });
|
||||||
|
});
|
||||||
|
globalThis.fetch = fetchMock as unknown as typeof globalThis.fetch;
|
||||||
|
|
||||||
|
const details = await lookupKeepaUpcs([
|
||||||
|
"",
|
||||||
|
"abc",
|
||||||
|
"12345678901",
|
||||||
|
"123456789012345",
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(fetchMock.mock.calls.length).toBe(0);
|
||||||
|
expect(details.size).toBe(4);
|
||||||
|
expect(details.get("")?.status).toBe("invalid_upc");
|
||||||
|
expect(details.get("abc")?.status).toBe("invalid_upc");
|
||||||
|
expect(details.get("12345678901")?.status).toBe("invalid_upc");
|
||||||
|
expect(details.get("123456789012345")?.status).toBe("invalid_upc");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("lookupKeepaUpcs returns found, not_found, and multiple_asins outcomes", async () => {
|
||||||
|
globalThis.fetch = mock(async () => {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
products: [
|
||||||
|
{
|
||||||
|
asin: "B000FND001",
|
||||||
|
upcList: ["012345678901"],
|
||||||
|
stats: {
|
||||||
|
current: [null, null, null, 1234],
|
||||||
|
avg: [2500, null, null, 1400],
|
||||||
|
},
|
||||||
|
csv: [[5000000, 2999, 5000100]],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
asin: "B000MUL001",
|
||||||
|
upcList: ["098765432109"],
|
||||||
|
stats: {
|
||||||
|
current: [null, null, null, 2000],
|
||||||
|
avg: [1800, null, null, 2200],
|
||||||
|
},
|
||||||
|
csv: [[1, 1999]],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
asin: "B000MUL002",
|
||||||
|
upcList: ["098765432109"],
|
||||||
|
stats: {
|
||||||
|
current: [null, null, null, 2100],
|
||||||
|
avg: [1850, null, null, 2250],
|
||||||
|
},
|
||||||
|
csv: [[1, 2099]],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
tokensLeft: 10,
|
||||||
|
refillRate: 1,
|
||||||
|
}),
|
||||||
|
{ status: 200 },
|
||||||
|
);
|
||||||
|
}) as unknown as typeof globalThis.fetch;
|
||||||
|
|
||||||
|
const details = await lookupKeepaUpcs([
|
||||||
|
"012345678901",
|
||||||
|
"098765432109",
|
||||||
|
"111111111111",
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(details.get("012345678901")?.status).toBe("found");
|
||||||
|
expect(details.get("012345678901")?.asin).toBe("B000FND001");
|
||||||
|
expect(details.get("012345678901")?.keepaData?.currentPrice).toBe(29.99);
|
||||||
|
expect(details.get("012345678901")?.keepaData?.currentPrice).toBe(29.99);
|
||||||
|
|
||||||
|
expect(details.get("098765432109")?.status).toBe("multiple_asins");
|
||||||
|
expect(details.get("098765432109")?.candidateAsins).toEqual([
|
||||||
|
"B000MUL001",
|
||||||
|
"B000MUL002",
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(details.get("111111111111")?.status).toBe("not_found");
|
||||||
|
|
||||||
|
const simpleMap = await mapUpcsToAsins([
|
||||||
|
"012345678901",
|
||||||
|
"098765432109",
|
||||||
|
"111111111111",
|
||||||
|
]);
|
||||||
|
expect(simpleMap.get("012345678901")).toBe("B000FND001");
|
||||||
|
expect(simpleMap.has("098765432109")).toBe(false);
|
||||||
|
expect(simpleMap.has("111111111111")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("lookupKeepaUpcs keeps partial success when one chunk fails", async () => {
|
||||||
|
const upcs = Array.from({ length: 101 }, (_, i) => makeUpc(700000000000 + i));
|
||||||
|
const firstChunkFirstUpc = upcs[0]!;
|
||||||
|
const secondChunkUpc = upcs[100]!;
|
||||||
|
|
||||||
|
globalThis.fetch = mock(async (input: string | URL | Request) => {
|
||||||
|
const rawUrl =
|
||||||
|
typeof input === "string"
|
||||||
|
? input
|
||||||
|
: input instanceof URL
|
||||||
|
? input.toString()
|
||||||
|
: input.url;
|
||||||
|
const url = new URL(rawUrl);
|
||||||
|
const codes = (url.searchParams.get("code") ?? "").split(",");
|
||||||
|
|
||||||
|
if (codes.includes(firstChunkFirstUpc)) {
|
||||||
|
return new Response("first chunk failed", { status: 500 });
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
products: [
|
||||||
|
{
|
||||||
|
asin: "B000LST001",
|
||||||
|
upcList: [secondChunkUpc],
|
||||||
|
stats: {
|
||||||
|
current: [null, null, null, 1000],
|
||||||
|
avg: [1500, null, null, 1200],
|
||||||
|
},
|
||||||
|
csv: [[1, 1599]],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
tokensLeft: 10,
|
||||||
|
refillRate: 1,
|
||||||
|
}),
|
||||||
|
{ status: 200 },
|
||||||
|
);
|
||||||
|
}) as unknown as typeof globalThis.fetch;
|
||||||
|
|
||||||
|
const details = await lookupKeepaUpcs(upcs);
|
||||||
|
|
||||||
|
expect(details.get(firstChunkFirstUpc)?.status).toBe("request_failed");
|
||||||
|
expect(details.get(secondChunkUpc)?.status).toBe("found");
|
||||||
|
expect(details.get(secondChunkUpc)?.asin).toBe("B000LST001");
|
||||||
|
|
||||||
|
const simpleMap = await mapUpcsToAsins(upcs);
|
||||||
|
expect(simpleMap.has(firstChunkFirstUpc)).toBe(false);
|
||||||
|
expect(simpleMap.get(secondChunkUpc)).toBe("B000LST001");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("lookupKeepaUpcs retries on 429 and succeeds after refill wait", async () => {
|
||||||
|
const targetUpc = "123456789012";
|
||||||
|
const fetchMock = mock(async () => {
|
||||||
|
const callNumber = fetchMock.mock.calls.length;
|
||||||
|
|
||||||
|
if (callNumber === 1) {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
refillIn: 0,
|
||||||
|
refillRate: 21,
|
||||||
|
tokensLeft: -1,
|
||||||
|
}),
|
||||||
|
{ status: 429 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
products: [
|
||||||
|
{
|
||||||
|
asin: "B000RTY001",
|
||||||
|
upcList: [targetUpc],
|
||||||
|
stats: {
|
||||||
|
current: [null, null, null, 1111],
|
||||||
|
avg: [1299, null, null, 1234],
|
||||||
|
},
|
||||||
|
csv: [[1, 1399]],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
tokensLeft: 10,
|
||||||
|
refillRate: 21,
|
||||||
|
}),
|
||||||
|
{ status: 200 },
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
globalThis.fetch = fetchMock as unknown as typeof globalThis.fetch;
|
||||||
|
|
||||||
|
const details = await lookupKeepaUpcs([targetUpc]);
|
||||||
|
|
||||||
|
expect(fetchMock.mock.calls.length).toBe(2);
|
||||||
|
expect(details.get(targetUpc)?.status).toBe("found");
|
||||||
|
expect(details.get(targetUpc)?.asin).toBe("B000RTY001");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("lookupKeepaUpcs uses lightweight query params for code mapping", async () => {
|
||||||
|
const targetUpc = "555555555555";
|
||||||
|
const fetchMock = mock(async (input: string | URL | Request) => {
|
||||||
|
const rawUrl =
|
||||||
|
typeof input === "string"
|
||||||
|
? input
|
||||||
|
: input instanceof URL
|
||||||
|
? input.toString()
|
||||||
|
: input.url;
|
||||||
|
|
||||||
|
const url = new URL(rawUrl);
|
||||||
|
expect(url.searchParams.get("code")).toBe(targetUpc);
|
||||||
|
expect(url.searchParams.has("stats")).toBe(false);
|
||||||
|
expect(url.searchParams.has("buybox")).toBe(false);
|
||||||
|
expect(url.searchParams.has("days")).toBe(false);
|
||||||
|
expect(url.searchParams.get("history")).toBe("0");
|
||||||
|
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
products: [
|
||||||
|
{
|
||||||
|
asin: "B000LGT001",
|
||||||
|
upcList: [targetUpc],
|
||||||
|
categoryTree: [{ name: "Test Category" }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
tokensLeft: 10,
|
||||||
|
refillRate: 21,
|
||||||
|
}),
|
||||||
|
{ status: 200 },
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
globalThis.fetch = fetchMock as unknown as typeof globalThis.fetch;
|
||||||
|
|
||||||
|
const details = await lookupKeepaUpcs([targetUpc]);
|
||||||
|
|
||||||
|
expect(fetchMock.mock.calls.length).toBe(1);
|
||||||
|
expect(details.get(targetUpc)?.status).toBe("found");
|
||||||
|
expect(details.get(targetUpc)?.asin).toBe("B000LGT001");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("fetchKeepaDataBatch uses token-efficient params", async () => {
|
||||||
|
const targetAsin = "B000EFF001";
|
||||||
|
const fetchMock = mock(async (input: string | URL | Request) => {
|
||||||
|
const rawUrl =
|
||||||
|
typeof input === "string"
|
||||||
|
? input
|
||||||
|
: input instanceof URL
|
||||||
|
? input.toString()
|
||||||
|
: input.url;
|
||||||
|
|
||||||
|
const url = new URL(rawUrl);
|
||||||
|
expect(url.searchParams.get("asin")).toBe(targetAsin);
|
||||||
|
expect(url.searchParams.get("stats")).toBe("90");
|
||||||
|
expect(url.searchParams.get("days")).toBe("90");
|
||||||
|
expect(url.searchParams.get("history")).toBe("0");
|
||||||
|
expect(url.searchParams.has("buybox")).toBe(false);
|
||||||
|
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
products: [
|
||||||
|
{
|
||||||
|
asin: targetAsin,
|
||||||
|
stats: {
|
||||||
|
current: [1999, null, null, 1234, null, null, null, null, null, null, null, 8],
|
||||||
|
avg: [2099, null, null, 1300],
|
||||||
|
min: [1799],
|
||||||
|
max: [2299],
|
||||||
|
},
|
||||||
|
csv: [[1, 1999]],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
tokensLeft: 9,
|
||||||
|
refillRate: 21,
|
||||||
|
}),
|
||||||
|
{ status: 200 },
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
globalThis.fetch = fetchMock as unknown as typeof globalThis.fetch;
|
||||||
|
|
||||||
|
const details = await fetchKeepaDataBatch([targetAsin]);
|
||||||
|
|
||||||
|
expect(fetchMock.mock.calls.length).toBe(1);
|
||||||
|
expect(details.get(targetAsin)?.currentPrice).toBe(19.99);
|
||||||
|
});
|
||||||
580
src/integrations/keepa.ts
Normal file
580
src/integrations/keepa.ts
Normal file
@@ -0,0 +1,580 @@
|
|||||||
|
import { config } from "../config.ts";
|
||||||
|
import { normalizeAsin } from "../asin.ts";
|
||||||
|
import type { KeepaData, KeepaUpcLookupDetail } from "../types.ts";
|
||||||
|
|
||||||
|
const KEEPA_BASE = "https://api.keepa.com";
|
||||||
|
const MAX_ASINS_PER_REQUEST = 100;
|
||||||
|
const MAX_CODES_PER_REQUEST = MAX_ASINS_PER_REQUEST;
|
||||||
|
const MAX_KEEPA_RETRIES = 4;
|
||||||
|
const KEEP_RETRY_BUFFER_MS = 250;
|
||||||
|
const AMAZON_US_SELLER_ID = "ATVPDKIKX0DER";
|
||||||
|
const KEEPA_MINUTES_OFFSET = 21_564_000;
|
||||||
|
const UPC_PATTERN = /^\d{12,14}$/;
|
||||||
|
|
||||||
|
type KeepaApiResponse = {
|
||||||
|
products?: Record<string, any>[];
|
||||||
|
tokensLeft?: number;
|
||||||
|
refillRate?: number;
|
||||||
|
refillIn?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Token-based rate limiting based on Keepa's tokensLeft/refillRate response fields.
|
||||||
|
// Actual token cost can be greater than 1 depending on endpoint parameters and payload.
|
||||||
|
// The client keeps request pace using tokensLeft/refillRate/refillIn to avoid 429 bursts.
|
||||||
|
let tokensLeft = 1; // Conservative start; updated from API response
|
||||||
|
let refillRate = 1; // tokens per minute, updated from API response
|
||||||
|
let lastRequestTime = 0;
|
||||||
|
|
||||||
|
async function waitForToken(): Promise<void> {
|
||||||
|
if (tokensLeft > 0) return;
|
||||||
|
|
||||||
|
const elapsed = (Date.now() - lastRequestTime) / 60_000; // minutes
|
||||||
|
const regenerated = Math.floor(elapsed * refillRate);
|
||||||
|
if (regenerated > 0) {
|
||||||
|
tokensLeft += regenerated;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait until we regenerate at least 1 token
|
||||||
|
const waitMs =
|
||||||
|
Math.ceil((1 / refillRate) * 60_000) - (Date.now() - lastRequestTime);
|
||||||
|
if (waitMs > 0) {
|
||||||
|
console.log(
|
||||||
|
`Keepa tokens exhausted. Waiting ${Math.ceil(waitMs / 1000)}s for token regeneration...`,
|
||||||
|
);
|
||||||
|
await new Promise((r) => setTimeout(r, waitMs));
|
||||||
|
}
|
||||||
|
tokensLeft = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
function wait(ms: number): Promise<void> {
|
||||||
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildProductUrl(
|
||||||
|
queryParam: "asin" | "code",
|
||||||
|
values: string[],
|
||||||
|
options?: {
|
||||||
|
includeStats?: boolean;
|
||||||
|
includeBuybox?: boolean;
|
||||||
|
includeHistory?: boolean;
|
||||||
|
days?: number;
|
||||||
|
},
|
||||||
|
): string {
|
||||||
|
const includeStats = options?.includeStats ?? true;
|
||||||
|
const includeBuybox = options?.includeBuybox ?? true;
|
||||||
|
const includeHistory = options?.includeHistory ?? true;
|
||||||
|
const days = options?.days ?? 90;
|
||||||
|
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
key: config.keepaApiKey,
|
||||||
|
domain: "1",
|
||||||
|
});
|
||||||
|
|
||||||
|
if (includeStats) {
|
||||||
|
params.set("stats", String(days));
|
||||||
|
params.set("days", String(days));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (includeBuybox) {
|
||||||
|
params.set("buybox", "1");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!includeHistory) {
|
||||||
|
params.set("history", "0");
|
||||||
|
}
|
||||||
|
|
||||||
|
params.set(queryParam, values.join(","));
|
||||||
|
return `${KEEPA_BASE}/product?${params.toString()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateTokenState(data: KeepaApiResponse): void {
|
||||||
|
if (data.tokensLeft != null) tokensLeft = data.tokensLeft;
|
||||||
|
if (data.refillRate != null) refillRate = data.refillRate;
|
||||||
|
}
|
||||||
|
|
||||||
|
function computeWaitMsFromRefill(refillIn?: number): number {
|
||||||
|
if (
|
||||||
|
typeof refillIn === "number" &&
|
||||||
|
Number.isFinite(refillIn) &&
|
||||||
|
refillIn >= 0
|
||||||
|
) {
|
||||||
|
return Math.max(
|
||||||
|
Math.ceil(refillIn) + KEEP_RETRY_BUFFER_MS,
|
||||||
|
KEEP_RETRY_BUFFER_MS,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const safeRefillRate = Math.max(1, refillRate);
|
||||||
|
return Math.ceil((1 / safeRefillRate) * 60_000) + KEEP_RETRY_BUFFER_MS;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseErrorPayload(text: string): KeepaApiResponse | null {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(text) as KeepaApiResponse;
|
||||||
|
return parsed && typeof parsed === "object" ? parsed : null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchKeepaWithRetries(
|
||||||
|
url: string,
|
||||||
|
operationLabel: string,
|
||||||
|
): Promise<KeepaApiResponse> {
|
||||||
|
let lastErrorMessage = "Unknown Keepa error";
|
||||||
|
|
||||||
|
for (let attempt = 1; attempt <= MAX_KEEPA_RETRIES; attempt++) {
|
||||||
|
await waitForToken();
|
||||||
|
|
||||||
|
const res = await fetch(url);
|
||||||
|
lastRequestTime = Date.now();
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
const data = (await res.json()) as KeepaApiResponse;
|
||||||
|
updateTokenState(data);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
const text = await res.text();
|
||||||
|
const payload = parseErrorPayload(text);
|
||||||
|
if (payload) {
|
||||||
|
updateTokenState(payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
lastErrorMessage = `Keepa API error ${res.status}: ${text}`;
|
||||||
|
|
||||||
|
if (res.status !== 429 || attempt === MAX_KEEPA_RETRIES) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
const waitMs = computeWaitMsFromRefill(payload?.refillIn);
|
||||||
|
tokensLeft = Math.min(tokensLeft, 0);
|
||||||
|
console.warn(
|
||||||
|
`Keepa throttled during ${operationLabel} (attempt ${attempt}/${MAX_KEEPA_RETRIES}). Waiting ${Math.ceil(waitMs / 1000)}s before retry...`,
|
||||||
|
);
|
||||||
|
await wait(waitMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(lastErrorMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeUpc(input: string): string {
|
||||||
|
return input.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function isValidUpc(value: string): boolean {
|
||||||
|
return UPC_PATTERN.test(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeCodeFromKeepa(value: string): string {
|
||||||
|
return value.replace(/\D/g, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectCodes(value: unknown, target: Set<string>): void {
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
for (const item of value) {
|
||||||
|
collectCodes(item, target);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof value === "number" && Number.isFinite(value)) {
|
||||||
|
const normalized = normalizeCodeFromKeepa(String(Math.trunc(value)));
|
||||||
|
if (isValidUpc(normalized)) target.add(normalized);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof value !== "string") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const rawPart of value.split(/[\s,;|]+/)) {
|
||||||
|
if (!rawPart) continue;
|
||||||
|
const normalized = normalizeCodeFromKeepa(rawPart);
|
||||||
|
if (isValidUpc(normalized)) target.add(normalized);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractUpcsFromProduct(product: Record<string, any>): string[] {
|
||||||
|
const codes = new Set<string>();
|
||||||
|
const candidates: unknown[] = [
|
||||||
|
product.upcList,
|
||||||
|
product.upc,
|
||||||
|
product.eanList,
|
||||||
|
product.ean,
|
||||||
|
product.gtinList,
|
||||||
|
product.gtin,
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const candidate of candidates) {
|
||||||
|
collectCodes(candidate, codes);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Array.from(codes);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildFailureDetail(
|
||||||
|
upc: string,
|
||||||
|
status: "invalid_upc" | "not_found" | "multiple_asins" | "request_failed",
|
||||||
|
reason: string,
|
||||||
|
candidateAsins: string[] = [],
|
||||||
|
): KeepaUpcLookupDetail {
|
||||||
|
return {
|
||||||
|
requestedUpc: upc,
|
||||||
|
normalizedUpc: upc,
|
||||||
|
status,
|
||||||
|
asin: null,
|
||||||
|
candidateAsins,
|
||||||
|
keepaData: null,
|
||||||
|
reason,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchKeepaDataBatch(
|
||||||
|
asins: string[],
|
||||||
|
): Promise<Map<string, KeepaData>> {
|
||||||
|
const results = new Map<string, KeepaData>();
|
||||||
|
const canonicalAsins = Array.from(
|
||||||
|
new Set(
|
||||||
|
asins
|
||||||
|
.map((asin) => normalizeAsin(asin))
|
||||||
|
.filter((asin): asin is string => asin !== null),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Split into chunks of MAX_ASINS_PER_REQUEST
|
||||||
|
for (let i = 0; i < canonicalAsins.length; i += MAX_ASINS_PER_REQUEST) {
|
||||||
|
const chunk = canonicalAsins.slice(i, i + MAX_ASINS_PER_REQUEST);
|
||||||
|
const url = buildProductUrl("asin", chunk, {
|
||||||
|
includeStats: true,
|
||||||
|
includeBuybox: false,
|
||||||
|
includeHistory: false,
|
||||||
|
days: 90,
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`Keepa: fetching ${chunk.length} ASINs (tokens left: ${tokensLeft})...`,
|
||||||
|
);
|
||||||
|
|
||||||
|
const data = await fetchKeepaWithRetries(url, "ASIN batch fetch");
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`Keepa: ${data.products?.length ?? 0} products returned, ${tokensLeft} tokens remaining (refill: ${refillRate}/min)`,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (data.products) {
|
||||||
|
for (const product of data.products) {
|
||||||
|
const asin = normalizeAsin(product.asin);
|
||||||
|
if (!asin) continue;
|
||||||
|
results.set(asin, parseKeepaProduct(product));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function lookupKeepaUpcs(
|
||||||
|
upcs: string[],
|
||||||
|
): Promise<Map<string, KeepaUpcLookupDetail>> {
|
||||||
|
const details = new Map<string, KeepaUpcLookupDetail>();
|
||||||
|
const validUpcs: string[] = [];
|
||||||
|
const seenValid = new Set<string>();
|
||||||
|
|
||||||
|
for (const rawUpc of upcs) {
|
||||||
|
const normalized = normalizeUpc(rawUpc);
|
||||||
|
if (!isValidUpc(normalized)) {
|
||||||
|
if (!details.has(normalized)) {
|
||||||
|
details.set(
|
||||||
|
normalized,
|
||||||
|
buildFailureDetail(
|
||||||
|
normalized,
|
||||||
|
"invalid_upc",
|
||||||
|
"UPC must be 12, 13, or 14 digits",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (seenValid.has(normalized)) continue;
|
||||||
|
seenValid.add(normalized);
|
||||||
|
validUpcs.push(normalized);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let i = 0; i < validUpcs.length; i += MAX_CODES_PER_REQUEST) {
|
||||||
|
const chunk = validUpcs.slice(i, i + MAX_CODES_PER_REQUEST);
|
||||||
|
const chunkSet = new Set(chunk);
|
||||||
|
const url = buildProductUrl("code", chunk, {
|
||||||
|
includeStats: false,
|
||||||
|
includeBuybox: false,
|
||||||
|
includeHistory: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`Keepa: mapping ${chunk.length} UPCs to ASINs (tokens left: ${tokensLeft})...`,
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = await fetchKeepaWithRetries(url, "UPC code lookup");
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`Keepa: ${data.products?.length ?? 0} products returned for UPC query, ${tokensLeft} tokens remaining (refill: ${refillRate}/min)`,
|
||||||
|
);
|
||||||
|
|
||||||
|
const byUpc = new Map<string, Map<string, KeepaData>>();
|
||||||
|
for (const product of data.products ?? []) {
|
||||||
|
const asin = normalizeAsin(product.asin);
|
||||||
|
if (!asin) continue;
|
||||||
|
|
||||||
|
const keepaData = parseKeepaProduct(product);
|
||||||
|
const productUpcs = extractUpcsFromProduct(product);
|
||||||
|
|
||||||
|
for (const upc of productUpcs) {
|
||||||
|
if (!chunkSet.has(upc)) continue;
|
||||||
|
if (!byUpc.has(upc)) byUpc.set(upc, new Map());
|
||||||
|
byUpc.get(upc)!.set(asin, keepaData);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const upc of chunk) {
|
||||||
|
const asinMap = byUpc.get(upc);
|
||||||
|
if (!asinMap || asinMap.size === 0) {
|
||||||
|
details.set(
|
||||||
|
upc,
|
||||||
|
buildFailureDetail(
|
||||||
|
upc,
|
||||||
|
"not_found",
|
||||||
|
"No Keepa product matched this UPC",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const candidateAsins = Array.from(asinMap.keys());
|
||||||
|
if (candidateAsins.length > 1) {
|
||||||
|
details.set(
|
||||||
|
upc,
|
||||||
|
buildFailureDetail(
|
||||||
|
upc,
|
||||||
|
"multiple_asins",
|
||||||
|
`UPC matched multiple ASINs (${candidateAsins.length})`,
|
||||||
|
candidateAsins,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const asin = candidateAsins[0]!;
|
||||||
|
details.set(upc, {
|
||||||
|
requestedUpc: upc,
|
||||||
|
normalizedUpc: upc,
|
||||||
|
status: "found",
|
||||||
|
asin,
|
||||||
|
candidateAsins: [asin],
|
||||||
|
keepaData: asinMap.get(asin) ?? null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
const reason = error instanceof Error ? error.message : String(error);
|
||||||
|
console.warn(
|
||||||
|
`Keepa UPC chunk failed (offset ${i}, size ${chunk.length}): ${reason}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const upc of chunk) {
|
||||||
|
details.set(upc, buildFailureDetail(upc, "request_failed", reason));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return details;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function mapUpcsToAsins(
|
||||||
|
upcs: string[],
|
||||||
|
): Promise<Map<string, string>> {
|
||||||
|
const details = await lookupKeepaUpcs(upcs);
|
||||||
|
const mapping = new Map<string, string>();
|
||||||
|
|
||||||
|
for (const [upc, detail] of details.entries()) {
|
||||||
|
if (detail.status === "found" && detail.asin) {
|
||||||
|
mapping.set(upc, detail.asin);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return mapping;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseKeepaProduct(product: Record<string, any>): KeepaData {
|
||||||
|
const stats = product.stats;
|
||||||
|
const csv = product.csv;
|
||||||
|
const salesRankDrops30 = pickKeepaNumber(
|
||||||
|
product.salesRankDrops30,
|
||||||
|
stats?.salesRankDrops30,
|
||||||
|
);
|
||||||
|
const salesRankDrops90 =
|
||||||
|
pickKeepaNumber(product.salesRankDrops90, stats?.salesRankDrops90) ??
|
||||||
|
(salesRankDrops30 != null ? salesRankDrops30 * 3 : null);
|
||||||
|
const monthlySold =
|
||||||
|
pickKeepaNumber(product.monthlySold, stats?.monthlySold) ??
|
||||||
|
salesRankDrops30;
|
||||||
|
const amazonIsSeller = resolveAmazonIsSeller(product, stats, csv);
|
||||||
|
const amazonBuyboxSharePct90d =
|
||||||
|
extractAmazonBuyboxSharePct90d(product, stats) ??
|
||||||
|
computeAmazonBuyBoxSharePctFromHistory(
|
||||||
|
product.buyBoxSellerIdHistory,
|
||||||
|
90,
|
||||||
|
new Set([AMAZON_US_SELLER_ID]),
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
currentPrice: extractCurrentPrice(csv),
|
||||||
|
avgPrice90: stats?.avg?.[0] != null ? stats.avg[0] / 100 : null,
|
||||||
|
minPrice90: stats?.min?.[0] != null ? stats.min[0] / 100 : null,
|
||||||
|
maxPrice90: stats?.max?.[0] != null ? stats.max[0] / 100 : null,
|
||||||
|
salesRank: stats?.current?.[3] ?? null,
|
||||||
|
salesRankAvg90: stats?.avg?.[3] ?? null,
|
||||||
|
salesRankDrops30,
|
||||||
|
salesRankDrops90,
|
||||||
|
sellerCount: stats?.current?.[11] ?? null,
|
||||||
|
amazonIsSeller,
|
||||||
|
amazonBuyboxSharePct90d,
|
||||||
|
buyBoxSeller: product.buyBoxSellerId ?? null,
|
||||||
|
buyBoxPrice: stats?.current?.[18] != null ? stats.current[18] / 100 : null,
|
||||||
|
buyBoxAvg90: stats?.avg?.[18] != null ? stats.avg[18] / 100 : null,
|
||||||
|
monthlySold,
|
||||||
|
categoryTree:
|
||||||
|
product.categoryTree?.map((c: { name: string }) => c.name) ?? [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveAmazonIsSeller(
|
||||||
|
product: Record<string, any>,
|
||||||
|
stats: Record<string, any> | undefined,
|
||||||
|
csv: number[][] | undefined,
|
||||||
|
): boolean | null {
|
||||||
|
if (typeof product.isAmazonSeller === "boolean") {
|
||||||
|
return product.isAmazonSeller;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof product.availabilityAmazon === "number") {
|
||||||
|
if (product.availabilityAmazon >= 0) return true;
|
||||||
|
if (
|
||||||
|
product.availabilityAmazon === -1 ||
|
||||||
|
product.availabilityAmazon === -2
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (stats?.buyBoxIsAmazon === true) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof stats?.current?.[0] === "number") {
|
||||||
|
if (stats.current[0] > 0) return true;
|
||||||
|
if (stats.current[0] === -1 || stats.current[0] === -2) return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const latestAmazonPrice = extractLatestPositivePrice(csv?.[0]);
|
||||||
|
if (latestAmazonPrice != null) return true;
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractAmazonBuyboxSharePct90d(
|
||||||
|
product: Record<string, any>,
|
||||||
|
stats: Record<string, any> | undefined,
|
||||||
|
): number | null {
|
||||||
|
const candidates: unknown[] = [
|
||||||
|
product.buyBoxStatsAmazon90,
|
||||||
|
stats?.buyBoxStatsAmazon90,
|
||||||
|
product.buyBoxStats?.amazon90,
|
||||||
|
product.buyBoxStats?.amazon?.[90],
|
||||||
|
product.buyBoxStats?.amazon?.["90"],
|
||||||
|
product.buyBoxStats?.[AMAZON_US_SELLER_ID]?.[90],
|
||||||
|
product.buyBoxStats?.[AMAZON_US_SELLER_ID]?.["90"],
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const value of candidates) {
|
||||||
|
if (typeof value !== "number" || !Number.isFinite(value)) continue;
|
||||||
|
if (value < 0 || value > 100) continue;
|
||||||
|
return Math.round(value * 100) / 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function computeAmazonBuyBoxSharePctFromHistory(
|
||||||
|
history: unknown,
|
||||||
|
windowDays: number,
|
||||||
|
amazonSellerIds: Set<string>,
|
||||||
|
): number | null {
|
||||||
|
if (!Array.isArray(history) || history.length < 2) return null;
|
||||||
|
|
||||||
|
const nowKeepaMinutes =
|
||||||
|
Math.floor(Date.now() / 60_000) - KEEPA_MINUTES_OFFSET;
|
||||||
|
const windowStart = nowKeepaMinutes - windowDays * 24 * 60;
|
||||||
|
let qualifiedMinutes = 0;
|
||||||
|
let amazonMinutes = 0;
|
||||||
|
|
||||||
|
for (let i = 0; i < history.length - 1; i += 2) {
|
||||||
|
const startMinute = Number.parseInt(String(history[i]), 10);
|
||||||
|
const sellerId = String(history[i + 1] ?? "").toUpperCase();
|
||||||
|
const nextRaw = i + 2 < history.length ? history[i + 2] : nowKeepaMinutes;
|
||||||
|
const endMinute = Number.parseInt(String(nextRaw), 10);
|
||||||
|
|
||||||
|
if (!Number.isFinite(startMinute) || !Number.isFinite(endMinute)) continue;
|
||||||
|
if (endMinute <= startMinute) continue;
|
||||||
|
|
||||||
|
const intervalStart = Math.max(startMinute, windowStart);
|
||||||
|
const intervalEnd = Math.min(endMinute, nowKeepaMinutes);
|
||||||
|
if (intervalEnd <= intervalStart) continue;
|
||||||
|
|
||||||
|
if (sellerId === "-1" || sellerId === "-2") continue;
|
||||||
|
|
||||||
|
const minutes = intervalEnd - intervalStart;
|
||||||
|
qualifiedMinutes += minutes;
|
||||||
|
if (amazonSellerIds.has(sellerId)) {
|
||||||
|
amazonMinutes += minutes;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (qualifiedMinutes === 0) return null;
|
||||||
|
return Math.round((amazonMinutes / qualifiedMinutes) * 10_000) / 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractLatestPositivePrice(series: unknown): number | null {
|
||||||
|
if (!Array.isArray(series) || series.length < 2) return null;
|
||||||
|
for (let i = series.length - 1; i >= 1; i--) {
|
||||||
|
if (i % 2 === 0) continue;
|
||||||
|
const value = series[i];
|
||||||
|
if (typeof value === "number" && Number.isFinite(value) && value > 0) {
|
||||||
|
return value / 100;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickKeepaNumber(...values: unknown[]): number | null {
|
||||||
|
for (const value of values) {
|
||||||
|
if (typeof value !== "number" || !Number.isFinite(value)) continue;
|
||||||
|
// Keepa often uses -1 as "not available".
|
||||||
|
if (value < 0) continue;
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractCurrentPrice(csv: number[][] | undefined): number | null {
|
||||||
|
if (!csv) return null;
|
||||||
|
|
||||||
|
// csv[0] = Amazon price history, csv[1] = Marketplace new price history
|
||||||
|
// Each is [time, price, time, price, ...]. Only odd indexes are prices.
|
||||||
|
for (const series of [csv[0], csv[1]]) {
|
||||||
|
const latestPrice = extractLatestPositivePrice(series);
|
||||||
|
if (latestPrice != null) return latestPrice;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { config } from "./config.ts";
|
import { config } from "../config.ts";
|
||||||
import type { EnrichedProduct, LlmVerdict } from "./types.ts";
|
import type { EnrichedProduct, LlmVerdict } from "../types.ts";
|
||||||
|
|
||||||
const SYSTEM_PROMPT = `You are an expert Amazon product analyst specializing in FBA and FBM fulfillment strategy.
|
const SYSTEM_PROMPT_STRICT = `You are an expert Amazon product analyst specializing in FBA and FBM fulfillment strategy.
|
||||||
|
|
||||||
Given product data, evaluate each product's viability for selling on Amazon. Consider:
|
Given product data, evaluate each product's viability for selling on Amazon. Consider:
|
||||||
|
|
||||||
@@ -29,14 +29,61 @@ Return ONLY a raw JSON array (no markdown, no code fences, no explanation before
|
|||||||
|
|
||||||
Keep each reasoning under 100 characters to stay within output limits and mention key blocker if skipped (e.g., restricted, low demand, thin margin).`;
|
Keep each reasoning under 100 characters to stay within output limits and mention key blocker if skipped (e.g., restricted, low demand, thin margin).`;
|
||||||
|
|
||||||
|
const SYSTEM_PROMPT_ASSUME_LISTABLE = `You are an expert Amazon product analyst specializing in FBA and FBM fulfillment strategy.
|
||||||
|
|
||||||
|
Given product data, evaluate each product's viability for selling on Amazon. Consider:
|
||||||
|
|
||||||
|
1. **Sales Velocity**: monthlySold and salesRankDrops30 are the most important signals. A product that doesn't sell is worthless regardless of margin. salesRankDrops30 = approximate units sold in 30 days. monthlySold is Keepa's estimate.
|
||||||
|
2. **Margin Analysis**: Sale price minus unit cost minus fees (FBA or FBM). Aim for >30% ROI minimum. The spreadsheet may include FBA NET and gross profit estimates — cross-check against Keepa pricing data.
|
||||||
|
3. **Sales Rank (BSR)**: Lower rank = higher demand. Rank <50,000 is good, <1,000 is excellent.
|
||||||
|
4. **Sales Rank Trend**: Compare current rank vs 90d average. Lower current = improving demand.
|
||||||
|
5. **Competition**: Number of sellers and Buy Box dynamics. Fewer sellers = easier entry.
|
||||||
|
6. **Price Stability**: Large price swings (high max vs low min over 90d) = volatile/risky.
|
||||||
|
7. **FBA vs FBM**: FBA preferred for fast-selling, small/light items. FBM for oversized, slow-moving, or high-margin items where fee savings matter.
|
||||||
|
8. **MOQ & Capital**: High MOQ with thin margins is risky.
|
||||||
|
9. **Supply Availability**: Total quantity available from supplier — low stock means limited runway.
|
||||||
|
|
||||||
|
Decision policy:
|
||||||
|
- Ignore seller eligibility restrictions/status in this run.
|
||||||
|
- Assume all products are listable by this seller account.
|
||||||
|
- Prioritize profitable + high-velocity products.
|
||||||
|
- Use "SKIP" when data quality is poor or risk is high.
|
||||||
|
|
||||||
|
Return ONLY a raw JSON array (no markdown, no code fences, no explanation before or after). One verdict per product:
|
||||||
|
[{ "asin": "B...", "verdict": "FBA" | "FBM" | "SKIP", "confidence": 0-100, "reasoning": "..." }]
|
||||||
|
|
||||||
|
Keep each reasoning under 100 characters to stay within output limits and mention key blocker if skipped (e.g., low demand, thin margin).`;
|
||||||
|
|
||||||
|
type AnalyzeProductsOptions = {
|
||||||
|
ignoreSellability?: boolean;
|
||||||
|
useClaude?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
type LlmProvider = "lm-studio" | "claude";
|
||||||
|
|
||||||
|
type LmStudioResponse = {
|
||||||
|
choices?: { message?: { content?: string } }[];
|
||||||
|
};
|
||||||
|
|
||||||
|
type ClaudeResponse = {
|
||||||
|
content?: Array<{ type?: string; text?: string }>;
|
||||||
|
};
|
||||||
|
|
||||||
|
function getSystemPrompt(options: AnalyzeProductsOptions): string {
|
||||||
|
if (options.ignoreSellability) {
|
||||||
|
return SYSTEM_PROMPT_ASSUME_LISTABLE;
|
||||||
|
}
|
||||||
|
return SYSTEM_PROMPT_STRICT;
|
||||||
|
}
|
||||||
|
|
||||||
export async function analyzeProducts(
|
export async function analyzeProducts(
|
||||||
products: EnrichedProduct[],
|
products: EnrichedProduct[],
|
||||||
|
options: AnalyzeProductsOptions = {},
|
||||||
): Promise<LlmVerdict[]> {
|
): Promise<LlmVerdict[]> {
|
||||||
try {
|
try {
|
||||||
return await analyzeProductsInternal(products);
|
return await analyzeProductsInternal(products, options);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const msg = String(err);
|
if (products.length > 1 && isContextOverflowError(err)) {
|
||||||
if (products.length > 1 && msg.includes("Context size has been exceeded")) {
|
|
||||||
console.warn(
|
console.warn(
|
||||||
`LLM context exceeded for batch of ${products.length}, retrying one product at a time...`,
|
`LLM context exceeded for batch of ${products.length}, retrying one product at a time...`,
|
||||||
);
|
);
|
||||||
@@ -44,7 +91,7 @@ export async function analyzeProducts(
|
|||||||
const fallback: LlmVerdict[] = [];
|
const fallback: LlmVerdict[] = [];
|
||||||
for (const product of products) {
|
for (const product of products) {
|
||||||
try {
|
try {
|
||||||
const single = await analyzeProductsInternal([product]);
|
const single = await analyzeProductsInternal([product], options);
|
||||||
fallback.push(
|
fallback.push(
|
||||||
single[0] ?? {
|
single[0] ?? {
|
||||||
asin: product.record.asin,
|
asin: product.record.asin,
|
||||||
@@ -64,15 +111,65 @@ export async function analyzeProducts(
|
|||||||
}
|
}
|
||||||
return fallback;
|
return fallback;
|
||||||
}
|
}
|
||||||
throw err;
|
|
||||||
|
const errorReason = formatErrorReason(err);
|
||||||
|
console.warn(
|
||||||
|
`LLM request failed for ${products.length} product(s): ${errorReason}`,
|
||||||
|
);
|
||||||
|
return products.map((product) => ({
|
||||||
|
asin: product.record.asin,
|
||||||
|
verdict: "SKIP" as const,
|
||||||
|
confidence: 0,
|
||||||
|
reasoning: `LLM analysis failed: ${errorReason}`,
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function analyzeProductsInternal(
|
async function analyzeProductsInternal(
|
||||||
products: EnrichedProduct[],
|
products: EnrichedProduct[],
|
||||||
|
options: AnalyzeProductsOptions,
|
||||||
): Promise<LlmVerdict[]> {
|
): Promise<LlmVerdict[]> {
|
||||||
const productSummaries = products.map(summarizeForLlm);
|
const productSummaries = products.map((p) =>
|
||||||
|
summarizeForLlm(p, options.ignoreSellability === true),
|
||||||
|
);
|
||||||
|
const systemPrompt = getSystemPrompt(options);
|
||||||
|
const provider = options.useClaude ? "claude" : "lm-studio";
|
||||||
|
const content = await requestLlmContent(
|
||||||
|
provider,
|
||||||
|
systemPrompt,
|
||||||
|
productSummaries,
|
||||||
|
);
|
||||||
|
|
||||||
|
return parseVerdicts(content, products);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isContextOverflowError(err: unknown): boolean {
|
||||||
|
const msg = String(err).toLowerCase();
|
||||||
|
return (
|
||||||
|
msg.includes("context size has been exceeded") ||
|
||||||
|
msg.includes("prompt is too long") ||
|
||||||
|
msg.includes("too many tokens") ||
|
||||||
|
msg.includes("maximum context") ||
|
||||||
|
msg.includes("context length") ||
|
||||||
|
msg.includes("max_tokens")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function requestLlmContent(
|
||||||
|
provider: LlmProvider,
|
||||||
|
systemPrompt: string,
|
||||||
|
productSummaries: ReturnType<typeof summarizeForLlm>[],
|
||||||
|
): Promise<string> {
|
||||||
|
if (provider === "claude") {
|
||||||
|
return requestClaudeContent(systemPrompt, productSummaries);
|
||||||
|
}
|
||||||
|
return requestLmStudioContent(systemPrompt, productSummaries);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function requestLmStudioContent(
|
||||||
|
systemPrompt: string,
|
||||||
|
productSummaries: ReturnType<typeof summarizeForLlm>[],
|
||||||
|
): Promise<string> {
|
||||||
const res = await fetch(`${config.llmUrl}/chat/completions`, {
|
const res = await fetch(`${config.llmUrl}/chat/completions`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
@@ -82,7 +179,7 @@ async function analyzeProductsInternal(
|
|||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
model: config.llmModel,
|
model: config.llmModel,
|
||||||
messages: [
|
messages: [
|
||||||
{ role: "system", content: SYSTEM_PROMPT },
|
{ role: "system", content: systemPrompt },
|
||||||
{ role: "user", content: JSON.stringify(productSummaries, null, 2) },
|
{ role: "user", content: JSON.stringify(productSummaries, null, 2) },
|
||||||
],
|
],
|
||||||
temperature: 0.3,
|
temperature: 0.3,
|
||||||
@@ -91,18 +188,111 @@ async function analyzeProductsInternal(
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
throw new Error(`LLM API error ${res.status}: ${await res.text()}`);
|
throw new Error(`LLM API error ${res.status}: ${await readErrorBody(res)}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = (await res.json()) as {
|
const data = (await res.json()) as LmStudioResponse;
|
||||||
choices?: { message?: { content?: string } }[];
|
return data.choices?.[0]?.message?.content ?? "";
|
||||||
};
|
|
||||||
const content = data.choices?.[0]?.message?.content ?? "";
|
|
||||||
|
|
||||||
return parseVerdicts(content, products);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function summarizeForLlm(p: EnrichedProduct) {
|
async function requestClaudeContent(
|
||||||
|
systemPrompt: string,
|
||||||
|
productSummaries: ReturnType<typeof summarizeForLlm>[],
|
||||||
|
): Promise<string> {
|
||||||
|
if (!config.anthropicApiKey) {
|
||||||
|
throw new Error(
|
||||||
|
"Missing required env var for --claude mode: ANTHROPIC_API_KEY",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const model = resolveAnthropicModel(
|
||||||
|
config.anthropicModel ?? "claude-sonnet-4-6",
|
||||||
|
);
|
||||||
|
|
||||||
|
const res = await fetch("https://api.anthropic.com/v1/messages", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"x-api-key": config.anthropicApiKey,
|
||||||
|
"anthropic-version": "2023-06-01",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
model,
|
||||||
|
system: systemPrompt,
|
||||||
|
messages: [
|
||||||
|
{ role: "user", content: JSON.stringify(productSummaries, null, 2) },
|
||||||
|
],
|
||||||
|
temperature: 0.3,
|
||||||
|
max_tokens: 2048,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(
|
||||||
|
`Claude API error ${res.status}: ${await readErrorBody(res)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = (await res.json()) as ClaudeResponse;
|
||||||
|
if (!Array.isArray(data.content)) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
return data.content
|
||||||
|
.filter((block) => block?.type === "text" && typeof block.text === "string")
|
||||||
|
.map((block) => block.text ?? "")
|
||||||
|
.join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveAnthropicModel(rawModel: string): string {
|
||||||
|
const normalized = rawModel.trim().toLowerCase();
|
||||||
|
const aliases: Record<string, string> = {
|
||||||
|
"claude-4-6-sonnet": "claude-sonnet-4-6",
|
||||||
|
"claude-4-6-haiku": "claude-haiku-4-5",
|
||||||
|
"claude-4-7-opus": "claude-opus-4-7",
|
||||||
|
};
|
||||||
|
|
||||||
|
const mapped = aliases[normalized];
|
||||||
|
if (mapped && mapped !== rawModel) {
|
||||||
|
console.warn(
|
||||||
|
`ANTHROPIC_MODEL '${rawModel}' is not an official API ID. Using '${mapped}' instead.`,
|
||||||
|
);
|
||||||
|
return mapped;
|
||||||
|
}
|
||||||
|
|
||||||
|
return rawModel;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatErrorReason(err: unknown): string {
|
||||||
|
const message = String(err).replace(/\s+/g, " ").trim();
|
||||||
|
if (!message) return "Unknown LLM error";
|
||||||
|
return message.length > 140 ? `${message.slice(0, 137)}...` : message;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readErrorBody(response: Response): Promise<string> {
|
||||||
|
const text = await response.text();
|
||||||
|
if (!text.trim()) return "No response body";
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(text) as {
|
||||||
|
error?: { message?: string; type?: string };
|
||||||
|
};
|
||||||
|
const type = parsed.error?.type?.trim();
|
||||||
|
const message = parsed.error?.message?.trim();
|
||||||
|
if (type && message) {
|
||||||
|
return `${type}: ${message}`;
|
||||||
|
}
|
||||||
|
if (message) {
|
||||||
|
return message;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Response was plain text.
|
||||||
|
}
|
||||||
|
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
|
||||||
|
function summarizeForLlm(p: EnrichedProduct, ignoreSellability: boolean) {
|
||||||
const salePrice =
|
const salePrice =
|
||||||
p.keepa?.currentPrice ??
|
p.keepa?.currentPrice ??
|
||||||
p.record.sellingPriceFromSheet ??
|
p.record.sellingPriceFromSheet ??
|
||||||
@@ -169,9 +359,11 @@ function summarizeForLlm(p: EnrichedProduct) {
|
|||||||
referralFee != null ? Math.round(referralFee * 100) / 100 : null,
|
referralFee != null ? Math.round(referralFee * 100) / 100 : null,
|
||||||
},
|
},
|
||||||
sellerEligibility: {
|
sellerEligibility: {
|
||||||
canSell: p.spApi.canSell,
|
canSell: ignoreSellability ? true : p.spApi.canSell,
|
||||||
status: p.spApi.sellabilityStatus,
|
status: ignoreSellability ? "available" : p.spApi.sellabilityStatus,
|
||||||
reason: clampText(p.spApi.sellabilityReason, 120),
|
reason: ignoreSellability
|
||||||
|
? "Assumed listable by sellability=all"
|
||||||
|
: clampText(p.spApi.sellabilityReason, 120),
|
||||||
},
|
},
|
||||||
estimatedProfit:
|
estimatedProfit:
|
||||||
fbaProfit != null && fbmProfit != null
|
fbaProfit != null && fbmProfit != null
|
||||||
351
src/integrations/searxng.test.ts
Normal file
351
src/integrations/searxng.test.ts
Normal file
@@ -0,0 +1,351 @@
|
|||||||
|
import { afterAll, beforeEach, expect, mock, test } from "bun:test";
|
||||||
|
import { normalizeAsin, searchProductOffers } from "./searxng.ts";
|
||||||
|
|
||||||
|
const originalFetch = globalThis.fetch;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
globalThis.fetch = originalFetch;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
globalThis.fetch = originalFetch;
|
||||||
|
});
|
||||||
|
|
||||||
|
test("normalizeAsin uppercases and validates ASINs", () => {
|
||||||
|
expect(normalizeAsin(" b07sn9bhvv ")).toBe("B07SN9BHVV");
|
||||||
|
expect(normalizeAsin("0306406152")).toBe("0306406152");
|
||||||
|
expect(() => normalizeAsin("not-an-asin")).toThrow("Invalid ASIN");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("searchProductOffers derives ASIN search behavior for ASIN-only queries", async () => {
|
||||||
|
const fetchMock = mock(async (input: string | URL | Request) => {
|
||||||
|
const url = input instanceof URL ? input : new URL(String(input));
|
||||||
|
expect(url.pathname).toBe("/search");
|
||||||
|
expect(url.searchParams.get("format")).toBe("json");
|
||||||
|
expect(url.searchParams.get("q")).toBe("B07SN9BHVV price sale offer buy online");
|
||||||
|
|
||||||
|
return Response.json({
|
||||||
|
results: [
|
||||||
|
{
|
||||||
|
title: "Amazon listing B07SN9BHVV",
|
||||||
|
url: "https://www.amazon.com/dp/B07SN9BHVV",
|
||||||
|
content: "Official marketplace listing.",
|
||||||
|
engines: ["duckduckgo"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Romand palette offer",
|
||||||
|
url: "https://example-shop.com/item",
|
||||||
|
content: "Buy product ASIN B07SN9BHVV. Offer price: $12.99 today.",
|
||||||
|
engines: ["brave"],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const results = await searchProductOffers("B07SN9BHVV", {
|
||||||
|
provider: "searxng",
|
||||||
|
baseUrl: "https://searxng.test/",
|
||||||
|
fetchImpl: fetchMock as unknown as typeof fetch,
|
||||||
|
maxResults: 10,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(results).toHaveLength(2);
|
||||||
|
expect(results[0]?.domain).toBe("example-shop.com");
|
||||||
|
expect(results[0]?.matchedAsin).toBe("B07SN9BHVV");
|
||||||
|
expect(results[0]?.detectedPrice).toBe(12.99);
|
||||||
|
expect(results[0]?.detectedPriceCurrency).toBe("USD");
|
||||||
|
expect(results[0]?.detectedPriceLabel).toBe("offer price");
|
||||||
|
expect(results[0]?.detectedPriceText).toBe("$12.99");
|
||||||
|
expect(results[0]?.engines).toEqual(["brave"]);
|
||||||
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("searchProductOffers falls back to HTML when JSON is unavailable", async () => {
|
||||||
|
const html = `
|
||||||
|
<article class="result result-default category-general">
|
||||||
|
<a class="url_header" href="https://supplier.example/products/romand"></a>
|
||||||
|
<h3><a href="https://supplier.example/products/romand">Supplier offer B07SN9BHVV</a></h3>
|
||||||
|
<p class="content">Wholesale product sale price: USD 9.50 with ASIN B07SN9BHVV.</p>
|
||||||
|
<div class="engines"><span>duckduckgo</span></div>
|
||||||
|
</article>
|
||||||
|
`;
|
||||||
|
const fetchMock = mock(async (input: string | URL | Request) => {
|
||||||
|
const url = input instanceof URL ? input : new URL(String(input));
|
||||||
|
if (url.searchParams.get("format") === "json") {
|
||||||
|
return new Response("forbidden", { status: 403 });
|
||||||
|
}
|
||||||
|
return new Response(html, {
|
||||||
|
status: 200,
|
||||||
|
headers: { "content-type": "text/html" },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const results = await searchProductOffers("B07SN9BHVV", {
|
||||||
|
provider: "searxng",
|
||||||
|
baseUrl: "https://searxng.test/",
|
||||||
|
fetchImpl: fetchMock as unknown as typeof fetch,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(results).toHaveLength(1);
|
||||||
|
expect(results[0]?.title).toBe("Supplier offer B07SN9BHVV");
|
||||||
|
expect(results[0]?.domain).toBe("supplier.example");
|
||||||
|
expect(results[0]?.detectedPrice).toBe(9.5);
|
||||||
|
expect(results[0]?.detectedPriceLabel).toBe("sale price");
|
||||||
|
expect(results[0]?.detectedPriceText).toBe("USD 9.50");
|
||||||
|
expect(results[0]?.matchedAsin).toBe("B07SN9BHVV");
|
||||||
|
expect(results[0]?.engines).toEqual(["duckduckgo"]);
|
||||||
|
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("searchProductOffers detects common selling and sale price formats", async () => {
|
||||||
|
const fetchMock = mock(async () =>
|
||||||
|
Response.json({
|
||||||
|
results: [
|
||||||
|
{
|
||||||
|
title: "Supplier page",
|
||||||
|
url: "https://supplier.example/item",
|
||||||
|
content: "Selling price is €18.75 and list price is $24.00.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Backup page",
|
||||||
|
url: "https://backup.example/item",
|
||||||
|
content: "Available now for 22.10 USD.",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const results = await searchProductOffers("romand palette price", {
|
||||||
|
provider: "searxng",
|
||||||
|
baseUrl: "https://searxng.test/",
|
||||||
|
fetchImpl: fetchMock as unknown as typeof fetch,
|
||||||
|
maxResults: 2,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(results[0]?.detectedPrice).toBe(18.75);
|
||||||
|
expect(results[0]?.detectedPriceCurrency).toBe("EUR");
|
||||||
|
expect(results[0]?.detectedPriceLabel).toBe("selling price");
|
||||||
|
expect(results[1]?.detectedPrice).toBe(22.1);
|
||||||
|
expect(results[1]?.detectedPriceCurrency).toBe("USD");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("searchProductOffers filters unrelated priced results for ASIN-only queries", async () => {
|
||||||
|
const fetchMock = mock(async () =>
|
||||||
|
Response.json({
|
||||||
|
results: [
|
||||||
|
{
|
||||||
|
title: "Unrelated deal",
|
||||||
|
url: "https://deals.example/phones",
|
||||||
|
content: "This price is $449 but it is for another product.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Amazon listing B07SN9BHVV",
|
||||||
|
url: "https://www.amazon.in/dp/B07SN9BHVV",
|
||||||
|
content: "1 offer from ₹550.00 · Buying options.",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const results = await searchProductOffers("B07SN9BHVV", {
|
||||||
|
provider: "searxng",
|
||||||
|
baseUrl: "https://searxng.test/",
|
||||||
|
fetchImpl: fetchMock as unknown as typeof fetch,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(results).toHaveLength(1);
|
||||||
|
expect(results[0]?.matchedAsin).toBe("B07SN9BHVV");
|
||||||
|
expect(results[0]?.detectedPrice).toBe(550);
|
||||||
|
expect(results[0]?.detectedPriceCurrency).toBe("INR");
|
||||||
|
expect(results[0]?.detectedPriceText).toBe("₹550.00");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("searchProductOffers keeps arbitrary query strings generic", async () => {
|
||||||
|
const fetchMock = mock(async (input: string | URL | Request) => {
|
||||||
|
const url = input instanceof URL ? input : new URL(String(input));
|
||||||
|
expect(url.searchParams.get("q")).toBe("romand dry mango tulip price");
|
||||||
|
|
||||||
|
return Response.json({
|
||||||
|
results: [
|
||||||
|
{
|
||||||
|
title: "Generic result",
|
||||||
|
url: "https://shop.example/romand",
|
||||||
|
content: "Sale price: $14.25",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const results = await searchProductOffers("romand dry mango tulip price", {
|
||||||
|
provider: "searxng",
|
||||||
|
baseUrl: "https://searxng.test/",
|
||||||
|
fetchImpl: fetchMock as unknown as typeof fetch,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(results).toHaveLength(1);
|
||||||
|
expect(results[0]?.asin).toBeUndefined();
|
||||||
|
expect(results[0]?.detectedPrice).toBe(14.25);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("searchProductOffers sends configured categories", async () => {
|
||||||
|
const fetchMock = mock(async (input: string | URL | Request) => {
|
||||||
|
const url = input instanceof URL ? input : new URL(String(input));
|
||||||
|
expect(url.searchParams.get("categories")).toBe("shopping");
|
||||||
|
|
||||||
|
return Response.json({
|
||||||
|
results: [
|
||||||
|
{
|
||||||
|
title: "Shopping result",
|
||||||
|
url: "https://shop.example/item",
|
||||||
|
content: "Offer price: $10.00",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const results = await searchProductOffers("romand price", {
|
||||||
|
provider: "searxng",
|
||||||
|
baseUrl: "https://searxng.test/",
|
||||||
|
categories: "shopping",
|
||||||
|
fetchImpl: fetchMock as unknown as typeof fetch,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(results[0]?.detectedPrice).toBe(10);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("searchProductOffers sends configured SearXNG engines", async () => {
|
||||||
|
const fetchMock = mock(async (input: string | URL | Request) => {
|
||||||
|
const url = input instanceof URL ? input : new URL(String(input));
|
||||||
|
expect(url.searchParams.get("engines")).toBe("google");
|
||||||
|
expect(url.searchParams.get("q")).toBe("!go romand price");
|
||||||
|
|
||||||
|
return Response.json({
|
||||||
|
results: [
|
||||||
|
{
|
||||||
|
title: "Google-backed result",
|
||||||
|
url: "https://shop.example/item",
|
||||||
|
content: "Offer price: $11.00",
|
||||||
|
engine: "google",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const results = await searchProductOffers("romand price", {
|
||||||
|
provider: "searxng",
|
||||||
|
baseUrl: "https://searxng.test/",
|
||||||
|
engines: "google",
|
||||||
|
fetchImpl: fetchMock as unknown as typeof fetch,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(results[0]?.detectedPrice).toBe(11);
|
||||||
|
expect(results[0]?.engines).toEqual(["google"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("searchProductOffers uses Google Custom Search API and pagemap offer prices", async () => {
|
||||||
|
const fetchMock = mock(async (input: string | URL | Request) => {
|
||||||
|
const url = input instanceof URL ? input : new URL(String(input));
|
||||||
|
expect(url.hostname).toBe("googleapis.test");
|
||||||
|
expect(url.searchParams.get("key")).toBe("test-key");
|
||||||
|
expect(url.searchParams.get("cx")).toBe("test-cx");
|
||||||
|
expect(url.searchParams.get("num")).toBe("5");
|
||||||
|
expect(url.searchParams.get("q")).toBe("romand dry mango tulip");
|
||||||
|
|
||||||
|
return Response.json({
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
title: "Romand Dry Mango Tulip",
|
||||||
|
link: "https://store.example/romand",
|
||||||
|
snippet: "Buy from Store Example.",
|
||||||
|
pagemap: {
|
||||||
|
offer: [{ price: "12.50", pricecurrency: "USD" }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const results = await searchProductOffers("romand dry mango tulip", {
|
||||||
|
provider: "google-custom-search",
|
||||||
|
baseUrl: "https://googleapis.test/customsearch/v1",
|
||||||
|
googleApiKey: "test-key",
|
||||||
|
googleCx: "test-cx",
|
||||||
|
maxResults: 5,
|
||||||
|
fetchImpl: fetchMock as unknown as typeof fetch,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(results).toHaveLength(1);
|
||||||
|
expect(results[0]?.title).toContain("Romand Dry Mango Tulip");
|
||||||
|
expect(results[0]?.domain).toBe("store.example");
|
||||||
|
expect(results[0]?.detectedPrice).toBe(12.5);
|
||||||
|
expect(results[0]?.detectedPriceLabel).toBe("offer price");
|
||||||
|
expect(results[0]?.engines).toEqual(["google custom search"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("searchProductOffers defaults to SerpApi Google Shopping results", async () => {
|
||||||
|
const fetchMock = mock(async (input: string | URL | Request) => {
|
||||||
|
const url = input instanceof URL ? input : new URL(String(input));
|
||||||
|
expect(url.hostname).toBe("serpapi.test");
|
||||||
|
expect(url.searchParams.get("engine")).toBe("google_shopping");
|
||||||
|
expect(url.searchParams.get("q")).toBe("dry mango tulip price");
|
||||||
|
expect(url.searchParams.get("api_key")).toBe("serpapi-key");
|
||||||
|
expect(url.searchParams.get("gl")).toBe("us");
|
||||||
|
expect(url.searchParams.get("hl")).toBe("en");
|
||||||
|
|
||||||
|
return Response.json({
|
||||||
|
shopping_results: [
|
||||||
|
{
|
||||||
|
position: 1,
|
||||||
|
title: "Romand Better Than Eyes Dry Mango Tulip",
|
||||||
|
source: "K-Beauty Store",
|
||||||
|
link: "https://store.example/products/romand",
|
||||||
|
price: "$13.40",
|
||||||
|
extracted_price: 13.4,
|
||||||
|
delivery: "$4.99 delivery",
|
||||||
|
rating: 4.7,
|
||||||
|
reviews: 128,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const results = await searchProductOffers("dry mango tulip price", {
|
||||||
|
baseUrl: "https://serpapi.test/search.json",
|
||||||
|
serpapiApiKey: "serpapi-key",
|
||||||
|
fetchImpl: fetchMock as unknown as typeof fetch,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(results).toHaveLength(1);
|
||||||
|
expect(results[0]?.domain).toBe("store.example");
|
||||||
|
expect(results[0]?.detectedPrice).toBe(13.4);
|
||||||
|
expect(results[0]?.detectedPriceText).toBe("$13.40");
|
||||||
|
expect(results[0]?.engines).toEqual(["serpapi google shopping"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("searchProductOffers applies result limits and handles empty results", async () => {
|
||||||
|
const fetchMock = mock(async () =>
|
||||||
|
Response.json({
|
||||||
|
results: [
|
||||||
|
{ title: "One", url: "https://one.example", content: "No price" },
|
||||||
|
{ title: "Two", url: "https://two.example", content: "$20.00" },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const limited = await searchProductOffers("romand palette", {
|
||||||
|
provider: "searxng",
|
||||||
|
baseUrl: "https://searxng.test/",
|
||||||
|
fetchImpl: fetchMock as unknown as typeof fetch,
|
||||||
|
maxResults: 1,
|
||||||
|
});
|
||||||
|
expect(limited).toHaveLength(1);
|
||||||
|
expect(limited[0]?.domain).toBe("two.example");
|
||||||
|
|
||||||
|
const emptyFetch = mock(async () => Response.json({ results: [] }));
|
||||||
|
const empty = await searchProductOffers("missing product", {
|
||||||
|
provider: "searxng",
|
||||||
|
baseUrl: "https://searxng.test/",
|
||||||
|
fetchImpl: emptyFetch as unknown as typeof fetch,
|
||||||
|
});
|
||||||
|
expect(empty).toEqual([]);
|
||||||
|
});
|
||||||
777
src/integrations/searxng.ts
Normal file
777
src/integrations/searxng.ts
Normal file
@@ -0,0 +1,777 @@
|
|||||||
|
import { normalizeAsin as normalizeCanonicalAsin } from "../asin.ts";
|
||||||
|
|
||||||
|
const DEFAULT_SEARXNG_URL = "https://searxng.nvictor.me/";
|
||||||
|
const DEFAULT_GOOGLE_CUSTOM_SEARCH_URL =
|
||||||
|
"https://www.googleapis.com/customsearch/v1";
|
||||||
|
const DEFAULT_SERPAPI_URL = "https://serpapi.com/search.json";
|
||||||
|
const DEFAULT_TIMEOUT_MS = 10_000;
|
||||||
|
const DEFAULT_MAX_RESULTS = 10;
|
||||||
|
const ASIN_MATCH_REGEX = /\bB[0-9A-Z]{9}\b/gi;
|
||||||
|
const PRICE_LABELS = [
|
||||||
|
"selling price",
|
||||||
|
"sale price",
|
||||||
|
"offer price",
|
||||||
|
"current price",
|
||||||
|
"our price",
|
||||||
|
"list price",
|
||||||
|
"price",
|
||||||
|
] as const;
|
||||||
|
const CURRENCY_CODES = "USD|US\\$|EUR|GBP|INR|CAD|AUD";
|
||||||
|
const CURRENCY_SYMBOLS = "$€£₹";
|
||||||
|
const LABELED_PRICE_REGEX =
|
||||||
|
new RegExp(
|
||||||
|
`\\b(selling price|sale price|offer price|current price|our price|list price|price)\\b[^${escapeForCharClass(CURRENCY_SYMBOLS)}0-9]{0,24}((?:${CURRENCY_CODES})?\\s*[${escapeForCharClass(CURRENCY_SYMBOLS)}]\\s*[0-9]{1,5}(?:,[0-9]{3})*(?:\\.[0-9]{2})?|(?:${CURRENCY_CODES})\\s*[0-9]{1,5}(?:,[0-9]{3})*(?:\\.[0-9]{2})?)`,
|
||||||
|
"gi",
|
||||||
|
);
|
||||||
|
const PRICE_REGEX = new RegExp(
|
||||||
|
`((?:${CURRENCY_CODES})?\\s*[${escapeForCharClass(CURRENCY_SYMBOLS)}]\\s*[0-9]{1,5}(?:,[0-9]{3})*(?:\\.[0-9]{2})?|(?:${CURRENCY_CODES})\\s*[0-9]{1,5}(?:,[0-9]{3})*(?:\\.[0-9]{2})?|[0-9]{1,5}(?:,[0-9]{3})*(?:\\.[0-9]{2})?\\s*(?:${CURRENCY_CODES}))`,
|
||||||
|
"gi",
|
||||||
|
);
|
||||||
|
|
||||||
|
export type SearxngOfferSearchResult = {
|
||||||
|
asin?: string;
|
||||||
|
query: string;
|
||||||
|
title: string;
|
||||||
|
url: string;
|
||||||
|
domain: string;
|
||||||
|
snippet: string;
|
||||||
|
rank: number;
|
||||||
|
score: number;
|
||||||
|
matchedAsin?: string;
|
||||||
|
detectedPrice?: number;
|
||||||
|
detectedPriceCurrency?: string;
|
||||||
|
detectedPriceLabel?: string;
|
||||||
|
detectedPriceText?: string;
|
||||||
|
engines: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SearxngSearchOptions = {
|
||||||
|
provider?: "serpapi" | "google-custom-search" | "searxng";
|
||||||
|
baseUrl?: string;
|
||||||
|
googleApiKey?: string;
|
||||||
|
googleCx?: string;
|
||||||
|
serpapiApiKey?: string;
|
||||||
|
timeoutMs?: number;
|
||||||
|
maxResults?: number;
|
||||||
|
page?: number;
|
||||||
|
categories?: string;
|
||||||
|
engines?: string;
|
||||||
|
includeUnmatchedAsinResults?: boolean;
|
||||||
|
fetchImpl?: typeof fetch;
|
||||||
|
};
|
||||||
|
|
||||||
|
type RawSearchResult = {
|
||||||
|
title: string;
|
||||||
|
url: string;
|
||||||
|
snippet: string;
|
||||||
|
engines: string[];
|
||||||
|
rank: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type JsonSearchResponse = {
|
||||||
|
results?: Array<Record<string, unknown>>;
|
||||||
|
};
|
||||||
|
|
||||||
|
type PriceDetection = {
|
||||||
|
amount: number;
|
||||||
|
currency: string;
|
||||||
|
text: string;
|
||||||
|
label?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function searchAsinOffers(
|
||||||
|
asin: string,
|
||||||
|
options: SearxngSearchOptions = {},
|
||||||
|
): Promise<SearxngOfferSearchResult[]> {
|
||||||
|
return searchProductOffers(normalizeAsin(asin), options);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function searchProductOffers(
|
||||||
|
query: string,
|
||||||
|
options: SearxngSearchOptions = {},
|
||||||
|
): Promise<SearxngOfferSearchResult[]> {
|
||||||
|
const normalizedQuery = query.trim();
|
||||||
|
if (!normalizedQuery) {
|
||||||
|
throw new Error("Search query is required.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const inferredAsin = getAsinQuery(normalizedQuery);
|
||||||
|
const searxngQuery = inferredAsin
|
||||||
|
? `${inferredAsin} price sale offer buy online`
|
||||||
|
: normalizedQuery;
|
||||||
|
const maxResults = positiveInteger(
|
||||||
|
options.maxResults ?? readEnvInt("SEARXNG_MAX_RESULTS", DEFAULT_MAX_RESULTS),
|
||||||
|
DEFAULT_MAX_RESULTS,
|
||||||
|
);
|
||||||
|
const rawResults =
|
||||||
|
options.provider === "searxng"
|
||||||
|
? await fetchSearxngResults(searxngQuery, options)
|
||||||
|
: options.provider === "google-custom-search"
|
||||||
|
? await fetchGoogleCustomSearchResults(searxngQuery, {
|
||||||
|
...options,
|
||||||
|
maxResults,
|
||||||
|
})
|
||||||
|
: await fetchSerpApiGoogleShoppingResults(searxngQuery, {
|
||||||
|
...options,
|
||||||
|
provider: "serpapi",
|
||||||
|
maxResults,
|
||||||
|
});
|
||||||
|
return rawResults
|
||||||
|
.map((result) => normalizeResult(result, searxngQuery, inferredAsin))
|
||||||
|
.filter((result) => {
|
||||||
|
if (!result.url) return false;
|
||||||
|
if (!inferredAsin || options.includeUnmatchedAsinResults) return true;
|
||||||
|
return result.matchedAsin === inferredAsin;
|
||||||
|
})
|
||||||
|
.sort((a, b) => b.score - a.score || a.rank - b.rank)
|
||||||
|
.slice(0, maxResults);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeAsin(value: string): string {
|
||||||
|
const asin = normalizeCanonicalAsin(value);
|
||||||
|
if (!asin) {
|
||||||
|
throw new Error(`Invalid ASIN: ${value}`);
|
||||||
|
}
|
||||||
|
return asin;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getAsinQuery(value: string): string | undefined {
|
||||||
|
return normalizeCanonicalAsin(value) ?? undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchSearxngResults(
|
||||||
|
query: string,
|
||||||
|
options: SearxngSearchOptions,
|
||||||
|
): Promise<RawSearchResult[]> {
|
||||||
|
const baseUrl = normalizeBaseUrl(
|
||||||
|
options.baseUrl ?? Bun.env.SEARXNG_URL ?? DEFAULT_SEARXNG_URL,
|
||||||
|
);
|
||||||
|
const timeoutMs = positiveInteger(
|
||||||
|
options.timeoutMs ?? readEnvInt("SEARXNG_TIMEOUT_MS", DEFAULT_TIMEOUT_MS),
|
||||||
|
DEFAULT_TIMEOUT_MS,
|
||||||
|
);
|
||||||
|
const page = positiveInteger(options.page ?? 1, 1);
|
||||||
|
const categories = options.categories ?? "general";
|
||||||
|
const fetchImpl = options.fetchImpl ?? fetch;
|
||||||
|
const requestQuery = applySearxngEngineBang(query, options.engines);
|
||||||
|
|
||||||
|
const jsonUrl = buildSearchUrl(baseUrl, requestQuery, {
|
||||||
|
categories,
|
||||||
|
engines: options.engines,
|
||||||
|
page,
|
||||||
|
format: "json",
|
||||||
|
});
|
||||||
|
const jsonResponse = await fetchWithTimeout(fetchImpl, jsonUrl, timeoutMs);
|
||||||
|
if (isJsonResponse(jsonResponse)) {
|
||||||
|
const json = (await jsonResponse.json()) as JsonSearchResponse;
|
||||||
|
return parseJsonResults(json);
|
||||||
|
}
|
||||||
|
|
||||||
|
const htmlUrl = buildSearchUrl(baseUrl, requestQuery, {
|
||||||
|
categories,
|
||||||
|
engines: options.engines,
|
||||||
|
page,
|
||||||
|
});
|
||||||
|
const htmlResponse = await fetchWithTimeout(fetchImpl, htmlUrl, timeoutMs);
|
||||||
|
if (!htmlResponse.ok) {
|
||||||
|
throw new Error(
|
||||||
|
`SearXNG search failed: status=${htmlResponse.status} url=${htmlUrl.toString()}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return parseHtmlResults(await htmlResponse.text());
|
||||||
|
}
|
||||||
|
|
||||||
|
function applySearxngEngineBang(query: string, engines: string | undefined): string {
|
||||||
|
if (!engines || query.trim().startsWith("!")) return query;
|
||||||
|
const engineList = engines
|
||||||
|
.split(",")
|
||||||
|
.map((engine) => engine.trim().toLowerCase())
|
||||||
|
.filter(Boolean);
|
||||||
|
if (engineList.length !== 1) return query;
|
||||||
|
|
||||||
|
const shortcut = searxngEngineShortcut(engineList[0]!);
|
||||||
|
return shortcut ? `!${shortcut} ${query}` : query;
|
||||||
|
}
|
||||||
|
|
||||||
|
function searxngEngineShortcut(engine: string): string | undefined {
|
||||||
|
if (engine === "google") return "go";
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isJsonResponse(response: Response): boolean {
|
||||||
|
const contentType = response.headers.get("content-type") ?? "";
|
||||||
|
return response.ok && contentType.toLowerCase().includes("application/json");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchWithTimeout(
|
||||||
|
fetchImpl: typeof fetch,
|
||||||
|
url: URL,
|
||||||
|
timeoutMs: number,
|
||||||
|
): Promise<Response> {
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
||||||
|
try {
|
||||||
|
return await fetchImpl(url, {
|
||||||
|
signal: controller.signal,
|
||||||
|
headers: {
|
||||||
|
accept: "application/json,text/html;q=0.9,*/*;q=0.8",
|
||||||
|
"user-agent": "asin-check/1.0 (+https://searxng.nvictor.me/)",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildSearchUrl(
|
||||||
|
baseUrl: URL,
|
||||||
|
query: string,
|
||||||
|
params: { categories: string; engines?: string; page: number; format?: string },
|
||||||
|
): URL {
|
||||||
|
const url = new URL("search", baseUrl);
|
||||||
|
url.searchParams.set("q", query);
|
||||||
|
url.searchParams.set("categories", params.categories);
|
||||||
|
if (params.engines) {
|
||||||
|
url.searchParams.set("engines", params.engines);
|
||||||
|
}
|
||||||
|
url.searchParams.set("pageno", String(params.page));
|
||||||
|
if (params.format) {
|
||||||
|
url.searchParams.set("format", params.format);
|
||||||
|
}
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchGoogleCustomSearchResults(
|
||||||
|
query: string,
|
||||||
|
options: SearxngSearchOptions,
|
||||||
|
): Promise<RawSearchResult[]> {
|
||||||
|
const apiKey = options.googleApiKey ?? Bun.env.GOOGLE_API_KEY;
|
||||||
|
const cx =
|
||||||
|
options.googleCx ??
|
||||||
|
Bun.env.GOOGLE_CSE_ID ??
|
||||||
|
Bun.env.GOOGLE_CX ??
|
||||||
|
Bun.env.GOOGLE_SEARCH_ENGINE_ID;
|
||||||
|
if (!apiKey) {
|
||||||
|
throw new Error("Missing GOOGLE_API_KEY for Google Custom Search.");
|
||||||
|
}
|
||||||
|
if (!cx) {
|
||||||
|
throw new Error(
|
||||||
|
"Missing Google Custom Search engine id. Set GOOGLE_CSE_ID, GOOGLE_CX, or GOOGLE_SEARCH_ENGINE_ID.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const timeoutMs = positiveInteger(
|
||||||
|
options.timeoutMs ?? readEnvInt("SEARXNG_TIMEOUT_MS", DEFAULT_TIMEOUT_MS),
|
||||||
|
DEFAULT_TIMEOUT_MS,
|
||||||
|
);
|
||||||
|
const page = positiveInteger(options.page ?? 1, 1);
|
||||||
|
const num = Math.min(
|
||||||
|
10,
|
||||||
|
positiveInteger(options.maxResults ?? DEFAULT_MAX_RESULTS, DEFAULT_MAX_RESULTS),
|
||||||
|
);
|
||||||
|
const fetchImpl = options.fetchImpl ?? fetch;
|
||||||
|
const url = new URL(options.baseUrl ?? DEFAULT_GOOGLE_CUSTOM_SEARCH_URL);
|
||||||
|
url.searchParams.set("key", apiKey);
|
||||||
|
url.searchParams.set("cx", cx);
|
||||||
|
url.searchParams.set("q", query);
|
||||||
|
url.searchParams.set("num", String(num));
|
||||||
|
url.searchParams.set("start", String((page - 1) * num + 1));
|
||||||
|
|
||||||
|
const response = await fetchWithTimeout(fetchImpl, url, timeoutMs);
|
||||||
|
if (!response.ok) {
|
||||||
|
const body = await response.text().catch(() => "");
|
||||||
|
throw new Error(
|
||||||
|
`Google Custom Search failed: status=${response.status} ${body.slice(0, 300)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const json = (await response.json()) as GoogleCustomSearchResponse;
|
||||||
|
return parseGoogleCustomSearchResults(json);
|
||||||
|
}
|
||||||
|
|
||||||
|
type GoogleCustomSearchResponse = {
|
||||||
|
items?: GoogleCustomSearchItem[];
|
||||||
|
};
|
||||||
|
|
||||||
|
type GoogleCustomSearchItem = {
|
||||||
|
title?: string;
|
||||||
|
link?: string;
|
||||||
|
snippet?: string;
|
||||||
|
displayLink?: string;
|
||||||
|
pagemap?: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
|
||||||
|
type SerpApiShoppingResponse = {
|
||||||
|
shopping_results?: SerpApiShoppingResult[];
|
||||||
|
inline_shopping_results?: SerpApiShoppingResult[];
|
||||||
|
categorized_shopping_results?: Array<{
|
||||||
|
shopping_results?: SerpApiShoppingResult[];
|
||||||
|
}>;
|
||||||
|
error?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type SerpApiShoppingResult = {
|
||||||
|
position?: number;
|
||||||
|
title?: string;
|
||||||
|
source?: string;
|
||||||
|
link?: string;
|
||||||
|
product_link?: string;
|
||||||
|
serpapi_product_api?: string;
|
||||||
|
price?: string;
|
||||||
|
extracted_price?: number;
|
||||||
|
old_price?: string;
|
||||||
|
extracted_old_price?: number;
|
||||||
|
delivery?: string;
|
||||||
|
rating?: number;
|
||||||
|
reviews?: number;
|
||||||
|
snippet?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
async function fetchSerpApiGoogleShoppingResults(
|
||||||
|
query: string,
|
||||||
|
options: SearxngSearchOptions,
|
||||||
|
): Promise<RawSearchResult[]> {
|
||||||
|
const apiKey = options.serpapiApiKey ?? Bun.env.SERPAPI_API_KEY;
|
||||||
|
if (!apiKey) {
|
||||||
|
throw new Error(
|
||||||
|
"Missing SERPAPI_API_KEY. Google does not provide an official public Shopping-tab search API; use SerpApi's google_shopping API or another SERP provider.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const timeoutMs = positiveInteger(
|
||||||
|
options.timeoutMs ?? readEnvInt("SEARXNG_TIMEOUT_MS", DEFAULT_TIMEOUT_MS),
|
||||||
|
DEFAULT_TIMEOUT_MS,
|
||||||
|
);
|
||||||
|
const page = positiveInteger(options.page ?? 1, 1);
|
||||||
|
const fetchImpl = options.fetchImpl ?? fetch;
|
||||||
|
const url = new URL(options.baseUrl ?? DEFAULT_SERPAPI_URL);
|
||||||
|
url.searchParams.set("engine", "google_shopping");
|
||||||
|
url.searchParams.set("q", query);
|
||||||
|
url.searchParams.set("api_key", apiKey);
|
||||||
|
url.searchParams.set("google_domain", "google.com");
|
||||||
|
url.searchParams.set("gl", "us");
|
||||||
|
url.searchParams.set("hl", "en");
|
||||||
|
url.searchParams.set("start", String((page - 1) * 60));
|
||||||
|
|
||||||
|
const response = await fetchWithTimeout(fetchImpl, url, timeoutMs);
|
||||||
|
if (!response.ok) {
|
||||||
|
const body = await response.text().catch(() => "");
|
||||||
|
throw new Error(
|
||||||
|
`SerpApi Google Shopping failed: status=${response.status} ${body.slice(0, 300)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const json = (await response.json()) as SerpApiShoppingResponse;
|
||||||
|
if (json.error) {
|
||||||
|
throw new Error(`SerpApi Google Shopping failed: ${json.error}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return parseSerpApiShoppingResults(json);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseSerpApiShoppingResults(
|
||||||
|
json: SerpApiShoppingResponse,
|
||||||
|
): RawSearchResult[] {
|
||||||
|
const results = [
|
||||||
|
...(json.shopping_results ?? []),
|
||||||
|
...(json.inline_shopping_results ?? []),
|
||||||
|
...(json.categorized_shopping_results ?? []).flatMap(
|
||||||
|
(category) => category.shopping_results ?? [],
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
return results.flatMap((item, index) => {
|
||||||
|
const url =
|
||||||
|
optionalString(item.link) ??
|
||||||
|
optionalString(item.product_link) ??
|
||||||
|
optionalString(item.serpapi_product_api);
|
||||||
|
if (!url) return [];
|
||||||
|
|
||||||
|
const priceText = optionalString(item.price);
|
||||||
|
const snippet = [
|
||||||
|
priceText ? `offer price: ${priceText}` : undefined,
|
||||||
|
optionalString(item.old_price)
|
||||||
|
? `list price: ${item.old_price}`
|
||||||
|
: undefined,
|
||||||
|
optionalString(item.source) ? `merchant: ${item.source}` : undefined,
|
||||||
|
optionalString(item.delivery),
|
||||||
|
optionalString(item.snippet),
|
||||||
|
typeof item.rating === "number" ? `rating: ${item.rating}` : undefined,
|
||||||
|
typeof item.reviews === "number" ? `reviews: ${item.reviews}` : undefined,
|
||||||
|
]
|
||||||
|
.filter((value): value is string => !!value)
|
||||||
|
.join(" ");
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
title: optionalString(item.title) ?? "",
|
||||||
|
url,
|
||||||
|
snippet,
|
||||||
|
engines: ["serpapi google shopping"],
|
||||||
|
rank: item.position ?? index + 1,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseGoogleCustomSearchResults(
|
||||||
|
json: GoogleCustomSearchResponse,
|
||||||
|
): RawSearchResult[] {
|
||||||
|
return (json.items ?? []).flatMap((item, index) => {
|
||||||
|
const url = optionalString(item.link);
|
||||||
|
if (!url) return [];
|
||||||
|
const metadataText = extractGoogleCustomSearchMetadataText(item);
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
title: optionalString(item.title) ?? "",
|
||||||
|
url,
|
||||||
|
snippet: [optionalString(item.snippet), metadataText]
|
||||||
|
.filter((value): value is string => !!value)
|
||||||
|
.join(" "),
|
||||||
|
engines: ["google custom search"],
|
||||||
|
rank: index + 1,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractGoogleCustomSearchMetadataText(
|
||||||
|
item: GoogleCustomSearchItem,
|
||||||
|
): string {
|
||||||
|
const pagemap = item.pagemap ?? {};
|
||||||
|
const chunks: string[] = [];
|
||||||
|
|
||||||
|
for (const offer of readPagemapObjects(pagemap.offer)) {
|
||||||
|
appendPriceMetadata(chunks, offer);
|
||||||
|
}
|
||||||
|
for (const product of readPagemapObjects(pagemap.product)) {
|
||||||
|
appendPriceMetadata(chunks, product);
|
||||||
|
}
|
||||||
|
for (const metatag of readPagemapObjects(pagemap.metatags)) {
|
||||||
|
appendPriceMetadata(chunks, metatag);
|
||||||
|
}
|
||||||
|
|
||||||
|
return chunks.join(" ");
|
||||||
|
}
|
||||||
|
|
||||||
|
function appendPriceMetadata(chunks: string[], value: Record<string, unknown>): void {
|
||||||
|
const price =
|
||||||
|
optionalString(value.price) ??
|
||||||
|
optionalString(value.lowprice) ??
|
||||||
|
optionalString(value.highprice) ??
|
||||||
|
optionalString(value["product:price:amount"]) ??
|
||||||
|
optionalString(value["og:price:amount"]) ??
|
||||||
|
optionalString(value["twitter:data1"]);
|
||||||
|
if (!price) return;
|
||||||
|
|
||||||
|
const currency =
|
||||||
|
optionalString(value.pricecurrency) ??
|
||||||
|
optionalString(value.priceCurrency) ??
|
||||||
|
optionalString(value["product:price:currency"]) ??
|
||||||
|
optionalString(value["og:price:currency"]);
|
||||||
|
chunks.push(currency ? `offer price: ${currency} ${price}` : `offer price: ${price}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function readPagemapObjects(value: unknown): Array<Record<string, unknown>> {
|
||||||
|
if (!Array.isArray(value)) return [];
|
||||||
|
return value.filter(
|
||||||
|
(item): item is Record<string, unknown> =>
|
||||||
|
item != null && typeof item === "object" && !Array.isArray(item),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseJsonResults(json: JsonSearchResponse): RawSearchResult[] {
|
||||||
|
return (json.results ?? []).flatMap((result, index) => {
|
||||||
|
const url = optionalString(result.url);
|
||||||
|
if (!url) return [];
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
title: optionalString(result.title) ?? "",
|
||||||
|
url,
|
||||||
|
snippet: optionalString(result.content) ?? "",
|
||||||
|
engines: normalizeEngines(result.engines ?? result.engine),
|
||||||
|
rank: index + 1,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function parseHtmlResults(html: string): Promise<RawSearchResult[]> {
|
||||||
|
type Draft = {
|
||||||
|
title: string;
|
||||||
|
url: string;
|
||||||
|
snippet: string;
|
||||||
|
engines: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
const results: RawSearchResult[] = [];
|
||||||
|
let current: Draft | null = null;
|
||||||
|
let currentTextTarget: "title" | "snippet" | "engine" | null = null;
|
||||||
|
|
||||||
|
const appendText = (text: string) => {
|
||||||
|
if (!current || !currentTextTarget) return;
|
||||||
|
const normalized = text.replace(/\s+/g, " ").trim();
|
||||||
|
if (!normalized) return;
|
||||||
|
|
||||||
|
if (currentTextTarget === "engine") {
|
||||||
|
current.engines.push(normalized);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
current[currentTextTarget] = appendWithSpace(
|
||||||
|
current[currentTextTarget],
|
||||||
|
normalized,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const response = new HTMLRewriter()
|
||||||
|
.on("article.result", {
|
||||||
|
element(element) {
|
||||||
|
current = { title: "", url: "", snippet: "", engines: [] };
|
||||||
|
const onEndTag = (element as unknown as {
|
||||||
|
onEndTag?: (handler: () => void) => void;
|
||||||
|
}).onEndTag;
|
||||||
|
onEndTag?.call(element, () => {
|
||||||
|
if (current?.url) {
|
||||||
|
results.push({ ...current, rank: results.length + 1 });
|
||||||
|
}
|
||||||
|
current = null;
|
||||||
|
currentTextTarget = null;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.on("article.result a.url_header", {
|
||||||
|
element(element) {
|
||||||
|
if (current && !current.url) {
|
||||||
|
current.url = element.getAttribute("href") ?? "";
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.on("article.result h3 a", {
|
||||||
|
element(element) {
|
||||||
|
if (current && !current.url) {
|
||||||
|
current.url = element.getAttribute("href") ?? "";
|
||||||
|
}
|
||||||
|
currentTextTarget = "title";
|
||||||
|
},
|
||||||
|
text(text) {
|
||||||
|
appendText(text.text);
|
||||||
|
if (text.lastInTextNode) currentTextTarget = null;
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.on("article.result p.content", {
|
||||||
|
text(text) {
|
||||||
|
currentTextTarget = "snippet";
|
||||||
|
appendText(text.text);
|
||||||
|
if (text.lastInTextNode) currentTextTarget = null;
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.on("article.result .engines span", {
|
||||||
|
text(text) {
|
||||||
|
currentTextTarget = "engine";
|
||||||
|
appendText(text.text);
|
||||||
|
if (text.lastInTextNode) currentTextTarget = null;
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.transform(new Response(html));
|
||||||
|
|
||||||
|
await response.text();
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeResult(
|
||||||
|
raw: RawSearchResult,
|
||||||
|
query: string,
|
||||||
|
asin?: string,
|
||||||
|
): SearxngOfferSearchResult {
|
||||||
|
const url = normalizeUrl(raw.url);
|
||||||
|
const domain = extractDomain(url);
|
||||||
|
const title = normalizeText(raw.title);
|
||||||
|
const snippet = normalizeText(raw.snippet);
|
||||||
|
const matchedAsin = findMatchedAsin(`${title} ${snippet} ${url}`);
|
||||||
|
const detectedPrice = detectPrice(`${title} ${snippet}`);
|
||||||
|
const score = scoreResult({
|
||||||
|
asin,
|
||||||
|
matchedAsin,
|
||||||
|
detectedPrice: detectedPrice?.amount,
|
||||||
|
domain,
|
||||||
|
rank: raw.rank,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
...(asin ? { asin } : {}),
|
||||||
|
query,
|
||||||
|
title,
|
||||||
|
url,
|
||||||
|
domain,
|
||||||
|
snippet,
|
||||||
|
rank: raw.rank,
|
||||||
|
score,
|
||||||
|
...(matchedAsin ? { matchedAsin } : {}),
|
||||||
|
...(detectedPrice
|
||||||
|
? {
|
||||||
|
detectedPrice: detectedPrice.amount,
|
||||||
|
detectedPriceCurrency: detectedPrice.currency,
|
||||||
|
...(detectedPrice.label
|
||||||
|
? { detectedPriceLabel: detectedPrice.label }
|
||||||
|
: {}),
|
||||||
|
detectedPriceText: detectedPrice.text,
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
engines: dedupe(raw.engines.map(normalizeText).filter(Boolean)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function scoreResult(input: {
|
||||||
|
asin?: string;
|
||||||
|
matchedAsin?: string;
|
||||||
|
detectedPrice?: number;
|
||||||
|
domain: string;
|
||||||
|
rank: number;
|
||||||
|
}): number {
|
||||||
|
let score = 100 - input.rank;
|
||||||
|
if (input.asin && input.matchedAsin === input.asin) score += 80;
|
||||||
|
if (input.matchedAsin && !input.asin) score += 40;
|
||||||
|
if (input.detectedPrice != null) score += 30;
|
||||||
|
if (input.domain && !isAmazonDomain(input.domain)) score += 20;
|
||||||
|
if (isAmazonDomain(input.domain)) score -= 15;
|
||||||
|
return score;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeBaseUrl(value: string): URL {
|
||||||
|
const url = new URL(value);
|
||||||
|
if (!url.pathname.endsWith("/")) {
|
||||||
|
url.pathname = `${url.pathname}/`;
|
||||||
|
}
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeUrl(value: string): string {
|
||||||
|
try {
|
||||||
|
return new URL(value).toString();
|
||||||
|
} catch {
|
||||||
|
return value.trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractDomain(value: string): string {
|
||||||
|
try {
|
||||||
|
return new URL(value).hostname.replace(/^www\./i, "").toLowerCase();
|
||||||
|
} catch {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isAmazonDomain(domain: string): boolean {
|
||||||
|
return /(^|\.)amazon\./i.test(domain);
|
||||||
|
}
|
||||||
|
|
||||||
|
function findMatchedAsin(value: string): string | undefined {
|
||||||
|
const match = value.toUpperCase().match(ASIN_MATCH_REGEX);
|
||||||
|
return match?.[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
function detectPrice(value: string): PriceDetection | undefined {
|
||||||
|
const labeledCandidates = Array.from(value.matchAll(LABELED_PRICE_REGEX))
|
||||||
|
.map((match) => parsePriceMatch(match[2], match[1]))
|
||||||
|
.filter((price): price is PriceDetection => !!price)
|
||||||
|
.sort(comparePriceDetections);
|
||||||
|
if (labeledCandidates[0]) return labeledCandidates[0];
|
||||||
|
|
||||||
|
const candidates = Array.from(value.matchAll(PRICE_REGEX))
|
||||||
|
.map((match) => parsePriceMatch(match[1]))
|
||||||
|
.filter((price): price is PriceDetection => !!price);
|
||||||
|
return candidates[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
function parsePriceMatch(
|
||||||
|
rawPrice: string | undefined,
|
||||||
|
rawLabel?: string,
|
||||||
|
): PriceDetection | undefined {
|
||||||
|
if (!rawPrice) return undefined;
|
||||||
|
|
||||||
|
const text = normalizeText(rawPrice);
|
||||||
|
const currency = detectCurrency(text);
|
||||||
|
const amountMatch = text.match(/[0-9]{1,5}(?:,[0-9]{3})*(?:\.[0-9]{2})?/);
|
||||||
|
if (!amountMatch?.[0]) return undefined;
|
||||||
|
|
||||||
|
const amount = Number(amountMatch[0].replace(/,/g, ""));
|
||||||
|
if (!Number.isFinite(amount) || amount <= 0) return undefined;
|
||||||
|
|
||||||
|
const label = rawLabel ? normalizeText(rawLabel).toLowerCase() : undefined;
|
||||||
|
return {
|
||||||
|
amount,
|
||||||
|
currency,
|
||||||
|
text,
|
||||||
|
...(label ? { label } : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function comparePriceDetections(a: PriceDetection, b: PriceDetection): number {
|
||||||
|
return priceLabelRank(a.label) - priceLabelRank(b.label);
|
||||||
|
}
|
||||||
|
|
||||||
|
function priceLabelRank(label: string | undefined): number {
|
||||||
|
if (!label) return PRICE_LABELS.length;
|
||||||
|
const index = PRICE_LABELS.indexOf(label as (typeof PRICE_LABELS)[number]);
|
||||||
|
return index === -1 ? PRICE_LABELS.length : index;
|
||||||
|
}
|
||||||
|
|
||||||
|
function detectCurrency(value: string): string {
|
||||||
|
if (/\b(EUR)\b|€/i.test(value)) return "EUR";
|
||||||
|
if (/\b(GBP)\b|£/i.test(value)) return "GBP";
|
||||||
|
if (/\b(INR)\b|₹/i.test(value)) return "INR";
|
||||||
|
if (/\b(CAD)\b/i.test(value)) return "CAD";
|
||||||
|
if (/\b(AUD)\b/i.test(value)) return "AUD";
|
||||||
|
return "USD";
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeForCharClass(value: string): string {
|
||||||
|
return value.replace(/[-\\\]^]/g, "\\$&");
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeEngines(value: unknown): string[] {
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
return value.map(String).filter(Boolean);
|
||||||
|
}
|
||||||
|
const engine = optionalString(value);
|
||||||
|
return engine ? [engine] : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function optionalString(value: unknown): string | undefined {
|
||||||
|
if (value == null) return undefined;
|
||||||
|
const text = String(value).trim();
|
||||||
|
return text ? text : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeText(value: string): string {
|
||||||
|
return decodeHtmlEntities(value).replace(/\s+/g, " ").trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function appendWithSpace(left: string, right: string): string {
|
||||||
|
return left ? `${left} ${right}` : right;
|
||||||
|
}
|
||||||
|
|
||||||
|
function decodeHtmlEntities(value: string): string {
|
||||||
|
return value
|
||||||
|
.replace(/&/g, "&")
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, "'")
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">")
|
||||||
|
.replace(/ /g, " ");
|
||||||
|
}
|
||||||
|
|
||||||
|
function dedupe(values: string[]): string[] {
|
||||||
|
return Array.from(new Set(values));
|
||||||
|
}
|
||||||
|
|
||||||
|
function readEnvInt(key: string, fallback: number): number {
|
||||||
|
const parsed = Number(Bun.env[key]);
|
||||||
|
return Number.isFinite(parsed) ? parsed : fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
function positiveInteger(value: number, fallback: number): number {
|
||||||
|
return Number.isInteger(value) && value > 0 ? value : fallback;
|
||||||
|
}
|
||||||
55
src/integrations/sp-api.test.ts
Normal file
55
src/integrations/sp-api.test.ts
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
import { expect, test } from "bun:test";
|
||||||
|
import { parseCatalogUpcLookupResponse } from "./sp-api.ts";
|
||||||
|
|
||||||
|
test("parseCatalogUpcLookupResponse resolves one ASIN", () => {
|
||||||
|
const detail = parseCatalogUpcLookupResponse("012345678901", {
|
||||||
|
items: [{ asin: "b000found1" }],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(detail.status).toBe("found");
|
||||||
|
expect(detail.asin).toBe("B000FOUND1");
|
||||||
|
expect(detail.candidateAsins).toEqual(["B000FOUND1"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("parseCatalogUpcLookupResponse marks no match", () => {
|
||||||
|
const detail = parseCatalogUpcLookupResponse("012345678901", {
|
||||||
|
payload: { items: [] },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(detail.status).toBe("not_found");
|
||||||
|
expect(detail.asin).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("parseCatalogUpcLookupResponse ignores invalid ASIN identifiers", () => {
|
||||||
|
const detail = parseCatalogUpcLookupResponse("012345678901", {
|
||||||
|
items: [{ asin: "012345678901" }],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(detail.status).toBe("not_found");
|
||||||
|
expect(detail.asin).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("parseCatalogUpcLookupResponse marks multiple ASINs", () => {
|
||||||
|
const detail = parseCatalogUpcLookupResponse("012345678901", {
|
||||||
|
payload: {
|
||||||
|
items: [{ asin: "B000000001" }, { asin: "B000000002" }],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(detail.status).toBe("multiple_asins");
|
||||||
|
expect(detail.candidateAsins).toEqual(["B000000001", "B000000002"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("parseCatalogUpcLookupResponse marks invalid UPCs", () => {
|
||||||
|
const detail = parseCatalogUpcLookupResponse("123", { items: [] });
|
||||||
|
|
||||||
|
expect(detail.status).toBe("invalid_upc");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("parseCatalogUpcLookupResponse marks malformed response as failed", () => {
|
||||||
|
const detail = parseCatalogUpcLookupResponse("012345678901", {
|
||||||
|
unexpected: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(detail.status).toBe("request_failed");
|
||||||
|
});
|
||||||
@@ -1,6 +1,12 @@
|
|||||||
import { SellingPartner } from "amazon-sp-api";
|
import { SellingPartner } from "amazon-sp-api";
|
||||||
import { config } from "./config.ts";
|
import { normalizeAsin } from "../asin.ts";
|
||||||
import type { SpApiData, SellabilityInfo } from "./types.ts";
|
import { config } from "../config.ts";
|
||||||
|
import type {
|
||||||
|
KeepaUpcLookupStatus,
|
||||||
|
SpApiData,
|
||||||
|
SellabilityInfo,
|
||||||
|
UpcLookupDetail,
|
||||||
|
} from "../types.ts";
|
||||||
|
|
||||||
type RegionCode = "na" | "eu" | "fe";
|
type RegionCode = "na" | "eu" | "fe";
|
||||||
|
|
||||||
@@ -118,8 +124,12 @@ function round2(value: number): number {
|
|||||||
return Math.round(value * 100) / 100;
|
return Math.round(value * 100) / 100;
|
||||||
}
|
}
|
||||||
|
|
||||||
const SELLABILITY_CONCURRENCY = 5;
|
const LISTINGS_RESTRICTIONS_RATE_LIMIT_PER_SECOND = 5;
|
||||||
|
const LISTINGS_RESTRICTIONS_BURST_REQUESTS = 10;
|
||||||
|
const SELLABILITY_CONCURRENCY = LISTINGS_RESTRICTIONS_RATE_LIMIT_PER_SECOND;
|
||||||
|
const SELLABILITY_PROGRESS_INTERVAL = LISTINGS_RESTRICTIONS_BURST_REQUESTS;
|
||||||
const PRICING_CONCURRENCY = 5;
|
const PRICING_CONCURRENCY = 5;
|
||||||
|
const UPC_PATTERN = /^\d{12,14}$/;
|
||||||
|
|
||||||
function parseSellabilityResponse(response: any): SellabilityInfo {
|
function parseSellabilityResponse(response: any): SellabilityInfo {
|
||||||
const restrictions = Array.isArray(response?.restrictions)
|
const restrictions = Array.isArray(response?.restrictions)
|
||||||
@@ -173,6 +183,100 @@ function parseSellabilityResponse(response: any): SellabilityInfo {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildUpcLookupDetail(
|
||||||
|
upc: string,
|
||||||
|
status: KeepaUpcLookupStatus,
|
||||||
|
reason: string,
|
||||||
|
candidateAsins: string[] = [],
|
||||||
|
): UpcLookupDetail {
|
||||||
|
const asin = status === "found" ? candidateAsins[0] ?? null : null;
|
||||||
|
return {
|
||||||
|
requestedUpc: upc,
|
||||||
|
normalizedUpc: upc,
|
||||||
|
status,
|
||||||
|
asin,
|
||||||
|
candidateAsins,
|
||||||
|
keepaData: null,
|
||||||
|
reason,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectCatalogItems(response: any): any[] | null {
|
||||||
|
const candidates = [
|
||||||
|
response?.items,
|
||||||
|
response?.payload?.items,
|
||||||
|
response?.payload,
|
||||||
|
response?.Items,
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const candidate of candidates) {
|
||||||
|
if (Array.isArray(candidate)) return candidate;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractCatalogAsin(item: any): string | null {
|
||||||
|
const raw =
|
||||||
|
item?.asin ??
|
||||||
|
item?.ASIN ??
|
||||||
|
item?.identifiers?.marketplaceASIN?.asin ??
|
||||||
|
item?.Identifiers?.MarketplaceASIN?.ASIN;
|
||||||
|
if (typeof raw !== "string") return null;
|
||||||
|
return normalizeAsin(raw);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseCatalogUpcLookupResponse(
|
||||||
|
upc: string,
|
||||||
|
response: unknown,
|
||||||
|
): UpcLookupDetail {
|
||||||
|
const normalizedUpc = upc.trim();
|
||||||
|
if (!UPC_PATTERN.test(normalizedUpc)) {
|
||||||
|
return buildUpcLookupDetail(
|
||||||
|
normalizedUpc,
|
||||||
|
"invalid_upc",
|
||||||
|
"UPC must be 12, 13, or 14 digits",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const items = collectCatalogItems(response);
|
||||||
|
if (!items) {
|
||||||
|
return buildUpcLookupDetail(
|
||||||
|
normalizedUpc,
|
||||||
|
"request_failed",
|
||||||
|
"Unexpected catalog response shape",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const candidateAsins = Array.from(
|
||||||
|
new Set(items.map(extractCatalogAsin).filter((asin): asin is string => !!asin)),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (candidateAsins.length === 0) {
|
||||||
|
return buildUpcLookupDetail(
|
||||||
|
normalizedUpc,
|
||||||
|
"not_found",
|
||||||
|
"No SP-API catalog item matched this UPC",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (candidateAsins.length > 1) {
|
||||||
|
return buildUpcLookupDetail(
|
||||||
|
normalizedUpc,
|
||||||
|
"multiple_asins",
|
||||||
|
`UPC matched multiple ASINs (${candidateAsins.length})`,
|
||||||
|
candidateAsins,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return buildUpcLookupDetail(
|
||||||
|
normalizedUpc,
|
||||||
|
"found",
|
||||||
|
"Matched by SP-API catalog",
|
||||||
|
candidateAsins,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
async function fetchSellabilityInternal(
|
async function fetchSellabilityInternal(
|
||||||
spClient: SellingPartner,
|
spClient: SellingPartner,
|
||||||
asin: string,
|
asin: string,
|
||||||
@@ -520,7 +624,6 @@ export async function fetchSellabilityBatch(
|
|||||||
}
|
}
|
||||||
|
|
||||||
let completed = 0;
|
let completed = 0;
|
||||||
let running = 0;
|
|
||||||
const queue = [...asins];
|
const queue = [...asins];
|
||||||
|
|
||||||
async function next(): Promise<void> {
|
async function next(): Promise<void> {
|
||||||
@@ -529,7 +632,10 @@ export async function fetchSellabilityBatch(
|
|||||||
const info = await fetchSellabilityInternal(spClient!, asin);
|
const info = await fetchSellabilityInternal(spClient!, asin);
|
||||||
results.set(asin, info);
|
results.set(asin, info);
|
||||||
completed++;
|
completed++;
|
||||||
if (completed % 10 === 0 || completed === asins.length) {
|
if (
|
||||||
|
completed % SELLABILITY_PROGRESS_INTERVAL === 0 ||
|
||||||
|
completed === asins.length
|
||||||
|
) {
|
||||||
console.log(` [sellability] ${completed}/${asins.length} checked`);
|
console.log(` [sellability] ${completed}/${asins.length} checked`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -544,9 +650,69 @@ export async function fetchSellabilityBatch(
|
|||||||
return results;
|
return results;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function lookupSpApiUpc(upc: string): Promise<UpcLookupDetail> {
|
||||||
|
const normalizedUpc = upc.trim();
|
||||||
|
if (!UPC_PATTERN.test(normalizedUpc)) {
|
||||||
|
return buildUpcLookupDetail(
|
||||||
|
normalizedUpc,
|
||||||
|
"invalid_upc",
|
||||||
|
"UPC must be 12, 13, or 14 digits",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const spClient = getSpClient();
|
||||||
|
if (!spClient) {
|
||||||
|
return buildUpcLookupDetail(
|
||||||
|
normalizedUpc,
|
||||||
|
"request_failed",
|
||||||
|
"SP-API credentials not configured",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await spClient.callAPI({
|
||||||
|
operation: "searchCatalogItems",
|
||||||
|
endpoint: "catalogItems",
|
||||||
|
query: {
|
||||||
|
marketplaceIds: [config.spApiMarketplaceId],
|
||||||
|
identifiers: [normalizedUpc],
|
||||||
|
identifiersType: "UPC",
|
||||||
|
includedData: ["identifiers", "summaries"],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return parseCatalogUpcLookupResponse(normalizedUpc, response);
|
||||||
|
} catch (err) {
|
||||||
|
return buildUpcLookupDetail(
|
||||||
|
normalizedUpc,
|
||||||
|
"request_failed",
|
||||||
|
`SP-API catalog lookup failed: ${extractErrorMessage(err)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function lookupSpApiUpcs(
|
||||||
|
upcs: string[],
|
||||||
|
): Promise<Map<string, UpcLookupDetail>> {
|
||||||
|
const results = new Map<string, UpcLookupDetail>();
|
||||||
|
const uniqueUpcs = Array.from(new Set(upcs.map((upc) => upc.trim())));
|
||||||
|
|
||||||
|
let completed = 0;
|
||||||
|
for (const upc of uniqueUpcs) {
|
||||||
|
const detail = await lookupSpApiUpc(upc);
|
||||||
|
results.set(upc, detail);
|
||||||
|
completed++;
|
||||||
|
if (completed % 10 === 0 || completed === uniqueUpcs.length) {
|
||||||
|
console.log(` [sp-api:catalog] ${completed}/${uniqueUpcs.length} UPCs checked`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
export async function fetchSpApiPricingAndFees(
|
export async function fetchSpApiPricingAndFees(
|
||||||
asin: string,
|
asin: string,
|
||||||
sellability: SellabilityInfo,
|
sellability: SellabilityInfo,
|
||||||
|
priceOverride?: number | null,
|
||||||
): Promise<SpApiData> {
|
): Promise<SpApiData> {
|
||||||
const fallback: SpApiData = {
|
const fallback: SpApiData = {
|
||||||
fbaFee: 5.0,
|
fbaFee: 5.0,
|
||||||
@@ -563,6 +729,11 @@ export async function fetchSpApiPricingAndFees(
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
let estimatedSalePrice =
|
||||||
|
typeof priceOverride === "number" && Number.isFinite(priceOverride)
|
||||||
|
? priceOverride
|
||||||
|
: 0;
|
||||||
|
if (estimatedSalePrice <= 0) {
|
||||||
const pricing = (await spClient.callAPI({
|
const pricing = (await spClient.callAPI({
|
||||||
operation: "getItemOffers",
|
operation: "getItemOffers",
|
||||||
endpoint: "productPricing",
|
endpoint: "productPricing",
|
||||||
@@ -573,7 +744,8 @@ export async function fetchSpApiPricingAndFees(
|
|||||||
},
|
},
|
||||||
})) as any;
|
})) as any;
|
||||||
|
|
||||||
const estimatedSalePrice = extractEstimatedSalePrice(pricing);
|
estimatedSalePrice = extractEstimatedSalePrice(pricing);
|
||||||
|
}
|
||||||
if (!Number.isFinite(estimatedSalePrice) || estimatedSalePrice <= 0) {
|
if (!Number.isFinite(estimatedSalePrice) || estimatedSalePrice <= 0) {
|
||||||
console.log(
|
console.log(
|
||||||
` [sp-api:fallback] ${asin} reason=no_valid_price_from_offers`,
|
` [sp-api:fallback] ${asin} reason=no_valid_price_from_offers`,
|
||||||
141
src/keepa.ts
141
src/keepa.ts
@@ -1,141 +0,0 @@
|
|||||||
import { config } from "./config.ts";
|
|
||||||
import type { KeepaData } from "./types.ts";
|
|
||||||
|
|
||||||
const KEEPA_BASE = "https://api.keepa.com";
|
|
||||||
const MAX_ASINS_PER_REQUEST = 100;
|
|
||||||
|
|
||||||
// Token-based rate limiting: Keepa Pro = 1 token/min regeneration.
|
|
||||||
// Each product request costs 1 token regardless of ASIN count (up to 100).
|
|
||||||
// The API response includes tokensLeft and refillRate — we use those to pace.
|
|
||||||
let tokensLeft = 1; // Conservative start; updated from API response
|
|
||||||
let refillRate = 1; // tokens per minute, updated from API response
|
|
||||||
let lastRequestTime = 0;
|
|
||||||
|
|
||||||
async function waitForToken(): Promise<void> {
|
|
||||||
if (tokensLeft > 0) return;
|
|
||||||
|
|
||||||
const elapsed = (Date.now() - lastRequestTime) / 60_000; // minutes
|
|
||||||
const regenerated = Math.floor(elapsed * refillRate);
|
|
||||||
if (regenerated > 0) {
|
|
||||||
tokensLeft += regenerated;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Wait until we regenerate at least 1 token
|
|
||||||
const waitMs =
|
|
||||||
Math.ceil((1 / refillRate) * 60_000) - (Date.now() - lastRequestTime);
|
|
||||||
if (waitMs > 0) {
|
|
||||||
console.log(
|
|
||||||
`Keepa tokens exhausted. Waiting ${Math.ceil(waitMs / 1000)}s for token regeneration...`,
|
|
||||||
);
|
|
||||||
await new Promise((r) => setTimeout(r, waitMs));
|
|
||||||
}
|
|
||||||
tokensLeft = 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function fetchKeepaDataBatch(
|
|
||||||
asins: string[],
|
|
||||||
): Promise<Map<string, KeepaData>> {
|
|
||||||
const results = new Map<string, KeepaData>();
|
|
||||||
|
|
||||||
// Split into chunks of MAX_ASINS_PER_REQUEST
|
|
||||||
for (let i = 0; i < asins.length; i += MAX_ASINS_PER_REQUEST) {
|
|
||||||
const chunk = asins.slice(i, i + MAX_ASINS_PER_REQUEST);
|
|
||||||
await waitForToken();
|
|
||||||
|
|
||||||
const asinParam = chunk.join(",");
|
|
||||||
const url = `${KEEPA_BASE}/product?key=${config.keepaApiKey}&domain=1&asin=${asinParam}&stats=90`;
|
|
||||||
|
|
||||||
console.log(
|
|
||||||
`Keepa: fetching ${chunk.length} ASINs (tokens left: ${tokensLeft})...`,
|
|
||||||
);
|
|
||||||
|
|
||||||
const res = await fetch(url);
|
|
||||||
lastRequestTime = Date.now();
|
|
||||||
|
|
||||||
if (!res.ok) {
|
|
||||||
const text = await res.text();
|
|
||||||
throw new Error(`Keepa API error ${res.status}: ${text}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = (await res.json()) as {
|
|
||||||
products?: Record<string, any>[];
|
|
||||||
tokensLeft?: number;
|
|
||||||
refillRate?: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Update token state from API response
|
|
||||||
if (data.tokensLeft != null) tokensLeft = data.tokensLeft;
|
|
||||||
if (data.refillRate != null) refillRate = data.refillRate;
|
|
||||||
|
|
||||||
console.log(
|
|
||||||
`Keepa: ${data.products?.length ?? 0} products returned, ${tokensLeft} tokens remaining (refill: ${refillRate}/min)`,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (data.products) {
|
|
||||||
for (const product of data.products) {
|
|
||||||
const asin = product.asin;
|
|
||||||
if (!asin) continue;
|
|
||||||
results.set(asin, parseKeepaProduct(product));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return results;
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseKeepaProduct(product: Record<string, any>): KeepaData {
|
|
||||||
const stats = product.stats;
|
|
||||||
const csv = product.csv;
|
|
||||||
const salesRankDrops30 = pickKeepaNumber(
|
|
||||||
product.salesRankDrops30,
|
|
||||||
stats?.salesRankDrops30,
|
|
||||||
);
|
|
||||||
const salesRankDrops90 =
|
|
||||||
pickKeepaNumber(product.salesRankDrops90, stats?.salesRankDrops90) ??
|
|
||||||
(salesRankDrops30 != null ? salesRankDrops30 * 3 : null);
|
|
||||||
const monthlySold =
|
|
||||||
pickKeepaNumber(product.monthlySold, stats?.monthlySold) ??
|
|
||||||
salesRankDrops30;
|
|
||||||
|
|
||||||
return {
|
|
||||||
currentPrice: extractCurrentPrice(csv),
|
|
||||||
avgPrice90: stats?.avg?.[0] != null ? stats.avg[0] / 100 : null,
|
|
||||||
minPrice90: stats?.min?.[0] != null ? stats.min[0] / 100 : null,
|
|
||||||
maxPrice90: stats?.max?.[0] != null ? stats.max[0] / 100 : null,
|
|
||||||
salesRank: stats?.current?.[3] ?? null,
|
|
||||||
salesRankAvg90: stats?.avg?.[3] ?? null,
|
|
||||||
salesRankDrops30,
|
|
||||||
salesRankDrops90,
|
|
||||||
sellerCount: stats?.current?.[11] ?? null,
|
|
||||||
buyBoxSeller: product.buyBoxSellerId ?? null,
|
|
||||||
buyBoxPrice: stats?.current?.[18] != null ? stats.current[18] / 100 : null,
|
|
||||||
monthlySold,
|
|
||||||
categoryTree:
|
|
||||||
product.categoryTree?.map((c: { name: string }) => c.name) ?? [],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function pickKeepaNumber(...values: unknown[]): number | null {
|
|
||||||
for (const value of values) {
|
|
||||||
if (typeof value !== "number" || !Number.isFinite(value)) continue;
|
|
||||||
// Keepa often uses -1 as "not available".
|
|
||||||
if (value < 0) continue;
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function extractCurrentPrice(csv: number[][] | undefined): number | null {
|
|
||||||
if (!csv) return null;
|
|
||||||
|
|
||||||
// csv[0] = Amazon price history, csv[1] = Marketplace new price history
|
|
||||||
// Each is [time, price, time, price, ...] — last value is most recent
|
|
||||||
for (const series of [csv[0], csv[1]]) {
|
|
||||||
if (series && series.length >= 2) {
|
|
||||||
const lastPrice = series[series.length - 1]!;
|
|
||||||
if (lastPrice > 0) return lastPrice / 100;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
@@ -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;
|
||||||
|
|||||||
1880
src/server.ts
1880
src/server.ts
File diff suppressed because it is too large
Load Diff
@@ -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);
|
||||||
|
|||||||
275
src/stalker/stalker-analyze.ts
Normal file
275
src/stalker/stalker-analyze.ts
Normal 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 {
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
221
src/stalker/stalker-sellability.test.ts
Normal file
221
src/stalker/stalker-sellability.test.ts
Normal file
@@ -0,0 +1,221 @@
|
|||||||
|
import { afterAll, beforeEach, expect, mock, test } from "bun:test";
|
||||||
|
import { mkdirSync, rmSync } from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
import * as XLSX from "xlsx";
|
||||||
|
|
||||||
|
let nextId = 0;
|
||||||
|
|
||||||
|
function chainable(resolveWith: any[] = []): any {
|
||||||
|
const p: any = Promise.resolve(resolveWith);
|
||||||
|
p.limit = (_n: any) => chainable(resolveWith);
|
||||||
|
p.where = (_cond: any) => chainable(resolveWith);
|
||||||
|
p.from = (_table: any) => chainable(resolveWith);
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
|
||||||
|
const makeMockTx = (): any => ({
|
||||||
|
insert: (_table: any) => ({
|
||||||
|
values: (_vals: any) => ({
|
||||||
|
returning: (_sel: any) => Promise.resolve([{ id: ++nextId }]),
|
||||||
|
onConflictDoUpdate: (_conf: any) => Promise.resolve([]),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
update: (_table: any) => ({
|
||||||
|
set: (_vals: any) => ({
|
||||||
|
where: (_cond: any) => Promise.resolve([]),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
select: (_sel?: any) => ({
|
||||||
|
from: (_table: any) => ({
|
||||||
|
where: (_cond: any) => chainable([{ id: ++nextId }]),
|
||||||
|
limit: (_n: any) => chainable([{ id: nextId }]),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
selectDistinct: (_sel: any) => ({
|
||||||
|
from: (_table: any) => chainable([]),
|
||||||
|
}),
|
||||||
|
execute: (_query: any) => Promise.resolve([]),
|
||||||
|
});
|
||||||
|
|
||||||
|
const makeMockDb = (): any => ({
|
||||||
|
insert: (_table: any) => ({
|
||||||
|
values: (_vals: any) => ({
|
||||||
|
returning: (_sel: any) => Promise.resolve([{ id: ++nextId }]),
|
||||||
|
onConflictDoUpdate: (_conf: any) => Promise.resolve([]),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
update: (_table: any) => ({
|
||||||
|
set: (_vals: any) => ({
|
||||||
|
where: (_cond: any) => Promise.resolve([]),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
select: (_sel?: any) => ({
|
||||||
|
from: (_table: any) => ({
|
||||||
|
where: (_cond: any) => chainable(),
|
||||||
|
limit: (_n: any) => chainable(),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
selectDistinct: (_sel: any) => ({
|
||||||
|
from: (_table: any) => chainable(),
|
||||||
|
}),
|
||||||
|
execute: (_query: any) => Promise.resolve([]),
|
||||||
|
transaction: async (fn: (tx: any) => Promise<any>) => fn(makeMockTx()),
|
||||||
|
});
|
||||||
|
|
||||||
|
mock.module("../db/index.ts", () => ({ db: makeMockDb(), client: {} }));
|
||||||
|
|
||||||
|
const TEST_DIR = path.join(process.cwd(), "test_output", "stalker-sellability");
|
||||||
|
const originalFetch = globalThis.fetch;
|
||||||
|
const originalKeepaKey = Bun.env.KEEPA_API_KEY;
|
||||||
|
|
||||||
|
const fetchSellabilityBatchMock = mock(async (asins: string[]) => {
|
||||||
|
return new Map(
|
||||||
|
asins.map((asin) => [
|
||||||
|
asin,
|
||||||
|
asin === "B111111111"
|
||||||
|
? {
|
||||||
|
canSell: true,
|
||||||
|
sellabilityStatus: "available" as const,
|
||||||
|
sellabilityReason: "No listing restrictions reported",
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
canSell: false,
|
||||||
|
sellabilityStatus: "restricted" as const,
|
||||||
|
sellabilityReason: "approval required",
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const modulePromise = import("./stalker.ts");
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
nextId = 0;
|
||||||
|
rmSync(TEST_DIR, { recursive: true, force: true });
|
||||||
|
mkdirSync(TEST_DIR, { recursive: true });
|
||||||
|
globalThis.fetch = originalFetch;
|
||||||
|
Bun.env.KEEPA_API_KEY = "test-keepa-key";
|
||||||
|
fetchSellabilityBatchMock.mockClear();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
globalThis.fetch = originalFetch;
|
||||||
|
if (originalKeepaKey == null) {
|
||||||
|
delete Bun.env.KEEPA_API_KEY;
|
||||||
|
} else {
|
||||||
|
Bun.env.KEEPA_API_KEY = originalKeepaKey;
|
||||||
|
}
|
||||||
|
rmSync(TEST_DIR, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("sellability checks matched seller inventory, not the source ASIN", async () => {
|
||||||
|
const { runStalker } = await modulePromise;
|
||||||
|
const inputPath = path.join(TEST_DIR, "input.xlsx");
|
||||||
|
const workbook = XLSX.utils.book_new();
|
||||||
|
XLSX.utils.book_append_sheet(
|
||||||
|
workbook,
|
||||||
|
XLSX.utils.json_to_sheet([{ asin: "B000000001" }]),
|
||||||
|
"Input",
|
||||||
|
);
|
||||||
|
XLSX.writeFile(workbook, inputPath);
|
||||||
|
|
||||||
|
globalThis.fetch = mock(async (input: string | URL | Request) => {
|
||||||
|
const rawUrl =
|
||||||
|
typeof input === "string"
|
||||||
|
? input
|
||||||
|
: input instanceof URL
|
||||||
|
? input.toString()
|
||||||
|
: input.url;
|
||||||
|
const url = new URL(rawUrl);
|
||||||
|
|
||||||
|
if (url.pathname === "/product") {
|
||||||
|
if (url.searchParams.get("asin") === "B111111111") {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
products: [
|
||||||
|
{
|
||||||
|
asin: "B111111111",
|
||||||
|
title: "Sellable Storefront Product",
|
||||||
|
brand: "Good Brand",
|
||||||
|
categoryTree: [{ name: "Kitchen" }, { name: "Storage" }],
|
||||||
|
monthlySold: 42,
|
||||||
|
stats: {
|
||||||
|
current: [null, null, null, 12345, null, null, null, null, null, null, null, 7],
|
||||||
|
avg: [2500],
|
||||||
|
},
|
||||||
|
csv: [[5000000, 1999, 5000100]],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
tokensLeft: 10,
|
||||||
|
refillRate: 10,
|
||||||
|
}),
|
||||||
|
{ status: 200 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
products: [
|
||||||
|
{
|
||||||
|
asin: "B000000001",
|
||||||
|
title: "Source Product",
|
||||||
|
offers: [{ sellerId: "AQUALIFIED", price: 1999 }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
tokensLeft: 10,
|
||||||
|
refillRate: 10,
|
||||||
|
}),
|
||||||
|
{ status: 200 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url.pathname === "/seller") {
|
||||||
|
const wantsStorefront = url.searchParams.get("storefront") === "1";
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
sellers: {
|
||||||
|
AQUALIFIED: {
|
||||||
|
sellerName: "New Seller",
|
||||||
|
currentRatingCount: 12,
|
||||||
|
asinList: wantsStorefront ? ["B111111111", "B222222222"] : [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
tokensLeft: 10,
|
||||||
|
refillRate: 10,
|
||||||
|
}),
|
||||||
|
{ status: 200 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Response("not found", { status: 404 });
|
||||||
|
}) as unknown as typeof globalThis.fetch;
|
||||||
|
|
||||||
|
const stats = await runStalker(
|
||||||
|
{
|
||||||
|
input: inputPath,
|
||||||
|
maxAsins: null,
|
||||||
|
offerLimit: 20,
|
||||||
|
sellerLimit: 30,
|
||||||
|
inventoryLimit: 200,
|
||||||
|
sellerCacheHours: 168,
|
||||||
|
includeStock: false,
|
||||||
|
dryRun: false,
|
||||||
|
resume: true,
|
||||||
|
maxSellerRequests: null,
|
||||||
|
sellability: true,
|
||||||
|
analyzeSellable: false,
|
||||||
|
useClaude: false,
|
||||||
|
},
|
||||||
|
{ fetchSellabilityBatch: fetchSellabilityBatchMock },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(fetchSellabilityBatchMock.mock.calls.length).toBe(1);
|
||||||
|
expect(fetchSellabilityBatchMock.mock.calls[0]?.[0]).toEqual([
|
||||||
|
"B111111111",
|
||||||
|
"B222222222",
|
||||||
|
]);
|
||||||
|
expect(stats.inventorySellabilityCheckedAsins).toBe(2);
|
||||||
|
expect(stats.inventorySellabilityAvailableAsins).toBe(1);
|
||||||
|
expect(stats.inventorySellabilityExcludedAsins).toBe(1);
|
||||||
|
expect(stats.persistedInventoryAsins).toBe(1);
|
||||||
|
});
|
||||||
280
src/stalker/stalker.test.ts
Normal file
280
src/stalker/stalker.test.ts
Normal file
@@ -0,0 +1,280 @@
|
|||||||
|
import { afterAll, beforeEach, expect, mock, test } from "bun:test";
|
||||||
|
import { mkdirSync, rmSync } from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
import * as XLSX from "xlsx";
|
||||||
|
import {
|
||||||
|
extractLiveOfferSellerCandidates,
|
||||||
|
isQualifyingSeller,
|
||||||
|
readAsinsFromXlsx,
|
||||||
|
runStalker,
|
||||||
|
} from "./stalker.ts";
|
||||||
|
|
||||||
|
let nextId = 0;
|
||||||
|
|
||||||
|
function chainable(resolveWith: any[] = []): any {
|
||||||
|
const p: any = Promise.resolve(resolveWith);
|
||||||
|
p.limit = (_n: any) => chainable(resolveWith);
|
||||||
|
p.where = (_cond: any) => chainable(resolveWith);
|
||||||
|
p.from = (_table: any) => chainable(resolveWith);
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Transaction mock returns rows for selects (needed for upsert-then-select patterns).
|
||||||
|
const makeMockTx = (): any => ({
|
||||||
|
insert: (_table: any) => ({
|
||||||
|
values: (_vals: any) => ({
|
||||||
|
returning: (_sel: any) => Promise.resolve([{ id: ++nextId }]),
|
||||||
|
onConflictDoUpdate: (_conf: any) => Promise.resolve([]),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
update: (_table: any) => ({
|
||||||
|
set: (_vals: any) => ({
|
||||||
|
where: (_cond: any) => Promise.resolve([]),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
select: (_sel?: any) => ({
|
||||||
|
from: (_table: any) => ({
|
||||||
|
where: (_cond: any) => chainable([{ id: ++nextId }]),
|
||||||
|
limit: (_n: any) => chainable([{ id: nextId }]),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
selectDistinct: (_sel: any) => ({
|
||||||
|
from: (_table: any) => chainable([]),
|
||||||
|
}),
|
||||||
|
execute: (_query: any) => Promise.resolve([]),
|
||||||
|
});
|
||||||
|
|
||||||
|
const makeMockDb = (): any => ({
|
||||||
|
insert: (_table: any) => ({
|
||||||
|
values: (_vals: any) => ({
|
||||||
|
returning: (_sel: any) => Promise.resolve([{ id: ++nextId }]),
|
||||||
|
onConflictDoUpdate: (_conf: any) => Promise.resolve([]),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
update: (_table: any) => ({
|
||||||
|
set: (_vals: any) => ({
|
||||||
|
where: (_cond: any) => Promise.resolve([]),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
select: (_sel?: any) => ({
|
||||||
|
from: (_table: any) => ({
|
||||||
|
where: (_cond: any) => chainable(),
|
||||||
|
limit: (_n: any) => chainable(),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
selectDistinct: (_sel: any) => ({
|
||||||
|
from: (_table: any) => chainable(),
|
||||||
|
}),
|
||||||
|
execute: (_query: any) => Promise.resolve([]),
|
||||||
|
transaction: async (fn: (tx: any) => Promise<any>) => fn(makeMockTx()),
|
||||||
|
});
|
||||||
|
|
||||||
|
mock.module("../db/index.ts", () => ({ db: makeMockDb(), client: {} }));
|
||||||
|
|
||||||
|
const TEST_DIR = path.join(process.cwd(), "test_output", "stalker");
|
||||||
|
const originalFetch = globalThis.fetch;
|
||||||
|
const originalKeepaKey = Bun.env.KEEPA_API_KEY;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
nextId = 0;
|
||||||
|
rmSync(TEST_DIR, { recursive: true, force: true });
|
||||||
|
mkdirSync(TEST_DIR, { recursive: true });
|
||||||
|
globalThis.fetch = originalFetch;
|
||||||
|
Bun.env.KEEPA_API_KEY = "test-keepa-key";
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
globalThis.fetch = originalFetch;
|
||||||
|
if (originalKeepaKey == null) {
|
||||||
|
delete Bun.env.KEEPA_API_KEY;
|
||||||
|
} else {
|
||||||
|
Bun.env.KEEPA_API_KEY = originalKeepaKey;
|
||||||
|
}
|
||||||
|
rmSync(TEST_DIR, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("readAsinsFromXlsx extracts valid ASINs and deduplicates in order", () => {
|
||||||
|
const filePath = path.join(TEST_DIR, "asins.xlsx");
|
||||||
|
const workbook = XLSX.utils.book_new();
|
||||||
|
const sheet = XLSX.utils.json_to_sheet([
|
||||||
|
{ ASIN: "b000000001" },
|
||||||
|
{ ASIN: "invalid" },
|
||||||
|
{ ASIN: "B000000002" },
|
||||||
|
{ ASIN: "B000000001" },
|
||||||
|
{ ASIN: "0306406152" },
|
||||||
|
{ ASIN: "" },
|
||||||
|
]);
|
||||||
|
XLSX.utils.book_append_sheet(workbook, sheet, "Input");
|
||||||
|
XLSX.writeFile(workbook, filePath);
|
||||||
|
|
||||||
|
expect(readAsinsFromXlsx(filePath)).toEqual([
|
||||||
|
"B000000001",
|
||||||
|
"B000000002",
|
||||||
|
"0306406152",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("isQualifyingSeller accepts rating counts from 1 to 30 only", () => {
|
||||||
|
expect(isQualifyingSeller({ ratingCount: null })).toBe(false);
|
||||||
|
expect(isQualifyingSeller({ ratingCount: 0 })).toBe(false);
|
||||||
|
expect(isQualifyingSeller({ ratingCount: 1 })).toBe(true);
|
||||||
|
expect(isQualifyingSeller({ ratingCount: 30 })).toBe(true);
|
||||||
|
expect(isQualifyingSeller({ ratingCount: 31 })).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("extractLiveOfferSellerCandidates ignores Amazon, missing seller IDs, and duplicates", () => {
|
||||||
|
const offers = extractLiveOfferSellerCandidates({
|
||||||
|
offers: [
|
||||||
|
{ sellerId: "ATVPDKIKX0DER", price: 1999 },
|
||||||
|
{ price: 1899 },
|
||||||
|
{ sellerId: "A1SELLER", price: 1599, isFBA: true, stock: 4 },
|
||||||
|
{ sellerId: "A1SELLER", price: 1499 },
|
||||||
|
{ sellerID: "A2SELLER", currentPrice: 2499, isFba: false },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(offers.map((offer) => offer.sellerId)).toEqual([
|
||||||
|
"A1SELLER",
|
||||||
|
"A2SELLER",
|
||||||
|
]);
|
||||||
|
expect(offers[0]?.offerPrice).toBe(15.99);
|
||||||
|
expect(offers[0]?.isFba).toBe(true);
|
||||||
|
expect(offers[0]?.stock).toBe(4);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("runStalker fetches product offers, filters sellers, and tracks stats", async () => {
|
||||||
|
const inputPath = path.join(TEST_DIR, "input.xlsx");
|
||||||
|
const workbook = XLSX.utils.book_new();
|
||||||
|
XLSX.utils.book_append_sheet(
|
||||||
|
workbook,
|
||||||
|
XLSX.utils.json_to_sheet([{ asin: "B000000001" }]),
|
||||||
|
"Input",
|
||||||
|
);
|
||||||
|
XLSX.writeFile(workbook, inputPath);
|
||||||
|
|
||||||
|
const fetchMock = mock(async (input: string | URL | Request) => {
|
||||||
|
const rawUrl =
|
||||||
|
typeof input === "string"
|
||||||
|
? input
|
||||||
|
: input instanceof URL
|
||||||
|
? input.toString()
|
||||||
|
: input.url;
|
||||||
|
const url = new URL(rawUrl);
|
||||||
|
|
||||||
|
if (url.pathname === "/product") {
|
||||||
|
expect(url.searchParams.get("asin")).toBe("B000000001");
|
||||||
|
expect(url.searchParams.get("offers")).toBe("20");
|
||||||
|
expect(url.searchParams.get("only-live-offers")).toBe("1");
|
||||||
|
expect(url.searchParams.has("stock")).toBe(false);
|
||||||
|
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
products: [
|
||||||
|
{
|
||||||
|
asin: "B000000001",
|
||||||
|
title: "Tracked Product",
|
||||||
|
offers: [
|
||||||
|
{
|
||||||
|
sellerId: "AQUALIFIED",
|
||||||
|
price: 1999,
|
||||||
|
condition: "New",
|
||||||
|
isFBA: true,
|
||||||
|
stock: 3,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
sellerId: "AOLDSELLER",
|
||||||
|
price: 2099,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
tokensLeft: 10,
|
||||||
|
refillRate: 10,
|
||||||
|
}),
|
||||||
|
{ status: 200 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url.pathname === "/seller") {
|
||||||
|
const wantsStorefront = url.searchParams.get("storefront") === "1";
|
||||||
|
if (wantsStorefront) {
|
||||||
|
expect(url.searchParams.has("update")).toBeFalse();
|
||||||
|
}
|
||||||
|
const sellerId = url.searchParams.get("seller");
|
||||||
|
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
sellers: {
|
||||||
|
...(!wantsStorefront && sellerId === "AQUALIFIED,AOLDSELLER"
|
||||||
|
? {
|
||||||
|
AQUALIFIED: {
|
||||||
|
sellerName: "New Seller",
|
||||||
|
currentRating: 96,
|
||||||
|
currentRatingCount: 12,
|
||||||
|
},
|
||||||
|
AOLDSELLER: {
|
||||||
|
sellerName: "Old Seller",
|
||||||
|
currentRating: 99,
|
||||||
|
currentRatingCount: 120,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
...(wantsStorefront && sellerId === "AQUALIFIED"
|
||||||
|
? {
|
||||||
|
AQUALIFIED: {
|
||||||
|
sellerName: "New Seller",
|
||||||
|
currentRating: 96,
|
||||||
|
currentRatingCount: 12,
|
||||||
|
asinList: ["B111111111", "B222222222"],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
},
|
||||||
|
tokensLeft: 10,
|
||||||
|
refillRate: 10,
|
||||||
|
}),
|
||||||
|
{ status: 200 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Response("not found", { status: 404 });
|
||||||
|
});
|
||||||
|
globalThis.fetch = fetchMock as unknown as typeof globalThis.fetch;
|
||||||
|
|
||||||
|
const stats = await runStalker({
|
||||||
|
input: inputPath,
|
||||||
|
maxAsins: null,
|
||||||
|
offerLimit: 20,
|
||||||
|
sellerLimit: 30,
|
||||||
|
inventoryLimit: 200,
|
||||||
|
sellerCacheHours: 168,
|
||||||
|
includeStock: false,
|
||||||
|
dryRun: false,
|
||||||
|
resume: true,
|
||||||
|
maxSellerRequests: null,
|
||||||
|
sellability: false,
|
||||||
|
analyzeSellable: false,
|
||||||
|
useClaude: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(stats.scannedAsins).toBe(1);
|
||||||
|
expect(stats.sourceAsinsWithMatches).toBe(1);
|
||||||
|
expect(stats.matchedSellers).toBe(1);
|
||||||
|
expect(stats.persistedInventoryAsins).toBe(0);
|
||||||
|
expect(stats.failedAsins).toBe(0);
|
||||||
|
expect(stats.candidateSellers).toBe(2);
|
||||||
|
expect(stats.qualifyingSellers).toBe(1);
|
||||||
|
expect(stats.sellerMetadataRequests).toBe(1);
|
||||||
|
expect(stats.sellerStorefrontRequests).toBe(1);
|
||||||
|
|
||||||
|
const sellerCalls = fetchMock.mock.calls.filter((call) => {
|
||||||
|
const rawUrl =
|
||||||
|
typeof call[0] === "string"
|
||||||
|
? call[0]
|
||||||
|
: call[0] instanceof URL
|
||||||
|
? call[0].toString()
|
||||||
|
: (call[0] as Request).url;
|
||||||
|
return new URL(rawUrl).pathname === "/seller";
|
||||||
|
});
|
||||||
|
expect(sellerCalls.length).toBe(2);
|
||||||
|
});
|
||||||
1630
src/stalker/stalker.ts
Normal file
1630
src/stalker/stalker.ts
Normal file
File diff suppressed because it is too large
Load Diff
135
src/supplier/supplier-export.test.ts
Normal file
135
src/supplier/supplier-export.test.ts
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
import { afterEach, expect, test } from "bun:test";
|
||||||
|
import path from "node:path";
|
||||||
|
import { rmSync } from "node:fs";
|
||||||
|
import ExcelJS from "exceljs";
|
||||||
|
import { writeSupplierWorkbook } from "./supplier-export.ts";
|
||||||
|
import type { SupplierAnalysisResult } from "../types.ts";
|
||||||
|
|
||||||
|
const OUTPUT_FILE = path.join("/private/tmp", "asin-check-supplier-export-test.xlsx");
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
rmSync(OUTPUT_FILE, { force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
function result(overrides: Partial<SupplierAnalysisResult> = {}): SupplierAnalysisResult {
|
||||||
|
return {
|
||||||
|
upc: "012345678901",
|
||||||
|
rowNumber: 2,
|
||||||
|
record: {
|
||||||
|
name: "Test Product",
|
||||||
|
unitCost: 10,
|
||||||
|
brand: "Brand",
|
||||||
|
category: "Grocery",
|
||||||
|
},
|
||||||
|
product: { asin: "B000000001", name: "Test Product", unitCost: 10 },
|
||||||
|
lookup: {
|
||||||
|
requestedUpc: "012345678901",
|
||||||
|
normalizedUpc: "012345678901",
|
||||||
|
status: "found",
|
||||||
|
asin: "B000000001",
|
||||||
|
candidateAsins: ["B000000001"],
|
||||||
|
keepaData: null,
|
||||||
|
},
|
||||||
|
keepa: {
|
||||||
|
currentPrice: 30,
|
||||||
|
avgPrice90: 29,
|
||||||
|
minPrice90: 25,
|
||||||
|
maxPrice90: 35,
|
||||||
|
salesRank: 1000,
|
||||||
|
salesRankAvg90: 1200,
|
||||||
|
salesRankDrops30: 60,
|
||||||
|
salesRankDrops90: 180,
|
||||||
|
sellerCount: 4,
|
||||||
|
amazonIsSeller: false,
|
||||||
|
amazonBuyboxSharePct90d: 0,
|
||||||
|
buyBoxSeller: "SELLER",
|
||||||
|
buyBoxPrice: 30,
|
||||||
|
buyBoxAvg90: 29,
|
||||||
|
monthlySold: 300,
|
||||||
|
categoryTree: ["Grocery"],
|
||||||
|
},
|
||||||
|
spApi: {
|
||||||
|
fbaFee: 5,
|
||||||
|
fbmFee: 3,
|
||||||
|
referralFeePercent: 15,
|
||||||
|
estimatedSalePrice: 30,
|
||||||
|
canSell: true,
|
||||||
|
sellabilityStatus: "available",
|
||||||
|
sellabilityReason: "ok",
|
||||||
|
},
|
||||||
|
score: {
|
||||||
|
salePrice: 30,
|
||||||
|
fbaFee: 5,
|
||||||
|
profit: 15,
|
||||||
|
margin: 0.5,
|
||||||
|
roi: 1.5,
|
||||||
|
demandScore: 1,
|
||||||
|
competitionPenalty: 1,
|
||||||
|
score: 70,
|
||||||
|
verdict: "BUY",
|
||||||
|
reason: "Profitable with demand",
|
||||||
|
},
|
||||||
|
fetchedAt: "2026-05-19T00:00:00.000Z",
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test("writeSupplierWorkbook writes ranked, skipped, and summary sheets", async () => {
|
||||||
|
await writeSupplierWorkbook(
|
||||||
|
OUTPUT_FILE,
|
||||||
|
[
|
||||||
|
result(),
|
||||||
|
result({
|
||||||
|
upc: "111111111111",
|
||||||
|
record: { name: "Missing", unitCost: 0 },
|
||||||
|
product: null,
|
||||||
|
lookup: {
|
||||||
|
requestedUpc: "111111111111",
|
||||||
|
normalizedUpc: "111111111111",
|
||||||
|
status: "not_found",
|
||||||
|
asin: null,
|
||||||
|
candidateAsins: [],
|
||||||
|
keepaData: null,
|
||||||
|
reason: "No match",
|
||||||
|
},
|
||||||
|
keepa: null,
|
||||||
|
spApi: null,
|
||||||
|
score: {
|
||||||
|
salePrice: null,
|
||||||
|
fbaFee: null,
|
||||||
|
profit: null,
|
||||||
|
margin: null,
|
||||||
|
roi: null,
|
||||||
|
demandScore: 0,
|
||||||
|
competitionPenalty: 1,
|
||||||
|
score: 0,
|
||||||
|
verdict: "SKIP",
|
||||||
|
reason: "No match",
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
{
|
||||||
|
processedRows: 2,
|
||||||
|
resolvedRows: 1,
|
||||||
|
eligibleRows: 1,
|
||||||
|
verdictCounts: { BUY: 1, WATCH: 0, SKIP: 1 },
|
||||||
|
unresolvedByStatus: {
|
||||||
|
found: 1,
|
||||||
|
invalid_upc: 0,
|
||||||
|
not_found: 1,
|
||||||
|
multiple_asins: 0,
|
||||||
|
request_failed: 0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const workbook = new ExcelJS.Workbook();
|
||||||
|
await workbook.xlsx.readFile(OUTPUT_FILE);
|
||||||
|
|
||||||
|
expect(workbook.getWorksheet("Ranked Leads")).toBeDefined();
|
||||||
|
expect(workbook.getWorksheet("Skipped")).toBeDefined();
|
||||||
|
expect(workbook.getWorksheet("Summary")).toBeDefined();
|
||||||
|
expect(workbook.getWorksheet("Ranked Leads")?.getCell("A1").value).toBe("UPC");
|
||||||
|
expect(workbook.getWorksheet("Ranked Leads")?.getCell("B2").value).toBe("B000000001");
|
||||||
|
expect(workbook.getWorksheet("Skipped")?.getCell("A2").value).toBe("111111111111");
|
||||||
|
});
|
||||||
159
src/supplier/supplier-export.ts
Normal file
159
src/supplier/supplier-export.ts
Normal file
@@ -0,0 +1,159 @@
|
|||||||
|
import ExcelJS from "exceljs";
|
||||||
|
import { dirname } from "node:path";
|
||||||
|
import { mkdirSync } from "node:fs";
|
||||||
|
import type {
|
||||||
|
KeepaUpcLookupStatus,
|
||||||
|
SupplierAnalysisResult,
|
||||||
|
SupplierVerdict,
|
||||||
|
} from "../types.ts";
|
||||||
|
|
||||||
|
export type SupplierExportSummary = {
|
||||||
|
processedRows: number;
|
||||||
|
resolvedRows: number;
|
||||||
|
eligibleRows: number;
|
||||||
|
verdictCounts: Record<SupplierVerdict, number>;
|
||||||
|
unresolvedByStatus: Record<KeepaUpcLookupStatus, number>;
|
||||||
|
};
|
||||||
|
|
||||||
|
function pct(value: number | null): number | "" {
|
||||||
|
return value == null ? "" : Math.round(value * 10_000) / 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
function rowForResult(result: SupplierAnalysisResult) {
|
||||||
|
const category =
|
||||||
|
result.record.category ?? result.keepa?.categoryTree?.join(" > ") ?? "";
|
||||||
|
const canSell =
|
||||||
|
result.spApi?.canSell == null ? "" : result.spApi.canSell ? "yes" : "no";
|
||||||
|
|
||||||
|
return {
|
||||||
|
UPC: result.upc,
|
||||||
|
ASIN: result.lookup.asin ?? "",
|
||||||
|
Name: result.record.name,
|
||||||
|
Brand: result.record.brand ?? "",
|
||||||
|
Category: category,
|
||||||
|
"Unit Cost": result.record.unitCost || "",
|
||||||
|
"Sale Price": result.score.salePrice ?? "",
|
||||||
|
"FBA Fee": result.score.fbaFee ?? "",
|
||||||
|
Profit: result.score.profit ?? "",
|
||||||
|
"Margin %": pct(result.score.margin),
|
||||||
|
"ROI %": pct(result.score.roi),
|
||||||
|
"BSR Current": result.keepa?.salesRank ?? "",
|
||||||
|
"BSR 90d": result.keepa?.salesRankAvg90 ?? "",
|
||||||
|
"Rank Drops 30d": result.keepa?.salesRankDrops30 ?? "",
|
||||||
|
"Rank Drops 90d": result.keepa?.salesRankDrops90 ?? "",
|
||||||
|
"Monthly Sold": result.keepa?.monthlySold ?? "",
|
||||||
|
"Seller Count": result.keepa?.sellerCount ?? "",
|
||||||
|
"Amazon Share 90d %": result.keepa?.amazonBuyboxSharePct90d ?? "",
|
||||||
|
"Can Sell": canSell,
|
||||||
|
Sellability: result.spApi?.sellabilityStatus ?? "",
|
||||||
|
Score: result.score.score,
|
||||||
|
Verdict: result.score.verdict,
|
||||||
|
Reason: result.score.reason,
|
||||||
|
"Lookup Status": result.lookup.status,
|
||||||
|
"Candidate ASINs": result.lookup.candidateAsins.join(","),
|
||||||
|
"Lookup Reason": result.lookup.reason ?? "",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function addRowsSheet(
|
||||||
|
workbook: ExcelJS.Workbook,
|
||||||
|
name: string,
|
||||||
|
rows: ReturnType<typeof rowForResult>[],
|
||||||
|
): void {
|
||||||
|
const sheet = workbook.addWorksheet(name);
|
||||||
|
const headers = rows[0] ? Object.keys(rows[0]) : Object.keys(rowForResult({
|
||||||
|
upc: "",
|
||||||
|
record: { name: "", unitCost: 0 },
|
||||||
|
product: null,
|
||||||
|
lookup: {
|
||||||
|
requestedUpc: "",
|
||||||
|
normalizedUpc: "",
|
||||||
|
status: "not_found",
|
||||||
|
asin: null,
|
||||||
|
candidateAsins: [],
|
||||||
|
keepaData: null,
|
||||||
|
},
|
||||||
|
keepa: null,
|
||||||
|
spApi: null,
|
||||||
|
score: {
|
||||||
|
salePrice: null,
|
||||||
|
fbaFee: null,
|
||||||
|
profit: null,
|
||||||
|
margin: null,
|
||||||
|
roi: null,
|
||||||
|
demandScore: 0,
|
||||||
|
competitionPenalty: 1,
|
||||||
|
score: 0,
|
||||||
|
verdict: "SKIP",
|
||||||
|
reason: "",
|
||||||
|
},
|
||||||
|
fetchedAt: "",
|
||||||
|
}));
|
||||||
|
|
||||||
|
sheet.columns = headers.map((header) => ({
|
||||||
|
header,
|
||||||
|
key: header,
|
||||||
|
width: Math.min(Math.max(header.length + 4, 12), 28),
|
||||||
|
}));
|
||||||
|
sheet.addRows(rows);
|
||||||
|
sheet.views = [{ state: "frozen", ySplit: 1 }];
|
||||||
|
sheet.autoFilter = {
|
||||||
|
from: { row: 1, column: 1 },
|
||||||
|
to: { row: 1, column: headers.length },
|
||||||
|
};
|
||||||
|
sheet.getRow(1).font = { bold: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
function addSummarySheet(
|
||||||
|
workbook: ExcelJS.Workbook,
|
||||||
|
summary: SupplierExportSummary,
|
||||||
|
): void {
|
||||||
|
const sheet = workbook.addWorksheet("Summary");
|
||||||
|
sheet.columns = [
|
||||||
|
{ header: "Metric", key: "Metric", width: 28 },
|
||||||
|
{ header: "Value", key: "Value", width: 18 },
|
||||||
|
];
|
||||||
|
|
||||||
|
sheet.addRows([
|
||||||
|
{ Metric: "Processed Rows", Value: summary.processedRows },
|
||||||
|
{ Metric: "Resolved Rows", Value: summary.resolvedRows },
|
||||||
|
{ Metric: "Eligible Rows", Value: summary.eligibleRows },
|
||||||
|
{ Metric: "BUY", Value: summary.verdictCounts.BUY },
|
||||||
|
{ Metric: "WATCH", Value: summary.verdictCounts.WATCH },
|
||||||
|
{ Metric: "SKIP", Value: summary.verdictCounts.SKIP },
|
||||||
|
{ Metric: "Unresolved invalid_upc", Value: summary.unresolvedByStatus.invalid_upc },
|
||||||
|
{ Metric: "Unresolved not_found", Value: summary.unresolvedByStatus.not_found },
|
||||||
|
{ Metric: "Unresolved multiple_asins", Value: summary.unresolvedByStatus.multiple_asins },
|
||||||
|
{ Metric: "Unresolved request_failed", Value: summary.unresolvedByStatus.request_failed },
|
||||||
|
]);
|
||||||
|
sheet.getRow(1).font = { bold: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function writeSupplierWorkbook(
|
||||||
|
outputFile: string,
|
||||||
|
results: SupplierAnalysisResult[],
|
||||||
|
summary: SupplierExportSummary,
|
||||||
|
): Promise<void> {
|
||||||
|
const outputDir = dirname(outputFile);
|
||||||
|
if (outputDir && outputDir !== ".") {
|
||||||
|
mkdirSync(outputDir, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
const workbook = new ExcelJS.Workbook();
|
||||||
|
workbook.creator = "asin-check";
|
||||||
|
workbook.created = new Date();
|
||||||
|
|
||||||
|
const ranked = results
|
||||||
|
.filter((result) => result.score.verdict !== "SKIP")
|
||||||
|
.sort((a, b) => b.score.score - a.score.score)
|
||||||
|
.map(rowForResult);
|
||||||
|
const skipped = results
|
||||||
|
.filter((result) => result.score.verdict === "SKIP")
|
||||||
|
.map(rowForResult);
|
||||||
|
|
||||||
|
addRowsSheet(workbook, "Ranked Leads", ranked);
|
||||||
|
addRowsSheet(workbook, "Skipped", skipped);
|
||||||
|
addSummarySheet(workbook, summary);
|
||||||
|
|
||||||
|
await workbook.xlsx.writeFile(outputFile);
|
||||||
|
}
|
||||||
97
src/supplier/supplier-scoring.test.ts
Normal file
97
src/supplier/supplier-scoring.test.ts
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
import { expect, test } from "bun:test";
|
||||||
|
import { scoreSupplierProduct } from "./supplier-scoring.ts";
|
||||||
|
import type { KeepaData, ProductRecord, SpApiData } from "../types.ts";
|
||||||
|
|
||||||
|
function record(overrides: Partial<ProductRecord> = {}): ProductRecord {
|
||||||
|
return {
|
||||||
|
asin: "B000000001",
|
||||||
|
name: "Test Product",
|
||||||
|
unitCost: 10,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function keepa(overrides: Partial<KeepaData> = {}): KeepaData {
|
||||||
|
return {
|
||||||
|
currentPrice: 30,
|
||||||
|
avgPrice90: 29,
|
||||||
|
minPrice90: 25,
|
||||||
|
maxPrice90: 35,
|
||||||
|
salesRank: 8_000,
|
||||||
|
salesRankAvg90: 10_000,
|
||||||
|
salesRankDrops30: 80,
|
||||||
|
salesRankDrops90: 220,
|
||||||
|
sellerCount: 4,
|
||||||
|
amazonIsSeller: false,
|
||||||
|
amazonBuyboxSharePct90d: 0,
|
||||||
|
buyBoxSeller: "SELLER",
|
||||||
|
buyBoxPrice: 30,
|
||||||
|
buyBoxAvg90: 29,
|
||||||
|
monthlySold: 350,
|
||||||
|
categoryTree: ["Grocery"],
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function spApi(overrides: Partial<SpApiData> = {}): SpApiData {
|
||||||
|
return {
|
||||||
|
fbaFee: 5,
|
||||||
|
fbmFee: 3,
|
||||||
|
referralFeePercent: 15,
|
||||||
|
estimatedSalePrice: 30,
|
||||||
|
canSell: true,
|
||||||
|
sellabilityStatus: "available",
|
||||||
|
sellabilityReason: "ok",
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test("profitable high-demand product ranks above competitive product", () => {
|
||||||
|
const strong = scoreSupplierProduct(record(), keepa(), spApi());
|
||||||
|
const competitive = scoreSupplierProduct(
|
||||||
|
record(),
|
||||||
|
keepa({
|
||||||
|
sellerCount: 35,
|
||||||
|
amazonIsSeller: true,
|
||||||
|
amazonBuyboxSharePct90d: 90,
|
||||||
|
}),
|
||||||
|
spApi(),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(strong.verdict).toBe("BUY");
|
||||||
|
expect(strong.score).toBeGreaterThan(competitive.score);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("missing cost skips", () => {
|
||||||
|
const score = scoreSupplierProduct(record({ unitCost: 0 }), keepa(), spApi());
|
||||||
|
|
||||||
|
expect(score.verdict).toBe("SKIP");
|
||||||
|
expect(score.reason).toContain("unit cost");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("restricted ASIN skips", () => {
|
||||||
|
const score = scoreSupplierProduct(
|
||||||
|
record(),
|
||||||
|
keepa(),
|
||||||
|
spApi({ canSell: false, sellabilityStatus: "restricted" }),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(score.verdict).toBe("SKIP");
|
||||||
|
expect(score.reason).toContain("restricted");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("missing price skips", () => {
|
||||||
|
const score = scoreSupplierProduct(
|
||||||
|
record(),
|
||||||
|
keepa({
|
||||||
|
currentPrice: null,
|
||||||
|
avgPrice90: null,
|
||||||
|
buyBoxPrice: null,
|
||||||
|
buyBoxAvg90: null,
|
||||||
|
}),
|
||||||
|
spApi({ estimatedSalePrice: 0 }),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(score.verdict).toBe("SKIP");
|
||||||
|
expect(score.reason).toContain("price");
|
||||||
|
});
|
||||||
224
src/supplier/supplier-scoring.ts
Normal file
224
src/supplier/supplier-scoring.ts
Normal file
@@ -0,0 +1,224 @@
|
|||||||
|
import type {
|
||||||
|
KeepaData,
|
||||||
|
ProductRecord,
|
||||||
|
SpApiData,
|
||||||
|
SupplierScore,
|
||||||
|
} from "../types.ts";
|
||||||
|
|
||||||
|
function round2(value: number): number {
|
||||||
|
return Math.round(value * 100) / 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
function clamp(value: number, min: number, max: number): number {
|
||||||
|
return Math.max(min, Math.min(max, value));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveSupplierSalePrice(
|
||||||
|
keepa: KeepaData | null,
|
||||||
|
spApi: SpApiData | null,
|
||||||
|
): number | null {
|
||||||
|
const candidates = [
|
||||||
|
keepa?.buyBoxPrice,
|
||||||
|
keepa?.buyBoxAvg90,
|
||||||
|
keepa?.currentPrice,
|
||||||
|
keepa?.avgPrice90,
|
||||||
|
spApi?.estimatedSalePrice,
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const candidate of candidates) {
|
||||||
|
if (typeof candidate === "number" && Number.isFinite(candidate) && candidate > 0) {
|
||||||
|
return round2(candidate);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function computeDemandScore(keepa: KeepaData | null): number {
|
||||||
|
if (!keepa) return 0;
|
||||||
|
|
||||||
|
const monthlySold = keepa.monthlySold ?? 0;
|
||||||
|
const rankDrops30 = keepa.salesRankDrops30 ?? 0;
|
||||||
|
const rankDrops90 = keepa.salesRankDrops90 ?? 0;
|
||||||
|
const velocityScore = clamp(
|
||||||
|
Math.max(monthlySold / 300, rankDrops30 / 60, rankDrops90 / 180),
|
||||||
|
0,
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
|
||||||
|
const rankCandidates = [keepa.salesRank, keepa.salesRankAvg90].filter(
|
||||||
|
(value): value is number =>
|
||||||
|
typeof value === "number" && Number.isFinite(value) && value > 0,
|
||||||
|
);
|
||||||
|
const bestRank = rankCandidates.length > 0 ? Math.min(...rankCandidates) : null;
|
||||||
|
const rankScore =
|
||||||
|
bestRank == null
|
||||||
|
? 0
|
||||||
|
: bestRank <= 10_000
|
||||||
|
? 1
|
||||||
|
: bestRank <= 50_000
|
||||||
|
? 0.8
|
||||||
|
: bestRank <= 100_000
|
||||||
|
? 0.55
|
||||||
|
: bestRank <= 250_000
|
||||||
|
? 0.3
|
||||||
|
: 0.1;
|
||||||
|
|
||||||
|
return round2(clamp(velocityScore * 0.65 + rankScore * 0.35, 0, 1));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function computeCompetitionPenalty(keepa: KeepaData | null): number {
|
||||||
|
if (!keepa) return 1;
|
||||||
|
|
||||||
|
const sellerCount = keepa.sellerCount ?? 0;
|
||||||
|
const sellerPenalty =
|
||||||
|
sellerCount <= 3
|
||||||
|
? 0.85
|
||||||
|
: sellerCount <= 8
|
||||||
|
? 1
|
||||||
|
: sellerCount <= 15
|
||||||
|
? 1.25
|
||||||
|
: sellerCount <= 30
|
||||||
|
? 1.6
|
||||||
|
: 2;
|
||||||
|
|
||||||
|
const amazonShare = keepa.amazonBuyboxSharePct90d ?? 0;
|
||||||
|
const amazonPenalty =
|
||||||
|
keepa.amazonIsSeller === true
|
||||||
|
? 1.35
|
||||||
|
: amazonShare >= 75
|
||||||
|
? 1.45
|
||||||
|
: amazonShare >= 35
|
||||||
|
? 1.2
|
||||||
|
: 1;
|
||||||
|
|
||||||
|
return round2(clamp(sellerPenalty * amazonPenalty, 0.75, 2.5));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function scoreSupplierProduct(
|
||||||
|
record: ProductRecord,
|
||||||
|
keepa: KeepaData | null,
|
||||||
|
spApi: SpApiData | null,
|
||||||
|
): SupplierScore {
|
||||||
|
const salePrice = resolveSupplierSalePrice(keepa, spApi);
|
||||||
|
const fbaFee = spApi?.fbaFee ?? null;
|
||||||
|
const demandScore = computeDemandScore(keepa);
|
||||||
|
const competitionPenalty = computeCompetitionPenalty(keepa);
|
||||||
|
|
||||||
|
if (spApi && spApi.sellabilityStatus !== "available") {
|
||||||
|
return {
|
||||||
|
salePrice,
|
||||||
|
fbaFee,
|
||||||
|
profit: null,
|
||||||
|
margin: null,
|
||||||
|
roi: null,
|
||||||
|
demandScore,
|
||||||
|
competitionPenalty,
|
||||||
|
score: 0,
|
||||||
|
verdict: "SKIP",
|
||||||
|
reason: `Not sellable: ${spApi.sellabilityStatus}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!salePrice) {
|
||||||
|
return {
|
||||||
|
salePrice,
|
||||||
|
fbaFee,
|
||||||
|
profit: null,
|
||||||
|
margin: null,
|
||||||
|
roi: null,
|
||||||
|
demandScore,
|
||||||
|
competitionPenalty,
|
||||||
|
score: 0,
|
||||||
|
verdict: "SKIP",
|
||||||
|
reason: "Missing sale price",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!record.unitCost || record.unitCost <= 0) {
|
||||||
|
return {
|
||||||
|
salePrice,
|
||||||
|
fbaFee,
|
||||||
|
profit: null,
|
||||||
|
margin: null,
|
||||||
|
roi: null,
|
||||||
|
demandScore,
|
||||||
|
competitionPenalty,
|
||||||
|
score: 0,
|
||||||
|
verdict: "SKIP",
|
||||||
|
reason: "Missing or invalid unit cost",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fbaFee == null || fbaFee < 0) {
|
||||||
|
return {
|
||||||
|
salePrice,
|
||||||
|
fbaFee,
|
||||||
|
profit: null,
|
||||||
|
margin: null,
|
||||||
|
roi: null,
|
||||||
|
demandScore,
|
||||||
|
competitionPenalty,
|
||||||
|
score: 0,
|
||||||
|
verdict: "SKIP",
|
||||||
|
reason: "Missing FBA fee",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const profit = round2(salePrice - record.unitCost - fbaFee);
|
||||||
|
const margin = round2(profit / salePrice);
|
||||||
|
const roi = round2(profit / record.unitCost);
|
||||||
|
|
||||||
|
if (profit <= 0 || margin <= 0 || roi <= 0) {
|
||||||
|
return {
|
||||||
|
salePrice,
|
||||||
|
fbaFee,
|
||||||
|
profit,
|
||||||
|
margin,
|
||||||
|
roi,
|
||||||
|
demandScore,
|
||||||
|
competitionPenalty,
|
||||||
|
score: 0,
|
||||||
|
verdict: "SKIP",
|
||||||
|
reason: "Non-positive profit",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (demandScore < 0.15) {
|
||||||
|
return {
|
||||||
|
salePrice,
|
||||||
|
fbaFee,
|
||||||
|
profit,
|
||||||
|
margin,
|
||||||
|
roi,
|
||||||
|
demandScore,
|
||||||
|
competitionPenalty,
|
||||||
|
score: 0,
|
||||||
|
verdict: "SKIP",
|
||||||
|
reason: "Weak demand signals",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const rawScore =
|
||||||
|
((margin * 0.55 + clamp(roi, 0, 2) * 0.45) * demandScore * 100) /
|
||||||
|
competitionPenalty;
|
||||||
|
const score = round2(clamp(rawScore, 0, 100));
|
||||||
|
const verdict = score >= 18 && margin >= 0.18 && roi >= 0.3 ? "BUY" : "WATCH";
|
||||||
|
const reason =
|
||||||
|
verdict === "BUY"
|
||||||
|
? "Profitable with demand"
|
||||||
|
: "Viable but needs review";
|
||||||
|
|
||||||
|
return {
|
||||||
|
salePrice,
|
||||||
|
fbaFee,
|
||||||
|
profit,
|
||||||
|
margin,
|
||||||
|
roi,
|
||||||
|
demandScore,
|
||||||
|
competitionPenalty,
|
||||||
|
score,
|
||||||
|
verdict,
|
||||||
|
reason,
|
||||||
|
};
|
||||||
|
}
|
||||||
563
src/supplier/upc-file-analysis.ts
Normal file
563
src/supplier/upc-file-analysis.ts
Normal file
@@ -0,0 +1,563 @@
|
|||||||
|
import path from "node:path";
|
||||||
|
import { requireAsin } from "../asin.ts";
|
||||||
|
import { fetchKeepaDataBatch, lookupKeepaUpcs } from "../integrations/keepa.ts";
|
||||||
|
import {
|
||||||
|
fetchSellabilityBatch,
|
||||||
|
fetchSpApiPricingAndFees,
|
||||||
|
lookupSpApiUpcs,
|
||||||
|
} from "../integrations/sp-api.ts";
|
||||||
|
import {
|
||||||
|
processUpcFileInBatches,
|
||||||
|
type UpcInputRow,
|
||||||
|
} from "./upc-file-reader.ts";
|
||||||
|
import {
|
||||||
|
appendSupplierResultsToRun,
|
||||||
|
completeRunInDb,
|
||||||
|
failRunInDb,
|
||||||
|
refreshRunCountsInDb,
|
||||||
|
startRunInDb,
|
||||||
|
type RunCounts,
|
||||||
|
} from "../writer.ts";
|
||||||
|
import { connectCache, disconnectCache } from "../integrations/cache.ts";
|
||||||
|
import { scoreSupplierProduct, resolveSupplierSalePrice } from "./supplier-scoring.ts";
|
||||||
|
import {
|
||||||
|
writeSupplierWorkbook,
|
||||||
|
type SupplierExportSummary,
|
||||||
|
} from "./supplier-export.ts";
|
||||||
|
import type {
|
||||||
|
KeepaUpcLookupDetail,
|
||||||
|
KeepaUpcLookupStatus,
|
||||||
|
ProductRecord,
|
||||||
|
SupplierAnalysisResult,
|
||||||
|
SupplierScore,
|
||||||
|
UpcLookupDetail,
|
||||||
|
} from "../types.ts";
|
||||||
|
|
||||||
|
const DEFAULT_INPUT_BATCH_SIZE = 200;
|
||||||
|
const DEFAULT_UPC_LOOKUP_BATCH_SIZE = 100;
|
||||||
|
const DEFAULT_PRICING_CONCURRENCY = 5;
|
||||||
|
|
||||||
|
export type UpcFileAnalysisOptions = {
|
||||||
|
inputFile: string;
|
||||||
|
outputFile?: string;
|
||||||
|
inputBatchSize?: number;
|
||||||
|
upcLookupBatchSize?: number;
|
||||||
|
maxRows?: number;
|
||||||
|
manageResources?: boolean;
|
||||||
|
dbPath?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type UpcFileAnalysisSummary = {
|
||||||
|
runId: number;
|
||||||
|
inputFile: string;
|
||||||
|
outputFile?: string;
|
||||||
|
processedRows: number;
|
||||||
|
matchedRows: number;
|
||||||
|
unresolvedByStatus: Record<KeepaUpcLookupStatus, number>;
|
||||||
|
runCounts: RunCounts;
|
||||||
|
reader: {
|
||||||
|
mode: "xlsx_stream" | "xlsx_fallback" | "xls_fallback";
|
||||||
|
totalRowsSeen: number;
|
||||||
|
emittedRows: number;
|
||||||
|
skippedMissingUpc: number;
|
||||||
|
skippedInvalidUpc: number;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
function printUsage(): void {
|
||||||
|
console.log("Usage:");
|
||||||
|
console.log(
|
||||||
|
" bun run src/upc-file-analysis.ts --input input/<file.xls|file.xlsx> [--out output/results.xlsx] [--input-batch-size 200] [--upc-lookup-batch-size 100] [--max-rows 1000]",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parsePositiveInt(value: string | undefined, flagName: string): number {
|
||||||
|
const parsed = Number.parseInt(String(value), 10);
|
||||||
|
if (!Number.isFinite(parsed) || parsed < 1) {
|
||||||
|
throw new Error(`Invalid value for ${flagName}: ${value}`);
|
||||||
|
}
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseArgs(argv: string[]): UpcFileAnalysisOptions {
|
||||||
|
let inputFile: string | undefined;
|
||||||
|
let outputFile: string | undefined;
|
||||||
|
let inputBatchSize: number | undefined;
|
||||||
|
let upcLookupBatchSize: number | undefined;
|
||||||
|
let maxRows: number | undefined;
|
||||||
|
|
||||||
|
for (let i = 0; i < argv.length; i++) {
|
||||||
|
const arg = argv[i]!;
|
||||||
|
|
||||||
|
if (arg === "--help" || arg === "-h") {
|
||||||
|
printUsage();
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (arg === "--input") {
|
||||||
|
const next = argv[i + 1];
|
||||||
|
if (!next) throw new Error("Missing value after --input");
|
||||||
|
inputFile = next;
|
||||||
|
i++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (arg === "--out") {
|
||||||
|
const next = argv[i + 1];
|
||||||
|
if (!next) throw new Error("Missing value after --out");
|
||||||
|
outputFile = next;
|
||||||
|
i++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (arg === "--input-batch-size") {
|
||||||
|
inputBatchSize = parsePositiveInt(argv[i + 1], "--input-batch-size");
|
||||||
|
i++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (arg === "--upc-lookup-batch-size") {
|
||||||
|
upcLookupBatchSize = parsePositiveInt(
|
||||||
|
argv[i + 1],
|
||||||
|
"--upc-lookup-batch-size",
|
||||||
|
);
|
||||||
|
i++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (arg === "--max-rows") {
|
||||||
|
maxRows = parsePositiveInt(argv[i + 1], "--max-rows");
|
||||||
|
i++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (arg.startsWith("--")) {
|
||||||
|
throw new Error(`Unknown flag: ${arg}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!inputFile) {
|
||||||
|
inputFile = arg;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(`Unexpected positional argument: ${arg}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!inputFile) {
|
||||||
|
throw new Error("Missing --input <file.xls|file.xlsx>");
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
inputFile,
|
||||||
|
outputFile,
|
||||||
|
inputBatchSize,
|
||||||
|
upcLookupBatchSize,
|
||||||
|
maxRows,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveDefaultOutputPath(inputFile: string): string {
|
||||||
|
const parsedInput = path.parse(inputFile);
|
||||||
|
return path.join("output", `${parsedInput.name}_upc_results.xlsx`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function createStatusCounter(): Record<KeepaUpcLookupStatus, number> {
|
||||||
|
return {
|
||||||
|
found: 0,
|
||||||
|
invalid_upc: 0,
|
||||||
|
not_found: 0,
|
||||||
|
multiple_asins: 0,
|
||||||
|
request_failed: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function chunkArray<T>(items: T[], chunkSize: number): T[][] {
|
||||||
|
const chunks: T[][] = [];
|
||||||
|
for (let i = 0; i < items.length; i += chunkSize) {
|
||||||
|
chunks.push(items.slice(i, i + chunkSize));
|
||||||
|
}
|
||||||
|
return chunks;
|
||||||
|
}
|
||||||
|
|
||||||
|
function skippedScore(reason: string): SupplierScore {
|
||||||
|
return {
|
||||||
|
salePrice: null,
|
||||||
|
fbaFee: null,
|
||||||
|
profit: null,
|
||||||
|
margin: null,
|
||||||
|
roi: null,
|
||||||
|
demandScore: 0,
|
||||||
|
competitionPenalty: 1,
|
||||||
|
score: 0,
|
||||||
|
verdict: "SKIP",
|
||||||
|
reason,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function lookupUpcsWithChunking(
|
||||||
|
rows: UpcInputRow[],
|
||||||
|
lookupBatchSize: number,
|
||||||
|
runCache: Map<string, KeepaUpcLookupDetail>,
|
||||||
|
): Promise<Map<string, UpcLookupDetail>> {
|
||||||
|
const uniqueUpcs = Array.from(new Set(rows.map((row) => row.upc)));
|
||||||
|
const missingUpcs = uniqueUpcs.filter((upc) => !runCache.has(upc));
|
||||||
|
const chunks = chunkArray(missingUpcs, lookupBatchSize);
|
||||||
|
const details = new Map<string, UpcLookupDetail>();
|
||||||
|
|
||||||
|
const cacheHits = uniqueUpcs.length - missingUpcs.length;
|
||||||
|
if (cacheHits > 0) {
|
||||||
|
console.log(
|
||||||
|
` Reusing cached UPC lookup results for ${cacheHits}/${uniqueUpcs.length} UPCs in this batch.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (missingUpcs.length === 0) {
|
||||||
|
for (const upc of uniqueUpcs) {
|
||||||
|
const detail = runCache.get(upc);
|
||||||
|
if (detail) details.set(upc, detail);
|
||||||
|
}
|
||||||
|
return details;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let i = 0; i < chunks.length; i++) {
|
||||||
|
const chunk = chunks[i]!;
|
||||||
|
console.log(
|
||||||
|
` SP-API UPC lookup chunk ${i + 1}/${chunks.length} (${chunk.length} UPCs)...`,
|
||||||
|
);
|
||||||
|
|
||||||
|
const spDetails = await lookupSpApiUpcs(chunk);
|
||||||
|
const fallbackUpcs = Array.from(spDetails.values())
|
||||||
|
.filter(
|
||||||
|
(detail) =>
|
||||||
|
detail.status === "not_found" || detail.status === "request_failed",
|
||||||
|
)
|
||||||
|
.map((detail) => detail.normalizedUpc);
|
||||||
|
const fallbackDetails =
|
||||||
|
fallbackUpcs.length > 0 ? await lookupKeepaUpcs(fallbackUpcs) : new Map();
|
||||||
|
|
||||||
|
const chunkDetails = new Map<string, UpcLookupDetail>();
|
||||||
|
for (const upc of chunk) {
|
||||||
|
const spDetail = spDetails.get(upc);
|
||||||
|
const fallbackDetail = fallbackDetails.get(upc);
|
||||||
|
chunkDetails.set(
|
||||||
|
upc,
|
||||||
|
fallbackDetail && fallbackDetail.status !== "request_failed"
|
||||||
|
? { ...fallbackDetail, provider: "keepa" }
|
||||||
|
: { ...spDetail!, provider: "sp_api" },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [upc, detail] of chunkDetails.entries()) {
|
||||||
|
runCache.set(upc, detail);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const upc of uniqueUpcs) {
|
||||||
|
const detail = runCache.get(upc);
|
||||||
|
if (detail) {
|
||||||
|
details.set(upc, detail);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return details;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toProductRecord(
|
||||||
|
row: UpcInputRow,
|
||||||
|
detail: UpcLookupDetail,
|
||||||
|
): ProductRecord {
|
||||||
|
const keepaCategory = detail.keepaData?.categoryTree?.[0];
|
||||||
|
|
||||||
|
return {
|
||||||
|
asin: requireAsin(detail.asin),
|
||||||
|
name: row.name ?? detail.asin ?? row.upc,
|
||||||
|
unitCost: row.unitCost ?? 0,
|
||||||
|
brand: row.brand,
|
||||||
|
category: row.category ?? keepaCategory,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function toSupplierInputRecord(row: UpcInputRow) {
|
||||||
|
return {
|
||||||
|
name: row.name ?? row.upc,
|
||||||
|
unitCost: row.unitCost ?? 0,
|
||||||
|
brand: row.brand,
|
||||||
|
category: row.category,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchFeesForProducts(
|
||||||
|
products: ProductRecord[],
|
||||||
|
keepaResults: Map<string, NonNullable<SupplierAnalysisResult["keepa"]>>,
|
||||||
|
sellabilityMap: Awaited<ReturnType<typeof fetchSellabilityBatch>>,
|
||||||
|
): Promise<Map<string, NonNullable<SupplierAnalysisResult["spApi"]>>> {
|
||||||
|
const spApiResults = new Map<string, NonNullable<SupplierAnalysisResult["spApi"]>>();
|
||||||
|
const queue = [...products];
|
||||||
|
let completed = 0;
|
||||||
|
|
||||||
|
async function next(): Promise<void> {
|
||||||
|
while (queue.length > 0) {
|
||||||
|
const product = queue.shift();
|
||||||
|
if (!product) return;
|
||||||
|
const sellability =
|
||||||
|
sellabilityMap.get(product.asin) ?? {
|
||||||
|
canSell: null,
|
||||||
|
sellabilityStatus: "unknown" as const,
|
||||||
|
sellabilityReason: "Sellability check returned no result",
|
||||||
|
};
|
||||||
|
const price = resolveSupplierSalePrice(
|
||||||
|
keepaResults.get(product.asin) ?? null,
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
const spApi = await fetchSpApiPricingAndFees(product.asin, sellability, price);
|
||||||
|
spApiResults.set(product.asin, spApi);
|
||||||
|
completed++;
|
||||||
|
if (completed % 10 === 0 || completed === products.length) {
|
||||||
|
console.log(` [fees] ${completed}/${products.length} fetched`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const workers = Array.from(
|
||||||
|
{ length: Math.min(DEFAULT_PRICING_CONCURRENCY, products.length || 1) },
|
||||||
|
() => next(),
|
||||||
|
);
|
||||||
|
await Promise.all(workers);
|
||||||
|
return spApiResults;
|
||||||
|
}
|
||||||
|
|
||||||
|
function summarizeSupplierResults(
|
||||||
|
results: SupplierAnalysisResult[],
|
||||||
|
unresolvedByStatus: Record<KeepaUpcLookupStatus, number>,
|
||||||
|
): SupplierExportSummary {
|
||||||
|
return {
|
||||||
|
processedRows: results.length,
|
||||||
|
resolvedRows: results.filter((result) => result.lookup.status === "found").length,
|
||||||
|
eligibleRows: results.filter(
|
||||||
|
(result) => result.spApi?.sellabilityStatus === "available",
|
||||||
|
).length,
|
||||||
|
verdictCounts: {
|
||||||
|
BUY: results.filter((result) => result.score.verdict === "BUY").length,
|
||||||
|
WATCH: results.filter((result) => result.score.verdict === "WATCH").length,
|
||||||
|
SKIP: results.filter((result) => result.score.verdict === "SKIP").length,
|
||||||
|
},
|
||||||
|
unresolvedByStatus,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function runUpcFileAnalysis(
|
||||||
|
options: UpcFileAnalysisOptions,
|
||||||
|
): Promise<UpcFileAnalysisSummary> {
|
||||||
|
const inputBatchSize = Math.max(
|
||||||
|
1,
|
||||||
|
options.inputBatchSize ?? DEFAULT_INPUT_BATCH_SIZE,
|
||||||
|
);
|
||||||
|
const lookupBatchSize = Math.max(
|
||||||
|
1,
|
||||||
|
options.upcLookupBatchSize ?? DEFAULT_UPC_LOOKUP_BATCH_SIZE,
|
||||||
|
);
|
||||||
|
const outputFile =
|
||||||
|
options.outputFile ?? resolveDefaultOutputPath(options.inputFile);
|
||||||
|
const manageResources = options.manageResources ?? true;
|
||||||
|
|
||||||
|
if (manageResources) {
|
||||||
|
console.log("Connecting to Redis...");
|
||||||
|
await connectCache();
|
||||||
|
}
|
||||||
|
|
||||||
|
const unresolvedByStatus = createStatusCounter();
|
||||||
|
const allResults: SupplierAnalysisResult[] = [];
|
||||||
|
const upcLookupCache = new Map<string, KeepaUpcLookupDetail>();
|
||||||
|
let processedRows = 0;
|
||||||
|
let matchedRows = 0;
|
||||||
|
|
||||||
|
const runId = await startRunInDb(options.inputFile, outputFile, undefined, "supplier_upc");
|
||||||
|
|
||||||
|
try {
|
||||||
|
const readerSummary = await processUpcFileInBatches(
|
||||||
|
options.inputFile,
|
||||||
|
async ({ batchNumber, rows }) => {
|
||||||
|
console.log(
|
||||||
|
`\n=== UPC input batch ${batchNumber} (${rows.length} rows) ===`,
|
||||||
|
);
|
||||||
|
|
||||||
|
processedRows += rows.length;
|
||||||
|
const detailMap = await lookupUpcsWithChunking(
|
||||||
|
rows,
|
||||||
|
lookupBatchSize,
|
||||||
|
upcLookupCache,
|
||||||
|
);
|
||||||
|
|
||||||
|
const matchedEntries: Array<{
|
||||||
|
row: UpcInputRow;
|
||||||
|
detail: UpcLookupDetail;
|
||||||
|
product: ProductRecord;
|
||||||
|
}> = [];
|
||||||
|
for (const row of rows) {
|
||||||
|
const detail =
|
||||||
|
detailMap.get(row.upc) ??
|
||||||
|
({
|
||||||
|
requestedUpc: row.upc,
|
||||||
|
normalizedUpc: row.upc,
|
||||||
|
status: "request_failed",
|
||||||
|
asin: null,
|
||||||
|
candidateAsins: [],
|
||||||
|
keepaData: null,
|
||||||
|
provider: "sp_api",
|
||||||
|
reason: "UPC lookup returned no result",
|
||||||
|
} satisfies UpcLookupDetail);
|
||||||
|
if (!detailMap.has(row.upc)) detailMap.set(row.upc, detail);
|
||||||
|
unresolvedByStatus[detail.status] += 1;
|
||||||
|
|
||||||
|
if (detail.status === "found" && detail.asin) {
|
||||||
|
matchedRows += 1;
|
||||||
|
matchedEntries.push({
|
||||||
|
row,
|
||||||
|
detail,
|
||||||
|
product: toProductRecord(row, detail),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const matchedProducts = matchedEntries.map((entry) => entry.product);
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`Batch ${batchNumber}: ${matchedProducts.length}/${rows.length} rows resolved to single ASINs`,
|
||||||
|
);
|
||||||
|
|
||||||
|
const batchResults: SupplierAnalysisResult[] = [];
|
||||||
|
for (const row of rows) {
|
||||||
|
const detail = detailMap.get(row.upc)!;
|
||||||
|
if (detail.status === "found") continue;
|
||||||
|
|
||||||
|
batchResults.push({
|
||||||
|
upc: row.upc,
|
||||||
|
rowNumber: row.rowNumber,
|
||||||
|
record: toSupplierInputRecord(row),
|
||||||
|
product: null,
|
||||||
|
lookup: detail,
|
||||||
|
keepa: null,
|
||||||
|
spApi: null,
|
||||||
|
score: skippedScore(detail?.reason ?? "UPC unresolved"),
|
||||||
|
fetchedAt: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (matchedProducts.length > 0) {
|
||||||
|
console.log(`Fetching ${matchedProducts.length} ASINs from Keepa...`);
|
||||||
|
const keepaResults = await fetchKeepaDataBatch(
|
||||||
|
matchedProducts.map((product) => product.asin),
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log(`Checking sellability for ${matchedProducts.length} ASINs...`);
|
||||||
|
const sellabilityMap = await fetchSellabilityBatch(
|
||||||
|
matchedProducts.map((product) => product.asin),
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log(`Fetching fees for ${matchedProducts.length} ASINs...`);
|
||||||
|
const spApiResults = await fetchFeesForProducts(
|
||||||
|
matchedProducts,
|
||||||
|
keepaResults,
|
||||||
|
sellabilityMap,
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const entry of matchedEntries) {
|
||||||
|
const keepa =
|
||||||
|
keepaResults.get(entry.product.asin) ??
|
||||||
|
entry.detail.keepaData ??
|
||||||
|
null;
|
||||||
|
const spApi = spApiResults.get(entry.product.asin) ?? null;
|
||||||
|
batchResults.push({
|
||||||
|
upc: entry.detail.normalizedUpc,
|
||||||
|
rowNumber: entry.row.rowNumber,
|
||||||
|
record: toSupplierInputRecord(entry.row),
|
||||||
|
product: entry.product,
|
||||||
|
lookup: entry.detail,
|
||||||
|
keepa,
|
||||||
|
spApi,
|
||||||
|
score: scoreSupplierProduct(entry.product, keepa, spApi),
|
||||||
|
fetchedAt: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await appendSupplierResultsToRun(runId, batchResults);
|
||||||
|
allResults.push(...batchResults);
|
||||||
|
},
|
||||||
|
{
|
||||||
|
batchSize: inputBatchSize,
|
||||||
|
maxRows: options.maxRows,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const runCounts = await refreshRunCountsInDb(runId);
|
||||||
|
|
||||||
|
const exportSummary = summarizeSupplierResults(allResults, unresolvedByStatus);
|
||||||
|
await writeSupplierWorkbook(outputFile, allResults, exportSummary);
|
||||||
|
await completeRunInDb(runId);
|
||||||
|
|
||||||
|
if (allResults.length > 0) {
|
||||||
|
const ranked = allResults
|
||||||
|
.filter((result) => result.score.verdict !== "SKIP")
|
||||||
|
.sort((a, b) => b.score.score - a.score.score)
|
||||||
|
.slice(0, 25)
|
||||||
|
.map((result) => ({
|
||||||
|
UPC: result.upc,
|
||||||
|
ASIN: result.lookup.asin ?? "",
|
||||||
|
Name: result.record.name.slice(0, 40),
|
||||||
|
Cost: result.record.unitCost,
|
||||||
|
Price: result.score.salePrice ?? "",
|
||||||
|
Profit: result.score.profit ?? "",
|
||||||
|
ROI: result.score.roi == null ? "" : `${Math.round(result.score.roi * 100)}%`,
|
||||||
|
Score: result.score.score,
|
||||||
|
Verdict: result.score.verdict,
|
||||||
|
Reason: result.score.reason,
|
||||||
|
}));
|
||||||
|
console.log("\n=== Top Supplier Leads ===\n");
|
||||||
|
console.table(ranked);
|
||||||
|
} else {
|
||||||
|
console.log("No supplier rows were analyzed.");
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Ranked workbook written: ${outputFile}`);
|
||||||
|
|
||||||
|
return {
|
||||||
|
runId,
|
||||||
|
inputFile: options.inputFile,
|
||||||
|
outputFile,
|
||||||
|
processedRows,
|
||||||
|
matchedRows,
|
||||||
|
unresolvedByStatus,
|
||||||
|
runCounts,
|
||||||
|
reader: {
|
||||||
|
mode: readerSummary.mode,
|
||||||
|
totalRowsSeen: readerSummary.totalRowsSeen,
|
||||||
|
emittedRows: readerSummary.emittedRows,
|
||||||
|
skippedMissingUpc: readerSummary.skippedMissingUpc,
|
||||||
|
skippedInvalidUpc: readerSummary.skippedInvalidUpc,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
await failRunInDb(runId, error);
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
if (manageResources) {
|
||||||
|
await disconnectCache();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main(): Promise<void> {
|
||||||
|
const parsed = parseArgs(process.argv.slice(2));
|
||||||
|
const summary = await runUpcFileAnalysis(parsed);
|
||||||
|
|
||||||
|
console.log("\n=== UPC file analysis summary ===");
|
||||||
|
console.log(JSON.stringify(summary, null, 2));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (import.meta.main) {
|
||||||
|
main().catch((err) => {
|
||||||
|
const message = err instanceof Error ? err.message : String(err);
|
||||||
|
console.error(`UPC file analysis failed: ${message}`);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
|
}
|
||||||
363
src/supplier/upc-file-reader.ts
Normal file
363
src/supplier/upc-file-reader.ts
Normal file
@@ -0,0 +1,363 @@
|
|||||||
|
import ExcelJS from "exceljs";
|
||||||
|
import * as XLSX from "xlsx";
|
||||||
|
import path from "node:path";
|
||||||
|
|
||||||
|
const UPC_PATTERN = /^\d{12,14}$/;
|
||||||
|
|
||||||
|
const COLUMN_CANDIDATES = {
|
||||||
|
upc: ["upc", "upc code", "upc/ean", "ean", "gtin", "barcode", "product code"],
|
||||||
|
name: ["name", "product name", "title", "product title"],
|
||||||
|
unitCost: ["unit cost", "cost", "price", "buy cost", "unit_cost", "unitcost"],
|
||||||
|
brand: ["brand"],
|
||||||
|
category: ["category"],
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
type ColumnKey = keyof typeof COLUMN_CANDIDATES;
|
||||||
|
type ColumnMap = Record<ColumnKey, number | undefined>;
|
||||||
|
|
||||||
|
export type UpcInputRow = {
|
||||||
|
rowNumber: number;
|
||||||
|
upc: string;
|
||||||
|
name?: string;
|
||||||
|
unitCost?: number;
|
||||||
|
brand?: string;
|
||||||
|
category?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type UpcInputBatch = {
|
||||||
|
batchNumber: number;
|
||||||
|
rows: UpcInputRow[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type UpcReaderSummary = {
|
||||||
|
filePath: string;
|
||||||
|
mode: "xlsx_stream" | "xlsx_fallback" | "xls_fallback";
|
||||||
|
totalRowsSeen: number;
|
||||||
|
emittedRows: number;
|
||||||
|
skippedMissingUpc: number;
|
||||||
|
skippedInvalidUpc: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type UpcReaderOptions = {
|
||||||
|
batchSize?: number;
|
||||||
|
maxRows?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function processUpcFileInBatches(
|
||||||
|
filePath: string,
|
||||||
|
onBatch: (batch: UpcInputBatch) => Promise<void>,
|
||||||
|
options: UpcReaderOptions = {},
|
||||||
|
): Promise<UpcReaderSummary> {
|
||||||
|
const ext = path.extname(filePath).toLowerCase();
|
||||||
|
|
||||||
|
if (ext === ".xlsx") {
|
||||||
|
try {
|
||||||
|
return await processXlsxStreaming(filePath, onBatch, options);
|
||||||
|
} catch (err) {
|
||||||
|
console.warn(
|
||||||
|
`XLSX streaming reader failed, falling back to in-memory parser: ${err}`,
|
||||||
|
);
|
||||||
|
return processXlsLikeFallback(
|
||||||
|
filePath,
|
||||||
|
onBatch,
|
||||||
|
options,
|
||||||
|
"xlsx_fallback",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ext === ".xls") {
|
||||||
|
return processXlsLikeFallback(filePath, onBatch, options, "xls_fallback");
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(`Unsupported file extension: ${ext}. Expected .xls or .xlsx`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function processXlsxStreaming(
|
||||||
|
filePath: string,
|
||||||
|
onBatch: (batch: UpcInputBatch) => Promise<void>,
|
||||||
|
options: UpcReaderOptions,
|
||||||
|
): Promise<UpcReaderSummary> {
|
||||||
|
const batchSize = Math.max(1, options.batchSize ?? 200);
|
||||||
|
const maxRows =
|
||||||
|
options.maxRows && options.maxRows > 0 ? options.maxRows : null;
|
||||||
|
|
||||||
|
let headerDetected = false;
|
||||||
|
let columns: ColumnMap | null = null;
|
||||||
|
let seenRows = 0;
|
||||||
|
let emittedRows = 0;
|
||||||
|
let skippedMissingUpc = 0;
|
||||||
|
let skippedInvalidUpc = 0;
|
||||||
|
let batchNumber = 1;
|
||||||
|
let currentBatch: UpcInputRow[] = [];
|
||||||
|
let stop = false;
|
||||||
|
|
||||||
|
const flush = async () => {
|
||||||
|
if (currentBatch.length === 0) return;
|
||||||
|
await onBatch({ batchNumber, rows: currentBatch });
|
||||||
|
batchNumber += 1;
|
||||||
|
currentBatch = [];
|
||||||
|
};
|
||||||
|
|
||||||
|
const workbookReader = new ExcelJS.stream.xlsx.WorkbookReader(filePath, {
|
||||||
|
worksheets: "emit",
|
||||||
|
sharedStrings: "cache",
|
||||||
|
hyperlinks: "ignore",
|
||||||
|
styles: "ignore",
|
||||||
|
});
|
||||||
|
|
||||||
|
for await (const worksheet of workbookReader) {
|
||||||
|
if (stop) break;
|
||||||
|
|
||||||
|
for await (const row of worksheet) {
|
||||||
|
const values = normalizeExcelJsRow(row.values as unknown[]);
|
||||||
|
if (!headerDetected) {
|
||||||
|
columns = detectColumns(values);
|
||||||
|
if (columns.upc == null) {
|
||||||
|
throw new Error(
|
||||||
|
`No UPC column found in header row. Header row values: ${values.join(", ")}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
headerDetected = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
seenRows += 1;
|
||||||
|
if (!columns) {
|
||||||
|
throw new Error("UPC reader columns were not initialized.");
|
||||||
|
}
|
||||||
|
const parsed = parseUpcInputRow(values, columns, row.number);
|
||||||
|
if (!parsed) {
|
||||||
|
skippedMissingUpc += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isValidUpc(parsed.upc)) {
|
||||||
|
skippedInvalidUpc += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
currentBatch.push(parsed);
|
||||||
|
emittedRows += 1;
|
||||||
|
|
||||||
|
if (currentBatch.length >= batchSize) {
|
||||||
|
await flush();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (maxRows != null && emittedRows >= maxRows) {
|
||||||
|
stop = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process only the first worksheet.
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
await flush();
|
||||||
|
|
||||||
|
if (!headerDetected) {
|
||||||
|
throw new Error("No rows found in the first worksheet.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
filePath,
|
||||||
|
mode: "xlsx_stream",
|
||||||
|
totalRowsSeen: seenRows,
|
||||||
|
emittedRows,
|
||||||
|
skippedMissingUpc,
|
||||||
|
skippedInvalidUpc,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function processXlsLikeFallback(
|
||||||
|
filePath: string,
|
||||||
|
onBatch: (batch: UpcInputBatch) => Promise<void>,
|
||||||
|
options: UpcReaderOptions,
|
||||||
|
mode: "xlsx_fallback" | "xls_fallback",
|
||||||
|
): Promise<UpcReaderSummary> {
|
||||||
|
return new Promise<UpcReaderSummary>(async (resolve, reject) => {
|
||||||
|
try {
|
||||||
|
const batchSize = Math.max(1, options.batchSize ?? 200);
|
||||||
|
const maxRows =
|
||||||
|
options.maxRows && options.maxRows > 0 ? options.maxRows : null;
|
||||||
|
|
||||||
|
const workbook = XLSX.readFile(filePath, { raw: true });
|
||||||
|
const sheetName = workbook.SheetNames[0];
|
||||||
|
if (!sheetName) throw new Error("No sheets found in file");
|
||||||
|
const sheet = workbook.Sheets[sheetName];
|
||||||
|
if (!sheet || !sheet["!ref"]) throw new Error("Sheet has no data");
|
||||||
|
|
||||||
|
const range = XLSX.utils.decode_range(sheet["!ref"]);
|
||||||
|
const headerValues: string[] = [];
|
||||||
|
for (let c = range.s.c; c <= range.e.c; c++) {
|
||||||
|
const cellAddress = XLSX.utils.encode_cell({ r: range.s.r, c });
|
||||||
|
const value = sheet[cellAddress]?.v;
|
||||||
|
headerValues.push(normalizeOptionalString(value) ?? "");
|
||||||
|
}
|
||||||
|
|
||||||
|
const columns = detectColumns(headerValues);
|
||||||
|
if (columns.upc == null) {
|
||||||
|
throw new Error(
|
||||||
|
`No UPC column found in header row. Header row values: ${headerValues.join(", ")}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let seenRows = 0;
|
||||||
|
let emittedRows = 0;
|
||||||
|
let skippedMissingUpc = 0;
|
||||||
|
let skippedInvalidUpc = 0;
|
||||||
|
let batchNumber = 1;
|
||||||
|
let currentBatch: UpcInputRow[] = [];
|
||||||
|
|
||||||
|
const flush = async () => {
|
||||||
|
if (currentBatch.length === 0) return;
|
||||||
|
await onBatch({ batchNumber, rows: currentBatch });
|
||||||
|
batchNumber += 1;
|
||||||
|
currentBatch = [];
|
||||||
|
};
|
||||||
|
|
||||||
|
for (let r = range.s.r + 1; r <= range.e.r; r++) {
|
||||||
|
seenRows += 1;
|
||||||
|
const rowValues: string[] = [];
|
||||||
|
for (let c = range.s.c; c <= range.e.c; c++) {
|
||||||
|
const cellAddress = XLSX.utils.encode_cell({ r, c });
|
||||||
|
rowValues.push(normalizeOptionalString(sheet[cellAddress]?.v) ?? "");
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = parseUpcInputRow(rowValues, columns, r + 1);
|
||||||
|
if (!parsed) {
|
||||||
|
skippedMissingUpc += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isValidUpc(parsed.upc)) {
|
||||||
|
skippedInvalidUpc += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
currentBatch.push(parsed);
|
||||||
|
emittedRows += 1;
|
||||||
|
|
||||||
|
if (currentBatch.length >= batchSize) {
|
||||||
|
await flush();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (maxRows != null && emittedRows >= maxRows) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await flush();
|
||||||
|
|
||||||
|
resolve({
|
||||||
|
filePath,
|
||||||
|
mode,
|
||||||
|
totalRowsSeen: seenRows,
|
||||||
|
emittedRows,
|
||||||
|
skippedMissingUpc,
|
||||||
|
skippedInvalidUpc,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
reject(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function detectColumns(headers: string[]): ColumnMap {
|
||||||
|
const columns = {} as ColumnMap;
|
||||||
|
for (const key of Object.keys(COLUMN_CANDIDATES) as ColumnKey[]) {
|
||||||
|
columns[key] = findColumnIndex(headers, [...COLUMN_CANDIDATES[key]]);
|
||||||
|
}
|
||||||
|
return columns;
|
||||||
|
}
|
||||||
|
|
||||||
|
function findColumnIndex(
|
||||||
|
headers: string[],
|
||||||
|
candidates: string[],
|
||||||
|
): number | undefined {
|
||||||
|
const normalizedCandidates = new Set(candidates.map(normalizeHeader));
|
||||||
|
for (let i = 0; i < headers.length; i++) {
|
||||||
|
if (normalizedCandidates.has(normalizeHeader(headers[i] ?? ""))) {
|
||||||
|
return i;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseUpcInputRow(
|
||||||
|
rowValues: string[],
|
||||||
|
columns: ColumnMap,
|
||||||
|
rowNumber: number,
|
||||||
|
): UpcInputRow | null {
|
||||||
|
if (columns.upc == null) return null;
|
||||||
|
|
||||||
|
const rawUpc = rowValues[columns.upc] ?? "";
|
||||||
|
const upc = rawUpc.replace(/\D/g, "").trim();
|
||||||
|
if (!upc) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
rowNumber,
|
||||||
|
upc,
|
||||||
|
name: getRowString(rowValues, columns.name),
|
||||||
|
unitCost: parseOptionalNumber(rowValues[columns.unitCost ?? -1]),
|
||||||
|
brand: getRowString(rowValues, columns.brand),
|
||||||
|
category: getRowString(rowValues, columns.category),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeExcelJsRow(values: unknown[]): string[] {
|
||||||
|
// ExcelJS row.values is 1-indexed with values[0] intentionally empty.
|
||||||
|
const normalized: string[] = [];
|
||||||
|
for (let i = 1; i < values.length; i++) {
|
||||||
|
normalized.push(normalizeOptionalString(values[i]) ?? "");
|
||||||
|
}
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRowString(
|
||||||
|
values: string[],
|
||||||
|
index: number | undefined,
|
||||||
|
): string | undefined {
|
||||||
|
if (index == null || index < 0) return undefined;
|
||||||
|
const value = values[index];
|
||||||
|
return value?.trim() ? value.trim() : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeHeader(value: string): string {
|
||||||
|
return value
|
||||||
|
.toLowerCase()
|
||||||
|
.trim()
|
||||||
|
.replace(/%/g, " pct ")
|
||||||
|
.replace(/\$/g, " usd ")
|
||||||
|
.replace(/[^a-z0-9]/g, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeOptionalString(value: unknown): string | undefined {
|
||||||
|
if (value == null) return undefined;
|
||||||
|
|
||||||
|
if (typeof value === "object") {
|
||||||
|
if ("text" in (value as Record<string, unknown>)) {
|
||||||
|
return normalizeOptionalString((value as { text?: unknown }).text);
|
||||||
|
}
|
||||||
|
if ("result" in (value as Record<string, unknown>)) {
|
||||||
|
return normalizeOptionalString((value as { result?: unknown }).result);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const text = String(value).trim();
|
||||||
|
return text.length > 0 ? text : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseOptionalNumber(value: unknown): number | undefined {
|
||||||
|
if (value == null || value === "") return undefined;
|
||||||
|
const cleaned = String(value).trim().replace(/[$,%]/g, "").replace(/,/g, "");
|
||||||
|
const parsed = Number(cleaned);
|
||||||
|
return Number.isFinite(parsed) ? parsed : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isValidUpc(value: string): boolean {
|
||||||
|
return UPC_PATTERN.test(value);
|
||||||
|
}
|
||||||
147
src/supplier/upc-lookup.ts
Normal file
147
src/supplier/upc-lookup.ts
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
import { lookupKeepaUpcs, mapUpcsToAsins } from "../integrations/keepa.ts";
|
||||||
|
|
||||||
|
function printUsage(): void {
|
||||||
|
console.log("Usage:");
|
||||||
|
console.log(
|
||||||
|
" bun run src/upc-lookup.ts <upc...> [--detailed] [--json] [--file path]",
|
||||||
|
);
|
||||||
|
console.log("");
|
||||||
|
console.log("Examples:");
|
||||||
|
console.log(" bun run src/upc-lookup.ts 012345678901 098765432109");
|
||||||
|
console.log(
|
||||||
|
" bun run src/upc-lookup.ts 012345678901,098765432109 --detailed",
|
||||||
|
);
|
||||||
|
console.log(" bun run src/upc-lookup.ts --file upcs.txt --detailed --json");
|
||||||
|
}
|
||||||
|
|
||||||
|
function splitRawUpcValues(input: string): string[] {
|
||||||
|
return input
|
||||||
|
.split(/[\s,;|]+/)
|
||||||
|
.map((chunk) => chunk.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readUpcsFromFile(path: string): Promise<string[]> {
|
||||||
|
const file = Bun.file(path);
|
||||||
|
if (!(await file.exists())) {
|
||||||
|
throw new Error(`UPC file not found: ${path}`);
|
||||||
|
}
|
||||||
|
return splitRawUpcValues(await file.text());
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseArgs(args: string[]): {
|
||||||
|
upcs: string[];
|
||||||
|
filePaths: string[];
|
||||||
|
detailed: boolean;
|
||||||
|
asJson: boolean;
|
||||||
|
} {
|
||||||
|
let detailed = false;
|
||||||
|
let asJson = false;
|
||||||
|
const collected: string[] = [];
|
||||||
|
const filePaths: string[] = [];
|
||||||
|
|
||||||
|
for (let i = 0; i < args.length; i++) {
|
||||||
|
const arg = args[i]!;
|
||||||
|
if (arg === "--help" || arg === "-h") {
|
||||||
|
printUsage();
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
if (arg === "--detailed") {
|
||||||
|
detailed = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (arg === "--json") {
|
||||||
|
asJson = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (arg === "--file") {
|
||||||
|
const next = args[i + 1];
|
||||||
|
if (!next) {
|
||||||
|
throw new Error("Missing file path after --file");
|
||||||
|
}
|
||||||
|
filePaths.push(next);
|
||||||
|
i++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (arg.startsWith("--")) {
|
||||||
|
throw new Error(`Unknown flag: ${arg}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
collected.push(...splitRawUpcValues(arg));
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
upcs: collected,
|
||||||
|
filePaths,
|
||||||
|
detailed,
|
||||||
|
asJson,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function dedupeUpcs(upcs: string[]): string[] {
|
||||||
|
return Array.from(new Set(upcs.map((upc) => upc.trim()).filter(Boolean)));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main(): Promise<void> {
|
||||||
|
const args = process.argv.slice(2);
|
||||||
|
const parsed = parseArgs(args);
|
||||||
|
|
||||||
|
const fileUpcs: string[] = [];
|
||||||
|
for (const path of parsed.filePaths) {
|
||||||
|
fileUpcs.push(...(await readUpcsFromFile(path)));
|
||||||
|
}
|
||||||
|
|
||||||
|
const upcs = dedupeUpcs([...parsed.upcs, ...fileUpcs]);
|
||||||
|
if (upcs.length === 0) {
|
||||||
|
printUsage();
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parsed.detailed) {
|
||||||
|
const details = await lookupKeepaUpcs(upcs);
|
||||||
|
const items = Array.from(details.values());
|
||||||
|
|
||||||
|
if (parsed.asJson) {
|
||||||
|
console.log(JSON.stringify(items, null, 2));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.table(
|
||||||
|
items.map((item) => ({
|
||||||
|
upc: item.normalizedUpc,
|
||||||
|
status: item.status,
|
||||||
|
asin: item.asin ?? "",
|
||||||
|
candidates: item.candidateAsins.join("|"),
|
||||||
|
reason: item.reason ?? "",
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const mapping = await mapUpcsToAsins(upcs);
|
||||||
|
const items = Array.from(mapping.entries()).map(([upc, asin]) => ({
|
||||||
|
upc,
|
||||||
|
asin,
|
||||||
|
}));
|
||||||
|
|
||||||
|
if (parsed.asJson) {
|
||||||
|
console.log(JSON.stringify(items, null, 2));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (items.length === 0) {
|
||||||
|
console.log(
|
||||||
|
"No one-to-one UPC to ASIN matches found. Run with --detailed for per-UPC status.",
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.table(items);
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((err) => {
|
||||||
|
const message = err instanceof Error ? err.message : String(err);
|
||||||
|
console.error(`UPC lookup failed: ${message}`);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
102
src/types.ts
102
src/types.ts
@@ -36,12 +36,35 @@ export interface KeepaData {
|
|||||||
salesRankDrops30: number | null;
|
salesRankDrops30: number | null;
|
||||||
salesRankDrops90: number | null;
|
salesRankDrops90: number | null;
|
||||||
sellerCount: number | null;
|
sellerCount: number | null;
|
||||||
|
amazonIsSeller: boolean | null;
|
||||||
|
amazonBuyboxSharePct90d: number | null;
|
||||||
buyBoxSeller: string | null;
|
buyBoxSeller: string | null;
|
||||||
buyBoxPrice: number | null;
|
buyBoxPrice: number | null;
|
||||||
|
buyBoxAvg90?: number | null;
|
||||||
monthlySold: number | null;
|
monthlySold: number | null;
|
||||||
categoryTree: string[];
|
categoryTree: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type KeepaUpcLookupStatus =
|
||||||
|
| "found"
|
||||||
|
| "invalid_upc"
|
||||||
|
| "not_found"
|
||||||
|
| "multiple_asins"
|
||||||
|
| "request_failed";
|
||||||
|
|
||||||
|
export interface KeepaUpcLookupDetail {
|
||||||
|
requestedUpc: string;
|
||||||
|
normalizedUpc: string;
|
||||||
|
status: KeepaUpcLookupStatus;
|
||||||
|
asin: string | null;
|
||||||
|
candidateAsins: string[];
|
||||||
|
keepaData: KeepaData | null;
|
||||||
|
provider?: "sp_api" | "keepa";
|
||||||
|
reason?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type UpcLookupDetail = KeepaUpcLookupDetail;
|
||||||
|
|
||||||
export type SellabilityInfo = {
|
export type SellabilityInfo = {
|
||||||
canSell: boolean | null;
|
canSell: boolean | null;
|
||||||
sellabilityStatus: "available" | "restricted" | "not_available" | "unknown";
|
sellabilityStatus: "available" | "restricted" | "not_available" | "unknown";
|
||||||
@@ -74,6 +97,85 @@ export interface AnalysisResult {
|
|||||||
verdict: LlmVerdict;
|
verdict: LlmVerdict;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type SupplierVerdict = "BUY" | "WATCH" | "SKIP";
|
||||||
|
|
||||||
|
export interface SupplierScore {
|
||||||
|
salePrice: number | null;
|
||||||
|
fbaFee: number | null;
|
||||||
|
profit: number | null;
|
||||||
|
margin: number | null;
|
||||||
|
roi: number | null;
|
||||||
|
demandScore: number;
|
||||||
|
competitionPenalty: number;
|
||||||
|
score: number;
|
||||||
|
verdict: SupplierVerdict;
|
||||||
|
reason: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SupplierAnalysisResult {
|
||||||
|
upc: string;
|
||||||
|
rowNumber?: number;
|
||||||
|
record: SupplierInputRecord;
|
||||||
|
product: ProductRecord | null;
|
||||||
|
lookup: UpcLookupDetail;
|
||||||
|
keepa: KeepaData | null;
|
||||||
|
spApi: SpApiData | null;
|
||||||
|
score: SupplierScore;
|
||||||
|
fetchedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SupplierInputRecord {
|
||||||
|
name: string;
|
||||||
|
unitCost: number;
|
||||||
|
brand?: string;
|
||||||
|
category?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Product {
|
||||||
|
asin: string;
|
||||||
|
name: string | null;
|
||||||
|
brand: string | null;
|
||||||
|
category: string | null;
|
||||||
|
firstSeenAt: string;
|
||||||
|
lastSeenAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProductObservation {
|
||||||
|
id: number;
|
||||||
|
productAsin: string;
|
||||||
|
runId: number;
|
||||||
|
source: string;
|
||||||
|
fetchedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Run {
|
||||||
|
id: number;
|
||||||
|
type:
|
||||||
|
| "lead_analysis"
|
||||||
|
| "category_analysis"
|
||||||
|
| "supplier_upc"
|
||||||
|
| "stalker"
|
||||||
|
| "stalker_analysis";
|
||||||
|
parentRunId?: number | null;
|
||||||
|
status: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RunItem {
|
||||||
|
id: number;
|
||||||
|
runId: number;
|
||||||
|
productAsin: string | null;
|
||||||
|
sourceRow?: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AnalysisRevision {
|
||||||
|
id: number;
|
||||||
|
runItemId: number;
|
||||||
|
decision: "FBA" | "FBM" | "BUY" | "WATCH" | "SKIP";
|
||||||
|
confidence: number | null;
|
||||||
|
reasoning: string | null;
|
||||||
|
analyzedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface CategoryRunSummaryDb {
|
export interface CategoryRunSummaryDb {
|
||||||
categoryId: number;
|
categoryId: number;
|
||||||
categoryLabel: string;
|
categoryLabel: string;
|
||||||
|
|||||||
1654
src/web/frontend.tsx
1654
src/web/frontend.tsx
File diff suppressed because it is too large
Load Diff
@@ -41,9 +41,24 @@ p {
|
|||||||
gap: 10px;
|
gap: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.section-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
.toolbar input,
|
.toolbar input,
|
||||||
.toolbar select,
|
.toolbar select,
|
||||||
button {
|
button,
|
||||||
|
.button-link {
|
||||||
height: 36px;
|
height: 36px;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
border: 1px solid #d8dce0;
|
border: 1px solid #d8dce0;
|
||||||
@@ -52,10 +67,29 @@ button {
|
|||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.button-link {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: inherit;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
button {
|
button {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
button.danger {
|
||||||
|
border-color: #efb8b8;
|
||||||
|
color: #9f1c1c;
|
||||||
|
background: #fff6f6;
|
||||||
|
}
|
||||||
|
|
||||||
|
button:disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 0.58;
|
||||||
|
}
|
||||||
|
|
||||||
.table-wrap {
|
.table-wrap {
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
border: 1px solid #eceef0;
|
border: 1px solid #eceef0;
|
||||||
@@ -91,6 +125,110 @@ td {
|
|||||||
overflow-wrap: anywhere;
|
overflow-wrap: anywhere;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.inventory-col {
|
||||||
|
min-width: 360px;
|
||||||
|
max-width: 520px;
|
||||||
|
white-space: normal;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inventory-col a {
|
||||||
|
display: inline-block;
|
||||||
|
margin-right: 8px;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stalker-table {
|
||||||
|
min-width: 1320px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stalker-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dist-research-entry {
|
||||||
|
padding: 16px 0;
|
||||||
|
border-top: 1px solid #eceef0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dist-research-entry:first-child {
|
||||||
|
border-top: none;
|
||||||
|
padding-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dist-entry-meta {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #5f6b7a;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dist-entry-run {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #8a95a0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dist-candidates {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dist-candidate-card {
|
||||||
|
border: 1px solid #e7e8ea;
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 14px 16px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dist-candidate-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dist-field {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dist-field-block {
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dist-label {
|
||||||
|
font-weight: 600;
|
||||||
|
color: #445060;
|
||||||
|
white-space: nowrap;
|
||||||
|
min-width: 120px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dist-field-block .dist-label {
|
||||||
|
min-width: unset;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dist-outreach {
|
||||||
|
background: #f7f8fa;
|
||||||
|
border: 1px solid #e7e8ea;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 12px 14px;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.6;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
th {
|
th {
|
||||||
background: #fafafb;
|
background: #fafafb;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
@@ -262,4 +400,9 @@ th button {
|
|||||||
.spark-grid {
|
.spark-grid {
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.section-header {
|
||||||
|
align-items: flex-start;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
230
src/writer.ts
230
src/writer.ts
@@ -1,5 +1,22 @@
|
|||||||
import { getDb } from "./database.ts";
|
import { eq } from "drizzle-orm";
|
||||||
import type { AnalysisResult } from "./types.ts";
|
import { db } from "./db/index.ts";
|
||||||
|
import { analysisRunStats, runs } from "./db/schema.ts";
|
||||||
|
import {
|
||||||
|
persistLlmResults,
|
||||||
|
persistSupplierResults,
|
||||||
|
refreshRunStats,
|
||||||
|
} from "./db/persistence.ts";
|
||||||
|
import type { AnalysisResult, SupplierAnalysisResult } from "./types.ts";
|
||||||
|
import { mkdirSync } from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
import * as XLSX from "xlsx";
|
||||||
|
|
||||||
|
export type RunCounts = {
|
||||||
|
totalProducts: number;
|
||||||
|
fbaCount: number;
|
||||||
|
fbmCount: number;
|
||||||
|
skipCount: number;
|
||||||
|
};
|
||||||
|
|
||||||
function buildRow(r: AnalysisResult) {
|
function buildRow(r: AnalysisResult) {
|
||||||
const price =
|
const price =
|
||||||
@@ -30,6 +47,9 @@ function buildRow(r: AnalysisResult) {
|
|||||||
"Sales Rank": rank ?? "",
|
"Sales Rank": rank ?? "",
|
||||||
"Rank Avg 90d": r.product.keepa?.salesRankAvg90 ?? "",
|
"Rank Avg 90d": r.product.keepa?.salesRankAvg90 ?? "",
|
||||||
Sellers: r.product.keepa?.sellerCount ?? "",
|
Sellers: r.product.keepa?.sellerCount ?? "",
|
||||||
|
"Amazon Is Seller": r.product.keepa?.amazonIsSeller ?? null,
|
||||||
|
"Amazon Buy Box Share 90d %":
|
||||||
|
r.product.keepa?.amazonBuyboxSharePct90d ?? "",
|
||||||
"Monthly Sold": r.product.keepa?.monthlySold ?? "",
|
"Monthly Sold": r.product.keepa?.monthlySold ?? "",
|
||||||
"Rank Drops 30d": r.product.keepa?.salesRankDrops30 ?? "",
|
"Rank Drops 30d": r.product.keepa?.salesRankDrops30 ?? "",
|
||||||
"Rank Drops 90d": r.product.keepa?.salesRankDrops90 ?? "",
|
"Rank Drops 90d": r.product.keepa?.salesRankDrops90 ?? "",
|
||||||
@@ -59,113 +79,115 @@ function buildRow(r: AnalysisResult) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function writeResultsToDb(
|
export async function writeResultsToDb(
|
||||||
results: AnalysisResult[],
|
results: AnalysisResult[],
|
||||||
dbPath: string,
|
|
||||||
inputFile: string,
|
inputFile: string,
|
||||||
outputFile: string | undefined,
|
outputFile: string | undefined,
|
||||||
): void {
|
): Promise<void> {
|
||||||
const database = getDb(dbPath);
|
const runId = await startRunInDb(inputFile, outputFile);
|
||||||
|
try {
|
||||||
const timestamp = new Date().toISOString();
|
await appendResultsToRun(runId, results);
|
||||||
const fbaCount = results.filter((r) => r.verdict.verdict === "FBA").length;
|
await refreshRunCountsInDb(runId);
|
||||||
const fbmCount = results.filter((r) => r.verdict.verdict === "FBM").length;
|
await completeRunInDb(runId);
|
||||||
const skipCount = results.filter((r) => r.verdict.verdict === "SKIP").length;
|
} catch (error) {
|
||||||
|
await failRunInDb(runId, error);
|
||||||
const insertRun = database.prepare(
|
throw error;
|
||||||
`INSERT INTO runs (
|
|
||||||
timestamp,
|
|
||||||
input_file,
|
|
||||||
output_file,
|
|
||||||
total_products,
|
|
||||||
fba_count,
|
|
||||||
fbm_count,
|
|
||||||
skip_count
|
|
||||||
) VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
|
||||||
);
|
|
||||||
const runInfo = insertRun.run(
|
|
||||||
timestamp,
|
|
||||||
inputFile,
|
|
||||||
outputFile ?? null,
|
|
||||||
results.length,
|
|
||||||
fbaCount,
|
|
||||||
fbmCount,
|
|
||||||
skipCount,
|
|
||||||
);
|
|
||||||
const runId =
|
|
||||||
(runInfo.changes as number) > 0
|
|
||||||
? (runInfo.lastInsertRowid as number)
|
|
||||||
: null;
|
|
||||||
|
|
||||||
if (runId === null) {
|
|
||||||
console.error("Failed to insert run record into SQLite.");
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
console.log(`Results written to database for run_id: ${runId}`);
|
||||||
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, 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["Monthly Sold"] ?? null,
|
|
||||||
row["Rank Drops 30d"] ?? null,
|
|
||||||
row["Rank Drops 90d"] ?? null,
|
|
||||||
row["FBA Net (sheet)"] ?? null,
|
|
||||||
row["Gross Profit $"] ?? null,
|
|
||||||
row["Gross Profit %"] ?? null,
|
|
||||||
row["Net Profit (sheet)"] ?? null,
|
|
||||||
row["ROI (sheet)"] ?? null,
|
|
||||||
row.MOQ ?? null,
|
|
||||||
row["MOQ Cost"] ?? null,
|
|
||||||
row["Qty Available"] ?? null,
|
|
||||||
row.Supplier ?? null,
|
|
||||||
row["Source URL"] ?? null,
|
|
||||||
row["ASIN Link"] ?? null,
|
|
||||||
row["Promo/Coupon Code"] ?? null,
|
|
||||||
row.Notes ?? null,
|
|
||||||
row["Lead Date"] ?? null,
|
|
||||||
row["FBA Fee"] ?? null,
|
|
||||||
row["FBM Fee"] ?? null,
|
|
||||||
row["Referral %"] ?? null,
|
|
||||||
row["Can Sell"],
|
|
||||||
row.Sellability,
|
|
||||||
row["Sellability Reason"] ?? null,
|
|
||||||
row.Verdict,
|
|
||||||
row.Confidence ?? null,
|
|
||||||
row.Reasoning,
|
|
||||||
r.product.fetchedAt,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
console.log(`Results written to SQLite database for run_id: ${runId}`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function writeResultsWorkbook(
|
||||||
|
results: AnalysisResult[],
|
||||||
|
outputFile: string,
|
||||||
|
): void {
|
||||||
|
const outputDir = path.dirname(outputFile);
|
||||||
|
if (outputDir && outputDir !== ".") {
|
||||||
|
mkdirSync(outputDir, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
const workbook = XLSX.utils.book_new();
|
||||||
|
const worksheet = XLSX.utils.json_to_sheet(results.map(buildRow));
|
||||||
|
XLSX.utils.book_append_sheet(workbook, worksheet, "Results");
|
||||||
|
XLSX.writeFile(workbook, outputFile);
|
||||||
|
console.log(`Results workbook written: ${outputFile}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function startRunInDb(
|
||||||
|
inputFile: string,
|
||||||
|
outputFile: string | undefined,
|
||||||
|
counts: RunCounts = {
|
||||||
|
totalProducts: 0,
|
||||||
|
fbaCount: 0,
|
||||||
|
fbmCount: 0,
|
||||||
|
skipCount: 0,
|
||||||
|
},
|
||||||
|
type: "lead_analysis" | "supplier_upc" = "lead_analysis",
|
||||||
|
): Promise<number> {
|
||||||
|
const [row] = await db
|
||||||
|
.insert(runs)
|
||||||
|
.values({
|
||||||
|
type,
|
||||||
|
inputFile,
|
||||||
|
outputFile: outputFile ?? null,
|
||||||
|
status: "running",
|
||||||
|
startedAt: new Date(),
|
||||||
|
})
|
||||||
|
.returning({ id: runs.id });
|
||||||
|
|
||||||
|
if (!row) throw new Error("Failed to insert run record.");
|
||||||
|
await db.insert(analysisRunStats).values({
|
||||||
|
runId: row.id,
|
||||||
|
processedCount: counts.totalProducts,
|
||||||
|
analyzedCount: counts.totalProducts,
|
||||||
|
fbaCount: counts.fbaCount,
|
||||||
|
fbmCount: counts.fbmCount,
|
||||||
|
skipCount: counts.skipCount,
|
||||||
|
});
|
||||||
|
return row.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function appendResultsToRun(
|
||||||
|
runId: number,
|
||||||
|
results: AnalysisResult[],
|
||||||
|
): Promise<void> {
|
||||||
|
if (results.length === 0) return;
|
||||||
|
await persistLlmResults(runId, results, {
|
||||||
|
source: "lead_analysis",
|
||||||
|
metadataSource: "input",
|
||||||
|
preserveSourcingInput: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function appendSupplierResultsToRun(
|
||||||
|
runId: number,
|
||||||
|
results: SupplierAnalysisResult[],
|
||||||
|
): Promise<void> {
|
||||||
|
if (results.length === 0) return;
|
||||||
|
await persistSupplierResults(runId, results);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function refreshRunCountsInDb(runId: number): Promise<RunCounts> {
|
||||||
|
return refreshRunStats(runId);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function completeRunInDb(runId: number): Promise<void> {
|
||||||
|
await db
|
||||||
|
.update(runs)
|
||||||
|
.set({ status: "completed", completedAt: new Date(), errorMessage: null })
|
||||||
|
.where(eq(runs.id, runId));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function failRunInDb(
|
||||||
|
runId: number,
|
||||||
|
error: unknown,
|
||||||
|
): Promise<void> {
|
||||||
|
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||||
|
await db
|
||||||
|
.update(runs)
|
||||||
|
.set({ status: "failed", completedAt: new Date(), errorMessage })
|
||||||
|
.where(eq(runs.id, runId));
|
||||||
|
}
|
||||||
|
|
||||||
export function printResults(results: AnalysisResult[]): void {
|
export function printResults(results: AnalysisResult[]): void {
|
||||||
const rows = results
|
const rows = results
|
||||||
.filter((r) => r.verdict.verdict === "FBA" || r.verdict.verdict === "FBM")
|
.filter((r) => r.verdict.verdict === "FBA" || r.verdict.verdict === "FBM")
|
||||||
|
|||||||
Reference in New Issue
Block a user