@gscdump/analysis 0.8.1 → 0.9.0
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.
- package/README.md +15 -5
- package/dist/analyzer/index.mjs +1 -2
- package/dist/default-registry.mjs +1 -2
- package/dist/index.d.mts +43 -22
- package/dist/index.mjs +1613 -78
- package/dist/report/index.d.mts +71 -0
- package/dist/report/index.mjs +1814 -0
- package/package.json +9 -4
|
@@ -0,0 +1,1814 @@
|
|
|
1
|
+
import { computeInputHash, createReportRegistry, defineReport } from "@gscdump/engine/report";
|
|
2
|
+
import { runAnalyzerFromSource } from "@gscdump/engine/analyzer";
|
|
3
|
+
const SEVERITY_GLYPH = {
|
|
4
|
+
info: "i",
|
|
5
|
+
low: "·",
|
|
6
|
+
medium: "!",
|
|
7
|
+
high: "!!"
|
|
8
|
+
};
|
|
9
|
+
function formatReport(report, opts = {}) {
|
|
10
|
+
const lines = [];
|
|
11
|
+
lines.push(`# ${report.id} — ${report.site}`);
|
|
12
|
+
lines.push(`window: ${report.window.start} → ${report.window.end} (${report.window.days}d)`);
|
|
13
|
+
if (report.window.comparison) lines.push(`compare: ${report.window.comparison.start} → ${report.window.comparison.end}`);
|
|
14
|
+
if (report.meta.degraded) lines.push(`! degraded: ${report.meta.steps.filter((s) => s.status === "error").map((s) => s.key).join(", ")}`);
|
|
15
|
+
lines.push("");
|
|
16
|
+
if (report.sections.length === 0) {
|
|
17
|
+
lines.push("(no sections)");
|
|
18
|
+
return lines.join("\n");
|
|
19
|
+
}
|
|
20
|
+
for (const section of report.sections) lines.push(...renderSection(section, opts.maxFindingsPerSection));
|
|
21
|
+
return lines.join("\n");
|
|
22
|
+
}
|
|
23
|
+
function renderSection(section, cap) {
|
|
24
|
+
const lines = [];
|
|
25
|
+
const glyph = SEVERITY_GLYPH[section.severity] ?? "";
|
|
26
|
+
lines.push(`## ${glyph} ${section.title}${section.coverage === "partial" ? " (partial)" : ""}`);
|
|
27
|
+
if (section.summary.magnitudeLabel) lines.push(` ${section.summary.magnitudeLabel}`);
|
|
28
|
+
const findings = cap ? section.findings.slice(0, cap) : section.findings;
|
|
29
|
+
for (const f of findings) {
|
|
30
|
+
const metricsStr = Object.entries(f.metrics).map(([k, v]) => `${k}=${formatNumber(v)}`).join(" ");
|
|
31
|
+
lines.push(` - [${f.entity.kind}] ${f.entity.value} ${metricsStr}${f.why ? ` — ${f.why}` : ""}`);
|
|
32
|
+
}
|
|
33
|
+
if (section.truncated && section.truncated.kept < section.truncated.total) lines.push(` … +${section.truncated.total - section.truncated.kept} more`);
|
|
34
|
+
for (const a of section.actions) {
|
|
35
|
+
const target = a.target ? ` ${a.target.kind}=${a.target.value}` : "";
|
|
36
|
+
lines.push(` → ${a.kind}${target}: ${a.rationale}`);
|
|
37
|
+
if (a.cliHint) lines.push(` $ ${a.cliHint}`);
|
|
38
|
+
}
|
|
39
|
+
lines.push("");
|
|
40
|
+
return lines;
|
|
41
|
+
}
|
|
42
|
+
function formatNumber(n) {
|
|
43
|
+
if (!Number.isFinite(n)) return String(n);
|
|
44
|
+
if (Number.isInteger(n)) return String(n);
|
|
45
|
+
return n.toFixed(2);
|
|
46
|
+
}
|
|
47
|
+
const DEFAULT_MAX$7 = 5;
|
|
48
|
+
const brandReport = defineReport({
|
|
49
|
+
id: "brand",
|
|
50
|
+
description: "Brand vs non-brand share, top brand keywords, and site-wide keyword concentration.",
|
|
51
|
+
defaultPeriod: "last-28d",
|
|
52
|
+
defaultComparison: "none",
|
|
53
|
+
argsSpec: {
|
|
54
|
+
"brand-terms": {
|
|
55
|
+
type: "string",
|
|
56
|
+
description: "Comma-separated brand terms",
|
|
57
|
+
required: true
|
|
58
|
+
},
|
|
59
|
+
"max-findings": {
|
|
60
|
+
type: "number",
|
|
61
|
+
description: "Cap findings per section",
|
|
62
|
+
default: DEFAULT_MAX$7
|
|
63
|
+
}
|
|
64
|
+
},
|
|
65
|
+
plan: (params, window) => {
|
|
66
|
+
if (!params.brandTerms || !params.brandTerms.trim()) throw new Error("brand report requires --brand-terms <comma,separated,list>");
|
|
67
|
+
const brandTerms = params.brandTerms.split(",").map((t) => t.trim()).filter(Boolean);
|
|
68
|
+
const dates = {
|
|
69
|
+
startDate: window.start,
|
|
70
|
+
endDate: window.end
|
|
71
|
+
};
|
|
72
|
+
return [{
|
|
73
|
+
key: "brand",
|
|
74
|
+
type: "brand",
|
|
75
|
+
params: {
|
|
76
|
+
...dates,
|
|
77
|
+
brandTerms,
|
|
78
|
+
limit: 200
|
|
79
|
+
},
|
|
80
|
+
required: true
|
|
81
|
+
}, {
|
|
82
|
+
key: "concentration",
|
|
83
|
+
type: "concentration",
|
|
84
|
+
params: {
|
|
85
|
+
...dates,
|
|
86
|
+
dimension: "keywords",
|
|
87
|
+
limit: 50
|
|
88
|
+
}
|
|
89
|
+
}];
|
|
90
|
+
},
|
|
91
|
+
reduce: (results, ctx) => {
|
|
92
|
+
const max = ctx.params.maxFindings ?? DEFAULT_MAX$7;
|
|
93
|
+
return { sections: [buildBrandSplitSection(results.brand, max), buildConcentrationSection(results.concentration, max)] };
|
|
94
|
+
}
|
|
95
|
+
});
|
|
96
|
+
function buildBrandSplitSection(res, max) {
|
|
97
|
+
const rows = res?.results ?? [];
|
|
98
|
+
const summary = res?.meta?.summary;
|
|
99
|
+
const findings = rows.filter((r) => r.segment === "brand").sort((a, b) => b.clicks - a.clicks).slice(0, max).map((r) => ({
|
|
100
|
+
entity: {
|
|
101
|
+
kind: "query",
|
|
102
|
+
value: r.query
|
|
103
|
+
},
|
|
104
|
+
metrics: {
|
|
105
|
+
clicks: r.clicks,
|
|
106
|
+
impressions: r.impressions,
|
|
107
|
+
ctr: r.ctr,
|
|
108
|
+
position: r.position
|
|
109
|
+
},
|
|
110
|
+
why: r.page ? `on ${r.page}` : void 0
|
|
111
|
+
}));
|
|
112
|
+
const brandShare = summary?.brandShare ?? 0;
|
|
113
|
+
return {
|
|
114
|
+
id: "brand-split",
|
|
115
|
+
title: "Brand vs non-brand",
|
|
116
|
+
severity: "info",
|
|
117
|
+
summary: { magnitudeLabel: summary ? `brand share ${(brandShare * 100).toFixed(1)}% (${summary.brandClicks} brand vs ${summary.nonBrandClicks} non-brand clicks)` : "no summary available" },
|
|
118
|
+
findings,
|
|
119
|
+
coverage: res ? "full" : "partial",
|
|
120
|
+
actions: [],
|
|
121
|
+
artifact: res ? {
|
|
122
|
+
analyzer: "brand",
|
|
123
|
+
params: { type: "brand" }
|
|
124
|
+
} : void 0
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
function buildConcentrationSection(res, max) {
|
|
128
|
+
const head = (res?.results ?? [])[0];
|
|
129
|
+
const findings = (head?.topNItems ?? []).slice(0, max).map((it) => ({
|
|
130
|
+
entity: {
|
|
131
|
+
kind: "query",
|
|
132
|
+
value: it.key
|
|
133
|
+
},
|
|
134
|
+
metrics: {
|
|
135
|
+
clicks: it.clicks,
|
|
136
|
+
share: it.share
|
|
137
|
+
}
|
|
138
|
+
}));
|
|
139
|
+
const severity = head?.riskLevel === "high" ? "high" : head?.riskLevel === "medium" ? "medium" : "low";
|
|
140
|
+
return {
|
|
141
|
+
id: "concentration",
|
|
142
|
+
title: "Keyword concentration (site-wide)",
|
|
143
|
+
severity: head ? severity : "info",
|
|
144
|
+
summary: head ? { magnitudeLabel: `HHI ${head.hhi.toFixed(0)} (${head.riskLevel}); top-N share ${(head.topNConcentration * 100).toFixed(1)}%` } : {},
|
|
145
|
+
findings,
|
|
146
|
+
coverage: res ? "full" : "partial",
|
|
147
|
+
actions: [],
|
|
148
|
+
artifact: res ? {
|
|
149
|
+
analyzer: "concentration",
|
|
150
|
+
params: {
|
|
151
|
+
type: "concentration",
|
|
152
|
+
dimension: "keywords"
|
|
153
|
+
}
|
|
154
|
+
} : void 0
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
const DEFAULT_MAX$6 = 5;
|
|
158
|
+
const growthReport = defineReport({
|
|
159
|
+
id: "growth",
|
|
160
|
+
description: "Strategic growth signals: content velocity, keyword breadth, intent atlas, and long-tail shape over a long window (default 90d / YoY).",
|
|
161
|
+
defaultPeriod: "last-90d",
|
|
162
|
+
defaultComparison: "yoy",
|
|
163
|
+
argsSpec: { "max-findings": {
|
|
164
|
+
type: "number",
|
|
165
|
+
description: "Cap findings per section (long-tail only)",
|
|
166
|
+
default: DEFAULT_MAX$6
|
|
167
|
+
} },
|
|
168
|
+
plan: (_params, window) => {
|
|
169
|
+
const dates = {
|
|
170
|
+
startDate: window.start,
|
|
171
|
+
endDate: window.end
|
|
172
|
+
};
|
|
173
|
+
return [
|
|
174
|
+
{
|
|
175
|
+
key: "content-velocity",
|
|
176
|
+
type: "content-velocity",
|
|
177
|
+
params: {
|
|
178
|
+
...dates,
|
|
179
|
+
days: window.days,
|
|
180
|
+
limit: 200
|
|
181
|
+
}
|
|
182
|
+
},
|
|
183
|
+
{
|
|
184
|
+
key: "keyword-breadth",
|
|
185
|
+
type: "keyword-breadth",
|
|
186
|
+
params: {
|
|
187
|
+
...dates,
|
|
188
|
+
limit: 50
|
|
189
|
+
}
|
|
190
|
+
},
|
|
191
|
+
{
|
|
192
|
+
key: "intent-atlas",
|
|
193
|
+
type: "intent-atlas",
|
|
194
|
+
params: {
|
|
195
|
+
...dates,
|
|
196
|
+
limit: 50
|
|
197
|
+
}
|
|
198
|
+
},
|
|
199
|
+
{
|
|
200
|
+
key: "long-tail",
|
|
201
|
+
type: "long-tail",
|
|
202
|
+
params: {
|
|
203
|
+
...dates,
|
|
204
|
+
limit: 100
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
];
|
|
208
|
+
},
|
|
209
|
+
reduce: (results, ctx) => {
|
|
210
|
+
const max = ctx.params.maxFindings ?? DEFAULT_MAX$6;
|
|
211
|
+
return { sections: [
|
|
212
|
+
buildContentVelocity(results["content-velocity"]),
|
|
213
|
+
buildKeywordBreadth(results["keyword-breadth"]),
|
|
214
|
+
buildIntentAtlas(results["intent-atlas"]),
|
|
215
|
+
buildLongTail(results["long-tail"], max)
|
|
216
|
+
] };
|
|
217
|
+
}
|
|
218
|
+
});
|
|
219
|
+
function buildContentVelocity(res) {
|
|
220
|
+
const rows = res?.results ?? [];
|
|
221
|
+
const totalNew = rows.reduce((s, r) => s + r.newKeywords, 0);
|
|
222
|
+
const avgPerWeek = rows.length > 0 ? totalNew / rows.length : 0;
|
|
223
|
+
return {
|
|
224
|
+
id: "content-velocity",
|
|
225
|
+
title: "Content velocity",
|
|
226
|
+
severity: "info",
|
|
227
|
+
summary: { magnitudeLabel: `${totalNew} new keywords across ${rows.length} weeks (avg ${avgPerWeek.toFixed(1)}/wk)` },
|
|
228
|
+
findings: [],
|
|
229
|
+
coverage: res ? "full" : "partial",
|
|
230
|
+
actions: [],
|
|
231
|
+
artifact: res ? {
|
|
232
|
+
analyzer: "content-velocity",
|
|
233
|
+
params: { type: "content-velocity" }
|
|
234
|
+
} : void 0
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
function buildKeywordBreadth(res) {
|
|
238
|
+
const rows = res?.results ?? [];
|
|
239
|
+
const totalPages = rows.reduce((s, r) => s + r.pageCount, 0);
|
|
240
|
+
const top = rows[0];
|
|
241
|
+
return {
|
|
242
|
+
id: "keyword-breadth",
|
|
243
|
+
title: "Keyword breadth",
|
|
244
|
+
severity: "info",
|
|
245
|
+
summary: { magnitudeLabel: top ? `${totalPages} pages; modal bucket "${top.bucket}" (${top.pageCount} pages)` : "no data" },
|
|
246
|
+
findings: [],
|
|
247
|
+
coverage: res ? "full" : "partial",
|
|
248
|
+
actions: [],
|
|
249
|
+
artifact: res ? {
|
|
250
|
+
analyzer: "keyword-breadth",
|
|
251
|
+
params: { type: "keyword-breadth" }
|
|
252
|
+
} : void 0
|
|
253
|
+
};
|
|
254
|
+
}
|
|
255
|
+
function buildIntentAtlas(res) {
|
|
256
|
+
const rows = res?.results ?? [];
|
|
257
|
+
return {
|
|
258
|
+
id: "intent-atlas",
|
|
259
|
+
title: "Intent atlas",
|
|
260
|
+
severity: "info",
|
|
261
|
+
summary: { magnitudeLabel: `${rows.length} clusters covering ${rows.reduce((s, r) => s + r.keywordCount, 0)} keywords` },
|
|
262
|
+
findings: [],
|
|
263
|
+
coverage: res ? "full" : "partial",
|
|
264
|
+
actions: [],
|
|
265
|
+
artifact: res ? {
|
|
266
|
+
analyzer: "intent-atlas",
|
|
267
|
+
params: { type: "intent-atlas" }
|
|
268
|
+
} : void 0
|
|
269
|
+
};
|
|
270
|
+
}
|
|
271
|
+
function buildLongTail(res, max) {
|
|
272
|
+
const rows = (res?.results ?? []).sort((a, b) => {
|
|
273
|
+
const rank = {
|
|
274
|
+
"head-heavy": 0,
|
|
275
|
+
"balanced": 1,
|
|
276
|
+
"flat-tail": 2
|
|
277
|
+
};
|
|
278
|
+
const dr = rank[a.fingerprint] - rank[b.fingerprint];
|
|
279
|
+
return dr !== 0 ? dr : b.headShare - a.headShare;
|
|
280
|
+
});
|
|
281
|
+
const kept = rows.slice(0, max);
|
|
282
|
+
const findings = kept.map((r) => ({
|
|
283
|
+
entity: {
|
|
284
|
+
kind: "page",
|
|
285
|
+
value: r.page
|
|
286
|
+
},
|
|
287
|
+
metrics: {
|
|
288
|
+
queryCount: r.queryCount,
|
|
289
|
+
impressions: r.totalImpressions,
|
|
290
|
+
headShare: r.headShare
|
|
291
|
+
},
|
|
292
|
+
why: `${r.fingerprint} (${(r.headShare * 100).toFixed(0)}% head share)`
|
|
293
|
+
}));
|
|
294
|
+
const headHeavy = rows.filter((r) => r.fingerprint === "head-heavy").length;
|
|
295
|
+
return {
|
|
296
|
+
id: "long-tail",
|
|
297
|
+
title: "Long-tail shape",
|
|
298
|
+
severity: headHeavy > rows.length / 3 ? "medium" : "info",
|
|
299
|
+
summary: { magnitudeLabel: `${rows.length} pages analysed; ${headHeavy} head-heavy` },
|
|
300
|
+
findings,
|
|
301
|
+
truncated: rows.length > max ? {
|
|
302
|
+
kept: kept.length,
|
|
303
|
+
total: rows.length
|
|
304
|
+
} : void 0,
|
|
305
|
+
coverage: res ? "full" : "partial",
|
|
306
|
+
actions: [],
|
|
307
|
+
artifact: res ? {
|
|
308
|
+
analyzer: "long-tail",
|
|
309
|
+
params: { type: "long-tail" }
|
|
310
|
+
} : void 0
|
|
311
|
+
};
|
|
312
|
+
}
|
|
313
|
+
const DEFAULT_MAX$5 = 5;
|
|
314
|
+
const healthReport = defineReport({
|
|
315
|
+
id: "health",
|
|
316
|
+
description: "CTR anomalies, change-points, and position-volatility hot spots in the recent window.",
|
|
317
|
+
defaultPeriod: "last-28d",
|
|
318
|
+
defaultComparison: "none",
|
|
319
|
+
argsSpec: { "max-findings": {
|
|
320
|
+
type: "number",
|
|
321
|
+
description: "Cap findings per section",
|
|
322
|
+
default: DEFAULT_MAX$5
|
|
323
|
+
} },
|
|
324
|
+
plan: (_params, window) => {
|
|
325
|
+
const dates = {
|
|
326
|
+
startDate: window.start,
|
|
327
|
+
endDate: window.end
|
|
328
|
+
};
|
|
329
|
+
return [
|
|
330
|
+
{
|
|
331
|
+
key: "ctr-anomaly",
|
|
332
|
+
type: "ctr-anomaly",
|
|
333
|
+
params: {
|
|
334
|
+
...dates,
|
|
335
|
+
limit: 100
|
|
336
|
+
},
|
|
337
|
+
required: true
|
|
338
|
+
},
|
|
339
|
+
{
|
|
340
|
+
key: "change-point",
|
|
341
|
+
type: "change-point",
|
|
342
|
+
params: {
|
|
343
|
+
...dates,
|
|
344
|
+
limit: 100
|
|
345
|
+
}
|
|
346
|
+
},
|
|
347
|
+
{
|
|
348
|
+
key: "position-volatility",
|
|
349
|
+
type: "position-volatility",
|
|
350
|
+
params: {
|
|
351
|
+
...dates,
|
|
352
|
+
limit: 100
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
];
|
|
356
|
+
},
|
|
357
|
+
reduce: (results, ctx) => {
|
|
358
|
+
const max = ctx.params.maxFindings ?? DEFAULT_MAX$5;
|
|
359
|
+
return { sections: [
|
|
360
|
+
buildCtrAnomalySection(results["ctr-anomaly"], max),
|
|
361
|
+
buildChangePointSection$1(results["change-point"], max),
|
|
362
|
+
buildPositionVolatilitySection(results["position-volatility"], max)
|
|
363
|
+
] };
|
|
364
|
+
}
|
|
365
|
+
});
|
|
366
|
+
function buildCtrAnomalySection(res, max) {
|
|
367
|
+
const rows = (res?.results ?? []).filter((r) => r.breachDaysDown > 0).sort((a, b) => b.clicksLost - a.clicksLost);
|
|
368
|
+
const kept = rows.slice(0, max);
|
|
369
|
+
const totalLost = kept.reduce((s, r) => s + r.clicksLost, 0);
|
|
370
|
+
const findings = kept.map((r) => ({
|
|
371
|
+
entity: {
|
|
372
|
+
kind: "query",
|
|
373
|
+
value: r.keyword
|
|
374
|
+
},
|
|
375
|
+
metrics: {
|
|
376
|
+
clicksLost: r.clicksLost,
|
|
377
|
+
breachDays: r.breachDaysDown,
|
|
378
|
+
severity: r.severity,
|
|
379
|
+
baselineCtr: r.baselineCtr
|
|
380
|
+
},
|
|
381
|
+
why: r.page ? `on ${r.page}` : void 0
|
|
382
|
+
}));
|
|
383
|
+
return {
|
|
384
|
+
id: "ctr-anomaly",
|
|
385
|
+
title: "CTR anomalies",
|
|
386
|
+
severity: totalLost >= 100 ? "high" : totalLost >= 25 ? "medium" : kept.length ? "low" : "info",
|
|
387
|
+
summary: {
|
|
388
|
+
delta: -totalLost,
|
|
389
|
+
direction: totalLost > 0 ? "down" : "flat",
|
|
390
|
+
magnitudeLabel: `${Math.round(totalLost)} clicks lost vs baseline`
|
|
391
|
+
},
|
|
392
|
+
findings,
|
|
393
|
+
truncated: rows.length > max ? {
|
|
394
|
+
kept: kept.length,
|
|
395
|
+
total: rows.length
|
|
396
|
+
} : void 0,
|
|
397
|
+
coverage: res ? "full" : "partial",
|
|
398
|
+
actions: [],
|
|
399
|
+
artifact: res ? {
|
|
400
|
+
analyzer: "ctr-anomaly",
|
|
401
|
+
params: { type: "ctr-anomaly" }
|
|
402
|
+
} : void 0
|
|
403
|
+
};
|
|
404
|
+
}
|
|
405
|
+
function buildChangePointSection$1(res, max) {
|
|
406
|
+
const rows = (res?.results ?? []).filter((r) => r.direction === "worsened").sort((a, b) => b.llr - a.llr);
|
|
407
|
+
const kept = rows.slice(0, max);
|
|
408
|
+
const findings = kept.map((r) => ({
|
|
409
|
+
entity: {
|
|
410
|
+
kind: "query",
|
|
411
|
+
value: r.keyword
|
|
412
|
+
},
|
|
413
|
+
metrics: {
|
|
414
|
+
llr: r.llr,
|
|
415
|
+
delta: r.delta
|
|
416
|
+
},
|
|
417
|
+
why: `worsened on ${r.changeDate}${r.page ? ` (${r.page})` : ""}`
|
|
418
|
+
}));
|
|
419
|
+
return {
|
|
420
|
+
id: "change-point",
|
|
421
|
+
title: "Change-points (worsening)",
|
|
422
|
+
severity: kept.length ? "medium" : "info",
|
|
423
|
+
summary: { magnitudeLabel: `${kept.length} worsening segments` },
|
|
424
|
+
findings,
|
|
425
|
+
truncated: rows.length > max ? {
|
|
426
|
+
kept: kept.length,
|
|
427
|
+
total: rows.length
|
|
428
|
+
} : void 0,
|
|
429
|
+
coverage: res ? "full" : "partial",
|
|
430
|
+
actions: [],
|
|
431
|
+
artifact: res ? {
|
|
432
|
+
analyzer: "change-point",
|
|
433
|
+
params: { type: "change-point" }
|
|
434
|
+
} : void 0
|
|
435
|
+
};
|
|
436
|
+
}
|
|
437
|
+
function buildPositionVolatilitySection(res, max) {
|
|
438
|
+
const rows = (res?.results ?? []).sort((a, b) => b.peakVolatility - a.peakVolatility);
|
|
439
|
+
const kept = rows.slice(0, max);
|
|
440
|
+
const findings = kept.map((r) => ({
|
|
441
|
+
entity: {
|
|
442
|
+
kind: "page",
|
|
443
|
+
value: r.page
|
|
444
|
+
},
|
|
445
|
+
metrics: {
|
|
446
|
+
avgVolatility: r.avgVolatility,
|
|
447
|
+
peakVolatility: r.peakVolatility,
|
|
448
|
+
impressions: r.totalImpressions
|
|
449
|
+
}
|
|
450
|
+
}));
|
|
451
|
+
return {
|
|
452
|
+
id: "position-volatility",
|
|
453
|
+
title: "Position volatility",
|
|
454
|
+
severity: kept.length ? "low" : "info",
|
|
455
|
+
summary: {},
|
|
456
|
+
findings,
|
|
457
|
+
truncated: rows.length > max ? {
|
|
458
|
+
kept: kept.length,
|
|
459
|
+
total: rows.length
|
|
460
|
+
} : void 0,
|
|
461
|
+
coverage: res ? "full" : "partial",
|
|
462
|
+
actions: [],
|
|
463
|
+
artifact: res ? {
|
|
464
|
+
analyzer: "position-volatility",
|
|
465
|
+
params: { type: "position-volatility" }
|
|
466
|
+
} : void 0
|
|
467
|
+
};
|
|
468
|
+
}
|
|
469
|
+
const DEFAULT_MAX$4 = 5;
|
|
470
|
+
const DEFAULT_MIN_CHANGE = 5;
|
|
471
|
+
const moversReport = defineReport({
|
|
472
|
+
id: "movers",
|
|
473
|
+
description: "Risers, decliners, and striking-distance opportunities over a current vs prior window.",
|
|
474
|
+
defaultPeriod: "last-7d",
|
|
475
|
+
defaultComparison: "prev-period",
|
|
476
|
+
argsSpec: {
|
|
477
|
+
"max-findings": {
|
|
478
|
+
type: "number",
|
|
479
|
+
description: "Cap findings per section",
|
|
480
|
+
default: DEFAULT_MAX$4
|
|
481
|
+
},
|
|
482
|
+
"min-clicks-change": {
|
|
483
|
+
type: "number",
|
|
484
|
+
description: "Min absolute click change",
|
|
485
|
+
default: DEFAULT_MIN_CHANGE
|
|
486
|
+
}
|
|
487
|
+
},
|
|
488
|
+
plan: (_params, window) => {
|
|
489
|
+
if (!window.comparison) throw new Error("movers report requires a comparison window — pass --vs prev-period");
|
|
490
|
+
const cur = {
|
|
491
|
+
startDate: window.start,
|
|
492
|
+
endDate: window.end
|
|
493
|
+
};
|
|
494
|
+
const prev = {
|
|
495
|
+
prevStartDate: window.comparison.start,
|
|
496
|
+
prevEndDate: window.comparison.end
|
|
497
|
+
};
|
|
498
|
+
return [
|
|
499
|
+
{
|
|
500
|
+
key: "movers",
|
|
501
|
+
type: "movers",
|
|
502
|
+
params: {
|
|
503
|
+
...cur,
|
|
504
|
+
...prev,
|
|
505
|
+
limit: 200
|
|
506
|
+
},
|
|
507
|
+
required: true
|
|
508
|
+
},
|
|
509
|
+
{
|
|
510
|
+
key: "decay",
|
|
511
|
+
type: "decay",
|
|
512
|
+
params: {
|
|
513
|
+
...cur,
|
|
514
|
+
...prev,
|
|
515
|
+
limit: 100
|
|
516
|
+
}
|
|
517
|
+
},
|
|
518
|
+
{
|
|
519
|
+
key: "striking",
|
|
520
|
+
type: "striking-distance",
|
|
521
|
+
params: {
|
|
522
|
+
...cur,
|
|
523
|
+
limit: 100
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
];
|
|
527
|
+
},
|
|
528
|
+
reduce: (results, ctx) => {
|
|
529
|
+
const max = ctx.params.maxFindings ?? DEFAULT_MAX$4;
|
|
530
|
+
const minChange = ctx.params.minClicksChange ?? DEFAULT_MIN_CHANGE;
|
|
531
|
+
const sections = [];
|
|
532
|
+
const moversRes = results.movers;
|
|
533
|
+
const decayRes = results.decay;
|
|
534
|
+
const strikingRes = results.striking;
|
|
535
|
+
sections.push(buildMoversSection(moversRes, "rising", max, minChange));
|
|
536
|
+
sections.push(buildDeclinersSection(moversRes, decayRes, max, minChange));
|
|
537
|
+
sections.push(buildStrikingSection$1(strikingRes, max));
|
|
538
|
+
return { sections };
|
|
539
|
+
}
|
|
540
|
+
});
|
|
541
|
+
function buildMoversSection(res, direction, max, minChange) {
|
|
542
|
+
const rows = (res?.results ?? []).filter((r) => r.direction === direction && Math.abs(r.clicksChange) >= minChange).sort((a, b) => Math.abs(b.clicksChange) - Math.abs(a.clicksChange));
|
|
543
|
+
const total = rows.length;
|
|
544
|
+
const kept = rows.slice(0, max);
|
|
545
|
+
const findings = kept.map((r) => ({
|
|
546
|
+
entity: {
|
|
547
|
+
kind: "query",
|
|
548
|
+
value: r.keyword
|
|
549
|
+
},
|
|
550
|
+
metrics: {
|
|
551
|
+
clicks: r.recentClicks,
|
|
552
|
+
clicksChange: r.clicksChange,
|
|
553
|
+
clicksChangePercent: r.clicksChangePercent,
|
|
554
|
+
positionChange: r.positionChange
|
|
555
|
+
},
|
|
556
|
+
delta: {
|
|
557
|
+
metric: "clicks",
|
|
558
|
+
prior: r.baselineClicks,
|
|
559
|
+
current: r.recentClicks,
|
|
560
|
+
pct: r.clicksChangePercent
|
|
561
|
+
},
|
|
562
|
+
why: r.page ? `on ${r.page}` : void 0
|
|
563
|
+
}));
|
|
564
|
+
const totalDelta = kept.reduce((sum, r) => sum + r.clicksChange, 0);
|
|
565
|
+
return {
|
|
566
|
+
id: direction,
|
|
567
|
+
title: direction === "rising" ? "Rising queries" : "Declining queries",
|
|
568
|
+
severity: direction === "rising" ? "info" : "medium",
|
|
569
|
+
summary: {
|
|
570
|
+
delta: totalDelta,
|
|
571
|
+
direction: totalDelta > 0 ? "up" : totalDelta < 0 ? "down" : "flat"
|
|
572
|
+
},
|
|
573
|
+
findings,
|
|
574
|
+
truncated: total > max ? {
|
|
575
|
+
kept: kept.length,
|
|
576
|
+
total
|
|
577
|
+
} : void 0,
|
|
578
|
+
coverage: res ? "full" : "partial",
|
|
579
|
+
actions: [],
|
|
580
|
+
artifact: res ? {
|
|
581
|
+
analyzer: "movers",
|
|
582
|
+
params: { type: "movers" }
|
|
583
|
+
} : void 0
|
|
584
|
+
};
|
|
585
|
+
}
|
|
586
|
+
function buildDeclinersSection(moversRes, decayRes, max, minChange) {
|
|
587
|
+
const decliningQueries = (moversRes?.results ?? []).filter((r) => r.direction === "declining" && Math.abs(r.clicksChange) >= minChange).slice(0, max);
|
|
588
|
+
const lostPages = (decayRes?.results ?? []).sort((a, b) => b.lostClicks - a.lostClicks).slice(0, max);
|
|
589
|
+
const findings = [...decliningQueries.map((r) => ({
|
|
590
|
+
entity: {
|
|
591
|
+
kind: "query",
|
|
592
|
+
value: r.keyword
|
|
593
|
+
},
|
|
594
|
+
metrics: {
|
|
595
|
+
clicks: r.recentClicks,
|
|
596
|
+
clicksChange: r.clicksChange
|
|
597
|
+
},
|
|
598
|
+
delta: {
|
|
599
|
+
metric: "clicks",
|
|
600
|
+
prior: r.baselineClicks,
|
|
601
|
+
current: r.recentClicks,
|
|
602
|
+
pct: r.clicksChangePercent
|
|
603
|
+
}
|
|
604
|
+
})), ...lostPages.map((r) => ({
|
|
605
|
+
entity: {
|
|
606
|
+
kind: "page",
|
|
607
|
+
value: r.page
|
|
608
|
+
},
|
|
609
|
+
metrics: {
|
|
610
|
+
clicks: r.currentClicks,
|
|
611
|
+
lostClicks: r.lostClicks,
|
|
612
|
+
declinePercent: r.declinePercent
|
|
613
|
+
},
|
|
614
|
+
delta: {
|
|
615
|
+
metric: "clicks",
|
|
616
|
+
prior: r.previousClicks,
|
|
617
|
+
current: r.currentClicks,
|
|
618
|
+
pct: -r.declinePercent * 100
|
|
619
|
+
},
|
|
620
|
+
why: "page-level decay"
|
|
621
|
+
}))];
|
|
622
|
+
const totalLost = decliningQueries.reduce((s, r) => s + Math.abs(r.clicksChange), 0) + lostPages.reduce((s, r) => s + r.lostClicks, 0);
|
|
623
|
+
return {
|
|
624
|
+
id: "decliners",
|
|
625
|
+
title: "Decliners",
|
|
626
|
+
severity: totalLost >= 100 ? "high" : totalLost >= 25 ? "medium" : "low",
|
|
627
|
+
summary: {
|
|
628
|
+
delta: -totalLost,
|
|
629
|
+
direction: totalLost > 0 ? "down" : "flat",
|
|
630
|
+
magnitudeLabel: `${Math.round(totalLost)} clicks lost`
|
|
631
|
+
},
|
|
632
|
+
findings,
|
|
633
|
+
coverage: decayRes && moversRes ? "full" : "partial",
|
|
634
|
+
actions: lostPages.slice(0, 1).map((r) => ({
|
|
635
|
+
kind: "analyzer",
|
|
636
|
+
target: {
|
|
637
|
+
kind: "page",
|
|
638
|
+
value: r.page
|
|
639
|
+
},
|
|
640
|
+
params: { type: "change-point" },
|
|
641
|
+
rationale: "Investigate the change-point on the worst-affected page",
|
|
642
|
+
cliHint: `gscdump analyze change-point --start <date> --end <date>`
|
|
643
|
+
}))
|
|
644
|
+
};
|
|
645
|
+
}
|
|
646
|
+
function buildStrikingSection$1(res, max) {
|
|
647
|
+
const rows = (res?.results ?? []).sort((a, b) => b.potentialClicks - a.potentialClicks);
|
|
648
|
+
const kept = rows.slice(0, max);
|
|
649
|
+
const findings = kept.map((r) => ({
|
|
650
|
+
entity: {
|
|
651
|
+
kind: "query",
|
|
652
|
+
value: r.keyword
|
|
653
|
+
},
|
|
654
|
+
metrics: {
|
|
655
|
+
position: r.position,
|
|
656
|
+
impressions: r.impressions,
|
|
657
|
+
clicks: r.clicks,
|
|
658
|
+
potentialClicks: r.potentialClicks
|
|
659
|
+
},
|
|
660
|
+
why: r.page ? `currently on ${r.page}` : void 0
|
|
661
|
+
}));
|
|
662
|
+
return {
|
|
663
|
+
id: "striking-distance",
|
|
664
|
+
title: "Striking-distance opportunities",
|
|
665
|
+
severity: "low",
|
|
666
|
+
summary: { magnitudeLabel: `${kept.reduce((s, r) => s + r.potentialClicks, 0)} potential clicks` },
|
|
667
|
+
findings,
|
|
668
|
+
truncated: rows.length > max ? {
|
|
669
|
+
kept: kept.length,
|
|
670
|
+
total: rows.length
|
|
671
|
+
} : void 0,
|
|
672
|
+
coverage: res ? "full" : "partial",
|
|
673
|
+
actions: [],
|
|
674
|
+
artifact: res ? {
|
|
675
|
+
analyzer: "striking-distance",
|
|
676
|
+
params: { type: "striking-distance" }
|
|
677
|
+
} : void 0
|
|
678
|
+
};
|
|
679
|
+
}
|
|
680
|
+
const DEFAULT_MAX$3 = 5;
|
|
681
|
+
const opportunitiesReport = defineReport({
|
|
682
|
+
id: "opportunities",
|
|
683
|
+
description: "Striking-distance, low-CTR, zero-click, and query-migration opportunities.",
|
|
684
|
+
defaultPeriod: "last-28d",
|
|
685
|
+
defaultComparison: "none",
|
|
686
|
+
argsSpec: { "max-findings": {
|
|
687
|
+
type: "number",
|
|
688
|
+
description: "Cap findings per section",
|
|
689
|
+
default: DEFAULT_MAX$3
|
|
690
|
+
} },
|
|
691
|
+
plan: (_params, window) => {
|
|
692
|
+
const dates = {
|
|
693
|
+
startDate: window.start,
|
|
694
|
+
endDate: window.end
|
|
695
|
+
};
|
|
696
|
+
return [
|
|
697
|
+
{
|
|
698
|
+
key: "striking",
|
|
699
|
+
type: "striking-distance",
|
|
700
|
+
params: {
|
|
701
|
+
...dates,
|
|
702
|
+
limit: 100
|
|
703
|
+
},
|
|
704
|
+
required: true
|
|
705
|
+
},
|
|
706
|
+
{
|
|
707
|
+
key: "opportunity",
|
|
708
|
+
type: "opportunity",
|
|
709
|
+
params: {
|
|
710
|
+
...dates,
|
|
711
|
+
limit: 100
|
|
712
|
+
}
|
|
713
|
+
},
|
|
714
|
+
{
|
|
715
|
+
key: "zero-click",
|
|
716
|
+
type: "zero-click",
|
|
717
|
+
params: {
|
|
718
|
+
...dates,
|
|
719
|
+
limit: 100
|
|
720
|
+
}
|
|
721
|
+
},
|
|
722
|
+
{
|
|
723
|
+
key: "query-migration",
|
|
724
|
+
type: "query-migration",
|
|
725
|
+
params: {
|
|
726
|
+
...dates,
|
|
727
|
+
limit: 50
|
|
728
|
+
}
|
|
729
|
+
}
|
|
730
|
+
];
|
|
731
|
+
},
|
|
732
|
+
reduce: (results, ctx) => {
|
|
733
|
+
const max = ctx.params.maxFindings ?? DEFAULT_MAX$3;
|
|
734
|
+
return { sections: [
|
|
735
|
+
buildStrikingSection(results.striking, max),
|
|
736
|
+
buildOpportunitySection(results.opportunity, max),
|
|
737
|
+
buildZeroClickSection(results["zero-click"], max),
|
|
738
|
+
buildMigrationSection$1(results["query-migration"], max)
|
|
739
|
+
] };
|
|
740
|
+
}
|
|
741
|
+
});
|
|
742
|
+
function buildStrikingSection(res, max) {
|
|
743
|
+
const rows = (res?.results ?? []).sort((a, b) => b.potentialClicks - a.potentialClicks);
|
|
744
|
+
const kept = rows.slice(0, max);
|
|
745
|
+
const findings = kept.map((r) => ({
|
|
746
|
+
entity: {
|
|
747
|
+
kind: "query",
|
|
748
|
+
value: r.keyword
|
|
749
|
+
},
|
|
750
|
+
metrics: {
|
|
751
|
+
position: r.position,
|
|
752
|
+
impressions: r.impressions,
|
|
753
|
+
clicks: r.clicks,
|
|
754
|
+
potentialClicks: r.potentialClicks
|
|
755
|
+
},
|
|
756
|
+
why: r.page ? `on ${r.page}` : void 0
|
|
757
|
+
}));
|
|
758
|
+
const totalPotential = kept.reduce((s, r) => s + r.potentialClicks, 0);
|
|
759
|
+
return {
|
|
760
|
+
id: "striking-distance",
|
|
761
|
+
title: "Striking distance",
|
|
762
|
+
severity: "low",
|
|
763
|
+
summary: { magnitudeLabel: `${Math.round(totalPotential)} potential clicks` },
|
|
764
|
+
findings,
|
|
765
|
+
truncated: rows.length > max ? {
|
|
766
|
+
kept: kept.length,
|
|
767
|
+
total: rows.length
|
|
768
|
+
} : void 0,
|
|
769
|
+
coverage: res ? "full" : "partial",
|
|
770
|
+
actions: [],
|
|
771
|
+
artifact: res ? {
|
|
772
|
+
analyzer: "striking-distance",
|
|
773
|
+
params: { type: "striking-distance" }
|
|
774
|
+
} : void 0
|
|
775
|
+
};
|
|
776
|
+
}
|
|
777
|
+
function buildOpportunitySection(res, max) {
|
|
778
|
+
const rows = (res?.results ?? []).sort((a, b) => b.opportunityScore - a.opportunityScore);
|
|
779
|
+
const kept = rows.slice(0, max);
|
|
780
|
+
return {
|
|
781
|
+
id: "low-ctr",
|
|
782
|
+
title: "Underperforming CTR",
|
|
783
|
+
severity: "low",
|
|
784
|
+
summary: {},
|
|
785
|
+
findings: kept.map((r) => ({
|
|
786
|
+
entity: {
|
|
787
|
+
kind: "query",
|
|
788
|
+
value: r.keyword
|
|
789
|
+
},
|
|
790
|
+
metrics: {
|
|
791
|
+
opportunityScore: r.opportunityScore,
|
|
792
|
+
ctr: r.ctr,
|
|
793
|
+
position: r.position,
|
|
794
|
+
impressions: r.impressions,
|
|
795
|
+
potentialClicks: r.potentialClicks
|
|
796
|
+
},
|
|
797
|
+
why: r.page ? `low CTR on ${r.page}` : "low CTR vs position"
|
|
798
|
+
})),
|
|
799
|
+
truncated: rows.length > max ? {
|
|
800
|
+
kept: kept.length,
|
|
801
|
+
total: rows.length
|
|
802
|
+
} : void 0,
|
|
803
|
+
coverage: res ? "full" : "partial",
|
|
804
|
+
actions: kept.slice(0, 1).map((r) => ({
|
|
805
|
+
kind: "fix",
|
|
806
|
+
target: r.page ? {
|
|
807
|
+
kind: "page",
|
|
808
|
+
value: r.page
|
|
809
|
+
} : {
|
|
810
|
+
kind: "query",
|
|
811
|
+
value: r.keyword
|
|
812
|
+
},
|
|
813
|
+
rationale: "Rewrite title/description to lift CTR at this position"
|
|
814
|
+
})),
|
|
815
|
+
artifact: res ? {
|
|
816
|
+
analyzer: "opportunity",
|
|
817
|
+
params: { type: "opportunity" }
|
|
818
|
+
} : void 0
|
|
819
|
+
};
|
|
820
|
+
}
|
|
821
|
+
function buildZeroClickSection(res, max) {
|
|
822
|
+
const rows = (res?.results ?? []).sort((a, b) => b.impressions - a.impressions);
|
|
823
|
+
const kept = rows.slice(0, max);
|
|
824
|
+
const findings = kept.map((r) => ({
|
|
825
|
+
entity: {
|
|
826
|
+
kind: "query",
|
|
827
|
+
value: r.query
|
|
828
|
+
},
|
|
829
|
+
metrics: {
|
|
830
|
+
impressions: r.impressions,
|
|
831
|
+
position: r.position,
|
|
832
|
+
ctr: r.ctr
|
|
833
|
+
},
|
|
834
|
+
why: `0 clicks on ${r.page}`
|
|
835
|
+
}));
|
|
836
|
+
return {
|
|
837
|
+
id: "zero-click",
|
|
838
|
+
title: "Zero-click queries",
|
|
839
|
+
severity: "info",
|
|
840
|
+
summary: { magnitudeLabel: `${kept.reduce((s, r) => s + r.impressions, 0)} impressions wasted` },
|
|
841
|
+
findings,
|
|
842
|
+
truncated: rows.length > max ? {
|
|
843
|
+
kept: kept.length,
|
|
844
|
+
total: rows.length
|
|
845
|
+
} : void 0,
|
|
846
|
+
coverage: res ? "full" : "partial",
|
|
847
|
+
actions: [],
|
|
848
|
+
artifact: res ? {
|
|
849
|
+
analyzer: "zero-click",
|
|
850
|
+
params: { type: "zero-click" }
|
|
851
|
+
} : void 0
|
|
852
|
+
};
|
|
853
|
+
}
|
|
854
|
+
function buildMigrationSection$1(res, max) {
|
|
855
|
+
const rows = (res?.results ?? []).sort((a, b) => b.weight - a.weight);
|
|
856
|
+
const kept = rows.slice(0, max);
|
|
857
|
+
return {
|
|
858
|
+
id: "query-migration",
|
|
859
|
+
title: "Query migration",
|
|
860
|
+
severity: "info",
|
|
861
|
+
summary: {},
|
|
862
|
+
findings: kept.map((r) => ({
|
|
863
|
+
entity: {
|
|
864
|
+
kind: "page",
|
|
865
|
+
value: r.targetPage
|
|
866
|
+
},
|
|
867
|
+
metrics: {
|
|
868
|
+
weight: r.weight,
|
|
869
|
+
queryCount: r.queryCount
|
|
870
|
+
},
|
|
871
|
+
why: `migrating from ${r.sourcePage}`
|
|
872
|
+
})),
|
|
873
|
+
truncated: rows.length > max ? {
|
|
874
|
+
kept: kept.length,
|
|
875
|
+
total: rows.length
|
|
876
|
+
} : void 0,
|
|
877
|
+
coverage: res ? "full" : "partial",
|
|
878
|
+
actions: [],
|
|
879
|
+
artifact: res ? {
|
|
880
|
+
analyzer: "query-migration",
|
|
881
|
+
params: { type: "query-migration" }
|
|
882
|
+
} : void 0
|
|
883
|
+
};
|
|
884
|
+
}
|
|
885
|
+
const DEFAULT_MAX$2 = 10;
|
|
886
|
+
const prePublishReport = defineReport({
|
|
887
|
+
id: "pre-publish",
|
|
888
|
+
description: "Pre-publish guard: cannibalization risk and striking-distance peers for a candidate topic or URL.",
|
|
889
|
+
defaultPeriod: "last-90d",
|
|
890
|
+
defaultComparison: "none",
|
|
891
|
+
argsSpec: {
|
|
892
|
+
"topic": {
|
|
893
|
+
type: "string",
|
|
894
|
+
description: "Topic / keyword / URL slug to check",
|
|
895
|
+
required: true
|
|
896
|
+
},
|
|
897
|
+
"max-findings": {
|
|
898
|
+
type: "number",
|
|
899
|
+
description: "Cap findings per section",
|
|
900
|
+
default: DEFAULT_MAX$2
|
|
901
|
+
}
|
|
902
|
+
},
|
|
903
|
+
plan: (params, window) => {
|
|
904
|
+
if (!params.topic) throw new Error("pre-publish report requires --topic <topic-or-url>");
|
|
905
|
+
const dates = {
|
|
906
|
+
startDate: window.start,
|
|
907
|
+
endDate: window.end
|
|
908
|
+
};
|
|
909
|
+
return [{
|
|
910
|
+
key: "cannibalization",
|
|
911
|
+
type: "cannibalization",
|
|
912
|
+
params: {
|
|
913
|
+
...dates,
|
|
914
|
+
limit: 200
|
|
915
|
+
}
|
|
916
|
+
}, {
|
|
917
|
+
key: "striking",
|
|
918
|
+
type: "striking-distance",
|
|
919
|
+
params: {
|
|
920
|
+
...dates,
|
|
921
|
+
limit: 200
|
|
922
|
+
}
|
|
923
|
+
}];
|
|
924
|
+
},
|
|
925
|
+
reduce: (results, ctx) => {
|
|
926
|
+
const topic = (ctx.params.topic ?? "").trim().toLowerCase();
|
|
927
|
+
const max = ctx.params.maxFindings ?? DEFAULT_MAX$2;
|
|
928
|
+
const matches = (val) => !!val && val.toLowerCase().includes(topic);
|
|
929
|
+
return { sections: [buildCannibalizationSection$1(results.cannibalization, matches, max), buildStrikingPeersSection(results.striking, matches, max, topic)] };
|
|
930
|
+
}
|
|
931
|
+
});
|
|
932
|
+
function buildCannibalizationSection$1(res, matches, max) {
|
|
933
|
+
const rows = (res?.results ?? []).filter((r) => matches(r.keyword) || (r.competitors ?? []).some((p) => matches(p.url))).sort((a, b) => b.totalClicks - a.totalClicks);
|
|
934
|
+
const kept = rows.slice(0, max);
|
|
935
|
+
const findings = kept.map((r) => {
|
|
936
|
+
const pageCount = r.competitorCount ?? r.competitors?.length ?? 0;
|
|
937
|
+
return {
|
|
938
|
+
entity: {
|
|
939
|
+
kind: "query",
|
|
940
|
+
value: r.keyword
|
|
941
|
+
},
|
|
942
|
+
metrics: {
|
|
943
|
+
pages: pageCount,
|
|
944
|
+
totalClicks: r.totalClicks,
|
|
945
|
+
totalImpressions: r.totalImpressions
|
|
946
|
+
},
|
|
947
|
+
why: `${pageCount} page(s) already targeting`
|
|
948
|
+
};
|
|
949
|
+
});
|
|
950
|
+
return {
|
|
951
|
+
id: "cannibalization-risk",
|
|
952
|
+
title: "Cannibalization risk",
|
|
953
|
+
severity: kept.length ? "high" : "info",
|
|
954
|
+
summary: { magnitudeLabel: kept.length ? `${kept.length} existing competition` : "no existing competition" },
|
|
955
|
+
findings,
|
|
956
|
+
truncated: rows.length > max ? {
|
|
957
|
+
kept: kept.length,
|
|
958
|
+
total: rows.length
|
|
959
|
+
} : void 0,
|
|
960
|
+
coverage: res ? "full" : "partial",
|
|
961
|
+
actions: kept.slice(0, 1).map(() => ({
|
|
962
|
+
kind: "fix",
|
|
963
|
+
rationale: "Decide before publishing: redirect existing page, target a different angle, or accept overlap."
|
|
964
|
+
})),
|
|
965
|
+
artifact: res ? {
|
|
966
|
+
analyzer: "cannibalization",
|
|
967
|
+
params: { type: "cannibalization" }
|
|
968
|
+
} : void 0
|
|
969
|
+
};
|
|
970
|
+
}
|
|
971
|
+
function buildStrikingPeersSection(res, matches, max, topic) {
|
|
972
|
+
const rows = (res?.results ?? []).filter((r) => matches(r.keyword) || matches(r.page)).sort((a, b) => b.potentialClicks - a.potentialClicks);
|
|
973
|
+
const kept = rows.slice(0, max);
|
|
974
|
+
const findings = kept.map((r) => ({
|
|
975
|
+
entity: {
|
|
976
|
+
kind: "query",
|
|
977
|
+
value: r.keyword
|
|
978
|
+
},
|
|
979
|
+
metrics: {
|
|
980
|
+
position: r.position,
|
|
981
|
+
impressions: r.impressions,
|
|
982
|
+
potentialClicks: r.potentialClicks
|
|
983
|
+
},
|
|
984
|
+
why: r.page ? `currently on ${r.page}` : void 0
|
|
985
|
+
}));
|
|
986
|
+
return {
|
|
987
|
+
id: "striking-peers",
|
|
988
|
+
title: "Existing striking-distance peers",
|
|
989
|
+
severity: kept.length ? "low" : "info",
|
|
990
|
+
summary: { magnitudeLabel: kept.length ? `${kept.length} adjacent rankings for "${topic}"` : "no adjacent rankings" },
|
|
991
|
+
findings,
|
|
992
|
+
truncated: rows.length > max ? {
|
|
993
|
+
kept: kept.length,
|
|
994
|
+
total: rows.length
|
|
995
|
+
} : void 0,
|
|
996
|
+
coverage: res ? "full" : "partial",
|
|
997
|
+
actions: [],
|
|
998
|
+
artifact: res ? {
|
|
999
|
+
analyzer: "striking-distance",
|
|
1000
|
+
params: { type: "striking-distance" }
|
|
1001
|
+
} : void 0
|
|
1002
|
+
};
|
|
1003
|
+
}
|
|
1004
|
+
function clamp01(value) {
|
|
1005
|
+
if (value < 0) return 0;
|
|
1006
|
+
if (value > 1) return 1;
|
|
1007
|
+
return value;
|
|
1008
|
+
}
|
|
1009
|
+
function clamp(value, min, max) {
|
|
1010
|
+
if (value < min) return min;
|
|
1011
|
+
if (value > max) return max;
|
|
1012
|
+
return value;
|
|
1013
|
+
}
|
|
1014
|
+
const DEFAULT_PRIORITY_SOURCES = [
|
|
1015
|
+
"striking-distance",
|
|
1016
|
+
"opportunity",
|
|
1017
|
+
"cannibalization",
|
|
1018
|
+
"ctr-anomaly",
|
|
1019
|
+
"change-point"
|
|
1020
|
+
];
|
|
1021
|
+
const EFFORT_BY_SOURCE = {
|
|
1022
|
+
"striking-distance": "low",
|
|
1023
|
+
"opportunity": "low",
|
|
1024
|
+
"cannibalization": "medium",
|
|
1025
|
+
"ctr-anomaly": "high",
|
|
1026
|
+
"change-point": "high"
|
|
1027
|
+
};
|
|
1028
|
+
const EFFORT_MULTIPLIER = {
|
|
1029
|
+
low: 1.3,
|
|
1030
|
+
medium: 1,
|
|
1031
|
+
high: .7
|
|
1032
|
+
};
|
|
1033
|
+
const EFFORT_RANK = {
|
|
1034
|
+
low: 0,
|
|
1035
|
+
medium: 1,
|
|
1036
|
+
high: 2
|
|
1037
|
+
};
|
|
1038
|
+
function idKey(keyword, page) {
|
|
1039
|
+
return `${keyword.toLowerCase()}|${page.toLowerCase()}`;
|
|
1040
|
+
}
|
|
1041
|
+
function truncate(s, n) {
|
|
1042
|
+
return s.length <= n ? s : `${s.slice(0, n - 1)}...`;
|
|
1043
|
+
}
|
|
1044
|
+
function buildAction(spec) {
|
|
1045
|
+
return {
|
|
1046
|
+
id: idKey(spec.keyword, spec.page),
|
|
1047
|
+
title: spec.title,
|
|
1048
|
+
keyword: spec.keyword,
|
|
1049
|
+
page: spec.page,
|
|
1050
|
+
sources: [spec.source],
|
|
1051
|
+
severity: spec.severity,
|
|
1052
|
+
impressions: spec.impressions,
|
|
1053
|
+
impact: spec.impact,
|
|
1054
|
+
effort: EFFORT_BY_SOURCE[spec.source],
|
|
1055
|
+
why: spec.why,
|
|
1056
|
+
priorityScore: 0,
|
|
1057
|
+
data: { [spec.source]: spec.data }
|
|
1058
|
+
};
|
|
1059
|
+
}
|
|
1060
|
+
function fromStrikingDistance(rows) {
|
|
1061
|
+
const out = [];
|
|
1062
|
+
for (const r of rows) {
|
|
1063
|
+
if (r.page == null) continue;
|
|
1064
|
+
const impact = Math.max(0, r.potentialClicks);
|
|
1065
|
+
if (impact <= 0) continue;
|
|
1066
|
+
const posScore = clamp01((20 - r.position) / 16);
|
|
1067
|
+
const imprScore = Math.min(1, r.impressions / 5e3);
|
|
1068
|
+
out.push(buildAction({
|
|
1069
|
+
source: "striking-distance",
|
|
1070
|
+
keyword: r.keyword,
|
|
1071
|
+
page: r.page,
|
|
1072
|
+
title: `Push "${truncate(r.keyword, 40)}" onto page 1`,
|
|
1073
|
+
why: `Ranks #${r.position.toFixed(1)} with ${Math.round(r.impressions)} impressions; small gains unlock page-1 clicks.`,
|
|
1074
|
+
severity: Math.round(100 * Math.sqrt(posScore * imprScore)),
|
|
1075
|
+
impressions: r.impressions,
|
|
1076
|
+
impact,
|
|
1077
|
+
data: r
|
|
1078
|
+
}));
|
|
1079
|
+
}
|
|
1080
|
+
return out;
|
|
1081
|
+
}
|
|
1082
|
+
function fromOpportunity(rows) {
|
|
1083
|
+
const out = [];
|
|
1084
|
+
for (const r of rows) {
|
|
1085
|
+
if (r.page == null) continue;
|
|
1086
|
+
const impact = Math.max(0, r.potentialClicks);
|
|
1087
|
+
if (impact <= 0) continue;
|
|
1088
|
+
out.push(buildAction({
|
|
1089
|
+
source: "opportunity",
|
|
1090
|
+
keyword: r.keyword,
|
|
1091
|
+
page: r.page,
|
|
1092
|
+
title: `Improve on-page for "${truncate(r.keyword, 40)}"`,
|
|
1093
|
+
why: `Opportunity score ${Math.round(r.opportunityScore)}; CTR ${(r.ctr * 100).toFixed(1)}% vs expected at pos ${r.position.toFixed(1)}.`,
|
|
1094
|
+
severity: Math.round(r.opportunityScore),
|
|
1095
|
+
impressions: r.impressions,
|
|
1096
|
+
impact,
|
|
1097
|
+
data: r
|
|
1098
|
+
}));
|
|
1099
|
+
}
|
|
1100
|
+
return out;
|
|
1101
|
+
}
|
|
1102
|
+
function fromCannibalization(events) {
|
|
1103
|
+
const out = [];
|
|
1104
|
+
for (const ev of events) {
|
|
1105
|
+
if (ev.severity < 30) continue;
|
|
1106
|
+
out.push(buildAction({
|
|
1107
|
+
source: "cannibalization",
|
|
1108
|
+
keyword: ev.keyword,
|
|
1109
|
+
page: ev.leaderUrl,
|
|
1110
|
+
title: `Consolidate cannibalization on "${truncate(ev.keyword, 36)}"`,
|
|
1111
|
+
why: `${ev.competitorCount} URLs split ${Math.round(ev.totalImpressions)} impressions; leader loses ~${Math.round(ev.stolenClicks)} clicks to siblings.`,
|
|
1112
|
+
severity: Math.round(ev.severity),
|
|
1113
|
+
impressions: ev.totalImpressions,
|
|
1114
|
+
impact: Math.max(0, ev.stolenClicks),
|
|
1115
|
+
data: ev
|
|
1116
|
+
}));
|
|
1117
|
+
}
|
|
1118
|
+
return out;
|
|
1119
|
+
}
|
|
1120
|
+
function fromCtrAnomaly(rows) {
|
|
1121
|
+
const out = [];
|
|
1122
|
+
let maxRaw = 0;
|
|
1123
|
+
for (const r of rows) if (r.severity > maxRaw) maxRaw = r.severity;
|
|
1124
|
+
for (const r of rows) {
|
|
1125
|
+
const impact = Math.max(0, r.clicksLost);
|
|
1126
|
+
if (impact <= 0) continue;
|
|
1127
|
+
out.push(buildAction({
|
|
1128
|
+
source: "ctr-anomaly",
|
|
1129
|
+
keyword: r.keyword,
|
|
1130
|
+
page: r.page,
|
|
1131
|
+
title: `Lift CTR on "${truncate(r.keyword, 36)}"`,
|
|
1132
|
+
why: `CTR collapsed ${r.breachDaysDown} days at flat position; ~${Math.round(r.clicksLost)} clicks lost vs baseline ${(r.baselineCtr * 100).toFixed(1)}%.`,
|
|
1133
|
+
severity: maxRaw > 0 ? Math.round(r.severity / maxRaw * 100) : 0,
|
|
1134
|
+
impressions: r.totalImpressions,
|
|
1135
|
+
impact,
|
|
1136
|
+
data: r
|
|
1137
|
+
}));
|
|
1138
|
+
}
|
|
1139
|
+
return out;
|
|
1140
|
+
}
|
|
1141
|
+
function fromChangePoint(rows) {
|
|
1142
|
+
const out = [];
|
|
1143
|
+
for (const r of rows) {
|
|
1144
|
+
if (r.direction !== "worsened") continue;
|
|
1145
|
+
const days = Math.max(1, r.totalDays / 2);
|
|
1146
|
+
const impact = Math.abs(r.leftMean - r.rightMean) * days;
|
|
1147
|
+
if (impact <= 0) continue;
|
|
1148
|
+
out.push(buildAction({
|
|
1149
|
+
source: "change-point",
|
|
1150
|
+
keyword: r.keyword,
|
|
1151
|
+
page: r.page,
|
|
1152
|
+
title: `Diagnose drop on "${truncate(r.keyword, 34)}"`,
|
|
1153
|
+
why: `Significant regression around ${r.changeDate} (${r.leftMean.toFixed(1)} -> ${r.rightMean.toFixed(1)}, LLR ${r.llr.toFixed(0)}).`,
|
|
1154
|
+
severity: clamp(Math.round(Math.log10(Math.max(10, r.llr)) / 3 * 100), 0, 100),
|
|
1155
|
+
impressions: r.totalImpressions,
|
|
1156
|
+
impact,
|
|
1157
|
+
data: r
|
|
1158
|
+
}));
|
|
1159
|
+
}
|
|
1160
|
+
return out;
|
|
1161
|
+
}
|
|
1162
|
+
function normalizePriorityActions(source, result) {
|
|
1163
|
+
const rows = result.results;
|
|
1164
|
+
if (source === "striking-distance") return fromStrikingDistance(rows);
|
|
1165
|
+
if (source === "opportunity") return fromOpportunity(rows);
|
|
1166
|
+
if (source === "cannibalization") return fromCannibalization(rows);
|
|
1167
|
+
if (source === "ctr-anomaly") return fromCtrAnomaly(rows);
|
|
1168
|
+
return fromChangePoint(rows);
|
|
1169
|
+
}
|
|
1170
|
+
function mergePriorityActions(all) {
|
|
1171
|
+
const byId = /* @__PURE__ */ new Map();
|
|
1172
|
+
for (const a of all) {
|
|
1173
|
+
const existing = byId.get(a.id);
|
|
1174
|
+
if (existing == null) {
|
|
1175
|
+
byId.set(a.id, {
|
|
1176
|
+
...a,
|
|
1177
|
+
sources: [...a.sources],
|
|
1178
|
+
data: { ...a.data }
|
|
1179
|
+
});
|
|
1180
|
+
continue;
|
|
1181
|
+
}
|
|
1182
|
+
const mergedSources = [...new Set([...existing.sources, ...a.sources])];
|
|
1183
|
+
const preferNew = a.severity > existing.severity;
|
|
1184
|
+
const mergedEffort = EFFORT_RANK[a.effort] < EFFORT_RANK[existing.effort] ? a.effort : existing.effort;
|
|
1185
|
+
byId.set(a.id, {
|
|
1186
|
+
id: existing.id,
|
|
1187
|
+
title: preferNew ? a.title : existing.title,
|
|
1188
|
+
keyword: existing.keyword,
|
|
1189
|
+
page: existing.page,
|
|
1190
|
+
sources: mergedSources,
|
|
1191
|
+
severity: Math.max(existing.severity, a.severity),
|
|
1192
|
+
impressions: Math.max(existing.impressions, a.impressions),
|
|
1193
|
+
impact: existing.impact + a.impact,
|
|
1194
|
+
why: preferNew ? a.why : existing.why,
|
|
1195
|
+
effort: mergedEffort,
|
|
1196
|
+
priorityScore: 0,
|
|
1197
|
+
data: {
|
|
1198
|
+
...existing.data,
|
|
1199
|
+
...a.data
|
|
1200
|
+
}
|
|
1201
|
+
});
|
|
1202
|
+
}
|
|
1203
|
+
return [...byId.values()];
|
|
1204
|
+
}
|
|
1205
|
+
function scorePriorityActions(actions) {
|
|
1206
|
+
for (const a of actions) {
|
|
1207
|
+
const mult = EFFORT_MULTIPLIER[a.effort];
|
|
1208
|
+
a.priorityScore = a.impact * (1 + a.severity / 100) * mult;
|
|
1209
|
+
}
|
|
1210
|
+
actions.sort((a, b) => b.priorityScore - a.priorityScore);
|
|
1211
|
+
return actions;
|
|
1212
|
+
}
|
|
1213
|
+
const DEFAULT_LIMIT = 40;
|
|
1214
|
+
const DEFAULT_ACTIONS = 10;
|
|
1215
|
+
const priorityReport = defineReport({
|
|
1216
|
+
id: "priority",
|
|
1217
|
+
description: "Ranked priority actions composed from striking-distance, opportunity, cannibalization, ctr-anomaly, and change-point signals.",
|
|
1218
|
+
defaultPeriod: "last-28d",
|
|
1219
|
+
defaultComparison: "prev-period",
|
|
1220
|
+
argsSpec: {
|
|
1221
|
+
"limit": {
|
|
1222
|
+
type: "number",
|
|
1223
|
+
description: "Max actions in the section",
|
|
1224
|
+
default: DEFAULT_LIMIT
|
|
1225
|
+
},
|
|
1226
|
+
"max-actions": {
|
|
1227
|
+
type: "number",
|
|
1228
|
+
description: "Top-N rendered as ReportAction",
|
|
1229
|
+
default: DEFAULT_ACTIONS
|
|
1230
|
+
}
|
|
1231
|
+
},
|
|
1232
|
+
plan: (_params, window) => {
|
|
1233
|
+
const dates = {
|
|
1234
|
+
startDate: window.start,
|
|
1235
|
+
endDate: window.end
|
|
1236
|
+
};
|
|
1237
|
+
const cmp = window.comparison ? {
|
|
1238
|
+
prevStartDate: window.comparison.start,
|
|
1239
|
+
prevEndDate: window.comparison.end
|
|
1240
|
+
} : {};
|
|
1241
|
+
return DEFAULT_PRIORITY_SOURCES.map((source) => ({
|
|
1242
|
+
key: source,
|
|
1243
|
+
type: source,
|
|
1244
|
+
params: {
|
|
1245
|
+
...dates,
|
|
1246
|
+
...cmp,
|
|
1247
|
+
limit: 100
|
|
1248
|
+
},
|
|
1249
|
+
required: false
|
|
1250
|
+
}));
|
|
1251
|
+
},
|
|
1252
|
+
reduce: (results, ctx) => {
|
|
1253
|
+
const limit = ctx.params.limit ?? DEFAULT_LIMIT;
|
|
1254
|
+
const maxActions = ctx.params.maxActions ?? DEFAULT_ACTIONS;
|
|
1255
|
+
const all = [];
|
|
1256
|
+
let anyMissing = false;
|
|
1257
|
+
for (const source of DEFAULT_PRIORITY_SOURCES) {
|
|
1258
|
+
const r = results[source];
|
|
1259
|
+
if (!r) {
|
|
1260
|
+
anyMissing = true;
|
|
1261
|
+
continue;
|
|
1262
|
+
}
|
|
1263
|
+
all.push(...normalizePriorityActions(source, r));
|
|
1264
|
+
}
|
|
1265
|
+
const ranked = scorePriorityActions(mergePriorityActions(all)).slice(0, limit);
|
|
1266
|
+
const findings = ranked.map((a) => ({
|
|
1267
|
+
entity: {
|
|
1268
|
+
kind: "query",
|
|
1269
|
+
value: a.keyword
|
|
1270
|
+
},
|
|
1271
|
+
metrics: {
|
|
1272
|
+
priorityScore: a.priorityScore,
|
|
1273
|
+
severity: a.severity,
|
|
1274
|
+
impact: a.impact,
|
|
1275
|
+
impressions: a.impressions
|
|
1276
|
+
},
|
|
1277
|
+
why: `${a.title} — ${a.why} (page: ${a.page})`
|
|
1278
|
+
}));
|
|
1279
|
+
const actions = ranked.slice(0, maxActions).map((a) => {
|
|
1280
|
+
const primarySource = a.sources[0];
|
|
1281
|
+
return {
|
|
1282
|
+
kind: primarySource === "cannibalization" ? "fix" : "analyzer",
|
|
1283
|
+
target: {
|
|
1284
|
+
kind: "page",
|
|
1285
|
+
value: a.page
|
|
1286
|
+
},
|
|
1287
|
+
params: { type: primarySource },
|
|
1288
|
+
rationale: a.title
|
|
1289
|
+
};
|
|
1290
|
+
});
|
|
1291
|
+
const topSeverity = ranked[0]?.severity ?? 0;
|
|
1292
|
+
return { sections: [{
|
|
1293
|
+
id: "priority",
|
|
1294
|
+
title: "Priority actions",
|
|
1295
|
+
severity: topSeverity >= 70 ? "high" : topSeverity >= 40 ? "medium" : ranked.length ? "low" : "info",
|
|
1296
|
+
summary: { magnitudeLabel: `${ranked.length} actions ranked` },
|
|
1297
|
+
findings,
|
|
1298
|
+
truncated: all.length > ranked.length ? {
|
|
1299
|
+
kept: ranked.length,
|
|
1300
|
+
total: all.length
|
|
1301
|
+
} : void 0,
|
|
1302
|
+
coverage: anyMissing ? "partial" : "full",
|
|
1303
|
+
actions
|
|
1304
|
+
}] };
|
|
1305
|
+
}
|
|
1306
|
+
});
|
|
1307
|
+
const DEFAULT_MAX$1 = 5;
|
|
1308
|
+
const risksReport = defineReport({
|
|
1309
|
+
id: "risks",
|
|
1310
|
+
description: "Decay, cannibalization, dark-traffic and device-gap risks vs prior period.",
|
|
1311
|
+
defaultPeriod: "last-28d",
|
|
1312
|
+
defaultComparison: "prev-period",
|
|
1313
|
+
argsSpec: { "max-findings": {
|
|
1314
|
+
type: "number",
|
|
1315
|
+
description: "Cap findings per section",
|
|
1316
|
+
default: DEFAULT_MAX$1
|
|
1317
|
+
} },
|
|
1318
|
+
plan: (_params, window) => {
|
|
1319
|
+
if (!window.comparison) throw new Error("risks report requires a comparison window — pass --vs prev-period");
|
|
1320
|
+
const dates = {
|
|
1321
|
+
startDate: window.start,
|
|
1322
|
+
endDate: window.end
|
|
1323
|
+
};
|
|
1324
|
+
const prev = {
|
|
1325
|
+
prevStartDate: window.comparison.start,
|
|
1326
|
+
prevEndDate: window.comparison.end
|
|
1327
|
+
};
|
|
1328
|
+
return [
|
|
1329
|
+
{
|
|
1330
|
+
key: "decay",
|
|
1331
|
+
type: "decay",
|
|
1332
|
+
params: {
|
|
1333
|
+
...dates,
|
|
1334
|
+
...prev,
|
|
1335
|
+
limit: 100
|
|
1336
|
+
},
|
|
1337
|
+
required: true
|
|
1338
|
+
},
|
|
1339
|
+
{
|
|
1340
|
+
key: "cannibalization",
|
|
1341
|
+
type: "cannibalization",
|
|
1342
|
+
params: {
|
|
1343
|
+
...dates,
|
|
1344
|
+
limit: 50
|
|
1345
|
+
}
|
|
1346
|
+
},
|
|
1347
|
+
{
|
|
1348
|
+
key: "dark-traffic",
|
|
1349
|
+
type: "dark-traffic",
|
|
1350
|
+
params: {
|
|
1351
|
+
...dates,
|
|
1352
|
+
limit: 50
|
|
1353
|
+
}
|
|
1354
|
+
},
|
|
1355
|
+
{
|
|
1356
|
+
key: "device-gap",
|
|
1357
|
+
type: "device-gap",
|
|
1358
|
+
params: {
|
|
1359
|
+
...dates,
|
|
1360
|
+
limit: 50
|
|
1361
|
+
}
|
|
1362
|
+
}
|
|
1363
|
+
];
|
|
1364
|
+
},
|
|
1365
|
+
reduce: (results, ctx) => {
|
|
1366
|
+
const max = ctx.params.maxFindings ?? DEFAULT_MAX$1;
|
|
1367
|
+
return { sections: [
|
|
1368
|
+
buildDecaySection(results.decay, max),
|
|
1369
|
+
buildCannibalizationSection(results.cannibalization, max),
|
|
1370
|
+
buildDarkTrafficSection(results["dark-traffic"], max),
|
|
1371
|
+
buildDeviceGapSection(results["device-gap"], max)
|
|
1372
|
+
] };
|
|
1373
|
+
}
|
|
1374
|
+
});
|
|
1375
|
+
function buildDecaySection(res, max) {
|
|
1376
|
+
const rows = (res?.results ?? []).sort((a, b) => b.lostClicks - a.lostClicks);
|
|
1377
|
+
const kept = rows.slice(0, max);
|
|
1378
|
+
const totalLost = kept.reduce((s, r) => s + r.lostClicks, 0);
|
|
1379
|
+
const findings = kept.map((r) => ({
|
|
1380
|
+
entity: {
|
|
1381
|
+
kind: "page",
|
|
1382
|
+
value: r.page
|
|
1383
|
+
},
|
|
1384
|
+
metrics: {
|
|
1385
|
+
lostClicks: r.lostClicks,
|
|
1386
|
+
currentClicks: r.currentClicks,
|
|
1387
|
+
declinePercent: r.declinePercent
|
|
1388
|
+
},
|
|
1389
|
+
delta: {
|
|
1390
|
+
metric: "clicks",
|
|
1391
|
+
prior: r.previousClicks,
|
|
1392
|
+
current: r.currentClicks,
|
|
1393
|
+
pct: -r.declinePercent * 100
|
|
1394
|
+
}
|
|
1395
|
+
}));
|
|
1396
|
+
return {
|
|
1397
|
+
id: "decay",
|
|
1398
|
+
title: "Decaying pages",
|
|
1399
|
+
severity: totalLost >= 200 ? "high" : totalLost >= 50 ? "medium" : "low",
|
|
1400
|
+
summary: {
|
|
1401
|
+
delta: -totalLost,
|
|
1402
|
+
direction: totalLost > 0 ? "down" : "flat",
|
|
1403
|
+
magnitudeLabel: `${Math.round(totalLost)} clicks lost`
|
|
1404
|
+
},
|
|
1405
|
+
findings,
|
|
1406
|
+
truncated: rows.length > max ? {
|
|
1407
|
+
kept: kept.length,
|
|
1408
|
+
total: rows.length
|
|
1409
|
+
} : void 0,
|
|
1410
|
+
coverage: res ? "full" : "partial",
|
|
1411
|
+
actions: kept.slice(0, 1).map((r) => ({
|
|
1412
|
+
kind: "analyzer",
|
|
1413
|
+
target: {
|
|
1414
|
+
kind: "page",
|
|
1415
|
+
value: r.page
|
|
1416
|
+
},
|
|
1417
|
+
params: { type: "change-point" },
|
|
1418
|
+
rationale: "Investigate change-point on the worst-affected page"
|
|
1419
|
+
})),
|
|
1420
|
+
artifact: res ? {
|
|
1421
|
+
analyzer: "decay",
|
|
1422
|
+
params: { type: "decay" }
|
|
1423
|
+
} : void 0
|
|
1424
|
+
};
|
|
1425
|
+
}
|
|
1426
|
+
function buildCannibalizationSection(res, max) {
|
|
1427
|
+
const rows = (res?.results ?? []).sort((a, b) => b.totalClicks - a.totalClicks);
|
|
1428
|
+
const kept = rows.slice(0, max);
|
|
1429
|
+
const findings = kept.map((r) => {
|
|
1430
|
+
const pageCount = r.competitorCount ?? r.competitors?.length ?? 0;
|
|
1431
|
+
return {
|
|
1432
|
+
entity: {
|
|
1433
|
+
kind: "query",
|
|
1434
|
+
value: r.keyword
|
|
1435
|
+
},
|
|
1436
|
+
metrics: {
|
|
1437
|
+
pages: pageCount,
|
|
1438
|
+
totalClicks: r.totalClicks,
|
|
1439
|
+
totalImpressions: r.totalImpressions
|
|
1440
|
+
},
|
|
1441
|
+
why: `${pageCount} pages competing`
|
|
1442
|
+
};
|
|
1443
|
+
});
|
|
1444
|
+
return {
|
|
1445
|
+
id: "cannibalization",
|
|
1446
|
+
title: "Cannibalizing queries",
|
|
1447
|
+
severity: kept.length ? "medium" : "info",
|
|
1448
|
+
summary: {},
|
|
1449
|
+
findings,
|
|
1450
|
+
truncated: rows.length > max ? {
|
|
1451
|
+
kept: kept.length,
|
|
1452
|
+
total: rows.length
|
|
1453
|
+
} : void 0,
|
|
1454
|
+
coverage: res ? "full" : "partial",
|
|
1455
|
+
actions: [],
|
|
1456
|
+
artifact: res ? {
|
|
1457
|
+
analyzer: "cannibalization",
|
|
1458
|
+
params: { type: "cannibalization" }
|
|
1459
|
+
} : void 0
|
|
1460
|
+
};
|
|
1461
|
+
}
|
|
1462
|
+
function buildDarkTrafficSection(res, max) {
|
|
1463
|
+
const rows = (res?.results ?? []).sort((a, b) => b.darkClicks - a.darkClicks);
|
|
1464
|
+
const kept = rows.slice(0, max);
|
|
1465
|
+
const totalDark = kept.reduce((s, r) => s + r.darkClicks, 0);
|
|
1466
|
+
const findings = kept.map((r) => ({
|
|
1467
|
+
entity: {
|
|
1468
|
+
kind: "page",
|
|
1469
|
+
value: r.url
|
|
1470
|
+
},
|
|
1471
|
+
metrics: {
|
|
1472
|
+
darkClicks: r.darkClicks,
|
|
1473
|
+
darkPercent: r.darkPercent,
|
|
1474
|
+
totalClicks: r.totalClicks
|
|
1475
|
+
}
|
|
1476
|
+
}));
|
|
1477
|
+
return {
|
|
1478
|
+
id: "dark-traffic",
|
|
1479
|
+
title: "Dark traffic",
|
|
1480
|
+
severity: kept.length ? "low" : "info",
|
|
1481
|
+
summary: { magnitudeLabel: `${Math.round(totalDark)} unattributed clicks` },
|
|
1482
|
+
findings,
|
|
1483
|
+
truncated: rows.length > max ? {
|
|
1484
|
+
kept: kept.length,
|
|
1485
|
+
total: rows.length
|
|
1486
|
+
} : void 0,
|
|
1487
|
+
coverage: res ? "full" : "partial",
|
|
1488
|
+
actions: [],
|
|
1489
|
+
artifact: res ? {
|
|
1490
|
+
analyzer: "dark-traffic",
|
|
1491
|
+
params: { type: "dark-traffic" }
|
|
1492
|
+
} : void 0
|
|
1493
|
+
};
|
|
1494
|
+
}
|
|
1495
|
+
function buildDeviceGapSection(res, max) {
|
|
1496
|
+
const rows = (res?.results ?? []).sort((a, b) => Math.abs(b.gaps.positionGap) - Math.abs(a.gaps.positionGap));
|
|
1497
|
+
const kept = rows.slice(0, max);
|
|
1498
|
+
return {
|
|
1499
|
+
id: "device-gap",
|
|
1500
|
+
title: "Device gap",
|
|
1501
|
+
severity: "info",
|
|
1502
|
+
summary: {},
|
|
1503
|
+
findings: kept.map((r) => ({
|
|
1504
|
+
entity: {
|
|
1505
|
+
kind: "page",
|
|
1506
|
+
value: r.date
|
|
1507
|
+
},
|
|
1508
|
+
metrics: {
|
|
1509
|
+
ctrGap: r.gaps.ctrGap,
|
|
1510
|
+
positionGap: r.gaps.positionGap,
|
|
1511
|
+
desktopCtr: r.desktop.ctr,
|
|
1512
|
+
mobileCtr: r.mobile.ctr
|
|
1513
|
+
},
|
|
1514
|
+
why: `desktop vs mobile delta`
|
|
1515
|
+
})),
|
|
1516
|
+
truncated: rows.length > max ? {
|
|
1517
|
+
kept: kept.length,
|
|
1518
|
+
total: rows.length
|
|
1519
|
+
} : void 0,
|
|
1520
|
+
coverage: res ? "full" : "partial",
|
|
1521
|
+
actions: [],
|
|
1522
|
+
artifact: res ? {
|
|
1523
|
+
analyzer: "device-gap",
|
|
1524
|
+
params: { type: "device-gap" }
|
|
1525
|
+
} : void 0
|
|
1526
|
+
};
|
|
1527
|
+
}
|
|
1528
|
+
function resolveTarget(opts) {
|
|
1529
|
+
const needle = opts.input.trim();
|
|
1530
|
+
if (!needle) return {
|
|
1531
|
+
exact: null,
|
|
1532
|
+
matches: [],
|
|
1533
|
+
unresolved: true
|
|
1534
|
+
};
|
|
1535
|
+
const candidates = opts.candidates;
|
|
1536
|
+
if (!candidates || candidates.length === 0) return {
|
|
1537
|
+
exact: needle,
|
|
1538
|
+
matches: [needle],
|
|
1539
|
+
unresolved: false
|
|
1540
|
+
};
|
|
1541
|
+
const lower = needle.toLowerCase();
|
|
1542
|
+
let exact = null;
|
|
1543
|
+
const matches = [];
|
|
1544
|
+
for (const c of candidates) {
|
|
1545
|
+
const cl = c.toLowerCase();
|
|
1546
|
+
if (cl === lower) {
|
|
1547
|
+
exact = c;
|
|
1548
|
+
if (!matches.includes(c)) matches.unshift(c);
|
|
1549
|
+
continue;
|
|
1550
|
+
}
|
|
1551
|
+
if (cl.includes(lower)) matches.push(c);
|
|
1552
|
+
}
|
|
1553
|
+
return {
|
|
1554
|
+
exact,
|
|
1555
|
+
matches,
|
|
1556
|
+
unresolved: matches.length === 0
|
|
1557
|
+
};
|
|
1558
|
+
}
|
|
1559
|
+
const DEFAULT_MAX = 10;
|
|
1560
|
+
const triageReport = defineReport({
|
|
1561
|
+
id: "triage",
|
|
1562
|
+
description: "Focused investigation: change-points, query migration, and position volatility scoped to one page or query.",
|
|
1563
|
+
defaultPeriod: "last-90d",
|
|
1564
|
+
defaultComparison: "none",
|
|
1565
|
+
argsSpec: {
|
|
1566
|
+
"target": {
|
|
1567
|
+
type: "string",
|
|
1568
|
+
description: "Target page URL or query string",
|
|
1569
|
+
required: true
|
|
1570
|
+
},
|
|
1571
|
+
"target-kind": {
|
|
1572
|
+
type: "string",
|
|
1573
|
+
description: "page | query",
|
|
1574
|
+
default: "page"
|
|
1575
|
+
},
|
|
1576
|
+
"max-findings": {
|
|
1577
|
+
type: "number",
|
|
1578
|
+
description: "Cap findings per section",
|
|
1579
|
+
default: DEFAULT_MAX
|
|
1580
|
+
}
|
|
1581
|
+
},
|
|
1582
|
+
plan: (params, window) => {
|
|
1583
|
+
if (!params.target) throw new Error("triage report requires --target <page-or-query>");
|
|
1584
|
+
const dates = {
|
|
1585
|
+
startDate: window.start,
|
|
1586
|
+
endDate: window.end
|
|
1587
|
+
};
|
|
1588
|
+
return [
|
|
1589
|
+
{
|
|
1590
|
+
key: "change-point",
|
|
1591
|
+
type: "change-point",
|
|
1592
|
+
params: {
|
|
1593
|
+
...dates,
|
|
1594
|
+
limit: 200
|
|
1595
|
+
}
|
|
1596
|
+
},
|
|
1597
|
+
{
|
|
1598
|
+
key: "query-migration",
|
|
1599
|
+
type: "query-migration",
|
|
1600
|
+
params: {
|
|
1601
|
+
...dates,
|
|
1602
|
+
limit: 200
|
|
1603
|
+
}
|
|
1604
|
+
},
|
|
1605
|
+
{
|
|
1606
|
+
key: "position-volatility",
|
|
1607
|
+
type: "position-volatility",
|
|
1608
|
+
params: {
|
|
1609
|
+
...dates,
|
|
1610
|
+
limit: 200
|
|
1611
|
+
}
|
|
1612
|
+
}
|
|
1613
|
+
];
|
|
1614
|
+
},
|
|
1615
|
+
reduce: (results, ctx) => {
|
|
1616
|
+
const target = ctx.params.target;
|
|
1617
|
+
const kind = ctx.params.targetKind ?? "page";
|
|
1618
|
+
const max = ctx.params.maxFindings ?? DEFAULT_MAX;
|
|
1619
|
+
const needle = (resolveTarget({
|
|
1620
|
+
kind,
|
|
1621
|
+
input: target
|
|
1622
|
+
}).exact ?? target).toLowerCase();
|
|
1623
|
+
const matches = (val) => val.toLowerCase().includes(needle);
|
|
1624
|
+
return { sections: [
|
|
1625
|
+
buildChangePointSection(results["change-point"], kind, matches, max),
|
|
1626
|
+
buildMigrationSection(results["query-migration"], kind, matches, max, target),
|
|
1627
|
+
buildVolatilitySection(results["position-volatility"], kind, matches, max)
|
|
1628
|
+
] };
|
|
1629
|
+
}
|
|
1630
|
+
});
|
|
1631
|
+
function buildChangePointSection(res, kind, matches, max) {
|
|
1632
|
+
const rows = (res?.results ?? []).filter((r) => matches(kind === "page" ? r.page : r.keyword)).sort((a, b) => b.llr - a.llr);
|
|
1633
|
+
const kept = rows.slice(0, max);
|
|
1634
|
+
const findings = kept.map((r) => ({
|
|
1635
|
+
entity: {
|
|
1636
|
+
kind: kind === "page" ? "page" : "query",
|
|
1637
|
+
value: kind === "page" ? r.page : r.keyword
|
|
1638
|
+
},
|
|
1639
|
+
metrics: {
|
|
1640
|
+
llr: r.llr,
|
|
1641
|
+
delta: r.delta
|
|
1642
|
+
},
|
|
1643
|
+
why: `${r.direction} on ${r.changeDate}`
|
|
1644
|
+
}));
|
|
1645
|
+
return {
|
|
1646
|
+
id: "change-point",
|
|
1647
|
+
title: "Change-points",
|
|
1648
|
+
severity: kept.length ? "medium" : "info",
|
|
1649
|
+
summary: { magnitudeLabel: `${kept.length} change-point${kept.length === 1 ? "" : "s"}` },
|
|
1650
|
+
findings,
|
|
1651
|
+
truncated: rows.length > max ? {
|
|
1652
|
+
kept: kept.length,
|
|
1653
|
+
total: rows.length
|
|
1654
|
+
} : void 0,
|
|
1655
|
+
coverage: res ? "full" : "partial",
|
|
1656
|
+
actions: [],
|
|
1657
|
+
artifact: res ? {
|
|
1658
|
+
analyzer: "change-point",
|
|
1659
|
+
params: { type: "change-point" }
|
|
1660
|
+
} : void 0
|
|
1661
|
+
};
|
|
1662
|
+
}
|
|
1663
|
+
function buildMigrationSection(res, kind, matches, max, _rawTarget) {
|
|
1664
|
+
const rows = (res?.results ?? []).filter((r) => kind === "page" ? matches(r.sourcePage) || matches(r.targetPage) : false).sort((a, b) => b.weight - a.weight);
|
|
1665
|
+
const kept = rows.slice(0, max);
|
|
1666
|
+
const findings = kept.map((r) => ({
|
|
1667
|
+
entity: {
|
|
1668
|
+
kind: "page",
|
|
1669
|
+
value: r.targetPage
|
|
1670
|
+
},
|
|
1671
|
+
metrics: {
|
|
1672
|
+
weight: r.weight,
|
|
1673
|
+
queryCount: r.queryCount
|
|
1674
|
+
},
|
|
1675
|
+
why: `from ${r.sourcePage}`
|
|
1676
|
+
}));
|
|
1677
|
+
return {
|
|
1678
|
+
id: "query-migration",
|
|
1679
|
+
title: "Query migration",
|
|
1680
|
+
severity: "info",
|
|
1681
|
+
summary: { magnitudeLabel: kind === "page" ? `${kept.length} migration edges touching target` : "N/A for query target" },
|
|
1682
|
+
findings,
|
|
1683
|
+
truncated: rows.length > max ? {
|
|
1684
|
+
kept: kept.length,
|
|
1685
|
+
total: rows.length
|
|
1686
|
+
} : void 0,
|
|
1687
|
+
coverage: res ? "full" : "partial",
|
|
1688
|
+
actions: [],
|
|
1689
|
+
artifact: res ? {
|
|
1690
|
+
analyzer: "query-migration",
|
|
1691
|
+
params: { type: "query-migration" }
|
|
1692
|
+
} : void 0
|
|
1693
|
+
};
|
|
1694
|
+
}
|
|
1695
|
+
function buildVolatilitySection(res, kind, matches, max) {
|
|
1696
|
+
const rows = kind === "page" ? (res?.results ?? []).filter((r) => matches(r.page)) : [];
|
|
1697
|
+
const kept = rows.sort((a, b) => b.peakVolatility - a.peakVolatility).slice(0, max);
|
|
1698
|
+
const findings = kept.map((r) => ({
|
|
1699
|
+
entity: {
|
|
1700
|
+
kind: "page",
|
|
1701
|
+
value: r.page
|
|
1702
|
+
},
|
|
1703
|
+
metrics: {
|
|
1704
|
+
avgVolatility: r.avgVolatility,
|
|
1705
|
+
peakVolatility: r.peakVolatility,
|
|
1706
|
+
impressions: r.totalImpressions
|
|
1707
|
+
}
|
|
1708
|
+
}));
|
|
1709
|
+
return {
|
|
1710
|
+
id: "position-volatility",
|
|
1711
|
+
title: "Position volatility",
|
|
1712
|
+
severity: kept.length ? "low" : "info",
|
|
1713
|
+
summary: { magnitudeLabel: kind === "page" ? `${kept.length} volatile day${kept.length === 1 ? "" : "s"}` : "N/A for query target" },
|
|
1714
|
+
findings,
|
|
1715
|
+
truncated: rows.length > max ? {
|
|
1716
|
+
kept: kept.length,
|
|
1717
|
+
total: rows.length
|
|
1718
|
+
} : void 0,
|
|
1719
|
+
coverage: res ? "full" : "partial",
|
|
1720
|
+
actions: [],
|
|
1721
|
+
artifact: res ? {
|
|
1722
|
+
analyzer: "position-volatility",
|
|
1723
|
+
params: { type: "position-volatility" }
|
|
1724
|
+
} : void 0
|
|
1725
|
+
};
|
|
1726
|
+
}
|
|
1727
|
+
const REPORTS = [
|
|
1728
|
+
brandReport,
|
|
1729
|
+
growthReport,
|
|
1730
|
+
healthReport,
|
|
1731
|
+
moversReport,
|
|
1732
|
+
opportunitiesReport,
|
|
1733
|
+
prePublishReport,
|
|
1734
|
+
priorityReport,
|
|
1735
|
+
risksReport,
|
|
1736
|
+
triageReport
|
|
1737
|
+
];
|
|
1738
|
+
const defaultReportRegistry = createReportRegistry({
|
|
1739
|
+
reports: REPORTS,
|
|
1740
|
+
version: "0"
|
|
1741
|
+
});
|
|
1742
|
+
async function analyzeFromSource(source, params, registry) {
|
|
1743
|
+
return runAnalyzerFromSource(source, params, registry);
|
|
1744
|
+
}
|
|
1745
|
+
async function executeStep(source, analyzers, step) {
|
|
1746
|
+
return analyzeFromSource(source, {
|
|
1747
|
+
...step.params,
|
|
1748
|
+
type: step.type
|
|
1749
|
+
}, analyzers).then((result) => ({
|
|
1750
|
+
state: {
|
|
1751
|
+
key: step.key,
|
|
1752
|
+
type: step.type,
|
|
1753
|
+
status: "done"
|
|
1754
|
+
},
|
|
1755
|
+
result
|
|
1756
|
+
})).catch((err) => {
|
|
1757
|
+
const message = err?.message ?? String(err);
|
|
1758
|
+
return { state: {
|
|
1759
|
+
key: step.key,
|
|
1760
|
+
type: step.type,
|
|
1761
|
+
status: "error",
|
|
1762
|
+
error: message
|
|
1763
|
+
} };
|
|
1764
|
+
});
|
|
1765
|
+
}
|
|
1766
|
+
async function runReport(report, opts) {
|
|
1767
|
+
const startedAt = Date.now();
|
|
1768
|
+
const generatedAt = new Date(startedAt).toISOString();
|
|
1769
|
+
const inputHash = await computeInputHash({
|
|
1770
|
+
id: report.id,
|
|
1771
|
+
site: opts.ctx.site,
|
|
1772
|
+
window: opts.ctx.window,
|
|
1773
|
+
params: opts.ctx.params,
|
|
1774
|
+
registryVersion: opts.ctx.registryVersion
|
|
1775
|
+
});
|
|
1776
|
+
const steps = report.plan(opts.ctx.params, opts.ctx.window);
|
|
1777
|
+
const outcomes = await Promise.all(steps.map((s) => executeStep(opts.source, opts.analyzers, s)));
|
|
1778
|
+
const required = new Map(steps.filter((s) => s.required).map((s) => [s.key, s]));
|
|
1779
|
+
const errored = outcomes.filter((o) => o.state.status === "error");
|
|
1780
|
+
for (const o of errored) if (required.has(o.state.key)) throw new Error(`runReport(${report.id}): required step "${o.state.key}" failed: ${o.state.error}`);
|
|
1781
|
+
const resultsByKey = {};
|
|
1782
|
+
for (const o of outcomes) if (o.result) resultsByKey[o.state.key] = o.result;
|
|
1783
|
+
const sections = report.reduce(resultsByKey, opts.ctx).sections;
|
|
1784
|
+
const degraded = errored.length > 0;
|
|
1785
|
+
const stepStates = outcomes.map((o) => o.state);
|
|
1786
|
+
return {
|
|
1787
|
+
id: report.id,
|
|
1788
|
+
site: opts.ctx.site,
|
|
1789
|
+
inputHash,
|
|
1790
|
+
generatedAt,
|
|
1791
|
+
window: opts.ctx.window,
|
|
1792
|
+
sections,
|
|
1793
|
+
meta: {
|
|
1794
|
+
durationMs: Date.now() - startedAt,
|
|
1795
|
+
rowsScanned: 0,
|
|
1796
|
+
degraded,
|
|
1797
|
+
steps: stepStates
|
|
1798
|
+
}
|
|
1799
|
+
};
|
|
1800
|
+
}
|
|
1801
|
+
async function dryRunReport(report, ctx) {
|
|
1802
|
+
return {
|
|
1803
|
+
steps: report.plan(ctx.params, ctx.window).map((s) => ({
|
|
1804
|
+
key: s.key,
|
|
1805
|
+
type: s.type
|
|
1806
|
+
})),
|
|
1807
|
+
windowResolved: {
|
|
1808
|
+
start: ctx.window.start,
|
|
1809
|
+
end: ctx.window.end,
|
|
1810
|
+
days: ctx.window.days
|
|
1811
|
+
}
|
|
1812
|
+
};
|
|
1813
|
+
}
|
|
1814
|
+
export { REPORTS, defaultReportRegistry, dryRunReport, formatReport, resolveTarget, runReport };
|