@gscdump/analysis 1.4.0 → 1.4.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.
Files changed (99) hide show
  1. package/dist/action-priority.d.mts +34 -0
  2. package/dist/action-priority.mjs +201 -0
  3. package/dist/analyzer/adapt-rows.mjs +12 -0
  4. package/dist/analyzer/all.mjs +61 -0
  5. package/dist/analyzer/paginate.mjs +85 -0
  6. package/dist/analyzer/row-analyzers.d.mts +3 -0
  7. package/dist/analyzer/row-analyzers.mjs +3 -0
  8. package/dist/analyzer/row-values.mjs +22 -0
  9. package/dist/analyzers/bayesian-ctr.mjs +177 -0
  10. package/dist/analyzers/bipartite-pagerank.mjs +243 -0
  11. package/dist/analyzers/brand.d.mts +33 -0
  12. package/dist/analyzers/brand.mjs +154 -0
  13. package/dist/analyzers/cannibalization.d.mts +58 -0
  14. package/dist/analyzers/cannibalization.mjs +267 -0
  15. package/dist/analyzers/change-point.mjs +197 -0
  16. package/dist/analyzers/clustering.d.mts +29 -0
  17. package/dist/analyzers/clustering.mjs +217 -0
  18. package/dist/analyzers/concentration.d.mts +42 -0
  19. package/dist/analyzers/concentration.mjs +180 -0
  20. package/dist/analyzers/content-velocity.mjs +87 -0
  21. package/dist/analyzers/ctr-anomaly.mjs +189 -0
  22. package/dist/analyzers/ctr-curve.mjs +128 -0
  23. package/dist/analyzers/dark-traffic.mjs +130 -0
  24. package/dist/analyzers/data-detail.mjs +44 -0
  25. package/dist/analyzers/data-query.mjs +44 -0
  26. package/dist/analyzers/decay.d.mts +38 -0
  27. package/dist/analyzers/decay.mjs +187 -0
  28. package/dist/analyzers/device-gap.mjs +135 -0
  29. package/dist/analyzers/intent-atlas.mjs +175 -0
  30. package/dist/analyzers/keyword-breadth.mjs +115 -0
  31. package/dist/analyzers/long-tail.mjs +150 -0
  32. package/dist/analyzers/movers.d.mts +43 -0
  33. package/dist/analyzers/movers.mjs +283 -0
  34. package/dist/analyzers/opportunity.d.mts +32 -0
  35. package/dist/analyzers/opportunity.mjs +212 -0
  36. package/dist/analyzers/position-distribution.mjs +57 -0
  37. package/dist/analyzers/position-volatility.mjs +143 -0
  38. package/dist/analyzers/query-migration.mjs +178 -0
  39. package/dist/analyzers/seasonality.d.mts +28 -0
  40. package/dist/analyzers/seasonality.mjs +136 -0
  41. package/dist/analyzers/stl-decompose.mjs +187 -0
  42. package/dist/analyzers/striking-distance.d.mts +54 -0
  43. package/dist/analyzers/striking-distance.mjs +82 -0
  44. package/dist/analyzers/survival.mjs +200 -0
  45. package/dist/analyzers/trends.mjs +156 -0
  46. package/dist/analyzers/zero-click.d.mts +17 -0
  47. package/dist/analyzers/zero-click.mjs +122 -0
  48. package/dist/browser.d.mts +5 -0
  49. package/dist/browser.mjs +11 -0
  50. package/dist/default-registry.d.mts +3 -1
  51. package/dist/default-registry.mjs +5 -1
  52. package/dist/errors.d.mts +51 -1
  53. package/dist/index.d.mts +30 -494
  54. package/dist/index.mjs +28 -49
  55. package/dist/query/analyzers.mjs +301 -0
  56. package/dist/query/index.mjs +4 -0
  57. package/dist/query/intent.d.mts +20 -0
  58. package/dist/query/intent.mjs +139 -0
  59. package/dist/query/normalize.d.mts +15 -0
  60. package/dist/query/normalize.mjs +204 -0
  61. package/dist/report/format.d.mts +7 -0
  62. package/dist/report/format.mjs +45 -0
  63. package/dist/report/index.d.mts +4 -1
  64. package/dist/report/index.mjs +4 -1
  65. package/dist/report/registry.d.mts +4 -0
  66. package/dist/report/registry.mjs +26 -0
  67. package/dist/report/reports/brand.mjs +104 -0
  68. package/dist/report/reports/growth.mjs +144 -0
  69. package/dist/report/reports/health.mjs +141 -0
  70. package/dist/report/reports/movers.mjs +203 -0
  71. package/dist/report/reports/opportunities.mjs +184 -0
  72. package/dist/report/reports/pre-publish.mjs +111 -0
  73. package/dist/report/reports/priority.mjs +97 -0
  74. package/dist/report/reports/risks.mjs +201 -0
  75. package/dist/report/reports/triage.mjs +155 -0
  76. package/dist/report/require.mjs +15 -0
  77. package/dist/report/resolve-target.d.mts +29 -0
  78. package/dist/report/resolve-target.mjs +32 -0
  79. package/dist/{_chunks/index.d.mts → report/runtime.d.mts} +2 -37
  80. package/dist/report/runtime.mjs +80 -0
  81. package/dist/report/sections.mjs +22 -0
  82. package/dist/sitemap-health.d.mts +34 -0
  83. package/dist/sitemap-health.mjs +37 -0
  84. package/dist/{_chunks/index2.d.mts → source/composite.d.mts} +2 -13
  85. package/dist/{_chunks/source.mjs → source/composite.mjs} +1 -17
  86. package/dist/source/in-memory.d.mts +14 -0
  87. package/dist/source/in-memory.mjs +17 -0
  88. package/dist/source/index.d.mts +2 -1
  89. package/dist/source/index.mjs +2 -1
  90. package/dist/sql-analyzers.d.mts +3 -0
  91. package/dist/sql-analyzers.mjs +3 -0
  92. package/dist/types.d.mts +34 -0
  93. package/dist/types.mjs +21 -0
  94. package/package.json +4 -4
  95. package/dist/_chunks/default-registry.d.mts +0 -5
  96. package/dist/_chunks/default-registry.mjs +0 -5183
  97. package/dist/_chunks/errors.d.mts +0 -52
  98. package/dist/_chunks/report.mjs +0 -1710
  99. /package/dist/{_chunks/scoring.mjs → scoring.mjs} +0 -0
