feat: enhance stalker functionality with inventory sellability checks and update frontend display
This commit is contained in:
112
src/stalker.ts
112
src/stalker.ts
@@ -1,6 +1,8 @@
|
||||
import * as XLSX from "xlsx";
|
||||
import path from "node:path";
|
||||
import { type Database, closeDb, getDb, initDb } from "./database.ts";
|
||||
import { fetchSellabilityBatch } from "./sp-api.ts";
|
||||
import type { SellabilityInfo } from "./types.ts";
|
||||
|
||||
const KEEPA_BASE = "https://api.keepa.com";
|
||||
const DOMAIN_US = "1";
|
||||
@@ -37,6 +39,7 @@ export type StalkerArgs = {
|
||||
dryRun: boolean;
|
||||
resume: boolean;
|
||||
maxSellerRequests: number | null;
|
||||
sellability: boolean;
|
||||
};
|
||||
|
||||
export type StalkerOffer = {
|
||||
@@ -62,6 +65,7 @@ export type StalkerSeller = {
|
||||
type StalkerInventoryItem = {
|
||||
asin: string;
|
||||
rawInventory: unknown;
|
||||
sellability: SellabilityInfo | null;
|
||||
};
|
||||
|
||||
type StalkerAsinResult = {
|
||||
@@ -88,6 +92,9 @@ type StalkerRunStats = {
|
||||
qualifyingSellers: number;
|
||||
sellerMetadataRequests: number;
|
||||
sellerStorefrontRequests: number;
|
||||
inventorySellabilityCheckedAsins: number;
|
||||
inventorySellabilityAvailableAsins: number;
|
||||
inventorySellabilityExcludedAsins: number;
|
||||
stoppedEarly: boolean;
|
||||
};
|
||||
|
||||
@@ -135,6 +142,7 @@ export function parseArgs(argv = process.argv.slice(2)): StalkerArgs {
|
||||
const includeStock = hasFlag(argv, "--include-stock");
|
||||
const dryRun = hasFlag(argv, "--dry-run");
|
||||
const resume = !hasFlag(argv, "--no-resume");
|
||||
const sellability = hasFlag(argv, "--sellability");
|
||||
|
||||
if (maxAsins != null && (!Number.isInteger(maxAsins) || maxAsins <= 0)) {
|
||||
printUsageAndExit("--max-asins must be a positive integer.");
|
||||
@@ -185,6 +193,7 @@ export function parseArgs(argv = process.argv.slice(2)): StalkerArgs {
|
||||
dryRun,
|
||||
resume,
|
||||
maxSellerRequests,
|
||||
sellability,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -276,21 +285,26 @@ export async function runStalker(args: StalkerArgs): Promise<StalkerRunStats> {
|
||||
initDb(args.dbPath);
|
||||
const database = getDb(args.dbPath);
|
||||
const completedAsins = args.resume ? loadPreviouslyScannedAsins(database) : new Set<string>();
|
||||
const asins = cappedAsins.filter((asin) => !completedAsins.has(asin));
|
||||
const resumeFilteredAsins = cappedAsins.filter(
|
||||
(asin) => !completedAsins.has(asin),
|
||||
);
|
||||
const runId = args.dryRun
|
||||
? null
|
||||
: startStalkerRun(database, args.input, asins.length);
|
||||
: startStalkerRun(database, args.input, resumeFilteredAsins.length);
|
||||
const stats: StalkerRunStats = {
|
||||
scannedAsins: 0,
|
||||
sourceAsinsWithMatches: 0,
|
||||
matchedSellers: 0,
|
||||
persistedInventoryAsins: 0,
|
||||
failedAsins: 0,
|
||||
skippedAsins: cappedAsins.length - asins.length,
|
||||
skippedAsins: cappedAsins.length - resumeFilteredAsins.length,
|
||||
candidateSellers: 0,
|
||||
qualifyingSellers: 0,
|
||||
sellerMetadataRequests: 0,
|
||||
sellerStorefrontRequests: 0,
|
||||
inventorySellabilityCheckedAsins: 0,
|
||||
inventorySellabilityAvailableAsins: 0,
|
||||
inventorySellabilityExcludedAsins: 0,
|
||||
stoppedEarly: false,
|
||||
};
|
||||
const context: StalkerRunContext = {
|
||||
@@ -308,8 +322,8 @@ export async function runStalker(args: StalkerArgs): Promise<StalkerRunStats> {
|
||||
console.log(`Stalker resume: skipped ${stats.skippedAsins} previously scanned ASIN(s).`);
|
||||
}
|
||||
|
||||
for (const asin of asins) {
|
||||
console.log(`Stalker: scanning ${asin} (${stats.scannedAsins + 1}/${asins.length})`);
|
||||
for (const asin of resumeFilteredAsins) {
|
||||
console.log(`Stalker: scanning ${asin} (${stats.scannedAsins + 1}/${resumeFilteredAsins.length})`);
|
||||
|
||||
const result = await scanAsin(asin, args, apiKey, context).catch((error) => ({
|
||||
asin,
|
||||
@@ -321,6 +335,10 @@ export async function runStalker(args: StalkerArgs): Promise<StalkerRunStats> {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
}));
|
||||
|
||||
if (args.sellability && !args.dryRun) {
|
||||
await enrichInventorySellability(result, stats);
|
||||
}
|
||||
|
||||
if (!args.dryRun && runId != null) {
|
||||
persistAsinResult(database, runId, result);
|
||||
}
|
||||
@@ -439,6 +457,54 @@ async function scanAsin(
|
||||
};
|
||||
}
|
||||
|
||||
async function enrichInventorySellability(
|
||||
result: StalkerAsinResult,
|
||||
stats: StalkerRunStats,
|
||||
): Promise<void> {
|
||||
const sellers = result.matchedSellers.map(({ seller }) => seller);
|
||||
const items = sellers.flatMap((seller) => seller.storefrontItems);
|
||||
const uniqueAsins = Array.from(new Set(items.map((item) => item.asin)));
|
||||
if (uniqueAsins.length === 0) return;
|
||||
|
||||
console.log(
|
||||
`Stalker inventory sellability: checking ${uniqueAsins.length} ASIN(s) from matched seller storefronts...`,
|
||||
);
|
||||
const sellabilityMap = await fetchSellabilityBatch(uniqueAsins);
|
||||
stats.inventorySellabilityCheckedAsins += uniqueAsins.length;
|
||||
|
||||
for (const asin of uniqueAsins) {
|
||||
const info = sellabilityMap.get(asin) ?? {
|
||||
canSell: null,
|
||||
sellabilityStatus: "unknown" as const,
|
||||
sellabilityReason: "Sellability check returned no result",
|
||||
};
|
||||
|
||||
if (info.sellabilityStatus === "available" && info.canSell === true) {
|
||||
stats.inventorySellabilityAvailableAsins += 1;
|
||||
} else {
|
||||
stats.inventorySellabilityExcludedAsins += 1;
|
||||
}
|
||||
}
|
||||
|
||||
for (const item of items) {
|
||||
item.sellability =
|
||||
sellabilityMap.get(item.asin) ?? {
|
||||
canSell: null,
|
||||
sellabilityStatus: "unknown",
|
||||
sellabilityReason: "Sellability check returned no result",
|
||||
};
|
||||
}
|
||||
|
||||
for (const seller of sellers) {
|
||||
seller.storefrontItems = seller.storefrontItems.filter(
|
||||
(item) =>
|
||||
item.sellability?.canSell === true &&
|
||||
item.sellability.sellabilityStatus === "available",
|
||||
);
|
||||
seller.storefrontAsins = seller.storefrontItems.map((item) => item.asin);
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchKeepaProduct(
|
||||
asin: string,
|
||||
apiKey: string,
|
||||
@@ -755,9 +821,13 @@ function upsertSellerInventory(
|
||||
): void {
|
||||
const insert = database.prepare(
|
||||
`INSERT INTO stalker_seller_inventory (
|
||||
run_id, seller_id, asin, last_seen_at, raw_inventory_json
|
||||
) VALUES (?, ?, ?, ?, ?)
|
||||
run_id, seller_id, asin, can_sell, sellability_status,
|
||||
sellability_reason, last_seen_at, raw_inventory_json
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(run_id, seller_id, asin) DO UPDATE SET
|
||||
can_sell = excluded.can_sell,
|
||||
sellability_status = excluded.sellability_status,
|
||||
sellability_reason = excluded.sellability_reason,
|
||||
last_seen_at = excluded.last_seen_at,
|
||||
raw_inventory_json = excluded.raw_inventory_json`,
|
||||
);
|
||||
@@ -767,6 +837,13 @@ function upsertSellerInventory(
|
||||
runId,
|
||||
seller.sellerId,
|
||||
item.asin,
|
||||
item.sellability?.canSell == null
|
||||
? null
|
||||
: item.sellability.canSell
|
||||
? 1
|
||||
: 0,
|
||||
item.sellability?.sellabilityStatus ?? null,
|
||||
item.sellability?.sellabilityReason ?? null,
|
||||
fetchedAt,
|
||||
JSON.stringify(item.rawInventory),
|
||||
);
|
||||
@@ -846,6 +923,9 @@ function logRunSummary(stats: StalkerRunStats, args: StalkerArgs): void {
|
||||
`qualifying_sellers=${stats.qualifyingSellers}`,
|
||||
`metadata_requests=${stats.sellerMetadataRequests}`,
|
||||
`storefront_requests=${stats.sellerStorefrontRequests}`,
|
||||
`sellability_checked=${stats.inventorySellabilityCheckedAsins}`,
|
||||
`sellability_available=${stats.inventorySellabilityAvailableAsins}`,
|
||||
`sellability_excluded=${stats.inventorySellabilityExcludedAsins}`,
|
||||
`storefront_requests_saved_by_two_phase=${estimatedStorefrontRequestsSaved}`,
|
||||
`persisted_inventory=${stats.persistedInventoryAsins}`,
|
||||
`dry_run=${args.dryRun ? "yes" : "no"}`,
|
||||
@@ -869,6 +949,9 @@ function refreshStalkerRun(
|
||||
matched_sellers = ?,
|
||||
seller_metadata_requests = ?,
|
||||
seller_storefront_requests = ?,
|
||||
inventory_sellability_checked_asins = ?,
|
||||
inventory_sellability_available_asins = ?,
|
||||
inventory_sellability_excluded_asins = ?,
|
||||
persisted_inventory_asins = ?,
|
||||
status = ?,
|
||||
completed_at = CASE WHEN ? = 'running' THEN completed_at ELSE ? END
|
||||
@@ -882,6 +965,9 @@ function refreshStalkerRun(
|
||||
stats.matchedSellers,
|
||||
stats.sellerMetadataRequests,
|
||||
stats.sellerStorefrontRequests,
|
||||
stats.inventorySellabilityCheckedAsins,
|
||||
stats.inventorySellabilityAvailableAsins,
|
||||
stats.inventorySellabilityExcludedAsins,
|
||||
stats.persistedInventoryAsins,
|
||||
status,
|
||||
status,
|
||||
@@ -906,6 +992,9 @@ function finishStalkerRunWithError(
|
||||
matched_sellers = ?,
|
||||
seller_metadata_requests = ?,
|
||||
seller_storefront_requests = ?,
|
||||
inventory_sellability_checked_asins = ?,
|
||||
inventory_sellability_available_asins = ?,
|
||||
inventory_sellability_excluded_asins = ?,
|
||||
persisted_inventory_asins = ?,
|
||||
status = 'failed',
|
||||
error_message = ?,
|
||||
@@ -920,6 +1009,9 @@ function finishStalkerRunWithError(
|
||||
stats.matchedSellers,
|
||||
stats.sellerMetadataRequests,
|
||||
stats.sellerStorefrontRequests,
|
||||
stats.inventorySellabilityCheckedAsins,
|
||||
stats.inventorySellabilityAvailableAsins,
|
||||
stats.inventorySellabilityExcludedAsins,
|
||||
stats.persistedInventoryAsins,
|
||||
errorMessage,
|
||||
new Date().toISOString(),
|
||||
@@ -1013,7 +1105,7 @@ function collectStorefrontItems(
|
||||
const asin = normalizeAsin((value as Record<string, unknown>).asin);
|
||||
if (asin && !seen.has(asin)) {
|
||||
seen.add(asin);
|
||||
items.push({ asin, rawInventory: value });
|
||||
items.push({ asin, rawInventory: value, sellability: null });
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -1021,7 +1113,7 @@ function collectStorefrontItems(
|
||||
const asin = normalizeAsin(value);
|
||||
if (!asin || seen.has(asin)) return;
|
||||
seen.add(asin);
|
||||
items.push({ asin, rawInventory: { asin } });
|
||||
items.push({ asin, rawInventory: { asin }, sellability: null });
|
||||
}
|
||||
|
||||
function extractSellerRatingCount(seller: Record<string, any>): number | null {
|
||||
@@ -1111,7 +1203,7 @@ function hasFlag(args: string[], flag: string): boolean {
|
||||
function printUsageAndExit(message: string): never {
|
||||
console.error(message);
|
||||
console.error(
|
||||
"Usage: bun run stalker --input input/asins.xlsx [--db db/results.db] [--max-asins N] [--offer-limit 100] [--seller-limit 30] [--inventory-limit 200] [--storefront-update-hours 168] [--seller-cache-hours 168] [--max-seller-requests N] [--include-stock] [--dry-run] [--no-resume]",
|
||||
"Usage: bun run stalker --input input/asins.xlsx [--db db/results.db] [--max-asins N] [--offer-limit 100] [--seller-limit 30] [--inventory-limit 200] [--storefront-update-hours 168] [--seller-cache-hours 168] [--max-seller-requests N] [--sellability] [--include-stock] [--dry-run] [--no-resume]",
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user