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:
Victor Noguera
2026-05-19 18:10:01 -04:00
parent 0f9b785cce
commit a7c0e44e3d
7 changed files with 2037 additions and 3 deletions

View File

@@ -7,6 +7,7 @@
"bestsellers": "bun run src/bestsellers-by-category.ts",
"monthly-sold": "bun run src/top-monthly-sold-by-category.ts",
"mid-range": "bun run src/mid-range-sellers-by-category.ts",
"stalker": "bun run src/stalker.ts",
"upc": "bun run src/upc-lookup.ts",
"upc-file": "bun run src/upc-file-analysis.ts",
"start": "bun run src/index.ts",

View File

@@ -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");
}

View File

@@ -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
View 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

File diff suppressed because it is too large Load Diff

View File

@@ -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">
<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");

View File

@@ -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;
}
}