#!/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 ? `` : ""}
|