feat: Integrate Amazon SP-API for product sellability and pricing

- Added `amazon-sp-api` dependency to package.json.
- Enhanced configuration to include SP-API credentials and settings.
- Implemented SP-API client initialization and error handling in sp-api.ts.
- Developed functions to fetch product sellability and pricing data.
- Updated main processing logic in index.ts to incorporate sellability checks before fetching pricing.
- Modified LLM analysis to account for sellability status and reasons.
- Created a new sp-test.ts script for testing SP-API connectivity and sellability.
- Updated types.ts to define SellabilityInfo and extend SpApiData.
- Enhanced result reporting in writer.ts to include sellability information.
This commit is contained in:
Victor Noguera
2026-04-08 21:33:43 -04:00
parent 2e626ce1f3
commit 53901e4dde
11 changed files with 1133 additions and 53 deletions

View File

@@ -1,10 +1,16 @@
import { readProducts } from "./reader.ts";
import { fetchKeepaDataBatch } from "./keepa.ts";
import { fetchSpApiData } from "./sp-api.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 type { EnrichedProduct, AnalysisResult, KeepaData, ProductRecord } from "./types.ts";
import type {
EnrichedProduct,
AnalysisResult,
KeepaData,
ProductRecord,
SellabilityInfo,
} from "./types.ts";
const LLM_BATCH_SIZE = 5;
@@ -15,7 +21,9 @@ function parseArgs(): { inputFile: string; outputFile?: string } {
const outputFile = outIdx !== -1 ? args[outIdx + 1] : undefined;
if (!inputFile) {
console.error("Usage: bun run src/index.ts <input.csv|xlsx> [--out results.csv]");
console.error(
"Usage: bun run src/index.ts <input.csv|xlsx> [--out results.csv]",
);
process.exit(1);
}
return { inputFile, outputFile };
@@ -27,6 +35,7 @@ async function main() {
console.log("Connecting to Redis...");
await connectCache();
// Phase 1: Read input file
console.log(`\nReading ${inputFile}...`);
const products = readProducts(inputFile);
@@ -35,7 +44,7 @@ async function main() {
process.exit(1);
}
// Phase 1: Check cache for all ASINs
// Phase 2: Check cache for all ASINs
console.log(`\nChecking cache for ${products.length} products...`);
const cached = new Map<string, EnrichedProduct>();
const uncachedProducts: ProductRecord[] = [];
@@ -51,35 +60,156 @@ async function main() {
}
console.log(`${cached.size} cached, ${uncachedProducts.length} to fetch`);
// Phase 2: Batch fetch from Keepa (all uncached ASINs in one request if ≤100)
let keepaResults = new Map<string, KeepaData>();
// Phase 3: Sellability gate — check all uncached ASINs before anything else
const sellabilityMap = new Map<string, SellabilityInfo>();
const sellableProducts: ProductRecord[] = [];
const skippedProducts: ProductRecord[] = [];
if (uncachedProducts.length > 0) {
console.log(`\nFetching ${uncachedProducts.length} ASINs from Keepa...`);
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: available, restricted (can request approval), unknown (proceed cautiously)
// Skip: not_available with canSell explicitly false
if (
info.sellabilityStatus === "not_available" &&
info.canSell === false
) {
skippedProducts.push(p);
console.log(
` [skip] ${p.asin}${info.sellabilityReason ?? "not available"}`,
);
} else {
sellableProducts.push(p);
console.log(
` [sellable] ${p.asin} — status=${info.sellabilityStatus}`,
);
}
}
console.log(
`\nSellability gate: ${sellableProducts.length} sellable, ${skippedProducts.length} skipped`,
);
}
// Phase 4: Keepa batch fetch — only for sellable (uncached) ASINs
let keepaResults = new Map<string, KeepaData>();
if (sellableProducts.length > 0) {
console.log(`\nFetching ${sellableProducts.length} ASINs from Keepa...`);
try {
keepaResults = await fetchKeepaDataBatch(uncachedProducts.map((p) => p.asin));
keepaResults = await fetchKeepaDataBatch(
sellableProducts.map((p) => p.asin),
);
} catch (err) {
console.warn(`Keepa batch fetch failed: ${err}`);
}
}
// Phase 3: Build enriched products
// Phase 5: SP-API pricing + fees — only for sellable ASINs
console.log(
`\nFetching pricing & fees for ${sellableProducts.length} ASINs...`,
);
const spApiResults = new Map<string, import("./types.ts").SpApiData>();
// Concurrency-limited pricing+fees fetches
const pricingQueue = [...sellableProducts];
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 === sellableProducts.length) {
console.log(
` [pricing] ${pricingDone}/${sellableProducts.length} fetched`,
);
}
}
}
const pricingWorkers = Array.from(
{ length: Math.min(5, sellableProducts.length || 1) },
() => fetchNextPricing(),
);
await Promise.all(pricingWorkers);
// Phase 6: Build enriched products
console.log(`\nEnriching products...`);
const enriched: EnrichedProduct[] = [];
const autoSkipResults: AnalysisResult[] = [];
for (const p of products) {
// Cached products — already enriched
const cachedProduct = cached.get(p.asin);
if (cachedProduct) {
enriched.push(cachedProduct);
continue;
}
const keepa = keepaResults.get(p.asin) ?? null;
const spApi = await fetchSpApiData(p.asin);
if (keepa?.currentPrice && spApi.estimatedSalePrice === 0) {
spApi.estimatedSalePrice = keepa.currentPrice;
// Skipped products — not sellable, auto-SKIP
if (skippedProducts.some((sp) => sp.asin === p.asin)) {
const sellability = sellabilityMap.get(p.asin)!;
const product: EnrichedProduct = {
record: p,
keepa: null,
spApi: {
fbaFee: 0,
fbmFee: 0,
referralFeePercent: 15,
estimatedSalePrice: 0,
...sellability,
},
fetchedAt: new Date().toISOString(),
};
autoSkipResults.push({
product,
verdict: {
asin: p.asin,
verdict: "SKIP",
confidence: 100,
reasoning:
`Not sellable: ${sellability.sellabilityReason ?? sellability.sellabilityStatus}`.slice(
0,
100,
),
},
});
continue;
}
// Sellable 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,
@@ -91,14 +221,18 @@ async function main() {
enriched.push(product);
if (keepa) {
console.log(` [enriched] ${p.asin} — price: $${keepa.currentPrice ?? "N/A"}, rank: ${keepa.salesRank ?? "N/A"}`);
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 4: LLM analysis in batches
console.log(`\nAnalyzing products via LLM (batch size: ${LLM_BATCH_SIZE})...\n`);
// Phase 7: LLM analysis in batches — only for enriched (sellable + cached) 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) {
@@ -140,10 +274,13 @@ async function main() {
}
}
printResults(results);
// Merge: LLM-analyzed results + auto-skipped results
const allResults = [...results, ...autoSkipResults];
printResults(allResults);
if (outputFile) {
writeResultsCsv(results, outputFile);
writeResultsCsv(allResults, outputFile);
}
await disconnectCache();