feat: add Amazon seller and buy box share metrics to product analysis
- Introduced `amazonIsSeller` and `amazonBuyboxSharePct90d` fields in KeepaData type. - Updated database schema and queries to store Amazon seller status and buy box share percentage. - Enhanced product analysis results with new metrics from Keepa API. - Modified frontend components to display Amazon seller status and buy box share percentage. - Implemented reanalysis functionality for products to refresh Amazon-related metrics.
This commit is contained in:
@@ -13,7 +13,6 @@ import type {
|
||||
SellabilityInfo,
|
||||
SpApiData,
|
||||
} from "./types.ts";
|
||||
|
||||
|
||||
type CategoryInfo = {
|
||||
id: number;
|
||||
@@ -45,6 +44,8 @@ type CategoryRunSummary = {
|
||||
|
||||
const KEEPA_BASE = "https://api.keepa.com";
|
||||
const DOMAIN_US = 1;
|
||||
const AMAZON_US_SELLER_ID = "ATVPDKIKX0DER";
|
||||
const KEEPA_MINUTES_OFFSET = 21_564_000;
|
||||
const DEFAULT_CATEGORY_LIMIT = 32;
|
||||
const DEFAULT_PER_CATEGORY_TOP = 100;
|
||||
const SELLABILITY_BATCH_SIZE = 60;
|
||||
@@ -162,7 +163,16 @@ export async function insertCategoryRunSummary(
|
||||
export async function updateCategoryRunSummary(
|
||||
db: Database,
|
||||
runId: number,
|
||||
summary: Pick<CategoryRunSummary, "topAsinsChecked" | "availableAsins" | "fba" | "fbm" | "skip" | "status" | "error">,
|
||||
summary: Pick<
|
||||
CategoryRunSummary,
|
||||
| "topAsinsChecked"
|
||||
| "availableAsins"
|
||||
| "fba"
|
||||
| "fbm"
|
||||
| "skip"
|
||||
| "status"
|
||||
| "error"
|
||||
>,
|
||||
): Promise<void> {
|
||||
db.run(
|
||||
`
|
||||
@@ -204,14 +214,15 @@ export async function insertProductAnalysisResults(
|
||||
asin, run_id, name, brand, category, unit_cost,
|
||||
current_price, avg_price_90d, avg_price_90d_sheet,
|
||||
selling_price_sheet, sales_rank, sales_rank_avg_90d,
|
||||
seller_count, monthly_sold, rank_drops_30d, rank_drops_90d,
|
||||
seller_count, amazon_is_seller, amazon_buybox_share_pct_90d,
|
||||
monthly_sold, rank_drops_30d, rank_drops_90d,
|
||||
fba_fee, fbm_fee, referral_percent, can_sell,
|
||||
sellability_status, sellability_reason,
|
||||
verdict, confidence, reasoning, fetched_at
|
||||
) VALUES (
|
||||
?, ?, ?, ?, ?, ?, ?, ?, ?, ?,
|
||||
?, ?, ?, ?, ?, ?, ?, ?, ?, ?,
|
||||
?, ?, ?, ?, ?, ?
|
||||
?, ?, ?, ?, ?, ?, ?, ?
|
||||
)
|
||||
ON CONFLICT(asin) DO UPDATE SET
|
||||
run_id = excluded.run_id,
|
||||
@@ -226,6 +237,8 @@ export async function insertProductAnalysisResults(
|
||||
sales_rank = excluded.sales_rank,
|
||||
sales_rank_avg_90d = excluded.sales_rank_avg_90d,
|
||||
seller_count = excluded.seller_count,
|
||||
amazon_is_seller = excluded.amazon_is_seller,
|
||||
amazon_buybox_share_pct_90d = excluded.amazon_buybox_share_pct_90d,
|
||||
monthly_sold = excluded.monthly_sold,
|
||||
rank_drops_30d = excluded.rank_drops_30d,
|
||||
rank_drops_90d = excluded.rank_drops_90d,
|
||||
@@ -265,6 +278,12 @@ export async function insertProductAnalysisResults(
|
||||
rank ?? null,
|
||||
r.product.keepa?.salesRankAvg90 ?? null,
|
||||
r.product.keepa?.sellerCount ?? null,
|
||||
r.product.keepa?.amazonIsSeller == null
|
||||
? null
|
||||
: r.product.keepa.amazonIsSeller
|
||||
? 1
|
||||
: 0,
|
||||
r.product.keepa?.amazonBuyboxSharePct90d ?? null,
|
||||
r.product.keepa?.monthlySold ?? null,
|
||||
r.product.keepa?.salesRankDrops30 ?? null,
|
||||
r.product.keepa?.salesRankDrops90 ?? null,
|
||||
@@ -776,6 +795,14 @@ function parseKeepaProduct(product: Record<string, any>): KeepaData {
|
||||
const monthlySold =
|
||||
pickKeepaNumber(product.monthlySold, stats?.monthlySold) ??
|
||||
salesRankDrops30;
|
||||
const amazonIsSeller = resolveAmazonIsSeller(product, stats, csv);
|
||||
const amazonBuyboxSharePct90d =
|
||||
extractAmazonBuyboxSharePct90d(product, stats) ??
|
||||
computeAmazonBuyBoxSharePctFromHistory(
|
||||
product.buyBoxSellerIdHistory,
|
||||
90,
|
||||
new Set([AMAZON_US_SELLER_ID]),
|
||||
);
|
||||
|
||||
return {
|
||||
currentPrice: extractCurrentPrice(csv),
|
||||
@@ -787,6 +814,8 @@ function parseKeepaProduct(product: Record<string, any>): KeepaData {
|
||||
salesRankDrops30,
|
||||
salesRankDrops90,
|
||||
sellerCount: stats?.current?.[11] ?? null,
|
||||
amazonIsSeller,
|
||||
amazonBuyboxSharePct90d,
|
||||
buyBoxSeller: product.buyBoxSellerId ?? null,
|
||||
buyBoxPrice: stats?.current?.[18] != null ? stats.current[18] / 100 : null,
|
||||
monthlySold,
|
||||
@@ -795,6 +824,108 @@ function parseKeepaProduct(product: Record<string, any>): KeepaData {
|
||||
};
|
||||
}
|
||||
|
||||
function resolveAmazonIsSeller(
|
||||
product: Record<string, any>,
|
||||
stats: Record<string, any> | undefined,
|
||||
csv: number[][] | undefined,
|
||||
): boolean | null {
|
||||
if (typeof product.isAmazonSeller === "boolean")
|
||||
return product.isAmazonSeller;
|
||||
|
||||
if (typeof product.availabilityAmazon === "number") {
|
||||
if (product.availabilityAmazon >= 0) return true;
|
||||
if (
|
||||
product.availabilityAmazon === -1 ||
|
||||
product.availabilityAmazon === -2
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (stats?.buyBoxIsAmazon === true) return true;
|
||||
|
||||
if (typeof stats?.current?.[0] === "number") {
|
||||
if (stats.current[0] > 0) return true;
|
||||
if (stats.current[0] === -1 || stats.current[0] === -2) return false;
|
||||
}
|
||||
|
||||
const latestAmazonPrice = extractLatestPositivePrice(csv?.[0]);
|
||||
if (latestAmazonPrice != null) return true;
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function extractAmazonBuyboxSharePct90d(
|
||||
product: Record<string, any>,
|
||||
stats: Record<string, any> | undefined,
|
||||
): number | null {
|
||||
const candidates: unknown[] = [
|
||||
product.buyBoxStatsAmazon90,
|
||||
stats?.buyBoxStatsAmazon90,
|
||||
product.buyBoxStats?.amazon90,
|
||||
product.buyBoxStats?.amazon?.[90],
|
||||
product.buyBoxStats?.amazon?.["90"],
|
||||
product.buyBoxStats?.[AMAZON_US_SELLER_ID]?.[90],
|
||||
product.buyBoxStats?.[AMAZON_US_SELLER_ID]?.["90"],
|
||||
];
|
||||
|
||||
for (const value of candidates) {
|
||||
if (typeof value !== "number" || !Number.isFinite(value)) continue;
|
||||
if (value < 0 || value > 100) continue;
|
||||
return Math.round(value * 100) / 100;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function computeAmazonBuyBoxSharePctFromHistory(
|
||||
history: unknown,
|
||||
windowDays: number,
|
||||
amazonSellerIds: Set<string>,
|
||||
): number | null {
|
||||
if (!Array.isArray(history) || history.length < 2) return null;
|
||||
|
||||
const nowKeepaMinutes =
|
||||
Math.floor(Date.now() / 60_000) - KEEPA_MINUTES_OFFSET;
|
||||
const windowStart = nowKeepaMinutes - windowDays * 24 * 60;
|
||||
let qualifiedMinutes = 0;
|
||||
let amazonMinutes = 0;
|
||||
|
||||
for (let i = 0; i < history.length - 1; i += 2) {
|
||||
const startMinute = Number.parseInt(String(history[i]), 10);
|
||||
const sellerId = String(history[i + 1] ?? "").toUpperCase();
|
||||
const nextRaw = i + 2 < history.length ? history[i + 2] : nowKeepaMinutes;
|
||||
const endMinute = Number.parseInt(String(nextRaw), 10);
|
||||
|
||||
if (!Number.isFinite(startMinute) || !Number.isFinite(endMinute)) continue;
|
||||
if (endMinute <= startMinute) continue;
|
||||
|
||||
const intervalStart = Math.max(startMinute, windowStart);
|
||||
const intervalEnd = Math.min(endMinute, nowKeepaMinutes);
|
||||
if (intervalEnd <= intervalStart) continue;
|
||||
|
||||
if (sellerId === "-1" || sellerId === "-2") continue;
|
||||
|
||||
const minutes = intervalEnd - intervalStart;
|
||||
qualifiedMinutes += minutes;
|
||||
if (amazonSellerIds.has(sellerId)) {
|
||||
amazonMinutes += minutes;
|
||||
}
|
||||
}
|
||||
|
||||
if (qualifiedMinutes === 0) return null;
|
||||
return Math.round((amazonMinutes / qualifiedMinutes) * 10_000) / 100;
|
||||
}
|
||||
|
||||
function extractLatestPositivePrice(series: unknown): number | null {
|
||||
if (!Array.isArray(series) || series.length < 2) return null;
|
||||
const last = series[series.length - 1];
|
||||
if (typeof last !== "number" || !Number.isFinite(last) || last <= 0) {
|
||||
return null;
|
||||
}
|
||||
return last / 100;
|
||||
}
|
||||
|
||||
async function fetchKeepaEnrichmentMap(
|
||||
asins: string[],
|
||||
): Promise<Map<string, { keepa: KeepaData; title: string }>> {
|
||||
@@ -804,7 +935,7 @@ async function fetchKeepaEnrichmentMap(
|
||||
const chunk = asins.slice(i, i + KEEPA_PRODUCT_CHUNK_SIZE);
|
||||
const asinParam = encodeURIComponent(chunk.join(","));
|
||||
const data = await keepaGetJson(
|
||||
`/product?key=${encodeURIComponent(config.keepaApiKey)}&domain=${DOMAIN_US}&asin=${asinParam}&stats=90`,
|
||||
`/product?key=${encodeURIComponent(config.keepaApiKey)}&domain=${DOMAIN_US}&asin=${asinParam}&stats=90&buybox=1&days=90`,
|
||||
);
|
||||
|
||||
const products = Array.isArray(data?.products) ? data.products : [];
|
||||
@@ -911,7 +1042,10 @@ export async function processCategory(
|
||||
|
||||
const uniqueTopAsins = Array.from(new Set(topAsins));
|
||||
if (uniqueTopAsins.length !== topAsins.length) {
|
||||
log("warn", ` Removed ${topAsins.length - uniqueTopAsins.length} duplicate ASINs before analysis.`);
|
||||
log(
|
||||
"warn",
|
||||
` Removed ${topAsins.length - uniqueTopAsins.length} duplicate ASINs before analysis.`,
|
||||
);
|
||||
}
|
||||
|
||||
log("info", ` Top ASINs fetched: ${uniqueTopAsins.length}`);
|
||||
@@ -922,7 +1056,10 @@ export async function processCategory(
|
||||
return info?.canSell === true && info.sellabilityStatus === "available";
|
||||
});
|
||||
|
||||
log("info", ` Sellable ASINs: ${availableAsins.length}/${uniqueTopAsins.length}`);
|
||||
log(
|
||||
"info",
|
||||
` Sellable ASINs: ${availableAsins.length}/${uniqueTopAsins.length}`,
|
||||
);
|
||||
if (availableAsins.length === 0) {
|
||||
await updateCategoryRunSummary(db, runId, {
|
||||
topAsinsChecked: uniqueTopAsins.length,
|
||||
@@ -1054,7 +1191,8 @@ export async function main(): Promise<void> {
|
||||
assertSpApiPrerequisites();
|
||||
|
||||
mkdirSync(args.outputDir, { recursive: true });
|
||||
const DB_PATH = process.env.RESULTS_DB_PATH || path.join(process.cwd(), "results.db");
|
||||
const DB_PATH =
|
||||
process.env.RESULTS_DB_PATH || path.join(process.cwd(), "results.db");
|
||||
initDb(DB_PATH);
|
||||
const db = getDb(DB_PATH);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user