@gscdump/analysis 1.0.2 → 1.0.3

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