feat: add XLSX export functionality for Stalker products and enhance UI for export link

This commit is contained in:
Victor Noguera
2026-05-19 23:12:34 -04:00
parent 90bfee8791
commit 0c2e59771c
3 changed files with 174 additions and 23 deletions

View File

@@ -1,5 +1,6 @@
import index from "./web/index.html";
import path from "node:path";
import * as XLSX from "xlsx";
import { getDb, initDb } from "./database.ts";
import {
fetchKeepaDataBatch,
@@ -123,6 +124,16 @@ function csv(text: string, filename: string): Response {
});
}
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);
@@ -1025,6 +1036,123 @@ function getStalkerProducts(filters: URLSearchParams) {
};
}
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,
@@ -1694,6 +1822,10 @@ const server = Bun.serve({
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);