Refactor SP-API test script and improve type definitions

- Updated `sp-test.ts` to enhance argument parsing and error handling for sellability checks.
- Refactored `types.ts` to maintain consistent formatting and improve readability.
- Improved `writer.ts` for better result handling and CSV writing, ensuring clarity in output.
- Adjusted `tsconfig.json` formatting for consistency and readability.
This commit is contained in:
Victor Noguera
2026-04-12 23:48:31 -04:00
parent 4386560964
commit dbe5b1ac71
18 changed files with 3497 additions and 2429 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -1,66 +1,66 @@
import Redis from "ioredis";
import { config } from "./config.ts";
import type { EnrichedProduct } from "./types.ts";
let redis: Redis | null = null;
let disabled = false;
export async function connectCache(): Promise<void> {
if (disabled) return;
try {
redis = new Redis(config.redisUrl, {
maxRetriesPerRequest: 1,
connectTimeout: 3000,
lazyConnect: true,
retryStrategy: () => null,
reconnectOnError: () => false,
});
// Swallow connection-level errors after we intentionally disable cache.
redis.on("error", () => {
// no-op
});
await redis.connect();
console.log("Redis connected");
} catch (err) {
console.warn(`Redis unavailable, running without cache: ${err}`);
if (redis) {
redis.disconnect();
}
redis = null;
disabled = true;
}
}
export async function getCache(asin: string): Promise<EnrichedProduct | null> {
if (!redis) return null;
try {
const data = await redis.get(`asin:${asin}`);
return data ? JSON.parse(data) : null;
} catch {
return null;
}
}
export async function setCache(
asin: string,
data: EnrichedProduct,
): Promise<void> {
if (!redis) return;
try {
await redis.set(
`asin:${asin}`,
JSON.stringify(data),
"EX",
config.cacheTtl,
);
} catch {
// Non-critical, continue without caching
}
}
export async function disconnectCache(): Promise<void> {
if (redis) {
await redis.quit();
redis = null;
}
}
import Redis from "ioredis";
import { config } from "./config.ts";
import type { EnrichedProduct } from "./types.ts";
let redis: Redis | null = null;
let disabled = false;
export async function connectCache(): Promise<void> {
if (disabled) return;
try {
redis = new Redis(config.redisUrl, {
maxRetriesPerRequest: 1,
connectTimeout: 3000,
lazyConnect: true,
retryStrategy: () => null,
reconnectOnError: () => false,
});
// Swallow connection-level errors after we intentionally disable cache.
redis.on("error", () => {
// no-op
});
await redis.connect();
console.log("Redis connected");
} catch (err) {
console.warn(`Redis unavailable, running without cache: ${err}`);
if (redis) {
redis.disconnect();
}
redis = null;
disabled = true;
}
}
export async function getCache(asin: string): Promise<EnrichedProduct | null> {
if (!redis) return null;
try {
const data = await redis.get(`asin:${asin}`);
return data ? JSON.parse(data) : null;
} catch {
return null;
}
}
export async function setCache(
asin: string,
data: EnrichedProduct,
): Promise<void> {
if (!redis) return;
try {
await redis.set(
`asin:${asin}`,
JSON.stringify(data),
"EX",
config.cacheTtl,
);
} catch {
// Non-critical, continue without caching
}
}
export async function disconnectCache(): Promise<void> {
if (redis) {
await redis.quit();
redis = null;
}
}

View File

@@ -1,34 +1,34 @@
function required(key: string): string {
const val = Bun.env[key];
if (!val) throw new Error(`Missing required env var: ${key}`);
return val;
}
function optional(key: string, fallback: string): string {
return Bun.env[key] || fallback;
}
function optionalBoolean(key: string, fallback: boolean): boolean {
const raw = Bun.env[key];
if (!raw) return fallback;
const value = raw.trim().toLowerCase();
return value === "1" || value === "true" || value === "yes";
}
export const config = {
keepaApiKey: required("KEEPA_API_KEY"),
redisUrl: optional("REDIS_URL", "redis://localhost:6379"),
llmUrl: optional("LLM_URL", "http://localhost:1234/v1"),
llmModel: optional("LLM_MODEL", "default"),
cacheTtl: parseInt(optional("CACHE_TTL", "86400"), 10),
spApiClientId: Bun.env.SP_API_CLIENT_ID,
spApiClientSecret: Bun.env.SP_API_CLIENT_SECRET,
spApiRefreshToken: Bun.env.SP_API_REFRESH_TOKEN,
spApiRegion: optional("SP_API_REGION", "na"),
spApiMarketplaceId: optional("SP_API_MARKETPLACE_ID", "ATVPDKIKX0DER"),
spApiSellerId: Bun.env.SP_API_SELLER_ID,
spApiUseSandbox: optionalBoolean("SP_API_USE_SANDBOX", false),
awsAccessKeyId: Bun.env.AWS_ACCESS_KEY_ID,
awsSecretAccessKey: Bun.env.AWS_SECRET_ACCESS_KEY,
awsSessionToken: Bun.env.AWS_SESSION_TOKEN,
} as const;
function required(key: string): string {
const val = Bun.env[key];
if (!val) throw new Error(`Missing required env var: ${key}`);
return val;
}
function optional(key: string, fallback: string): string {
return Bun.env[key] || fallback;
}
function optionalBoolean(key: string, fallback: boolean): boolean {
const raw = Bun.env[key];
if (!raw) return fallback;
const value = raw.trim().toLowerCase();
return value === "1" || value === "true" || value === "yes";
}
export const config = {
keepaApiKey: required("KEEPA_API_KEY"),
redisUrl: optional("REDIS_URL", "redis://localhost:6379"),
llmUrl: optional("LLM_URL", "http://localhost:1234/v1"),
llmModel: optional("LLM_MODEL", "default"),
cacheTtl: parseInt(optional("CACHE_TTL", "86400"), 10),
spApiClientId: Bun.env.SP_API_CLIENT_ID,
spApiClientSecret: Bun.env.SP_API_CLIENT_SECRET,
spApiRefreshToken: Bun.env.SP_API_REFRESH_TOKEN,
spApiRegion: optional("SP_API_REGION", "na"),
spApiMarketplaceId: optional("SP_API_MARKETPLACE_ID", "ATVPDKIKX0DER"),
spApiSellerId: Bun.env.SP_API_SELLER_ID,
spApiUseSandbox: optionalBoolean("SP_API_USE_SANDBOX", false),
awsAccessKeyId: Bun.env.AWS_ACCESS_KEY_ID,
awsSecretAccessKey: Bun.env.AWS_SECRET_ACCESS_KEY,
awsSessionToken: Bun.env.AWS_SESSION_TOKEN,
} as const;

View File

