feat: add supplier scoring and UPC file analysis functionality

- Implemented supplier scoring logic in `supplier-scoring.ts` with functions to compute demand score, competition penalty, and overall supplier product score.
- Created unit tests for supplier scoring in `supplier-scoring.test.ts` to validate scoring logic against various scenarios.
- Developed UPC file analysis tool in `upc-file-analysis.ts` to process UPCs in batches, fetch product data from Keepa and SP-API, and generate supplier results.
- Added UPC input reading functionality in `upc-file-reader.ts` to handle XLSX and XLS files, including validation for UPC formats.
- Introduced a command-line tool in `upc-lookup.ts` for looking up UPCs and displaying detailed results or mappings to ASINs.
- Enhanced error handling and logging throughout the new modules for better traceability and user feedback.
This commit is contained in:
Victor Noguera
2026-05-25 00:53:47 -04:00
parent b982edd160
commit c006d87c54
36 changed files with 1905 additions and 113 deletions

View File

@@ -1,7 +1,7 @@
import { fetchKeepaDataBatch } from "./keepa.ts";
import { fetchSellabilityBatch, fetchSpApiPricingAndFees } from "./sp-api.ts";
import { getCache, setCache } from "./cache.ts";
import { analyzeProducts } from "./llm.ts";
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,

View File

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

View File

