feat: initialize asin-check project with Bun
- Add README.md with installation and usage instructions. - Create bun.lock for dependency management. - Add package.json to define project metadata and dependencies. - Implement caching with Redis in cache.ts for ASIN data. - Configure environment variables in config.ts for API keys and Redis URL. - Develop main application logic in index.ts to read products, fetch data, and analyze results. - Integrate Keepa API for product data retrieval in keepa.ts. - Create LLM analysis functionality in llm.ts for product viability assessment. - Implement product reading from Excel files in reader.ts. - Stub SP-API integration in sp-api.ts for future implementation. - Define TypeScript types in types.ts for product and analysis data structures. - Write results to console and CSV in writer.ts. - Configure TypeScript settings in tsconfig.json for project compilation.
This commit is contained in:
49
src/cache.ts
Normal file
49
src/cache.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import Redis from "ioredis";
|
||||
import { config } from "./config.ts";
|
||||
import type { EnrichedProduct } from "./types.ts";
|
||||
|
||||
let redis: Redis | null = null;
|
||||
let disabled = false;
|
||||
|
||||
export async function connectCache(): Promise<void> {
|
||||
if (disabled) return;
|
||||
try {
|
||||
redis = new Redis(config.redisUrl, {
|
||||
maxRetriesPerRequest: 1,
|
||||
connectTimeout: 3000,
|
||||
lazyConnect: true,
|
||||
});
|
||||
await redis.connect();
|
||||
console.log("Redis connected");
|
||||
} catch (err) {
|
||||
console.warn(`Redis unavailable, running without cache: ${err}`);
|
||||
redis = null;
|
||||
disabled = true;
|
||||
}
|
||||
}
|
||||
|
||||
export async function getCache(asin: string): Promise<EnrichedProduct | null> {
|
||||
if (!redis) return null;
|
||||
try {
|
||||
const data = await redis.get(`asin:${asin}`);
|
||||
return data ? JSON.parse(data) : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function setCache(asin: string, data: EnrichedProduct): Promise<void> {
|
||||
if (!redis) return;
|
||||
try {
|
||||
await redis.set(`asin:${asin}`, JSON.stringify(data), "EX", config.cacheTtl);
|
||||
} catch {
|
||||
// Non-critical, continue without caching
|
||||
}
|
||||
}
|
||||
|
||||
export async function disconnectCache(): Promise<void> {
|
||||
if (redis) {
|
||||
await redis.quit();
|
||||
redis = null;
|
||||
}
|
||||
}
|
||||
17
src/config.ts
Normal file
17
src/config.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
function required(key: string): string {
|
||||
const val = Bun.env[key];
|
||||
if (!val) throw new Error(`Missing required env var: ${key}`);
|
||||
return val;
|
||||
}
|
||||
|
||||
function optional(key: string, fallback: string): string {
|
||||
return Bun.env[key] || fallback;
|
||||
}
|
||||
|
||||
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),
|
||||
} as const;
|
||||
155
src/index.ts
Normal file
155
src/index.ts
Normal file
@@ -0,0 +1,155 @@
|
||||
import { readProducts } from "./reader.ts";
|
||||
import { fetchKeepaDataBatch } from "./keepa.ts";
|
||||
import { fetchSpApiData } 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";
|
||||
|
||||
const LLM_BATCH_SIZE = 5;
|
||||
|
||||
function parseArgs(): { inputFile: string; outputFile?: string } {
|
||||
const args = process.argv.slice(2);
|
||||
const inputFile = args.find((a) => !a.startsWith("--"));
|
||||
const outIdx = args.indexOf("--out");
|
||||
const outputFile = outIdx !== -1 ? args[outIdx + 1] : undefined;
|
||||
|
||||
if (!inputFile) {
|
||||
console.error("Usage: bun run src/index.ts <input.csv|xlsx> [--out results.csv]");
|
||||
process.exit(1);
|
||||
}
|
||||
return { inputFile, outputFile };
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const { inputFile, outputFile } = parseArgs();
|
||||
|
||||
console.log("Connecting to Redis...");
|
||||
await connectCache();
|
||||
|
||||
console.log(`\nReading ${inputFile}...`);
|
||||
const products = readProducts(inputFile);
|
||||
|
||||
if (products.length === 0) {
|
||||
console.error("No valid products found in input file.");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Phase 1: Check cache for all ASINs
|
||||
console.log(`\nChecking cache for ${products.length} products...`);
|
||||
const cached = new Map<string, EnrichedProduct>();
|
||||
const uncachedProducts: ProductRecord[] = [];
|
||||
|
||||
for (const p of products) {
|
||||
const hit = await getCache(p.asin);
|
||||
if (hit) {
|
||||
console.log(` [cache hit] ${p.asin}`);
|
||||
cached.set(p.asin, hit);
|
||||
} else {
|
||||
uncachedProducts.push(p);
|
||||
}
|
||||
}
|
||||
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>();
|
||||
if (uncachedProducts.length > 0) {
|
||||
console.log(`\nFetching ${uncachedProducts.length} ASINs from Keepa...`);
|
||||
try {
|
||||
keepaResults = await fetchKeepaDataBatch(uncachedProducts.map((p) => p.asin));
|
||||
} catch (err) {
|
||||
console.warn(`Keepa batch fetch failed: ${err}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Phase 3: Build enriched products
|
||||
console.log(`\nEnriching products...`);
|
||||
const enriched: EnrichedProduct[] = [];
|
||||
|
||||
for (const p of products) {
|
||||
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;
|
||||
}
|
||||
|
||||
const product: EnrichedProduct = {
|
||||
record: p,
|
||||
keepa,
|
||||
spApi,
|
||||
fetchedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
await setCache(p.asin, product);
|
||||
enriched.push(product);
|
||||
|
||||
if (keepa) {
|
||||
console.log(` [enriched] ${p.asin} — price: $${keepa.currentPrice ?? "N/A"}, rank: ${keepa.salesRank ?? "N/A"}`);
|
||||
} else {
|
||||
console.log(` [no keepa] ${p.asin} — using spreadsheet data only`);
|
||||
}
|
||||
}
|
||||
|
||||
// Phase 4: LLM analysis in batches
|
||||
console.log(`\nAnalyzing products via LLM (batch size: ${LLM_BATCH_SIZE})...\n`);
|
||||
|
||||
const results: AnalysisResult[] = [];
|
||||
for (let i = 0; i < enriched.length; i += LLM_BATCH_SIZE) {
|
||||
const batch = enriched.slice(i, i + LLM_BATCH_SIZE);
|
||||
const batchNum = Math.floor(i / LLM_BATCH_SIZE) + 1;
|
||||
const totalBatches = Math.ceil(enriched.length / LLM_BATCH_SIZE);
|
||||
console.log(` LLM batch ${batchNum}/${totalBatches}...`);
|
||||
|
||||
// Wait between batches to avoid overwhelming LM Studio
|
||||
if (i > 0) {
|
||||
console.log(` Waiting 5s before next batch...`);
|
||||
await new Promise((r) => setTimeout(r, 5000));
|
||||
}
|
||||
|
||||
let verdicts;
|
||||
try {
|
||||
verdicts = await analyzeProducts(batch);
|
||||
} catch {
|
||||
console.warn(` LLM batch error, retrying after 10s...`);
|
||||
await new Promise((r) => setTimeout(r, 10_000));
|
||||
try {
|
||||
verdicts = await analyzeProducts(batch);
|
||||
} catch (retryErr) {
|
||||
console.error(` LLM analysis failed: ${retryErr}`);
|
||||
verdicts = null;
|
||||
}
|
||||
}
|
||||
|
||||
for (let j = 0; j < batch.length; j++) {
|
||||
results.push({
|
||||
product: batch[j]!,
|
||||
verdict: verdicts?.[j] ?? {
|
||||
asin: batch[j]!.record.asin,
|
||||
verdict: "SKIP",
|
||||
confidence: 0,
|
||||
reasoning: "LLM analysis failed",
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
printResults(results);
|
||||
|
||||
if (outputFile) {
|
||||
writeResultsCsv(results, outputFile);
|
||||
}
|
||||
|
||||
await disconnectCache();
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error("Fatal error:", err);
|
||||
process.exit(1);
|
||||
});
|
||||
111
src/keepa.ts
Normal file
111
src/keepa.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
import { config } from "./config.ts";
|
||||
import type { KeepaData } from "./types.ts";
|
||||
|
||||
const KEEPA_BASE = "https://api.keepa.com";
|
||||
const MAX_ASINS_PER_REQUEST = 100;
|
||||
|
||||
// Token-based rate limiting: Keepa Pro = 1 token/min regeneration.
|
||||
// Each product request costs 1 token regardless of ASIN count (up to 100).
|
||||
// The API response includes tokensLeft and refillRate — we use those to pace.
|
||||
let tokensLeft = 1; // Conservative start; updated from API response
|
||||
let refillRate = 1; // tokens per minute, updated from API response
|
||||
let lastRequestTime = 0;
|
||||
|
||||
async function waitForToken(): Promise<void> {
|
||||
if (tokensLeft > 0) return;
|
||||
|
||||
const elapsed = (Date.now() - lastRequestTime) / 60_000; // minutes
|
||||
const regenerated = Math.floor(elapsed * refillRate);
|
||||
if (regenerated > 0) {
|
||||
tokensLeft += regenerated;
|
||||
return;
|
||||
}
|
||||
|
||||
// Wait until we regenerate at least 1 token
|
||||
const waitMs = Math.ceil((1 / refillRate) * 60_000) - (Date.now() - lastRequestTime);
|
||||
if (waitMs > 0) {
|
||||
console.log(`Keepa tokens exhausted. Waiting ${Math.ceil(waitMs / 1000)}s for token regeneration...`);
|
||||
await new Promise((r) => setTimeout(r, waitMs));
|
||||
}
|
||||
tokensLeft = 1;
|
||||
}
|
||||
|
||||
export async function fetchKeepaDataBatch(asins: string[]): Promise<Map<string, KeepaData>> {
|
||||
const results = new Map<string, KeepaData>();
|
||||
|
||||
// Split into chunks of MAX_ASINS_PER_REQUEST
|
||||
for (let i = 0; i < asins.length; i += MAX_ASINS_PER_REQUEST) {
|
||||
const chunk = asins.slice(i, i + MAX_ASINS_PER_REQUEST);
|
||||
await waitForToken();
|
||||
|
||||
const asinParam = chunk.join(",");
|
||||
const url = `${KEEPA_BASE}/product?key=${config.keepaApiKey}&domain=1&asin=${asinParam}&stats=90`;
|
||||
|
||||
console.log(`Keepa: fetching ${chunk.length} ASINs (tokens left: ${tokensLeft})...`);
|
||||
|
||||
const res = await fetch(url);
|
||||
lastRequestTime = Date.now();
|
||||
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
throw new Error(`Keepa API error ${res.status}: ${text}`);
|
||||
}
|
||||
|
||||
const data = (await res.json()) as {
|
||||
products?: Record<string, any>[];
|
||||
tokensLeft?: number;
|
||||
refillRate?: number;
|
||||
};
|
||||
|
||||
// Update token state from API response
|
||||
if (data.tokensLeft != null) tokensLeft = data.tokensLeft;
|
||||
if (data.refillRate != null) refillRate = data.refillRate;
|
||||
|
||||
console.log(`Keepa: ${data.products?.length ?? 0} products returned, ${tokensLeft} tokens remaining (refill: ${refillRate}/min)`);
|
||||
|
||||
if (data.products) {
|
||||
for (const product of data.products) {
|
||||
const asin = product.asin;
|
||||
if (!asin) continue;
|
||||
results.set(asin, parseKeepaProduct(product));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
function parseKeepaProduct(product: Record<string, any>): KeepaData {
|
||||
const stats = product.stats;
|
||||
const csv = product.csv;
|
||||
|
||||
return {
|
||||
currentPrice: extractCurrentPrice(csv),
|
||||
avgPrice90: stats?.avg?.[0] != null ? stats.avg[0] / 100 : null,
|
||||
minPrice90: stats?.min?.[0] != null ? stats.min[0] / 100 : null,
|
||||
maxPrice90: stats?.max?.[0] != null ? stats.max[0] / 100 : null,
|
||||
salesRank: stats?.current?.[3] ?? null,
|
||||
salesRankAvg90: stats?.avg?.[3] ?? null,
|
||||
salesRankDrops30: product.salesRankDrops30 ?? null,
|
||||
salesRankDrops90: product.salesRankDrops90 ?? null,
|
||||
sellerCount: stats?.current?.[11] ?? null,
|
||||
buyBoxSeller: product.buyBoxSellerId ?? null,
|
||||
buyBoxPrice: stats?.current?.[18] != null ? stats.current[18] / 100 : null,
|
||||
monthlySold: product.monthlySold ?? null,
|
||||
categoryTree: product.categoryTree?.map((c: { name: string }) => c.name) ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
function extractCurrentPrice(csv: number[][] | undefined): number | null {
|
||||
if (!csv) return null;
|
||||
|
||||
// csv[0] = Amazon price history, csv[1] = Marketplace new price history
|
||||
// Each is [time, price, time, price, ...] — last value is most recent
|
||||
for (const series of [csv[0], csv[1]]) {
|
||||
if (series && series.length >= 2) {
|
||||
const lastPrice = series[series.length - 1]!;
|
||||
if (lastPrice > 0) return lastPrice / 100;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
160
src/llm.ts
Normal file
160
src/llm.ts
Normal file
@@ -0,0 +1,160 @@
|
||||
import { config } from "./config.ts";
|
||||
import type { EnrichedProduct, LlmVerdict } from "./types.ts";
|
||||
|
||||
const SYSTEM_PROMPT = `You are an expert Amazon product analyst specializing in FBA and FBM fulfillment strategy.
|
||||
|
||||
Given product data, evaluate each product's viability for selling on Amazon. Consider:
|
||||
|
||||
1. **Sales Velocity**: monthlySold and salesRankDrops30 are the most important signals. A product that doesn't sell is worthless regardless of margin. salesRankDrops30 = approximate units sold in 30 days. monthlySold is Keepa's estimate.
|
||||
2. **Margin Analysis**: Sale price minus unit cost minus fees (FBA or FBM). Aim for >30% ROI minimum. The spreadsheet may include FBA NET and gross profit estimates — cross-check against Keepa pricing data.
|
||||
3. **Sales Rank (BSR)**: Lower rank = higher demand. Rank <50,000 is good, <1,000 is excellent.
|
||||
4. **Sales Rank Trend**: Compare current rank vs 90d average. Lower current = improving demand.
|
||||
5. **Competition**: Number of sellers and Buy Box dynamics. Fewer sellers = easier entry.
|
||||
6. **Price Stability**: Large price swings (high max vs low min over 90d) = volatile/risky.
|
||||
7. **FBA vs FBM**: FBA preferred for fast-selling, small/light items. FBM for oversized, slow-moving, or high-margin items where fee savings matter.
|
||||
8. **MOQ & Capital**: High MOQ with thin margins is risky.
|
||||
9. **Supply Availability**: Total quantity available from supplier — low stock means limited runway.
|
||||
|
||||
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.`;
|
||||
|
||||
export async function analyzeProducts(
|
||||
products: EnrichedProduct[],
|
||||
): Promise<LlmVerdict[]> {
|
||||
const productSummaries = products.map(summarizeForLlm);
|
||||
|
||||
const res = await fetch(`${config.llmUrl}/chat/completions`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: "Bearer lm-studio",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: config.llmModel,
|
||||
messages: [
|
||||
{ role: "system", content: SYSTEM_PROMPT },
|
||||
{ role: "user", content: JSON.stringify(productSummaries, null, 2) },
|
||||
],
|
||||
temperature: 0.3,
|
||||
max_tokens: 2048,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(`LLM API error ${res.status}: ${await res.text()}`);
|
||||
}
|
||||
|
||||
const data = (await res.json()) as {
|
||||
choices?: { message?: { content?: string } }[];
|
||||
};
|
||||
const content = data.choices?.[0]?.message?.content ?? "";
|
||||
|
||||
return parseVerdicts(content, products);
|
||||
}
|
||||
|
||||
function summarizeForLlm(p: EnrichedProduct) {
|
||||
const salePrice = p.keepa?.currentPrice ?? p.spApi.estimatedSalePrice;
|
||||
const referralFee = salePrice * (p.spApi.referralFeePercent / 100);
|
||||
const fbaProfit =
|
||||
salePrice - p.record.unitCost - p.spApi.fbaFee - referralFee;
|
||||
const fbmProfit =
|
||||
salePrice - p.record.unitCost - p.spApi.fbmFee - referralFee;
|
||||
|
||||
return {
|
||||
asin: p.record.asin,
|
||||
name: p.record.name,
|
||||
brand: p.record.brand,
|
||||
category: p.record.category ?? p.keepa?.categoryTree?.join(" > "),
|
||||
unitCost: p.record.unitCost,
|
||||
currentPrice: salePrice,
|
||||
priceRange90d: p.keepa
|
||||
? {
|
||||
min: p.keepa.minPrice90,
|
||||
max: p.keepa.maxPrice90,
|
||||
avg: p.keepa.avgPrice90,
|
||||
}
|
||||
: null,
|
||||
salesRank: p.keepa?.salesRank ?? p.record.amazonRank,
|
||||
salesRankAvg90d: p.keepa?.salesRankAvg90,
|
||||
sellerCount: p.keepa?.sellerCount,
|
||||
salesVelocity: {
|
||||
monthlySold: p.keepa?.monthlySold,
|
||||
salesRankDrops30: p.keepa?.salesRankDrops30,
|
||||
salesRankDrops90: p.keepa?.salesRankDrops90,
|
||||
},
|
||||
spreadsheetEstimates: {
|
||||
fbaNet: p.record.fbaNet,
|
||||
grossProfit: p.record.grossProfit,
|
||||
grossProfitPct: p.record.grossProfitPct,
|
||||
},
|
||||
moq: p.record.moq,
|
||||
moqCost: p.record.moqCost,
|
||||
totalQtyAvail: p.record.totalQtyAvail,
|
||||
fees: {
|
||||
fbaFee: p.spApi.fbaFee,
|
||||
fbmFee: p.spApi.fbmFee,
|
||||
referralFeePercent: p.spApi.referralFeePercent,
|
||||
referralFee: Math.round(referralFee * 100) / 100,
|
||||
},
|
||||
estimatedProfit: {
|
||||
fba: Math.round(fbaProfit * 100) / 100,
|
||||
fbm: Math.round(fbmProfit * 100) / 100,
|
||||
},
|
||||
estimatedROI: {
|
||||
fba:
|
||||
p.record.unitCost > 0
|
||||
? Math.round((fbaProfit / p.record.unitCost) * 100)
|
||||
: null,
|
||||
fbm:
|
||||
p.record.unitCost > 0
|
||||
? Math.round((fbmProfit / p.record.unitCost) * 100)
|
||||
: null,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function cleanLlmJson(text: string): string {
|
||||
// Remove ```json ... ``` or ``` ... ``` wrapping
|
||||
const fenceMatch = text.match(/```(?:json)?\s*\n?([\s\S]*?)```/);
|
||||
let cleaned = fenceMatch ? fenceMatch[1]!.trim() : text.trim();
|
||||
|
||||
// Fix trailing comma-quote before closing brace: ,"} → "}
|
||||
cleaned = cleaned.replace(/,"\s*}/g, '"}');
|
||||
|
||||
// Fix trailing commas before ] or }
|
||||
cleaned = cleaned.replace(/,\s*([}\]])/g, "$1");
|
||||
|
||||
return cleaned;
|
||||
}
|
||||
|
||||
function parseVerdicts(
|
||||
content: string,
|
||||
products: EnrichedProduct[],
|
||||
): LlmVerdict[] {
|
||||
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"),
|
||||
}));
|
||||
} catch (err) {
|
||||
console.warn("Failed to parse LLM response, marking all as ANALYSIS_FAILED");
|
||||
console.warn("Raw LLM content:", content.slice(0, 500));
|
||||
return products.map((p) => ({
|
||||
asin: p.record.asin,
|
||||
verdict: "SKIP" as const,
|
||||
confidence: 0,
|
||||
reasoning: `Analysis failed: could not parse LLM output`,
|
||||
}));
|
||||
}
|
||||
}
|
||||
81
src/reader.ts
Normal file
81
src/reader.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
import * as XLSX from "xlsx";
|
||||
import type { ProductRecord } from "./types.ts";
|
||||
|
||||
export function readProducts(filePath: string): ProductRecord[] {
|
||||
const workbook = XLSX.readFile(filePath);
|
||||
const sheetName = workbook.SheetNames[0];
|
||||
if (!sheetName) throw new Error("No sheets found in file");
|
||||
|
||||
const sheet = workbook.Sheets[sheetName]!;
|
||||
const rows = XLSX.utils.sheet_to_json<Record<string, unknown>>(sheet);
|
||||
|
||||
if (rows.length === 0) throw new Error("File contains no data rows");
|
||||
|
||||
const headers = Object.keys(rows[0]!);
|
||||
const asinCol = findColumn(headers, ["asin"]);
|
||||
const nameCol = findColumn(headers, ["name", "product name", "title", "product title"]);
|
||||
const costCol = findColumn(headers, ["unit cost", "cost", "unitcost", "unit_cost", "price", "buy cost"]);
|
||||
|
||||
const brandCol = findColumn(headers, ["brand"]);
|
||||
const categoryCol = findColumn(headers, ["category"]);
|
||||
const amazonRankCol = findColumn(headers, ["amazon rank", "amazonrank", "sales rank", "bsr"]);
|
||||
const fbaNetCol = findColumn(headers, ["fba net", "fbanet", "fba_net"]);
|
||||
const grossProfitCol = findColumn(headers, ["gross profit $", "gross profit", "grossprofit"]);
|
||||
const grossProfitPctCol = findColumn(headers, ["gross profit %", "gross profit pct", "grossprofitpct"]);
|
||||
const moqCol = findColumn(headers, ["moq", "min order qty", "minimum order quantity"]);
|
||||
const moqCostCol = findColumn(headers, ["moq cost", "moqcost"]);
|
||||
const totalQtyCol = findColumn(headers, ["total qty avail", "totalqtyavail", "qty available", "quantity"]);
|
||||
|
||||
const linkCol = findColumn(headers, ["link", "url", "source"]);
|
||||
|
||||
if (!asinCol) throw new Error(`No ASIN column found. Available columns: ${headers.join(", ")}`);
|
||||
|
||||
const knownCols = new Set([asinCol, nameCol, costCol, brandCol, categoryCol, amazonRankCol, fbaNetCol, grossProfitCol, grossProfitPctCol, moqCol, moqCostCol, totalQtyCol, linkCol].filter(Boolean));
|
||||
|
||||
const products: ProductRecord[] = [];
|
||||
|
||||
for (const row of rows) {
|
||||
const asin = String(row[asinCol] ?? "").trim().toUpperCase();
|
||||
if (!asin || !/^B[0-9A-Z]{9}$/.test(asin)) {
|
||||
console.warn(`Skipping invalid ASIN: "${asin}"`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const name = nameCol ? String(row[nameCol] ?? "") : "";
|
||||
const unitCost = costCol ? parseFloat(String(row[costCol] ?? "0")) : 0;
|
||||
|
||||
const extra: Record<string, unknown> = {};
|
||||
for (const h of headers) {
|
||||
if (!knownCols.has(h)) extra[h] = row[h];
|
||||
}
|
||||
|
||||
products.push({
|
||||
asin,
|
||||
name,
|
||||
unitCost,
|
||||
brand: brandCol ? String(row[brandCol] ?? "") : undefined,
|
||||
category: categoryCol ? String(row[categoryCol] ?? "") : undefined,
|
||||
amazonRank: amazonRankCol ? Number(row[amazonRankCol]) || undefined : undefined,
|
||||
fbaNet: fbaNetCol ? Number(row[fbaNetCol]) || undefined : undefined,
|
||||
grossProfit: grossProfitCol ? Number(row[grossProfitCol]) || undefined : undefined,
|
||||
grossProfitPct: grossProfitPctCol ? Number(row[grossProfitPctCol]) || undefined : undefined,
|
||||
moq: moqCol ? Number(row[moqCol]) || undefined : undefined,
|
||||
moqCost: moqCostCol ? Number(row[moqCostCol]) || undefined : undefined,
|
||||
totalQtyAvail: totalQtyCol ? Number(row[totalQtyCol]) || undefined : undefined,
|
||||
|
||||
link: linkCol ? String(row[linkCol] ?? "") : undefined,
|
||||
...extra,
|
||||
});
|
||||
}
|
||||
|
||||
console.log(`Read ${products.length} valid products from ${filePath}`);
|
||||
return products;
|
||||
}
|
||||
|
||||
function findColumn(headers: string[], candidates: string[]): string | undefined {
|
||||
for (const candidate of candidates) {
|
||||
const match = headers.find((h) => h.toLowerCase().trim() === candidate);
|
||||
if (match) return match;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
18
src/sp-api.ts
Normal file
18
src/sp-api.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import type { SpApiData } 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
|
||||
|
||||
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 {
|
||||
fbaFee: 5.0,
|
||||
fbmFee: 1.5,
|
||||
referralFeePercent: 15,
|
||||
estimatedSalePrice: 0, // Will be overridden by Keepa current price if available
|
||||
};
|
||||
}
|
||||
59
src/types.ts
Normal file
59
src/types.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
export interface ProductRecord {
|
||||
asin: string;
|
||||
name: string;
|
||||
unitCost: number;
|
||||
brand?: string;
|
||||
category?: string;
|
||||
amazonRank?: number;
|
||||
fbaNet?: number;
|
||||
grossProfit?: number;
|
||||
grossProfitPct?: number;
|
||||
moq?: number;
|
||||
moqCost?: number;
|
||||
totalQtyAvail?: number;
|
||||
|
||||
link?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface KeepaData {
|
||||
currentPrice: number | null;
|
||||
avgPrice90: number | null;
|
||||
minPrice90: number | null;
|
||||
maxPrice90: number | null;
|
||||
salesRank: number | null;
|
||||
salesRankAvg90: number | null;
|
||||
salesRankDrops30: number | null;
|
||||
salesRankDrops90: number | null;
|
||||
sellerCount: number | null;
|
||||
buyBoxSeller: string | null;
|
||||
buyBoxPrice: number | null;
|
||||
monthlySold: number | null;
|
||||
categoryTree: string[];
|
||||
}
|
||||
|
||||
export interface SpApiData {
|
||||
fbaFee: number;
|
||||
fbmFee: number;
|
||||
referralFeePercent: number;
|
||||
estimatedSalePrice: number;
|
||||
}
|
||||
|
||||
export interface EnrichedProduct {
|
||||
record: ProductRecord;
|
||||
keepa: KeepaData | null;
|
||||
spApi: SpApiData;
|
||||
fetchedAt: string;
|
||||
}
|
||||
|
||||
export interface LlmVerdict {
|
||||
asin: string;
|
||||
verdict: "FBA" | "FBM" | "SKIP";
|
||||
confidence: number;
|
||||
reasoning: string;
|
||||
}
|
||||
|
||||
export interface AnalysisResult {
|
||||
product: EnrichedProduct;
|
||||
verdict: LlmVerdict;
|
||||
}
|
||||
67
src/writer.ts
Normal file
67
src/writer.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import * as XLSX from "xlsx";
|
||||
import type { AnalysisResult } from "./types.ts";
|
||||
|
||||
function buildRow(r: AnalysisResult) {
|
||||
const price = r.product.keepa?.currentPrice ?? r.product.spApi.estimatedSalePrice;
|
||||
const rank = r.product.keepa?.salesRank ?? r.product.record.amazonRank;
|
||||
|
||||
return {
|
||||
ASIN: r.product.record.asin,
|
||||
Name: r.product.record.name,
|
||||
Brand: r.product.record.brand ?? "",
|
||||
Category: r.product.record.category ?? r.product.keepa?.categoryTree?.join(" > ") ?? "",
|
||||
"Unit Cost": r.product.record.unitCost,
|
||||
"Current Price": price ?? "",
|
||||
"Avg Price 90d": r.product.keepa?.avgPrice90 ?? "",
|
||||
"Sales Rank": rank ?? "",
|
||||
"Rank Avg 90d": r.product.keepa?.salesRankAvg90 ?? "",
|
||||
Sellers: r.product.keepa?.sellerCount ?? "",
|
||||
"Monthly Sold": r.product.keepa?.monthlySold ?? "",
|
||||
"Rank Drops 30d": r.product.keepa?.salesRankDrops30 ?? "",
|
||||
"Rank Drops 90d": r.product.keepa?.salesRankDrops90 ?? "",
|
||||
"FBA Net (sheet)": r.product.record.fbaNet ?? "",
|
||||
"Gross Profit $": r.product.record.grossProfit ?? "",
|
||||
"Gross Profit %": r.product.record.grossProfitPct ?? "",
|
||||
MOQ: r.product.record.moq ?? "",
|
||||
"MOQ Cost": r.product.record.moqCost ?? "",
|
||||
"Qty Available": r.product.record.totalQtyAvail ?? "",
|
||||
"FBA Fee": r.product.spApi.fbaFee,
|
||||
"FBM Fee": r.product.spApi.fbmFee,
|
||||
"Referral %": r.product.spApi.referralFeePercent,
|
||||
Verdict: r.verdict.verdict,
|
||||
Confidence: r.verdict.confidence,
|
||||
Reasoning: r.verdict.reasoning,
|
||||
};
|
||||
}
|
||||
|
||||
export function printResults(results: AnalysisResult[]): void {
|
||||
const rows = results.map((r) => {
|
||||
const row = buildRow(r);
|
||||
return {
|
||||
...row,
|
||||
Name: row.Name.slice(0, 40),
|
||||
Category: String(row.Category).slice(0, 20),
|
||||
Reasoning: row.Reasoning.slice(0, 60),
|
||||
};
|
||||
});
|
||||
|
||||
console.log("\n=== Analysis Results ===\n");
|
||||
console.table(rows);
|
||||
|
||||
const summary = {
|
||||
FBA: results.filter((r) => r.verdict.verdict === "FBA").length,
|
||||
FBM: results.filter((r) => r.verdict.verdict === "FBM").length,
|
||||
SKIP: results.filter((r) => r.verdict.verdict === "SKIP").length,
|
||||
};
|
||||
console.log(`\nSummary: ${summary.FBA} FBA | ${summary.FBM} FBM | ${summary.SKIP} SKIP out of ${results.length} products\n`);
|
||||
}
|
||||
|
||||
export function writeResultsCsv(results: AnalysisResult[], outputPath: string): void {
|
||||
const rows = results.map(buildRow);
|
||||
|
||||
const ws = XLSX.utils.json_to_sheet(rows);
|
||||
const wb = XLSX.utils.book_new();
|
||||
XLSX.utils.book_append_sheet(wb, ws, "Results");
|
||||
XLSX.writeFile(wb, outputPath);
|
||||
console.log(`Results written to ${outputPath}`);
|
||||
}
|
||||
Reference in New Issue
Block a user