3218320b1e
Daily digest now runs as the n8n "Umami daily report" workflow (Schedule -> Code -> Send Email) instead of the host cron. Code node logic in scripts/n8n-report-core.js; reads stats via a dedicated umami 'reporter' admin user (login API). Gmail SMTP on 587/STARTTLS (465 blocked). Host cron retired (disabled, script kept as fallback). Inventory updated. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
81 lines
6.9 KiB
JavaScript
81 lines
6.9 KiB
JavaScript
// Core report builder shared by the standalone validator and the n8n Code node.
|
|
// `http(opts)` is an async fn: {method,url,headers,body} -> parsed JSON.
|
|
async function buildReport(http, REPORTER_PW) {
|
|
const base = "https://analytics.kennethreitz.org";
|
|
const sites = [
|
|
["41b1fd15-82b1-4211-aeff-b3caa2d50ea3", "kennethreitz.org"],
|
|
["8df7f6f8-ad61-451a-bbf3-c29f1c6c5f25", "photos"],
|
|
["b8877828-28c8-4fa1-8179-7e2cd5aca1bc", "kjvstudy"],
|
|
["c4cebd03-b17e-4b68-b460-73c3c753fb5d", "poemsbysarah"],
|
|
["c7fc6c8d-0df8-405d-a113-a61f25b24550", "interpretations"],
|
|
["7b9905ff-4ea6-464f-b65f-763103d9783c", "strainsdb"],
|
|
["2bb8ad58-25d3-41bf-aa4c-f7d236410e17", "rhymepad"],
|
|
["a691eb37-8047-4529-9ea8-64ef1957f564", "pytheory-playground"],
|
|
["abaa0013-d524-4583-b70e-6d0ff5b7360c", "pytheory-docs"],
|
|
];
|
|
const dayMs = 86400000, tz = "America/New_York";
|
|
const etParts = (d) => {
|
|
const f = new Intl.DateTimeFormat("en-CA", { timeZone: tz, year: "numeric", month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", second: "2-digit", hour12: false });
|
|
const o = {}; for (const p of f.formatToParts(d)) o[p.type] = p.value; return o;
|
|
};
|
|
const etMidnightToday = () => {
|
|
const o = etParts(new Date());
|
|
const guess = Date.UTC(+o.year, +o.month - 1, +o.day, 0, 0, 0);
|
|
const g = etParts(new Date(guess));
|
|
const gUTC = Date.UTC(+g.year, +g.month - 1, +g.day, +g.hour, +g.minute, +g.second);
|
|
return guess - (gUTC - guess);
|
|
};
|
|
const y1 = etMidnightToday(), y0 = y1 - dayMs, p0 = y1 - 2 * dayMs;
|
|
|
|
const login = await http({ method: "POST", url: base + "/api/auth/login", body: { username: "reporter", password: REPORTER_PW } });
|
|
const auth = { Authorization: "Bearer " + login.token };
|
|
const stat = (id, a, b) => http({ method: "GET", url: `${base}/api/websites/${id}/stats?startAt=${a}&endAt=${b}`, headers: auth });
|
|
const topPages = (id, a, b) => http({ method: "GET", url: `${base}/api/websites/${id}/metrics?type=path&startAt=${a}&endAt=${b}&limit=5`, headers: auth });
|
|
|
|
const rows = [], pages = [];
|
|
let tpv = 0, tvis = 0, tpvP = 0, tvisP = 0;
|
|
for (const [id, name] of sites) {
|
|
const cur = await stat(id, y0, y1), prev = await stat(id, p0, y0);
|
|
const pv = cur.pageviews?.value ?? cur.pageviews ?? 0;
|
|
const vis = cur.visitors?.value ?? cur.visitors ?? 0;
|
|
const visits = cur.visits?.value ?? cur.visits ?? 0;
|
|
const pvP = prev.pageviews?.value ?? prev.pageviews ?? 0;
|
|
const visP = prev.visitors?.value ?? prev.visitors ?? 0;
|
|
tpv += pv; tvis += vis; tpvP += pvP; tvisP += visP;
|
|
if (pv > 0 || pvP > 0) rows.push({ name, pv, vis, visits, pvP, visP });
|
|
if (pv > 0) {
|
|
try { for (const u of await topPages(id, y0, y1)) pages.push({ name, path: u.x, c: u.y }); } catch (e) {}
|
|
}
|
|
}
|
|
rows.sort((a, b) => b.pv - a.pv);
|
|
pages.sort((a, b) => b.c - a.c);
|
|
|
|
const delta = (c, p) => {
|
|
if (!p) return c ? ["new", "#2563eb"] : ["-", "#9ca3af"];
|
|
const pct = Math.round((c - p) / p * 100);
|
|
if (pct > 0) return ["▲ " + pct + "%", "#16a34a"];
|
|
if (pct < 0) return ["▼ " + Math.abs(pct) + "%", "#dc2626"];
|
|
return ["0%", "#9ca3af"];
|
|
};
|
|
const nf = (n) => n.toLocaleString("en-US");
|
|
const dayLabel = new Date(y0).toLocaleDateString("en-US", { timeZone: "UTC", weekday: "long", year: "numeric", month: "long", day: "numeric" });
|
|
const [pvd, pvc] = delta(tpv, tpvP), [visd, visc] = delta(tvis, tvisP);
|
|
|
|
const rowHtml = rows.map((r) => {
|
|
const [d1, c1] = delta(r.pv, r.pvP), [d2, c2] = delta(r.vis, r.visP);
|
|
return `<tr><td style="padding:10px 12px;border-bottom:1px solid #eee;font-weight:600;color:#111">${r.name}</td>` +
|
|
`<td style="padding:10px 12px;border-bottom:1px solid #eee;text-align:right;color:#111">${nf(r.pv)} <span style="color:${c1};font-size:12px">${d1}</span></td>` +
|
|
`<td style="padding:10px 12px;border-bottom:1px solid #eee;text-align:right;color:#111">${nf(r.vis)} <span style="color:${c2};font-size:12px">${d2}</span></td>` +
|
|
`<td style="padding:10px 12px;border-bottom:1px solid #eee;text-align:right;color:#555">${nf(r.visits)}</td></tr>`;
|
|
}).join("");
|
|
const topHtml = pages.slice(0, 10).map((p) =>
|
|
`<tr><td style="padding:6px 12px;border-bottom:1px solid #f1f1f1;color:#555">${p.name}</td>` +
|
|
`<td style="padding:6px 12px;border-bottom:1px solid #f1f1f1;color:#111">${p.path}</td>` +
|
|
`<td style="padding:6px 12px;border-bottom:1px solid #f1f1f1;text-align:right;color:#111">${nf(p.c)}</td></tr>`).join("");
|
|
|
|
const html = `<!doctype html><html><body style="margin:0;background:#f6f7f9;font-family:-apple-system,Segoe UI,Roboto,Helvetica,Arial,sans-serif"><div style="max-width:640px;margin:0 auto;padding:24px"><div style="background:linear-gradient(135deg,#6366f1,#8b5cf6);border-radius:14px;padding:22px 24px;color:#fff"><div style="font-size:13px;opacity:.85;letter-spacing:.4px;text-transform:uppercase">Umami Daily Report</div><div style="font-size:20px;font-weight:700;margin-top:2px">${dayLabel}</div></div><div style="display:flex;gap:12px;margin:16px 0"><div style="flex:1;background:#fff;border-radius:12px;padding:16px 18px;box-shadow:0 1px 3px rgba(0,0,0,.06)"><div style="font-size:12px;color:#888;text-transform:uppercase;letter-spacing:.4px">Pageviews</div><div style="font-size:26px;font-weight:700;color:#111">${nf(tpv)} <span style="font-size:13px;color:${pvc}">${pvd}</span></div></div><div style="flex:1;background:#fff;border-radius:12px;padding:16px 18px;box-shadow:0 1px 3px rgba(0,0,0,.06)"><div style="font-size:12px;color:#888;text-transform:uppercase;letter-spacing:.4px">Visitors</div><div style="font-size:26px;font-weight:700;color:#111">${nf(tvis)} <span style="font-size:13px;color:${visc}">${visd}</span></div></div></div><div style="background:#fff;border-radius:12px;padding:6px 4px;box-shadow:0 1px 3px rgba(0,0,0,.06)"><table style="width:100%;border-collapse:collapse;font-size:14px"><thead><tr style="color:#888;font-size:11px;text-transform:uppercase;letter-spacing:.4px"><th style="padding:10px 12px;text-align:left">Site</th><th style="padding:10px 12px;text-align:right">Pageviews</th><th style="padding:10px 12px;text-align:right">Visitors</th><th style="padding:10px 12px;text-align:right">Visits</th></tr></thead><tbody>${rowHtml}</tbody></table></div><div style="font-size:12px;color:#888;text-transform:uppercase;letter-spacing:.4px;margin:20px 0 8px;padding-left:4px">Top pages yesterday</div><div style="background:#fff;border-radius:12px;padding:6px 4px;box-shadow:0 1px 3px rgba(0,0,0,.06)"><table style="width:100%;border-collapse:collapse;font-size:13px"><tbody>${topHtml}</tbody></table></div><div style="text-align:center;color:#aab;font-size:12px;margin-top:20px"><a href="https://analytics.kennethreitz.org" style="color:#8b5cf6;text-decoration:none">Open Umami →</a></div></div></body></html>`;
|
|
const subj = "Umami daily report — " + new Date(y0).toLocaleDateString("en-US", { timeZone: "UTC", month: "short", day: "numeric" });
|
|
return { subject: subj, html };
|
|
}
|
|
if (typeof module !== "undefined") module.exports = { buildReport };
|