spirit-tracker/tools/build_email_alert.js
Brennan Wilkes (Text Groove) 22f96065a9 feat: Email alerts v1
2026-01-21 23:53:33 -08:00

491 lines
15 KiB
JavaScript
Executable file
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/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.
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 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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
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 {
// exists on data branch because you merge main -> data before committing runs
// 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 {
// fallback: use 6-digit SKU if present; else url hash-ish (still stable enough for 1 run)
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;
}
// restored not used for now (you didnt request it)
}
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()) {
// availability
if (!availability.has(it.canonSku)) availability.set(it.canonSku, new Set());
availability.get(it.canonSku).add(storeLabel);
// per-store lookup
byStoreCanon.get(storeLabel).set(it.canonSku, it);
// cheapest
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 || it.newPrice || "");
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>
<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: it.newPrice }, 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;
}
// Current-state indexes (across ALL stores) from disk
const { availability, cheapest, byStoreCanon } = buildCurrentIndexes(skuMap);
const uniqueNews = [];
const bigSales = [];
for (const file of changed) {
const prevObj = gitShowJson(parentSha, file);
const nextObj = gitShowJson(headSha, file);
if (!prevObj && !nextObj) continue;
const { newItems, priceDown } = diffDb(prevObj, nextObj, skuMap);
// New unique listings (canon sku available at exactly 1 store)
for (const it of newItems) {
const stores = availability.get(it.canonSku);
const storeCount = stores ? stores.size : 0;
if (storeCount !== 1) continue;
// ensure the only store is this one
if (!stores.has(it.storeLabel)) continue;
// refresh with current item to get img if present now
const cur = (byStoreCanon.get(it.storeLabel) || new Map()).get(it.canonSku) || it;
uniqueNews.push(cur);
}
// Sales: >=20% and cheapest store currently (ties allowed)
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;
// must be at cheapest price, and this store among cheapest stores
if (best.priceNum !== newN) continue;
if (!best.stores.has(it.storeLabel)) continue;
// refresh with current item for img/name/category if needed
const cur = (byStoreCanon.get(it.storeLabel) || new Map()).get(it.canonSku) || it;
bigSales.push({
...cur,
oldPrice: it.oldPrice,
newPrice: it.newPrice,
pct,
});
}
}
// de-dupe by (canonSku, storeLabel)
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();