diff --git a/src/server.ts b/src/server.ts index 428570d..b177c69 100644 --- a/src/server.ts +++ b/src/server.ts @@ -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); diff --git a/src/web/frontend.tsx b/src/web/frontend.tsx index 533f3dc..43f9425 100644 --- a/src/web/frontend.tsx +++ b/src/web/frontend.tsx @@ -1153,32 +1153,39 @@ function StalkerProductsExplorer({ const [pageSize, setPageSize] = useState(50); const [sort, setSort] = useState({ field: "monthly_sold", direction: "DESC" }); + function buildStalkerProductParams(includePaging: boolean): URLSearchParams { + const params = new URLSearchParams({ + sort: buildSortValue(sort), + }); + if (includePaging) { + params.set("page", String(page)); + params.set("pageSize", String(pageSize)); + } + if (search) params.set("q", search); + if (sellerId) params.set("sellerId", sellerId); + if (runId) params.set("runId", runId); + if (verdict) params.set("verdict", verdict); + if (amazonIsSeller) params.set("amazonIsSeller", amazonIsSeller); + if (minPrice) params.set("minPrice", minPrice); + if (maxPrice) params.set("maxPrice", maxPrice); + if (minMonthlySold) params.set("minMonthlySold", minMonthlySold); + if (maxMonthlySold) params.set("maxMonthlySold", maxMonthlySold); + if (minSalesRank) params.set("minSalesRank", minSalesRank); + if (maxSalesRank) params.set("maxSalesRank", maxSalesRank); + if (minSellerCount) params.set("minSellerCount", minSellerCount); + if (maxSellerCount) params.set("maxSellerCount", maxSellerCount); + if (minRatingCount) params.set("minRatingCount", minRatingCount); + if (maxRatingCount) params.set("maxRatingCount", maxRatingCount); + if (minConfidence) params.set("minConfidence", minConfidence); + if (maxConfidence) params.set("maxConfidence", maxConfidence); + return params; + } + useEffect(() => { let cancelled = false; async function load() { setLoading(true); - const params = new URLSearchParams({ - page: String(page), - pageSize: String(pageSize), - sort: buildSortValue(sort), - }); - if (search) params.set("q", search); - if (sellerId) params.set("sellerId", sellerId); - if (runId) params.set("runId", runId); - if (verdict) params.set("verdict", verdict); - if (amazonIsSeller) params.set("amazonIsSeller", amazonIsSeller); - if (minPrice) params.set("minPrice", minPrice); - if (maxPrice) params.set("maxPrice", maxPrice); - if (minMonthlySold) params.set("minMonthlySold", minMonthlySold); - if (maxMonthlySold) params.set("maxMonthlySold", maxMonthlySold); - if (minSalesRank) params.set("minSalesRank", minSalesRank); - if (maxSalesRank) params.set("maxSalesRank", maxSalesRank); - if (minSellerCount) params.set("minSellerCount", minSellerCount); - if (maxSellerCount) params.set("maxSellerCount", maxSellerCount); - if (minRatingCount) params.set("minRatingCount", minRatingCount); - if (maxRatingCount) params.set("maxRatingCount", maxRatingCount); - if (minConfidence) params.set("minConfidence", minConfidence); - if (maxConfidence) params.set("maxConfidence", maxConfidence); + const params = buildStalkerProductParams(true); const res = await fetch(`/api/stalker/products?${params.toString()}`); const payload = (await res.json()) as StalkerProductsResponse; @@ -1236,6 +1243,8 @@ function StalkerProductsExplorer({ setPage(1); } + const exportHref = `/api/stalker/products/export.xlsx?${buildStalkerProductParams(false).toString()}`; + return (
@@ -1298,6 +1307,7 @@ function StalkerProductsExplorer({ + Export XLSX
diff --git a/src/web/styles.css b/src/web/styles.css index 8303a4a..fc36c0b 100644 --- a/src/web/styles.css +++ b/src/web/styles.css @@ -57,7 +57,8 @@ p { .toolbar input, .toolbar select, -button { +button, +.button-link { height: 36px; border-radius: 8px; border: 1px solid #d8dce0; @@ -66,6 +67,14 @@ button { font-size: 14px; } +.button-link { + display: inline-flex; + align-items: center; + justify-content: center; + color: inherit; + text-decoration: none; +} + button { cursor: pointer; }