diff --git a/src/database.ts b/src/database.ts index cdef940..35a1074 100644 --- a/src/database.ts +++ b/src/database.ts @@ -354,6 +354,9 @@ export function initStalkerDb(database: Database): void { matched_sellers INTEGER NOT NULL DEFAULT 0, seller_metadata_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, status TEXT NOT NULL, error_message TEXT @@ -413,6 +416,9 @@ export function initStalkerDb(database: Database): void { run_id INTEGER NOT NULL, seller_id TEXT NOT NULL, asin TEXT NOT NULL, + can_sell INTEGER, + sellability_status TEXT, + sellability_reason TEXT, last_seen_at TEXT NOT NULL, raw_inventory_json TEXT, UNIQUE(run_id, seller_id, asin), @@ -448,7 +454,13 @@ function resetLegacyStalkerSchema(database: Database): void { if (runColumns.length === 0) return; 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_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_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"); +} diff --git a/src/server.ts b/src/server.ts index 2221df6..92a3a2e 100644 --- a/src/server.ts +++ b/src/server.ts @@ -58,22 +58,15 @@ type StalkerResultRecord = { 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; + discovered_from_count: number; + first_seen_at: string; + last_seen_at: string; persisted_inventory_asin_count: number; inventory_sample_asins: string | null; }; @@ -690,14 +683,14 @@ function parseStalkerFilters(filters: URLSearchParams) { 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 ( + `(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); + params.push(wildcard, wildcard, wildcard); } return { @@ -710,22 +703,19 @@ 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", + "discovered_from_count", "persisted_inventory_asin_count", "storefront_asin_total", - "scan_fetched_at", + "last_seen_at", ]); const parsed = parseSort( sortParam, allowedSort, - "started_at DESC, runId DESC, source_asin ASC", + "persisted_inventory_asin_count DESC, last_seen_at DESC, seller_id ASC", ); return parsed @@ -751,22 +741,15 @@ function getStalkerResults(filters: URLSearchParams) { 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 sc.source_asin) AS discovered_from_count, + MIN(sc.fetched_at) AS first_seen_at, + MAX(sc.fetched_at) AS last_seen_at, COUNT(DISTINCT inv.asin) AS persisted_inventory_asin_count, GROUP_CONCAT(DISTINCT inv.asin) AS inventory_sample_asins FROM stalker_asin_sellers sas @@ -777,7 +760,7 @@ function getStalkerResults(filters: URLSearchParams) { ON inv.run_id = r.id AND inv.seller_id = s.seller_id ${where} - GROUP BY sas.id + GROUP BY r.id, s.seller_id `; const totalRow = db @@ -788,14 +771,12 @@ function getStalkerResults(filters: URLSearchParams) { .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; }; diff --git a/src/stalker-sellability.test.ts b/src/stalker-sellability.test.ts new file mode 100644 index 0000000..33d2dbd --- /dev/null +++ b/src/stalker-sellability.test.ts @@ -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", + }, + ]); +}); diff --git a/src/stalker.test.ts b/src/stalker.test.ts index 42ffc08..6c5ad7e 100644 --- a/src/stalker.test.ts +++ b/src/stalker.test.ts @@ -216,6 +216,7 @@ test("runStalker fetches product offers, filters sellers, and persists storefron dryRun: false, resume: true, maxSellerRequests: null, + sellability: false, }); 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.seller_metadata_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); const scan = db.query("SELECT * FROM stalker_asin_scans").get() as any; diff --git a/src/stalker.ts b/src/stalker.ts index f4b6563..8fba937 100644 --- a/src/stalker.ts +++ b/src/stalker.ts @@ -1,6 +1,8 @@ import * as XLSX from "xlsx"; import path from "node:path"; import { type Database, closeDb, getDb, initDb } from "./database.ts"; +import { fetchSellabilityBatch } from "./sp-api.ts"; +import type { SellabilityInfo } from "./types.ts"; const KEEPA_BASE = "https://api.keepa.com"; const DOMAIN_US = "1"; @@ -37,6 +39,7 @@ export type StalkerArgs = { dryRun: boolean; resume: boolean; maxSellerRequests: number | null; + sellability: boolean; }; export type StalkerOffer = { @@ -62,6 +65,7 @@ export type StalkerSeller = { type StalkerInventoryItem = { asin: string; rawInventory: unknown; + sellability: SellabilityInfo | null; }; type StalkerAsinResult = { @@ -88,6 +92,9 @@ type StalkerRunStats = { qualifyingSellers: number; sellerMetadataRequests: number; sellerStorefrontRequests: number; + inventorySellabilityCheckedAsins: number; + inventorySellabilityAvailableAsins: number; + inventorySellabilityExcludedAsins: number; stoppedEarly: boolean; }; @@ -135,6 +142,7 @@ export function parseArgs(argv = process.argv.slice(2)): StalkerArgs { const includeStock = hasFlag(argv, "--include-stock"); const dryRun = hasFlag(argv, "--dry-run"); const resume = !hasFlag(argv, "--no-resume"); + const sellability = hasFlag(argv, "--sellability"); if (maxAsins != null && (!Number.isInteger(maxAsins) || maxAsins <= 0)) { printUsageAndExit("--max-asins must be a positive integer."); @@ -185,6 +193,7 @@ export function parseArgs(argv = process.argv.slice(2)): StalkerArgs { dryRun, resume, maxSellerRequests, + sellability, }; } @@ -276,21 +285,26 @@ export async function runStalker(args: StalkerArgs): Promise { initDb(args.dbPath); const database = getDb(args.dbPath); const completedAsins = args.resume ? loadPreviouslyScannedAsins(database) : new Set(); - const asins = cappedAsins.filter((asin) => !completedAsins.has(asin)); + const resumeFilteredAsins = cappedAsins.filter( + (asin) => !completedAsins.has(asin), + ); const runId = args.dryRun ? null - : startStalkerRun(database, args.input, asins.length); + : startStalkerRun(database, args.input, resumeFilteredAsins.length); const stats: StalkerRunStats = { scannedAsins: 0, sourceAsinsWithMatches: 0, matchedSellers: 0, persistedInventoryAsins: 0, failedAsins: 0, - skippedAsins: cappedAsins.length - asins.length, + skippedAsins: cappedAsins.length - resumeFilteredAsins.length, candidateSellers: 0, qualifyingSellers: 0, sellerMetadataRequests: 0, sellerStorefrontRequests: 0, + inventorySellabilityCheckedAsins: 0, + inventorySellabilityAvailableAsins: 0, + inventorySellabilityExcludedAsins: 0, stoppedEarly: false, }; const context: StalkerRunContext = { @@ -308,8 +322,8 @@ export async function runStalker(args: StalkerArgs): Promise { console.log(`Stalker resume: skipped ${stats.skippedAsins} previously scanned ASIN(s).`); } - for (const asin of asins) { - console.log(`Stalker: scanning ${asin} (${stats.scannedAsins + 1}/${asins.length})`); + for (const asin of resumeFilteredAsins) { + console.log(`Stalker: scanning ${asin} (${stats.scannedAsins + 1}/${resumeFilteredAsins.length})`); const result = await scanAsin(asin, args, apiKey, context).catch((error) => ({ asin, @@ -321,6 +335,10 @@ export async function runStalker(args: StalkerArgs): Promise { error: error instanceof Error ? error.message : String(error), })); + if (args.sellability && !args.dryRun) { + await enrichInventorySellability(result, stats); + } + if (!args.dryRun && runId != null) { persistAsinResult(database, runId, result); } @@ -439,6 +457,54 @@ async function scanAsin( }; } +async function enrichInventorySellability( + result: StalkerAsinResult, + stats: StalkerRunStats, +): Promise { + const sellers = result.matchedSellers.map(({ seller }) => seller); + const items = sellers.flatMap((seller) => seller.storefrontItems); + const uniqueAsins = Array.from(new Set(items.map((item) => item.asin))); + if (uniqueAsins.length === 0) return; + + console.log( + `Stalker inventory sellability: checking ${uniqueAsins.length} ASIN(s) from matched seller storefronts...`, + ); + const sellabilityMap = await fetchSellabilityBatch(uniqueAsins); + stats.inventorySellabilityCheckedAsins += uniqueAsins.length; + + for (const asin of uniqueAsins) { + const info = sellabilityMap.get(asin) ?? { + canSell: null, + sellabilityStatus: "unknown" as const, + sellabilityReason: "Sellability check returned no result", + }; + + if (info.sellabilityStatus === "available" && info.canSell === true) { + stats.inventorySellabilityAvailableAsins += 1; + } else { + stats.inventorySellabilityExcludedAsins += 1; + } + } + + for (const item of items) { + item.sellability = + sellabilityMap.get(item.asin) ?? { + canSell: null, + sellabilityStatus: "unknown", + sellabilityReason: "Sellability check returned no result", + }; + } + + for (const seller of sellers) { + seller.storefrontItems = seller.storefrontItems.filter( + (item) => + item.sellability?.canSell === true && + item.sellability.sellabilityStatus === "available", + ); + seller.storefrontAsins = seller.storefrontItems.map((item) => item.asin); + } +} + async function fetchKeepaProduct( asin: string, apiKey: string, @@ -755,9 +821,13 @@ function upsertSellerInventory( ): void { const insert = database.prepare( `INSERT INTO stalker_seller_inventory ( - run_id, seller_id, asin, last_seen_at, raw_inventory_json - ) VALUES (?, ?, ?, ?, ?) + run_id, seller_id, asin, can_sell, sellability_status, + sellability_reason, last_seen_at, raw_inventory_json + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(run_id, seller_id, asin) DO UPDATE SET + can_sell = excluded.can_sell, + sellability_status = excluded.sellability_status, + sellability_reason = excluded.sellability_reason, last_seen_at = excluded.last_seen_at, raw_inventory_json = excluded.raw_inventory_json`, ); @@ -767,6 +837,13 @@ function upsertSellerInventory( runId, seller.sellerId, item.asin, + item.sellability?.canSell == null + ? null + : item.sellability.canSell + ? 1 + : 0, + item.sellability?.sellabilityStatus ?? null, + item.sellability?.sellabilityReason ?? null, fetchedAt, JSON.stringify(item.rawInventory), ); @@ -846,6 +923,9 @@ function logRunSummary(stats: StalkerRunStats, args: StalkerArgs): void { `qualifying_sellers=${stats.qualifyingSellers}`, `metadata_requests=${stats.sellerMetadataRequests}`, `storefront_requests=${stats.sellerStorefrontRequests}`, + `sellability_checked=${stats.inventorySellabilityCheckedAsins}`, + `sellability_available=${stats.inventorySellabilityAvailableAsins}`, + `sellability_excluded=${stats.inventorySellabilityExcludedAsins}`, `storefront_requests_saved_by_two_phase=${estimatedStorefrontRequestsSaved}`, `persisted_inventory=${stats.persistedInventoryAsins}`, `dry_run=${args.dryRun ? "yes" : "no"}`, @@ -869,6 +949,9 @@ function refreshStalkerRun( matched_sellers = ?, seller_metadata_requests = ?, seller_storefront_requests = ?, + inventory_sellability_checked_asins = ?, + inventory_sellability_available_asins = ?, + inventory_sellability_excluded_asins = ?, persisted_inventory_asins = ?, status = ?, completed_at = CASE WHEN ? = 'running' THEN completed_at ELSE ? END @@ -882,6 +965,9 @@ function refreshStalkerRun( stats.matchedSellers, stats.sellerMetadataRequests, stats.sellerStorefrontRequests, + stats.inventorySellabilityCheckedAsins, + stats.inventorySellabilityAvailableAsins, + stats.inventorySellabilityExcludedAsins, stats.persistedInventoryAsins, status, status, @@ -906,6 +992,9 @@ function finishStalkerRunWithError( matched_sellers = ?, seller_metadata_requests = ?, seller_storefront_requests = ?, + inventory_sellability_checked_asins = ?, + inventory_sellability_available_asins = ?, + inventory_sellability_excluded_asins = ?, persisted_inventory_asins = ?, status = 'failed', error_message = ?, @@ -920,6 +1009,9 @@ function finishStalkerRunWithError( stats.matchedSellers, stats.sellerMetadataRequests, stats.sellerStorefrontRequests, + stats.inventorySellabilityCheckedAsins, + stats.inventorySellabilityAvailableAsins, + stats.inventorySellabilityExcludedAsins, stats.persistedInventoryAsins, errorMessage, new Date().toISOString(), @@ -1013,7 +1105,7 @@ function collectStorefrontItems( const asin = normalizeAsin((value as Record).asin); if (asin && !seen.has(asin)) { seen.add(asin); - items.push({ asin, rawInventory: value }); + items.push({ asin, rawInventory: value, sellability: null }); } return; } @@ -1021,7 +1113,7 @@ function collectStorefrontItems( const asin = normalizeAsin(value); if (!asin || seen.has(asin)) return; seen.add(asin); - items.push({ asin, rawInventory: { asin } }); + items.push({ asin, rawInventory: { asin }, sellability: null }); } function extractSellerRatingCount(seller: Record): number | null { @@ -1111,7 +1203,7 @@ function hasFlag(args: string[], flag: string): boolean { function printUsageAndExit(message: string): never { console.error(message); console.error( - "Usage: bun run stalker --input input/asins.xlsx [--db db/results.db] [--max-asins N] [--offer-limit 100] [--seller-limit 30] [--inventory-limit 200] [--storefront-update-hours 168] [--seller-cache-hours 168] [--max-seller-requests N] [--include-stock] [--dry-run] [--no-resume]", + "Usage: bun run stalker --input input/asins.xlsx [--db db/results.db] [--max-asins N] [--offer-limit 100] [--seller-limit 30] [--inventory-limit 200] [--storefront-update-hours 168] [--seller-cache-hours 168] [--max-seller-requests N] [--sellability] [--include-stock] [--dry-run] [--no-resume]", ); process.exit(1); } diff --git a/src/web/frontend.tsx b/src/web/frontend.tsx index 19aa424..f4bf2ad 100644 --- a/src/web/frontend.tsx +++ b/src/web/frontend.tsx @@ -114,22 +114,15 @@ type StalkerResultItem = { 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; + discovered_from_count: number; + first_seen_at: string; + last_seen_at: string; persisted_inventory_asin_count: number; inventory_sample_asins: string | null; }; @@ -138,7 +131,6 @@ type StalkerResultsResponse = { items: StalkerResultItem[]; summary: { runs: number; - sourceAsins: number; sellers: number; persistedInventoryAsins: number; }; @@ -907,7 +899,7 @@ function StalkerExplorer({ onBack }: { onBack: () => void }) { const [maxRatingCount, setMaxRatingCount] = useState("30"); const [page, setPage] = useState(1); const [pageSize, setPageSize] = useState(25); - const [sort, setSort] = useState({ field: "started_at", direction: "DESC" }); + const [sort, setSort] = useState({ field: "persisted_inventory_asin_count", direction: "DESC" }); useEffect(() => { let cancelled = false; @@ -943,7 +935,7 @@ function StalkerExplorer({ onBack }: { onBack: () => void }) {
-

Stalker Results

+

Seller Storefronts

@@ -951,23 +943,19 @@ function StalkerExplorer({ onBack }: { onBack: () => void }) {
Runs
{formatNumber(results?.summary.runs)}
-
-
Source ASINs
-
{formatNumber(results?.summary.sourceAsins)}
-
Matched sellers
{formatNumber(results?.summary.sellers)}
-
Persisted inventory ASINs
+
Sellable inventory ASINs
{formatNumber(results?.summary.persistedInventoryAsins)}
- { setPage(1); setSearch(e.target.value); }} placeholder="Search source ASIN/title/seller/inventory" /> + { setPage(1); setSearch(e.target.value); }} placeholder="Search seller or sellable ASIN" /> { setPage(1); setSellerId(e.target.value.toUpperCase()); }} placeholder="Seller ID" /> { setPage(1); setRunId(e.target.value); }} placeholder="Run ID" /> { setPage(1); setMinRatingCount(e.target.value); }} placeholder="Min rating count" /> @@ -986,24 +974,20 @@ function StalkerExplorer({ onBack }: { onBack: () => void }) { - - - - - FBA - + - - Inventory ASIN sample + + + Sellable inventory ASIN sample {loading ? ( - Loading... + Loading... ) : results?.items.length ? ( results.items.map((item) => { const inventorySample = (item.inventory_sample_asins ?? "") @@ -1011,20 +995,16 @@ function StalkerExplorer({ onBack }: { onBack: () => void }) { .filter(Boolean) .slice(0, 20); return ( - + {item.runId} - {formatDate(item.started_at)} - {item.source_asin} - {item.title || "-"} {item.seller_id} {item.seller_name || "-"} {formatNumber(item.rating)} {formatNumber(item.rating_count)} - {formatCurrency(item.offer_price)} - {formatBoolean(item.is_fba)} - {formatNumber(item.stock)} + {formatNumber(item.discovered_from_count)} {formatNumber(item.storefront_asin_total)} {formatNumber(item.persisted_inventory_asin_count)} + {formatDate(item.last_seen_at)} {inventorySample.length === 0 ? "-" : inventorySample.map((asin) => ( {asin} @@ -1034,7 +1014,7 @@ function StalkerExplorer({ onBack }: { onBack: () => void }) { ); }) ) : ( - No stalker results found + No seller storefronts found )}