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

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