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:
@@ -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;
|
||||
|
||||
175
src/index.ts
175
src/index.ts
@@ -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();
|
||||
|
||||
147
src/llm.ts
147
src/llm.ts
@@ -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",
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
652
src/sp-api.ts
652
src/sp-api.ts
@@ -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
48
src/sp-test.ts
Normal 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);
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user