@@ -1,345 +1,345 @@
import { readProducts } from "./reader.ts";
import { fetchKeepaDataBatch } from "./keepa.ts";
import { fetchSellabilityBatch, fetchSpApiPricingAndFees } from "./sp-api.ts";
import { connectCache, getCache, setCache, disconnectCache } from "./cache.ts";
import { analyzeProducts } from "./llm.ts";
import { printResults, writeResultsCsv } from "./writer.ts";
import path from "node:path";
import type {
EnrichedProduct,
AnalysisResult,
KeepaData,
ProductRecord,
SellabilityInfo,
SpApiData,
} from "./types.ts";
const LLM_BATCH_SIZE = 5;
const INPUT_BATCH_SIZE = 50;
function parseArgs(): { inputFile: string; outputFile?: string } {
const args = process.argv.slice(2);
const inputFile = args.find((a) => !a.startsWith("--"));
const outIdx = args.indexOf("--out");
const outputFile = outIdx !== -1 ? args[outIdx + 1] : undefined;
if (!inputFile) {
console.error(
"Usage: bun run src/index.ts <input.csv|xlsx> [--out results.csv]",
);
process.exit(1);
}
return { inputFile, outputFile };
}
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 resolveBaseOutputPath(inputFile: string, outputFile?: string): string {
if (outputFile) return outputFile;
const parsedInput = path.parse(inputFile);
return path.join(parsedInput.dir, `${parsedInput.name}_results.xlsx`);
}
function buildChunkOutputPath(
baseOutputPath: string,
chunkIndex: number,
): string {
const parsed = path.parse(baseOutputPath);
const extension = parsed.ext || ".xlsx";
const chunkSuffix = String(chunkIndex + 1).padStart(3, "0");
return path.join(
parsed.dir,
`${parsed.name}_part_${chunkSuffix}${extension}`,
);
}
async function processProductChunk(
products: ProductRecord[],
): Promise<AnalysisResult[]> {
// Phase 2: Check cache for all ASINs in chunk
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`,
);
// Phase 3: Sellability gate — check uncached ASINs before anything else
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);
// Keep only ASINs that are explicitly available.
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`,
);
}
// Phase 4: Keepa batch fetch — only for available (uncached) ASINs
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}`);
}
}
// Phase 5: SP-API pricing + fees — only for available ASINs
console.log(
`\nFetching pricing & fees for ${availableProducts.length} ASINs...`,
);
const spApiResults = new Map<string, SpApiData>();
// Concurrency-limited pricing+fees fetches
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);
// Phase 6: Build enriched products
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;
}
// Cached products — already enriched
const cachedProduct = cached.get(p.asin);
if (cachedProduct) {
enriched.push(cachedProduct);
continue;
}
// Exclude products that are not explicitly available.
if (!availableAsins.has(p.asin)) {
continue;
}
// Available products — full enrichment
const keepa = keepaResults.get(p.asin) ?? null;
const spApi = spApiResults.get(p.asin) ?? {
fbaFee: 5.0,
fbmFee: 1.5,
referralFeePercent: 15,
estimatedSalePrice: 0,
canSell: null,
sellabilityStatus: "unknown" as const,
sellabilityReason: "SP-API data missing",
};
const product: EnrichedProduct = {
record: p,
keepa,
spApi,
fetchedAt: new Date().toISOString(),
};
await setCache(p.asin, product);
enriched.push(product);
if (keepa) {
console.log(
` [enriched] ${p.asin} — price: $${keepa.currentPrice ?? "N/A"}, rank: ${keepa.salesRank ?? "N/A"}`,
);
} else {
console.log(` [no keepa] ${p.asin} — using spreadsheet data only`);
}
}
// Phase 7: LLM analysis in batches — only for enriched available products
console.log(
`\nAnalyzing ${enriched.length} products via LLM (batch size: ${LLM_BATCH_SIZE})...\n`,
);
const results: AnalysisResult[] = [];
for (let i = 0; i < enriched.length; i += LLM_BATCH_SIZE) {
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}...`);
// Wait between batches to avoid overwhelming LM Studio
if (i > 0) {
console.log(` Waiting 5s before next batch...`);
await new Promise((r) => setTimeout(r, 5000));
}
let verdicts;
try {
verdicts = await analyzeProducts(batch);
} catch {
console.warn(` LLM batch error, retrying after 10s...`);
await new Promise((r) => setTimeout(r, 10_000));
try {
verdicts = await analyzeProducts(batch);
} catch (retryErr) {
console.error(` LLM analysis failed: ${retryErr}`);
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() {
const { inputFile, outputFile } = parseArgs();
console.log("Connecting to Redis...");
await connectCache();
try {
// Phase 1: Read input file
console.log(`\nReading ${inputFile}...`);
const products = readProducts(inputFile);
if (products.length === 0) {
console.error("No valid products found in input file.");
process.exit(1);
}
const productChunks = chunkArray(products, INPUT_BATCH_SIZE);
const hasMultipleChunks = productChunks.length > 1;
const shouldWriteChunkFiles = hasMultipleChunks;
const resolvedBaseOutputPath = resolveBaseOutputPath(inputFile, outputFile);
const allResults: AnalysisResult[] = [];
if (hasMultipleChunks) {
console.log(
`\nLarge input detected (${products.length} products). Processing in chunks of ${INPUT_BATCH_SIZE}.`,
);
console.log(
`Chunk outputs will be written as numbered files using base: ${resolvedBaseOutputPath}`,
);
}
for (let chunkIndex = 0; chunkIndex < productChunks.length; chunkIndex++) {
const chunk = productChunks[chunkIndex]!;
console.log(
`\n=== Input chunk ${chunkIndex + 1}/${productChunks.length} (${chunk.length} products) ===`,
);
const chunkResults = await processProductChunk(chunk);
allResults.push(...chunkResults);
if (shouldWriteChunkFiles) {
const chunkOutputPath = buildChunkOutputPath(
resolvedBaseOutputPath,
chunkIndex,
);
writeResultsCsv(chunkResults, chunkOutputPath);
}
}
printResults(allResults);
if (!hasMultipleChunks && outputFile) {
writeResultsCsv(allResults, outputFile);
}
} finally {
await disconnectCache();
}
}
main().catch((err) => {
console.error("Fatal error:", err);
process.exit(1);
});
import { readProducts } from "./reader.ts";
import { fetchKeepaDataBatch } from "./keepa.ts";
import { fetchSellabilityBatch, fetchSpApiPricingAndFees } from "./sp-api.ts";
import { connectCache, getCache, setCache, disconnectCache } from "./cache.ts";
import { analyzeProducts } from "./llm.ts";
import { printResults, writeResultsCsv } from "./writer.ts";
import path from "node:path";
import type {
EnrichedProduct,
AnalysisResult,
KeepaData,
ProductRecord,
SellabilityInfo,
SpApiData,
} from "./types.ts";
const LLM_BATCH_SIZE = 5;
const INPUT_BATCH_SIZE = 50;
function parseArgs(): { inputFile: string; outputFile?: string } {
const args = process.argv.slice(2);
const inputFile = args.find((a) => !a.startsWith("--"));
const outIdx = args.indexOf("--out");
const outputFile = outIdx !== -1 ? args[outIdx + 1] : undefined;
if (!inputFile) {
console.error(
"Usage: bun run src/index.ts <input.csv|xlsx> [--out results.csv]",
);
process.exit(1);
}
return { inputFile, outputFile };
}
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 resolveBaseOutputPath(inputFile: string, outputFile?: string): string {
if (outputFile) return outputFile;
const parsedInput = path.parse(inputFile);
return path.join(parsedInput.dir, `${parsedInput.name}_results.xlsx`);
}
function buildChunkOutputPath(
baseOutputPath: string,
chunkIndex: number,
): string {
const parsed = path.parse(baseOutputPath);
const extension = parsed.ext || ".xlsx";
const chunkSuffix = String(chunkIndex + 1).padStart(3, "0");
return path.join(
parsed.dir,
`${parsed.name}_part_${chunkSuffix}${extension}`,
);
}
async function processProductChunk(
products: ProductRecord[],
): Promise<AnalysisResult[]> {
// Phase 2: Check cache for all ASINs in chunk
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`,
);
// Phase 3: Sellability gate — check uncached ASINs before anything else
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);
// Keep only ASINs that are explicitly available.
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`,
);
}
// Phase 4: Keepa batch fetch — only for available (uncached) ASINs
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}`);
}
}
// Phase 5: SP-API pricing + fees — only for available ASINs
console.log(
`\nFetching pricing & fees for ${availableProducts.length} ASINs...`,
);
const spApiResults = new Map<string, SpApiData>();
// Concurrency-limited pricing+fees fetches
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);
// Phase 6: Build enriched products
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;
}
// Cached products — already enriched
const cachedProduct = cached.get(p.asin);
if (cachedProduct) {
enriched.push(cachedProduct);
continue;
}
// Exclude products that are not explicitly available.
if (!availableAsins.has(p.asin)) {
continue;
}
// Available products — full enrichment
const keepa = keepaResults.get(p.asin) ?? null;
const spApi = spApiResults.get(p.asin) ?? {
fbaFee: 5.0,
fbmFee: 1.5,
referralFeePercent: 15,
estimatedSalePrice: 0,
canSell: null,
sellabilityStatus: "unknown" as const,
sellabilityReason: "SP-API data missing",
};
const product: EnrichedProduct = {
record: p,
keepa,
spApi,
fetchedAt: new Date().toISOString(),
};
await setCache(p.asin, product);
enriched.push(product);
if (keepa) {
console.log(
` [enriched] ${p.asin} — price: $${keepa.currentPrice ?? "N/A"}, rank: ${keepa.salesRank ?? "N/A"}`,
);
} else {
console.log(` [no keepa] ${p.asin} — using spreadsheet data only`);
}
}
// Phase 7: LLM analysis in batches — only for enriched available products
console.log(
`\nAnalyzing ${enriched.length} products via LLM (batch size: ${LLM_BATCH_SIZE})...\n`,
);
const results: AnalysisResult[] = [];
for (let i = 0; i < enriched.length; i += LLM_BATCH_SIZE) {
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}...`);
// Wait between batches to avoid overwhelming LM Studio
if (i > 0) {
console.log(` Waiting 5s before next batch...`);
await new Promise((r) => setTimeout(r, 5000));
}
let verdicts;
try {
verdicts = await analyzeProducts(batch);
} catch {
console.warn(` LLM batch error, retrying after 10s...`);
await new Promise((r) => setTimeout(r, 10_000));
try {
verdicts = await analyzeProducts(batch);
} catch (retryErr) {
console.error(` LLM analysis failed: ${retryErr}`);
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() {
const { inputFile, outputFile } = parseArgs();
console.log("Connecting to Redis...");
await connectCache();
try {
// Phase 1: Read input file
console.log(`\nReading ${inputFile}...`);
const products = readProducts(inputFile);
if (products.length === 0) {
console.error("No valid products found in input file.");
process.exit(1);
}
const productChunks = chunkArray(products, INPUT_BATCH_SIZE);
const hasMultipleChunks = productChunks.length > 1;
const shouldWriteChunkFiles = hasMultipleChunks;
const resolvedBaseOutputPath = resolveBaseOutputPath(inputFile, outputFile);
const allResults: AnalysisResult[] = [];
if (hasMultipleChunks) {
console.log(
`\nLarge input detected (${products.length} products). Processing in chunks of ${INPUT_BATCH_SIZE}.`,
);
console.log(
`Chunk outputs will be written as numbered files using base: ${resolvedBaseOutputPath}`,
);
}
for (let chunkIndex = 0; chunkIndex < productChunks.length; chunkIndex++) {
const chunk = productChunks[chunkIndex]!;
console.log(
`\n=== Input chunk ${chunkIndex + 1}/${productChunks.length} (${chunk.length} products) ===`,
);
const chunkResults = await processProductChunk(chunk);
allResults.push(...chunkResults);
if (shouldWriteChunkFiles) {
const chunkOutputPath = buildChunkOutputPath(
resolvedBaseOutputPath,
chunkIndex,
);
writeResultsCsv(chunkResults, chunkOutputPath);
}
}
printResults(allResults);
if (!hasMultipleChunks && outputFile) {
writeResultsCsv(allResults, outputFile);
}
} finally {
await disconnectCache();
}
}
main().catch((err) => {
console.error("Fatal error:", err);
process.exit(1);
});

View File

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

View File

@@ -1,353 +1,353 @@
import { config } from "./config.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.
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.
10. **Seller Eligibility (critical)**:
- If sellerEligibility.status is "restricted" or "not_available", return verdict = "SKIP".
- If sellerEligibility.status is "unknown", treat as elevated risk and only allow FBA/FBM with clearly strong economics + demand.
- If canSell is false, return "SKIP" regardless of margin.
Decision policy:
- Do not recommend products that cannot be listed by this seller account.
- Prioritize profitable + high-velocity + listable products.
- Use "SKIP" when data quality is poor or risk is high.
Return ONLY a raw JSON array (no markdown, no code fences, no explanation before or after). One verdict per product:
[{ "asin": "B...", "verdict": "FBA" | "FBM" | "SKIP", "confidence": 0-100, "reasoning": "..." }]
Keep each reasoning under 100 characters to stay within output limits and mention key blocker if skipped (e.g., restricted, low demand, thin margin).`;
export async function analyzeProducts(
products: EnrichedProduct[],
): Promise<LlmVerdict[]> {
try {
return await analyzeProductsInternal(products);
} catch (err) {
const msg = String(err);
if (products.length > 1 && msg.includes("Context size has been exceeded")) {
console.warn(
`LLM context exceeded for batch of ${products.length}, retrying one product at a time...`,
);
const fallback: LlmVerdict[] = [];
for (const product of products) {
try {
const single = await analyzeProductsInternal([product]);
fallback.push(
single[0] ?? {
asin: product.record.asin,
verdict: "SKIP",
confidence: 0,
reasoning: "LLM returned empty verdict",
},
);
} catch {
fallback.push({
asin: product.record.asin,
verdict: "SKIP",
confidence: 0,
reasoning: "LLM context overflow on single-item fallback",
});
}
}
return fallback;
}
throw err;
}
}
async function analyzeProductsInternal(
products: EnrichedProduct[],
): Promise<LlmVerdict[]> {
const productSummaries = products.map(summarizeForLlm);
const res = await fetch(`${config.llmUrl}/chat/completions`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: "Bearer lm-studio",
},
body: JSON.stringify({
model: config.llmModel,
messages: [
{ role: "system", content: SYSTEM_PROMPT },
{ role: "user", content: JSON.stringify(productSummaries, null, 2) },
],
temperature: 0.3,
max_tokens: 2048,
}),
});
if (!res.ok) {
throw new Error(`LLM API error ${res.status}: ${await res.text()}`);
}
const data = (await res.json()) as {
choices?: { message?: { content?: string } }[];
};
const content = data.choices?.[0]?.message?.content ?? "";
return parseVerdicts(content, products);
}
function summarizeForLlm(p: EnrichedProduct) {
const salePrice =
p.keepa?.currentPrice ??
p.record.sellingPriceFromSheet ??
p.spApi.estimatedSalePrice;
const referralFee = salePrice * (p.spApi.referralFeePercent / 100);
const fbaProfit =
salePrice - p.record.unitCost - p.spApi.fbaFee - referralFee;
const fbmProfit =
salePrice - p.record.unitCost - p.spApi.fbmFee - referralFee;
return {
asin: p.record.asin,
name: clampText(p.record.name, 80),
brand: p.record.brand,
category: clampText(
p.record.category ?? p.keepa?.categoryTree?.join(" > "),
60,
),
unitCost: p.record.unitCost,
currentPrice: salePrice,
priceRange90d: p.keepa
? {
min: p.keepa.minPrice90,
max: p.keepa.maxPrice90,
avg: p.keepa.avgPrice90,
}
: null,
salesRank: p.keepa?.salesRank ?? p.record.amazonRank,
salesRankAvg90d: p.keepa?.salesRankAvg90,
sellerCount: p.keepa?.sellerCount,
salesVelocity: {
monthlySold: p.keepa?.monthlySold,
salesRankDrops30: p.keepa?.salesRankDrops30,
salesRankDrops90: p.keepa?.salesRankDrops90,
},
spreadsheetEstimates: {
avgPrice90: p.record.avgPrice90FromSheet,
sellingPrice: p.record.sellingPriceFromSheet,
fbaNet: p.record.fbaNet,
grossProfit: p.record.grossProfit,
grossProfitPct: p.record.grossProfitPct,
netProfit: p.record.netProfitFromSheet,
roi: p.record.roiFromSheet,
},
supplier: clampText(p.record.supplier, 40),
moq: p.record.moq,
moqCost: p.record.moqCost,
totalQtyAvail: p.record.totalQtyAvail,
fees: {
fbaFee: p.spApi.fbaFee,
fbmFee: p.spApi.fbmFee,
referralFeePercent: p.spApi.referralFeePercent,
referralFee: Math.round(referralFee * 100) / 100,
},
sellerEligibility: {
canSell: p.spApi.canSell,
status: p.spApi.sellabilityStatus,
reason: clampText(p.spApi.sellabilityReason, 120),
},
estimatedProfit: {
fba: Math.round(fbaProfit * 100) / 100,
fbm: Math.round(fbmProfit * 100) / 100,
},
estimatedROI: {
fba:
p.record.unitCost > 0
? Math.round((fbaProfit / p.record.unitCost) * 100)
: null,
fbm:
p.record.unitCost > 0
? Math.round((fbmProfit / p.record.unitCost) * 100)
: null,
},
};
}
function clampText(value: unknown, maxLen: number): string | undefined {
if (value == null) return undefined;
const s = String(value).trim();
if (!s) return undefined;
return s.length > maxLen ? `${s.slice(0, maxLen - 1)}.` : s;
}
function cleanLlmJson(text: string): string {
// Remove ```json ... ``` or ``` ... ``` wrapping
const fenceMatch = text.match(/```(?:json)?\s*\n?([\s\S]*?)```/);
let cleaned = fenceMatch ? fenceMatch[1]!.trim() : text.trim();
// Strip any non-JSON wrapper text by taking the largest JSON-looking segment
const firstArray = cleaned.indexOf("[");
const firstObject = cleaned.indexOf("{");
const startCandidates = [firstArray, firstObject].filter((i) => i >= 0);
const start = startCandidates.length > 0 ? Math.min(...startCandidates) : -1;
const endArray = cleaned.lastIndexOf("]");
const endObject = cleaned.lastIndexOf("}");
const end = Math.max(endArray, endObject);
if (start >= 0 && end > start) {
cleaned = cleaned.slice(start, end + 1);
}
// Fix trailing comma-quote before closing brace: ,"} → "}
cleaned = cleaned.replace(/,"\s*}/g, '"}');
// Fix malformed comma-quote before a closing bracket/brace: ,"} or ,"]
cleaned = cleaned.replace(/,\s*"\s*([}\]])/g, "$1");
// Fix malformed quote-comma before a closing bracket/brace: ",} or ",]
cleaned = cleaned.replace(/"\s*,\s*([}\]])/g, '"$1');
// Fix trailing commas before ] or }
cleaned = cleaned.replace(/,\s*([}\]])/g, "$1");
return cleaned;
}
function parseVerdicts(
content: string,
products: EnrichedProduct[],
): LlmVerdict[] {
const cleaned = cleanLlmJson(content);
try {
const parsed = JSON.parse(cleaned) as unknown;
return alignVerdicts(products, normalizeVerdicts(parsed));
} catch (err) {
const salvaged = extractVerdictsLoosely(cleaned);
if (salvaged.length > 0) {
console.warn(
`LLM response was invalid JSON; salvaged ${salvaged.length} verdict(s) with loose parsing.`,
);
return alignVerdicts(products, salvaged);
}
console.warn(
"Failed to parse LLM response, marking all as ANALYSIS_FAILED",
);
console.warn("Raw LLM content:", content.slice(0, 500));
return products.map((p) => ({
asin: p.record.asin,
verdict: "SKIP" as const,
confidence: 0,
reasoning: `Analysis failed: could not parse LLM output`,
}));
}
}
function normalizeVerdicts(parsed: unknown): LlmVerdict[] {
const container =
parsed && typeof parsed === "object"
? (parsed as Record<string, unknown>)
: undefined;
const nested = container?.verdicts ?? container?.results;
const arr: unknown[] = Array.isArray(parsed)
? parsed
: Array.isArray(nested)
? nested
: [parsed];
return arr
.filter((v): v is Record<string, unknown> => !!v && typeof v === "object")
.map((v) => ({
asin: String(v.asin ?? "")
.trim()
.toUpperCase(),
verdict: (String(v.verdict).toUpperCase() === "FBA" ||
String(v.verdict).toUpperCase() === "FBM" ||
String(v.verdict).toUpperCase() === "SKIP"
? String(v.verdict).toUpperCase()
: "SKIP") as LlmVerdict["verdict"],
confidence: clampConfidence(
typeof v.confidence === "number"
? v.confidence
: Number(v.confidence ?? 0),
),
reasoning: String(v.reasoning ?? "No reasoning provided"),
}));
}
function extractVerdictsLoosely(text: string): LlmVerdict[] {
const objectMatches = text.match(/\{[\s\S]*?\}/g) ?? [];
const verdicts: LlmVerdict[] = [];
for (const chunk of objectMatches) {
const asin = extractField(chunk, /"asin"\s*:\s*"?([A-Z0-9]{10})"?/i) ?? "";
const verdictRaw =
extractField(chunk, /"verdict"\s*:\s*"?([A-Z]+)"?/i) ?? "SKIP";
const confidenceRaw =
extractField(chunk, /"confidence"\s*:\s*([0-9]+(?:\.[0-9]+)?)/i) ?? "0";
const reasoning =
extractField(chunk, /"reasoning"\s*:\s*"([\s\S]*?)"\s*(?:,|})/i) ??
"No reasoning provided";
const normalizedVerdict = verdictRaw.toUpperCase();
if (!asin) continue;
verdicts.push({
asin,
verdict: (normalizedVerdict === "FBA" ||
normalizedVerdict === "FBM" ||
normalizedVerdict === "SKIP"
? normalizedVerdict
: "SKIP") as LlmVerdict["verdict"],
confidence: clampConfidence(Number(confidenceRaw)),
reasoning,
});
}
return verdicts;
}
function extractField(text: string, regex: RegExp): string | undefined {
const match = text.match(regex);
return match?.[1]?.trim();
}
function clampConfidence(value: number): number {
if (!Number.isFinite(value)) return 0;
return Math.max(0, Math.min(100, Math.round(value)));
}
function alignVerdicts(
products: EnrichedProduct[],
verdicts: LlmVerdict[],
): LlmVerdict[] {
const byAsin = new Map<string, LlmVerdict>();
for (const verdict of verdicts) {
if (verdict.asin && !byAsin.has(verdict.asin)) {
byAsin.set(verdict.asin, verdict);
}
}
return products.map((product, index) => {
const asin = product.record.asin;
const byAsinVerdict = byAsin.get(asin);
if (byAsinVerdict) return { ...byAsinVerdict, asin };
const byIndexVerdict = verdicts[index];
if (byIndexVerdict) return { ...byIndexVerdict, asin };
return {
asin,
verdict: "SKIP" as const,
confidence: 0,
reasoning: "LLM returned no verdict for this product",
};
});
}
import { config } from "./config.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.
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.
10. **Seller Eligibility (critical)**:
- If sellerEligibility.status is "restricted" or "not_available", return verdict = "SKIP".
- If sellerEligibility.status is "unknown", treat as elevated risk and only allow FBA/FBM with clearly strong economics + demand.
- If canSell is false, return "SKIP" regardless of margin.
Decision policy:
- Do not recommend products that cannot be listed by this seller account.
- Prioritize profitable + high-velocity + listable products.
- Use "SKIP" when data quality is poor or risk is high.
Return ONLY a raw JSON array (no markdown, no code fences, no explanation before or after). One verdict per product:
[{ "asin": "B...", "verdict": "FBA" | "FBM" | "SKIP", "confidence": 0-100, "reasoning": "..." }]
Keep each reasoning under 100 characters to stay within output limits and mention key blocker if skipped (e.g., restricted, low demand, thin margin).`;
export async function analyzeProducts(
products: EnrichedProduct[],
): Promise<LlmVerdict[]> {
try {
return await analyzeProductsInternal(products);
} catch (err) {
const msg = String(err);
if (products.length > 1 && msg.includes("Context size has been exceeded")) {
console.warn(
`LLM context exceeded for batch of ${products.length}, retrying one product at a time...`,
);
const fallback: LlmVerdict[] = [];
for (const product of products) {
try {
const single = await analyzeProductsInternal([product]);
fallback.push(
single[0] ?? {
asin: product.record.asin,
verdict: "SKIP",
confidence: 0,
reasoning: "LLM returned empty verdict",
},
);
} catch {
fallback.push({
asin: product.record.asin,
verdict: "SKIP",
confidence: 0,
reasoning: "LLM context overflow on single-item fallback",
});
}
}
return fallback;
}
throw err;
}
}
async function analyzeProductsInternal(
products: EnrichedProduct[],
): Promise<LlmVerdict[]> {
const productSummaries = products.map(summarizeForLlm);
const res = await fetch(`${config.llmUrl}/chat/completions`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: "Bearer lm-studio",
},
body: JSON.stringify({
model: config.llmModel,
messages: [
{ role: "system", content: SYSTEM_PROMPT },
{ role: "user", content: JSON.stringify(productSummaries, null, 2) },
],
temperature: 0.3,
max_tokens: 2048,
}),
});
if (!res.ok) {
throw new Error(`LLM API error ${res.status}: ${await res.text()}`);
}
const data = (await res.json()) as {
choices?: { message?: { content?: string } }[];
};
const content = data.choices?.[0]?.message?.content ?? "";
return parseVerdicts(content, products);
}
function summarizeForLlm(p: EnrichedProduct) {
const salePrice =
p.keepa?.currentPrice ??
p.record.sellingPriceFromSheet ??
p.spApi.estimatedSalePrice;
const referralFee = salePrice * (p.spApi.referralFeePercent / 100);
const fbaProfit =
salePrice - p.record.unitCost - p.spApi.fbaFee - referralFee;
const fbmProfit =
salePrice - p.record.unitCost - p.spApi.fbmFee - referralFee;
return {
asin: p.record.asin,
name: clampText(p.record.name, 80),
brand: p.record.brand,
category: clampText(
p.record.category ?? p.keepa?.categoryTree?.join(" > "),
60,
),
unitCost: p.record.unitCost,
currentPrice: salePrice,
priceRange90d: p.keepa
? {
min: p.keepa.minPrice90,
max: p.keepa.maxPrice90,
avg: p.keepa.avgPrice90,
}
: null,
salesRank: p.keepa?.salesRank ?? p.record.amazonRank,
salesRankAvg90d: p.keepa?.salesRankAvg90,
sellerCount: p.keepa?.sellerCount,
salesVelocity: {
monthlySold: p.keepa?.monthlySold,
salesRankDrops30: p.keepa?.salesRankDrops30,
salesRankDrops90: p.keepa?.salesRankDrops90,
},
spreadsheetEstimates: {
avgPrice90: p.record.avgPrice90FromSheet,
sellingPrice: p.record.sellingPriceFromSheet,
fbaNet: p.record.fbaNet,
grossProfit: p.record.grossProfit,
grossProfitPct: p.record.grossProfitPct,
netProfit: p.record.netProfitFromSheet,
roi: p.record.roiFromSheet,
},
supplier: clampText(p.record.supplier, 40),
moq: p.record.moq,
moqCost: p.record.moqCost,
totalQtyAvail: p.record.totalQtyAvail,
fees: {
fbaFee: p.spApi.fbaFee,
fbmFee: p.spApi.fbmFee,
referralFeePercent: p.spApi.referralFeePercent,
referralFee: Math.round(referralFee * 100) / 100,
},
sellerEligibility: {
canSell: p.spApi.canSell,
status: p.spApi.sellabilityStatus,
reason: clampText(p.spApi.sellabilityReason, 120),
},
estimatedProfit: {
fba: Math.round(fbaProfit * 100) / 100,
fbm: Math.round(fbmProfit * 100) / 100,
},
estimatedROI: {
fba:
p.record.unitCost > 0
? Math.round((fbaProfit / p.record.unitCost) * 100)
: null,
fbm:
p.record.unitCost > 0
? Math.round((fbmProfit / p.record.unitCost) * 100)
: null,
},
};
}
function clampText(value: unknown, maxLen: number): string | undefined {
if (value == null) return undefined;
const s = String(value).trim();
if (!s) return undefined;
return s.length > maxLen ? `${s.slice(0, maxLen - 1)}.` : s;
}
function cleanLlmJson(text: string): string {
// Remove ```json ... ``` or ``` ... ``` wrapping
const fenceMatch = text.match(/```(?:json)?\s*\n?([\s\S]*?)```/);
let cleaned = fenceMatch ? fenceMatch[1]!.trim() : text.trim();
// Strip any non-JSON wrapper text by taking the largest JSON-looking segment
const firstArray = cleaned.indexOf("[");
const firstObject = cleaned.indexOf("{");
const startCandidates = [firstArray, firstObject].filter((i) => i >= 0);
const start = startCandidates.length > 0 ? Math.min(...startCandidates) : -1;
const endArray = cleaned.lastIndexOf("]");
const endObject = cleaned.lastIndexOf("}");
const end = Math.max(endArray, endObject);
if (start >= 0 && end > start) {
cleaned = cleaned.slice(start, end + 1);
}
// Fix trailing comma-quote before closing brace: ,"} → "}
cleaned = cleaned.replace(/,"\s*}/g, '"}');
// Fix malformed comma-quote before a closing bracket/brace: ,"} or ,"]
cleaned = cleaned.replace(/,\s*"\s*([}\]])/g, "$1");
// Fix malformed quote-comma before a closing bracket/brace: ",} or ",]
cleaned = cleaned.replace(/"\s*,\s*([}\]])/g, '"$1');
// Fix trailing commas before ] or }
cleaned = cleaned.replace(/,\s*([}\]])/g, "$1");
return cleaned;
}
function parseVerdicts(
content: string,
products: EnrichedProduct[],
): LlmVerdict[] {
const cleaned = cleanLlmJson(content);
try {
const parsed = JSON.parse(cleaned) as unknown;
return alignVerdicts(products, normalizeVerdicts(parsed));
} catch (err) {
const salvaged = extractVerdictsLoosely(cleaned);
if (salvaged.length > 0) {
console.warn(
`LLM response was invalid JSON; salvaged ${salvaged.length} verdict(s) with loose parsing.`,
);
return alignVerdicts(products, salvaged);
}
console.warn(
"Failed to parse LLM response, marking all as ANALYSIS_FAILED",
);
console.warn("Raw LLM content:", content.slice(0, 500));
return products.map((p) => ({
asin: p.record.asin,
verdict: "SKIP" as const,
confidence: 0,
reasoning: `Analysis failed: could not parse LLM output`,
}));
}
}
function normalizeVerdicts(parsed: unknown): LlmVerdict[] {
const container =
parsed && typeof parsed === "object"
? (parsed as Record<string, unknown>)
: undefined;
const nested = container?.verdicts ?? container?.results;
const arr: unknown[] = Array.isArray(parsed)
? parsed
: Array.isArray(nested)
? nested
: [parsed];
return arr
.filter((v): v is Record<string, unknown> => !!v && typeof v === "object")
.map((v) => ({
asin: String(v.asin ?? "")
.trim()
.toUpperCase(),
verdict: (String(v.verdict).toUpperCase() === "FBA" ||
String(v.verdict).toUpperCase() === "FBM" ||
String(v.verdict).toUpperCase() === "SKIP"
? String(v.verdict).toUpperCase()
: "SKIP") as LlmVerdict["verdict"],
confidence: clampConfidence(
typeof v.confidence === "number"
? v.confidence
: Number(v.confidence ?? 0),
),
reasoning: String(v.reasoning ?? "No reasoning provided"),
}));
}
function extractVerdictsLoosely(text: string): LlmVerdict[] {
const objectMatches = text.match(/\{[\s\S]*?\}/g) ?? [];
const verdicts: LlmVerdict[] = [];
for (const chunk of objectMatches) {
const asin = extractField(chunk, /"asin"\s*:\s*"?([A-Z0-9]{10})"?/i) ?? "";
const verdictRaw =
extractField(chunk, /"verdict"\s*:\s*"?([A-Z]+)"?/i) ?? "SKIP";
const confidenceRaw =
extractField(chunk, /"confidence"\s*:\s*([0-9]+(?:\.[0-9]+)?)/i) ?? "0";
const reasoning =
extractField(chunk, /"reasoning"\s*:\s*"([\s\S]*?)"\s*(?:,|})/i) ??
"No reasoning provided";
const normalizedVerdict = verdictRaw.toUpperCase();
if (!asin) continue;
verdicts.push({
asin,
verdict: (normalizedVerdict === "FBA" ||
normalizedVerdict === "FBM" ||
normalizedVerdict === "SKIP"
? normalizedVerdict
: "SKIP") as LlmVerdict["verdict"],
confidence: clampConfidence(Number(confidenceRaw)),
reasoning,
});
}
return verdicts;
}
function extractField(text: string, regex: RegExp): string | undefined {
const match = text.match(regex);
return match?.[1]?.trim();
}
function clampConfidence(value: number): number {
if (!Number.isFinite(value)) return 0;
return Math.max(0, Math.min(100, Math.round(value)));
}
function alignVerdicts(
products: EnrichedProduct[],
verdicts: LlmVerdict[],
): LlmVerdict[] {
const byAsin = new Map<string, LlmVerdict>();
for (const verdict of verdicts) {
if (verdict.asin && !byAsin.has(verdict.asin)) {
byAsin.set(verdict.asin, verdict);
}
}
return products.map((product, index) => {
const asin = product.record.asin;
const byAsinVerdict = byAsin.get(asin);
if (byAsinVerdict) return { ...byAsinVerdict, asin };
const byIndexVerdict = verdicts[index];
if (byIndexVerdict) return { ...byIndexVerdict, asin };
return {
asin,
verdict: "SKIP" as const,
confidence: 0,
reasoning: "LLM returned no verdict for this product",
};
});
}

