@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,45 @@
1
+ const SEVERITY_GLYPH = {
2
+ info: "i",
3
+ low: "·",
4
+ medium: "!",
5
+ high: "!!"
6
+ };
7
+ function formatReport(report, opts = {}) {
8
+ const lines = [];
9
+ lines.push(`# ${report.id} — ${report.site}`);
10
+ lines.push(`window: ${report.window.start} → ${report.window.end} (${report.window.days}d)`);
11
+ if (report.window.comparison) lines.push(`compare: ${report.window.comparison.start} → ${report.window.comparison.end}`);
12
+ if (report.meta.degraded) lines.push(`! degraded: ${report.meta.steps.filter((s) => s.status === "error").map((s) => s.key).join(", ")}`);
13
+ lines.push("");
14
+ if (report.sections.length === 0) {
15
+ lines.push("(no sections)");
16
+ return lines.join("\n");
17
+ }
18
+ for (const section of report.sections) lines.push(...renderSection(section, opts.maxFindingsPerSection));
19
+ return lines.join("\n");
20
+ }
21
+ function renderSection(section, cap) {
22
+ const lines = [];
23
+ const glyph = SEVERITY_GLYPH[section.severity] ?? "";
24
+ lines.push(`## ${glyph} ${section.title}${section.coverage === "partial" ? " (partial)" : ""}`);
25
+ if (section.summary.magnitudeLabel) lines.push(` ${section.summary.magnitudeLabel}`);
26
+ const findings = cap ? section.findings.slice(0, cap) : section.findings;
27
+ for (const f of findings) {
28
+ const metricsStr = Object.entries(f.metrics).map(([k, v]) => `${k}=${formatNumber(v)}`).join(" ");
29
+ lines.push(` - [${f.entity.kind}] ${f.entity.value} ${metricsStr}${f.why ? ` — ${f.why}` : ""}`);
30
+ }
31
+ if (section.truncated && section.truncated.kept < section.truncated.total) lines.push(` … +${section.truncated.total - section.truncated.kept} more`);
32
+ for (const a of section.actions) {
33
+ const target = a.target ? ` ${a.target.kind}=${a.target.value}` : "";
34
+ lines.push(` → ${a.kind}${target}: ${a.rationale}`);
35
+ if (a.cliHint) lines.push(` $ ${a.cliHint}`);
36
+ }
37
+ lines.push("");
38
+ return lines;
39
+ }
40
+ function formatNumber(n) {
41
+ if (!Number.isFinite(n)) return String(n);
42
+ if (Number.isInteger(n)) return String(n);
43
+ return n.toFixed(2);
44
+ }
45
+ export { formatReport };
@@ -1,2 +1,5 @@
1
- import { DryRunReportResult, FormatReportOptions, REPORTS, ResolveTargetInput, ResolveTargetKind, ResolveTargetResult, RunReportOptions, defaultReportRegistry, dryRunReport, formatReport, resolveTarget, runReport, runReportResult } from "../_chunks/index.mjs";
1
+ import { FormatReportOptions, formatReport } from "./format.mjs";
2
+ import { REPORTS, defaultReportRegistry } from "./registry.mjs";
3
+ import { ResolveTargetInput, ResolveTargetKind, ResolveTargetResult, resolveTarget } from "./resolve-target.mjs";
4
+ import { DryRunReportResult, RunReportOptions, dryRunReport, runReport, runReportResult } from "./runtime.mjs";
2
5
  export { type DryRunReportResult, type FormatReportOptions, REPORTS, type ResolveTargetInput, type ResolveTargetKind, type ResolveTargetResult, type RunReportOptions, defaultReportRegistry, dryRunReport, formatReport, resolveTarget, runReport, runReportResult };
@@ -1,2 +1,5 @@
1
- import { REPORTS, defaultReportRegistry, dryRunReport, formatReport, resolveTarget, runReport, runReportResult } from "../_chunks/report.mjs";
1
+ import { formatReport } from "./format.mjs";
2
+ import { resolveTarget } from "./resolve-target.mjs";
3
+ import { REPORTS, defaultReportRegistry } from "./registry.mjs";
4
+ import { dryRunReport, runReport, runReportResult } from "./runtime.mjs";
2
5
  export { REPORTS, defaultReportRegistry, dryRunReport, formatReport, resolveTarget, runReport, runReportResult };
