Files
asin-check/src/server.ts
Victor Noguera 95cebaa27c feat: add support for Claude LLM integration across multiple modules
- Introduced `useClaude` option in `AnalysisPipelineOptions` to toggle Claude LLM usage.
- Updated `processProductChunk` and `analyzeProducts` functions to accept and handle `useClaude` parameter.
- Modified argument parsing in various scripts (`bestsellers-by-category`, `mid-range-sellers-by-category`, `top-monthly-sold-by-category`, etc.) to include `--claude` flag.
- Enhanced `analyzeProductsInternal` to differentiate between LLM providers and handle requests to Claude API.
- Added error handling for Claude API responses and ensured proper configuration for using Claude.
- Updated documentation and usage messages to reflect the new `--claude` flag.
2026-05-21 19:57:46 -04:00

2073 lines
55 KiB
TypeScript

import index from "./web/index.html";
import path from "node:path";
import * as XLSX from "xlsx";
import { getDb, initDb } from "./database.ts";
import {
fetchKeepaDataBatch,
lookupKeepaUpcs,
mapUpcsToAsins,
} from "./keepa.ts";
import { runUpcFileAnalysis } from "./upc-file-analysis.ts";
import { fetchSellabilityBatch, fetchSpApiPricingAndFees } from "./sp-api.ts";
import { analyzeProducts } from "./llm.ts";
import type {
EnrichedProduct,
KeepaUpcLookupDetail,
ProductRecord,
SpApiData,
} from "./types.ts";
type ProcessType = "lead_analysis" | "category_analysis";
type RunRecord = {
processType: ProcessType;
runId: number;
timestamp: string;
status: string;
jobType: string;
source: string | null;
output: string | null;
totalProducts: number;
fbaCount: number;
fbmCount: number;
skipCount: number;
};
type ProductListRecord = {
processType: ProcessType;
runId: number;
asin: string;
product_name: string | null;
brand: string | null;
category: string | null;
verdict: "FBA" | "FBM" | "SKIP";
confidence: number | null;
sellability_status: string | null;
monthly_sold: number | null;
seller_count: number | null;
amazon_is_seller: number | null;
amazon_buybox_share_pct_90d: number | null;
sales_rank: number | null;
current_price: number | null;
avg_price_90d: number | null;
reasoning: string | null;
fetched_at: string;
};
type StalkerResultRecord = {
runId: number;
started_at: string;
status: string;
input_file: 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;
discovered_from_count: number;
first_seen_at: string;
last_seen_at: string;
persisted_inventory_asin_count: number;
inventory_sample_asins: string | null;
};
type StalkerProductRecord = {
runId: number;
started_at: string;
seller_id: string;
seller_name: string | null;
rating: number | null;
rating_count: number | null;
asin: string;
can_sell: number;
sellability_status: string;
sellability_reason: string | null;
product_title: string | null;
brand: string | null;
category_tree: string | null;
current_price: number | null;
avg_price_90d: number | null;
sales_rank: number | null;
monthly_sold: number | null;
seller_count: number | null;
amazon_is_seller: number | null;
verdict: string | null;
confidence: number | null;
reasoning: string | null;
last_seen_at: string;
};
const DB_PATH = process.env.RESULTS_DB_PATH || path.join("db", "results.db");
const DEFAULT_PAGE_SIZE = 25;
const MAX_PAGE_SIZE = 200;
const ASIN_PATTERN = /^[A-Z0-9]{10}$/;
const MAX_UPCS_PER_REQUEST = 1000;
const USE_CLAUDE = process.argv.includes("--claude");
initDb(DB_PATH);
const db = getDb(DB_PATH);
function json(data: unknown, status = 200): Response {
return new Response(JSON.stringify(data), {
status,
headers: { "content-type": "application/json; charset=utf-8" },
});
}
function csv(text: string, filename: string): Response {
return new Response(text, {
status: 200,
headers: {
"content-type": "text/csv; charset=utf-8",
"content-disposition": `attachment; filename="${filename}"`,
},
});
}
function xlsx(buffer: ArrayBuffer, filename: string): Response {
return new Response(buffer, {
status: 200,
headers: {
"content-type":
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
"content-disposition": `attachment; filename="${filename}"`,
},
});
}
function parseIntParam(value: string | null, fallback: number): number {
if (!value) return fallback;
const parsed = Number.parseInt(value, 10);
if (!Number.isFinite(parsed) || parsed < 1) return fallback;
return parsed;
}
function normalizeAsin(value: string): string {
return value.trim().toUpperCase();
}
function isValidAsin(value: string): boolean {
return ASIN_PATTERN.test(value);
}
function splitRawUpcValues(input: string): string[] {
return input
.split(/[\s,;|]+/)
.map((chunk) => chunk.trim())
.filter(Boolean);
}
function collectUpcsFromUnknown(value: unknown, target: string[]): void {
if (typeof value === "string") {
target.push(...splitRawUpcValues(value));
return;
}
if (typeof value === "number" && Number.isFinite(value)) {
target.push(String(Math.trunc(value)));
return;
}
if (Array.isArray(value)) {
for (const item of value) {
collectUpcsFromUnknown(item, target);
}
}
}
function normalizeAndDedupeUpcs(values: string[]): string[] {
const seen = new Set<string>();
const normalized: string[] = [];
for (const value of values) {
const upc = value.trim();
if (!upc || seen.has(upc)) continue;
seen.add(upc);
normalized.push(upc);
}
return normalized;
}
function parseUpcsFromSearchParams(params: URLSearchParams): string[] {
const parsed: string[] = [];
for (const value of params.getAll("upc")) {
collectUpcsFromUnknown(value, parsed);
}
const upcsValue = params.get("upcs");
if (upcsValue) {
collectUpcsFromUnknown(upcsValue, parsed);
}
return normalizeAndDedupeUpcs(parsed);
}
async function parseUpcsFromRequest(req: Request): Promise<string[]> {
if (req.method === "GET") {
const url = new URL(req.url);
return parseUpcsFromSearchParams(url.searchParams);
}
if (req.method !== "POST") {
throw new Error("Method not allowed");
}
let body: unknown;
try {
body = await req.json();
} catch {
throw new Error("Invalid JSON body");
}
const parsed: string[] = [];
if (body && typeof body === "object" && "upcs" in body) {
collectUpcsFromUnknown((body as { upcs?: unknown }).upcs, parsed);
} else {
collectUpcsFromUnknown(body, parsed);
}
return normalizeAndDedupeUpcs(parsed);
}
function validateUpcRequest(upcs: string[]): string | null {
if (upcs.length === 0) {
return "Provide at least one UPC via query (?upc=...) or JSON body { upcs: [...] }";
}
if (upcs.length > MAX_UPCS_PER_REQUEST) {
return `Too many UPCs. Maximum allowed per request is ${MAX_UPCS_PER_REQUEST}.`;
}
return null;
}
function summarizeLookupStatuses(
details: KeepaUpcLookupDetail[],
): Record<string, number> {
const counts: Record<string, number> = {};
for (const detail of details) {
counts[detail.status] = (counts[detail.status] ?? 0) + 1;
}
return counts;
}
function parsePositiveIntField(
value: unknown,
fieldName: string,
): number | undefined {
if (value == null) return undefined;
if (typeof value === "number") {
if (!Number.isInteger(value) || value < 1) {
throw new Error(`${fieldName} must be a positive integer`);
}
return value;
}
if (typeof value === "string" && value.trim().length > 0) {
const parsed = Number.parseInt(value, 10);
if (!Number.isFinite(parsed) || parsed < 1) {
throw new Error(`${fieldName} must be a positive integer`);
}
return parsed;
}
throw new Error(`${fieldName} must be a positive integer`);
}
type UpcFileProcessRequest = {
inputFile: string;
outputFile?: string;
inputBatchSize?: number;
upcLookupBatchSize?: number;
maxRows?: number;
};
async function parseUpcFileProcessRequest(
req: Request,
): Promise<UpcFileProcessRequest> {
if (req.method !== "POST") {
throw new Error("Method not allowed");
}
let body: unknown;
try {
body = await req.json();
} catch {
throw new Error("Invalid JSON body");
}
if (!body || typeof body !== "object") {
throw new Error("Request body must be an object");
}
const parsedBody = body as Record<string, unknown>;
const inputFileValue = parsedBody.inputFile;
if (
typeof inputFileValue !== "string" ||
inputFileValue.trim().length === 0
) {
throw new Error("inputFile is required and must be a non-empty string");
}
const outputFileValue = parsedBody.outputFile;
if (
outputFileValue != null &&
(typeof outputFileValue !== "string" || outputFileValue.trim().length === 0)
) {
throw new Error("outputFile must be a non-empty string when provided");
}
return {
inputFile: inputFileValue.trim(),
outputFile:
typeof outputFileValue === "string" ? outputFileValue.trim() : undefined,
inputBatchSize: parsePositiveIntField(
parsedBody.inputBatchSize,
"inputBatchSize",
),
upcLookupBatchSize: parsePositiveIntField(
parsedBody.upcLookupBatchSize,
"upcLookupBatchSize",
),
maxRows: parsePositiveIntField(parsedBody.maxRows, "maxRows"),
};
}
function parseSort(
sortParam: string | null,
allowed: Set<string>,
fallback: string,
): string {
if (!sortParam) return fallback;
const clauses = sortParam
.split(",")
.map((chunk) => chunk.trim())
.filter(Boolean)
.map((chunk) => {
const [fieldRaw, dirRaw] = chunk.split(":");
const field = fieldRaw?.trim();
const dir = dirRaw?.trim().toUpperCase() === "DESC" ? "DESC" : "ASC";
if (!field || !allowed.has(field)) return null;
return `${field} ${dir}`;
})
.filter((value): value is string => value !== null);
return clauses.length > 0 ? clauses.join(", ") : fallback;
}
function parseResultSort(
sortParam: string | null,
allowed: Set<string>,
fallback: string,
): string {
if (!sortParam) return fallback;
const clauses = sortParam
.split(",")
.map((chunk) => chunk.trim())
.filter(Boolean)
.map((chunk) => {
const [fieldRaw, dirRaw] = chunk.split(":");
const field = fieldRaw?.trim();
const dir = dirRaw?.trim().toUpperCase() === "DESC" ? "DESC" : "ASC";
if (!field || !allowed.has(field)) return null;
if (field === "monthly_sold")
return `CAST(COALESCE(monthly_sold, 0) AS INTEGER) ${dir}`;
if (field === "amazon_is_seller")
return `CAST(COALESCE(amazon_is_seller, 0) AS INTEGER) ${dir}`;
if (field === "amazon_buybox_share_pct_90d") {
return `CAST(COALESCE(amazon_buybox_share_pct_90d, 0) AS REAL) ${dir}`;
}
return `${field} ${dir}`;
})
.filter((value): value is string => value !== null);
return clauses.length > 0 ? clauses.join(", ") : fallback;
}
function escapeCsvValue(value: unknown): string {
if (value === null || value === undefined) return "";
const text = String(value);
const escaped = text.replaceAll('"', '""');
return /[",\n]/.test(escaped) ? `"${escaped}"` : escaped;
}
function parseResultFilters(
processType: ProcessType,
runId: number,
filters: URLSearchParams,
) {
const q = filters.get("q")?.trim() || "";
const verdict = filters.get("verdict")?.trim();
const sellabilityStatus = filters.get("sellabilityStatus")?.trim();
const minConfidence = filters.get("minConfidence")?.trim();
const maxConfidence = filters.get("maxConfidence")?.trim();
const amazonIsSeller = filters.get("amazonIsSeller")?.trim();
const conditions: string[] = ["run_id = ?"];
const params: Array<string | number> = [runId];
if (verdict) {
conditions.push("verdict = ?");
params.push(verdict);
}
if (sellabilityStatus) {
conditions.push("sellability_status = ?");
params.push(sellabilityStatus);
}
if (minConfidence) {
conditions.push("confidence >= ?");
params.push(Number(minConfidence));
}
if (maxConfidence) {
conditions.push("confidence <= ?");
params.push(Number(maxConfidence));
}
if (amazonIsSeller === "yes") {
conditions.push("amazon_is_seller = 1");
} else if (amazonIsSeller === "no") {
conditions.push("amazon_is_seller = 0");
}
if (q) {
const wildcard = `%${q}%`;
if (processType === "lead_analysis") {
conditions.push(
"(asin LIKE ? OR product_name LIKE ? OR brand LIKE ? OR category LIKE ? OR reasoning LIKE ?)",
);
params.push(wildcard, wildcard, wildcard, wildcard, wildcard);
} else {
conditions.push(
"(asin LIKE ? OR name LIKE ? OR brand LIKE ? OR category LIKE ? OR reasoning LIKE ?)",
);
params.push(wildcard, wildcard, wildcard, wildcard, wildcard);
}
}
return {
where: `WHERE ${conditions.join(" AND ")}`,
params,
};
}
function getRuns(filters: URLSearchParams) {
const q = filters.get("q")?.trim() || "";
const processType = filters.get("processType")?.trim();
const status = filters.get("status")?.trim();
const startDate = filters.get("startDate")?.trim();
const endDate = filters.get("endDate")?.trim();
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 allowedSort = new Set([
"timestamp",
"status",
"totalProducts",
"fbaCount",
"fbmCount",
"skipCount",
"runId",
"jobType",
]);
const orderBy = parseSort(
filters.get("sort"),
allowedSort,
"timestamp DESC, runId DESC",
);
const conditions: string[] = [];
const params: Array<string | number> = [];
if (processType === "lead_analysis" || processType === "category_analysis") {
conditions.push("processType = ?");
params.push(processType);
}
if (status) {
conditions.push("status = ?");
params.push(status);
}
if (startDate) {
conditions.push("timestamp >= ?");
params.push(startDate);
}
if (endDate) {
conditions.push("timestamp <= ?");
params.push(endDate);
}
if (q) {
conditions.push(
"(jobType LIKE ? OR source LIKE ? OR output LIKE ? OR CAST(runId AS TEXT) LIKE ?)",
);
const wildcard = `%${q}%`;
params.push(wildcard, wildcard, wildcard, wildcard);
}
const where =
conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
const baseUnion = `
SELECT
'lead_analysis' AS processType,
id AS runId,
timestamp,
'completed' AS status,
'lead_file_analysis' AS jobType,
input_file AS source,
output_file AS output,
COALESCE(total_products, 0) AS totalProducts,
COALESCE(fba_count, 0) AS fbaCount,
COALESCE(fbm_count, 0) AS fbmCount,
COALESCE(skip_count, 0) AS skipCount
FROM runs
UNION ALL
SELECT
'category_analysis' AS processType,
id AS runId,
run_timestamp AS timestamp,
status,
category_label AS jobType,
CAST(category_id AS TEXT) AS source,
NULL AS output,
COALESCE(top_asins_checked, 0) AS totalProducts,
COALESCE(fba_count, 0) AS fbaCount,
COALESCE(fbm_count, 0) AS fbmCount,
COALESCE(skip_count, 0) AS skipCount
FROM category_analysis_runs
`;
const totalRow = db
.query(`SELECT COUNT(*) as total FROM (${baseUnion}) all_runs ${where}`)
.get(...params) as { total: number };
const items = db
.query(
`SELECT * FROM (${baseUnion}) all_runs ${where} ORDER BY ${orderBy} LIMIT ? OFFSET ?`,
)
.all(...params, pageSize, offset) as RunRecord[];
return {
items,
page,
pageSize,
total: totalRow.total,
totalPages: Math.max(1, Math.ceil(totalRow.total / pageSize)),
};
}
function getProductList(filters: URLSearchParams) {
const q = filters.get("q")?.trim() || "";
const verdict = filters.get("verdict")?.trim();
const amazonIsSeller = filters.get("amazonIsSeller")?.trim();
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 conditions: string[] = [];
const params: Array<string | number> = [];
if (verdict === "FBA" || verdict === "FBM" || verdict === "SKIP") {
conditions.push("verdict = ?");
params.push(verdict);
}
if (amazonIsSeller === "yes") {
conditions.push("amazon_is_seller = 1");
} else if (amazonIsSeller === "no") {
conditions.push("amazon_is_seller = 0");
}
if (q) {
const wildcard = `%${q}%`;
conditions.push(
"(asin LIKE ? OR product_name LIKE ? OR brand LIKE ? OR category LIKE ?)",
);
params.push(wildcard, wildcard, wildcard, wildcard);
}
const where =
conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
const allowedSort = new Set([
"asin",
"verdict",
"monthly_sold",
"seller_count",
"amazon_is_seller",
"amazon_buybox_share_pct_90d",
"sales_rank",
"current_price",
"product_name",
"brand",
"category",
"avg_price_90d",
"confidence",
"reasoning",
"fetched_at",
]);
const orderBy = parseResultSort(
filters.get("sort"),
allowedSort,
"CAST(COALESCE(monthly_sold, 0) AS INTEGER) DESC, fetched_at DESC",
);
const baseUnion = `
SELECT
'lead_analysis' AS processType,
run_id AS runId,
asin,
product_name,
brand,
category,
verdict,
confidence,
sellability_status,
monthly_sold,
sellers AS seller_count,
amazon_is_seller,
amazon_buybox_share_pct_90d,
sales_rank,
current_price,
avg_price_90d,
reasoning,
fetched_at
FROM results
UNION ALL
SELECT
'category_analysis' AS processType,
run_id AS runId,
asin,
name AS product_name,
brand,
category,
verdict,
confidence,
sellability_status,
monthly_sold,
seller_count,
amazon_is_seller,
amazon_buybox_share_pct_90d,
sales_rank,
current_price,
avg_price_90d,
reasoning,
fetched_at
FROM product_analysis_results
`;
const totalRow = db
.query(`SELECT COUNT(*) as total FROM (${baseUnion}) all_products ${where}`)
.get(...params) as { total: number };
const items = db
.query(
`SELECT * FROM (${baseUnion}) all_products ${where} ORDER BY ${orderBy} LIMIT ? OFFSET ?`,
)
.all(...params, pageSize, offset) as ProductListRecord[];
return {
items,
page,
pageSize,
total: totalRow.total,
totalPages: Math.max(1, Math.ceil(totalRow.total / pageSize)),
};
}
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(
`(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);
}
return {
where: conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "",
params,
};
}
function parseStalkerSort(sortParam: string | null): string {
const allowedSort = new Set([
"runId",
"started_at",
"seller_id",
"seller_name",
"rating",
"rating_count",
"discovered_from_count",
"persisted_inventory_asin_count",
"storefront_asin_total",
"last_seen_at",
]);
const parsed = parseSort(
sortParam,
allowedSort,
"persisted_inventory_asin_count DESC, last_seen_at DESC, seller_id 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,
s.seller_id,
s.seller_name,
s.rating,
s.rating_count,
s.storefront_asin_total,
s.persisted_inventory_sample_count,
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
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 r.id, s.seller_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 seller_id) AS sellers,
COALESCE(SUM(persisted_inventory_asin_count), 0) AS persistedInventoryAsins
FROM (${baseSelect}) stalker_rows`,
)
.get(...params) as {
runs: 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 parseStalkerProductFilters(filters: URLSearchParams) {
const q = filters.get("q")?.trim() || "";
const sellerId = filters.get("sellerId")?.trim().toUpperCase() || "";
const runIdRaw = filters.get("runId")?.trim() || "";
const verdict = filters.get("verdict")?.trim().toUpperCase() || "";
const amazonIsSeller = filters.get("amazonIsSeller")?.trim() || "";
const minPriceRaw = filters.get("minPrice")?.trim() || "";
const maxPriceRaw = filters.get("maxPrice")?.trim() || "";
const minMonthlySoldRaw = filters.get("minMonthlySold")?.trim() || "";
const maxMonthlySoldRaw = filters.get("maxMonthlySold")?.trim() || "";
const minSalesRankRaw = filters.get("minSalesRank")?.trim() || "";
const maxSalesRankRaw = filters.get("maxSalesRank")?.trim() || "";
const minSellerCountRaw = filters.get("minSellerCount")?.trim() || "";
const maxSellerCountRaw = filters.get("maxSellerCount")?.trim() || "";
const minRatingCountRaw = filters.get("minRatingCount")?.trim() || "";
const maxRatingCountRaw = filters.get("maxRatingCount")?.trim() || "";
const minConfidenceRaw = filters.get("minConfidence")?.trim() || "";
const maxConfidenceRaw = filters.get("maxConfidence")?.trim() || "";
const conditions = [
"inv.can_sell = 1",
"inv.sellability_status = 'available'",
];
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 (verdict === "FBA" || verdict === "FBM" || verdict === "SKIP") {
conditions.push("analysis.verdict = ?");
params.push(verdict);
} else if (verdict === "UNANALYZED") {
conditions.push("analysis.verdict IS NULL");
}
if (amazonIsSeller === "yes") {
conditions.push("inv.amazon_is_seller = 1");
} else if (amazonIsSeller === "no") {
conditions.push("inv.amazon_is_seller = 0");
} else if (amazonIsSeller === "unknown") {
conditions.push("inv.amazon_is_seller IS NULL");
}
const numericFilters: Array<[string, string, string]> = [
[minPriceRaw, "inv.current_price >= ?", "minPrice"],
[maxPriceRaw, "inv.current_price <= ?", "maxPrice"],
[minMonthlySoldRaw, "inv.monthly_sold >= ?", "minMonthlySold"],
[maxMonthlySoldRaw, "inv.monthly_sold <= ?", "maxMonthlySold"],
[minSalesRankRaw, "inv.sales_rank >= ?", "minSalesRank"],
[maxSalesRankRaw, "inv.sales_rank <= ?", "maxSalesRank"],
[minSellerCountRaw, "inv.seller_count >= ?", "minSellerCount"],
[maxSellerCountRaw, "inv.seller_count <= ?", "maxSellerCount"],
[minRatingCountRaw, "s.rating_count >= ?", "minRatingCount"],
[maxRatingCountRaw, "s.rating_count <= ?", "maxRatingCount"],
[minConfidenceRaw, "analysis.confidence >= ?", "minConfidence"],
[maxConfidenceRaw, "analysis.confidence <= ?", "maxConfidence"],
];
for (const [raw, condition] of numericFilters) {
if (!raw) continue;
const value = Number(raw);
if (Number.isFinite(value)) {
conditions.push(condition);
params.push(value);
}
}
if (q) {
const wildcard = `%${q}%`;
conditions.push(
`(
inv.asin LIKE ?
OR inv.product_title LIKE ?
OR inv.brand LIKE ?
OR inv.category_tree LIKE ?
OR s.seller_id LIKE ?
OR s.seller_name LIKE ?
)`,
);
params.push(wildcard, wildcard, wildcard, wildcard, wildcard, wildcard);
}
return {
where: `WHERE ${conditions.join(" AND ")}`,
params,
};
}
function parseStalkerProductSort(sortParam: string | null): string {
const allowedSort = new Set([
"runId",
"started_at",
"seller_id",
"seller_name",
"rating",
"rating_count",
"asin",
"product_title",
"brand",
"current_price",
"avg_price_90d",
"sales_rank",
"monthly_sold",
"seller_count",
"amazon_is_seller",
"verdict",
"confidence",
"last_seen_at",
]);
return parseSort(
sortParam,
allowedSort,
"monthly_sold DESC, last_seen_at DESC, asin ASC",
);
}
function getStalkerProducts(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 } = parseStalkerProductFilters(filters);
const orderBy = parseStalkerProductSort(filters.get("sort"));
const baseSelect = `
SELECT
r.id AS runId,
r.started_at,
s.seller_id,
s.seller_name,
s.rating,
s.rating_count,
inv.asin,
inv.can_sell,
inv.sellability_status,
inv.sellability_reason,
inv.product_title,
inv.brand,
inv.category_tree,
inv.current_price,
inv.avg_price_90d,
inv.sales_rank,
inv.monthly_sold,
inv.seller_count,
inv.amazon_is_seller,
analysis.verdict,
analysis.confidence,
analysis.reasoning,
inv.last_seen_at
FROM stalker_seller_inventory inv
JOIN stalker_runs r ON r.id = inv.run_id
JOIN stalker_sellers s ON s.seller_id = inv.seller_id
LEFT JOIN product_analysis_results analysis ON analysis.asin = inv.asin
${where}
`;
const totalRow = db
.query(`SELECT COUNT(*) AS total FROM (${baseSelect}) stalker_products`)
.get(...params) as { total: number };
const summary = db
.query(
`SELECT
COUNT(DISTINCT runId) AS runs,
COUNT(DISTINCT seller_id) AS sellers,
COUNT(DISTINCT asin) AS products
FROM (${baseSelect}) stalker_products`,
)
.get(...params) as {
runs: number;
sellers: number;
products: number;
};
const items = db
.query(
`SELECT * FROM (${baseSelect}) stalker_products
ORDER BY ${orderBy}
LIMIT ? OFFSET ?`,
)
.all(...params, pageSize, offset) as StalkerProductRecord[];
return {
items,
summary,
page,
pageSize,
total: totalRow.total,
totalPages: Math.max(1, Math.ceil(totalRow.total / pageSize)),
};
}
function getStalkerProductsForExport(
filters: URLSearchParams,
): StalkerProductRecord[] {
const { where, params } = parseStalkerProductFilters(filters);
const orderBy = parseStalkerProductSort(filters.get("sort"));
return db
.query(
`SELECT * FROM (
SELECT
r.id AS runId,
r.started_at,
s.seller_id,
s.seller_name,
s.rating,
s.rating_count,
inv.asin,
inv.can_sell,
inv.sellability_status,
inv.sellability_reason,
inv.product_title,
inv.brand,
inv.category_tree,
inv.current_price,
inv.avg_price_90d,
inv.sales_rank,
inv.monthly_sold,
inv.seller_count,
inv.amazon_is_seller,
analysis.verdict,
analysis.confidence,
analysis.reasoning,
inv.last_seen_at
FROM stalker_seller_inventory inv
JOIN stalker_runs r ON r.id = inv.run_id
JOIN stalker_sellers s ON s.seller_id = inv.seller_id
LEFT JOIN product_analysis_results analysis ON analysis.asin = inv.asin
${where}
) stalker_products
ORDER BY ${orderBy}`,
)
.all(...params) as StalkerProductRecord[];
}
function parseCategoryTreeForExport(value: string | null): string {
if (!value) return "";
try {
const parsed = JSON.parse(value);
return Array.isArray(parsed)
? parsed.filter((item) => typeof item === "string").join(" > ")
: "";
} catch {
return "";
}
}
function exportStalkerProductsXlsx(filters: URLSearchParams): Response {
const rows = getStalkerProductsForExport(filters);
const data = rows.map((row) => ({
ASIN: row.asin,
"Amazon URL": `https://amazon.com/dp/${row.asin}`,
Product: row.product_title ?? "",
Brand: row.brand ?? "",
Category: parseCategoryTreeForExport(row.category_tree),
"Monthly Sold": row.monthly_sold ?? null,
Sellers: row.seller_count ?? null,
"Amazon Seller":
row.amazon_is_seller == null
? ""
: row.amazon_is_seller === 1
? "Yes"
: "No",
"Sales Rank": row.sales_rank ?? null,
"Current Price": row.current_price ?? null,
"Avg 90d": row.avg_price_90d ?? null,
Verdict: row.verdict ?? "",
Confidence: row.confidence ?? null,
Reasoning: row.reasoning ?? "",
"Seller ID": row.seller_id,
Seller: row.seller_name ?? "",
"Seller Rating": row.rating ?? null,
"Seller Rating Count": row.rating_count ?? null,
"Sellability Status": row.sellability_status,
"Sellability Reason": row.sellability_reason ?? "",
"Run ID": row.runId,
"Last Seen": row.last_seen_at,
}));
const workbook = XLSX.utils.book_new();
const worksheet = XLSX.utils.json_to_sheet(data);
worksheet["!cols"] = [
{ wch: 12 },
{ wch: 32 },
{ wch: 48 },
{ wch: 20 },
{ wch: 34 },
{ wch: 14 },
{ wch: 10 },
{ wch: 14 },
{ wch: 12 },
{ wch: 12 },
{ wch: 12 },
{ wch: 10 },
{ wch: 12 },
{ wch: 60 },
{ wch: 18 },
{ wch: 24 },
{ wch: 12 },
{ wch: 20 },
{ wch: 18 },
{ wch: 40 },
{ wch: 10 },
{ wch: 24 },
];
XLSX.utils.book_append_sheet(workbook, worksheet, "Sellable Products");
const buffer = XLSX.write(workbook, {
type: "array",
bookType: "xlsx",
}) as ArrayBuffer;
return xlsx(buffer, "stalker-sellable-products.xlsx");
}
function purgeStalkerData() {
const counts = {
inventory: (
db
.query("SELECT COUNT(*) AS count FROM stalker_seller_inventory")
.get() as { count: number }
).count,
asinSellers: (
db.query("SELECT COUNT(*) AS count FROM stalker_asin_sellers").get() as {
count: number;
}
).count,
sellers: (
db.query("SELECT COUNT(*) AS count FROM stalker_sellers").get() as {
count: number;
}
).count,
scans: (
db.query("SELECT COUNT(*) AS count FROM stalker_asin_scans").get() as {
count: number;
}
).count,
runs: (
db.query("SELECT COUNT(*) AS count FROM stalker_runs").get() as {
count: number;
}
).count,
};
db.transaction(() => {
db.run("DELETE FROM stalker_seller_inventory");
db.run("DELETE FROM stalker_asin_sellers");
db.run("DELETE FROM stalker_sellers");
db.run("DELETE FROM stalker_asin_scans");
db.run("DELETE FROM stalker_runs");
})();
return { ok: true, deleted: counts };
}
function getRun(processType: ProcessType, runId: number) {
if (processType === "lead_analysis") {
const run = db
.query(
`SELECT
id AS runId,
timestamp,
'completed' AS status,
'lead_file_analysis' AS jobType,
input_file AS source,
output_file AS output,
COALESCE(total_products, 0) AS totalProducts,
COALESCE(fba_count, 0) AS fbaCount,
COALESCE(fbm_count, 0) AS fbmCount,
COALESCE(skip_count, 0) AS skipCount
FROM runs WHERE id = ?`,
)
.get(runId);
return run ?? null;
}
const run = db
.query(
`SELECT
id AS runId,
run_timestamp AS timestamp,
status,
category_label AS jobType,
CAST(category_id AS TEXT) AS source,
NULL AS output,
COALESCE(top_asins_checked, 0) AS totalProducts,
COALESCE(fba_count, 0) AS fbaCount,
COALESCE(fbm_count, 0) AS fbmCount,
COALESCE(skip_count, 0) AS skipCount,
error_message AS errorMessage,
available_asins AS availableAsins
FROM category_analysis_runs WHERE id = ?`,
)
.get(runId);
return run ?? null;
}
function getRunResults(
processType: ProcessType,
runId: number,
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 tableName =
processType === "lead_analysis" ? "results" : "product_analysis_results";
const productNameSelect =
processType === "lead_analysis" ? "product_name" : "name AS product_name";
const sellerCountSelect =
processType === "lead_analysis"
? "sellers AS seller_count"
: "seller_count";
const salesRankAvgSelect =
processType === "lead_analysis"
? "rank_avg_90d AS sales_rank_avg_90d"
: "sales_rank_avg_90d";
const { where, params } = parseResultFilters(processType, runId, filters);
const allowedSort = new Set([
"asin",
"product_name",
"brand",
"category",
"current_price",
"avg_price_90d",
"sales_rank",
"seller_count",
"amazon_is_seller",
"amazon_buybox_share_pct_90d",
"monthly_sold",
"verdict",
"confidence",
"fetched_at",
]);
const orderBy = parseResultSort(
filters.get("sort"),
allowedSort,
"CAST(COALESCE(monthly_sold, 0) AS INTEGER) DESC, asin ASC",
);
const totalRow = db
.query(`SELECT COUNT(*) as total FROM ${tableName} ${where}`)
.get(...params) as { total: number };
const items = db
.query(
`SELECT
id,
run_id,
asin,
${productNameSelect},
brand,
category,
unit_cost,
current_price,
avg_price_90d,
avg_price_90d_sheet,
selling_price_sheet,
sales_rank,
${salesRankAvgSelect},
${sellerCountSelect},
amazon_is_seller,
amazon_buybox_share_pct_90d,
monthly_sold,
rank_drops_30d,
rank_drops_90d,
fba_fee,
fbm_fee,
referral_percent,
can_sell,
sellability_status,
sellability_reason,
verdict,
confidence,
reasoning,
fetched_at
FROM ${tableName}
${where}
ORDER BY ${orderBy}
LIMIT ? OFFSET ?`,
)
.all(...params, pageSize, offset);
return {
items,
page,
pageSize,
total: totalRow.total,
totalPages: Math.max(1, Math.ceil(totalRow.total / pageSize)),
};
}
function deleteRun(processType: ProcessType, runId: number) {
if (processType === "lead_analysis") {
const resultRows = db
.query("DELETE FROM results WHERE run_id = ?")
.run(runId);
const runRows = db.query("DELETE FROM runs WHERE id = ?").run(runId);
return {
deletedRun: runRows.changes > 0,
deletedResults: resultRows.changes,
};
}
const resultRows = db
.query("DELETE FROM product_analysis_results WHERE run_id = ?")
.run(runId);
const runRows = db
.query("DELETE FROM category_analysis_runs WHERE id = ?")
.run(runId);
return {
deletedRun: runRows.changes > 0,
deletedResults: resultRows.changes,
};
}
function exportRunResultsCsv(
processType: ProcessType,
runId: number,
filters: URLSearchParams,
) {
const tableName =
processType === "lead_analysis" ? "results" : "product_analysis_results";
const productNameSelect =
processType === "lead_analysis" ? "product_name" : "name AS product_name";
const sellerCountSelect =
processType === "lead_analysis"
? "sellers AS seller_count"
: "seller_count";
const salesRankAvgSelect =
processType === "lead_analysis"
? "rank_avg_90d AS sales_rank_avg_90d"
: "sales_rank_avg_90d";
const { where, params } = parseResultFilters(processType, runId, filters);
const allowedSort = new Set([
"asin",
"product_name",
"brand",
"category",
"current_price",
"avg_price_90d",
"sales_rank",
"seller_count",
"amazon_is_seller",
"amazon_buybox_share_pct_90d",
"monthly_sold",
"verdict",
"confidence",
"fetched_at",
]);
const orderBy = parseResultSort(
filters.get("sort"),
allowedSort,
"CAST(COALESCE(monthly_sold, 0) AS INTEGER) DESC, asin ASC",
);
const rows = db
.query(
`SELECT
run_id,
asin,
${productNameSelect},
brand,
category,
unit_cost,
current_price,
avg_price_90d,
${salesRankAvgSelect},
${sellerCountSelect},
amazon_is_seller,
amazon_buybox_share_pct_90d,
monthly_sold,
sellability_status,
verdict,
confidence,
reasoning,
fetched_at
FROM ${tableName}
${where}
ORDER BY ${orderBy}`,
)
.all(...params) as Array<Record<string, unknown>>;
const headers = [
"run_id",
"asin",
"product_name",
"brand",
"category",
"unit_cost",
"current_price",
"avg_price_90d",
"sales_rank_avg_90d",
"seller_count",
"amazon_is_seller",
"amazon_buybox_share_pct_90d",
"monthly_sold",
"sellability_status",
"verdict",
"confidence",
"reasoning",
"fetched_at",
];
const lines = [headers.join(",")];
for (const row of rows) {
lines.push(headers.map((h) => escapeCsvValue(row[h])).join(","));
}
return lines.join("\n");
}
type ReanalyzeSourceRow = {
asin: string;
product_name: string | null;
brand: string | null;
category: string | null;
unit_cost: number | null;
sales_rank: number | null;
avg_price_90d_sheet: number | null;
selling_price_sheet: number | null;
fba_net_sheet: number | null;
gross_profit_dollar: number | null;
gross_profit_pct: number | null;
net_profit_sheet: number | null;
roi_sheet: number | null;
moq: number | null;
moq_cost: number | null;
qty_available: number | null;
supplier: string | null;
source_url: string | null;
asin_link: string | null;
promo_coupon_code: string | null;
notes: string | null;
lead_date: string | null;
};
function getReanalyzeSourceRow(
processType: ProcessType,
runId: number,
asin: string,
): ReanalyzeSourceRow | null {
if (processType === "lead_analysis") {
return (
(db
.query(
`SELECT
asin,
product_name,
brand,
category,
unit_cost,
sales_rank,
avg_price_90d_sheet,
selling_price_sheet,
fba_net_sheet,
gross_profit_dollar,
gross_profit_pct,
net_profit_sheet,
roi_sheet,
moq,
moq_cost,
qty_available,
supplier,
source_url,
asin_link,
promo_coupon_code,
notes,
lead_date
FROM results
WHERE run_id = ? AND asin = ?
LIMIT 1`,
)
.get(runId, asin) as ReanalyzeSourceRow | null) ?? null
);
}
return (
(db
.query(
`SELECT
asin,
name AS product_name,
brand,
category,
unit_cost,
sales_rank,
avg_price_90d_sheet,
selling_price_sheet,
NULL AS fba_net_sheet,
NULL AS gross_profit_dollar,
NULL AS gross_profit_pct,
NULL AS net_profit_sheet,
NULL AS roi_sheet,
NULL AS moq,
NULL AS moq_cost,
NULL AS qty_available,
NULL AS supplier,
NULL AS source_url,
NULL AS asin_link,
NULL AS promo_coupon_code,
NULL AS notes,
NULL AS lead_date
FROM product_analysis_results
WHERE run_id = ? AND asin = ?
LIMIT 1`,
)
.get(runId, asin) as ReanalyzeSourceRow | null) ?? null
);
}
function toProductRecord(row: ReanalyzeSourceRow): ProductRecord {
return {
asin: row.asin,
name: row.product_name ?? row.asin,
unitCost: row.unit_cost ?? 0,
brand: row.brand ?? undefined,
category: row.category ?? undefined,
amazonRank: row.sales_rank ?? undefined,
avgPrice90FromSheet: row.avg_price_90d_sheet ?? undefined,
sellingPriceFromSheet: row.selling_price_sheet ?? undefined,
fbaNet: row.fba_net_sheet ?? undefined,
grossProfit: row.gross_profit_dollar ?? undefined,
grossProfitPct: row.gross_profit_pct ?? undefined,
netProfitFromSheet: row.net_profit_sheet ?? undefined,
roiFromSheet: row.roi_sheet ?? undefined,
moq: row.moq ?? undefined,
moqCost: row.moq_cost ?? undefined,
totalQtyAvail: row.qty_available ?? undefined,
supplier: row.supplier ?? undefined,
sourceUrl: row.source_url ?? undefined,
asinLink: row.asin_link ?? undefined,
promoCouponCode: row.promo_coupon_code ?? undefined,
notes: row.notes ?? undefined,
leadDate: row.lead_date ?? undefined,
};
}
function unknownSpApiData(): SpApiData {
return {
fbaFee: 5.0,
fbmFee: 1.5,
referralFeePercent: 15,
estimatedSalePrice: 0,
canSell: null,
sellabilityStatus: "unknown",
sellabilityReason: "Sellability check returned no result",
};
}
function refreshRunCounts(processType: ProcessType, runId: number): void {
if (processType === "lead_analysis") {
const stats = db
.query(
`SELECT
COUNT(*) AS total,
SUM(CASE WHEN verdict = 'FBA' THEN 1 ELSE 0 END) AS fba,
SUM(CASE WHEN verdict = 'FBM' THEN 1 ELSE 0 END) AS fbm,
SUM(CASE WHEN verdict = 'SKIP' THEN 1 ELSE 0 END) AS skip
FROM results
WHERE run_id = ?`,
)
.get(runId) as {
total: number;
fba: number | null;
fbm: number | null;
skip: number | null;
};
db.query(
`UPDATE runs
SET total_products = ?, fba_count = ?, fbm_count = ?, skip_count = ?
WHERE id = ?`,
).run(
stats.total ?? 0,
stats.fba ?? 0,
stats.fbm ?? 0,
stats.skip ?? 0,
runId,
);
return;
}
const stats = db
.query(
`SELECT
COUNT(*) AS available,
SUM(CASE WHEN verdict = 'FBA' THEN 1 ELSE 0 END) AS fba,
SUM(CASE WHEN verdict = 'FBM' THEN 1 ELSE 0 END) AS fbm,
SUM(CASE WHEN verdict = 'SKIP' THEN 1 ELSE 0 END) AS skip
FROM product_analysis_results
WHERE run_id = ?`,
)
.get(runId) as {
available: number;
fba: number | null;
fbm: number | null;
skip: number | null;
};
db.query(
`UPDATE category_analysis_runs
SET available_asins = ?, fba_count = ?, fbm_count = ?, skip_count = ?
WHERE id = ?`,
).run(
stats.available ?? 0,
stats.fba ?? 0,
stats.fbm ?? 0,
stats.skip ?? 0,
runId,
);
}
async function reanalyzeSingleAsin(
processType: ProcessType,
runId: number,
asin: string,
): Promise<{
asin: string;
runId: number;
processType: ProcessType;
fetchedAt: string;
}> {
const row = getReanalyzeSourceRow(processType, runId, asin);
if (!row) {
throw new Error("Result row not found");
}
const productRecord = toProductRecord(row);
let keepa = null;
try {
const keepaMap = await fetchKeepaDataBatch([asin]);
keepa = keepaMap.get(asin) ?? null;
} catch {
keepa = null;
}
const sellabilityMap = await fetchSellabilityBatch([asin]);
const sellability = sellabilityMap.get(asin) ?? {
canSell: null,
sellabilityStatus: "unknown" as const,
sellabilityReason: "Sellability check returned no result",
};
const spApi = await fetchSpApiPricingAndFees(asin, sellability);
if (keepa?.currentPrice && spApi.estimatedSalePrice === 0) {
spApi.estimatedSalePrice = keepa.currentPrice;
}
const enriched: EnrichedProduct = {
record: productRecord,
keepa,
spApi: spApi ?? unknownSpApiData(),
fetchedAt: new Date().toISOString(),
};
const verdicts = await analyzeProducts([enriched], {
useClaude: USE_CLAUDE,
});
const verdict = verdicts[0] ?? {
asin,
verdict: "SKIP" as const,
confidence: 0,
reasoning: "LLM analysis returned no verdict",
};
const amazonIsSeller =
keepa?.amazonIsSeller == null ? null : keepa.amazonIsSeller ? 1 : 0;
const fetchedAt = enriched.fetchedAt;
if (processType === "lead_analysis") {
db.query(
`UPDATE results SET
current_price = ?,
avg_price_90d = ?,
sales_rank = ?,
rank_avg_90d = ?,
sellers = ?,
amazon_is_seller = ?,
amazon_buybox_share_pct_90d = ?,
monthly_sold = ?,
rank_drops_30d = ?,
rank_drops_90d = ?,
fba_fee = ?,
fbm_fee = ?,
referral_percent = ?,
can_sell = ?,
sellability_status = ?,
sellability_reason = ?,
verdict = ?,
confidence = ?,
reasoning = ?,
fetched_at = ?
WHERE run_id = ? AND asin = ?`,
).run(
keepa?.currentPrice ?? null,
keepa?.avgPrice90 ?? null,
keepa?.salesRank ?? row.sales_rank ?? null,
keepa?.salesRankAvg90 ?? null,
keepa?.sellerCount ?? null,
amazonIsSeller,
keepa?.amazonBuyboxSharePct90d ?? null,
keepa?.monthlySold ?? null,
keepa?.salesRankDrops30 ?? null,
keepa?.salesRankDrops90 ?? null,
spApi.fbaFee,
spApi.fbmFee,
spApi.referralFeePercent,
spApi.canSell == null ? null : spApi.canSell ? 1 : 0,
spApi.sellabilityStatus,
spApi.sellabilityReason ?? null,
verdict.verdict,
verdict.confidence,
verdict.reasoning,
fetchedAt,
runId,
asin,
);
} else {
db.query(
`UPDATE product_analysis_results SET
current_price = ?,
avg_price_90d = ?,
sales_rank = ?,
sales_rank_avg_90d = ?,
seller_count = ?,
amazon_is_seller = ?,
amazon_buybox_share_pct_90d = ?,
monthly_sold = ?,
rank_drops_30d = ?,
rank_drops_90d = ?,
fba_fee = ?,
fbm_fee = ?,
referral_percent = ?,
can_sell = ?,
sellability_status = ?,
sellability_reason = ?,
verdict = ?,
confidence = ?,
reasoning = ?,
fetched_at = ?
WHERE run_id = ? AND asin = ?`,
).run(
keepa?.currentPrice ?? null,
keepa?.avgPrice90 ?? null,
keepa?.salesRank ?? row.sales_rank ?? null,
keepa?.salesRankAvg90 ?? null,
keepa?.sellerCount ?? null,
amazonIsSeller,
keepa?.amazonBuyboxSharePct90d ?? null,
keepa?.monthlySold ?? null,
keepa?.salesRankDrops30 ?? null,
keepa?.salesRankDrops90 ?? null,
spApi.fbaFee,
spApi.fbmFee,
spApi.referralFeePercent,
spApi.canSell == null ? null : spApi.canSell ? 1 : 0,
spApi.sellabilityStatus,
spApi.sellabilityReason ?? null,
verdict.verdict,
verdict.confidence,
verdict.reasoning,
fetchedAt,
runId,
asin,
);
}
refreshRunCounts(processType, runId);
return { asin, runId, processType, fetchedAt };
}
const server = Bun.serve({
port: Number(process.env.PORT || "3000"),
routes: {
"/": index,
"/products": index,
"/stalker": index,
"/stalker/products": index,
"/runs/:processType/:runId": index,
"/api/runs": (req) => {
const url = new URL(req.url);
return json(getRuns(url.searchParams));
},
"/api/products": (req) => {
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/stalker/products": (req) => {
const url = new URL(req.url);
return json(getStalkerProducts(url.searchParams));
},
"/api/stalker/products/export.xlsx": (req) => {
const url = new URL(req.url);
return exportStalkerProductsXlsx(url.searchParams);
},
"/api/stalker/purge": (req) => {
if (req.method !== "DELETE" && req.method !== "POST") {
return json({ error: "Method not allowed" }, 405);
}
return json(purgeStalkerData());
},
"/api/upc/map": async (req) => {
let upcs: string[];
try {
upcs = await parseUpcsFromRequest(req);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
const status = message === "Method not allowed" ? 405 : 400;
return json({ error: message }, status);
}
const validationError = validateUpcRequest(upcs);
if (validationError) {
return json({ error: validationError }, 400);
}
try {
const mapping = await mapUpcsToAsins(upcs);
const items = Array.from(mapping.entries()).map(([upc, asin]) => ({
upc,
asin,
}));
return json({
requested: upcs.length,
matched: items.length,
items,
});
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
return json({ error: message }, 500);
}
},
"/api/upc/lookup": async (req) => {
let upcs: string[];
try {
upcs = await parseUpcsFromRequest(req);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
const status = message === "Method not allowed" ? 405 : 400;
return json({ error: message }, status);
}
const validationError = validateUpcRequest(upcs);
if (validationError) {
return json({ error: validationError }, 400);
}
try {
const detailMap = await lookupKeepaUpcs(upcs);
const items = Array.from(detailMap.values());
return json({
requested: upcs.length,
statusCounts: summarizeLookupStatuses(items),
items,
});
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
return json({ error: message }, 500);
}
},
"/api/process/upc-file": async (req) => {
let parsed: UpcFileProcessRequest;
try {
parsed = await parseUpcFileProcessRequest(req);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
const status =
message === "Method not allowed"
? 405
: message === "Invalid JSON body"
? 400
: 400;
return json({ error: message }, status);
}
try {
const summary = await runUpcFileAnalysis({
inputFile: parsed.inputFile,
outputFile: parsed.outputFile,
inputBatchSize: parsed.inputBatchSize,
upcLookupBatchSize: parsed.upcLookupBatchSize,
maxRows: parsed.maxRows,
dbPath: DB_PATH,
manageResources: false,
});
return json(summary);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
return json({ error: message }, 500);
}
},
"/api/runs/:processType/:runId": (req) => {
const processType = req.params.processType as ProcessType;
const runId = Number(req.params.runId);
if (
!(
processType === "lead_analysis" || processType === "category_analysis"
) ||
!Number.isInteger(runId)
) {
return json({ error: "Invalid run identifier" }, 400);
}
if (req.method === "DELETE") {
const deleted = deleteRun(processType, runId);
if (!deleted.deletedRun) return json({ error: "Run not found" }, 404);
return json(deleted);
}
const run = getRun(processType, runId);
if (!run) return json({ error: "Run not found" }, 404);
const summary = {
totalProducts: (run as { totalProducts: number }).totalProducts,
fbaCount: (run as { fbaCount: number }).fbaCount,
fbmCount: (run as { fbmCount: number }).fbmCount,
skipCount: (run as { skipCount: number }).skipCount,
};
return json({ processType, ...run, summary });
},
"/api/runs/:processType/:runId/results": (req) => {
const processType = req.params.processType as ProcessType;
const runId = Number(req.params.runId);
if (
!(
processType === "lead_analysis" || processType === "category_analysis"
) ||
!Number.isInteger(runId)
) {
return json({ error: "Invalid run identifier" }, 400);
}
const url = new URL(req.url);
const payload = getRunResults(processType, runId, url.searchParams);
return json(payload);
},
"/api/runs/:processType/:runId/asins/:asin/reanalyze": async (req) => {
const processType = req.params.processType as ProcessType;
const runId = Number(req.params.runId);
const asin = normalizeAsin(req.params.asin);
if (
!(
processType === "lead_analysis" || processType === "category_analysis"
) ||
!Number.isInteger(runId)
) {
return json({ error: "Invalid run identifier" }, 400);
}
if (req.method !== "POST") {
return json({ error: "Method not allowed" }, 405);
}
if (!isValidAsin(asin)) {
return json({ error: "Invalid ASIN" }, 400);
}
try {
const result = await reanalyzeSingleAsin(processType, runId, asin);
return json(result);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
if (message === "Result row not found") {
return json({ error: message }, 404);
}
return json({ error: message }, 500);
}
},
"/api/runs/:processType/:runId/export.csv": (req) => {
const processType = req.params.processType as ProcessType;
const runId = Number(req.params.runId);
if (
!(
processType === "lead_analysis" || processType === "category_analysis"
) ||
!Number.isInteger(runId)
) {
return json({ error: "Invalid run identifier" }, 400);
}
const url = new URL(req.url);
const csvText = exportRunResultsCsv(processType, runId, url.searchParams);
return csv(csvText, `run-${processType}-${runId}.csv`);
},
},
fetch() {
return json({ error: "Not found" }, 404);
},
development: {
hmr: true,
console: true,
},
});
console.log(`Results viewer running on http://localhost:${server.port}`);