diff --git a/.github/workflows/pages.yaml b/.github/workflows/pages.yaml index 6939723..de10642 100644 --- a/.github/workflows/pages.yaml +++ b/.github/workflows/pages.yaml @@ -57,3 +57,25 @@ jobs: - name: Deploy to GitHub Pages id: deployment uses: actions/deploy-pages@v4 + + - name: Build email alert (HTML) + id: alert + shell: bash + run: | + set -euo pipefail + node tools/build_email_alert.js + echo "should_send=$(cat reports/alert_should_send.txt | tr -d '\r\n')" >> "$GITHUB_OUTPUT" + + - name: Send email alert + if: steps.alert.outputs.should_send == '1' + uses: dawidd6/action-send-mail@v7 + with: + server_address: smtp.gmail.com + server_port: 465 + secure: true + username: ${{ secrets.MAIL_USERNAME }} + password: ${{ secrets.MAIL_PASSWORD }} + subject: file://reports/alert_subject.txt + to: brennan@codexwilkes.com + from: Spirit Tracker <${{ secrets.MAIL_USERNAME }}> + html_body: file://reports/alert.html diff --git a/tools/build_email_alert.js b/tools/build_email_alert.js new file mode 100755 index 0000000..f16208b --- /dev/null +++ b/tools/build_email_alert.js @@ -0,0 +1,491 @@ +#!/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, "&") + .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 { + // 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 didn’t 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 ` +
${htmlEscape(titleText)}
+ ${rowsHtml || `
None
`} + `; + } + + function card(it, extraHtml) { + const img = it.img ? `` : ""; + 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 ` + + + + + +
${img || ""} +
${name}
+
${store}${cat ? " · " + cat : ""}
+
${price}
+ ${extraHtml || ""} + ${url ? `
View item
` : ""} +
+ `; + } + + 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 = ` +
+ ${oldP} + + ${newP} + ${pct !== null ? `(${pct}% off)` : ""} +
+ `; + return card({ ...it, price: it.newPrice }, extra); + }) + .join(""); + + const links = ` +
+ ${commitUrl ? `Commit: ${htmlEscape(commitUrl)}
` : ""} + ${pagesUrl ? `Visualizer: ${htmlEscape(pagesUrl)}` : ""} +
Generated at ${htmlEscape(now)}
+
+ `; + + return ` + + + + + ${htmlEscape(title)} + + +
+
+
${htmlEscape(title)}
+
${htmlEscape(subtitle || "")}
+ ${section("Unique new listings", uniqueHtml)} + ${section("Big sales (>= 20% and cheapest)", salesHtml)} + ${links} +
+
+ +`; +} + +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();