@@ -0,0 +1,184 @@
1
+ import { reportRows, sectionArtifact, sectionCoverage, truncation } from "../sections.mjs";
2
+ import { defineReport } from "@gscdump/engine/report";
3
+ const DEFAULT_MAX = 5;
4
+ const opportunitiesReport = defineReport({
5
+ id: "opportunities",
6
+ description: "Striking-distance, low-CTR, zero-click, and query-migration opportunities.",
7
+ defaultPeriod: "last-28d",
8
+ defaultComparison: "none",
9
+ argsSpec: { "max-findings": {
10
+ type: "number",
11
+ description: "Cap findings per section",
12
+ default: DEFAULT_MAX
13
+ } },
14
+ plan: (_params, window) => {
15
+ const dates = {
16
+ startDate: window.start,
17
+ endDate: window.end
18
+ };
19
+ return [
20
+ {
21
+ key: "striking",
22
+ type: "striking-distance",
23
+ params: {
24
+ ...dates,
25
+ limit: 100
26
+ },
27
+ required: true
28
+ },
29
+ {
30
+ key: "opportunity",
31
+ type: "opportunity",
32
+ params: {
33
+ ...dates,
34
+ limit: 100
35
+ }
36
+ },
37
+ {
38
+ key: "zero-click",
39
+ type: "zero-click",
40
+ params: {
41
+ ...dates,
42
+ limit: 100
43
+ }
44
+ },
45
+ {
46
+ key: "query-migration",
47
+ type: "query-migration",
48
+ params: {
49
+ ...dates,
50
+ limit: 50
51
+ }
52
+ }
53
+ ];
54
+ },
55
+ reduce: (results, ctx) => {
56
+ const max = ctx.params.maxFindings ?? DEFAULT_MAX;
57
+ return { sections: [
58
+ buildStrikingSection(results.striking, max),
59
+ buildOpportunitySection(results.opportunity, max),
60
+ buildZeroClickSection(results["zero-click"], max),
61
+ buildMigrationSection(results["query-migration"], max)
62
+ ] };
63
+ }
64
+ });
65
+ function buildStrikingSection(res, max) {
66
+ const rows = reportRows(res).sort((a, b) => b.potentialClicks - a.potentialClicks);
67
+ const kept = rows.slice(0, max);
68
+ const findings = kept.map((r) => ({
69
+ entity: {
70
+ kind: "query",
71
+ value: r.keyword
72
+ },
73
+ metrics: {
74
+ position: r.position,
75
+ impressions: r.impressions,
76
+ clicks: r.clicks,
77
+ potentialClicks: r.potentialClicks
78
+ },
79
+ why: r.page ? `on ${r.page}` : void 0
80
+ }));
81
+ const totalPotential = kept.reduce((s, r) => s + r.potentialClicks, 0);
82
+ return {
83
+ id: "striking-distance",
84
+ title: "Striking distance",
85
+ severity: "low",
86
+ summary: { magnitudeLabel: `${Math.round(totalPotential)} potential clicks` },
87
+ findings,
88
+ truncated: truncation(rows.length, kept.length),
89
+ coverage: sectionCoverage(res),
90
+ actions: [],
91
+ artifact: sectionArtifact(res, "striking-distance")
92
+ };
93
+ }
94
+ function buildOpportunitySection(res, max) {
95
+ const rows = reportRows(res).sort((a, b) => b.opportunityScore - a.opportunityScore);
96
+ const kept = rows.slice(0, max);
97
+ return {
98
+ id: "low-ctr",
99
+ title: "Underperforming CTR",
100
+ severity: "low",
101
+ summary: {},
102
+ findings: kept.map((r) => ({
103
+ entity: {
104
+ kind: "query",
105
+ value: r.keyword
106
+ },
107
+ metrics: {
108
+ opportunityScore: r.opportunityScore,
109
+ ctr: r.ctr,
110
+ position: r.position,
111
+ impressions: r.impressions,
112
+ potentialClicks: r.potentialClicks
113
+ },
114
+ why: r.page ? `low CTR on ${r.page}` : "low CTR vs position"
115
+ })),
116
+ truncated: truncation(rows.length, kept.length),
117
+ coverage: sectionCoverage(res),
118
+ actions: kept.slice(0, 1).map((r) => ({
119
+ kind: "fix",
120
+ target: r.page ? {
121
+ kind: "page",
122
+ value: r.page
123
+ } : {
124
+ kind: "query",
125
+ value: r.keyword
126
+ },
127
+ rationale: "Rewrite title/description to lift CTR at this position"
128
+ })),
129
+ artifact: sectionArtifact(res, "opportunity")
130
+ };
131
+ }
132
+ function buildZeroClickSection(res, max) {
133
+ const rows = reportRows(res).sort((a, b) => b.impressions - a.impressions);
134
+ const kept = rows.slice(0, max);
135
+ const findings = kept.map((r) => ({
136
+ entity: {
137
+ kind: "query",
138
+ value: r.query
139
+ },
140
+ metrics: {
141
+ impressions: r.impressions,
142
+ position: r.position,
143
+ ctr: r.ctr
144
+ },
145
+ why: `0 clicks on ${r.page}`
146
+ }));
147
+ return {
148
+ id: "zero-click",
149
+ title: "Zero-click queries",
150
+ severity: "info",
151
+ summary: { magnitudeLabel: `${kept.reduce((s, r) => s + r.impressions, 0)} impressions wasted` },
152
+ findings,
153
+ truncated: truncation(rows.length, kept.length),
154
+ coverage: sectionCoverage(res),
155
+ actions: [],
156
+ artifact: sectionArtifact(res, "zero-click")
157
+ };
158
+ }
159
+ function buildMigrationSection(res, max) {
160
+ const rows = reportRows(res).sort((a, b) => b.weight - a.weight);
161
+ const kept = rows.slice(0, max);
162
+ return {
163
+ id: "query-migration",
164
+ title: "Query migration",
165
+ severity: "info",
166
+ summary: {},
167
+ findings: kept.map((r) => ({
168
+ entity: {
169
+ kind: "page",
170
+ value: r.targetPage
171
+ },
172
+ metrics: {
173
+ weight: r.weight,
174
+ queryCount: r.queryCount
175
+ },
176
+ why: `migrating from ${r.sourcePage}`
177
+ })),
178
+ truncated: truncation(rows.length, kept.length),
179
+ coverage: sectionCoverage(res),
180
+ actions: [],
181
+ artifact: sectionArtifact(res, "query-migration")
182
+ };
183
+ }
184
+ export { opportunitiesReport };
@@ -0,0 +1,111 @@
1
+ import { requireReportParam } from "../require.mjs";
2
+ import { reportRows, sectionArtifact, sectionCoverage, truncation } from "../sections.mjs";
3
+ import { defineReport } from "@gscdump/engine/report";
4
+ const DEFAULT_MAX = 10;
5
+ const prePublishReport = defineReport({
6
+ id: "pre-publish",
7
+ description: "Pre-publish guard: cannibalization risk and striking-distance peers for a candidate topic or URL.",
8
+ defaultPeriod: "last-90d",
9
+ defaultComparison: "none",
10
+ argsSpec: {
11
+ "topic": {
12
+ type: "string",
13
+ description: "Topic / keyword / URL slug to check",
14
+ required: true
15
+ },
16
+ "max-findings": {
17
+ type: "number",
18
+ description: "Cap findings per section",
19
+ default: DEFAULT_MAX
20
+ }
21
+ },
22
+ plan: (params, window) => {
23
+ requireReportParam("pre-publish", "topic", params.topic, "pre-publish report requires --topic <topic-or-url>");
24
+ const dates = {
25
+ startDate: window.start,
26
+ endDate: window.end
27
+ };
28
+ return [{
29
+ key: "cannibalization",
30
+ type: "cannibalization",
31
+ params: {
32
+ ...dates,
33
+ limit: 200
34
+ }
35
+ }, {
36
+ key: "striking",
37
+ type: "striking-distance",
38
+ params: {
39
+ ...dates,
40
+ limit: 200
41
+ }
42
+ }];
43
+ },
44
+ reduce: (results, ctx) => {
45
+ const topic = (ctx.params.topic ?? "").trim().toLowerCase();
46
+ const max = ctx.params.maxFindings ?? DEFAULT_MAX;
47
+ const matches = (val) => !!val && val.toLowerCase().includes(topic);
48
+ return { sections: [buildCannibalizationSection(results.cannibalization, matches, max), buildStrikingPeersSection(results.striking, matches, max, topic)] };
49
+ }
50
+ });
51
+ function buildCannibalizationSection(res, matches, max) {
52
+ const rows = reportRows(res).filter((r) => matches(r.keyword) || (r.competitors ?? []).some((p) => matches(p.url))).sort((a, b) => b.totalClicks - a.totalClicks);
53
+ const kept = rows.slice(0, max);
54
+ const findings = kept.map((r) => {
55
+ const pageCount = r.competitorCount ?? r.competitors?.length ?? 0;
56
+ return {
57
+ entity: {
58
+ kind: "query",
59
+ value: r.keyword
60
+ },
61
+ metrics: {
62
+ pages: pageCount,
63
+ totalClicks: r.totalClicks,
64
+ totalImpressions: r.totalImpressions
65
+ },
66
+ why: `${pageCount} page(s) already targeting`
67
+ };
68
+ });
69
+ return {
70
+ id: "cannibalization-risk",
71
+ title: "Cannibalization risk",
72
+ severity: kept.length ? "high" : "info",
73
+ summary: { magnitudeLabel: kept.length ? `${kept.length} existing competition` : "no existing competition" },
74
+ findings,
75
+ truncated: truncation(rows.length, kept.length),
76
+ coverage: sectionCoverage(res),
77
+ actions: kept.slice(0, 1).map(() => ({
78
+ kind: "fix",
79
+ rationale: "Decide before publishing: redirect existing page, target a different angle, or accept overlap."
80
+ })),
81
+ artifact: sectionArtifact(res, "cannibalization")
82
+ };
83
+ }
84
+ function buildStrikingPeersSection(res, matches, max, topic) {
85
+ const rows = reportRows(res).filter((r) => matches(r.keyword) || matches(r.page)).sort((a, b) => b.potentialClicks - a.potentialClicks);
86
+ const kept = rows.slice(0, max);
87
+ const findings = kept.map((r) => ({
88
+ entity: {
89
+ kind: "query",
90
+ value: r.keyword
91
+ },
92
+ metrics: {
93
+ position: r.position,
94
+ impressions: r.impressions,
95
+ potentialClicks: r.potentialClicks
96
+ },
97
+ why: r.page ? `currently on ${r.page}` : void 0
98
+ }));
99
+ return {
100
+ id: "striking-peers",
101
+ title: "Existing striking-distance peers",
102
+ severity: kept.length ? "low" : "info",
103
+ summary: { magnitudeLabel: kept.length ? `${kept.length} adjacent rankings for "${topic}"` : "no adjacent rankings" },
104
+ findings,
105
+ truncated: truncation(rows.length, kept.length),
106
+ coverage: sectionCoverage(res),
107
+ actions: [],
108
+ artifact: sectionArtifact(res, "striking-distance")
109
+ };
110
+ }
111
+ export { prePublishReport };
@@ -0,0 +1,97 @@
1
+ import { DEFAULT_PRIORITY_SOURCES, mergePriorityActions, normalizePriorityActions, scorePriorityActions } from "../../action-priority.mjs";
2
+ import { defineReport } from "@gscdump/engine/report";
3
+ const DEFAULT_LIMIT = 40;
4
+ const DEFAULT_ACTIONS = 10;
5
+ const priorityReport = defineReport({
6
+ id: "priority",
7
+ description: "Ranked priority actions composed from striking-distance, opportunity, cannibalization, ctr-anomaly, and change-point signals.",
8
+ defaultPeriod: "last-28d",
9
+ defaultComparison: "prev-period",
10
+ argsSpec: {
11
+ "limit": {
12
+ type: "number",
13
+ description: "Max actions in the section",
14
+ default: DEFAULT_LIMIT
15
+ },
16
+ "max-actions": {
17
+ type: "number",
18
+ description: "Top-N rendered as ReportAction",
19
+ default: DEFAULT_ACTIONS
20
+ }
21
+ },
22
+ plan: (_params, window) => {
23
+ const dates = {
24
+ startDate: window.start,
25
+ endDate: window.end
26
+ };
27
+ const cmp = window.comparison ? {
28
+ prevStartDate: window.comparison.start,
29
+ prevEndDate: window.comparison.end
30
+ } : {};
31
+ return DEFAULT_PRIORITY_SOURCES.map((source) => ({
32
+ key: source,
33
+ type: source,
34
+ params: {
35
+ ...dates,
36
+ ...cmp,
37
+ limit: 100
38
+ },
39
+ required: false
40
+ }));
41
+ },
42
+ reduce: (results, ctx) => {
43
+ const limit = ctx.params.limit ?? DEFAULT_LIMIT;
44
+ const maxActions = ctx.params.maxActions ?? DEFAULT_ACTIONS;
45
+ const all = [];
46
+ let anyMissing = false;
47
+ for (const source of DEFAULT_PRIORITY_SOURCES) {
48
+ const r = results[source];
49
+ if (!r) {
50
+ anyMissing = true;
51
+ continue;
52
+ }
53
+ all.push(...normalizePriorityActions(source, r));
54
+ }
55
+ const ranked = scorePriorityActions(mergePriorityActions(all)).slice(0, limit);
56
+ const findings = ranked.map((a) => ({
57
+ entity: {
58
+ kind: "query",
59
+ value: a.keyword
60
+ },
61
+ metrics: {
62
+ priorityScore: a.priorityScore,
63
+ severity: a.severity,
64
+ impact: a.impact,
65
+ impressions: a.impressions
66
+ },
67
+ why: `${a.title} — ${a.why} (page: ${a.page})`
68
+ }));
69
+ const actions = ranked.slice(0, maxActions).map((a) => {
70
+ const primarySource = a.sources[0];
71
+ return {
72
+ kind: primarySource === "cannibalization" ? "fix" : "analyzer",
73
+ target: {
74
+ kind: "page",
75
+ value: a.page
76
+ },
77
+ params: { type: primarySource },
78
+ rationale: a.title
79
+ };
80
+ });
81
+ const topSeverity = ranked[0]?.severity ?? 0;
82
+ return { sections: [{
83
+ id: "priority",
84
+ title: "Priority actions",
85
+ severity: topSeverity >= 70 ? "high" : topSeverity >= 40 ? "medium" : ranked.length ? "low" : "info",
86
+ summary: { magnitudeLabel: `${ranked.length} actions ranked` },
87
+ findings,
88
+ truncated: all.length > ranked.length ? {
89
+ kept: ranked.length,
90
+ total: all.length
91
+ } : void 0,
92
+ coverage: anyMissing ? "partial" : "full",
93
+ actions
94
+ }] };
95
+ }
96
+ });
97
+ export { priorityReport };
@@ -0,0 +1,201 @@
1
+ import { requireComparisonWindow } from "../require.mjs";
2
+ import { reportRows, sectionArtifact, sectionCoverage, truncation } from "../sections.mjs";
3
+ import { defineReport } from "@gscdump/engine/report";
4
+ const DEFAULT_MAX = 5;
5
+ const risksReport = defineReport({
6
+ id: "risks",
7
+ description: "Decay, cannibalization, dark-traffic and device-gap risks vs prior period.",
8
+ defaultPeriod: "last-28d",
9
+ defaultComparison: "prev-period",
10
+ argsSpec: { "max-findings": {
11
+ type: "number",
12
+ description: "Cap findings per section",
13
+ default: DEFAULT_MAX
14
+ } },
15
+ plan: (_params, window) => {
16
+ const comparison = requireComparisonWindow("risks", window, "risks report requires a comparison window — pass --vs prev-period");
17
+ const dates = {
18
+ startDate: window.start,
19
+ endDate: window.end
20
+ };
21
+ const prev = {
22
+ prevStartDate: comparison.start,
23
+ prevEndDate: comparison.end
24
+ };
25
+ return [
26
+ {
27
+ key: "decay",
28
+ type: "decay",
29
+ params: {
30
+ ...dates,
31
+ ...prev,
32
+ limit: 100
33
+ },
34
+ required: true
35
+ },
36
+ {
37
+ key: "cannibalization",
38
+ type: "cannibalization",
39
+ params: {
40
+ ...dates,
41
+ limit: 50
42
+ }
43
+ },
44
+ {
45
+ key: "dark-traffic",
46
+ type: "dark-traffic",
47
+ params: {
48
+ ...dates,
49
+ limit: 50
50
+ }
51
+ },
52
+ {
53
+ key: "device-gap",
54
+ type: "device-gap",
55
+ params: {
56
+ ...dates,
57
+ limit: 50
58
+ }
59
+ }
60
+ ];
61
+ },
62
+ reduce: (results, ctx) => {
63
+ const max = ctx.params.maxFindings ?? DEFAULT_MAX;
64
+ return { sections: [
65
+ buildDecaySection(results.decay, max),
66
+ buildCannibalizationSection(results.cannibalization, max),
67
+ buildDarkTrafficSection(results["dark-traffic"], max),
68
+ buildDeviceGapSection(results["device-gap"], max)
69
+ ] };
70
+ }
71
+ });
72
+ function buildDecaySection(res, max) {
73
+ const rows = reportRows(res).sort((a, b) => b.lostClicks - a.lostClicks);
74
+ const kept = rows.slice(0, max);
75
+ const totalLost = kept.reduce((s, r) => s + r.lostClicks, 0);
76
+ const findings = kept.map((r) => ({
77
+ entity: {
78
+ kind: "page",
79
+ value: r.page
80
+ },
81
+ metrics: {
82
+ lostClicks: r.lostClicks,
83
+ currentClicks: r.currentClicks,
84
+ declinePercent: r.declinePercent
85
+ },
86
+ delta: {
87
+ metric: "clicks",
88
+ prior: r.previousClicks,
89
+ current: r.currentClicks,
90
+ pct: -r.declinePercent * 100
91
+ }
92
+ }));
93
+ return {
94
+ id: "decay",
95
+ title: "Decaying pages",
96
+ severity: totalLost >= 200 ? "high" : totalLost >= 50 ? "medium" : "low",
97
+ summary: {
98
+ delta: -totalLost,
99
+ direction: totalLost > 0 ? "down" : "flat",
100
+ magnitudeLabel: `${Math.round(totalLost)} clicks lost`
101
+ },
102
+ findings,
103
+ truncated: truncation(rows.length, kept.length),
104
+ coverage: sectionCoverage(res),
105
+ actions: kept.slice(0, 1).map((r) => ({
106
+ kind: "analyzer",
107
+ target: {
108
+ kind: "page",
109
+ value: r.page
110
+ },
111
+ params: { type: "change-point" },
112
+ rationale: "Investigate change-point on the worst-affected page"
113
+ })),
114
+ artifact: sectionArtifact(res, "decay")
115
+ };
116
+ }
117
+ function buildCannibalizationSection(res, max) {
118
+ const rows = reportRows(res).sort((a, b) => b.totalClicks - a.totalClicks);
119
+ const kept = rows.slice(0, max);
120
+ const findings = kept.map((r) => {
121
+ const pageCount = r.competitorCount ?? r.competitors?.length ?? 0;
122
+ return {
123
+ entity: {
124
+ kind: "query",
125
+ value: r.keyword
126
+ },
127
+ metrics: {
128
+ pages: pageCount,
129
+ totalClicks: r.totalClicks,
130
+ totalImpressions: r.totalImpressions
131
+ },
132
+ why: `${pageCount} pages competing`
133
+ };
134
+ });
135
+ return {
136
+ id: "cannibalization",
137
+ title: "Cannibalizing queries",
138
+ severity: kept.length ? "medium" : "info",
139
+ summary: {},
140
+ findings,
141
+ truncated: truncation(rows.length, kept.length),
142
+ coverage: sectionCoverage(res),
143
+ actions: [],
144
+ artifact: sectionArtifact(res, "cannibalization")
145
+ };
146
+ }
147
+ function buildDarkTrafficSection(res, max) {
148
+ const rows = reportRows(res).sort((a, b) => b.darkClicks - a.darkClicks);
149
+ const kept = rows.slice(0, max);
150
+ const totalDark = kept.reduce((s, r) => s + r.darkClicks, 0);
151
+ const findings = kept.map((r) => ({
152
+ entity: {
153
+ kind: "page",
154
+ value: r.url
155
+ },
156
+ metrics: {
157
+ darkClicks: r.darkClicks,
158
+ darkPercent: r.darkPercent,
159
+ totalClicks: r.totalClicks
160
+ }
161
+ }));
162
+ return {
163
+ id: "dark-traffic",
164
+ title: "Dark traffic",
165
+ severity: kept.length ? "low" : "info",
166
+ summary: { magnitudeLabel: `${Math.round(totalDark)} unattributed clicks` },
167
+ findings,
168
+ truncated: truncation(rows.length, kept.length),
169
+ coverage: sectionCoverage(res),
170
+ actions: [],
171
+ artifact: sectionArtifact(res, "dark-traffic")
172
+ };
173
+ }
174
+ function buildDeviceGapSection(res, max) {
175
+ const rows = reportRows(res).sort((a, b) => Math.abs(b.gaps.positionGap) - Math.abs(a.gaps.positionGap));
176
+ const kept = rows.slice(0, max);
177
+ return {
178
+ id: "device-gap",
179
+ title: "Device gap",
180
+ severity: "info",
181
+ summary: {},
182
+ findings: kept.map((r) => ({
183
+ entity: {
184
+ kind: "page",
185
+ value: r.date
186
+ },
187
+ metrics: {
188
+ ctrGap: r.gaps.ctrGap,
189
+ positionGap: r.gaps.positionGap,
190
+ desktopCtr: r.desktop.ctr,
191
+ mobileCtr: r.mobile.ctr
192
+ },
193
+ why: `desktop vs mobile delta`
194
+ })),
195
+ truncated: truncation(rows.length, kept.length),
196
+ coverage: sectionCoverage(res),
197
+ actions: [],
198
+ artifact: sectionArtifact(res, "device-gap")
199
+ };
200
+ }
201
+ export { risksReport };