@@ -0,0 +1,4 @@
1
+ import { DefinedReport, ReportParams } from "@gscdump/engine/report";
2
+ declare const REPORTS: readonly DefinedReport<ReportParams>[];
3
+ declare const defaultReportRegistry: import("@gscdump/engine/report").ReportRegistry;
4
+ export { REPORTS, defaultReportRegistry };
@@ -0,0 +1,26 @@
1
+ import { brandReport } from "./reports/brand.mjs";
2
+ import { growthReport } from "./reports/growth.mjs";
3
+ import { healthReport } from "./reports/health.mjs";
4
+ import { moversReport } from "./reports/movers.mjs";
5
+ import { opportunitiesReport } from "./reports/opportunities.mjs";
6
+ import { prePublishReport } from "./reports/pre-publish.mjs";
7
+ import { priorityReport } from "./reports/priority.mjs";
8
+ import { risksReport } from "./reports/risks.mjs";
9
+ import { triageReport } from "./reports/triage.mjs";
10
+ import { createReportRegistry } from "@gscdump/engine/report";
11
+ const REPORTS = [
12
+ brandReport,
13
+ growthReport,
14
+ healthReport,
15
+ moversReport,
16
+ opportunitiesReport,
17
+ prePublishReport,
18
+ priorityReport,
19
+ risksReport,
20
+ triageReport
21
+ ];
22
+ const defaultReportRegistry = createReportRegistry({
23
+ reports: REPORTS,
24
+ version: "0"
25
+ });
26
+ export { REPORTS, defaultReportRegistry };
@@ -0,0 +1,104 @@
1
+ import { requireReportParam } from "../require.mjs";
2
+ import { reportRows, sectionArtifact, sectionCoverage } from "../sections.mjs";
3
+ import { defineReport } from "@gscdump/engine/report";
4
+ const DEFAULT_MAX = 5;
5
+ const brandReport = defineReport({
6
+ id: "brand",
7
+ description: "Brand vs non-brand share, top brand keywords, and site-wide keyword concentration.",
8
+ defaultPeriod: "last-28d",
9
+ defaultComparison: "none",
10
+ argsSpec: {
11
+ "brand-terms": {
12
+ type: "string",
13
+ description: "Comma-separated brand terms",
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
+ const brandTerms = requireReportParam("brand", "brand-terms", params.brandTerms, "brand report requires --brand-terms <comma,separated,list>").split(",").map((t) => t.trim()).filter(Boolean);
24
+ const dates = {
25
+ startDate: window.start,
26
+ endDate: window.end
27
+ };
28
+ return [{
29
+ key: "brand",
30
+ type: "brand",
31
+ params: {
32
+ ...dates,
33
+ brandTerms,
34
+ limit: 200
35
+ },
36
+ required: true
37
+ }, {
38
+ key: "concentration",
39
+ type: "concentration",
40
+ params: {
41
+ ...dates,
42
+ dimension: "keywords",
43
+ limit: 50
44
+ }
45
+ }];
46
+ },
47
+ reduce: (results, ctx) => {
48
+ const max = ctx.params.maxFindings ?? DEFAULT_MAX;
49
+ return { sections: [buildBrandSplitSection(results.brand, max), buildConcentrationSection(results.concentration, max)] };
50
+ }
51
+ });
52
+ function buildBrandSplitSection(res, max) {
53
+ const rows = reportRows(res);
54
+ const summary = res?.meta?.summary;
55
+ const findings = rows.filter((r) => r.segment === "brand").sort((a, b) => b.clicks - a.clicks).slice(0, max).map((r) => ({
56
+ entity: {
57
+ kind: "query",
58
+ value: r.query
59
+ },
60
+ metrics: {
61
+ clicks: r.clicks,
62
+ impressions: r.impressions,
63
+ ctr: r.ctr,
64
+ position: r.position
65
+ },
66
+ why: r.page ? `on ${r.page}` : void 0
67
+ }));
68
+ const brandShare = summary?.brandShare ?? 0;
69
+ return {
70
+ id: "brand-split",
71
+ title: "Brand vs non-brand",
72
+ severity: "info",
73
+ summary: { magnitudeLabel: summary ? `brand share ${(brandShare * 100).toFixed(1)}% (${summary.brandClicks} brand vs ${summary.nonBrandClicks} non-brand clicks)` : "no summary available" },
74
+ findings,
75
+ coverage: sectionCoverage(res),
76
+ actions: [],
77
+ artifact: sectionArtifact(res, "brand")
78
+ };
79
+ }
80
+ function buildConcentrationSection(res, max) {
81
+ const head = reportRows(res)[0];
82
+ const findings = (head?.topNItems ?? []).slice(0, max).map((it) => ({
83
+ entity: {
84
+ kind: "query",
85
+ value: it.key
86
+ },
87
+ metrics: {
88
+ clicks: it.clicks,
89
+ share: it.share
90
+ }
91
+ }));
92
+ const severity = head?.riskLevel === "high" ? "high" : head?.riskLevel === "medium" ? "medium" : "low";
93
+ return {
94
+ id: "concentration",
95
+ title: "Keyword concentration (site-wide)",
96
+ severity: head ? severity : "info",
97
+ summary: head ? { magnitudeLabel: `HHI ${head.hhi.toFixed(0)} (${head.riskLevel}); top-N share ${(head.topNConcentration * 100).toFixed(1)}%` } : {},
98
+ findings,
99
+ coverage: sectionCoverage(res),
100
+ actions: [],
101
+ artifact: sectionArtifact(res, "concentration", { dimension: "keywords" })
102
+ };
103
+ }
104
+ export { brandReport };
@@ -0,0 +1,144 @@
1
+ import { reportRows, sectionArtifact, sectionCoverage, truncation } from "../sections.mjs";
2
+ import { defineReport } from "@gscdump/engine/report";
3
+ const DEFAULT_MAX = 5;
4
+ const growthReport = defineReport({
5
+ id: "growth",
6
+ description: "Strategic growth signals: content velocity, keyword breadth, intent atlas, and long-tail shape over a long window (default 90d / YoY).",
7
+ defaultPeriod: "last-90d",
8
+ defaultComparison: "yoy",
9
+ argsSpec: { "max-findings": {
10
+ type: "number",
11
+ description: "Cap findings per section (long-tail only)",
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: "content-velocity",
22
+ type: "content-velocity",
23
+ params: {
24
+ ...dates,
25
+ days: window.days,
26
+ limit: 200
27
+ }
28
+ },
29
+ {
30
+ key: "keyword-breadth",
31
+ type: "keyword-breadth",
32
+ params: {
33
+ ...dates,
34
+ limit: 50
35
+ }
36
+ },
37
+ {
38
+ key: "intent-atlas",
39
+ type: "intent-atlas",
40
+ params: {
41
+ ...dates,
42
+ limit: 50
43
+ }
44
+ },
45
+ {
46
+ key: "long-tail",
47
+ type: "long-tail",
48
+ params: {
49
+ ...dates,
50
+ limit: 100
51
+ }
52
+ }
53
+ ];
54
+ },
55
+ reduce: (results, ctx) => {
56
+ const max = ctx.params.maxFindings ?? DEFAULT_MAX;
57
+ return { sections: [
58
+ buildContentVelocity(results["content-velocity"]),
59
+ buildKeywordBreadth(results["keyword-breadth"]),
60
+ buildIntentAtlas(results["intent-atlas"]),
61
+ buildLongTail(results["long-tail"], max)
62
+ ] };
63
+ }
64
+ });
65
+ function buildContentVelocity(res) {
66
+ const rows = reportRows(res);
67
+ const totalNew = rows.reduce((s, r) => s + r.newKeywords, 0);
68
+ const avgPerWeek = rows.length > 0 ? totalNew / rows.length : 0;
69
+ return {
70
+ id: "content-velocity",
71
+ title: "Content velocity",
72
+ severity: "info",
73
+ summary: { magnitudeLabel: `${totalNew} new keywords across ${rows.length} weeks (avg ${avgPerWeek.toFixed(1)}/wk)` },
74
+ findings: [],
75
+ coverage: sectionCoverage(res),
76
+ actions: [],
77
+ artifact: sectionArtifact(res, "content-velocity")
78
+ };
79
+ }
80
+ function buildKeywordBreadth(res) {
81
+ const rows = reportRows(res);
82
+ const totalPages = rows.reduce((s, r) => s + r.pageCount, 0);
83
+ const top = rows[0];
84
+ return {
85
+ id: "keyword-breadth",
86
+ title: "Keyword breadth",
87
+ severity: "info",
88
+ summary: { magnitudeLabel: top ? `${totalPages} pages; modal bucket "${top.bucket}" (${top.pageCount} pages)` : "no data" },
89
+ findings: [],
90
+ coverage: sectionCoverage(res),
91
+ actions: [],
92
+ artifact: sectionArtifact(res, "keyword-breadth")
93
+ };
94
+ }
95
+ function buildIntentAtlas(res) {
96
+ const rows = reportRows(res);
97
+ return {
98
+ id: "intent-atlas",
99
+ title: "Intent atlas",
100
+ severity: "info",
101
+ summary: { magnitudeLabel: `${rows.length} clusters covering ${rows.reduce((s, r) => s + r.keywordCount, 0)} keywords` },
102
+ findings: [],
103
+ coverage: sectionCoverage(res),
104
+ actions: [],
105
+ artifact: sectionArtifact(res, "intent-atlas")
106
+ };
107
+ }
108
+ function buildLongTail(res, max) {
109
+ const rows = reportRows(res).sort((a, b) => {
110
+ const rank = {
111
+ "head-heavy": 0,
112
+ "balanced": 1,
113
+ "flat-tail": 2
114
+ };
115
+ const dr = rank[a.fingerprint] - rank[b.fingerprint];
116
+ return dr !== 0 ? dr : b.headShare - a.headShare;
117
+ });
118
+ const kept = rows.slice(0, max);
119
+ const findings = kept.map((r) => ({
120
+ entity: {
121
+ kind: "page",
122
+ value: r.page
123
+ },
124
+ metrics: {
125
+ queryCount: r.queryCount,
126
+ impressions: r.totalImpressions,
127
+ headShare: r.headShare
128
+ },
129
+ why: `${r.fingerprint} (${(r.headShare * 100).toFixed(0)}% head share)`
130
+ }));
131
+ const headHeavy = rows.filter((r) => r.fingerprint === "head-heavy").length;
132
+ return {
133
+ id: "long-tail",
134
+ title: "Long-tail shape",
135
+ severity: headHeavy > rows.length / 3 ? "medium" : "info",
136
+ summary: { magnitudeLabel: `${rows.length} pages analysed; ${headHeavy} head-heavy` },
137
+ findings,
138
+ truncated: truncation(rows.length, kept.length),
139
+ coverage: sectionCoverage(res),
140
+ actions: [],
141
+ artifact: sectionArtifact(res, "long-tail")
142
+ };
143
+ }
144
+ export { growthReport };
@@ -0,0 +1,141 @@
1
+ import { reportRows, sectionArtifact, sectionCoverage, truncation } from "../sections.mjs";
2
+ import { defineReport } from "@gscdump/engine/report";
3
+ const DEFAULT_MAX = 5;
4
+ const healthReport = defineReport({
5
+ id: "health",
6
+ description: "CTR anomalies, change-points, and position-volatility hot spots in the recent window.",
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: "ctr-anomaly",
22
+ type: "ctr-anomaly",
23
+ params: {
24
+ ...dates,
25
+ limit: 100
26
+ },
27
+ required: true
28
+ },
29
+ {
30
+ key: "change-point",
31
+ type: "change-point",
32
+ params: {
33
+ ...dates,
34
+ limit: 100
35
+ }
36
+ },
37
+ {
38
+ key: "position-volatility",
39
+ type: "position-volatility",
40
+ params: {
41
+ ...dates,
42
+ limit: 100
43
+ }
44
+ }
45
+ ];
46
+ },
47
+ reduce: (results, ctx) => {
48
+ const max = ctx.params.maxFindings ?? DEFAULT_MAX;
49
+ return { sections: [
50
+ buildCtrAnomalySection(results["ctr-anomaly"], max),
51
+ buildChangePointSection(results["change-point"], max),
52
+ buildPositionVolatilitySection(results["position-volatility"], max)
53
+ ] };
54
+ }
55
+ });
56
+ function buildCtrAnomalySection(res, max) {
57
+ const rows = reportRows(res).filter((r) => r.breachDaysDown > 0).sort((a, b) => b.clicksLost - a.clicksLost);
58
+ const kept = rows.slice(0, max);
59
+ const totalLost = kept.reduce((s, r) => s + r.clicksLost, 0);
60
+ const findings = kept.map((r) => ({
61
+ entity: {
62
+ kind: "query",
63
+ value: r.keyword
64
+ },
65
+ metrics: {
66
+ clicksLost: r.clicksLost,
67
+ breachDays: r.breachDaysDown,
68
+ severity: r.severity,
69
+ baselineCtr: r.baselineCtr
70
+ },
71
+ why: r.page ? `on ${r.page}` : void 0
72
+ }));
73
+ return {
74
+ id: "ctr-anomaly",
75
+ title: "CTR anomalies",
76
+ severity: totalLost >= 100 ? "high" : totalLost >= 25 ? "medium" : kept.length ? "low" : "info",
77
+ summary: {
78
+ delta: -totalLost,
79
+ direction: totalLost > 0 ? "down" : "flat",
80
+ magnitudeLabel: `${Math.round(totalLost)} clicks lost vs baseline`
81
+ },
82
+ findings,
83
+ truncated: truncation(rows.length, kept.length),
84
+ coverage: sectionCoverage(res),
85
+ actions: [],
86
+ artifact: sectionArtifact(res, "ctr-anomaly")
87
+ };
88
+ }
89
+ function buildChangePointSection(res, max) {
90
+ const rows = reportRows(res).filter((r) => r.direction === "worsened").sort((a, b) => b.llr - a.llr);
91
+ const kept = rows.slice(0, max);
92
+ const findings = kept.map((r) => ({
93
+ entity: {
94
+ kind: "query",
95
+ value: r.keyword
96
+ },
97
+ metrics: {
98
+ llr: r.llr,
99
+ delta: r.delta
100
+ },
101
+ why: `worsened on ${r.changeDate}${r.page ? ` (${r.page})` : ""}`
102
+ }));
103
+ return {
104
+ id: "change-point",
105
+ title: "Change-points (worsening)",
106
+ severity: kept.length ? "medium" : "info",
107
+ summary: { magnitudeLabel: `${kept.length} worsening segments` },
108
+ findings,
109
+ truncated: truncation(rows.length, kept.length),
110
+ coverage: sectionCoverage(res),
111
+ actions: [],
112
+ artifact: sectionArtifact(res, "change-point")
113
+ };
114
+ }
115
+ function buildPositionVolatilitySection(res, max) {
116
+ const rows = reportRows(res).sort((a, b) => b.peakVolatility - a.peakVolatility);
117
+ const kept = rows.slice(0, max);
118
+ const findings = kept.map((r) => ({
119
+ entity: {
120
+ kind: "page",
121
+ value: r.page
122
+ },
123
+ metrics: {
124
+ avgVolatility: r.avgVolatility,
125
+ peakVolatility: r.peakVolatility,
126
+ impressions: r.totalImpressions
127
+ }
128
+ }));
129
+ return {
130
+ id: "position-volatility",
131
+ title: "Position volatility",
132
+ severity: kept.length ? "low" : "info",
133
+ summary: {},
134
+ findings,
135
+ truncated: truncation(rows.length, kept.length),
136
+ coverage: sectionCoverage(res),
137
+ actions: [],
138
+ artifact: sectionArtifact(res, "position-volatility")
139
+ };
140
+ }
141
+ export { healthReport };
@@ -0,0 +1,203 @@
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 DEFAULT_MIN_CHANGE = 5;
6
+ const moversReport = defineReport({
7
+ id: "movers",
8
+ description: "Risers, decliners, and striking-distance opportunities over a current vs prior window.",
9
+ defaultPeriod: "last-7d",
10
+ defaultComparison: "prev-period",
11
+ argsSpec: {
12
+ "max-findings": {
13
+ type: "number",
14
+ description: "Cap findings per section",
15
+ default: DEFAULT_MAX
16
+ },
17
+ "min-clicks-change": {
18
+ type: "number",
19
+ description: "Min absolute click change",
20
+ default: DEFAULT_MIN_CHANGE
21
+ }
22
+ },
23
+ plan: (_params, window) => {
24
+ const comparison = requireComparisonWindow("movers", window, "movers report requires a comparison window — pass --vs prev-period");
25
+ const cur = {
26
+ startDate: window.start,
27
+ endDate: window.end
28
+ };
29
+ const prev = {
30
+ prevStartDate: comparison.start,
31
+ prevEndDate: comparison.end
32
+ };
33
+ return [
34
+ {
35
+ key: "movers",
36
+ type: "movers",
37
+ params: {
38
+ ...cur,
39
+ ...prev,
40
+ limit: 200
41
+ },
42
+ required: true
43
+ },
44
+ {
45
+ key: "decay",
46
+ type: "decay",
47
+ params: {
48
+ ...cur,
49
+ ...prev,
50
+ limit: 100
51
+ }
52
+ },
53
+ {
54
+ key: "striking",
55
+ type: "striking-distance",
56
+ params: {
57
+ ...cur,
58
+ limit: 100
59
+ }
60
+ }
61
+ ];
62
+ },
63
+ reduce: (results, ctx) => {
64
+ const max = ctx.params.maxFindings ?? DEFAULT_MAX;
65
+ const minChange = ctx.params.minClicksChange ?? DEFAULT_MIN_CHANGE;
66
+ const sections = [];
67
+ const moversRes = results.movers;
68
+ const decayRes = results.decay;
69
+ const strikingRes = results.striking;
70
+ sections.push(buildMoversSection(moversRes, "rising", max, minChange));
71
+ sections.push(buildDeclinersSection(moversRes, decayRes, max, minChange));
72
+ sections.push(buildStrikingSection(strikingRes, max));
73
+ return { sections };
74
+ }
75
+ });
76
+ function buildMoversSection(res, direction, max, minChange) {
77
+ 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));
78
+ const total = rows.length;
79
+ const kept = rows.slice(0, max);
80
+ const findings = kept.map((r) => ({
81
+ entity: {
82
+ kind: "query",
83
+ value: r.keyword
84
+ },
85
+ metrics: {
86
+ clicks: r.recentClicks,
87
+ clicksChange: r.clicksChange,
88
+ clicksChangePercent: r.clicksChangePercent,
89
+ ...r.positionChange != null ? { positionChange: r.positionChange } : {}
90
+ },
91
+ delta: {
92
+ metric: "clicks",
93
+ prior: r.baselineClicks,
94
+ current: r.recentClicks,
95
+ pct: r.clicksChangePercent
96
+ },
97
+ why: r.page ? `on ${r.page}` : void 0
98
+ }));
99
+ const totalDelta = kept.reduce((sum, r) => sum + r.clicksChange, 0);
100
+ return {
101
+ id: direction,
102
+ title: direction === "rising" ? "Rising queries" : "Declining queries",
103
+ severity: direction === "rising" ? "info" : "medium",
104
+ summary: {
105
+ delta: totalDelta,
106
+ direction: totalDelta > 0 ? "up" : totalDelta < 0 ? "down" : "flat"
107
+ },
108
+ findings,
109
+ truncated: truncation(total, kept.length),
110
+ coverage: sectionCoverage(res),
111
+ actions: [],
112
+ artifact: sectionArtifact(res, "movers")
113
+ };
114
+ }
115
+ function buildDeclinersSection(moversRes, decayRes, max, minChange) {
116
+ const decliningQueries = reportRows(moversRes).filter((r) => r.direction === "declining" && Math.abs(r.clicksChange) >= minChange).slice(0, max);
117
+ const lostPages = reportRows(decayRes).sort((a, b) => b.lostClicks - a.lostClicks).slice(0, max);
118
+ const findings = [...decliningQueries.map((r) => ({
119
+ entity: {
120
+ kind: "query",
121
+ value: r.keyword
122
+ },
123
+ metrics: {
124
+ clicks: r.recentClicks,
125
+ clicksChange: r.clicksChange
126
+ },
127
+ delta: {
128
+ metric: "clicks",
129
+ prior: r.baselineClicks,
130
+ current: r.recentClicks,
131
+ pct: r.clicksChangePercent
132
+ }
133
+ })), ...lostPages.map((r) => ({
134
+ entity: {
135
+ kind: "page",
136
+ value: r.page
137
+ },
138
+ metrics: {
139
+ clicks: r.currentClicks,
140
+ lostClicks: r.lostClicks,
141
+ declinePercent: r.declinePercent
142
+ },
143
+ delta: {
144
+ metric: "clicks",
145
+ prior: r.previousClicks,
146
+ current: r.currentClicks,
147
+ pct: -r.declinePercent * 100
148
+ },
149
+ why: "page-level decay"
150
+ }))];
151
+ const totalLost = decliningQueries.reduce((s, r) => s + Math.abs(r.clicksChange), 0) + lostPages.reduce((s, r) => s + r.lostClicks, 0);
152
+ return {
153
+ id: "decliners",
154
+ title: "Decliners",
155
+ severity: totalLost >= 100 ? "high" : totalLost >= 25 ? "medium" : "low",
156
+ summary: {
157
+ delta: -totalLost,
158
+ direction: totalLost > 0 ? "down" : "flat",
159
+ magnitudeLabel: `${Math.round(totalLost)} clicks lost`
160
+ },
161
+ findings,
162
+ coverage: decayRes && moversRes ? "full" : "partial",
163
+ actions: lostPages.slice(0, 1).map((r) => ({
164
+ kind: "analyzer",
165
+ target: {
166
+ kind: "page",
167
+ value: r.page
168
+ },
169
+ params: { type: "change-point" },
170
+ rationale: "Investigate the change-point on the worst-affected page",
171
+ cliHint: `gscdump analyze change-point --start <date> --end <date>`
172
+ }))
173
+ };
174
+ }
175
+ function buildStrikingSection(res, max) {
176
+ const rows = reportRows(res).sort((a, b) => b.potentialClicks - a.potentialClicks);
177
+ const kept = rows.slice(0, max);
178
+ const findings = kept.map((r) => ({
179
+ entity: {
180
+ kind: "query",
181
+ value: r.keyword
182
+ },
183
+ metrics: {
184
+ position: r.position,
185
+ impressions: r.impressions,
186
+ clicks: r.clicks,
187
+ potentialClicks: r.potentialClicks
188
+ },
189
+ why: r.page ? `currently on ${r.page}` : void 0
190
+ }));
191
+ return {
192
+ id: "striking-distance",
193
+ title: "Striking-distance opportunities",
194
+ severity: "low",
195
+ summary: { magnitudeLabel: `${kept.reduce((s, r) => s + r.potentialClicks, 0)} potential clicks` },
196
+ findings,
197
+ truncated: truncation(rows.length, kept.length),
198
+ coverage: sectionCoverage(res),
199
+ actions: [],
200
+ artifact: sectionArtifact(res, "striking-distance")
201
+ };
202
+ }
203
+ export { moversReport };