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:
Victor Noguera
2026-04-04 21:33:27 -04:00
commit 061f771279
17 changed files with 1005 additions and 0 deletions

111
src/keepa.ts Normal file
View 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;
}