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 index from "./web/index.html";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
|
import * as XLSX from "xlsx";
|
||||||
import { getDb, initDb } from "./database.ts";
|
import { getDb, initDb } from "./database.ts";
|
||||||
import {
|
import {
|
||||||
fetchKeepaDataBatch,
|
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 {
|
function parseIntParam(value: string | null, fallback: number): number {
|
||||||
if (!value) return fallback;
|
if (!value) return fallback;
|
||||||
const parsed = Number.parseInt(value, 10);
|
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() {
|
function purgeStalkerData() {
|
||||||
const counts = {
|
const counts = {
|
||||||
inventory: (db.query("SELECT COUNT(*) AS count FROM stalker_seller_inventory").get() as { count: number }).count,
|
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);
|
const url = new URL(req.url);
|
||||||
return json(getStalkerProducts(url.searchParams));
|
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) => {
|
"/api/stalker/purge": (req) => {
|
||||||
if (req.method !== "DELETE" && req.method !== "POST") {
|
if (req.method !== "DELETE" && req.method !== "POST") {
|
||||||
return json({ error: "Method not allowed" }, 405);
|
return json({ error: "Method not allowed" }, 405);
|
||||||
|
|||||||
@@ -1153,32 +1153,39 @@ function StalkerProductsExplorer({
|
|||||||
const [pageSize, setPageSize] = useState(50);
|
const [pageSize, setPageSize] = useState(50);
|
||||||
const [sort, setSort] = useState<SortState>({ field: "monthly_sold", direction: "DESC" });
|
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(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
async function load() {
|
async function load() {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
const params = new URLSearchParams({
|
const params = buildStalkerProductParams(true);
|
||||||
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 res = await fetch(`/api/stalker/products?${params.toString()}`);
|
const res = await fetch(`/api/stalker/products?${params.toString()}`);
|
||||||
const payload = (await res.json()) as StalkerProductsResponse;
|
const payload = (await res.json()) as StalkerProductsResponse;
|
||||||
@@ -1236,6 +1243,8 @@ function StalkerProductsExplorer({
|
|||||||
setPage(1);
|
setPage(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const exportHref = `/api/stalker/products/export.xlsx?${buildStalkerProductParams(false).toString()}`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="page">
|
<div className="page">
|
||||||
<button className="back" onClick={onBack}>Back</button>
|
<button className="back" onClick={onBack}>Back</button>
|
||||||
@@ -1298,6 +1307,7 @@ function StalkerProductsExplorer({
|
|||||||
<option value="100">100 / page</option>
|
<option value="100">100 / page</option>
|
||||||
</select>
|
</select>
|
||||||
<button onClick={resetFilters}>Reset filters</button>
|
<button onClick={resetFilters}>Reset filters</button>
|
||||||
|
<a className="button-link" href={exportHref}>Export XLSX</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -57,7 +57,8 @@ p {
|
|||||||
|
|
||||||
.toolbar input,
|
.toolbar input,
|
||||||
.toolbar select,
|
.toolbar select,
|
||||||
button {
|
button,
|
||||||
|
.button-link {
|
||||||
height: 36px;
|
height: 36px;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
border: 1px solid #d8dce0;
|
border: 1px solid #d8dce0;
|
||||||
@@ -66,6 +67,14 @@ button {
|
|||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.button-link {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: inherit;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
button {
|
button {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user