- 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.
2073 lines
55 KiB
TypeScript
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}`);
|