feat: add XLSX export functionality for Stalker products and enhance UI for export link
This commit is contained in:
132
src/server.ts
132
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);
|
||||
|
||||
@@ -1153,32 +1153,39 @@ function StalkerProductsExplorer({
|
||||
const [pageSize, setPageSize] = useState(50);
|
||||
const [sort, setSort] = useState<SortState>({ 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 (
|
||||
<div className="page">
|
||||
<button className="back" onClick={onBack}>Back</button>
|
||||
@@ -1298,6 +1307,7 @@ function StalkerProductsExplorer({
|
||||
<option value="100">100 / page</option>
|
||||
</select>
|
||||
<button onClick={resetFilters}>Reset filters</button>
|
||||
<a className="button-link" href={exportHref}>Export XLSX</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user