View File

@@ -1,209 +1,209 @@
import * as XLSX from "xlsx";
import type { ProductRecord } from "./types.ts";
const ASIN_REGEX = /^B[0-9A-Z]{9}$/;
const COLUMN_CANDIDATES = {
asin: ["asin"],
name: ["name", "product name", "title", "product title"],
cost: ["unit cost", "cost", "unitcost", "unit_cost", "price", "buy cost"],
brand: ["brand"],
category: ["category"],
amazonRank: ["amazon rank", "amazonrank", "sales rank", "bsr"],
avgPrice90: [
"90 day average",
"90-day average",
"avg price 90d",
"avg 90 day",
"90d average",
],
sellingPrice: ["selling price", "sale price", "sell price"],
fbaNet: ["fba net", "fbanet", "fba_net"],
grossProfit: ["gross profit $", "gross profit", "grossprofit"],
grossProfitPct: ["gross profit %", "gross profit pct", "grossprofitpct"],
netProfit: ["net profit", "netprofit"],
roi: ["roi", "return on investment"],
moq: ["moq", "min order qty", "minimum order quantity"],
moqCost: ["moq cost", "moqcost", "moq_cost"],
totalQty: ["total qty avail", "totalqtyavail", "qty available", "quantity"],
link: ["link", "url", "source"],
asinLink: ["asin link", "amazon link", "asin url"],
sourceUrl: ["source url", "supplier url", "source link"],
supplier: ["supplier", "vendor"],
promoCouponCode: [
"promo/coupon code",
"promo coupon code",
"coupon code",
"promo code",
],
notes: ["notes", "note"],
leadDate: ["date", "lead date"],
} as const;
type ColumnKey = keyof typeof COLUMN_CANDIDATES;
type ColumnMap = Record<ColumnKey, string | undefined>;
export function readProducts(filePath: string): ProductRecord[] {
const workbook = XLSX.readFile(filePath);
const sheetName = workbook.SheetNames[0];
if (!sheetName) throw new Error("No sheets found in file");
const sheet = workbook.Sheets[sheetName]!;
const rows = XLSX.utils.sheet_to_json<Record<string, unknown>>(sheet);
if (rows.length === 0) throw new Error("File contains no data rows");
const headers = Object.keys(rows[0]!);
const columns = detectColumns(headers);
const asinColumn = columns.asin;
if (!asinColumn)
throw new Error(
`No ASIN column found. Available columns: ${headers.join(", ")}`,
);
logColumnDetection(headers, columns);
const knownCols = getKnownColumns(columns);
const products: ProductRecord[] = [];
for (const row of rows) {
const asin = parseAsin(row[asinColumn]);
if (!asin) continue;
const sourceUrl = getOptionalString(row, columns.sourceUrl);
const asinLink = getOptionalString(row, columns.asinLink);
const link = sourceUrl ?? asinLink ?? getOptionalString(row, columns.link);
const extra = getExtraFields(row, headers, knownCols);
const netProfitFromSheet = getOptionalNumber(row, columns.netProfit);
const roiFromSheet = getOptionalNumber(row, columns.roi);
products.push({
asin,
name: getOptionalString(row, columns.name) ?? "",
unitCost: getOptionalNumber(row, columns.cost) ?? 0,
brand: getOptionalString(row, columns.brand),
category: getOptionalString(row, columns.category),
amazonRank: getOptionalNumber(row, columns.amazonRank),
avgPrice90FromSheet: getOptionalNumber(row, columns.avgPrice90),
sellingPriceFromSheet: getOptionalNumber(row, columns.sellingPrice),
fbaNet: getOptionalNumber(row, columns.fbaNet),
grossProfit: getOptionalNumber(row, columns.grossProfit) ?? netProfitFromSheet,
grossProfitPct:
getOptionalNumber(row, columns.grossProfitPct) ?? roiFromSheet,
netProfitFromSheet,
roiFromSheet,
moq: getOptionalNumber(row, columns.moq),
moqCost: getOptionalNumber(row, columns.moqCost),
totalQtyAvail: getOptionalNumber(row, columns.totalQty),
link,
asinLink,
sourceUrl,
supplier: getOptionalString(row, columns.supplier),
promoCouponCode: getOptionalString(row, columns.promoCouponCode),
notes: getOptionalString(row, columns.notes),
leadDate: getOptionalString(row, columns.leadDate),
...extra,
});
}
console.log(`Read ${products.length} valid products from ${filePath}`);
return products;
}
function detectColumns(headers: string[]): ColumnMap {
const columns = {} as ColumnMap;
for (const key of Object.keys(COLUMN_CANDIDATES) as ColumnKey[]) {
columns[key] = findColumn(headers, [...COLUMN_CANDIDATES[key]]);
}
return columns;
}
function logColumnDetection(headers: string[], columns: ColumnMap): void {
console.log(`Found columns: ${headers.join(", ")}`);
console.log(
`Detected columns -> ASIN: ${columns.asin ?? "n/a"}, Name: ${columns.name ?? "n/a"}, Cost: ${columns.cost ?? "n/a"}, 90d Avg: ${columns.avgPrice90 ?? "n/a"}, Selling Price: ${columns.sellingPrice ?? "n/a"}, Net Profit: ${columns.netProfit ?? columns.grossProfit ?? "n/a"}, ROI: ${columns.roi ?? columns.grossProfitPct ?? "n/a"}, Source URL: ${columns.sourceUrl ?? "n/a"}, ASIN Link: ${columns.asinLink ?? "n/a"}`,
);
}
function getKnownColumns(columns: ColumnMap): Set<string> {
return new Set(Object.values(columns).filter((column): column is string => !!column));
}
function parseAsin(value: unknown): string | undefined {
const asin = String(value ?? "")
.trim()
.toUpperCase();
if (!asin || !ASIN_REGEX.test(asin)) {
console.warn(`Skipping invalid ASIN: "${asin}"`);
return undefined;
}
return asin;
}
function getOptionalString(
row: Record<string, unknown>,
column: string | undefined,
): string | undefined {
if (!column) return undefined;
return normalizeOptionalString(row[column]);
}
function getOptionalNumber(
row: Record<string, unknown>,
column: string | undefined,
): number | undefined {
if (!column) return undefined;
return parseOptionalNumber(row[column]);
}
function getExtraFields(
row: Record<string, unknown>,
headers: string[],
knownCols: Set<string>,
): Record<string, unknown> {
const extra: Record<string, unknown> = {};
for (const header of headers) {
if (!knownCols.has(header)) extra[header] = row[header];
}
return extra;
}
function findColumn(
headers: string[],
candidates: string[],
): string | undefined {
const normalizedCandidates = new Set(candidates.map(normalizeHeader));
for (const header of headers) {
if (normalizedCandidates.has(normalizeHeader(header))) {
return header;
}
}
return 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;
const s = String(value).trim();
return s.length > 0 ? s : 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;
}
import * as XLSX from "xlsx";
import type { ProductRecord } from "./types.ts";
const ASIN_REGEX = /^B[0-9A-Z]{9}$/;
const COLUMN_CANDIDATES = {
asin: ["asin"],
name: ["name", "product name", "title", "product title"],
cost: ["unit cost", "cost", "unitcost", "unit_cost", "price", "buy cost"],
brand: ["brand"],
category: ["category"],
amazonRank: ["amazon rank", "amazonrank", "sales rank", "bsr"],
avgPrice90: [
"90 day average",
"90-day average",
"avg price 90d",
"avg 90 day",
"90d average",
],
sellingPrice: ["selling price", "sale price", "sell price"],
fbaNet: ["fba net", "fbanet", "fba_net"],
grossProfit: ["gross profit $", "gross profit", "grossprofit"],
grossProfitPct: ["gross profit %", "gross profit pct", "grossprofitpct"],
netProfit: ["net profit", "netprofit"],
roi: ["roi", "return on investment"],
moq: ["moq", "min order qty", "minimum order quantity"],
moqCost: ["moq cost", "moqcost", "moq_cost"],
totalQty: ["total qty avail", "totalqtyavail", "qty available", "quantity"],
link: ["link", "url", "source"],
asinLink: ["asin link", "amazon link", "asin url"],
sourceUrl: ["source url", "supplier url", "source link"],
supplier: ["supplier", "vendor"],
promoCouponCode: [
"promo/coupon code",
"promo coupon code",
"coupon code",
"promo code",
],
notes: ["notes", "note"],
leadDate: ["date", "lead date"],
} as const;
type ColumnKey = keyof typeof COLUMN_CANDIDATES;
type ColumnMap = Record<ColumnKey, string | undefined>;
export function readProducts(filePath: string): ProductRecord[] {
const workbook = XLSX.readFile(filePath);
const sheetName = workbook.SheetNames[0];
if (!sheetName) throw new Error("No sheets found in file");
const sheet = workbook.Sheets[sheetName]!;
const rows = XLSX.utils.sheet_to_json<Record<string, unknown>>(sheet);
if (rows.length === 0) throw new Error("File contains no data rows");
const headers = Object.keys(rows[0]!);
const columns = detectColumns(headers);
const asinColumn = columns.asin;
if (!asinColumn)
throw new Error(
`No ASIN column found. Available columns: ${headers.join(", ")}`,
);
logColumnDetection(headers, columns);
const knownCols = getKnownColumns(columns);
const products: ProductRecord[] = [];
for (const row of rows) {
const asin = parseAsin(row[asinColumn]);
if (!asin) continue;
const sourceUrl = getOptionalString(row, columns.sourceUrl);
const asinLink = getOptionalString(row, columns.asinLink);
const link = sourceUrl ?? asinLink ?? getOptionalString(row, columns.link);
const extra = getExtraFields(row, headers, knownCols);
const netProfitFromSheet = getOptionalNumber(row, columns.netProfit);
const roiFromSheet = getOptionalNumber(row, columns.roi);
products.push({
asin,
name: getOptionalString(row, columns.name) ?? "",
unitCost: getOptionalNumber(row, columns.cost) ?? 0,
brand: getOptionalString(row, columns.brand),
category: getOptionalString(row, columns.category),
amazonRank: getOptionalNumber(row, columns.amazonRank),
avgPrice90FromSheet: getOptionalNumber(row, columns.avgPrice90),
sellingPriceFromSheet: getOptionalNumber(row, columns.sellingPrice),
fbaNet: getOptionalNumber(row, columns.fbaNet),
grossProfit: getOptionalNumber(row, columns.grossProfit) ?? netProfitFromSheet,
grossProfitPct:
getOptionalNumber(row, columns.grossProfitPct) ?? roiFromSheet,
netProfitFromSheet,
roiFromSheet,
moq: getOptionalNumber(row, columns.moq),
moqCost: getOptionalNumber(row, columns.moqCost),
totalQtyAvail: getOptionalNumber(row, columns.totalQty),
link,
asinLink,
sourceUrl,
supplier: getOptionalString(row, columns.supplier),
promoCouponCode: getOptionalString(row, columns.promoCouponCode),
notes: getOptionalString(row, columns.notes),
leadDate: getOptionalString(row, columns.leadDate),
...extra,
});
}
console.log(`Read ${products.length} valid products from ${filePath}`);
return products;
}
function detectColumns(headers: string[]): ColumnMap {
const columns = {} as ColumnMap;
for (const key of Object.keys(COLUMN_CANDIDATES) as ColumnKey[]) {
columns[key] = findColumn(headers, [...COLUMN_CANDIDATES[key]]);
}
return columns;
}
function logColumnDetection(headers: string[], columns: ColumnMap): void {
console.log(`Found columns: ${headers.join(", ")}`);
console.log(
`Detected columns -> ASIN: ${columns.asin ?? "n/a"}, Name: ${columns.name ?? "n/a"}, Cost: ${columns.cost ?? "n/a"}, 90d Avg: ${columns.avgPrice90 ?? "n/a"}, Selling Price: ${columns.sellingPrice ?? "n/a"}, Net Profit: ${columns.netProfit ?? columns.grossProfit ?? "n/a"}, ROI: ${columns.roi ?? columns.grossProfitPct ?? "n/a"}, Source URL: ${columns.sourceUrl ?? "n/a"}, ASIN Link: ${columns.asinLink ?? "n/a"}`,
);
}
function getKnownColumns(columns: ColumnMap): Set<string> {
return new Set(Object.values(columns).filter((column): column is string => !!column));
}
function parseAsin(value: unknown): string | undefined {
const asin = String(value ?? "")
.trim()
.toUpperCase();
if (!asin || !ASIN_REGEX.test(asin)) {
console.warn(`Skipping invalid ASIN: "${asin}"`);
return undefined;
}
return asin;
}
function getOptionalString(
row: Record<string, unknown>,
column: string | undefined,
): string | undefined {
if (!column) return undefined;
return normalizeOptionalString(row[column]);
}
function getOptionalNumber(
row: Record<string, unknown>,
column: string | undefined,
): number | undefined {
if (!column) return undefined;
return parseOptionalNumber(row[column]);
}
function getExtraFields(
row: Record<string, unknown>,
headers: string[],
knownCols: Set<string>,
): Record<string, unknown> {
const extra: Record<string, unknown> = {};
for (const header of headers) {
if (!knownCols.has(header)) extra[header] = row[header];
}
return extra;
}
function findColumn(
headers: string[],
candidates: string[],
): string | undefined {
const normalizedCandidates = new Set(candidates.map(normalizeHeader));
for (const header of headers) {
if (normalizedCandidates.has(normalizeHeader(header))) {
return header;
}
}
return 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;
const s = String(value).trim();
return s.length > 0 ? s : 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;
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,48 +1,48 @@
import { testSpApiConnectivity, testSpApiSellability } from "./sp-api.ts";
function parseArgs(): { asin?: string; sellabilityMode: boolean } {
const args = process.argv.slice(2);
const sellabilityMode = args.includes("--sellability");
const asin = args.find((arg) => !arg.startsWith("--"));
return { asin, sellabilityMode };
}
async function main() {
const { asin, sellabilityMode } = parseArgs();
console.log("Running SP-API connectivity test...");
if (sellabilityMode) {
if (!asin) {
console.error("Usage: bun run src/sp-test.ts --sellability <ASIN>");
process.exit(1);
}
console.log(`Running sellability check for ASIN: ${asin}`);
const sellability = await testSpApiSellability(asin);
if (!sellability.ok) {
console.error(`SP-API sellability test failed: ${sellability.message}`);
process.exit(1);
}
console.log(`SP-API sellability test passed: ${sellability.message}`);
return;
}
if (asin) {
console.log(`Including pricing connectivity check for ASIN: ${asin}`);
}
const result = await testSpApiConnectivity(asin);
if (!result.ok) {
console.error(`SP-API test failed: ${result.message}`);
process.exit(1);
}
console.log(`SP-API test passed: ${result.message}`);
}
main().catch((err) => {
console.error(`SP-API test crashed: ${String(err)}`);
process.exit(1);
});
import { testSpApiConnectivity, testSpApiSellability } from "./sp-api.ts";
function parseArgs(): { asin?: string; sellabilityMode: boolean } {
const args = process.argv.slice(2);
const sellabilityMode = args.includes("--sellability");
const asin = args.find((arg) => !arg.startsWith("--"));
return { asin, sellabilityMode };
}
async function main() {
const { asin, sellabilityMode } = parseArgs();
console.log("Running SP-API connectivity test...");
if (sellabilityMode) {
if (!asin) {
console.error("Usage: bun run src/sp-test.ts --sellability <ASIN>");
process.exit(1);
}
console.log(`Running sellability check for ASIN: ${asin}`);
const sellability = await testSpApiSellability(asin);
if (!sellability.ok) {
console.error(`SP-API sellability test failed: ${sellability.message}`);
process.exit(1);
}
console.log(`SP-API sellability test passed: ${sellability.message}`);
return;
}
if (asin) {
console.log(`Including pricing connectivity check for ASIN: ${asin}`);
}
const result = await testSpApiConnectivity(asin);
if (!result.ok) {
console.error(`SP-API test failed: ${result.message}`);
process.exit(1);
}
console.log(`SP-API test passed: ${result.message}`);
}
main().catch((err) => {
console.error(`SP-API test crashed: ${String(err)}`);
process.exit(1);
});

View File

@@ -1,75 +1,75 @@
export interface ProductRecord {
asin: string;
name: string;
unitCost: number;
brand?: string;
category?: string;
amazonRank?: number;
avgPrice90FromSheet?: number;
sellingPriceFromSheet?: number;
fbaNet?: number;
grossProfit?: number;
grossProfitPct?: number;
netProfitFromSheet?: number;
roiFromSheet?: number;
moq?: number;
moqCost?: number;
totalQtyAvail?: number;
link?: string;
asinLink?: string;
sourceUrl?: string;
supplier?: string;
promoCouponCode?: string;
notes?: string;
leadDate?: string;
[key: string]: unknown;
}
export interface KeepaData {
currentPrice: number | null;
avgPrice90: number | null;
minPrice90: number | null;
maxPrice90: number | null;
salesRank: number | null;
salesRankAvg90: number | null;
salesRankDrops30: number | null;
salesRankDrops90: number | null;
sellerCount: number | null;
buyBoxSeller: string | null;
buyBoxPrice: number | null;
monthlySold: number | null;
categoryTree: string[];
}
export type SellabilityInfo = {
canSell: boolean | null;
sellabilityStatus: "available" | "restricted" | "not_available" | "unknown";
sellabilityReason?: string;
};
export interface SpApiData extends SellabilityInfo {
fbaFee: number;
fbmFee: number;
referralFeePercent: number;
estimatedSalePrice: number;
}
export interface EnrichedProduct {
record: ProductRecord;
keepa: KeepaData | null;
spApi: SpApiData;
fetchedAt: string;
}
export interface LlmVerdict {
asin: string;
verdict: "FBA" | "FBM" | "SKIP";
confidence: number;
reasoning: string;
}
export interface AnalysisResult {
product: EnrichedProduct;
verdict: LlmVerdict;
}
export interface ProductRecord {
asin: string;
name: string;
unitCost: number;
brand?: string;
category?: string;
amazonRank?: number;
avgPrice90FromSheet?: number;
sellingPriceFromSheet?: number;
fbaNet?: number;
grossProfit?: number;
grossProfitPct?: number;
netProfitFromSheet?: number;
roiFromSheet?: number;
moq?: number;
moqCost?: number;
totalQtyAvail?: number;
link?: string;
asinLink?: string;
sourceUrl?: string;
supplier?: string;
promoCouponCode?: string;
notes?: string;
leadDate?: string;
[key: string]: unknown;
}
export interface KeepaData {
currentPrice: number | null;
avgPrice90: number | null;
minPrice90: number | null;
maxPrice90: number | null;
salesRank: number | null;
salesRankAvg90: number | null;
salesRankDrops30: number | null;
salesRankDrops90: number | null;
sellerCount: number | null;
buyBoxSeller: string | null;
buyBoxPrice: number | null;
monthlySold: number | null;
categoryTree: string[];
}
export type SellabilityInfo = {
canSell: boolean | null;
sellabilityStatus: "available" | "restricted" | "not_available" | "unknown";
sellabilityReason?: string;
};
export interface SpApiData extends SellabilityInfo {
fbaFee: number;
fbmFee: number;
referralFeePercent: number;
estimatedSalePrice: number;
}
export interface EnrichedProduct {
record: ProductRecord;
keepa: KeepaData | null;
spApi: SpApiData;
fetchedAt: string;
}
export interface LlmVerdict {
asin: string;
verdict: "FBA" | "FBM" | "SKIP";
confidence: number;
reasoning: string;
}
export interface AnalysisResult {
product: EnrichedProduct;
verdict: LlmVerdict;
}

View File

@@ -1,159 +1,159 @@
import * as XLSX from "xlsx";
import type { AnalysisResult } from "./types.ts";
function buildRow(r: AnalysisResult) {
const price =
r.product.keepa?.currentPrice ??
r.product.record.sellingPriceFromSheet ??
r.product.spApi.estimatedSalePrice;
const rank = r.product.keepa?.salesRank ?? r.product.record.amazonRank;
return {
ASIN: r.product.record.asin,
Name: r.product.record.name,
Brand: r.product.record.brand ?? "",
Category:
r.product.record.category ??
r.product.keepa?.categoryTree?.join(" > ") ??
"",
"Unit Cost": r.product.record.unitCost,
"Current Price": price ?? "",
"Avg Price 90d": r.product.keepa?.avgPrice90 ?? "",
"Avg Price 90d (sheet)": r.product.record.avgPrice90FromSheet ?? "",
"Selling Price (sheet)": r.product.record.sellingPriceFromSheet ?? "",
"Sales Rank": rank ?? "",
"Rank Avg 90d": r.product.keepa?.salesRankAvg90 ?? "",
Sellers: r.product.keepa?.sellerCount ?? "",
"Monthly Sold": r.product.keepa?.monthlySold ?? "",
"Rank Drops 30d": r.product.keepa?.salesRankDrops30 ?? "",
"Rank Drops 90d": r.product.keepa?.salesRankDrops90 ?? "",
"FBA Net (sheet)": r.product.record.fbaNet ?? "",
"Gross Profit $": r.product.record.grossProfit ?? "",
"Gross Profit %": r.product.record.grossProfitPct ?? "",
"Net Profit (sheet)": r.product.record.netProfitFromSheet ?? "",
"ROI (sheet)": r.product.record.roiFromSheet ?? "",
MOQ: r.product.record.moq ?? "",
"MOQ Cost": r.product.record.moqCost ?? "",
"Qty Available": r.product.record.totalQtyAvail ?? "",
Supplier: r.product.record.supplier ?? "",
"Source URL": r.product.record.sourceUrl ?? "",
"ASIN Link": r.product.record.asinLink ?? "",
"Promo/Coupon Code": r.product.record.promoCouponCode ?? "",
Notes: r.product.record.notes ?? "",
"Lead Date": r.product.record.leadDate ?? "",
"FBA Fee": r.product.spApi.fbaFee,
"FBM Fee": r.product.spApi.fbmFee,
"Referral %": r.product.spApi.referralFeePercent,
"Can Sell":
r.product.spApi.canSell == null
? "unknown"
: r.product.spApi.canSell
? "yes"
: "no",
Sellability: r.product.spApi.sellabilityStatus,
"Sellability Reason": r.product.spApi.sellabilityReason ?? "",
Verdict: r.verdict.verdict,
Confidence: r.verdict.confidence,
Reasoning: r.verdict.reasoning,
};
}
export function printResults(results: AnalysisResult[]): void {
const rows = results
.filter((r) => r.verdict.verdict === "FBA" || r.verdict.verdict === "FBM")
.map((r) => {
const sellingPrice =
r.product.keepa?.currentPrice ??
r.product.record.sellingPriceFromSheet ??
r.product.spApi.estimatedSalePrice;
const referralFee =
sellingPrice != null
? sellingPrice * (r.product.spApi.referralFeePercent / 100)
: null;
const fulfillmentFee =
r.verdict.verdict === "FBA"
? r.product.spApi.fbaFee
: r.product.spApi.fbmFee;
const netProfit =
sellingPrice != null
? Math.round(
(sellingPrice -
r.product.record.unitCost -
fulfillmentFee -
(referralFee ?? 0)) *
100,
) / 100
: "";
return {
ASIN: r.product.record.asin,
Name: r.product.record.name.slice(0, 40),
Category: String(
r.product.record.category ??
r.product.keepa?.categoryTree?.join(" > ") ??
"",
).slice(0, 20),
"Unit Cost": r.product.record.unitCost,
"Selling Price": sellingPrice ?? "",
"Net Profit": netProfit,
"Monthly Sold": r.product.keepa?.monthlySold ?? "",
"Sold 90 Day": r.product.keepa?.salesRankDrops90 ?? "",
"Can Sell":
r.product.spApi.canSell == null
? "unknown"
: r.product.spApi.canSell
? "yes"
: "no",
Sellability: r.product.spApi.sellabilityStatus,
"Sellability Reason": String(
r.product.spApi.sellabilityReason ?? "",
).slice(0, 60),
Confidence: r.verdict.confidence,
Reasoning: r.verdict.reasoning.slice(0, 60),
};
});
console.log("\n=== Analysis Results ===\n");
if (rows.length === 0) {
console.log("No FBA/FBM leads found.");
} else {
console.table(rows);
}
const summary = {
FBA: results.filter((r) => r.verdict.verdict === "FBA").length,
FBM: results.filter((r) => r.verdict.verdict === "FBM").length,
SKIP: results.filter((r) => r.verdict.verdict === "SKIP").length,
Available: results.filter(
(r) => r.product.spApi.sellabilityStatus === "available",
).length,
Restricted: results.filter(
(r) => r.product.spApi.sellabilityStatus === "restricted",
).length,
NotAvailable: results.filter(
(r) => r.product.spApi.sellabilityStatus === "not_available",
).length,
Unknown: results.filter(
(r) => r.product.spApi.sellabilityStatus === "unknown",
).length,
};
console.log(
`\nSummary: ${summary.FBA} FBA | ${summary.FBM} FBM | ${summary.SKIP} SKIP out of ${results.length} products\n`,
);
console.log(
`Sellability: ${summary.Available} available | ${summary.Restricted} restricted | ${summary.NotAvailable} not_available | ${summary.Unknown} unknown\n`,
);
}
export function writeResultsCsv(
results: AnalysisResult[],
outputPath: string,
): void {
const rows = results.map(buildRow);
const ws = XLSX.utils.json_to_sheet(rows);
const wb = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(wb, ws, "Results");
XLSX.writeFile(wb, outputPath);
console.log(`Results written to ${outputPath}`);
}
import * as XLSX from "xlsx";
import type { AnalysisResult } from "./types.ts";
function buildRow(r: AnalysisResult) {
const price =
r.product.keepa?.currentPrice ??
r.product.record.sellingPriceFromSheet ??
r.product.spApi.estimatedSalePrice;
const rank = r.product.keepa?.salesRank ?? r.product.record.amazonRank;
return {
ASIN: r.product.record.asin,
Name: r.product.record.name,
Brand: r.product.record.brand ?? "",
Category:
r.product.record.category ??
r.product.keepa?.categoryTree?.join(" > ") ??
"",
"Unit Cost": r.product.record.unitCost,
"Current Price": price ?? "",
"Avg Price 90d": r.product.keepa?.avgPrice90 ?? "",
"Avg Price 90d (sheet)": r.product.record.avgPrice90FromSheet ?? "",
"Selling Price (sheet)": r.product.record.sellingPriceFromSheet ?? "",
"Sales Rank": rank ?? "",
"Rank Avg 90d": r.product.keepa?.salesRankAvg90 ?? "",
Sellers: r.product.keepa?.sellerCount ?? "",
"Monthly Sold": r.product.keepa?.monthlySold ?? "",
"Rank Drops 30d": r.product.keepa?.salesRankDrops30 ?? "",
"Rank Drops 90d": r.product.keepa?.salesRankDrops90 ?? "",
"FBA Net (sheet)": r.product.record.fbaNet ?? "",
"Gross Profit $": r.product.record.grossProfit ?? "",
"Gross Profit %": r.product.record.grossProfitPct ?? "",
"Net Profit (sheet)": r.product.record.netProfitFromSheet ?? "",
"ROI (sheet)": r.product.record.roiFromSheet ?? "",
MOQ: r.product.record.moq ?? "",
"MOQ Cost": r.product.record.moqCost ?? "",
"Qty Available": r.product.record.totalQtyAvail ?? "",
Supplier: r.product.record.supplier ?? "",
"Source URL": r.product.record.sourceUrl ?? "",
"ASIN Link": r.product.record.asinLink ?? "",
"Promo/Coupon Code": r.product.record.promoCouponCode ?? "",
Notes: r.product.record.notes ?? "",
"Lead Date": r.product.record.leadDate ?? "",
"FBA Fee": r.product.spApi.fbaFee,
"FBM Fee": r.product.spApi.fbmFee,
"Referral %": r.product.spApi.referralFeePercent,
"Can Sell":
r.product.spApi.canSell == null
? "unknown"
: r.product.spApi.canSell
? "yes"
: "no",
Sellability: r.product.spApi.sellabilityStatus,
"Sellability Reason": r.product.spApi.sellabilityReason ?? "",
Verdict: r.verdict.verdict,
Confidence: r.verdict.confidence,
Reasoning: r.verdict.reasoning,
};
}
export function printResults(results: AnalysisResult[]): void {
const rows = results
.filter((r) => r.verdict.verdict === "FBA" || r.verdict.verdict === "FBM")
.map((r) => {
const sellingPrice =
r.product.keepa?.currentPrice ??
r.product.record.sellingPriceFromSheet ??
r.product.spApi.estimatedSalePrice;
const referralFee =
sellingPrice != null
? sellingPrice * (r.product.spApi.referralFeePercent / 100)
: null;
const fulfillmentFee =
r.verdict.verdict === "FBA"
? r.product.spApi.fbaFee
: r.product.spApi.fbmFee;
const netProfit =
sellingPrice != null
? Math.round(
(sellingPrice -
r.product.record.unitCost -
fulfillmentFee -
(referralFee ?? 0)) *
100,
) / 100
: "";
return {
ASIN: r.product.record.asin,
Name: r.product.record.name.slice(0, 40),
Category: String(
r.product.record.category ??
r.product.keepa?.categoryTree?.join(" > ") ??
"",
).slice(0, 20),
"Unit Cost": r.product.record.unitCost,
"Selling Price": sellingPrice ?? "",
"Net Profit": netProfit,
"Monthly Sold": r.product.keepa?.monthlySold ?? "",
"Sold 90 Day": r.product.keepa?.salesRankDrops90 ?? "",
"Can Sell":
r.product.spApi.canSell == null
? "unknown"
: r.product.spApi.canSell
? "yes"
: "no",
Sellability: r.product.spApi.sellabilityStatus,
"Sellability Reason": String(
r.product.spApi.sellabilityReason ?? "",
).slice(0, 60),
Confidence: r.verdict.confidence,
Reasoning: r.verdict.reasoning.slice(0, 60),
};
});
console.log("\n=== Analysis Results ===\n");
if (rows.length === 0) {
console.log("No FBA/FBM leads found.");
} else {
console.table(rows);
}
const summary = {
FBA: results.filter((r) => r.verdict.verdict === "FBA").length,
FBM: results.filter((r) => r.verdict.verdict === "FBM").length,
SKIP: results.filter((r) => r.verdict.verdict === "SKIP").length,
Available: results.filter(
(r) => r.product.spApi.sellabilityStatus === "available",
).length,
Restricted: results.filter(
(r) => r.product.spApi.sellabilityStatus === "restricted",
).length,
NotAvailable: results.filter(
(r) => r.product.spApi.sellabilityStatus === "not_available",
).length,
Unknown: results.filter(
(r) => r.product.spApi.sellabilityStatus === "unknown",
).length,
};
console.log(
`\nSummary: ${summary.FBA} FBA | ${summary.FBM} FBM | ${summary.SKIP} SKIP out of ${results.length} products\n`,
);
console.log(
`Sellability: ${summary.Available} available | ${summary.Restricted} restricted | ${summary.NotAvailable} not_available | ${summary.Unknown} unknown\n`,
);
}
export function writeResultsCsv(
results: AnalysisResult[],
outputPath: string,
): void {
const rows = results.map(buildRow);
const ws = XLSX.utils.json_to_sheet(rows);
const wb = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(wb, ws, "Results");
XLSX.writeFile(wb, outputPath);
console.log(`Results written to ${outputPath}`);
}