@gscdump/analysis 0.8.2 → 0.9.1

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