mirror of
https://github.com/samsonjs/spirit-tracker.git
synced 2026-03-25 09:25:51 +00:00
501 lines
15 KiB
JavaScript
Executable file
501 lines
15 KiB
JavaScript
Executable file
#!/usr/bin/env node
|
|
"use strict";
|
|
|
|
/*
|
|
Build an HTML email alert for the latest data-branch commit.
|
|
|
|
Criteria (per your spec):
|
|
- NEW listings: include only if the canonical SKU is available at exactly 1 store (this one).
|
|
- SALES: include only if
|
|
A) >= 20% off (old->new)
|
|
B) this store is currently the cheapest for that canonical SKU (ties allowed)
|
|
- If nothing matches, do not send email.
|
|
|
|
NEW CHANGE (2026-01):
|
|
- If a store/category DB file is completely new in this commit (file did not exist in previous commit),
|
|
then ALL of its "new" rows are ignored for the email alert (but still appear in report text elsewhere).
|
|
|
|
Outputs:
|
|
reports/alert.html
|
|
reports/alert_subject.txt
|
|
reports/alert_should_send.txt ("1" or "0")
|
|
If GITHUB_OUTPUT is set, also writes:
|
|
should_send=0/1
|
|
subject=...
|
|
html_path=...
|
|
*/
|
|
|
|
const { execFileSync } = require("child_process");
|
|
const fs = require("fs");
|
|
const path = require("path");
|
|
|
|
function runGit(args) {
|
|
return execFileSync("git", args, { encoding: "utf8" }).trimEnd();
|
|
}
|
|
|
|
function gitShowJson(sha, filePath) {
|
|
try {
|
|
const txt = execFileSync("git", ["show", `${sha}:${filePath}`], {
|
|
encoding: "utf8",
|
|
stdio: ["ignore", "pipe", "pipe"],
|
|
});
|
|
return JSON.parse(txt);
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function gitFileExistsAtSha(sha, filePath) {
|
|
if (!sha) return false;
|
|
try {
|
|
execFileSync("git", ["cat-file", "-e", `${sha}:${filePath}`], {
|
|
stdio: ["ignore", "ignore", "ignore"],
|
|
});
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
function readJson(filePath) {
|
|
try {
|
|
return JSON.parse(fs.readFileSync(filePath, "utf8"));
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function ensureDir(dir) {
|
|
fs.mkdirSync(dir, { recursive: true });
|
|
}
|
|
|
|
function priceToNumber(v) {
|
|
const s = String(v ?? "").replace(/[^0-9.]/g, "");
|
|
const n = Number(s);
|
|
return Number.isFinite(n) ? n : null;
|
|
}
|
|
|
|
function pctOff(oldStr, newStr) {
|
|
const a = priceToNumber(oldStr);
|
|
const b = priceToNumber(newStr);
|
|
if (a === null || b === null) return null;
|
|
if (a <= 0) return null;
|
|
if (b >= a) return 0;
|
|
return Math.round(((a - b) / a) * 100);
|
|
}
|
|
|
|
function htmlEscape(s) {
|
|
return String(s ?? "")
|
|
.replace(/&/g, "&")
|
|
.replace(/</g, "<")
|
|
.replace(/>/g, ">")
|
|
.replace(/"/g, """);
|
|
}
|
|
|
|
function normToken(s) {
|
|
return String(s || "")
|
|
.toLowerCase()
|
|
.trim()
|
|
.replace(/\s+/g, " ")
|
|
.replace(/[^\w:./-]+/g, "");
|
|
}
|
|
|
|
function getFirstParentSha(headSha) {
|
|
try {
|
|
const out = runGit(["rev-list", "--parents", "-n", "1", headSha]);
|
|
const parts = out.split(/\s+/).filter(Boolean);
|
|
return parts.length >= 2 ? parts[1] : "";
|
|
} catch {
|
|
return "";
|
|
}
|
|
}
|
|
|
|
function listChangedDbFiles(fromSha, toSha) {
|
|
try {
|
|
const out = runGit(["diff", "--name-only", fromSha, toSha, "--", "data/db"]);
|
|
return out
|
|
.split(/\r?\n/)
|
|
.map((s) => s.trim())
|
|
.filter((s) => s && s.endsWith(".json"));
|
|
} catch {
|
|
return [];
|
|
}
|
|
}
|
|
|
|
function listDbFilesOnDisk() {
|
|
const dir = path.join(process.cwd(), "data", "db");
|
|
try {
|
|
return fs
|
|
.readdirSync(dir, { withFileTypes: true })
|
|
.filter((e) => e.isFile() && e.name.endsWith(".json"))
|
|
.map((e) => path.posix.join("data/db", e.name));
|
|
} catch {
|
|
return [];
|
|
}
|
|
}
|
|
|
|
// We reuse your existing canonical SKU mapping logic.
|
|
function loadSkuMapOrNull() {
|
|
try {
|
|
// eslint-disable-next-line node/no-missing-require
|
|
const { loadSkuMap } = require(path.join(process.cwd(), "src", "utils", "sku_map"));
|
|
return loadSkuMap({ dbDir: path.join(process.cwd(), "data", "db") });
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function normalizeSkuKeyOrEmpty({ skuRaw, storeLabel, url }) {
|
|
try {
|
|
// eslint-disable-next-line node/no-missing-require
|
|
const { normalizeSkuKey } = require(path.join(process.cwd(), "src", "utils", "sku"));
|
|
const k = normalizeSkuKey(skuRaw, { storeLabel, url });
|
|
return k ? String(k) : "";
|
|
} catch {
|
|
const m = String(skuRaw ?? "").match(/\b(\d{6})\b/);
|
|
if (m) return m[1];
|
|
if (url) return `u:${normToken(storeLabel)}:${normToken(url)}`;
|
|
return "";
|
|
}
|
|
}
|
|
|
|
function canonicalize(skuKey, skuMap) {
|
|
if (!skuKey) return "";
|
|
if (skuMap && typeof skuMap.canonicalSku === "function") return String(skuMap.canonicalSku(skuKey) || skuKey);
|
|
return skuKey;
|
|
}
|
|
|
|
function mapDbItems(obj, skuMap, { includeRemoved }) {
|
|
const storeLabel = String(obj?.storeLabel || obj?.store || "");
|
|
const categoryLabel = String(obj?.categoryLabel || obj?.category || "");
|
|
const items = Array.isArray(obj?.items) ? obj.items : [];
|
|
|
|
const m = new Map(); // canonSku -> item (for this store+category db)
|
|
for (const it of items) {
|
|
if (!it) continue;
|
|
const removed = Boolean(it.removed);
|
|
if (!includeRemoved && removed) continue;
|
|
|
|
const skuKey = normalizeSkuKeyOrEmpty({ skuRaw: it.sku, storeLabel, url: it.url });
|
|
const canon = canonicalize(skuKey, skuMap);
|
|
if (!canon) continue;
|
|
|
|
m.set(canon, {
|
|
canonSku: canon,
|
|
skuRaw: String(it.sku || ""),
|
|
name: String(it.name || ""),
|
|
price: String(it.price || ""),
|
|
url: String(it.url || ""),
|
|
img: String(it.img || it.image || it.thumb || ""),
|
|
removed,
|
|
storeLabel,
|
|
categoryLabel,
|
|
});
|
|
}
|
|
return m;
|
|
}
|
|
|
|
function diffDb(prevObj, nextObj, skuMap) {
|
|
const prevAll = mapDbItems(prevObj, skuMap, { includeRemoved: true });
|
|
const nextAll = mapDbItems(nextObj, skuMap, { includeRemoved: true });
|
|
const prevLive = mapDbItems(prevObj, skuMap, { includeRemoved: false });
|
|
const nextLive = mapDbItems(nextObj, skuMap, { includeRemoved: false });
|
|
|
|
const newItems = [];
|
|
const priceDown = [];
|
|
|
|
for (const [canon, now] of nextLive.entries()) {
|
|
const had = prevAll.get(canon);
|
|
if (!had) {
|
|
newItems.push(now);
|
|
continue;
|
|
}
|
|
}
|
|
|
|
for (const [canon, now] of nextLive.entries()) {
|
|
const was = prevLive.get(canon);
|
|
if (!was) continue;
|
|
|
|
const a = String(was.price || "");
|
|
const b = String(now.price || "");
|
|
if (a === b) continue;
|
|
|
|
const aN = priceToNumber(a);
|
|
const bN = priceToNumber(b);
|
|
if (aN === null || bN === null) continue;
|
|
if (bN >= aN) continue;
|
|
|
|
priceDown.push({
|
|
...now,
|
|
oldPrice: a,
|
|
newPrice: b,
|
|
pct: pctOff(a, b),
|
|
});
|
|
}
|
|
|
|
return { newItems, priceDown };
|
|
}
|
|
|
|
function buildCurrentIndexes(skuMap) {
|
|
const files = listDbFilesOnDisk();
|
|
const availability = new Map(); // canonSku -> Set(storeLabel)
|
|
const cheapest = new Map(); // canonSku -> { priceNum, stores:Set, example:{name,url,img,categoryLabel} }
|
|
const byStoreCanon = new Map(); // storeLabel -> Map(canonSku -> item)
|
|
|
|
for (const file of files) {
|
|
const obj = readJson(file);
|
|
if (!obj) continue;
|
|
const storeLabel = String(obj.storeLabel || obj.store || "");
|
|
if (!storeLabel) continue;
|
|
|
|
const live = mapDbItems(obj, skuMap, { includeRemoved: false });
|
|
if (!byStoreCanon.has(storeLabel)) byStoreCanon.set(storeLabel, new Map());
|
|
|
|
for (const it of live.values()) {
|
|
if (!availability.has(it.canonSku)) availability.set(it.canonSku, new Set());
|
|
availability.get(it.canonSku).add(storeLabel);
|
|
|
|
byStoreCanon.get(storeLabel).set(it.canonSku, it);
|
|
|
|
const p = priceToNumber(it.price);
|
|
if (p === null) continue;
|
|
|
|
const cur = cheapest.get(it.canonSku);
|
|
if (!cur) {
|
|
cheapest.set(it.canonSku, {
|
|
priceNum: p,
|
|
stores: new Set([storeLabel]),
|
|
example: { name: it.name, url: it.url, img: it.img, categoryLabel: it.categoryLabel },
|
|
});
|
|
} else if (p < cur.priceNum) {
|
|
cheapest.set(it.canonSku, {
|
|
priceNum: p,
|
|
stores: new Set([storeLabel]),
|
|
example: { name: it.name, url: it.url, img: it.img, categoryLabel: it.categoryLabel },
|
|
});
|
|
} else if (p === cur.priceNum) {
|
|
cur.stores.add(storeLabel);
|
|
}
|
|
}
|
|
}
|
|
|
|
return { availability, cheapest, byStoreCanon };
|
|
}
|
|
|
|
function renderHtml({ title, subtitle, uniqueNews, bigSales, commitUrl, pagesUrl }) {
|
|
const now = new Date().toISOString();
|
|
|
|
function section(titleText, rowsHtml) {
|
|
return `
|
|
<div style="margin:16px 0 6px 0;font-weight:700;font-size:16px">${htmlEscape(titleText)}</div>
|
|
${rowsHtml || `<div style="color:#666">None</div>`}
|
|
`;
|
|
}
|
|
|
|
function card(it, extraHtml) {
|
|
const img = it.img
|
|
? `<img src="${htmlEscape(it.img)}" width="84" height="84" style="object-fit:contain;border-radius:8px;border:1px solid #eee;background:#fff" />`
|
|
: "";
|
|
const name = htmlEscape(it.name || "");
|
|
const store = htmlEscape(it.storeLabel || "");
|
|
const cat = htmlEscape(it.categoryLabel || "");
|
|
const price = htmlEscape(it.price || "");
|
|
const url = htmlEscape(it.url || "");
|
|
return `
|
|
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="border:1px solid #eee;border-radius:12px;margin:10px 0">
|
|
<tr>
|
|
<td style="padding:12px;vertical-align:top;width:96px">${img || ""}</td>
|
|
<td style="padding:12px;vertical-align:top">
|
|
<div style="font-weight:700;font-size:14px;line-height:1.3">${name}</div>
|
|
<div style="color:#666;font-size:12px;margin-top:2px">${store}${cat ? " · " + cat : ""}</div>
|
|
${price ? `<div style="margin-top:8px;font-size:13px"><span style="font-weight:700">${price}</span></div>` : ""}
|
|
${extraHtml || ""}
|
|
${url ? `<div style="margin-top:8px"><a href="${url}" style="color:#0b57d0;text-decoration:none">View item</a></div>` : ""}
|
|
</td>
|
|
</tr>
|
|
</table>
|
|
`;
|
|
}
|
|
|
|
const uniqueHtml = uniqueNews.map((it) => card(it)).join("");
|
|
const salesHtml = bigSales
|
|
.map((it) => {
|
|
const pct = Number.isFinite(it.pct) ? it.pct : null;
|
|
const oldP = htmlEscape(it.oldPrice || "");
|
|
const newP = htmlEscape(it.newPrice || "");
|
|
const extra = `
|
|
<div style="margin-top:6px;font-size:13px">
|
|
<span style="color:#b00020;text-decoration:line-through">${oldP}</span>
|
|
<span style="margin:0 6px;color:#666">→</span>
|
|
<span style="font-weight:700;color:#137333">${newP}</span>
|
|
${pct !== null ? `<span style="margin-left:8px;color:#137333;font-weight:700">(${pct}% off)</span>` : ""}
|
|
</div>
|
|
`;
|
|
return card({ ...it, price: "" }, extra);
|
|
})
|
|
.join("");
|
|
|
|
const links = `
|
|
<div style="margin-top:10px;font-size:12px;color:#666">
|
|
${commitUrl ? `Commit: <a href="${htmlEscape(commitUrl)}" style="color:#0b57d0;text-decoration:none">${htmlEscape(commitUrl)}</a><br/>` : ""}
|
|
${pagesUrl ? `Visualizer: <a href="${htmlEscape(pagesUrl)}" style="color:#0b57d0;text-decoration:none">${htmlEscape(pagesUrl)}</a>` : ""}
|
|
<div style="margin-top:6px;color:#999">Generated at ${htmlEscape(now)}</div>
|
|
</div>
|
|
`;
|
|
|
|
return `<!doctype html>
|
|
<html>
|
|
<head>
|
|
<meta charset="utf-8" />
|
|
<meta name="viewport" content="width=device-width" />
|
|
<title>${htmlEscape(title)}</title>
|
|
</head>
|
|
<body style="margin:0;padding:0;background:#fafafa;font-family:-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Helvetica,Arial,sans-serif;">
|
|
<div style="max-width:720px;margin:0 auto;padding:18px;">
|
|
<div style="background:#fff;border:1px solid #eee;border-radius:14px;padding:16px;">
|
|
<div style="font-weight:800;font-size:18px">${htmlEscape(title)}</div>
|
|
<div style="color:#666;margin-top:4px">${htmlEscape(subtitle || "")}</div>
|
|
${section("Unique new listings", uniqueHtml)}
|
|
${section("Big sales (>= 20% and cheapest)", salesHtml)}
|
|
${links}
|
|
</div>
|
|
</div>
|
|
</body>
|
|
</html>`;
|
|
}
|
|
|
|
function writeGithubOutput(kv) {
|
|
const outPath = process.env.GITHUB_OUTPUT;
|
|
if (!outPath) return;
|
|
const lines = [];
|
|
for (const [k, v] of Object.entries(kv)) lines.push(`${k}=${String(v)}`);
|
|
fs.appendFileSync(outPath, lines.join("\n") + "\n", "utf8");
|
|
}
|
|
|
|
function main() {
|
|
const repoRoot = process.cwd();
|
|
const reportsDir = path.join(repoRoot, "reports");
|
|
ensureDir(reportsDir);
|
|
|
|
const headSha = runGit(["rev-parse", "HEAD"]);
|
|
const parentSha = getFirstParentSha(headSha);
|
|
if (!parentSha) {
|
|
fs.writeFileSync(path.join(reportsDir, "alert_should_send.txt"), "0\n", "utf8");
|
|
writeGithubOutput({ should_send: 0 });
|
|
return;
|
|
}
|
|
|
|
const skuMap = loadSkuMapOrNull();
|
|
|
|
const changed = listChangedDbFiles(parentSha, headSha);
|
|
if (!changed.length) {
|
|
fs.writeFileSync(path.join(reportsDir, "alert_should_send.txt"), "0\n", "utf8");
|
|
writeGithubOutput({ should_send: 0 });
|
|
return;
|
|
}
|
|
|
|
const { availability, cheapest, byStoreCanon } = buildCurrentIndexes(skuMap);
|
|
|
|
const uniqueNews = [];
|
|
const bigSales = [];
|
|
|
|
for (const file of changed) {
|
|
const existedBefore = gitFileExistsAtSha(parentSha, file);
|
|
const existsNow = gitFileExistsAtSha(headSha, file);
|
|
|
|
// NEW FEATURE: if this DB file is brand new, ignore its "new items" for alert.
|
|
if (!existedBefore && existsNow) {
|
|
continue;
|
|
}
|
|
|
|
const prevObj = gitShowJson(parentSha, file);
|
|
const nextObj = gitShowJson(headSha, file);
|
|
if (!prevObj && !nextObj) continue;
|
|
|
|
const { newItems, priceDown } = diffDb(prevObj, nextObj, skuMap);
|
|
|
|
for (const it of newItems) {
|
|
const stores = availability.get(it.canonSku);
|
|
const storeCount = stores ? stores.size : 0;
|
|
if (storeCount !== 1) continue;
|
|
if (!stores.has(it.storeLabel)) continue;
|
|
|
|
const cur = (byStoreCanon.get(it.storeLabel) || new Map()).get(it.canonSku) || it;
|
|
uniqueNews.push(cur);
|
|
}
|
|
|
|
for (const it of priceDown) {
|
|
const pct = it.pct;
|
|
if (!Number.isFinite(pct) || pct < 20) continue;
|
|
|
|
const best = cheapest.get(it.canonSku);
|
|
if (!best) continue;
|
|
|
|
const newN = priceToNumber(it.newPrice);
|
|
if (newN === null) continue;
|
|
|
|
if (best.priceNum !== newN) continue;
|
|
if (!best.stores.has(it.storeLabel)) continue;
|
|
|
|
const cur = (byStoreCanon.get(it.storeLabel) || new Map()).get(it.canonSku) || it;
|
|
|
|
bigSales.push({
|
|
...cur,
|
|
oldPrice: it.oldPrice,
|
|
newPrice: it.newPrice,
|
|
pct,
|
|
});
|
|
}
|
|
}
|
|
|
|
function dedupe(arr) {
|
|
const out = [];
|
|
const seen = new Set();
|
|
for (const it of arr) {
|
|
const k = `${it.canonSku}|${it.storeLabel}`;
|
|
if (seen.has(k)) continue;
|
|
seen.add(k);
|
|
out.push(it);
|
|
}
|
|
return out;
|
|
}
|
|
|
|
const uniqueFinal = dedupe(uniqueNews).sort((a, b) => (a.name || "").localeCompare(b.name || ""));
|
|
const salesFinal = dedupe(bigSales).sort((a, b) => (b.pct || 0) - (a.pct || 0));
|
|
|
|
const shouldSend = uniqueFinal.length > 0 || salesFinal.length > 0;
|
|
|
|
const subject = shouldSend
|
|
? `Spirit Tracker: ${uniqueFinal.length} unique new · ${salesFinal.length} big sales`
|
|
: `Spirit Tracker: (no alert)`;
|
|
|
|
const ghRepo = process.env.GITHUB_REPOSITORY || "";
|
|
const ghUrl = process.env.GITHUB_SERVER_URL || "https://github.com";
|
|
const commitUrl = ghRepo ? `${ghUrl}/${ghRepo}/commit/${headSha}` : "";
|
|
const pagesUrl = process.env.PAGES_URL || "";
|
|
|
|
const html = renderHtml({
|
|
title: "Spirit Tracker Alert",
|
|
subtitle: subject,
|
|
uniqueNews: uniqueFinal,
|
|
bigSales: salesFinal,
|
|
commitUrl,
|
|
pagesUrl,
|
|
});
|
|
|
|
const htmlPath = path.join(reportsDir, "alert.html");
|
|
const subjPath = path.join(reportsDir, "alert_subject.txt");
|
|
const sendPath = path.join(reportsDir, "alert_should_send.txt");
|
|
|
|
fs.writeFileSync(htmlPath, html, "utf8");
|
|
fs.writeFileSync(subjPath, subject + "\n", "utf8");
|
|
fs.writeFileSync(sendPath, (shouldSend ? "1\n" : "0\n"), "utf8");
|
|
|
|
writeGithubOutput({
|
|
should_send: shouldSend ? 1 : 0,
|
|
subject,
|
|
html_path: htmlPath,
|
|
});
|
|
}
|
|
|
|
main();
|