@daemonitor/plugins 1.1.39 → 1.1.41
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
|
@@ -10,7 +10,9 @@ import { getJson, googleAccessToken, shiftDate, todayIn } from "./AdsensePlugin.
|
|
|
10
10
|
// property asks the Data API for: active users right now (realtime), users,
|
|
11
11
|
// sessions and page views for today, yesterday and the last 7 days, and 30
|
|
12
12
|
// days of daily figures. 7-day users come from GA itself, never from summing
|
|
13
|
-
// days: one person on three days is one user, not three.
|
|
13
|
+
// days: one person on three days is one user, not three. Each property also
|
|
14
|
+
// says which sites it covers (its web streams) and its most viewed pages, so a
|
|
15
|
+
// site in daemonitor can show only its own.
|
|
14
16
|
//
|
|
15
17
|
// The report carries its own 30-day history, so the server stores no history
|
|
16
18
|
// rows for this type. Hiding properties and nicknames live in the portal.
|
|
@@ -38,9 +40,10 @@ const totals = (row) => row ? { users: num(row.metricValues?.[0]), sessions: num
|
|
|
38
40
|
* batchRunReports response -> the figures the portal shows.
|
|
39
41
|
* Report 0: no dimensions, three named date ranges (today, yesterday, last7).
|
|
40
42
|
* Report 1: one row per date over the last 30 days.
|
|
43
|
+
* Reports 2 and 3: the most viewed pages over 30 days and since yesterday.
|
|
41
44
|
*/
|
|
42
45
|
export function parseProperty(batch, now = new Date()) {
|
|
43
|
-
const [summary, byDate] = batch?.reports || [];
|
|
46
|
+
const [summary, byDate, popular, trending] = batch?.reports || [];
|
|
44
47
|
// A multi-range report adds a "dateRange" dimension holding each range's name.
|
|
45
48
|
const range = (name) => totals((summary?.rows || []).find((r) => r?.dimensionValues?.[0]?.value === name));
|
|
46
49
|
// Gap-fill the 30 days in the property's own time zone: GA leaves days with
|
|
@@ -65,11 +68,87 @@ export function parseProperty(batch, now = new Date()) {
|
|
|
65
68
|
sessions: days.map((d) => d.sessions),
|
|
66
69
|
views: days.map((d) => d.views),
|
|
67
70
|
},
|
|
71
|
+
pages: { popular: parsePages(popular), trending: parsePages(trending) },
|
|
68
72
|
};
|
|
69
73
|
}
|
|
70
74
|
/** runRealtimeReport -> users active in the last 30 minutes. No rows means zero. */
|
|
71
75
|
export const parseRealtime = (r) => num(r?.rows?.[0]?.metricValues?.[0]);
|
|
72
|
-
|
|
76
|
+
// What a list of top pages leaves out: the home page, section fronts (a single
|
|
77
|
+
// path segment) and archives, which outrank every article and say nothing.
|
|
78
|
+
export const EXCLUDE_REGEX = ["^/([^/]+)?/?$", "^/(calendar|category|tag|author)/.*$"];
|
|
79
|
+
// Per host, per list: a property's reports stay small enough to send every poll.
|
|
80
|
+
const PAGES_PER_HOST = 10;
|
|
81
|
+
/** A runReport request for a date range's most viewed pages. `/a` and `/a/` are one page, so both are excluded. */
|
|
82
|
+
export function pagesRequest(startDate, exclude = []) {
|
|
83
|
+
const exact = exclude.flatMap((p) => [p.replace(/\/$/, ""), `${p.replace(/\/$/, "")}/`]);
|
|
84
|
+
return {
|
|
85
|
+
dateRanges: [{ startDate, endDate: "today" }],
|
|
86
|
+
dimensions: [{ name: "hostName" }, { name: "pagePath" }, { name: "pageTitle" }],
|
|
87
|
+
metrics: [{ name: "screenPageViews" }],
|
|
88
|
+
orderBys: [{ metric: { metricName: "screenPageViews" }, desc: true }],
|
|
89
|
+
limit: 50,
|
|
90
|
+
dimensionFilter: {
|
|
91
|
+
notExpression: {
|
|
92
|
+
orGroup: {
|
|
93
|
+
expressions: [
|
|
94
|
+
...(exact.length ? [{ filter: { fieldName: "pagePath", inListFilter: { values: exact } } }] : []),
|
|
95
|
+
...EXCLUDE_REGEX.map((value) => ({ filter: { fieldName: "pagePath", stringFilter: { matchType: "FULL_REGEXP", value } } })),
|
|
96
|
+
],
|
|
97
|
+
},
|
|
98
|
+
},
|
|
99
|
+
},
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
const bareHost = (h) => h.trim().toLowerCase().replace(/^www\./, "");
|
|
103
|
+
/**
|
|
104
|
+
* A pages report -> the most viewed pages per host. `/a` and `/a/` add up, and a
|
|
105
|
+
* site name most titles end in (" | The Architect's Newspaper") comes off.
|
|
106
|
+
*/
|
|
107
|
+
export function parsePages(report) {
|
|
108
|
+
const rows = (report?.rows || []).map((r) => ({
|
|
109
|
+
host: bareHost(String(r?.dimensionValues?.[0]?.value || "")),
|
|
110
|
+
path: String(r?.dimensionValues?.[1]?.value || "").replace(/(.)\/$/, "$1"),
|
|
111
|
+
title: String(r?.dimensionValues?.[2]?.value || ""),
|
|
112
|
+
views: num(r?.metricValues?.[0]),
|
|
113
|
+
}));
|
|
114
|
+
const tails = new Map();
|
|
115
|
+
for (const r of rows) {
|
|
116
|
+
const m = /\s[|–—-]\s[^|–—-]+$/.exec(r.title);
|
|
117
|
+
if (m)
|
|
118
|
+
tails.set(m[0], (tails.get(m[0]) ?? 0) + 1);
|
|
119
|
+
}
|
|
120
|
+
const [tail, n] = [...tails].sort((a, b) => b[1] - a[1])[0] ?? ["", 0];
|
|
121
|
+
const shared = n >= 2 && n >= rows.length * 0.6 ? tail : "";
|
|
122
|
+
const by = new Map();
|
|
123
|
+
for (const r of rows) {
|
|
124
|
+
const key = `${r.host}${r.path}`;
|
|
125
|
+
const had = by.get(key);
|
|
126
|
+
if (had)
|
|
127
|
+
had.views += r.views;
|
|
128
|
+
else
|
|
129
|
+
by.set(key, { ...r, title: shared && r.title.endsWith(shared) ? r.title.slice(0, -shared.length) : r.title });
|
|
130
|
+
}
|
|
131
|
+
const counts = new Map();
|
|
132
|
+
return [...by.values()].sort((a, b) => b.views - a.views).filter((p) => {
|
|
133
|
+
const c = (counts.get(p.host) ?? 0) + 1;
|
|
134
|
+
counts.set(p.host, c);
|
|
135
|
+
return c <= PAGES_PER_HOST;
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
/** dataStreams response -> the sites a property covers: each web stream's host, without www. */
|
|
139
|
+
export function parseDomains(r) {
|
|
140
|
+
const uris = (r?.dataStreams || []).map((s) => s?.webStreamData?.defaultUri).filter(Boolean);
|
|
141
|
+
const hosts = uris.flatMap((u) => {
|
|
142
|
+
try {
|
|
143
|
+
return [bareHost(new URL(u.includes("://") ? u : `https://${u}`).hostname)];
|
|
144
|
+
}
|
|
145
|
+
catch {
|
|
146
|
+
return [];
|
|
147
|
+
}
|
|
148
|
+
});
|
|
149
|
+
return [...new Set(hosts)];
|
|
150
|
+
}
|
|
151
|
+
export const batchBody = (exclude = []) => ({
|
|
73
152
|
requests: [
|
|
74
153
|
{
|
|
75
154
|
dateRanges: [
|
|
@@ -85,8 +164,11 @@ export const BATCH_BODY = {
|
|
|
85
164
|
metrics: METRICS,
|
|
86
165
|
limit: DAYS + 5,
|
|
87
166
|
},
|
|
167
|
+
// Popular over 30 days, trending since yesterday, as the site's own lists are.
|
|
168
|
+
pagesRequest("30daysAgo", exclude),
|
|
169
|
+
pagesRequest("1daysAgo", exclude),
|
|
88
170
|
],
|
|
89
|
-
};
|
|
171
|
+
});
|
|
90
172
|
async function inBatches(items, size, fn) {
|
|
91
173
|
const out = [];
|
|
92
174
|
for (let i = 0; i < items.length; i += size)
|
|
@@ -120,11 +202,12 @@ export function createGa4Plugin() {
|
|
|
120
202
|
account.properties = await inBatches(account.properties, CONCURRENCY, async (p) => {
|
|
121
203
|
// One property failing (no access to its data, quota) must not hide the rest.
|
|
122
204
|
try {
|
|
123
|
-
const [batch, realtime] = await Promise.all([
|
|
124
|
-
post(`${DATA}/properties/${p.id}:batchRunReports`,
|
|
205
|
+
const [batch, realtime, streams] = await Promise.all([
|
|
206
|
+
post(`${DATA}/properties/${p.id}:batchRunReports`, batchBody(cfg.excludePages)),
|
|
125
207
|
post(`${DATA}/properties/${p.id}:runRealtimeReport`, { metrics: [{ name: "activeUsers" }] }).catch(() => null),
|
|
208
|
+
get(`${ADMIN}/properties/${p.id}/dataStreams?pageSize=50`).catch(() => null),
|
|
126
209
|
]);
|
|
127
|
-
return { ...p, ...parseProperty(batch), activeNow: realtime ? parseRealtime(realtime) : null };
|
|
210
|
+
return { ...p, ...parseProperty(batch), activeNow: realtime ? parseRealtime(realtime) : null, domains: parseDomains(streams) };
|
|
128
211
|
}
|
|
129
212
|
catch (e) {
|
|
130
213
|
return { ...p, error: String(e?.message || e).slice(0, 160) };
|
|
@@ -42,15 +42,16 @@ const BASE64_BLOB = /[A-Za-z0-9+/]{40,}={0,2}/;
|
|
|
42
42
|
const UPLOADS_PHP = /\/wp-content\/uploads\/.*\.php$/i;
|
|
43
43
|
const PLUGIN_PHP = /\/wp-content\/(?:plugins|mu-plugins)\/.*\.php$/i;
|
|
44
44
|
const BATCH_PATH = /^\/wp-json\/batch\/v1|rest_route=\/?batch\/v1/i;
|
|
45
|
+
// The omnisend-integration mu-plugin's wp_send_json_error "Security check
|
|
46
|
+
// failed" logs as 79 bytes direct from Cloudflare and 90 through cloudflared
|
|
47
|
+
// (since 2026-10-28); success logs as 77 and 88. Matching on size is the only
|
|
48
|
+
// signal the access log carries, so a change to that message, to nginx's gzip
|
|
49
|
+
// settings or to the proxy in front silences this rule.
|
|
50
|
+
const SUBSCRIBE_FAIL_BYTES = new Set([79, 90]);
|
|
51
|
+
const SUBSCRIBE_REFERER = /\/subscribe\/?(?:[?#]|$)/;
|
|
45
52
|
// wp-admin paths that answer 2xx to anonymous callers, so a 2xx on them proves
|
|
46
53
|
// nothing about being logged in. Everything else under /wp-admin/ redirects a
|
|
47
54
|
// logged-out visitor to the login form.
|
|
48
|
-
// The omnisend-integration mu-plugin's wp_send_json_error "Security check
|
|
49
|
-
// failed" gzips to exactly 79 bytes; success gzips to 77. Matching on size is
|
|
50
|
-
// the only signal the access log carries, so a change to that message or to
|
|
51
|
-
// nginx's gzip settings silences this rule.
|
|
52
|
-
const SUBSCRIBE_FAIL_BYTES = 79;
|
|
53
|
-
const SUBSCRIBE_REFERER = /\/subscribe\/?(?:[?#]|$)/;
|
|
54
55
|
const ANON_ADMIN = /^\/wp-admin\/(?:admin-ajax|admin-post|load-styles|load-scripts|install|setup-config)\.php$|^\/wp-admin\/(?:css|js|images|fonts)\//i;
|
|
55
56
|
// Read new bytes since last offset, advancing state. Handles rotation (file
|
|
56
57
|
// shrank → restart at 0) and partial trailing lines (buffered in remainder).
|
|
@@ -173,7 +174,7 @@ export function analyze(lines, cfg, windowSec, st) {
|
|
|
173
174
|
// every WP site is probed constantly for my.php / academy.php / etc., and
|
|
174
175
|
// those hits 404 (not there) or 403 (WAF-blocked). Alerting on the probes
|
|
175
176
|
// is pure noise; alert only when the shell actually RAN.
|
|
176
|
-
if (e.method === "POST" && pathNoQuery === "/wp-admin/admin-ajax.php" && e.bytes
|
|
177
|
+
if (e.method === "POST" && pathNoQuery === "/wp-admin/admin-ajax.php" && SUBSCRIBE_FAIL_BYTES.has(e.bytes) && SUBSCRIBE_REFERER.test(e.referer)) {
|
|
177
178
|
subscribeFail.set(e.ip, (subscribeFail.get(e.ip) || 0) + 1);
|
|
178
179
|
noteHost(e.ip, e.host);
|
|
179
180
|
}
|