@@ -35,7 +35,7 @@ const makeMockDb = (): any => ({
transaction: async (fn: (tx: any) => Promise<any>) => fn(makeMockDb()),
});
mock.module("./db/index.ts", () => ({ db: makeMockDb(), client: {} }));
mock.module("../db/index.ts", () => ({ db: makeMockDb(), client: {} }));
const fetchSellabilityBatchMock = mock(async (asins: string[]) => {
return new Map(
@@ -69,12 +69,12 @@ const analyzeProductsMock = mock(async (products: any[]) => {
}));
});
mock.module("./sp-api.ts", () => ({
mock.module("../integrations/sp-api.ts", () => ({
fetchSellabilityBatch: fetchSellabilityBatchMock,
fetchSpApiPricingAndFees: fetchSpApiPricingAndFeesMock,
}));
mock.module("./llm.ts", () => ({
mock.module("../integrations/llm.ts", () => ({
analyzeProducts: analyzeProductsMock,
}));

View File

@@ -1,11 +1,11 @@
import { existsSync, mkdirSync, readFileSync } from "node:fs";
import path from "node:path";
import { db } from "./db/index.ts";
import { runs, categoryProductResults } from "./db/schema.ts";
import { db } from "../db/index.ts";
import { runs, categoryProductResults } from "../db/schema.ts";
import { eq, sql } from "drizzle-orm";
import { config } from "./config.ts";
import { analyzeProducts } from "./llm.ts";
import { fetchSellabilityBatch, fetchSpApiPricingAndFees } from "./sp-api.ts";
import { config } from "../config.ts";
import { analyzeProducts } from "../integrations/llm.ts";
import { fetchSellabilityBatch, fetchSpApiPricingAndFees } from "../integrations/sp-api.ts";
import type {
AnalysisResult,
EnrichedProduct,
@@ -14,7 +14,7 @@ import type {
ProductRecord,
SellabilityInfo,
SpApiData,
} from "./types.ts";
} from "../types.ts";
type CategoryInfo = {
id: number;

View File

@@ -35,7 +35,7 @@ const makeMockDb = (): any => ({
transaction: async (fn: (tx: any) => Promise<any>) => fn(makeMockDb()),
});
mock.module("./db/index.ts", () => ({ db: makeMockDb(), client: {} }));
mock.module("../db/index.ts", () => ({ db: makeMockDb(), client: {} }));
const fetchSellabilityBatchMock = mock(async (asins: string[]) => {
return new Map<string, any>(
@@ -84,12 +84,12 @@ const analyzeProductsMock = mock(async (products: any[]) => {
}));
});
mock.module("./sp-api.ts", () => ({
mock.module("../integrations/sp-api.ts", () => ({
fetchSellabilityBatch: fetchSellabilityBatchMock,
fetchSpApiPricingAndFees: fetchSpApiPricingAndFeesMock,
}));
mock.module("./llm.ts", () => ({
mock.module("../integrations/llm.ts", () => ({
analyzeProducts: analyzeProductsMock,
}));

View File

@@ -2,18 +2,18 @@ import { existsSync, mkdirSync, readFileSync } from "node:fs";
import path from "node:path";
import { createInterface } from "node:readline/promises";
import { stdin as input, stdout as output } from "node:process";
import { db } from "./db/index.ts";
import { runs, categoryProductResults } from "./db/schema.ts";
import { db } from "../db/index.ts";
import { runs, categoryProductResults } from "../db/schema.ts";
import { eq, sql } from "drizzle-orm";
import { config } from "./config.ts";
import { config } from "../config.ts";
import {
connectCache,
disconnectCache,
getApiCache,
setApiCache,
} from "./cache.ts";
import { analyzeProducts } from "./llm.ts";
import { fetchSellabilityBatch, fetchSpApiPricingAndFees } from "./sp-api.ts";
} from "../integrations/cache.ts";
import { analyzeProducts } from "../integrations/llm.ts";
import { fetchSellabilityBatch, fetchSpApiPricingAndFees } from "../integrations/sp-api.ts";
import type {
AnalysisResult,
EnrichedProduct,
@@ -22,7 +22,7 @@ import type {
ProductRecord,
SellabilityInfo,
SpApiData,
} from "./types.ts";
} from "../types.ts";
type CategoryInfo = {
id: number;

View File

@@ -35,7 +35,7 @@ const makeMockDb = (): any => ({
transaction: async (fn: (tx: any) => Promise<any>) => fn(makeMockDb()),
});
mock.module("./db/index.ts", () => ({ db: makeMockDb(), client: {} }));
mock.module("../db/index.ts", () => ({ db: makeMockDb(), client: {} }));
const fetchSellabilityBatchMock = mock(async (asins: string[]) => {
return new Map<string, any>(
@@ -82,12 +82,12 @@ const analyzeProductsMock = mock(async (products: any[]) => {
}));
});
mock.module("./sp-api.ts", () => ({
mock.module("../integrations/sp-api.ts", () => ({
fetchSellabilityBatch: fetchSellabilityBatchMock,
fetchSpApiPricingAndFees: fetchSpApiPricingAndFeesMock,
}));
mock.module("./llm.ts", () => ({
mock.module("../integrations/llm.ts", () => ({
analyzeProducts: analyzeProductsMock,
}));

View File

@@ -1,11 +1,11 @@
import { existsSync, mkdirSync, readFileSync } from "node:fs";
import path from "node:path";
import { db } from "./db/index.ts";
import { runs, categoryProductResults } from "./db/schema.ts";
import { db } from "../db/index.ts";
import { runs, categoryProductResults } from "../db/schema.ts";
import { eq, sql } from "drizzle-orm";
import { config } from "./config.ts";
import { analyzeProducts } from "./llm.ts";
import { fetchSellabilityBatch, fetchSpApiPricingAndFees } from "./sp-api.ts";
import { config } from "../config.ts";
import { analyzeProducts } from "../integrations/llm.ts";
import { fetchSellabilityBatch, fetchSpApiPricingAndFees } from "../integrations/sp-api.ts";
import type {
AnalysisResult,
EnrichedProduct,
@@ -14,7 +14,7 @@ import type {
ProductRecord,
SellabilityInfo,
SpApiData,
} from "./types.ts";
} from "../types.ts";
type CategoryInfo = {
id: number;

View File

@@ -1,3 +0,0 @@
// Central re-export so existing `import { db } from "./database.ts"` keeps working.
export { db, type Db } from "./db/index.ts";
export * as schema from "./db/schema.ts";

View File

@@ -1,5 +1,5 @@
import { readProducts } from "./reader.ts";
import { connectCache, disconnectCache } from "./cache.ts";
import { connectCache, disconnectCache } from "./integrations/cache.ts";
import {
printResults,
writeResultsToDb,

View File

@@ -1,6 +1,6 @@
import Redis from "ioredis";
import { config } from "./config.ts";
import type { EnrichedProduct, KeepaData, SpApiData } from "./types.ts";
import { config } from "../config.ts";
import type { EnrichedProduct, KeepaData, SpApiData } from "../types.ts";
let redis: Redis | null = null;
let disabled = false;

View File

@@ -1,5 +1,5 @@
import { config } from "./config.ts";
import type { KeepaData, KeepaUpcLookupDetail } from "./types.ts";
import { config } from "../config.ts";
import type { KeepaData, KeepaUpcLookupDetail } from "../types.ts";
const KEEPA_BASE = "https://api.keepa.com";
const MAX_ASINS_PER_REQUEST = 100;

View File

@@ -1,5 +1,5 @@
import { config } from "./config.ts";
import type { EnrichedProduct, LlmVerdict } from "./types.ts";
import { config } from "../config.ts";
import type { EnrichedProduct, LlmVerdict } from "../types.ts";
const SYSTEM_PROMPT_STRICT = `You are an expert Amazon product analyst specializing in FBA and FBM fulfillment strategy.

View File

@@ -1,11 +1,11 @@
import { SellingPartner } from "amazon-sp-api";
import { config } from "./config.ts";
import { config } from "../config.ts";
import type {
KeepaUpcLookupStatus,
SpApiData,
SellabilityInfo,
UpcLookupDetail,
} from "./types.ts";
} from "../types.ts";
type RegionCode = "na" | "eu" | "fe";

View File

@@ -5,10 +5,10 @@ import {
fetchKeepaDataBatch,
lookupKeepaUpcs,
mapUpcsToAsins,
} from "./keepa.ts";
import { runUpcFileAnalysis } from "./upc-file-analysis.ts";
import { fetchSellabilityBatch, fetchSpApiPricingAndFees } from "./sp-api.ts";
import { analyzeProducts } from "./llm.ts";
} from "./integrations/keepa.ts";
import { runUpcFileAnalysis } from "./supplier/upc-file-analysis.ts";
import { fetchSellabilityBatch, fetchSpApiPricingAndFees } from "./integrations/sp-api.ts";
import { analyzeProducts } from "./integrations/llm.ts";
import type {
EnrichedProduct,
KeepaUpcLookupDetail,

View File

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

View File

@@ -1,15 +1,15 @@
import { db } from "./db/index.ts";
import { categoryProductResults, runs } from "./db/schema.ts";
import { db } from "../db/index.ts";
import { categoryProductResults, runs } from "../db/schema.ts";
import { eq, sql } from "drizzle-orm";
import { analyzeProducts } from "./llm.ts";
import { fetchSpApiPricingAndFees } from "./sp-api.ts";
import { analyzeProducts } from "../integrations/llm.ts";
import { fetchSpApiPricingAndFees } from "../integrations/sp-api.ts";
import type {
AnalysisResult,
EnrichedProduct,
KeepaData,
ProductRecord,
SellabilityInfo,
} from "./types.ts";
} from "../types.ts";
const LLM_BATCH_SIZE = 5;
const LLM_BATCH_DELAY_MS = 5_000;

View File

@@ -62,7 +62,7 @@ const makeMockDb = (): any => ({
transaction: async (fn: (tx: any) => Promise<any>) => fn(makeMockTx()),
});
mock.module("./db/index.ts", () => ({ db: makeMockDb(), client: {} }));
mock.module("../db/index.ts", () => ({ db: makeMockDb(), client: {} }));
const TEST_DIR = path.join(process.cwd(), "test_output", "stalker-sellability");
const originalFetch = globalThis.fetch;
@@ -87,10 +87,6 @@ const fetchSellabilityBatchMock = mock(async (asins: string[]) => {
);
});
mock.module("./sp-api.ts", () => ({
fetchSellabilityBatch: fetchSellabilityBatchMock,
}));
const modulePromise = import("./stalker.ts");
beforeEach(() => {
@@ -194,22 +190,25 @@ test("sellability checks matched seller inventory, not the source ASIN", async (
return new Response("not found", { status: 404 });
}) as unknown as typeof globalThis.fetch;
const stats = await runStalker({
input: inputPath,
maxAsins: null,
storefrontUpdateHours: 168,
offerLimit: 20,
sellerLimit: 30,
inventoryLimit: 200,
sellerCacheHours: 168,
includeStock: false,
dryRun: false,
resume: true,
maxSellerRequests: null,
sellability: true,
analyzeSellable: false,
useClaude: false,
});
const stats = await runStalker(
{
input: inputPath,
maxAsins: null,
storefrontUpdateHours: 168,
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([

View File

@@ -69,7 +69,7 @@ const makeMockDb = (): any => ({
transaction: async (fn: (tx: any) => Promise<any>) => fn(makeMockTx()),
});
mock.module("./db/index.ts", () => ({ db: makeMockDb(), client: {} }));
mock.module("../db/index.ts", () => ({ db: makeMockDb(), client: {} }));
const TEST_DIR = path.join(process.cwd(), "test_output", "stalker");
const originalFetch = globalThis.fetch;

View File

@@ -1,6 +1,6 @@
import * as XLSX from "xlsx";
import path from "node:path";
import { db } from "./db/index.ts";
import { db } from "../db/index.ts";
import {
runs,
stalkerRuns,
@@ -8,10 +8,10 @@ import {
sellers,
stalkerAsinSellers,
stalkerSellerInventory,
} from "./db/schema.ts";
} from "../db/schema.ts";
import { eq, sql } from "drizzle-orm";
import { fetchSellabilityBatch } from "./sp-api.ts";
import type { SellabilityInfo } from "./types.ts";
import { fetchSellabilityBatch } from "../integrations/sp-api.ts";
import type { SellabilityInfo } from "../types.ts";
const KEEPA_BASE = "https://api.keepa.com";
const DOMAIN_US = "1";
@@ -310,7 +310,11 @@ export function extractLiveOfferSellerCandidates(
return Array.from(bySeller.values());
}
export async function runStalker(args: StalkerArgs): Promise<StalkerRunStats> {
export type StalkerDeps = {
fetchSellabilityBatch?: (asins: string[]) => Promise<Map<string, SellabilityInfo>>;
};
export async function runStalker(args: StalkerArgs, deps: StalkerDeps = {}): Promise<StalkerRunStats> {
const apiKey = Bun.env.KEEPA_API_KEY;
if (!apiKey) throw new Error("Missing required env var: KEEPA_API_KEY");
@@ -383,7 +387,7 @@ export async function runStalker(args: StalkerArgs): Promise<StalkerRunStats> {
);
if (args.sellability && !args.dryRun) {
await enrichInventorySellability(result, stats);
await enrichInventorySellability(result, stats, deps.fetchSellabilityBatch ?? fetchSellabilityBatch);
}
applyInventoryPersistencePolicy(result, args.sellability && !args.dryRun);
if (args.sellability && !args.dryRun) {
@@ -545,6 +549,7 @@ function applyInventoryPersistencePolicy(
async function enrichInventorySellability(
result: StalkerAsinResult,
stats: StalkerRunStats,
sellabilityFn: (asins: string[]) => Promise<Map<string, SellabilityInfo>>,
): Promise<void> {
const sellers = result.matchedSellers.map(({ seller }) => seller);
const items = sellers.flatMap((seller) => seller.storefrontItems);
@@ -554,7 +559,7 @@ async function enrichInventorySellability(
console.log(
`Stalker inventory sellability: checking ${uniqueAsins.length} ASIN(s) from matched seller storefronts...`,
);
const sellabilityMap = await fetchSellabilityBatch(uniqueAsins);
const sellabilityMap = await sellabilityFn(uniqueAsins);
stats.inventorySellabilityCheckedAsins += uniqueAsins.length;
for (const asin of uniqueAsins) {

View File

@@ -3,7 +3,7 @@ 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";
import type { SupplierAnalysisResult } from "../types.ts";
const OUTPUT_FILE = path.join("/private/tmp", "asin-check-supplier-export-test.xlsx");

View File

@@ -5,7 +5,7 @@ import type {
KeepaUpcLookupStatus,
SupplierAnalysisResult,
SupplierVerdict,
} from "./types.ts";
} from "../types.ts";
export type SupplierExportSummary = {
processedRows: number;

View File

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

View File

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

View File

@@ -1,10 +1,10 @@
import path from "node:path";
import { fetchKeepaDataBatch, lookupKeepaUpcs } from "./keepa.ts";
import { fetchKeepaDataBatch, lookupKeepaUpcs } from "../integrations/keepa.ts";
import {
fetchSellabilityBatch,
fetchSpApiPricingAndFees,
lookupSpApiUpcs,
} from "./sp-api.ts";
} from "../integrations/sp-api.ts";
import {
processUpcFileInBatches,
type UpcInputRow,
@@ -14,8 +14,8 @@ import {
refreshRunCountsInDb,
startRunInDb,
type RunCounts,
} from "./writer.ts";
import { connectCache, disconnectCache } from "./cache.ts";
} from "../writer.ts";
import { connectCache, disconnectCache } from "../integrations/cache.ts";
import { scoreSupplierProduct, resolveSupplierSalePrice } from "./supplier-scoring.ts";
import {
writeSupplierWorkbook,
@@ -28,7 +28,7 @@ import type {
SupplierAnalysisResult,
SupplierScore,
UpcLookupDetail,
} from "./types.ts";
} from "../types.ts";
const DEFAULT_INPUT_BATCH_SIZE = 200;
const DEFAULT_UPC_LOOKUP_BATCH_SIZE = 100;

View File

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