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