@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.
- package/dist/action-priority.d.mts +34 -0
- package/dist/action-priority.mjs +201 -0
- package/dist/analyzer/adapt-rows.mjs +12 -0
- package/dist/analyzer/all.mjs +61 -0
- package/dist/analyzer/paginate.mjs +85 -0
- package/dist/analyzer/row-analyzers.d.mts +3 -0
- package/dist/analyzer/row-analyzers.mjs +3 -0
- package/dist/analyzer/row-values.mjs +22 -0
- package/dist/analyzers/bayesian-ctr.mjs +177 -0
- package/dist/analyzers/bipartite-pagerank.mjs +243 -0
- package/dist/analyzers/brand.d.mts +33 -0
- package/dist/analyzers/brand.mjs +154 -0
- package/dist/analyzers/cannibalization.d.mts +58 -0
- package/dist/analyzers/cannibalization.mjs +267 -0
- package/dist/analyzers/change-point.mjs +197 -0
- package/dist/analyzers/clustering.d.mts +29 -0
- package/dist/analyzers/clustering.mjs +217 -0
- package/dist/analyzers/concentration.d.mts +42 -0
- package/dist/analyzers/concentration.mjs +180 -0
- package/dist/analyzers/content-velocity.mjs +87 -0
- package/dist/analyzers/ctr-anomaly.mjs +189 -0
- package/dist/analyzers/ctr-curve.mjs +128 -0
- package/dist/analyzers/dark-traffic.mjs +130 -0
- package/dist/analyzers/data-detail.mjs +44 -0
- package/dist/analyzers/data-query.mjs +44 -0
- package/dist/analyzers/decay.d.mts +38 -0
- package/dist/analyzers/decay.mjs +187 -0
- package/dist/analyzers/device-gap.mjs +135 -0
- package/dist/analyzers/intent-atlas.mjs +175 -0
- package/dist/analyzers/keyword-breadth.mjs +115 -0
- package/dist/analyzers/long-tail.mjs +150 -0
- package/dist/analyzers/movers.d.mts +43 -0
- package/dist/analyzers/movers.mjs +283 -0
- package/dist/analyzers/opportunity.d.mts +32 -0
- package/dist/analyzers/opportunity.mjs +212 -0
- package/dist/analyzers/position-distribution.mjs +57 -0
- package/dist/analyzers/position-volatility.mjs +143 -0
- package/dist/analyzers/query-migration.mjs +178 -0
- package/dist/analyzers/seasonality.d.mts +28 -0
- package/dist/analyzers/seasonality.mjs +136 -0
- package/dist/analyzers/stl-decompose.mjs +187 -0
- package/dist/analyzers/striking-distance.d.mts +54 -0
- package/dist/analyzers/striking-distance.mjs +82 -0
- package/dist/analyzers/survival.mjs +200 -0
- package/dist/analyzers/trends.mjs +156 -0
- package/dist/analyzers/zero-click.d.mts +17 -0
- package/dist/analyzers/zero-click.mjs +122 -0
- package/dist/browser.d.mts +5 -0
- package/dist/browser.mjs +11 -0
- package/dist/default-registry.d.mts +3 -1
- package/dist/default-registry.mjs +5 -1
- package/dist/errors.d.mts +51 -1
- package/dist/index.d.mts +30 -494
- package/dist/index.mjs +28 -49
- package/dist/query/analyzers.mjs +301 -0
- package/dist/query/index.mjs +4 -0
- package/dist/query/intent.d.mts +20 -0
- package/dist/query/intent.mjs +139 -0
- package/dist/query/normalize.d.mts +15 -0
- package/dist/query/normalize.mjs +204 -0
- package/dist/report/format.d.mts +7 -0
- package/dist/report/format.mjs +45 -0
- package/dist/report/index.d.mts +4 -1
- package/dist/report/index.mjs +4 -1
- package/dist/report/registry.d.mts +4 -0
- package/dist/report/registry.mjs +26 -0
- package/dist/report/reports/brand.mjs +104 -0
- package/dist/report/reports/growth.mjs +144 -0
- package/dist/report/reports/health.mjs +141 -0
- package/dist/report/reports/movers.mjs +203 -0
- package/dist/report/reports/opportunities.mjs +184 -0
- package/dist/report/reports/pre-publish.mjs +111 -0
- package/dist/report/reports/priority.mjs +97 -0
- package/dist/report/reports/risks.mjs +201 -0
- package/dist/report/reports/triage.mjs +155 -0
- package/dist/report/require.mjs +15 -0
- package/dist/report/resolve-target.d.mts +29 -0
- package/dist/report/resolve-target.mjs +32 -0
- package/dist/{_chunks/index.d.mts → report/runtime.d.mts} +2 -37
- package/dist/report/runtime.mjs +80 -0
- package/dist/report/sections.mjs +22 -0
- package/dist/sitemap-health.d.mts +34 -0
- package/dist/sitemap-health.mjs +37 -0
- package/dist/{_chunks/index2.d.mts → source/composite.d.mts} +2 -13
- package/dist/{_chunks/source.mjs → source/composite.mjs} +1 -17
- package/dist/source/in-memory.d.mts +14 -0
- package/dist/source/in-memory.mjs +17 -0
- package/dist/source/index.d.mts +2 -1
- package/dist/source/index.mjs +2 -1
- package/dist/sql-analyzers.d.mts +3 -0
- package/dist/sql-analyzers.mjs +3 -0
- package/dist/types.d.mts +34 -0
- package/dist/types.mjs +21 -0
- package/package.json +4 -4
- package/dist/_chunks/default-registry.d.mts +0 -5
- package/dist/_chunks/default-registry.mjs +0 -5183
- package/dist/_chunks/errors.d.mts +0 -52
- package/dist/_chunks/report.mjs +0 -1710
- /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 };
|
package/dist/report/index.d.mts
CHANGED
|
@@ -1,2 +1,5 @@
|
|
|
1
|
-
import {
|
|
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 };
|
package/dist/report/index.mjs
CHANGED
|
@@ -1,2 +1,5 @@
|
|
|
1
|
-
import {
|
|
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,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 };
|