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 ` +
| ${img || ""} | +
+ ${name}
+ ${store}${cat ? " · " + cat : ""}
+ ${price}
+ ${extraHtml || ""}
+ ${url ? `` : ""}
+ |
+