feat: enhance stalker functionality with inventory sellability checks and update frontend display
This commit is contained in:
@@ -354,6 +354,9 @@ export function initStalkerDb(database: Database): void {
|
|||||||
matched_sellers INTEGER NOT NULL DEFAULT 0,
|
matched_sellers INTEGER NOT NULL DEFAULT 0,
|
||||||
seller_metadata_requests INTEGER NOT NULL DEFAULT 0,
|
seller_metadata_requests INTEGER NOT NULL DEFAULT 0,
|
||||||
seller_storefront_requests INTEGER NOT NULL DEFAULT 0,
|
seller_storefront_requests INTEGER NOT NULL DEFAULT 0,
|
||||||
|
inventory_sellability_checked_asins INTEGER NOT NULL DEFAULT 0,
|
||||||
|
inventory_sellability_available_asins INTEGER NOT NULL DEFAULT 0,
|
||||||
|
inventory_sellability_excluded_asins INTEGER NOT NULL DEFAULT 0,
|
||||||
persisted_inventory_asins INTEGER NOT NULL DEFAULT 0,
|
persisted_inventory_asins INTEGER NOT NULL DEFAULT 0,
|
||||||
status TEXT NOT NULL,
|
status TEXT NOT NULL,
|
||||||
error_message TEXT
|
error_message TEXT
|
||||||
@@ -413,6 +416,9 @@ export function initStalkerDb(database: Database): void {
|
|||||||
run_id INTEGER NOT NULL,
|
run_id INTEGER NOT NULL,
|
||||||
seller_id TEXT NOT NULL,
|
seller_id TEXT NOT NULL,
|
||||||
asin TEXT NOT NULL,
|
asin TEXT NOT NULL,
|
||||||
|
can_sell INTEGER,
|
||||||
|
sellability_status TEXT,
|
||||||
|
sellability_reason TEXT,
|
||||||
last_seen_at TEXT NOT NULL,
|
last_seen_at TEXT NOT NULL,
|
||||||
raw_inventory_json TEXT,
|
raw_inventory_json TEXT,
|
||||||
UNIQUE(run_id, seller_id, asin),
|
UNIQUE(run_id, seller_id, asin),
|
||||||
@@ -448,7 +454,13 @@ function resetLegacyStalkerSchema(database: Database): void {
|
|||||||
if (runColumns.length === 0) return;
|
if (runColumns.length === 0) return;
|
||||||
|
|
||||||
const columnNames = new Set(runColumns.map((column) => column.name));
|
const columnNames = new Set(runColumns.map((column) => column.name));
|
||||||
if (columnNames.has("scanned_asins")) return;
|
if (
|
||||||
|
columnNames.has("scanned_asins") &&
|
||||||
|
columnNames.has("inventory_sellability_checked_asins") &&
|
||||||
|
inventoryColumnsHaveSellability(database)
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
database.run("DROP TABLE IF EXISTS stalker_seller_inventory");
|
database.run("DROP TABLE IF EXISTS stalker_seller_inventory");
|
||||||
database.run("DROP TABLE IF EXISTS stalker_asin_sellers");
|
database.run("DROP TABLE IF EXISTS stalker_asin_sellers");
|
||||||
@@ -456,3 +468,10 @@ function resetLegacyStalkerSchema(database: Database): void {
|
|||||||
database.run("DROP TABLE IF EXISTS stalker_asin_scans");
|
database.run("DROP TABLE IF EXISTS stalker_asin_scans");
|
||||||
database.run("DROP TABLE IF EXISTS stalker_runs");
|
database.run("DROP TABLE IF EXISTS stalker_runs");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function inventoryColumnsHaveSellability(database: Database): boolean {
|
||||||
|
const inventoryColumns = database
|
||||||
|
.query("PRAGMA table_info(stalker_seller_inventory)")
|
||||||
|
.all() as Array<{ name: string }>;
|
||||||
|
return inventoryColumns.some((column) => column.name === "sellability_status");
|
||||||
|
}
|
||||||
|
|||||||
@@ -58,22 +58,15 @@ type StalkerResultRecord = {
|
|||||||
started_at: string;
|
started_at: string;
|
||||||
status: string;
|
status: string;
|
||||||
input_file: string;
|
input_file: string;
|
||||||
source_asin: string;
|
|
||||||
title: string | null;
|
|
||||||
offer_count: number;
|
|
||||||
candidate_seller_count: number;
|
|
||||||
matched_seller_count: number;
|
|
||||||
scan_fetched_at: string;
|
|
||||||
seller_id: string;
|
seller_id: string;
|
||||||
seller_name: string | null;
|
seller_name: string | null;
|
||||||
rating: number | null;
|
rating: number | null;
|
||||||
rating_count: number | null;
|
rating_count: number | null;
|
||||||
storefront_asin_total: number | null;
|
storefront_asin_total: number | null;
|
||||||
persisted_inventory_sample_count: number | null;
|
persisted_inventory_sample_count: number | null;
|
||||||
offer_price: number | null;
|
discovered_from_count: number;
|
||||||
condition: string | null;
|
first_seen_at: string;
|
||||||
is_fba: number | null;
|
last_seen_at: string;
|
||||||
stock: number | null;
|
|
||||||
persisted_inventory_asin_count: number;
|
persisted_inventory_asin_count: number;
|
||||||
inventory_sample_asins: string | null;
|
inventory_sample_asins: string | null;
|
||||||
};
|
};
|
||||||
@@ -690,14 +683,14 @@ function parseStalkerFilters(filters: URLSearchParams) {
|
|||||||
if (q) {
|
if (q) {
|
||||||
const wildcard = `%${q}%`;
|
const wildcard = `%${q}%`;
|
||||||
conditions.push(
|
conditions.push(
|
||||||
`(sc.source_asin LIKE ? OR sc.title LIKE ? OR s.seller_id LIKE ? OR s.seller_name LIKE ? OR EXISTS (
|
`(s.seller_id LIKE ? OR s.seller_name LIKE ? OR EXISTS (
|
||||||
SELECT 1 FROM stalker_seller_inventory inv_q
|
SELECT 1 FROM stalker_seller_inventory inv_q
|
||||||
WHERE inv_q.run_id = r.id
|
WHERE inv_q.run_id = r.id
|
||||||
AND inv_q.seller_id = s.seller_id
|
AND inv_q.seller_id = s.seller_id
|
||||||
AND inv_q.asin LIKE ?
|
AND inv_q.asin LIKE ?
|
||||||
))`,
|
))`,
|
||||||
);
|
);
|
||||||
params.push(wildcard, wildcard, wildcard, wildcard, wildcard);
|
params.push(wildcard, wildcard, wildcard);
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -710,22 +703,19 @@ function parseStalkerSort(sortParam: string | null): string {
|
|||||||
const allowedSort = new Set([
|
const allowedSort = new Set([
|
||||||
"runId",
|
"runId",
|
||||||
"started_at",
|
"started_at",
|
||||||
"source_asin",
|
|
||||||
"title",
|
|
||||||
"seller_id",
|
"seller_id",
|
||||||
"seller_name",
|
"seller_name",
|
||||||
"rating",
|
"rating",
|
||||||
"rating_count",
|
"rating_count",
|
||||||
"offer_price",
|
"discovered_from_count",
|
||||||
"stock",
|
|
||||||
"persisted_inventory_asin_count",
|
"persisted_inventory_asin_count",
|
||||||
"storefront_asin_total",
|
"storefront_asin_total",
|
||||||
"scan_fetched_at",
|
"last_seen_at",
|
||||||
]);
|
]);
|
||||||
const parsed = parseSort(
|
const parsed = parseSort(
|
||||||
sortParam,
|
sortParam,
|
||||||
allowedSort,
|
allowedSort,
|
||||||
"started_at DESC, runId DESC, source_asin ASC",
|
"persisted_inventory_asin_count DESC, last_seen_at DESC, seller_id ASC",
|
||||||
);
|
);
|
||||||
|
|
||||||
return parsed
|
return parsed
|
||||||
@@ -751,22 +741,15 @@ function getStalkerResults(filters: URLSearchParams) {
|
|||||||
r.started_at,
|
r.started_at,
|
||||||
r.status,
|
r.status,
|
||||||
r.input_file,
|
r.input_file,
|
||||||
sc.source_asin,
|
|
||||||
sc.title,
|
|
||||||
sc.offer_count,
|
|
||||||
sc.candidate_seller_count,
|
|
||||||
sc.matched_seller_count,
|
|
||||||
sc.fetched_at AS scan_fetched_at,
|
|
||||||
s.seller_id,
|
s.seller_id,
|
||||||
s.seller_name,
|
s.seller_name,
|
||||||
s.rating,
|
s.rating,
|
||||||
s.rating_count,
|
s.rating_count,
|
||||||
s.storefront_asin_total,
|
s.storefront_asin_total,
|
||||||
s.persisted_inventory_sample_count,
|
s.persisted_inventory_sample_count,
|
||||||
sas.offer_price,
|
COUNT(DISTINCT sc.source_asin) AS discovered_from_count,
|
||||||
sas.condition,
|
MIN(sc.fetched_at) AS first_seen_at,
|
||||||
sas.is_fba,
|
MAX(sc.fetched_at) AS last_seen_at,
|
||||||
sas.stock,
|
|
||||||
COUNT(DISTINCT inv.asin) AS persisted_inventory_asin_count,
|
COUNT(DISTINCT inv.asin) AS persisted_inventory_asin_count,
|
||||||
GROUP_CONCAT(DISTINCT inv.asin) AS inventory_sample_asins
|
GROUP_CONCAT(DISTINCT inv.asin) AS inventory_sample_asins
|
||||||
FROM stalker_asin_sellers sas
|
FROM stalker_asin_sellers sas
|
||||||
@@ -777,7 +760,7 @@ function getStalkerResults(filters: URLSearchParams) {
|
|||||||
ON inv.run_id = r.id
|
ON inv.run_id = r.id
|
||||||
AND inv.seller_id = s.seller_id
|
AND inv.seller_id = s.seller_id
|
||||||
${where}
|
${where}
|
||||||
GROUP BY sas.id
|
GROUP BY r.id, s.seller_id
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const totalRow = db
|
const totalRow = db
|
||||||
@@ -788,14 +771,12 @@ function getStalkerResults(filters: URLSearchParams) {
|
|||||||
.query(
|
.query(
|
||||||
`SELECT
|
`SELECT
|
||||||
COUNT(DISTINCT runId) AS runs,
|
COUNT(DISTINCT runId) AS runs,
|
||||||
COUNT(DISTINCT source_asin) AS sourceAsins,
|
|
||||||
COUNT(DISTINCT seller_id) AS sellers,
|
COUNT(DISTINCT seller_id) AS sellers,
|
||||||
COALESCE(SUM(persisted_inventory_asin_count), 0) AS persistedInventoryAsins
|
COALESCE(SUM(persisted_inventory_asin_count), 0) AS persistedInventoryAsins
|
||||||
FROM (${baseSelect}) stalker_rows`,
|
FROM (${baseSelect}) stalker_rows`,
|
||||||
)
|
)
|
||||||
.get(...params) as {
|
.get(...params) as {
|
||||||
runs: number;
|
runs: number;
|
||||||
sourceAsins: number;
|
|
||||||
sellers: number;
|
sellers: number;
|
||||||
persistedInventoryAsins: number;
|
persistedInventoryAsins: number;
|
||||||
};
|
};
|
||||||
|
|||||||
163
src/stalker-sellability.test.ts
Normal file
163
src/stalker-sellability.test.ts
Normal file
@@ -0,0 +1,163 @@
|
|||||||
|
import { afterAll, beforeEach, expect, mock, test } from "bun:test";
|
||||||
|
import { mkdirSync, rmSync } from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
import * as XLSX from "xlsx";
|
||||||
|
import { closeDb, getDb } from "./database.ts";
|
||||||
|
|
||||||
|
const TEST_DIR = path.join(process.cwd(), "test_output", "stalker-sellability");
|
||||||
|
const originalFetch = globalThis.fetch;
|
||||||
|
const originalKeepaKey = Bun.env.KEEPA_API_KEY;
|
||||||
|
|
||||||
|
const fetchSellabilityBatchMock = mock(async (asins: string[]) => {
|
||||||
|
return new Map(
|
||||||
|
asins.map((asin) => [
|
||||||
|
asin,
|
||||||
|
asin === "B111111111"
|
||||||
|
? {
|
||||||
|
canSell: true,
|
||||||
|
sellabilityStatus: "available" as const,
|
||||||
|
sellabilityReason: "No listing restrictions reported",
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
canSell: false,
|
||||||
|
sellabilityStatus: "restricted" as const,
|
||||||
|
sellabilityReason: "approval required",
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
mock.module("./sp-api.ts", () => ({
|
||||||
|
fetchSellabilityBatch: fetchSellabilityBatchMock,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const modulePromise = import("./stalker.ts");
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
closeDb();
|
||||||
|
rmSync(TEST_DIR, { recursive: true, force: true });
|
||||||
|
mkdirSync(TEST_DIR, { recursive: true });
|
||||||
|
globalThis.fetch = originalFetch;
|
||||||
|
Bun.env.KEEPA_API_KEY = "test-keepa-key";
|
||||||
|
fetchSellabilityBatchMock.mockClear();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
globalThis.fetch = originalFetch;
|
||||||
|
if (originalKeepaKey == null) {
|
||||||
|
delete Bun.env.KEEPA_API_KEY;
|
||||||
|
} else {
|
||||||
|
Bun.env.KEEPA_API_KEY = originalKeepaKey;
|
||||||
|
}
|
||||||
|
closeDb();
|
||||||
|
rmSync(TEST_DIR, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("sellability checks matched seller inventory, not the source ASIN", async () => {
|
||||||
|
const { runStalker } = await modulePromise;
|
||||||
|
const inputPath = path.join(TEST_DIR, "input.xlsx");
|
||||||
|
const dbPath = path.join(TEST_DIR, "stalker.sqlite");
|
||||||
|
const workbook = XLSX.utils.book_new();
|
||||||
|
XLSX.utils.book_append_sheet(
|
||||||
|
workbook,
|
||||||
|
XLSX.utils.json_to_sheet([{ asin: "B000000001" }]),
|
||||||
|
"Input",
|
||||||
|
);
|
||||||
|
XLSX.writeFile(workbook, inputPath);
|
||||||
|
|
||||||
|
globalThis.fetch = mock(async (input: string | URL | Request) => {
|
||||||
|
const rawUrl =
|
||||||
|
typeof input === "string"
|
||||||
|
? input
|
||||||
|
: input instanceof URL
|
||||||
|
? input.toString()
|
||||||
|
: input.url;
|
||||||
|
const url = new URL(rawUrl);
|
||||||
|
|
||||||
|
if (url.pathname === "/product") {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
products: [
|
||||||
|
{
|
||||||
|
asin: "B000000001",
|
||||||
|
title: "Source Product",
|
||||||
|
offers: [{ sellerId: "AQUALIFIED", price: 1999 }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
tokensLeft: 10,
|
||||||
|
refillRate: 10,
|
||||||
|
}),
|
||||||
|
{ status: 200 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url.pathname === "/seller") {
|
||||||
|
const wantsStorefront = url.searchParams.get("storefront") === "1";
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
sellers: {
|
||||||
|
AQUALIFIED: {
|
||||||
|
sellerName: "New Seller",
|
||||||
|
currentRatingCount: 12,
|
||||||
|
asinList: wantsStorefront ? ["B111111111", "B222222222"] : [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
tokensLeft: 10,
|
||||||
|
refillRate: 10,
|
||||||
|
}),
|
||||||
|
{ status: 200 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Response("not found", { status: 404 });
|
||||||
|
}) as unknown as typeof globalThis.fetch;
|
||||||
|
|
||||||
|
const stats = await runStalker({
|
||||||
|
input: inputPath,
|
||||||
|
dbPath,
|
||||||
|
maxAsins: null,
|
||||||
|
storefrontUpdateHours: 168,
|
||||||
|
offerLimit: 20,
|
||||||
|
sellerLimit: 30,
|
||||||
|
inventoryLimit: 200,
|
||||||
|
sellerCacheHours: 168,
|
||||||
|
includeStock: false,
|
||||||
|
dryRun: false,
|
||||||
|
resume: true,
|
||||||
|
maxSellerRequests: null,
|
||||||
|
sellability: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(fetchSellabilityBatchMock.mock.calls.length).toBe(1);
|
||||||
|
expect(fetchSellabilityBatchMock.mock.calls[0]?.[0]).toEqual([
|
||||||
|
"B111111111",
|
||||||
|
"B222222222",
|
||||||
|
]);
|
||||||
|
expect(stats.inventorySellabilityCheckedAsins).toBe(2);
|
||||||
|
expect(stats.inventorySellabilityAvailableAsins).toBe(1);
|
||||||
|
expect(stats.inventorySellabilityExcludedAsins).toBe(1);
|
||||||
|
expect(stats.persistedInventoryAsins).toBe(1);
|
||||||
|
|
||||||
|
const db = getDb(dbPath);
|
||||||
|
const scan = db.query("SELECT source_asin FROM stalker_asin_scans").get() as {
|
||||||
|
source_asin: string;
|
||||||
|
};
|
||||||
|
expect(scan.source_asin).toBe("B000000001");
|
||||||
|
|
||||||
|
const inventory = db
|
||||||
|
.query(
|
||||||
|
"SELECT asin, can_sell, sellability_status FROM stalker_seller_inventory ORDER BY asin",
|
||||||
|
)
|
||||||
|
.all() as Array<{
|
||||||
|
asin: string;
|
||||||
|
can_sell: number | null;
|
||||||
|
sellability_status: string | null;
|
||||||
|
}>;
|
||||||
|
expect(inventory).toEqual([
|
||||||
|
{
|
||||||
|
asin: "B111111111",
|
||||||
|
can_sell: 1,
|
||||||
|
sellability_status: "available",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
@@ -216,6 +216,7 @@ test("runStalker fetches product offers, filters sellers, and persists storefron
|
|||||||
dryRun: false,
|
dryRun: false,
|
||||||
resume: true,
|
resume: true,
|
||||||
maxSellerRequests: null,
|
maxSellerRequests: null,
|
||||||
|
sellability: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(stats.scannedAsins).toBe(1);
|
expect(stats.scannedAsins).toBe(1);
|
||||||
@@ -249,6 +250,9 @@ test("runStalker fetches product offers, filters sellers, and persists storefron
|
|||||||
expect(run.matched_sellers).toBe(1);
|
expect(run.matched_sellers).toBe(1);
|
||||||
expect(run.seller_metadata_requests).toBe(1);
|
expect(run.seller_metadata_requests).toBe(1);
|
||||||
expect(run.seller_storefront_requests).toBe(1);
|
expect(run.seller_storefront_requests).toBe(1);
|
||||||
|
expect(run.inventory_sellability_checked_asins).toBe(0);
|
||||||
|
expect(run.inventory_sellability_available_asins).toBe(0);
|
||||||
|
expect(run.inventory_sellability_excluded_asins).toBe(0);
|
||||||
expect(run.persisted_inventory_asins).toBe(2);
|
expect(run.persisted_inventory_asins).toBe(2);
|
||||||
|
|
||||||
const scan = db.query("SELECT * FROM stalker_asin_scans").get() as any;
|
const scan = db.query("SELECT * FROM stalker_asin_scans").get() as any;
|
||||||
|
|||||||
112
src/stalker.ts
112
src/stalker.ts
@@ -1,6 +1,8 @@
|
|||||||
import * as XLSX from "xlsx";
|
import * as XLSX from "xlsx";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { type Database, closeDb, getDb, initDb } from "./database.ts";
|
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 KEEPA_BASE = "https://api.keepa.com";
|
||||||
const DOMAIN_US = "1";
|
const DOMAIN_US = "1";
|
||||||
@@ -37,6 +39,7 @@ export type StalkerArgs = {
|
|||||||
dryRun: boolean;
|
dryRun: boolean;
|
||||||
resume: boolean;
|
resume: boolean;
|
||||||
maxSellerRequests: number | null;
|
maxSellerRequests: number | null;
|
||||||
|
sellability: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type StalkerOffer = {
|
export type StalkerOffer = {
|
||||||
@@ -62,6 +65,7 @@ export type StalkerSeller = {
|
|||||||
type StalkerInventoryItem = {
|
type StalkerInventoryItem = {
|
||||||
asin: string;
|
asin: string;
|
||||||
rawInventory: unknown;
|
rawInventory: unknown;
|
||||||
|
sellability: SellabilityInfo | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
type StalkerAsinResult = {
|
type StalkerAsinResult = {
|
||||||
@@ -88,6 +92,9 @@ type StalkerRunStats = {
|
|||||||
qualifyingSellers: number;
|
qualifyingSellers: number;
|
||||||
sellerMetadataRequests: number;
|
sellerMetadataRequests: number;
|
||||||
sellerStorefrontRequests: number;
|
sellerStorefrontRequests: number;
|
||||||
|
inventorySellabilityCheckedAsins: number;
|
||||||
|
inventorySellabilityAvailableAsins: number;
|
||||||
|
inventorySellabilityExcludedAsins: number;
|
||||||
stoppedEarly: boolean;
|
stoppedEarly: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -135,6 +142,7 @@ export function parseArgs(argv = process.argv.slice(2)): StalkerArgs {
|
|||||||
const includeStock = hasFlag(argv, "--include-stock");
|
const includeStock = hasFlag(argv, "--include-stock");
|
||||||
const dryRun = hasFlag(argv, "--dry-run");
|
const dryRun = hasFlag(argv, "--dry-run");
|
||||||
const resume = !hasFlag(argv, "--no-resume");
|
const resume = !hasFlag(argv, "--no-resume");
|
||||||
|
const sellability = hasFlag(argv, "--sellability");
|
||||||
|
|
||||||
if (maxAsins != null && (!Number.isInteger(maxAsins) || maxAsins <= 0)) {
|
if (maxAsins != null && (!Number.isInteger(maxAsins) || maxAsins <= 0)) {
|
||||||
printUsageAndExit("--max-asins must be a positive integer.");
|
printUsageAndExit("--max-asins must be a positive integer.");
|
||||||
@@ -185,6 +193,7 @@ export function parseArgs(argv = process.argv.slice(2)): StalkerArgs {
|
|||||||
dryRun,
|
dryRun,
|
||||||
resume,
|
resume,
|
||||||
maxSellerRequests,
|
maxSellerRequests,
|
||||||
|
sellability,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -276,21 +285,26 @@ export async function runStalker(args: StalkerArgs): Promise<StalkerRunStats> {
|
|||||||
initDb(args.dbPath);
|
initDb(args.dbPath);
|
||||||
const database = getDb(args.dbPath);
|
const database = getDb(args.dbPath);
|
||||||
const completedAsins = args.resume ? loadPreviouslyScannedAsins(database) : new Set<string>();
|
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
|
const runId = args.dryRun
|
||||||
? null
|
? null
|
||||||
: startStalkerRun(database, args.input, asins.length);
|
: startStalkerRun(database, args.input, resumeFilteredAsins.length);
|
||||||
const stats: StalkerRunStats = {
|
const stats: StalkerRunStats = {
|
||||||
scannedAsins: 0,
|
scannedAsins: 0,
|
||||||
sourceAsinsWithMatches: 0,
|
sourceAsinsWithMatches: 0,
|
||||||
matchedSellers: 0,
|
matchedSellers: 0,
|
||||||
persistedInventoryAsins: 0,
|
persistedInventoryAsins: 0,
|
||||||
failedAsins: 0,
|
failedAsins: 0,
|
||||||
skippedAsins: cappedAsins.length - asins.length,
|
skippedAsins: cappedAsins.length - resumeFilteredAsins.length,
|
||||||
candidateSellers: 0,
|
candidateSellers: 0,
|
||||||
qualifyingSellers: 0,
|
qualifyingSellers: 0,
|
||||||
sellerMetadataRequests: 0,
|
sellerMetadataRequests: 0,
|
||||||
sellerStorefrontRequests: 0,
|
sellerStorefrontRequests: 0,
|
||||||
|
inventorySellabilityCheckedAsins: 0,
|
||||||
|
inventorySellabilityAvailableAsins: 0,
|
||||||
|
inventorySellabilityExcludedAsins: 0,
|
||||||
stoppedEarly: false,
|
stoppedEarly: false,
|
||||||
};
|
};
|
||||||
const context: StalkerRunContext = {
|
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).`);
|
console.log(`Stalker resume: skipped ${stats.skippedAsins} previously scanned ASIN(s).`);
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const asin of asins) {
|
for (const asin of resumeFilteredAsins) {
|
||||||
console.log(`Stalker: scanning ${asin} (${stats.scannedAsins + 1}/${asins.length})`);
|
console.log(`Stalker: scanning ${asin} (${stats.scannedAsins + 1}/${resumeFilteredAsins.length})`);
|
||||||
|
|
||||||
const result = await scanAsin(asin, args, apiKey, context).catch((error) => ({
|
const result = await scanAsin(asin, args, apiKey, context).catch((error) => ({
|
||||||
asin,
|
asin,
|
||||||
@@ -321,6 +335,10 @@ export async function runStalker(args: StalkerArgs): Promise<StalkerRunStats> {
|
|||||||
error: error instanceof Error ? error.message : String(error),
|
error: error instanceof Error ? error.message : String(error),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
if (args.sellability && !args.dryRun) {
|
||||||
|
await enrichInventorySellability(result, stats);
|
||||||
|
}
|
||||||
|
|
||||||
if (!args.dryRun && runId != null) {
|
if (!args.dryRun && runId != null) {
|
||||||
persistAsinResult(database, runId, result);
|
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(
|
async function fetchKeepaProduct(
|
||||||
asin: string,
|
asin: string,
|
||||||
apiKey: string,
|
apiKey: string,
|
||||||
@@ -755,9 +821,13 @@ function upsertSellerInventory(
|
|||||||
): void {
|
): void {
|
||||||
const insert = database.prepare(
|
const insert = database.prepare(
|
||||||
`INSERT INTO stalker_seller_inventory (
|
`INSERT INTO stalker_seller_inventory (
|
||||||
run_id, seller_id, asin, last_seen_at, raw_inventory_json
|
run_id, seller_id, asin, can_sell, sellability_status,
|
||||||
) VALUES (?, ?, ?, ?, ?)
|
sellability_reason, last_seen_at, raw_inventory_json
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
ON CONFLICT(run_id, seller_id, asin) DO UPDATE SET
|
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,
|
last_seen_at = excluded.last_seen_at,
|
||||||
raw_inventory_json = excluded.raw_inventory_json`,
|
raw_inventory_json = excluded.raw_inventory_json`,
|
||||||
);
|
);
|
||||||
@@ -767,6 +837,13 @@ function upsertSellerInventory(
|
|||||||
runId,
|
runId,
|
||||||
seller.sellerId,
|
seller.sellerId,
|
||||||
item.asin,
|
item.asin,
|
||||||
|
item.sellability?.canSell == null
|
||||||
|
? null
|
||||||
|
: item.sellability.canSell
|
||||||
|
? 1
|
||||||
|
: 0,
|
||||||
|
item.sellability?.sellabilityStatus ?? null,
|
||||||
|
item.sellability?.sellabilityReason ?? null,
|
||||||
fetchedAt,
|
fetchedAt,
|
||||||
JSON.stringify(item.rawInventory),
|
JSON.stringify(item.rawInventory),
|
||||||
);
|
);
|
||||||
@@ -846,6 +923,9 @@ function logRunSummary(stats: StalkerRunStats, args: StalkerArgs): void {
|
|||||||
`qualifying_sellers=${stats.qualifyingSellers}`,
|
`qualifying_sellers=${stats.qualifyingSellers}`,
|
||||||
`metadata_requests=${stats.sellerMetadataRequests}`,
|
`metadata_requests=${stats.sellerMetadataRequests}`,
|
||||||
`storefront_requests=${stats.sellerStorefrontRequests}`,
|
`storefront_requests=${stats.sellerStorefrontRequests}`,
|
||||||
|
`sellability_checked=${stats.inventorySellabilityCheckedAsins}`,
|
||||||
|
`sellability_available=${stats.inventorySellabilityAvailableAsins}`,
|
||||||
|
`sellability_excluded=${stats.inventorySellabilityExcludedAsins}`,
|
||||||
`storefront_requests_saved_by_two_phase=${estimatedStorefrontRequestsSaved}`,
|
`storefront_requests_saved_by_two_phase=${estimatedStorefrontRequestsSaved}`,
|
||||||
`persisted_inventory=${stats.persistedInventoryAsins}`,
|
`persisted_inventory=${stats.persistedInventoryAsins}`,
|
||||||
`dry_run=${args.dryRun ? "yes" : "no"}`,
|
`dry_run=${args.dryRun ? "yes" : "no"}`,
|
||||||
@@ -869,6 +949,9 @@ function refreshStalkerRun(
|
|||||||
matched_sellers = ?,
|
matched_sellers = ?,
|
||||||
seller_metadata_requests = ?,
|
seller_metadata_requests = ?,
|
||||||
seller_storefront_requests = ?,
|
seller_storefront_requests = ?,
|
||||||
|
inventory_sellability_checked_asins = ?,
|
||||||
|
inventory_sellability_available_asins = ?,
|
||||||
|
inventory_sellability_excluded_asins = ?,
|
||||||
persisted_inventory_asins = ?,
|
persisted_inventory_asins = ?,
|
||||||
status = ?,
|
status = ?,
|
||||||
completed_at = CASE WHEN ? = 'running' THEN completed_at ELSE ? END
|
completed_at = CASE WHEN ? = 'running' THEN completed_at ELSE ? END
|
||||||
@@ -882,6 +965,9 @@ function refreshStalkerRun(
|
|||||||
stats.matchedSellers,
|
stats.matchedSellers,
|
||||||
stats.sellerMetadataRequests,
|
stats.sellerMetadataRequests,
|
||||||
stats.sellerStorefrontRequests,
|
stats.sellerStorefrontRequests,
|
||||||
|
stats.inventorySellabilityCheckedAsins,
|
||||||
|
stats.inventorySellabilityAvailableAsins,
|
||||||
|
stats.inventorySellabilityExcludedAsins,
|
||||||
stats.persistedInventoryAsins,
|
stats.persistedInventoryAsins,
|
||||||
status,
|
status,
|
||||||
status,
|
status,
|
||||||
@@ -906,6 +992,9 @@ function finishStalkerRunWithError(
|
|||||||
matched_sellers = ?,
|
matched_sellers = ?,
|
||||||
seller_metadata_requests = ?,
|
seller_metadata_requests = ?,
|
||||||
seller_storefront_requests = ?,
|
seller_storefront_requests = ?,
|
||||||
|
inventory_sellability_checked_asins = ?,
|
||||||
|
inventory_sellability_available_asins = ?,
|
||||||
|
inventory_sellability_excluded_asins = ?,
|
||||||
persisted_inventory_asins = ?,
|
persisted_inventory_asins = ?,
|
||||||
status = 'failed',
|
status = 'failed',
|
||||||
error_message = ?,
|
error_message = ?,
|
||||||
@@ -920,6 +1009,9 @@ function finishStalkerRunWithError(
|
|||||||
stats.matchedSellers,
|
stats.matchedSellers,
|
||||||
stats.sellerMetadataRequests,
|
stats.sellerMetadataRequests,
|
||||||
stats.sellerStorefrontRequests,
|
stats.sellerStorefrontRequests,
|
||||||
|
stats.inventorySellabilityCheckedAsins,
|
||||||
|
stats.inventorySellabilityAvailableAsins,
|
||||||
|
stats.inventorySellabilityExcludedAsins,
|
||||||
stats.persistedInventoryAsins,
|
stats.persistedInventoryAsins,
|
||||||
errorMessage,
|
errorMessage,
|
||||||
new Date().toISOString(),
|
new Date().toISOString(),
|
||||||
@@ -1013,7 +1105,7 @@ function collectStorefrontItems(
|
|||||||
const asin = normalizeAsin((value as Record<string, unknown>).asin);
|
const asin = normalizeAsin((value as Record<string, unknown>).asin);
|
||||||
if (asin && !seen.has(asin)) {
|
if (asin && !seen.has(asin)) {
|
||||||
seen.add(asin);
|
seen.add(asin);
|
||||||
items.push({ asin, rawInventory: value });
|
items.push({ asin, rawInventory: value, sellability: null });
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -1021,7 +1113,7 @@ function collectStorefrontItems(
|
|||||||
const asin = normalizeAsin(value);
|
const asin = normalizeAsin(value);
|
||||||
if (!asin || seen.has(asin)) return;
|
if (!asin || seen.has(asin)) return;
|
||||||
seen.add(asin);
|
seen.add(asin);
|
||||||
items.push({ asin, rawInventory: { asin } });
|
items.push({ asin, rawInventory: { asin }, sellability: null });
|
||||||
}
|
}
|
||||||
|
|
||||||
function extractSellerRatingCount(seller: Record<string, any>): number | 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 {
|
function printUsageAndExit(message: string): never {
|
||||||
console.error(message);
|
console.error(message);
|
||||||
console.error(
|
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);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -114,22 +114,15 @@ type StalkerResultItem = {
|
|||||||
started_at: string;
|
started_at: string;
|
||||||
status: string;
|
status: string;
|
||||||
input_file: string;
|
input_file: string;
|
||||||
source_asin: string;
|
|
||||||
title: string | null;
|
|
||||||
offer_count: number;
|
|
||||||
candidate_seller_count: number;
|
|
||||||
matched_seller_count: number;
|
|
||||||
scan_fetched_at: string;
|
|
||||||
seller_id: string;
|
seller_id: string;
|
||||||
seller_name: string | null;
|
seller_name: string | null;
|
||||||
rating: number | null;
|
rating: number | null;
|
||||||
rating_count: number | null;
|
rating_count: number | null;
|
||||||
storefront_asin_total: number | null;
|
storefront_asin_total: number | null;
|
||||||
persisted_inventory_sample_count: number | null;
|
persisted_inventory_sample_count: number | null;
|
||||||
offer_price: number | null;
|
discovered_from_count: number;
|
||||||
condition: string | null;
|
first_seen_at: string;
|
||||||
is_fba: number | null;
|
last_seen_at: string;
|
||||||
stock: number | null;
|
|
||||||
persisted_inventory_asin_count: number;
|
persisted_inventory_asin_count: number;
|
||||||
inventory_sample_asins: string | null;
|
inventory_sample_asins: string | null;
|
||||||
};
|
};
|
||||||
@@ -138,7 +131,6 @@ type StalkerResultsResponse = {
|
|||||||
items: StalkerResultItem[];
|
items: StalkerResultItem[];
|
||||||
summary: {
|
summary: {
|
||||||
runs: number;
|
runs: number;
|
||||||
sourceAsins: number;
|
|
||||||
sellers: number;
|
sellers: number;
|
||||||
persistedInventoryAsins: number;
|
persistedInventoryAsins: number;
|
||||||
};
|
};
|
||||||
@@ -907,7 +899,7 @@ function StalkerExplorer({ onBack }: { onBack: () => void }) {
|
|||||||
const [maxRatingCount, setMaxRatingCount] = useState("30");
|
const [maxRatingCount, setMaxRatingCount] = useState("30");
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
const [pageSize, setPageSize] = useState(25);
|
const [pageSize, setPageSize] = useState(25);
|
||||||
const [sort, setSort] = useState<SortState>({ field: "started_at", direction: "DESC" });
|
const [sort, setSort] = useState<SortState>({ field: "persisted_inventory_asin_count", direction: "DESC" });
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
@@ -943,7 +935,7 @@ function StalkerExplorer({ onBack }: { onBack: () => void }) {
|
|||||||
<button className="back" onClick={onBack}>Back</button>
|
<button className="back" onClick={onBack}>Back</button>
|
||||||
|
|
||||||
<div className="card">
|
<div className="card">
|
||||||
<h2>Stalker Results</h2>
|
<h2>Seller Storefronts</h2>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="metrics">
|
<div className="metrics">
|
||||||
@@ -951,23 +943,19 @@ function StalkerExplorer({ onBack }: { onBack: () => void }) {
|
|||||||
<div className="label">Runs</div>
|
<div className="label">Runs</div>
|
||||||
<div className="value">{formatNumber(results?.summary.runs)}</div>
|
<div className="value">{formatNumber(results?.summary.runs)}</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="metric">
|
|
||||||
<div className="label">Source ASINs</div>
|
|
||||||
<div className="value">{formatNumber(results?.summary.sourceAsins)}</div>
|
|
||||||
</div>
|
|
||||||
<div className="metric">
|
<div className="metric">
|
||||||
<div className="label">Matched sellers</div>
|
<div className="label">Matched sellers</div>
|
||||||
<div className="value">{formatNumber(results?.summary.sellers)}</div>
|
<div className="value">{formatNumber(results?.summary.sellers)}</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="metric">
|
<div className="metric">
|
||||||
<div className="label">Persisted inventory ASINs</div>
|
<div className="label">Sellable inventory ASINs</div>
|
||||||
<div className="value">{formatNumber(results?.summary.persistedInventoryAsins)}</div>
|
<div className="value">{formatNumber(results?.summary.persistedInventoryAsins)}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="card">
|
<div className="card">
|
||||||
<div className="toolbar">
|
<div className="toolbar">
|
||||||
<input value={search} onChange={(e) => { setPage(1); setSearch(e.target.value); }} placeholder="Search source ASIN/title/seller/inventory" />
|
<input value={search} onChange={(e) => { setPage(1); setSearch(e.target.value); }} placeholder="Search seller or sellable ASIN" />
|
||||||
<input value={sellerId} onChange={(e) => { setPage(1); setSellerId(e.target.value.toUpperCase()); }} placeholder="Seller ID" />
|
<input value={sellerId} onChange={(e) => { setPage(1); setSellerId(e.target.value.toUpperCase()); }} placeholder="Seller ID" />
|
||||||
<input value={runId} onChange={(e) => { setPage(1); setRunId(e.target.value); }} placeholder="Run ID" />
|
<input value={runId} onChange={(e) => { setPage(1); setRunId(e.target.value); }} placeholder="Run ID" />
|
||||||
<input value={minRatingCount} onChange={(e) => { setPage(1); setMinRatingCount(e.target.value); }} placeholder="Min rating count" />
|
<input value={minRatingCount} onChange={(e) => { setPage(1); setMinRatingCount(e.target.value); }} placeholder="Min rating count" />
|
||||||
@@ -986,24 +974,20 @@ function StalkerExplorer({ onBack }: { onBack: () => void }) {
|
|||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th><button onClick={() => setSort(nextSort(sort, "runId"))}>Run</button></th>
|
<th><button onClick={() => setSort(nextSort(sort, "runId"))}>Run</button></th>
|
||||||
<th><button onClick={() => setSort(nextSort(sort, "started_at"))}>Started</button></th>
|
|
||||||
<th><button onClick={() => setSort(nextSort(sort, "source_asin"))}>Source ASIN</button></th>
|
|
||||||
<th><button onClick={() => setSort(nextSort(sort, "title"))}>Title</button></th>
|
|
||||||
<th><button onClick={() => setSort(nextSort(sort, "seller_id"))}>Seller ID</button></th>
|
<th><button onClick={() => setSort(nextSort(sort, "seller_id"))}>Seller ID</button></th>
|
||||||
<th><button onClick={() => setSort(nextSort(sort, "seller_name"))}>Seller</button></th>
|
<th><button onClick={() => setSort(nextSort(sort, "seller_name"))}>Seller</button></th>
|
||||||
<th><button onClick={() => setSort(nextSort(sort, "rating"))}>Rating</button></th>
|
<th><button onClick={() => setSort(nextSort(sort, "rating"))}>Rating</button></th>
|
||||||
<th><button onClick={() => setSort(nextSort(sort, "rating_count"))}>Rating Count</button></th>
|
<th><button onClick={() => setSort(nextSort(sort, "rating_count"))}>Rating Count</button></th>
|
||||||
<th><button onClick={() => setSort(nextSort(sort, "offer_price"))}>Offer</button></th>
|
<th><button onClick={() => setSort(nextSort(sort, "discovered_from_count"))}>Hits</button></th>
|
||||||
<th>FBA</th>
|
|
||||||
<th><button onClick={() => setSort(nextSort(sort, "stock"))}>Stock</button></th>
|
|
||||||
<th><button onClick={() => setSort(nextSort(sort, "storefront_asin_total"))}>Storefront total</button></th>
|
<th><button onClick={() => setSort(nextSort(sort, "storefront_asin_total"))}>Storefront total</button></th>
|
||||||
<th><button onClick={() => setSort(nextSort(sort, "persisted_inventory_asin_count"))}>Persisted sample</button></th>
|
<th><button onClick={() => setSort(nextSort(sort, "persisted_inventory_asin_count"))}>Sellable sample</button></th>
|
||||||
<th className="inventory-col">Inventory ASIN sample</th>
|
<th><button onClick={() => setSort(nextSort(sort, "last_seen_at"))}>Last Seen</button></th>
|
||||||
|
<th className="inventory-col">Sellable inventory ASIN sample</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<tr><td colSpan={14}>Loading...</td></tr>
|
<tr><td colSpan={10}>Loading...</td></tr>
|
||||||
) : results?.items.length ? (
|
) : results?.items.length ? (
|
||||||
results.items.map((item) => {
|
results.items.map((item) => {
|
||||||
const inventorySample = (item.inventory_sample_asins ?? "")
|
const inventorySample = (item.inventory_sample_asins ?? "")
|
||||||
@@ -1011,20 +995,16 @@ function StalkerExplorer({ onBack }: { onBack: () => void }) {
|
|||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.slice(0, 20);
|
.slice(0, 20);
|
||||||
return (
|
return (
|
||||||
<tr key={`${item.runId}-${item.source_asin}-${item.seller_id}`}>
|
<tr key={`${item.runId}-${item.seller_id}`}>
|
||||||
<td>{item.runId}</td>
|
<td>{item.runId}</td>
|
||||||
<td>{formatDate(item.started_at)}</td>
|
|
||||||
<td><a href={`http://amazon.com/dp/${item.source_asin}`} target="_blank" rel="noreferrer">{item.source_asin}</a></td>
|
|
||||||
<td className="product-col">{item.title || "-"}</td>
|
|
||||||
<td>{item.seller_id}</td>
|
<td>{item.seller_id}</td>
|
||||||
<td>{item.seller_name || "-"}</td>
|
<td>{item.seller_name || "-"}</td>
|
||||||
<td>{formatNumber(item.rating)}</td>
|
<td>{formatNumber(item.rating)}</td>
|
||||||
<td>{formatNumber(item.rating_count)}</td>
|
<td>{formatNumber(item.rating_count)}</td>
|
||||||
<td>{formatCurrency(item.offer_price)}</td>
|
<td>{formatNumber(item.discovered_from_count)}</td>
|
||||||
<td>{formatBoolean(item.is_fba)}</td>
|
|
||||||
<td>{formatNumber(item.stock)}</td>
|
|
||||||
<td>{formatNumber(item.storefront_asin_total)}</td>
|
<td>{formatNumber(item.storefront_asin_total)}</td>
|
||||||
<td>{formatNumber(item.persisted_inventory_asin_count)}</td>
|
<td>{formatNumber(item.persisted_inventory_asin_count)}</td>
|
||||||
|
<td>{formatDate(item.last_seen_at)}</td>
|
||||||
<td className="inventory-col">
|
<td className="inventory-col">
|
||||||
{inventorySample.length === 0 ? "-" : inventorySample.map((asin) => (
|
{inventorySample.length === 0 ? "-" : inventorySample.map((asin) => (
|
||||||
<a key={asin} href={`http://amazon.com/dp/${asin}`} target="_blank" rel="noreferrer">{asin}</a>
|
<a key={asin} href={`http://amazon.com/dp/${asin}`} target="_blank" rel="noreferrer">{asin}</a>
|
||||||
@@ -1034,7 +1014,7 @@ function StalkerExplorer({ onBack }: { onBack: () => void }) {
|
|||||||
);
|
);
|
||||||
})
|
})
|
||||||
) : (
|
) : (
|
||||||
<tr><td colSpan={14}>No stalker results found</td></tr>
|
<tr><td colSpan={10}>No seller storefronts found</td></tr>
|
||||||
)}
|
)}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|||||||
Reference in New Issue
Block a user