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

@@ -8,10 +8,27 @@ 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,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();

View File

@@ -14,11 +14,20 @@ Given product data, evaluate each product's viability for selling on Amazon. Con
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.`;
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[],
@@ -148,6 +157,11 @@ function summarizeForLlm(p: EnrichedProduct) {
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,
@@ -195,6 +209,9 @@ function cleanLlmJson(text: string): string {
// 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");
@@ -205,21 +222,20 @@ function parseVerdicts(
content: string,
products: EnrichedProduct[],
): LlmVerdict[] {
const cleaned = cleanLlmJson(content);
try {
const cleaned = cleanLlmJson(content);
const parsed = JSON.parse(cleaned);
const arr = Array.isArray(parsed)
? parsed
: (parsed.verdicts ?? parsed.results ?? [parsed]);
return arr.map((v: Record<string, unknown>) => ({
asin: String(v.asin ?? ""),
verdict: (["FBA", "FBM", "SKIP"].includes(String(v.verdict))
? v.verdict
: "SKIP") as LlmVerdict["verdict"],
confidence: typeof v.confidence === "number" ? v.confidence : 0,
reasoning: String(v.reasoning ?? "No reasoning provided"),
}));
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",
);
@@ -232,3 +248,106 @@ function parseVerdicts(
}));
}
}
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,18 +1,650 @@
import type { SpApiData } from "./types.ts";
import { SellingPartner } from "amazon-sp-api";
import { config } from "./config.ts";
import type { SpApiData, SellabilityInfo } from "./types.ts";
// TODO: Implement real SP-API integration with LWA OAuth
// - LWA token endpoint: https://api.amazon.com/auth/o2/token
// - Catalog Items: GET /catalog/2022-04-01/items/{asin}
// - Product Pricing: GET /products/pricing/v0/price
// - Product Fees: GET /products/fees/v0/items/{asin}/feesEstimate
type RegionCode = "na" | "eu" | "fe";
let client: SellingPartner | null = null;
let loggedMissingCreds = false;
let loggedSandboxMode = false;
function normalizeRegion(region: string): RegionCode {
const value = region.trim().toLowerCase();
if (value === "us") return "na";
if (value === "na" || value === "eu" || value === "fe") return value;
console.warn(`Unknown SP_API_REGION \"${region}\", defaulting to \"na\".`);
return "na";
}
function hasSpApiCredentials(): boolean {
return !!(
config.spApiClientId &&
config.spApiClientSecret &&
config.spApiRefreshToken
);
}
function getSpClient(): SellingPartner | null {
if (!hasSpApiCredentials()) {
if (!loggedMissingCreds) {
console.warn(
"SP-API credentials not configured; falling back to fee defaults. Set SP_API_CLIENT_ID, SP_API_CLIENT_SECRET, and SP_API_REFRESH_TOKEN.",
);
loggedMissingCreds = true;
}
return null;
}
if (client) return client;
if (config.spApiUseSandbox && !loggedSandboxMode) {
console.warn(
"SP-API sandbox mode is enabled (SP_API_USE_SANDBOX=true). Production ASIN calls may be denied.",
);
loggedSandboxMode = true;
}
client = new SellingPartner({
region: normalizeRegion(config.spApiRegion),
refresh_token: config.spApiRefreshToken!,
credentials: {
SELLING_PARTNER_APP_CLIENT_ID: config.spApiClientId!,
SELLING_PARTNER_APP_CLIENT_SECRET: config.spApiClientSecret!,
...(config.awsAccessKeyId && config.awsSecretAccessKey
? {
AWS_ACCESS_KEY_ID: config.awsAccessKeyId,
AWS_SECRET_ACCESS_KEY: config.awsSecretAccessKey,
}
: {}),
...(config.awsSessionToken
? { AWS_SESSION_TOKEN: config.awsSessionToken }
: {}),
},
options: {
auto_request_tokens: true,
auto_request_throttled: true,
use_sandbox: config.spApiUseSandbox,
debug_log: false,
},
});
return client;
}
function getAmount(value: unknown): number | undefined {
if (!value || typeof value !== "object") return undefined;
const amount = (value as { Amount?: unknown }).Amount;
return typeof amount === "number" && Number.isFinite(amount)
? amount
: undefined;
}
function extractEstimatedSalePrice(pricing: any): number {
const buyBox = pricing?.Summary?.BuyBoxPrices?.[0];
const lowest = pricing?.Summary?.LowestPrices?.[0];
const buyBoxLanded = getAmount(buyBox?.LandedPrice);
if (buyBoxLanded != null) return buyBoxLanded;
const lowestLanded = getAmount(lowest?.LandedPrice);
if (lowestLanded != null) return lowestLanded;
const firstOffer = pricing?.Offers?.[0];
const listing = getAmount(firstOffer?.ListingPrice) ?? 0;
const shipping = getAmount(firstOffer?.Shipping) ?? 0;
return listing + shipping;
}
function extractFeeResult(feesResponse: any): {
totalFee: number;
referralFee?: number;
} {
const result = feesResponse?.payload?.FeesEstimateResult;
const total = getAmount(result?.FeesEstimate?.TotalFeesEstimate) ?? 0;
const feeDetails = result?.FeesEstimate?.FeeDetailList;
const referralDetail = Array.isArray(feeDetails)
? feeDetails.find((f: any) =>
String(f?.FeeType ?? "")
.toLowerCase()
.includes("referral"),
)
: undefined;
const referralFee = getAmount(referralDetail?.FinalFee);
return { totalFee: total, referralFee };
}
function round2(value: number): number {
return Math.round(value * 100) / 100;
}
const SELLABILITY_CONCURRENCY = 5;
const PRICING_CONCURRENCY = 5;
function parseSellabilityResponse(response: any): SellabilityInfo {
const restrictions = Array.isArray(response?.restrictions)
? response.restrictions
: Array.isArray(response?.payload?.restrictions)
? response.payload.restrictions
: null;
if (!Array.isArray(restrictions)) {
return {
canSell: null,
sellabilityStatus: "unknown",
sellabilityReason: "Unexpected restrictions response shape",
};
}
if (restrictions.length === 0) {
return {
canSell: true,
sellabilityStatus: "available",
sellabilityReason: "No listing restrictions reported",
};
}
const reasons = restrictions.flatMap((r: any) =>
Array.isArray(r?.reasons) ? r.reasons : [],
);
const reasonCodes = reasons
.map((r: any) => String(r?.reasonCode ?? "").trim())
.filter((r: string) => r.length > 0);
const reasonMessages = reasons
.map((r: any) => String(r?.message ?? "").trim())
.filter((m: string) => m.length > 0);
const allReasonText = [...reasonCodes, ...reasonMessages]
.join(" | ")
.toLowerCase();
const status =
allReasonText.includes("not_eligible") ||
allReasonText.includes("not eligible") ||
allReasonText.includes("not available")
? "not_available"
: "restricted";
return {
canSell: false,
sellabilityStatus: status,
sellabilityReason:
[...reasonCodes, ...reasonMessages].join(" | ") ||
"Listing restrictions reported",
};
}
async function fetchSellabilityInternal(
spClient: SellingPartner,
asin: string,
): Promise<SellabilityInfo> {
if (!config.spApiSellerId) {
return {
canSell: null,
sellabilityStatus: "unknown",
sellabilityReason: "Missing SP_API_SELLER_ID",
};
}
try {
const restrictionResponse = await spClient.callAPI({
operation: "getListingsRestrictions",
endpoint: "listingsRestrictions",
query: {
asin,
sellerId: config.spApiSellerId,
marketplaceIds: [config.spApiMarketplaceId],
conditionType: "new_new",
},
});
return parseSellabilityResponse(restrictionResponse);
} catch (err) {
return {
canSell: null,
sellabilityStatus: "unknown",
sellabilityReason: `Restrictions check failed: ${extractErrorMessage(err)}`,
};
}
}
function missingSpApiEnvVars(): string[] {
const missing: string[] = [];
if (!config.spApiClientId) missing.push("SP_API_CLIENT_ID");
if (!config.spApiClientSecret) missing.push("SP_API_CLIENT_SECRET");
if (!config.spApiRefreshToken) missing.push("SP_API_REFRESH_TOKEN");
return missing;
}
function extractErrorMessage(err: unknown): string {
if (err instanceof Error) return err.message;
return String(err);
}
function isAccessDeniedMessage(message: string): boolean {
const value = message.toLowerCase();
return (
value.includes("access to requested resource is denied") ||
value.includes("access denied") ||
value.includes("forbidden") ||
value.includes("unauthorized")
);
}
function deniedHint(operation: string): string {
const sandboxHint = config.spApiUseSandbox
? " Sandbox mode is enabled; production data calls are often denied in sandbox."
: "";
if (operation === "sellers") {
return (
" Check app authorization, role grants, and that refresh token belongs to the intended seller account." +
sandboxHint
);
}
return (
" Check that Product Pricing role is enabled and re-authorize app to mint a new refresh token." +
sandboxHint
);
}
export async function testSpApiConnectivity(
asin?: string,
): Promise<{ ok: boolean; message: string }> {
const missingVars = missingSpApiEnvVars();
if (missingVars.length > 0) {
return {
ok: false,
message: `Missing required SP-API env vars: ${missingVars.join(", ")}`,
};
}
const spClient = getSpClient();
if (!spClient) {
return {
ok: false,
message: "SP-API client could not be initialized.",
};
}
try {
let sellersRes: { payload?: unknown[] };
try {
sellersRes = (await spClient.callAPI({
operation: "getMarketplaceParticipations",
endpoint: "sellers",
})) as { payload?: unknown[] };
} catch (err) {
const message = extractErrorMessage(err);
const hint = isAccessDeniedMessage(message) ? deniedHint("sellers") : "";
return {
ok: false,
message: `Auth probe failed (sellers.getMarketplaceParticipations): ${message}.${hint}`,
};
}
const participationCount = Array.isArray(sellersRes?.payload)
? sellersRes.payload.length
: 0;
if (!asin) {
return {
ok: true,
message: `SP-API auth OK. Seller participations returned: ${participationCount}.`,
};
}
let pricingRes: { status?: string; ASIN?: string };
try {
pricingRes = (await spClient.callAPI({
operation: "getItemOffers",
endpoint: "productPricing",
path: { Asin: asin },
query: {
MarketplaceId: config.spApiMarketplaceId,
ItemCondition: "New",
},
})) as { status?: string; ASIN?: string };
} catch (err) {
const message = extractErrorMessage(err);
const hint = isAccessDeniedMessage(message) ? deniedHint("pricing") : "";
return {
ok: false,
message: `Pricing probe failed (productPricing.getItemOffers): ${message}.${hint}`,
};
}
return {
ok: true,
message:
`SP-API auth OK. Seller participations: ${participationCount}. ` +
`Pricing check for ${asin} returned status=${pricingRes?.status ?? "unknown"} asin=${pricingRes?.ASIN ?? "unknown"}.`,
};
} catch (err) {
return {
ok: false,
message: `SP-API connectivity failed unexpectedly: ${extractErrorMessage(err)}`,
};
}
}
export async function testSpApiSellability(
asin: string,
): Promise<{ ok: boolean; message: string }> {
const missingVars = missingSpApiEnvVars();
if (missingVars.length > 0) {
return {
ok: false,
message: `Missing required SP-API env vars: ${missingVars.join(", ")}`,
};
}
if (!config.spApiSellerId) {
return {
ok: false,
message: "Missing required env var: SP_API_SELLER_ID",
};
}
const spClient = getSpClient();
if (!spClient) {
return {
ok: false,
message: "SP-API client could not be initialized.",
};
}
const sellability = await fetchSellabilityInternal(spClient, asin);
if (sellability.sellabilityStatus === "unknown") {
return {
ok: false,
message:
`Sellability probe failed for ${asin}: ${sellability.sellabilityReason ?? "unknown reason"}. ` +
"This usually means sellerId is missing/incorrect or the app lacks Listings Restrictions permission.",
};
}
const canSell =
sellability.canSell == null
? "unknown"
: sellability.canSell
? "yes"
: "no";
return {
ok: true,
message:
`Sellability probe OK for ${asin}: status=${sellability.sellabilityStatus}, canSell=${canSell}. ` +
`${sellability.sellabilityReason ?? ""}`,
};
}
export async function fetchSpApiData(asin: string): Promise<SpApiData> {
// Stub: returns realistic mock fee estimates
// Average FBA referral fee is ~15%, FBA fulfillment fee ~$3-5 for standard size
return {
const fallback: SpApiData = {
fbaFee: 5.0,
fbmFee: 1.5,
referralFeePercent: 15,
estimatedSalePrice: 0, // Will be overridden by Keepa current price if available
estimatedSalePrice: 0,
canSell: null,
sellabilityStatus: "unknown",
sellabilityReason: "SP-API fallback values in use",
};
const spClient = getSpClient();
if (!spClient) {
console.log(` [sp-api:fallback] ${asin} reason=missing_credentials`);
return fallback;
}
try {
const pricing = (await spClient.callAPI({
operation: "getItemOffers",
endpoint: "productPricing",
path: { Asin: asin },
query: {
MarketplaceId: config.spApiMarketplaceId,
ItemCondition: "New",
},
})) as any;
const sellability = await fetchSellabilityInternal(spClient, asin);
const estimatedSalePrice = extractEstimatedSalePrice(pricing);
if (!Number.isFinite(estimatedSalePrice) || estimatedSalePrice <= 0) {
console.log(
` [sp-api:fallback] ${asin} reason=no_valid_price_from_offers`,
);
return {
...fallback,
...sellability,
};
}
const [fbaFeesRes, fbmFeesRes] = await Promise.all([
spClient.callAPI({
operation: "getMyFeesEstimateForASIN",
endpoint: "productFees",
path: { Asin: asin },
body: {
FeesEstimateRequest: {
MarketplaceId: config.spApiMarketplaceId,
IsAmazonFulfilled: true,
Identifier: `${asin}-fba`,
PriceToEstimateFees: {
ListingPrice: {
CurrencyCode: "USD",
Amount: estimatedSalePrice,
},
},
},
},
}),
spClient.callAPI({
operation: "getMyFeesEstimateForASIN",
endpoint: "productFees",
path: { Asin: asin },
body: {
FeesEstimateRequest: {
MarketplaceId: config.spApiMarketplaceId,
IsAmazonFulfilled: false,
Identifier: `${asin}-fbm`,
PriceToEstimateFees: {
ListingPrice: {
CurrencyCode: "USD",
Amount: estimatedSalePrice,
},
},
},
},
}),
]);
const fba = extractFeeResult(fbaFeesRes);
const fbm = extractFeeResult(fbmFeesRes);
const referralFee =
fba.referralFee ?? fbm.referralFee ?? (estimatedSalePrice * 15) / 100;
const referralFeePercent = round2((referralFee / estimatedSalePrice) * 100);
const result: SpApiData = {
fbaFee: round2(fba.totalFee || fallback.fbaFee),
fbmFee: round2(fbm.totalFee || fallback.fbmFee),
referralFeePercent:
Number.isFinite(referralFeePercent) && referralFeePercent > 0
? referralFeePercent
: fallback.referralFeePercent,
estimatedSalePrice: round2(estimatedSalePrice),
...sellability,
};
console.log(
` [sp-api:live] ${asin} price=$${result.estimatedSalePrice} fbaFee=$${result.fbaFee} fbmFee=$${result.fbmFee} referralPct=${result.referralFeePercent} sellability=${result.sellabilityStatus}`,
);
return result;
} catch (err) {
console.warn(`SP-API fetch failed for ${asin}: ${String(err)}`);
console.log(` [sp-api:fallback] ${asin} reason=request_failed`);
return fallback;
}
}
// ---------------------------------------------------------------------------
// Public sellability + pricing/fees functions for the new pipeline
// ---------------------------------------------------------------------------
export async function fetchSellability(asin: string): Promise<SellabilityInfo> {
const spClient = getSpClient();
if (!spClient) {
return {
canSell: null,
sellabilityStatus: "unknown",
sellabilityReason: "SP-API credentials not configured",
};
}
return fetchSellabilityInternal(spClient, asin);
}
export async function fetchSellabilityBatch(
asins: string[],
): Promise<Map<string, SellabilityInfo>> {
const results = new Map<string, SellabilityInfo>();
const spClient = getSpClient();
if (!spClient) {
for (const asin of asins) {
results.set(asin, {
canSell: null,
sellabilityStatus: "unknown",
sellabilityReason: "SP-API credentials not configured",
});
}
return results;
}
let completed = 0;
let running = 0;
const queue = [...asins];
async function next(): Promise<void> {
while (queue.length > 0) {
const asin = queue.shift()!;
const info = await fetchSellabilityInternal(spClient!, asin);
results.set(asin, info);
completed++;
if (completed % 10 === 0 || completed === asins.length) {
console.log(` [sellability] ${completed}/${asins.length} checked`);
}
}
}
const workers = Array.from(
{ length: Math.min(SELLABILITY_CONCURRENCY, asins.length) },
() => next(),
);
await Promise.all(workers);
return results;
}
export async function fetchSpApiPricingAndFees(
asin: string,
sellability: SellabilityInfo,
): Promise<SpApiData> {
const fallback: SpApiData = {
fbaFee: 5.0,
fbmFee: 1.5,
referralFeePercent: 15,
estimatedSalePrice: 0,
...sellability,
};
const spClient = getSpClient();
if (!spClient) {
console.log(` [sp-api:fallback] ${asin} reason=missing_credentials`);
return fallback;
}
try {
const pricing = (await spClient.callAPI({
operation: "getItemOffers",
endpoint: "productPricing",
path: { Asin: asin },
query: {
MarketplaceId: config.spApiMarketplaceId,
ItemCondition: "New",
},
})) as any;
const estimatedSalePrice = extractEstimatedSalePrice(pricing);
if (!Number.isFinite(estimatedSalePrice) || estimatedSalePrice <= 0) {
console.log(
` [sp-api:fallback] ${asin} reason=no_valid_price_from_offers`,
);
return fallback;
}
const [fbaFeesRes, fbmFeesRes] = await Promise.all([
spClient.callAPI({
operation: "getMyFeesEstimateForASIN",
endpoint: "productFees",
path: { Asin: asin },
body: {
FeesEstimateRequest: {
MarketplaceId: config.spApiMarketplaceId,
IsAmazonFulfilled: true,
Identifier: `${asin}-fba`,
PriceToEstimateFees: {
ListingPrice: {
CurrencyCode: "USD",
Amount: estimatedSalePrice,
},
},
},
},
}),
spClient.callAPI({
operation: "getMyFeesEstimateForASIN",
endpoint: "productFees",
path: { Asin: asin },
body: {
FeesEstimateRequest: {
MarketplaceId: config.spApiMarketplaceId,
IsAmazonFulfilled: false,
Identifier: `${asin}-fbm`,
PriceToEstimateFees: {
ListingPrice: {
CurrencyCode: "USD",
Amount: estimatedSalePrice,
},
},
},
},
}),
]);
const fba = extractFeeResult(fbaFeesRes);
const fbm = extractFeeResult(fbmFeesRes);
const referralFee =
fba.referralFee ?? fbm.referralFee ?? (estimatedSalePrice * 15) / 100;
const referralFeePercent = round2((referralFee / estimatedSalePrice) * 100);
const result: SpApiData = {
fbaFee: round2(fba.totalFee || fallback.fbaFee),
fbmFee: round2(fbm.totalFee || fallback.fbmFee),
referralFeePercent:
Number.isFinite(referralFeePercent) && referralFeePercent > 0
? referralFeePercent
: fallback.referralFeePercent,
estimatedSalePrice: round2(estimatedSalePrice),
...sellability,
};
console.log(
` [sp-api:live] ${asin} price=$${result.estimatedSalePrice} fbaFee=$${result.fbaFee} fbmFee=$${result.fbmFee} referralPct=${result.referralFeePercent}`,
);
return result;
} catch (err) {
console.warn(`SP-API pricing/fees failed for ${asin}: ${String(err)}`);
console.log(` [sp-api:fallback] ${asin} reason=request_failed`);
return fallback;
}
}

48
src/sp-test.ts Normal file
View File

@@ -0,0 +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);
});

View File

@@ -42,7 +42,13 @@ export interface KeepaData {
categoryTree: string[];
}
export interface SpApiData {
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;

View File

@@ -44,6 +44,14 @@ function buildRow(r: AnalysisResult) {
"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,
@@ -90,6 +98,16 @@ export function printResults(results: AnalysisResult[]): void {
"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),
};
@@ -106,10 +124,25 @@ export function printResults(results: AnalysisResult[]): void {
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(