feat: add Stalker results page with filtering and pagination
- Introduced StalkerResultItem and StalkerResultsResponse types for handling API responses. - Implemented StalkerExplorer component for displaying Stalker results with search and filter options. - Added sorting functionality for Stalker results table. - Enhanced Dashboard to include a button for navigating to Stalker results. - Updated routing to support Stalker results page. - Improved styles for section headers and inventory columns in the results table.
This commit is contained in:
122
src/database.ts
122
src/database.ts
@@ -333,4 +333,126 @@ export function initDb(dbPath: string): void {
|
||||
database.run(
|
||||
`CREATE INDEX IF NOT EXISTS idx_product_results_fetched_at ON product_analysis_results(fetched_at DESC);`,
|
||||
);
|
||||
initStalkerDb(database);
|
||||
}
|
||||
|
||||
export function initStalkerDb(database: Database): void {
|
||||
resetLegacyStalkerSchema(database);
|
||||
|
||||
database.run(`
|
||||
CREATE TABLE IF NOT EXISTS stalker_runs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
input_file TEXT NOT NULL,
|
||||
started_at TEXT NOT NULL,
|
||||
completed_at TEXT,
|
||||
requested_asins INTEGER NOT NULL DEFAULT 0,
|
||||
skipped_asins INTEGER NOT NULL DEFAULT 0,
|
||||
scanned_asins INTEGER NOT NULL DEFAULT 0,
|
||||
source_asins_with_matches INTEGER NOT NULL DEFAULT 0,
|
||||
candidate_sellers INTEGER NOT NULL DEFAULT 0,
|
||||
qualifying_sellers INTEGER NOT NULL DEFAULT 0,
|
||||
matched_sellers INTEGER NOT NULL DEFAULT 0,
|
||||
seller_metadata_requests INTEGER NOT NULL DEFAULT 0,
|
||||
seller_storefront_requests INTEGER NOT NULL DEFAULT 0,
|
||||
persisted_inventory_asins INTEGER NOT NULL DEFAULT 0,
|
||||
status TEXT NOT NULL,
|
||||
error_message TEXT
|
||||
);
|
||||
`);
|
||||
|
||||
database.run(`
|
||||
CREATE TABLE IF NOT EXISTS stalker_asin_scans (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
run_id INTEGER NOT NULL,
|
||||
source_asin TEXT NOT NULL,
|
||||
title TEXT,
|
||||
offer_count INTEGER NOT NULL DEFAULT 0,
|
||||
candidate_seller_count INTEGER NOT NULL DEFAULT 0,
|
||||
matched_seller_count INTEGER NOT NULL DEFAULT 0,
|
||||
fetched_at TEXT NOT NULL,
|
||||
raw_product_json TEXT,
|
||||
UNIQUE(run_id, source_asin),
|
||||
FOREIGN KEY (run_id) REFERENCES stalker_runs(id) ON DELETE CASCADE
|
||||
);
|
||||
`);
|
||||
|
||||
database.run(`
|
||||
CREATE TABLE IF NOT EXISTS stalker_sellers (
|
||||
seller_id TEXT PRIMARY KEY,
|
||||
seller_name TEXT,
|
||||
rating REAL,
|
||||
rating_count INTEGER,
|
||||
storefront_asin_total INTEGER,
|
||||
persisted_inventory_sample_count INTEGER,
|
||||
last_updated_at TEXT NOT NULL,
|
||||
raw_seller_json TEXT
|
||||
);
|
||||
`);
|
||||
|
||||
database.run(`
|
||||
CREATE TABLE IF NOT EXISTS stalker_asin_sellers (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
scan_id INTEGER NOT NULL,
|
||||
seller_id TEXT NOT NULL,
|
||||
offer_price REAL,
|
||||
condition TEXT,
|
||||
is_fba INTEGER,
|
||||
stock INTEGER,
|
||||
seller_rating REAL,
|
||||
seller_rating_count INTEGER,
|
||||
raw_offer_json TEXT,
|
||||
UNIQUE(scan_id, seller_id),
|
||||
FOREIGN KEY (scan_id) REFERENCES stalker_asin_scans(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (seller_id) REFERENCES stalker_sellers(seller_id)
|
||||
);
|
||||
`);
|
||||
|
||||
database.run(`
|
||||
CREATE TABLE IF NOT EXISTS stalker_seller_inventory (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
run_id INTEGER NOT NULL,
|
||||
seller_id TEXT NOT NULL,
|
||||
asin TEXT NOT NULL,
|
||||
last_seen_at TEXT NOT NULL,
|
||||
raw_inventory_json TEXT,
|
||||
UNIQUE(run_id, seller_id, asin),
|
||||
FOREIGN KEY (run_id) REFERENCES stalker_runs(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (seller_id) REFERENCES stalker_sellers(seller_id)
|
||||
);
|
||||
`);
|
||||
|
||||
database.run(
|
||||
`CREATE INDEX IF NOT EXISTS idx_stalker_runs_started_at ON stalker_runs(started_at DESC);`,
|
||||
);
|
||||
database.run(
|
||||
`CREATE INDEX IF NOT EXISTS idx_stalker_scans_run_id ON stalker_asin_scans(run_id);`,
|
||||
);
|
||||
database.run(
|
||||
`CREATE INDEX IF NOT EXISTS idx_stalker_scans_source_asin ON stalker_asin_scans(source_asin);`,
|
||||
);
|
||||
database.run(
|
||||
`CREATE INDEX IF NOT EXISTS idx_stalker_asin_sellers_seller_id ON stalker_asin_sellers(seller_id);`,
|
||||
);
|
||||
database.run(
|
||||
`CREATE INDEX IF NOT EXISTS idx_stalker_inventory_seller_id ON stalker_seller_inventory(seller_id);`,
|
||||
);
|
||||
database.run(
|
||||
`CREATE INDEX IF NOT EXISTS idx_stalker_inventory_asin ON stalker_seller_inventory(asin);`,
|
||||
);
|
||||
}
|
||||
|
||||
function resetLegacyStalkerSchema(database: Database): void {
|
||||
const runColumns = database
|
||||
.query("PRAGMA table_info(stalker_runs)")
|
||||
.all() as Array<{ name: string }>;
|
||||
if (runColumns.length === 0) return;
|
||||
|
||||
const columnNames = new Set(runColumns.map((column) => column.name));
|
||||
if (columnNames.has("scanned_asins")) return;
|
||||
|
||||
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_sellers");
|
||||
database.run("DROP TABLE IF EXISTS stalker_asin_scans");
|
||||
database.run("DROP TABLE IF EXISTS stalker_runs");
|
||||
}
|
||||
|
||||
194
src/server.ts
194
src/server.ts
@@ -53,6 +53,31 @@ type ProductListRecord = {
|
||||
fetched_at: string;
|
||||
};
|
||||
|
||||
type StalkerResultRecord = {
|
||||
runId: number;
|
||||
started_at: string;
|
||||
status: 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_name: string | null;
|
||||
rating: number | null;
|
||||
rating_count: number | null;
|
||||
storefront_asin_total: number | null;
|
||||
persisted_inventory_sample_count: number | null;
|
||||
offer_price: number | null;
|
||||
condition: string | null;
|
||||
is_fba: number | null;
|
||||
stock: number | null;
|
||||
persisted_inventory_asin_count: number;
|
||||
inventory_sample_asins: string | null;
|
||||
};
|
||||
|
||||
const DB_PATH = process.env.RESULTS_DB_PATH || path.join("db", "results.db");
|
||||
const DEFAULT_PAGE_SIZE = 25;
|
||||
const MAX_PAGE_SIZE = 200;
|
||||
@@ -629,6 +654,170 @@ function getProductList(filters: URLSearchParams) {
|
||||
};
|
||||
}
|
||||
|
||||
function parseStalkerFilters(filters: URLSearchParams) {
|
||||
const q = filters.get("q")?.trim() || "";
|
||||
const sellerId = filters.get("sellerId")?.trim().toUpperCase() || "";
|
||||
const runIdRaw = filters.get("runId")?.trim() || "";
|
||||
const minRatingCountRaw = filters.get("minRatingCount")?.trim() || "";
|
||||
const maxRatingCountRaw = filters.get("maxRatingCount")?.trim() || "";
|
||||
|
||||
const conditions: string[] = [];
|
||||
const params: Array<string | number> = [];
|
||||
|
||||
if (runIdRaw) {
|
||||
const runId = Number(runIdRaw);
|
||||
if (Number.isInteger(runId) && runId > 0) {
|
||||
conditions.push("r.id = ?");
|
||||
params.push(runId);
|
||||
}
|
||||
}
|
||||
|
||||
if (sellerId) {
|
||||
conditions.push("s.seller_id = ?");
|
||||
params.push(sellerId);
|
||||
}
|
||||
|
||||
if (minRatingCountRaw) {
|
||||
conditions.push("s.rating_count >= ?");
|
||||
params.push(Number(minRatingCountRaw));
|
||||
}
|
||||
|
||||
if (maxRatingCountRaw) {
|
||||
conditions.push("s.rating_count <= ?");
|
||||
params.push(Number(maxRatingCountRaw));
|
||||
}
|
||||
|
||||
if (q) {
|
||||
const wildcard = `%${q}%`;
|
||||
conditions.push(
|
||||
`(sc.source_asin LIKE ? OR sc.title LIKE ? OR s.seller_id LIKE ? OR s.seller_name LIKE ? OR EXISTS (
|
||||
SELECT 1 FROM stalker_seller_inventory inv_q
|
||||
WHERE inv_q.run_id = r.id
|
||||
AND inv_q.seller_id = s.seller_id
|
||||
AND inv_q.asin LIKE ?
|
||||
))`,
|
||||
);
|
||||
params.push(wildcard, wildcard, wildcard, wildcard, wildcard);
|
||||
}
|
||||
|
||||
return {
|
||||
where: conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "",
|
||||
params,
|
||||
};
|
||||
}
|
||||
|
||||
function parseStalkerSort(sortParam: string | null): string {
|
||||
const allowedSort = new Set([
|
||||
"runId",
|
||||
"started_at",
|
||||
"source_asin",
|
||||
"title",
|
||||
"seller_id",
|
||||
"seller_name",
|
||||
"rating",
|
||||
"rating_count",
|
||||
"offer_price",
|
||||
"stock",
|
||||
"persisted_inventory_asin_count",
|
||||
"storefront_asin_total",
|
||||
"scan_fetched_at",
|
||||
]);
|
||||
const parsed = parseSort(
|
||||
sortParam,
|
||||
allowedSort,
|
||||
"started_at DESC, runId DESC, source_asin ASC",
|
||||
);
|
||||
|
||||
return parsed
|
||||
.replaceAll("runId", "runId")
|
||||
.replaceAll("rating_count", "rating_count")
|
||||
.replaceAll("persisted_inventory_asin_count", "persisted_inventory_asin_count")
|
||||
.replaceAll("storefront_asin_total", "storefront_asin_total");
|
||||
}
|
||||
|
||||
function getStalkerResults(filters: URLSearchParams) {
|
||||
const page = parseIntParam(filters.get("page"), 1);
|
||||
const pageSize = Math.min(
|
||||
parseIntParam(filters.get("pageSize"), DEFAULT_PAGE_SIZE),
|
||||
MAX_PAGE_SIZE,
|
||||
);
|
||||
const offset = (page - 1) * pageSize;
|
||||
const { where, params } = parseStalkerFilters(filters);
|
||||
const orderBy = parseStalkerSort(filters.get("sort"));
|
||||
|
||||
const baseSelect = `
|
||||
SELECT
|
||||
r.id AS runId,
|
||||
r.started_at,
|
||||
r.status,
|
||||
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_name,
|
||||
s.rating,
|
||||
s.rating_count,
|
||||
s.storefront_asin_total,
|
||||
s.persisted_inventory_sample_count,
|
||||
sas.offer_price,
|
||||
sas.condition,
|
||||
sas.is_fba,
|
||||
sas.stock,
|
||||
COUNT(DISTINCT inv.asin) AS persisted_inventory_asin_count,
|
||||
GROUP_CONCAT(DISTINCT inv.asin) AS inventory_sample_asins
|
||||
FROM stalker_asin_sellers sas
|
||||
JOIN stalker_asin_scans sc ON sc.id = sas.scan_id
|
||||
JOIN stalker_runs r ON r.id = sc.run_id
|
||||
JOIN stalker_sellers s ON s.seller_id = sas.seller_id
|
||||
LEFT JOIN stalker_seller_inventory inv
|
||||
ON inv.run_id = r.id
|
||||
AND inv.seller_id = s.seller_id
|
||||
${where}
|
||||
GROUP BY sas.id
|
||||
`;
|
||||
|
||||
const totalRow = db
|
||||
.query(`SELECT COUNT(*) AS total FROM (${baseSelect}) stalker_rows`)
|
||||
.get(...params) as { total: number };
|
||||
|
||||
const summary = db
|
||||
.query(
|
||||
`SELECT
|
||||
COUNT(DISTINCT runId) AS runs,
|
||||
COUNT(DISTINCT source_asin) AS sourceAsins,
|
||||
COUNT(DISTINCT seller_id) AS sellers,
|
||||
COALESCE(SUM(persisted_inventory_asin_count), 0) AS persistedInventoryAsins
|
||||
FROM (${baseSelect}) stalker_rows`,
|
||||
)
|
||||
.get(...params) as {
|
||||
runs: number;
|
||||
sourceAsins: number;
|
||||
sellers: number;
|
||||
persistedInventoryAsins: number;
|
||||
};
|
||||
|
||||
const items = db
|
||||
.query(
|
||||
`SELECT * FROM (${baseSelect}) stalker_rows
|
||||
ORDER BY ${orderBy}
|
||||
LIMIT ? OFFSET ?`,
|
||||
)
|
||||
.all(...params, pageSize, offset) as StalkerResultRecord[];
|
||||
|
||||
return {
|
||||
items,
|
||||
summary,
|
||||
page,
|
||||
pageSize,
|
||||
total: totalRow.total,
|
||||
totalPages: Math.max(1, Math.ceil(totalRow.total / pageSize)),
|
||||
};
|
||||
}
|
||||
|
||||
function getRun(processType: ProcessType, runId: number) {
|
||||
if (processType === "lead_analysis") {
|
||||
const run = db
|
||||
@@ -1259,6 +1448,7 @@ const server = Bun.serve({
|
||||
routes: {
|
||||
"/": index,
|
||||
"/products": index,
|
||||
"/stalker": index,
|
||||
"/runs/:processType/:runId": index,
|
||||
"/api/runs": (req) => {
|
||||
const url = new URL(req.url);
|
||||
@@ -1268,6 +1458,10 @@ const server = Bun.serve({
|
||||
const url = new URL(req.url);
|
||||
return json(getProductList(url.searchParams));
|
||||
},
|
||||
"/api/stalker/results": (req) => {
|
||||
const url = new URL(req.url);
|
||||
return json(getStalkerResults(url.searchParams));
|
||||
},
|
||||
"/api/upc/map": async (req) => {
|
||||
let upcs: string[];
|
||||
try {
|
||||
|
||||
281
src/stalker.test.ts
Normal file
281
src/stalker.test.ts
Normal file
@@ -0,0 +1,281 @@
|
||||
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, initDb } from "./database.ts";
|
||||
import {
|
||||
extractLiveOfferSellerCandidates,
|
||||
isQualifyingSeller,
|
||||
readAsinsFromXlsx,
|
||||
runStalker,
|
||||
} from "./stalker.ts";
|
||||
|
||||
const TEST_DIR = path.join(process.cwd(), "test_output", "stalker");
|
||||
const originalFetch = globalThis.fetch;
|
||||
const originalKeepaKey = Bun.env.KEEPA_API_KEY;
|
||||
|
||||
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";
|
||||
});
|
||||
|
||||
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("readAsinsFromXlsx extracts valid ASINs and deduplicates in order", () => {
|
||||
const filePath = path.join(TEST_DIR, "asins.xlsx");
|
||||
const workbook = XLSX.utils.book_new();
|
||||
const sheet = XLSX.utils.json_to_sheet([
|
||||
{ ASIN: "b000000001" },
|
||||
{ ASIN: "invalid" },
|
||||
{ ASIN: "B000000002" },
|
||||
{ ASIN: "B000000001" },
|
||||
{ ASIN: "" },
|
||||
]);
|
||||
XLSX.utils.book_append_sheet(workbook, sheet, "Input");
|
||||
XLSX.writeFile(workbook, filePath);
|
||||
|
||||
expect(readAsinsFromXlsx(filePath)).toEqual(["B000000001", "B000000002"]);
|
||||
});
|
||||
|
||||
test("isQualifyingSeller accepts rating counts from 1 to 30 only", () => {
|
||||
expect(isQualifyingSeller({ ratingCount: null })).toBe(false);
|
||||
expect(isQualifyingSeller({ ratingCount: 0 })).toBe(false);
|
||||
expect(isQualifyingSeller({ ratingCount: 1 })).toBe(true);
|
||||
expect(isQualifyingSeller({ ratingCount: 30 })).toBe(true);
|
||||
expect(isQualifyingSeller({ ratingCount: 31 })).toBe(false);
|
||||
});
|
||||
|
||||
test("extractLiveOfferSellerCandidates ignores Amazon, missing seller IDs, and duplicates", () => {
|
||||
const offers = extractLiveOfferSellerCandidates({
|
||||
offers: [
|
||||
{ sellerId: "ATVPDKIKX0DER", price: 1999 },
|
||||
{ price: 1899 },
|
||||
{ sellerId: "A1SELLER", price: 1599, isFBA: true, stock: 4 },
|
||||
{ sellerId: "A1SELLER", price: 1499 },
|
||||
{ sellerID: "A2SELLER", currentPrice: 2499, isFba: false },
|
||||
],
|
||||
});
|
||||
|
||||
expect(offers.map((offer) => offer.sellerId)).toEqual([
|
||||
"A1SELLER",
|
||||
"A2SELLER",
|
||||
]);
|
||||
expect(offers[0]?.offerPrice).toBe(15.99);
|
||||
expect(offers[0]?.isFba).toBe(true);
|
||||
expect(offers[0]?.stock).toBe(4);
|
||||
});
|
||||
|
||||
test("initDb creates stalker tables and indexes", () => {
|
||||
const dbPath = path.join(TEST_DIR, "schema.sqlite");
|
||||
initDb(dbPath);
|
||||
const db = getDb(dbPath);
|
||||
|
||||
const tables = db
|
||||
.query(
|
||||
`SELECT name FROM sqlite_master WHERE type = 'table' AND name LIKE 'stalker_%' ORDER BY name`,
|
||||
)
|
||||
.all() as Array<{ name: string }>;
|
||||
expect(tables.map((row) => row.name)).toEqual([
|
||||
"stalker_asin_scans",
|
||||
"stalker_asin_sellers",
|
||||
"stalker_runs",
|
||||
"stalker_seller_inventory",
|
||||
"stalker_sellers",
|
||||
]);
|
||||
|
||||
const indexes = db
|
||||
.query(
|
||||
`SELECT name FROM sqlite_master WHERE type = 'index' AND name LIKE 'idx_stalker_%' ORDER BY name`,
|
||||
)
|
||||
.all() as Array<{ name: string }>;
|
||||
expect(indexes.length).toBeGreaterThanOrEqual(6);
|
||||
});
|
||||
|
||||
test("runStalker fetches product offers, filters sellers, and persists storefront inventory", async () => {
|
||||
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);
|
||||
|
||||
const fetchMock = 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") {
|
||||
expect(url.searchParams.get("asin")).toBe("B000000001");
|
||||
expect(url.searchParams.get("offers")).toBe("20");
|
||||
expect(url.searchParams.get("only-live-offers")).toBe("1");
|
||||
expect(url.searchParams.has("stock")).toBe(false);
|
||||
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
products: [
|
||||
{
|
||||
asin: "B000000001",
|
||||
title: "Tracked Product",
|
||||
offers: [
|
||||
{
|
||||
sellerId: "AQUALIFIED",
|
||||
price: 1999,
|
||||
condition: "New",
|
||||
isFBA: true,
|
||||
stock: 3,
|
||||
},
|
||||
{
|
||||
sellerId: "AOLDSELLER",
|
||||
price: 2099,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
tokensLeft: 10,
|
||||
refillRate: 10,
|
||||
}),
|
||||
{ status: 200 },
|
||||
);
|
||||
}
|
||||
|
||||
if (url.pathname === "/seller") {
|
||||
const wantsStorefront = url.searchParams.get("storefront") === "1";
|
||||
if (wantsStorefront) {
|
||||
expect(url.searchParams.get("update")).toBe("168");
|
||||
}
|
||||
const sellerId = url.searchParams.get("seller");
|
||||
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
sellers: {
|
||||
...(!wantsStorefront && sellerId === "AQUALIFIED,AOLDSELLER"
|
||||
? {
|
||||
AQUALIFIED: {
|
||||
sellerName: "New Seller",
|
||||
currentRating: 96,
|
||||
currentRatingCount: 12,
|
||||
},
|
||||
AOLDSELLER: {
|
||||
sellerName: "Old Seller",
|
||||
currentRating: 99,
|
||||
currentRatingCount: 120,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
...(wantsStorefront && sellerId === "AQUALIFIED"
|
||||
? {
|
||||
AQUALIFIED: {
|
||||
sellerName: "New Seller",
|
||||
currentRating: 96,
|
||||
currentRatingCount: 12,
|
||||
asinList: ["B111111111", "B222222222"],
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
tokensLeft: 10,
|
||||
refillRate: 10,
|
||||
}),
|
||||
{ status: 200 },
|
||||
);
|
||||
}
|
||||
|
||||
return new Response("not found", { status: 404 });
|
||||
});
|
||||
globalThis.fetch = fetchMock 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,
|
||||
});
|
||||
|
||||
expect(stats.scannedAsins).toBe(1);
|
||||
expect(stats.sourceAsinsWithMatches).toBe(1);
|
||||
expect(stats.matchedSellers).toBe(1);
|
||||
expect(stats.persistedInventoryAsins).toBe(2);
|
||||
expect(stats.failedAsins).toBe(0);
|
||||
expect(stats.candidateSellers).toBe(2);
|
||||
expect(stats.qualifyingSellers).toBe(1);
|
||||
expect(stats.sellerMetadataRequests).toBe(1);
|
||||
expect(stats.sellerStorefrontRequests).toBe(1);
|
||||
const sellerCalls = fetchMock.mock.calls.filter((call) => {
|
||||
const rawUrl =
|
||||
typeof call[0] === "string"
|
||||
? call[0]
|
||||
: call[0] instanceof URL
|
||||
? call[0].toString()
|
||||
: (call[0] as Request).url;
|
||||
return new URL(rawUrl).pathname === "/seller";
|
||||
});
|
||||
expect(sellerCalls.length).toBe(2);
|
||||
|
||||
const db = getDb(dbPath);
|
||||
const run = db.query("SELECT * FROM stalker_runs").get() as any;
|
||||
expect(run.status).toBe("completed");
|
||||
expect(run.requested_asins).toBe(1);
|
||||
expect(run.scanned_asins).toBe(1);
|
||||
expect(run.source_asins_with_matches).toBe(1);
|
||||
expect(run.candidate_sellers).toBe(2);
|
||||
expect(run.qualifying_sellers).toBe(1);
|
||||
expect(run.matched_sellers).toBe(1);
|
||||
expect(run.seller_metadata_requests).toBe(1);
|
||||
expect(run.seller_storefront_requests).toBe(1);
|
||||
expect(run.persisted_inventory_asins).toBe(2);
|
||||
|
||||
const scan = db.query("SELECT * FROM stalker_asin_scans").get() as any;
|
||||
expect(scan.source_asin).toBe("B000000001");
|
||||
expect(scan.title).toBe("Tracked Product");
|
||||
expect(scan.offer_count).toBe(2);
|
||||
expect(scan.candidate_seller_count).toBe(2);
|
||||
expect(scan.matched_seller_count).toBe(1);
|
||||
|
||||
const sellers = db.query("SELECT * FROM stalker_sellers").all() as any[];
|
||||
expect(sellers.length).toBe(1);
|
||||
expect(sellers[0].seller_id).toBe("AQUALIFIED");
|
||||
expect(sellers[0].rating_count).toBe(12);
|
||||
expect(sellers[0].storefront_asin_total).toBe(2);
|
||||
expect(sellers[0].persisted_inventory_sample_count).toBe(2);
|
||||
|
||||
const asinSellers = db.query("SELECT * FROM stalker_asin_sellers").all() as any[];
|
||||
expect(asinSellers.length).toBe(1);
|
||||
expect(asinSellers[0].offer_price).toBe(19.99);
|
||||
expect(asinSellers[0].is_fba).toBe(1);
|
||||
expect(asinSellers[0].stock).toBe(3);
|
||||
|
||||
const inventory = db
|
||||
.query("SELECT asin FROM stalker_seller_inventory ORDER BY asin")
|
||||
.all() as Array<{ asin: string }>;
|
||||
expect(inventory.map((row) => row.asin)).toEqual([
|
||||
"B111111111",
|
||||
"B222222222",
|
||||
]);
|
||||
});
|
||||
1189
src/stalker.ts
Normal file
1189
src/stalker.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -109,6 +109,45 @@ type ProductListResponse = {
|
||||
totalPages: number;
|
||||
};
|
||||
|
||||
type StalkerResultItem = {
|
||||
runId: number;
|
||||
started_at: string;
|
||||
status: 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_name: string | null;
|
||||
rating: number | null;
|
||||
rating_count: number | null;
|
||||
storefront_asin_total: number | null;
|
||||
persisted_inventory_sample_count: number | null;
|
||||
offer_price: number | null;
|
||||
condition: string | null;
|
||||
is_fba: number | null;
|
||||
stock: number | null;
|
||||
persisted_inventory_asin_count: number;
|
||||
inventory_sample_asins: string | null;
|
||||
};
|
||||
|
||||
type StalkerResultsResponse = {
|
||||
items: StalkerResultItem[];
|
||||
summary: {
|
||||
runs: number;
|
||||
sourceAsins: number;
|
||||
sellers: number;
|
||||
persistedInventoryAsins: number;
|
||||
};
|
||||
page: number;
|
||||
pageSize: number;
|
||||
total: number;
|
||||
totalPages: number;
|
||||
};
|
||||
|
||||
type SortState = {
|
||||
field: string;
|
||||
direction: SortDirection;
|
||||
@@ -139,6 +178,11 @@ function formatAmazonSeller(value: number | null | undefined): string {
|
||||
return value === 1 ? "Yes" : "No";
|
||||
}
|
||||
|
||||
function formatBoolean(value: number | null | undefined): string {
|
||||
if (value === null || value === undefined) return "-";
|
||||
return value === 1 ? "Yes" : "No";
|
||||
}
|
||||
|
||||
function buildSortValue(sort: SortState): string {
|
||||
return `${sort.field}:${sort.direction}`;
|
||||
}
|
||||
@@ -197,9 +241,11 @@ function detectAnomaly(item: ResultItem): string {
|
||||
function Dashboard({
|
||||
onOpenRun,
|
||||
onOpenProducts,
|
||||
onOpenStalker,
|
||||
}: {
|
||||
onOpenRun: (run: Run) => void;
|
||||
onOpenProducts: (verdict: VerdictFilter) => void;
|
||||
onOpenStalker: () => void;
|
||||
}) {
|
||||
const [runs, setRuns] = useState<RunsResponse | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
@@ -288,7 +334,10 @@ function Dashboard({
|
||||
return (
|
||||
<div className="page">
|
||||
<div className="card">
|
||||
<h2>Runs Dashboard</h2>
|
||||
<div className="section-header">
|
||||
<h2>Runs Dashboard</h2>
|
||||
<button onClick={onOpenStalker}>Stalker results</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="metrics">
|
||||
@@ -848,10 +897,166 @@ function ProductList({ verdict, onBack }: { verdict: VerdictFilter; onBack: () =
|
||||
);
|
||||
}
|
||||
|
||||
function StalkerExplorer({ onBack }: { onBack: () => void }) {
|
||||
const [results, setResults] = useState<StalkerResultsResponse | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [search, setSearch] = useState("");
|
||||
const [sellerId, setSellerId] = useState("");
|
||||
const [runId, setRunId] = useState("");
|
||||
const [minRatingCount, setMinRatingCount] = useState("1");
|
||||
const [maxRatingCount, setMaxRatingCount] = useState("30");
|
||||
const [page, setPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(25);
|
||||
const [sort, setSort] = useState<SortState>({ field: "started_at", direction: "DESC" });
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
async function load() {
|
||||
setLoading(true);
|
||||
const params = new URLSearchParams({
|
||||
page: String(page),
|
||||
pageSize: String(pageSize),
|
||||
sort: buildSortValue(sort),
|
||||
});
|
||||
if (search) params.set("q", search);
|
||||
if (sellerId) params.set("sellerId", sellerId);
|
||||
if (runId) params.set("runId", runId);
|
||||
if (minRatingCount) params.set("minRatingCount", minRatingCount);
|
||||
if (maxRatingCount) params.set("maxRatingCount", maxRatingCount);
|
||||
|
||||
const res = await fetch(`/api/stalker/results?${params.toString()}`);
|
||||
const payload = (await res.json()) as StalkerResultsResponse;
|
||||
if (!cancelled) {
|
||||
setResults(payload);
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
load();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [search, sellerId, runId, minRatingCount, maxRatingCount, page, pageSize, sort]);
|
||||
|
||||
return (
|
||||
<div className="page">
|
||||
<button className="back" onClick={onBack}>Back</button>
|
||||
|
||||
<div className="card">
|
||||
<h2>Stalker Results</h2>
|
||||
</div>
|
||||
|
||||
<div className="metrics">
|
||||
<div className="metric">
|
||||
<div className="label">Runs</div>
|
||||
<div className="value">{formatNumber(results?.summary.runs)}</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="label">Matched sellers</div>
|
||||
<div className="value">{formatNumber(results?.summary.sellers)}</div>
|
||||
</div>
|
||||
<div className="metric">
|
||||
<div className="label">Persisted inventory ASINs</div>
|
||||
<div className="value">{formatNumber(results?.summary.persistedInventoryAsins)}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<div className="toolbar">
|
||||
<input value={search} onChange={(e) => { setPage(1); setSearch(e.target.value); }} placeholder="Search source ASIN/title/seller/inventory" />
|
||||
<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={minRatingCount} onChange={(e) => { setPage(1); setMinRatingCount(e.target.value); }} placeholder="Min rating count" />
|
||||
<input value={maxRatingCount} onChange={(e) => { setPage(1); setMaxRatingCount(e.target.value); }} placeholder="Max rating count" />
|
||||
<select value={String(pageSize)} onChange={(e) => { setPage(1); setPageSize(Number(e.target.value)); }}>
|
||||
<option value="25">25 / page</option>
|
||||
<option value="50">50 / page</option>
|
||||
<option value="100">100 / page</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<div className="table-wrap">
|
||||
<table className="stalker-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<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_name"))}>Seller</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, "offer_price"))}>Offer</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, "persisted_inventory_asin_count"))}>Persisted sample</button></th>
|
||||
<th className="inventory-col">Inventory ASIN sample</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{loading ? (
|
||||
<tr><td colSpan={14}>Loading...</td></tr>
|
||||
) : results?.items.length ? (
|
||||
results.items.map((item) => {
|
||||
const inventorySample = (item.inventory_sample_asins ?? "")
|
||||
.split(",")
|
||||
.filter(Boolean)
|
||||
.slice(0, 20);
|
||||
return (
|
||||
<tr key={`${item.runId}-${item.source_asin}-${item.seller_id}`}>
|
||||
<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_name || "-"}</td>
|
||||
<td>{formatNumber(item.rating)}</td>
|
||||
<td>{formatNumber(item.rating_count)}</td>
|
||||
<td>{formatCurrency(item.offer_price)}</td>
|
||||
<td>{formatBoolean(item.is_fba)}</td>
|
||||
<td>{formatNumber(item.stock)}</td>
|
||||
<td>{formatNumber(item.storefront_asin_total)}</td>
|
||||
<td>{formatNumber(item.persisted_inventory_asin_count)}</td>
|
||||
<td className="inventory-col">
|
||||
{inventorySample.length === 0 ? "-" : inventorySample.map((asin) => (
|
||||
<a key={asin} href={`http://amazon.com/dp/${asin}`} target="_blank" rel="noreferrer">{asin}</a>
|
||||
))}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<tr><td colSpan={14}>No stalker results found</td></tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div className="pager">
|
||||
<div>Showing {results?.items.length ?? 0} of {results?.total ?? 0}</div>
|
||||
<div>
|
||||
<button disabled={page <= 1} onClick={() => setPage((p) => Math.max(1, p - 1))}>Previous</button>
|
||||
<span style={{ padding: "0 8px" }}>Page {results?.page ?? page} / {results?.totalPages ?? 1}</span>
|
||||
<button disabled={Boolean(results && page >= results.totalPages)} onClick={() => setPage((p) => p + 1)}>Next</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type AppRoute =
|
||||
| { kind: "dashboard" }
|
||||
| { kind: "run"; processType: ProcessType; runId: number }
|
||||
| { kind: "products"; verdict: VerdictFilter };
|
||||
| { kind: "products"; verdict: VerdictFilter }
|
||||
| { kind: "stalker" };
|
||||
|
||||
function parseRoute(pathname: string, search: string): AppRoute {
|
||||
const runMatch = pathname.match(/^\/runs\/(lead_analysis|category_analysis)\/(\d+)$/);
|
||||
@@ -866,6 +1071,10 @@ function parseRoute(pathname: string, search: string): AppRoute {
|
||||
return { kind: "products", verdict };
|
||||
}
|
||||
|
||||
if (pathname === "/stalker") {
|
||||
return { kind: "stalker" };
|
||||
}
|
||||
|
||||
return { kind: "dashboard" };
|
||||
}
|
||||
|
||||
@@ -890,6 +1099,11 @@ function App() {
|
||||
setRoute({ kind: "products", verdict });
|
||||
}
|
||||
|
||||
function openStalker() {
|
||||
history.pushState({}, "", "/stalker");
|
||||
setRoute({ kind: "stalker" });
|
||||
}
|
||||
|
||||
function backToDashboard() {
|
||||
history.pushState({}, "", "/");
|
||||
setRoute({ kind: "dashboard" });
|
||||
@@ -903,7 +1117,11 @@ function App() {
|
||||
return <ProductList verdict={route.verdict} onBack={backToDashboard} />;
|
||||
}
|
||||
|
||||
return <Dashboard onOpenRun={openRun} onOpenProducts={openProducts} />;
|
||||
if (route.kind === "stalker") {
|
||||
return <StalkerExplorer onBack={backToDashboard} />;
|
||||
}
|
||||
|
||||
return <Dashboard onOpenRun={openRun} onOpenProducts={openProducts} onOpenStalker={openStalker} />;
|
||||
}
|
||||
|
||||
const root = document.getElementById("root");
|
||||
|
||||
@@ -41,6 +41,13 @@ p {
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.section-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.toolbar input,
|
||||
.toolbar select,
|
||||
button {
|
||||
@@ -91,6 +98,23 @@ td {
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.inventory-col {
|
||||
min-width: 360px;
|
||||
max-width: 520px;
|
||||
white-space: normal;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.inventory-col a {
|
||||
display: inline-block;
|
||||
margin-right: 8px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.stalker-table {
|
||||
min-width: 1320px;
|
||||
}
|
||||
|
||||
th {
|
||||
background: #fafafb;
|
||||
font-weight: 600;
|
||||
@@ -262,4 +286,9 @@ th button {
|
||||
.spark-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.section-header {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user