@gscdump/analysis 1.4.0 → 1.4.2

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