@gscdump/analysis 0.8.1 → 0.9.0
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/README.md +15 -5
- package/dist/analyzer/index.mjs +1 -2
- package/dist/default-registry.mjs +1 -2
- package/dist/index.d.mts +43 -22
- package/dist/index.mjs +1613 -78
- package/dist/report/index.d.mts +71 -0
- package/dist/report/index.mjs +1814 -0
- package/package.json +9 -4
package/dist/index.mjs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { AnalyzerCapabilityError, createAnalyzerRegistry, createAnalyzerRegistry as createAnalyzerRegistry$1, defineAnalyzer, defineAnalyzer as defineAnalyzer$1, runAnalyzerFromSource, runAnalyzerFromSource as runAnalyzerFromSource$1 } from "@gscdump/engine/analyzer";
|
|
2
1
|
import { num, num as num$1 } from "@gscdump/engine/analysis-types";
|
|
2
|
+
import { AnalyzerCapabilityError, createAnalyzerRegistry, createAnalyzerRegistry as createAnalyzerRegistry$1, defineAnalyzer, defineAnalyzer as defineAnalyzer$1, runAnalyzerFromSource, runAnalyzerFromSource as runAnalyzerFromSource$1 } from "@gscdump/engine/analyzer";
|
|
3
3
|
import { comparisonOf, comparisonOf as comparisonOf$1, defaultEndDate, padTimeseries, padTimeseries as padTimeseries$1, periodOf, periodOf as periodOf$1, resolveWindow, windowToComparisonPeriod, windowToPeriod } from "@gscdump/engine/period";
|
|
4
4
|
import { enumeratePartitions } from "@gscdump/engine/planner";
|
|
5
5
|
import { METRIC_EXPR } from "@gscdump/engine/sql-fragments";
|
|
@@ -7,6 +7,7 @@ import { between, date, extractDateRange, gsc, page, query } from "gscdump/query
|
|
|
7
7
|
import { ENGINE_QUERY_CAPABILITIES, createAttachedTableSource, createEngineQuerySource, queryComparisonRows, queryRows, queryRows as queryRows$1, rewriteForTableSource, runAnalyzerWithEngine, typedQuery, typedQuery as typedQuery$1 } from "@gscdump/engine/source";
|
|
8
8
|
import { MS_PER_DAY, daysAgo, toIsoDate } from "gscdump";
|
|
9
9
|
import { buildExtrasQueries, buildTotalsSql, isSqlQuerySource, mergeExtras, pgResolverAdapter, resolveComparisonSQL, resolveToSQL, resolveToSQLOptimized } from "@gscdump/engine/resolver";
|
|
10
|
+
import { computeInputHash, createReportRegistry, defineReport } from "@gscdump/engine/report";
|
|
10
11
|
import { canProxyToGsc } from "@gscdump/engine-gsc-api";
|
|
11
12
|
function clamp01(value) {
|
|
12
13
|
if (value < 0) return 0;
|
|
@@ -22,10 +23,7 @@ function percentDifference(current, previous) {
|
|
|
22
23
|
if (previous === 0) return current > 0 ? 100 : 0;
|
|
23
24
|
return (current - previous) / previous * 100;
|
|
24
25
|
}
|
|
25
|
-
|
|
26
|
-
return runAnalyzerFromSource$1(source, params, registry);
|
|
27
|
-
}
|
|
28
|
-
const DEFAULT_SOURCES = [
|
|
26
|
+
const DEFAULT_PRIORITY_SOURCES = [
|
|
29
27
|
"striking-distance",
|
|
30
28
|
"opportunity",
|
|
31
29
|
"cannibalization",
|
|
@@ -224,78 +222,14 @@ function scorePriorityActions(actions) {
|
|
|
224
222
|
actions.sort((a, b) => b.priorityScore - a.priorityScore);
|
|
225
223
|
return actions;
|
|
226
224
|
}
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
const states = /* @__PURE__ */ new Map();
|
|
230
|
-
for (const source of sources) {
|
|
231
|
-
const state = {
|
|
232
|
-
source,
|
|
233
|
-
status: "pending",
|
|
234
|
-
count: 0
|
|
235
|
-
};
|
|
236
|
-
states.set(source, state);
|
|
237
|
-
onSourceStatus?.(state);
|
|
238
|
-
}
|
|
239
|
-
const update = (source, patch) => {
|
|
240
|
-
const next = {
|
|
241
|
-
...states.get(source) ?? {
|
|
242
|
-
source,
|
|
243
|
-
status: "pending",
|
|
244
|
-
count: 0
|
|
245
|
-
},
|
|
246
|
-
...patch
|
|
247
|
-
};
|
|
248
|
-
states.set(source, next);
|
|
249
|
-
onSourceStatus?.(next);
|
|
250
|
-
};
|
|
251
|
-
const runOne = async (source) => {
|
|
252
|
-
update(source, {
|
|
253
|
-
status: "running",
|
|
254
|
-
error: void 0
|
|
255
|
-
});
|
|
256
|
-
const params = {
|
|
257
|
-
type: source,
|
|
258
|
-
...paramsBySource[source] ?? {}
|
|
259
|
-
};
|
|
260
|
-
return analyzer.analyze(params).then((result) => {
|
|
261
|
-
const normalized = normalizePriorityActions(source, result);
|
|
262
|
-
update(source, {
|
|
263
|
-
status: normalized.length === 0 ? "skipped" : "done",
|
|
264
|
-
count: normalized.length
|
|
265
|
-
});
|
|
266
|
-
return normalized;
|
|
267
|
-
}).catch((error) => {
|
|
268
|
-
update(source, {
|
|
269
|
-
status: "error",
|
|
270
|
-
count: 0,
|
|
271
|
-
error: error instanceof Error ? error.message : String(error)
|
|
272
|
-
});
|
|
273
|
-
if (!continueOnError) throw error;
|
|
274
|
-
return [];
|
|
275
|
-
});
|
|
276
|
-
};
|
|
277
|
-
const all = (await Promise.all(sources.map(runOne))).flat();
|
|
278
|
-
return {
|
|
279
|
-
actions: scorePriorityActions(mergePriorityActions(all)).slice(0, limit),
|
|
280
|
-
totalSignals: all.length,
|
|
281
|
-
sources: sources.map((source) => states.get(source) ?? {
|
|
282
|
-
source,
|
|
283
|
-
status: "pending",
|
|
284
|
-
count: 0
|
|
285
|
-
})
|
|
286
|
-
};
|
|
287
|
-
}
|
|
288
|
-
async function analyzeActionPriorityFromSource(source, registry, options = {}) {
|
|
289
|
-
return analyzeActionPriority({ analyze: (params) => analyzeFromSource(source, params, registry) }, options);
|
|
290
|
-
}
|
|
291
|
-
const DEFAULT_LIMIT$1 = 25e3;
|
|
292
|
-
function keywordsQueryState(period, limit = DEFAULT_LIMIT$1) {
|
|
225
|
+
const DEFAULT_LIMIT$2 = 25e3;
|
|
226
|
+
function keywordsQueryState(period, limit = DEFAULT_LIMIT$2) {
|
|
293
227
|
return gsc.select(query, page).where(between(date, period.startDate, period.endDate)).limit(limit).getState();
|
|
294
228
|
}
|
|
295
|
-
function pagesQueryState(period, limit = DEFAULT_LIMIT$
|
|
229
|
+
function pagesQueryState(period, limit = DEFAULT_LIMIT$2) {
|
|
296
230
|
return gsc.select(page).where(between(date, period.startDate, period.endDate)).limit(limit).getState();
|
|
297
231
|
}
|
|
298
|
-
function datesQueryState(period, limit = DEFAULT_LIMIT$
|
|
232
|
+
function datesQueryState(period, limit = DEFAULT_LIMIT$2) {
|
|
299
233
|
return gsc.select(date).where(between(date, period.startDate, period.endDate)).limit(limit).getState();
|
|
300
234
|
}
|
|
301
235
|
function escapeRegexAlt(s) {
|
|
@@ -1547,9 +1481,9 @@ const moversAnalyzer = defineAnalyzer$1({
|
|
|
1547
1481
|
};
|
|
1548
1482
|
}
|
|
1549
1483
|
});
|
|
1550
|
-
const DEFAULT_LIMIT = 1e3;
|
|
1484
|
+
const DEFAULT_LIMIT$1 = 1e3;
|
|
1551
1485
|
const MAX_LIMIT = 5e4;
|
|
1552
|
-
function clampLimit(limit, fallback = DEFAULT_LIMIT) {
|
|
1486
|
+
function clampLimit(limit, fallback = DEFAULT_LIMIT$1) {
|
|
1553
1487
|
const n = Number(limit ?? fallback);
|
|
1554
1488
|
if (!Number.isFinite(n) || n <= 0) return fallback;
|
|
1555
1489
|
return Math.min(n, MAX_LIMIT);
|
|
@@ -3997,8 +3931,7 @@ const keywordBreadthAnalyzer = defineAnalyzer$1({
|
|
|
3997
3931
|
FROM per_page
|
|
3998
3932
|
)
|
|
3999
3933
|
SELECT
|
|
4000
|
-
(SELECT to_json(list({ 'bucket': bucket, 'pageCount': pageCount, 'sortKey': sort_key })
|
|
4001
|
-
ORDER BY sort_key ASC) FROM bucketed) AS distribution_json,
|
|
3934
|
+
(SELECT to_json(list({ 'bucket': bucket, 'pageCount': pageCount, 'sortKey': sort_key })) FROM bucketed) AS distribution_json,
|
|
4002
3935
|
(SELECT to_json(list({ 'url': url, 'keywordCount': keywordCount, 'clicks': clicks, 'impressions': impressions })) FROM fragile) AS fragile_json,
|
|
4003
3936
|
(SELECT to_json(list({ 'url': url, 'keywordCount': keywordCount, 'clicks': clicks, 'impressions': impressions })) FROM authority) AS authority_json,
|
|
4004
3937
|
(SELECT to_json({
|
|
@@ -5168,6 +5101,1608 @@ async function analyzeInBrowser(runner, opts, params) {
|
|
|
5168
5101
|
opts.signal?.throwIfAborted();
|
|
5169
5102
|
return runAnalyzerFromSource$1(createAttachedTableSource(runner, opts), params, defaultAnalyzerRegistry);
|
|
5170
5103
|
}
|
|
5104
|
+
const SEVERITY_GLYPH = {
|
|
5105
|
+
info: "i",
|
|
5106
|
+
low: "·",
|
|
5107
|
+
medium: "!",
|
|
5108
|
+
high: "!!"
|
|
5109
|
+
};
|
|
5110
|
+
function formatReport(report, opts = {}) {
|
|
5111
|
+
const lines = [];
|
|
5112
|
+
lines.push(`# ${report.id} — ${report.site}`);
|
|
5113
|
+
lines.push(`window: ${report.window.start} → ${report.window.end} (${report.window.days}d)`);
|
|
5114
|
+
if (report.window.comparison) lines.push(`compare: ${report.window.comparison.start} → ${report.window.comparison.end}`);
|
|
5115
|
+
if (report.meta.degraded) lines.push(`! degraded: ${report.meta.steps.filter((s) => s.status === "error").map((s) => s.key).join(", ")}`);
|
|
5116
|
+
lines.push("");
|
|
5117
|
+
if (report.sections.length === 0) {
|
|
5118
|
+
lines.push("(no sections)");
|
|
5119
|
+
return lines.join("\n");
|
|
5120
|
+
}
|
|
5121
|
+
for (const section of report.sections) lines.push(...renderSection(section, opts.maxFindingsPerSection));
|
|
5122
|
+
return lines.join("\n");
|
|
5123
|
+
}
|
|
5124
|
+
function renderSection(section, cap) {
|
|
5125
|
+
const lines = [];
|
|
5126
|
+
const glyph = SEVERITY_GLYPH[section.severity] ?? "";
|
|
5127
|
+
lines.push(`## ${glyph} ${section.title}${section.coverage === "partial" ? " (partial)" : ""}`);
|
|
5128
|
+
if (section.summary.magnitudeLabel) lines.push(` ${section.summary.magnitudeLabel}`);
|
|
5129
|
+
const findings = cap ? section.findings.slice(0, cap) : section.findings;
|
|
5130
|
+
for (const f of findings) {
|
|
5131
|
+
const metricsStr = Object.entries(f.metrics).map(([k, v]) => `${k}=${formatNumber(v)}`).join(" ");
|
|
5132
|
+
lines.push(` - [${f.entity.kind}] ${f.entity.value} ${metricsStr}${f.why ? ` — ${f.why}` : ""}`);
|
|
5133
|
+
}
|
|
5134
|
+
if (section.truncated && section.truncated.kept < section.truncated.total) lines.push(` … +${section.truncated.total - section.truncated.kept} more`);
|
|
5135
|
+
for (const a of section.actions) {
|
|
5136
|
+
const target = a.target ? ` ${a.target.kind}=${a.target.value}` : "";
|
|
5137
|
+
lines.push(` → ${a.kind}${target}: ${a.rationale}`);
|
|
5138
|
+
if (a.cliHint) lines.push(` $ ${a.cliHint}`);
|
|
5139
|
+
}
|
|
5140
|
+
lines.push("");
|
|
5141
|
+
return lines;
|
|
5142
|
+
}
|
|
5143
|
+
function formatNumber(n) {
|
|
5144
|
+
if (!Number.isFinite(n)) return String(n);
|
|
5145
|
+
if (Number.isInteger(n)) return String(n);
|
|
5146
|
+
return n.toFixed(2);
|
|
5147
|
+
}
|
|
5148
|
+
const DEFAULT_MAX$7 = 5;
|
|
5149
|
+
const brandReport = defineReport({
|
|
5150
|
+
id: "brand",
|
|
5151
|
+
description: "Brand vs non-brand share, top brand keywords, and site-wide keyword concentration.",
|
|
5152
|
+
defaultPeriod: "last-28d",
|
|
5153
|
+
defaultComparison: "none",
|
|
5154
|
+
argsSpec: {
|
|
5155
|
+
"brand-terms": {
|
|
5156
|
+
type: "string",
|
|
5157
|
+
description: "Comma-separated brand terms",
|
|
5158
|
+
required: true
|
|
5159
|
+
},
|
|
5160
|
+
"max-findings": {
|
|
5161
|
+
type: "number",
|
|
5162
|
+
description: "Cap findings per section",
|
|
5163
|
+
default: DEFAULT_MAX$7
|
|
5164
|
+
}
|
|
5165
|
+
},
|
|
5166
|
+
plan: (params, window) => {
|
|
5167
|
+
if (!params.brandTerms || !params.brandTerms.trim()) throw new Error("brand report requires --brand-terms <comma,separated,list>");
|
|
5168
|
+
const brandTerms = params.brandTerms.split(",").map((t) => t.trim()).filter(Boolean);
|
|
5169
|
+
const dates = {
|
|
5170
|
+
startDate: window.start,
|
|
5171
|
+
endDate: window.end
|
|
5172
|
+
};
|
|
5173
|
+
return [{
|
|
5174
|
+
key: "brand",
|
|
5175
|
+
type: "brand",
|
|
5176
|
+
params: {
|
|
5177
|
+
...dates,
|
|
5178
|
+
brandTerms,
|
|
5179
|
+
limit: 200
|
|
5180
|
+
},
|
|
5181
|
+
required: true
|
|
5182
|
+
}, {
|
|
5183
|
+
key: "concentration",
|
|
5184
|
+
type: "concentration",
|
|
5185
|
+
params: {
|
|
5186
|
+
...dates,
|
|
5187
|
+
dimension: "keywords",
|
|
5188
|
+
limit: 50
|
|
5189
|
+
}
|
|
5190
|
+
}];
|
|
5191
|
+
},
|
|
5192
|
+
reduce: (results, ctx) => {
|
|
5193
|
+
const max = ctx.params.maxFindings ?? DEFAULT_MAX$7;
|
|
5194
|
+
return { sections: [buildBrandSplitSection(results.brand, max), buildConcentrationSection(results.concentration, max)] };
|
|
5195
|
+
}
|
|
5196
|
+
});
|
|
5197
|
+
function buildBrandSplitSection(res, max) {
|
|
5198
|
+
const rows = res?.results ?? [];
|
|
5199
|
+
const summary = res?.meta?.summary;
|
|
5200
|
+
const findings = rows.filter((r) => r.segment === "brand").sort((a, b) => b.clicks - a.clicks).slice(0, max).map((r) => ({
|
|
5201
|
+
entity: {
|
|
5202
|
+
kind: "query",
|
|
5203
|
+
value: r.query
|
|
5204
|
+
},
|
|
5205
|
+
metrics: {
|
|
5206
|
+
clicks: r.clicks,
|
|
5207
|
+
impressions: r.impressions,
|
|
5208
|
+
ctr: r.ctr,
|
|
5209
|
+
position: r.position
|
|
5210
|
+
},
|
|
5211
|
+
why: r.page ? `on ${r.page}` : void 0
|
|
5212
|
+
}));
|
|
5213
|
+
const brandShare = summary?.brandShare ?? 0;
|
|
5214
|
+
return {
|
|
5215
|
+
id: "brand-split",
|
|
5216
|
+
title: "Brand vs non-brand",
|
|
5217
|
+
severity: "info",
|
|
5218
|
+
summary: { magnitudeLabel: summary ? `brand share ${(brandShare * 100).toFixed(1)}% (${summary.brandClicks} brand vs ${summary.nonBrandClicks} non-brand clicks)` : "no summary available" },
|
|
5219
|
+
findings,
|
|
5220
|
+
coverage: res ? "full" : "partial",
|
|
5221
|
+
actions: [],
|
|
5222
|
+
artifact: res ? {
|
|
5223
|
+
analyzer: "brand",
|
|
5224
|
+
params: { type: "brand" }
|
|
5225
|
+
} : void 0
|
|
5226
|
+
};
|
|
5227
|
+
}
|
|
5228
|
+
function buildConcentrationSection(res, max) {
|
|
5229
|
+
const head = (res?.results ?? [])[0];
|
|
5230
|
+
const findings = (head?.topNItems ?? []).slice(0, max).map((it) => ({
|
|
5231
|
+
entity: {
|
|
5232
|
+
kind: "query",
|
|
5233
|
+
value: it.key
|
|
5234
|
+
},
|
|
5235
|
+
metrics: {
|
|
5236
|
+
clicks: it.clicks,
|
|
5237
|
+
share: it.share
|
|
5238
|
+
}
|
|
5239
|
+
}));
|
|
5240
|
+
const severity = head?.riskLevel === "high" ? "high" : head?.riskLevel === "medium" ? "medium" : "low";
|
|
5241
|
+
return {
|
|
5242
|
+
id: "concentration",
|
|
5243
|
+
title: "Keyword concentration (site-wide)",
|
|
5244
|
+
severity: head ? severity : "info",
|
|
5245
|
+
summary: head ? { magnitudeLabel: `HHI ${head.hhi.toFixed(0)} (${head.riskLevel}); top-N share ${(head.topNConcentration * 100).toFixed(1)}%` } : {},
|
|
5246
|
+
findings,
|
|
5247
|
+
coverage: res ? "full" : "partial",
|
|
5248
|
+
actions: [],
|
|
5249
|
+
artifact: res ? {
|
|
5250
|
+
analyzer: "concentration",
|
|
5251
|
+
params: {
|
|
5252
|
+
type: "concentration",
|
|
5253
|
+
dimension: "keywords"
|
|
5254
|
+
}
|
|
5255
|
+
} : void 0
|
|
5256
|
+
};
|
|
5257
|
+
}
|
|
5258
|
+
const DEFAULT_MAX$6 = 5;
|
|
5259
|
+
const growthReport = defineReport({
|
|
5260
|
+
id: "growth",
|
|
5261
|
+
description: "Strategic growth signals: content velocity, keyword breadth, intent atlas, and long-tail shape over a long window (default 90d / YoY).",
|
|
5262
|
+
defaultPeriod: "last-90d",
|
|
5263
|
+
defaultComparison: "yoy",
|
|
5264
|
+
argsSpec: { "max-findings": {
|
|
5265
|
+
type: "number",
|
|
5266
|
+
description: "Cap findings per section (long-tail only)",
|
|
5267
|
+
default: DEFAULT_MAX$6
|
|
5268
|
+
} },
|
|
5269
|
+
plan: (_params, window) => {
|
|
5270
|
+
const dates = {
|
|
5271
|
+
startDate: window.start,
|
|
5272
|
+
endDate: window.end
|
|
5273
|
+
};
|
|
5274
|
+
return [
|
|
5275
|
+
{
|
|
5276
|
+
key: "content-velocity",
|
|
5277
|
+
type: "content-velocity",
|
|
5278
|
+
params: {
|
|
5279
|
+
...dates,
|
|
5280
|
+
days: window.days,
|
|
5281
|
+
limit: 200
|
|
5282
|
+
}
|
|
5283
|
+
},
|
|
5284
|
+
{
|
|
5285
|
+
key: "keyword-breadth",
|
|
5286
|
+
type: "keyword-breadth",
|
|
5287
|
+
params: {
|
|
5288
|
+
...dates,
|
|
5289
|
+
limit: 50
|
|
5290
|
+
}
|
|
5291
|
+
},
|
|
5292
|
+
{
|
|
5293
|
+
key: "intent-atlas",
|
|
5294
|
+
type: "intent-atlas",
|
|
5295
|
+
params: {
|
|
5296
|
+
...dates,
|
|
5297
|
+
limit: 50
|
|
5298
|
+
}
|
|
5299
|
+
},
|
|
5300
|
+
{
|
|
5301
|
+
key: "long-tail",
|
|
5302
|
+
type: "long-tail",
|
|
5303
|
+
params: {
|
|
5304
|
+
...dates,
|
|
5305
|
+
limit: 100
|
|
5306
|
+
}
|
|
5307
|
+
}
|
|
5308
|
+
];
|
|
5309
|
+
},
|
|
5310
|
+
reduce: (results, ctx) => {
|
|
5311
|
+
const max = ctx.params.maxFindings ?? DEFAULT_MAX$6;
|
|
5312
|
+
return { sections: [
|
|
5313
|
+
buildContentVelocity(results["content-velocity"]),
|
|
5314
|
+
buildKeywordBreadth(results["keyword-breadth"]),
|
|
5315
|
+
buildIntentAtlas(results["intent-atlas"]),
|
|
5316
|
+
buildLongTail(results["long-tail"], max)
|
|
5317
|
+
] };
|
|
5318
|
+
}
|
|
5319
|
+
});
|
|
5320
|
+
function buildContentVelocity(res) {
|
|
5321
|
+
const rows = res?.results ?? [];
|
|
5322
|
+
const totalNew = rows.reduce((s, r) => s + r.newKeywords, 0);
|
|
5323
|
+
const avgPerWeek = rows.length > 0 ? totalNew / rows.length : 0;
|
|
5324
|
+
return {
|
|
5325
|
+
id: "content-velocity",
|
|
5326
|
+
title: "Content velocity",
|
|
5327
|
+
severity: "info",
|
|
5328
|
+
summary: { magnitudeLabel: `${totalNew} new keywords across ${rows.length} weeks (avg ${avgPerWeek.toFixed(1)}/wk)` },
|
|
5329
|
+
findings: [],
|
|
5330
|
+
coverage: res ? "full" : "partial",
|
|
5331
|
+
actions: [],
|
|
5332
|
+
artifact: res ? {
|
|
5333
|
+
analyzer: "content-velocity",
|
|
5334
|
+
params: { type: "content-velocity" }
|
|
5335
|
+
} : void 0
|
|
5336
|
+
};
|
|
5337
|
+
}
|
|
5338
|
+
function buildKeywordBreadth(res) {
|
|
5339
|
+
const rows = res?.results ?? [];
|
|
5340
|
+
const totalPages = rows.reduce((s, r) => s + r.pageCount, 0);
|
|
5341
|
+
const top = rows[0];
|
|
5342
|
+
return {
|
|
5343
|
+
id: "keyword-breadth",
|
|
5344
|
+
title: "Keyword breadth",
|
|
5345
|
+
severity: "info",
|
|
5346
|
+
summary: { magnitudeLabel: top ? `${totalPages} pages; modal bucket "${top.bucket}" (${top.pageCount} pages)` : "no data" },
|
|
5347
|
+
findings: [],
|
|
5348
|
+
coverage: res ? "full" : "partial",
|
|
5349
|
+
actions: [],
|
|
5350
|
+
artifact: res ? {
|
|
5351
|
+
analyzer: "keyword-breadth",
|
|
5352
|
+
params: { type: "keyword-breadth" }
|
|
5353
|
+
} : void 0
|
|
5354
|
+
};
|
|
5355
|
+
}
|
|
5356
|
+
function buildIntentAtlas(res) {
|
|
5357
|
+
const rows = res?.results ?? [];
|
|
5358
|
+
return {
|
|
5359
|
+
id: "intent-atlas",
|
|
5360
|
+
title: "Intent atlas",
|
|
5361
|
+
severity: "info",
|
|
5362
|
+
summary: { magnitudeLabel: `${rows.length} clusters covering ${rows.reduce((s, r) => s + r.keywordCount, 0)} keywords` },
|
|
5363
|
+
findings: [],
|
|
5364
|
+
coverage: res ? "full" : "partial",
|
|
5365
|
+
actions: [],
|
|
5366
|
+
artifact: res ? {
|
|
5367
|
+
analyzer: "intent-atlas",
|
|
5368
|
+
params: { type: "intent-atlas" }
|
|
5369
|
+
} : void 0
|
|
5370
|
+
};
|
|
5371
|
+
}
|
|
5372
|
+
function buildLongTail(res, max) {
|
|
5373
|
+
const rows = (res?.results ?? []).sort((a, b) => {
|
|
5374
|
+
const rank = {
|
|
5375
|
+
"head-heavy": 0,
|
|
5376
|
+
"balanced": 1,
|
|
5377
|
+
"flat-tail": 2
|
|
5378
|
+
};
|
|
5379
|
+
const dr = rank[a.fingerprint] - rank[b.fingerprint];
|
|
5380
|
+
return dr !== 0 ? dr : b.headShare - a.headShare;
|
|
5381
|
+
});
|
|
5382
|
+
const kept = rows.slice(0, max);
|
|
5383
|
+
const findings = kept.map((r) => ({
|
|
5384
|
+
entity: {
|
|
5385
|
+
kind: "page",
|
|
5386
|
+
value: r.page
|
|
5387
|
+
},
|
|
5388
|
+
metrics: {
|
|
5389
|
+
queryCount: r.queryCount,
|
|
5390
|
+
impressions: r.totalImpressions,
|
|
5391
|
+
headShare: r.headShare
|
|
5392
|
+
},
|
|
5393
|
+
why: `${r.fingerprint} (${(r.headShare * 100).toFixed(0)}% head share)`
|
|
5394
|
+
}));
|
|
5395
|
+
const headHeavy = rows.filter((r) => r.fingerprint === "head-heavy").length;
|
|
5396
|
+
return {
|
|
5397
|
+
id: "long-tail",
|
|
5398
|
+
title: "Long-tail shape",
|
|
5399
|
+
severity: headHeavy > rows.length / 3 ? "medium" : "info",
|
|
5400
|
+
summary: { magnitudeLabel: `${rows.length} pages analysed; ${headHeavy} head-heavy` },
|
|
5401
|
+
findings,
|
|
5402
|
+
truncated: rows.length > max ? {
|
|
5403
|
+
kept: kept.length,
|
|
5404
|
+
total: rows.length
|
|
5405
|
+
} : void 0,
|
|
5406
|
+
coverage: res ? "full" : "partial",
|
|
5407
|
+
actions: [],
|
|
5408
|
+
artifact: res ? {
|
|
5409
|
+
analyzer: "long-tail",
|
|
5410
|
+
params: { type: "long-tail" }
|
|
5411
|
+
} : void 0
|
|
5412
|
+
};
|
|
5413
|
+
}
|
|
5414
|
+
const DEFAULT_MAX$5 = 5;
|
|
5415
|
+
const healthReport = defineReport({
|
|
5416
|
+
id: "health",
|
|
5417
|
+
description: "CTR anomalies, change-points, and position-volatility hot spots in the recent window.",
|
|
5418
|
+
defaultPeriod: "last-28d",
|
|
5419
|
+
defaultComparison: "none",
|
|
5420
|
+
argsSpec: { "max-findings": {
|
|
5421
|
+
type: "number",
|
|
5422
|
+
description: "Cap findings per section",
|
|
5423
|
+
default: DEFAULT_MAX$5
|
|
5424
|
+
} },
|
|
5425
|
+
plan: (_params, window) => {
|
|
5426
|
+
const dates = {
|
|
5427
|
+
startDate: window.start,
|
|
5428
|
+
endDate: window.end
|
|
5429
|
+
};
|
|
5430
|
+
return [
|
|
5431
|
+
{
|
|
5432
|
+
key: "ctr-anomaly",
|
|
5433
|
+
type: "ctr-anomaly",
|
|
5434
|
+
params: {
|
|
5435
|
+
...dates,
|
|
5436
|
+
limit: 100
|
|
5437
|
+
},
|
|
5438
|
+
required: true
|
|
5439
|
+
},
|
|
5440
|
+
{
|
|
5441
|
+
key: "change-point",
|
|
5442
|
+
type: "change-point",
|
|
5443
|
+
params: {
|
|
5444
|
+
...dates,
|
|
5445
|
+
limit: 100
|
|
5446
|
+
}
|
|
5447
|
+
},
|
|
5448
|
+
{
|
|
5449
|
+
key: "position-volatility",
|
|
5450
|
+
type: "position-volatility",
|
|
5451
|
+
params: {
|
|
5452
|
+
...dates,
|
|
5453
|
+
limit: 100
|
|
5454
|
+
}
|
|
5455
|
+
}
|
|
5456
|
+
];
|
|
5457
|
+
},
|
|
5458
|
+
reduce: (results, ctx) => {
|
|
5459
|
+
const max = ctx.params.maxFindings ?? DEFAULT_MAX$5;
|
|
5460
|
+
return { sections: [
|
|
5461
|
+
buildCtrAnomalySection(results["ctr-anomaly"], max),
|
|
5462
|
+
buildChangePointSection$1(results["change-point"], max),
|
|
5463
|
+
buildPositionVolatilitySection(results["position-volatility"], max)
|
|
5464
|
+
] };
|
|
5465
|
+
}
|
|
5466
|
+
});
|
|
5467
|
+
function buildCtrAnomalySection(res, max) {
|
|
5468
|
+
const rows = (res?.results ?? []).filter((r) => r.breachDaysDown > 0).sort((a, b) => b.clicksLost - a.clicksLost);
|
|
5469
|
+
const kept = rows.slice(0, max);
|
|
5470
|
+
const totalLost = kept.reduce((s, r) => s + r.clicksLost, 0);
|
|
5471
|
+
const findings = kept.map((r) => ({
|
|
5472
|
+
entity: {
|
|
5473
|
+
kind: "query",
|
|
5474
|
+
value: r.keyword
|
|
5475
|
+
},
|
|
5476
|
+
metrics: {
|
|
5477
|
+
clicksLost: r.clicksLost,
|
|
5478
|
+
breachDays: r.breachDaysDown,
|
|
5479
|
+
severity: r.severity,
|
|
5480
|
+
baselineCtr: r.baselineCtr
|
|
5481
|
+
},
|
|
5482
|
+
why: r.page ? `on ${r.page}` : void 0
|
|
5483
|
+
}));
|
|
5484
|
+
return {
|
|
5485
|
+
id: "ctr-anomaly",
|
|
5486
|
+
title: "CTR anomalies",
|
|
5487
|
+
severity: totalLost >= 100 ? "high" : totalLost >= 25 ? "medium" : kept.length ? "low" : "info",
|
|
5488
|
+
summary: {
|
|
5489
|
+
delta: -totalLost,
|
|
5490
|
+
direction: totalLost > 0 ? "down" : "flat",
|
|
5491
|
+
magnitudeLabel: `${Math.round(totalLost)} clicks lost vs baseline`
|
|
5492
|
+
},
|
|
5493
|
+
findings,
|
|
5494
|
+
truncated: rows.length > max ? {
|
|
5495
|
+
kept: kept.length,
|
|
5496
|
+
total: rows.length
|
|
5497
|
+
} : void 0,
|
|
5498
|
+
coverage: res ? "full" : "partial",
|
|
5499
|
+
actions: [],
|
|
5500
|
+
artifact: res ? {
|
|
5501
|
+
analyzer: "ctr-anomaly",
|
|
5502
|
+
params: { type: "ctr-anomaly" }
|
|
5503
|
+
} : void 0
|
|
5504
|
+
};
|
|
5505
|
+
}
|
|
5506
|
+
function buildChangePointSection$1(res, max) {
|
|
5507
|
+
const rows = (res?.results ?? []).filter((r) => r.direction === "worsened").sort((a, b) => b.llr - a.llr);
|
|
5508
|
+
const kept = rows.slice(0, max);
|
|
5509
|
+
const findings = kept.map((r) => ({
|
|
5510
|
+
entity: {
|
|
5511
|
+
kind: "query",
|
|
5512
|
+
value: r.keyword
|
|
5513
|
+
},
|
|
5514
|
+
metrics: {
|
|
5515
|
+
llr: r.llr,
|
|
5516
|
+
delta: r.delta
|
|
5517
|
+
},
|
|
5518
|
+
why: `worsened on ${r.changeDate}${r.page ? ` (${r.page})` : ""}`
|
|
5519
|
+
}));
|
|
5520
|
+
return {
|
|
5521
|
+
id: "change-point",
|
|
5522
|
+
title: "Change-points (worsening)",
|
|
5523
|
+
severity: kept.length ? "medium" : "info",
|
|
5524
|
+
summary: { magnitudeLabel: `${kept.length} worsening segments` },
|
|
5525
|
+
findings,
|
|
5526
|
+
truncated: rows.length > max ? {
|
|
5527
|
+
kept: kept.length,
|
|
5528
|
+
total: rows.length
|
|
5529
|
+
} : void 0,
|
|
5530
|
+
coverage: res ? "full" : "partial",
|
|
5531
|
+
actions: [],
|
|
5532
|
+
artifact: res ? {
|
|
5533
|
+
analyzer: "change-point",
|
|
5534
|
+
params: { type: "change-point" }
|
|
5535
|
+
} : void 0
|
|
5536
|
+
};
|
|
5537
|
+
}
|
|
5538
|
+
function buildPositionVolatilitySection(res, max) {
|
|
5539
|
+
const rows = (res?.results ?? []).sort((a, b) => b.peakVolatility - a.peakVolatility);
|
|
5540
|
+
const kept = rows.slice(0, max);
|
|
5541
|
+
const findings = kept.map((r) => ({
|
|
5542
|
+
entity: {
|
|
5543
|
+
kind: "page",
|
|
5544
|
+
value: r.page
|
|
5545
|
+
},
|
|
5546
|
+
metrics: {
|
|
5547
|
+
avgVolatility: r.avgVolatility,
|
|
5548
|
+
peakVolatility: r.peakVolatility,
|
|
5549
|
+
impressions: r.totalImpressions
|
|
5550
|
+
}
|
|
5551
|
+
}));
|
|
5552
|
+
return {
|
|
5553
|
+
id: "position-volatility",
|
|
5554
|
+
title: "Position volatility",
|
|
5555
|
+
severity: kept.length ? "low" : "info",
|
|
5556
|
+
summary: {},
|
|
5557
|
+
findings,
|
|
5558
|
+
truncated: rows.length > max ? {
|
|
5559
|
+
kept: kept.length,
|
|
5560
|
+
total: rows.length
|
|
5561
|
+
} : void 0,
|
|
5562
|
+
coverage: res ? "full" : "partial",
|
|
5563
|
+
actions: [],
|
|
5564
|
+
artifact: res ? {
|
|
5565
|
+
analyzer: "position-volatility",
|
|
5566
|
+
params: { type: "position-volatility" }
|
|
5567
|
+
} : void 0
|
|
5568
|
+
};
|
|
5569
|
+
}
|
|
5570
|
+
const DEFAULT_MAX$4 = 5;
|
|
5571
|
+
const DEFAULT_MIN_CHANGE = 5;
|
|
5572
|
+
const moversReport = defineReport({
|
|
5573
|
+
id: "movers",
|
|
5574
|
+
description: "Risers, decliners, and striking-distance opportunities over a current vs prior window.",
|
|
5575
|
+
defaultPeriod: "last-7d",
|
|
5576
|
+
defaultComparison: "prev-period",
|
|
5577
|
+
argsSpec: {
|
|
5578
|
+
"max-findings": {
|
|
5579
|
+
type: "number",
|
|
5580
|
+
description: "Cap findings per section",
|
|
5581
|
+
default: DEFAULT_MAX$4
|
|
5582
|
+
},
|
|
5583
|
+
"min-clicks-change": {
|
|
5584
|
+
type: "number",
|
|
5585
|
+
description: "Min absolute click change",
|
|
5586
|
+
default: DEFAULT_MIN_CHANGE
|
|
5587
|
+
}
|
|
5588
|
+
},
|
|
5589
|
+
plan: (_params, window) => {
|
|
5590
|
+
if (!window.comparison) throw new Error("movers report requires a comparison window — pass --vs prev-period");
|
|
5591
|
+
const cur = {
|
|
5592
|
+
startDate: window.start,
|
|
5593
|
+
endDate: window.end
|
|
5594
|
+
};
|
|
5595
|
+
const prev = {
|
|
5596
|
+
prevStartDate: window.comparison.start,
|
|
5597
|
+
prevEndDate: window.comparison.end
|
|
5598
|
+
};
|
|
5599
|
+
return [
|
|
5600
|
+
{
|
|
5601
|
+
key: "movers",
|
|
5602
|
+
type: "movers",
|
|
5603
|
+
params: {
|
|
5604
|
+
...cur,
|
|
5605
|
+
...prev,
|
|
5606
|
+
limit: 200
|
|
5607
|
+
},
|
|
5608
|
+
required: true
|
|
5609
|
+
},
|
|
5610
|
+
{
|
|
5611
|
+
key: "decay",
|
|
5612
|
+
type: "decay",
|
|
5613
|
+
params: {
|
|
5614
|
+
...cur,
|
|
5615
|
+
...prev,
|
|
5616
|
+
limit: 100
|
|
5617
|
+
}
|
|
5618
|
+
},
|
|
5619
|
+
{
|
|
5620
|
+
key: "striking",
|
|
5621
|
+
type: "striking-distance",
|
|
5622
|
+
params: {
|
|
5623
|
+
...cur,
|
|
5624
|
+
limit: 100
|
|
5625
|
+
}
|
|
5626
|
+
}
|
|
5627
|
+
];
|
|
5628
|
+
},
|
|
5629
|
+
reduce: (results, ctx) => {
|
|
5630
|
+
const max = ctx.params.maxFindings ?? DEFAULT_MAX$4;
|
|
5631
|
+
const minChange = ctx.params.minClicksChange ?? DEFAULT_MIN_CHANGE;
|
|
5632
|
+
const sections = [];
|
|
5633
|
+
const moversRes = results.movers;
|
|
5634
|
+
const decayRes = results.decay;
|
|
5635
|
+
const strikingRes = results.striking;
|
|
5636
|
+
sections.push(buildMoversSection(moversRes, "rising", max, minChange));
|
|
5637
|
+
sections.push(buildDeclinersSection(moversRes, decayRes, max, minChange));
|
|
5638
|
+
sections.push(buildStrikingSection$1(strikingRes, max));
|
|
5639
|
+
return { sections };
|
|
5640
|
+
}
|
|
5641
|
+
});
|
|
5642
|
+
function buildMoversSection(res, direction, max, minChange) {
|
|
5643
|
+
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));
|
|
5644
|
+
const total = rows.length;
|
|
5645
|
+
const kept = rows.slice(0, max);
|
|
5646
|
+
const findings = kept.map((r) => ({
|
|
5647
|
+
entity: {
|
|
5648
|
+
kind: "query",
|
|
5649
|
+
value: r.keyword
|
|
5650
|
+
},
|
|
5651
|
+
metrics: {
|
|
5652
|
+
clicks: r.recentClicks,
|
|
5653
|
+
clicksChange: r.clicksChange,
|
|
5654
|
+
clicksChangePercent: r.clicksChangePercent,
|
|
5655
|
+
positionChange: r.positionChange
|
|
5656
|
+
},
|
|
5657
|
+
delta: {
|
|
5658
|
+
metric: "clicks",
|
|
5659
|
+
prior: r.baselineClicks,
|
|
5660
|
+
current: r.recentClicks,
|
|
5661
|
+
pct: r.clicksChangePercent
|
|
5662
|
+
},
|
|
5663
|
+
why: r.page ? `on ${r.page}` : void 0
|
|
5664
|
+
}));
|
|
5665
|
+
const totalDelta = kept.reduce((sum, r) => sum + r.clicksChange, 0);
|
|
5666
|
+
return {
|
|
5667
|
+
id: direction,
|
|
5668
|
+
title: direction === "rising" ? "Rising queries" : "Declining queries",
|
|
5669
|
+
severity: direction === "rising" ? "info" : "medium",
|
|
5670
|
+
summary: {
|
|
5671
|
+
delta: totalDelta,
|
|
5672
|
+
direction: totalDelta > 0 ? "up" : totalDelta < 0 ? "down" : "flat"
|
|
5673
|
+
},
|
|
5674
|
+
findings,
|
|
5675
|
+
truncated: total > max ? {
|
|
5676
|
+
kept: kept.length,
|
|
5677
|
+
total
|
|
5678
|
+
} : void 0,
|
|
5679
|
+
coverage: res ? "full" : "partial",
|
|
5680
|
+
actions: [],
|
|
5681
|
+
artifact: res ? {
|
|
5682
|
+
analyzer: "movers",
|
|
5683
|
+
params: { type: "movers" }
|
|
5684
|
+
} : void 0
|
|
5685
|
+
};
|
|
5686
|
+
}
|
|
5687
|
+
function buildDeclinersSection(moversRes, decayRes, max, minChange) {
|
|
5688
|
+
const decliningQueries = (moversRes?.results ?? []).filter((r) => r.direction === "declining" && Math.abs(r.clicksChange) >= minChange).slice(0, max);
|
|
5689
|
+
const lostPages = (decayRes?.results ?? []).sort((a, b) => b.lostClicks - a.lostClicks).slice(0, max);
|
|
5690
|
+
const findings = [...decliningQueries.map((r) => ({
|
|
5691
|
+
entity: {
|
|
5692
|
+
kind: "query",
|
|
5693
|
+
value: r.keyword
|
|
5694
|
+
},
|
|
5695
|
+
metrics: {
|
|
5696
|
+
clicks: r.recentClicks,
|
|
5697
|
+
clicksChange: r.clicksChange
|
|
5698
|
+
},
|
|
5699
|
+
delta: {
|
|
5700
|
+
metric: "clicks",
|
|
5701
|
+
prior: r.baselineClicks,
|
|
5702
|
+
current: r.recentClicks,
|
|
5703
|
+
pct: r.clicksChangePercent
|
|
5704
|
+
}
|
|
5705
|
+
})), ...lostPages.map((r) => ({
|
|
5706
|
+
entity: {
|
|
5707
|
+
kind: "page",
|
|
5708
|
+
value: r.page
|
|
5709
|
+
},
|
|
5710
|
+
metrics: {
|
|
5711
|
+
clicks: r.currentClicks,
|
|
5712
|
+
lostClicks: r.lostClicks,
|
|
5713
|
+
declinePercent: r.declinePercent
|
|
5714
|
+
},
|
|
5715
|
+
delta: {
|
|
5716
|
+
metric: "clicks",
|
|
5717
|
+
prior: r.previousClicks,
|
|
5718
|
+
current: r.currentClicks,
|
|
5719
|
+
pct: -r.declinePercent * 100
|
|
5720
|
+
},
|
|
5721
|
+
why: "page-level decay"
|
|
5722
|
+
}))];
|
|
5723
|
+
const totalLost = decliningQueries.reduce((s, r) => s + Math.abs(r.clicksChange), 0) + lostPages.reduce((s, r) => s + r.lostClicks, 0);
|
|
5724
|
+
return {
|
|
5725
|
+
id: "decliners",
|
|
5726
|
+
title: "Decliners",
|
|
5727
|
+
severity: totalLost >= 100 ? "high" : totalLost >= 25 ? "medium" : "low",
|
|
5728
|
+
summary: {
|
|
5729
|
+
delta: -totalLost,
|
|
5730
|
+
direction: totalLost > 0 ? "down" : "flat",
|
|
5731
|
+
magnitudeLabel: `${Math.round(totalLost)} clicks lost`
|
|
5732
|
+
},
|
|
5733
|
+
findings,
|
|
5734
|
+
coverage: decayRes && moversRes ? "full" : "partial",
|
|
5735
|
+
actions: lostPages.slice(0, 1).map((r) => ({
|
|
5736
|
+
kind: "analyzer",
|
|
5737
|
+
target: {
|
|
5738
|
+
kind: "page",
|
|
5739
|
+
value: r.page
|
|
5740
|
+
},
|
|
5741
|
+
params: { type: "change-point" },
|
|
5742
|
+
rationale: "Investigate the change-point on the worst-affected page",
|
|
5743
|
+
cliHint: `gscdump analyze change-point --start <date> --end <date>`
|
|
5744
|
+
}))
|
|
5745
|
+
};
|
|
5746
|
+
}
|
|
5747
|
+
function buildStrikingSection$1(res, max) {
|
|
5748
|
+
const rows = (res?.results ?? []).sort((a, b) => b.potentialClicks - a.potentialClicks);
|
|
5749
|
+
const kept = rows.slice(0, max);
|
|
5750
|
+
const findings = kept.map((r) => ({
|
|
5751
|
+
entity: {
|
|
5752
|
+
kind: "query",
|
|
5753
|
+
value: r.keyword
|
|
5754
|
+
},
|
|
5755
|
+
metrics: {
|
|
5756
|
+
position: r.position,
|
|
5757
|
+
impressions: r.impressions,
|
|
5758
|
+
clicks: r.clicks,
|
|
5759
|
+
potentialClicks: r.potentialClicks
|
|
5760
|
+
},
|
|
5761
|
+
why: r.page ? `currently on ${r.page}` : void 0
|
|
5762
|
+
}));
|
|
5763
|
+
return {
|
|
5764
|
+
id: "striking-distance",
|
|
5765
|
+
title: "Striking-distance opportunities",
|
|
5766
|
+
severity: "low",
|
|
5767
|
+
summary: { magnitudeLabel: `${kept.reduce((s, r) => s + r.potentialClicks, 0)} potential clicks` },
|
|
5768
|
+
findings,
|
|
5769
|
+
truncated: rows.length > max ? {
|
|
5770
|
+
kept: kept.length,
|
|
5771
|
+
total: rows.length
|
|
5772
|
+
} : void 0,
|
|
5773
|
+
coverage: res ? "full" : "partial",
|
|
5774
|
+
actions: [],
|
|
5775
|
+
artifact: res ? {
|
|
5776
|
+
analyzer: "striking-distance",
|
|
5777
|
+
params: { type: "striking-distance" }
|
|
5778
|
+
} : void 0
|
|
5779
|
+
};
|
|
5780
|
+
}
|
|
5781
|
+
const DEFAULT_MAX$3 = 5;
|
|
5782
|
+
const opportunitiesReport = defineReport({
|
|
5783
|
+
id: "opportunities",
|
|
5784
|
+
description: "Striking-distance, low-CTR, zero-click, and query-migration opportunities.",
|
|
5785
|
+
defaultPeriod: "last-28d",
|
|
5786
|
+
defaultComparison: "none",
|
|
5787
|
+
argsSpec: { "max-findings": {
|
|
5788
|
+
type: "number",
|
|
5789
|
+
description: "Cap findings per section",
|
|
5790
|
+
default: DEFAULT_MAX$3
|
|
5791
|
+
} },
|
|
5792
|
+
plan: (_params, window) => {
|
|
5793
|
+
const dates = {
|
|
5794
|
+
startDate: window.start,
|
|
5795
|
+
endDate: window.end
|
|
5796
|
+
};
|
|
5797
|
+
return [
|
|
5798
|
+
{
|
|
5799
|
+
key: "striking",
|
|
5800
|
+
type: "striking-distance",
|
|
5801
|
+
params: {
|
|
5802
|
+
...dates,
|
|
5803
|
+
limit: 100
|
|
5804
|
+
},
|
|
5805
|
+
required: true
|
|
5806
|
+
},
|
|
5807
|
+
{
|
|
5808
|
+
key: "opportunity",
|
|
5809
|
+
type: "opportunity",
|
|
5810
|
+
params: {
|
|
5811
|
+
...dates,
|
|
5812
|
+
limit: 100
|
|
5813
|
+
}
|
|
5814
|
+
},
|
|
5815
|
+
{
|
|
5816
|
+
key: "zero-click",
|
|
5817
|
+
type: "zero-click",
|
|
5818
|
+
params: {
|
|
5819
|
+
...dates,
|
|
5820
|
+
limit: 100
|
|
5821
|
+
}
|
|
5822
|
+
},
|
|
5823
|
+
{
|
|
5824
|
+
key: "query-migration",
|
|
5825
|
+
type: "query-migration",
|
|
5826
|
+
params: {
|
|
5827
|
+
...dates,
|
|
5828
|
+
limit: 50
|
|
5829
|
+
}
|
|
5830
|
+
}
|
|
5831
|
+
];
|
|
5832
|
+
},
|
|
5833
|
+
reduce: (results, ctx) => {
|
|
5834
|
+
const max = ctx.params.maxFindings ?? DEFAULT_MAX$3;
|
|
5835
|
+
return { sections: [
|
|
5836
|
+
buildStrikingSection(results.striking, max),
|
|
5837
|
+
buildOpportunitySection(results.opportunity, max),
|
|
5838
|
+
buildZeroClickSection(results["zero-click"], max),
|
|
5839
|
+
buildMigrationSection$1(results["query-migration"], max)
|
|
5840
|
+
] };
|
|
5841
|
+
}
|
|
5842
|
+
});
|
|
5843
|
+
function buildStrikingSection(res, max) {
|
|
5844
|
+
const rows = (res?.results ?? []).sort((a, b) => b.potentialClicks - a.potentialClicks);
|
|
5845
|
+
const kept = rows.slice(0, max);
|
|
5846
|
+
const findings = kept.map((r) => ({
|
|
5847
|
+
entity: {
|
|
5848
|
+
kind: "query",
|
|
5849
|
+
value: r.keyword
|
|
5850
|
+
},
|
|
5851
|
+
metrics: {
|
|
5852
|
+
position: r.position,
|
|
5853
|
+
impressions: r.impressions,
|
|
5854
|
+
clicks: r.clicks,
|
|
5855
|
+
potentialClicks: r.potentialClicks
|
|
5856
|
+
},
|
|
5857
|
+
why: r.page ? `on ${r.page}` : void 0
|
|
5858
|
+
}));
|
|
5859
|
+
const totalPotential = kept.reduce((s, r) => s + r.potentialClicks, 0);
|
|
5860
|
+
return {
|
|
5861
|
+
id: "striking-distance",
|
|
5862
|
+
title: "Striking distance",
|
|
5863
|
+
severity: "low",
|
|
5864
|
+
summary: { magnitudeLabel: `${Math.round(totalPotential)} potential clicks` },
|
|
5865
|
+
findings,
|
|
5866
|
+
truncated: rows.length > max ? {
|
|
5867
|
+
kept: kept.length,
|
|
5868
|
+
total: rows.length
|
|
5869
|
+
} : void 0,
|
|
5870
|
+
coverage: res ? "full" : "partial",
|
|
5871
|
+
actions: [],
|
|
5872
|
+
artifact: res ? {
|
|
5873
|
+
analyzer: "striking-distance",
|
|
5874
|
+
params: { type: "striking-distance" }
|
|
5875
|
+
} : void 0
|
|
5876
|
+
};
|
|
5877
|
+
}
|
|
5878
|
+
function buildOpportunitySection(res, max) {
|
|
5879
|
+
const rows = (res?.results ?? []).sort((a, b) => b.opportunityScore - a.opportunityScore);
|
|
5880
|
+
const kept = rows.slice(0, max);
|
|
5881
|
+
return {
|
|
5882
|
+
id: "low-ctr",
|
|
5883
|
+
title: "Underperforming CTR",
|
|
5884
|
+
severity: "low",
|
|
5885
|
+
summary: {},
|
|
5886
|
+
findings: kept.map((r) => ({
|
|
5887
|
+
entity: {
|
|
5888
|
+
kind: "query",
|
|
5889
|
+
value: r.keyword
|
|
5890
|
+
},
|
|
5891
|
+
metrics: {
|
|
5892
|
+
opportunityScore: r.opportunityScore,
|
|
5893
|
+
ctr: r.ctr,
|
|
5894
|
+
position: r.position,
|
|
5895
|
+
impressions: r.impressions,
|
|
5896
|
+
potentialClicks: r.potentialClicks
|
|
5897
|
+
},
|
|
5898
|
+
why: r.page ? `low CTR on ${r.page}` : "low CTR vs position"
|
|
5899
|
+
})),
|
|
5900
|
+
truncated: rows.length > max ? {
|
|
5901
|
+
kept: kept.length,
|
|
5902
|
+
total: rows.length
|
|
5903
|
+
} : void 0,
|
|
5904
|
+
coverage: res ? "full" : "partial",
|
|
5905
|
+
actions: kept.slice(0, 1).map((r) => ({
|
|
5906
|
+
kind: "fix",
|
|
5907
|
+
target: r.page ? {
|
|
5908
|
+
kind: "page",
|
|
5909
|
+
value: r.page
|
|
5910
|
+
} : {
|
|
5911
|
+
kind: "query",
|
|
5912
|
+
value: r.keyword
|
|
5913
|
+
},
|
|
5914
|
+
rationale: "Rewrite title/description to lift CTR at this position"
|
|
5915
|
+
})),
|
|
5916
|
+
artifact: res ? {
|
|
5917
|
+
analyzer: "opportunity",
|
|
5918
|
+
params: { type: "opportunity" }
|
|
5919
|
+
} : void 0
|
|
5920
|
+
};
|
|
5921
|
+
}
|
|
5922
|
+
function buildZeroClickSection(res, max) {
|
|
5923
|
+
const rows = (res?.results ?? []).sort((a, b) => b.impressions - a.impressions);
|
|
5924
|
+
const kept = rows.slice(0, max);
|
|
5925
|
+
const findings = kept.map((r) => ({
|
|
5926
|
+
entity: {
|
|
5927
|
+
kind: "query",
|
|
5928
|
+
value: r.query
|
|
5929
|
+
},
|
|
5930
|
+
metrics: {
|
|
5931
|
+
impressions: r.impressions,
|
|
5932
|
+
position: r.position,
|
|
5933
|
+
ctr: r.ctr
|
|
5934
|
+
},
|
|
5935
|
+
why: `0 clicks on ${r.page}`
|
|
5936
|
+
}));
|
|
5937
|
+
return {
|
|
5938
|
+
id: "zero-click",
|
|
5939
|
+
title: "Zero-click queries",
|
|
5940
|
+
severity: "info",
|
|
5941
|
+
summary: { magnitudeLabel: `${kept.reduce((s, r) => s + r.impressions, 0)} impressions wasted` },
|
|
5942
|
+
findings,
|
|
5943
|
+
truncated: rows.length > max ? {
|
|
5944
|
+
kept: kept.length,
|
|
5945
|
+
total: rows.length
|
|
5946
|
+
} : void 0,
|
|
5947
|
+
coverage: res ? "full" : "partial",
|
|
5948
|
+
actions: [],
|
|
5949
|
+
artifact: res ? {
|
|
5950
|
+
analyzer: "zero-click",
|
|
5951
|
+
params: { type: "zero-click" }
|
|
5952
|
+
} : void 0
|
|
5953
|
+
};
|
|
5954
|
+
}
|
|
5955
|
+
function buildMigrationSection$1(res, max) {
|
|
5956
|
+
const rows = (res?.results ?? []).sort((a, b) => b.weight - a.weight);
|
|
5957
|
+
const kept = rows.slice(0, max);
|
|
5958
|
+
return {
|
|
5959
|
+
id: "query-migration",
|
|
5960
|
+
title: "Query migration",
|
|
5961
|
+
severity: "info",
|
|
5962
|
+
summary: {},
|
|
5963
|
+
findings: kept.map((r) => ({
|
|
5964
|
+
entity: {
|
|
5965
|
+
kind: "page",
|
|
5966
|
+
value: r.targetPage
|
|
5967
|
+
},
|
|
5968
|
+
metrics: {
|
|
5969
|
+
weight: r.weight,
|
|
5970
|
+
queryCount: r.queryCount
|
|
5971
|
+
},
|
|
5972
|
+
why: `migrating from ${r.sourcePage}`
|
|
5973
|
+
})),
|
|
5974
|
+
truncated: rows.length > max ? {
|
|
5975
|
+
kept: kept.length,
|
|
5976
|
+
total: rows.length
|
|
5977
|
+
} : void 0,
|
|
5978
|
+
coverage: res ? "full" : "partial",
|
|
5979
|
+
actions: [],
|
|
5980
|
+
artifact: res ? {
|
|
5981
|
+
analyzer: "query-migration",
|
|
5982
|
+
params: { type: "query-migration" }
|
|
5983
|
+
} : void 0
|
|
5984
|
+
};
|
|
5985
|
+
}
|
|
5986
|
+
const DEFAULT_MAX$2 = 10;
|
|
5987
|
+
const prePublishReport = defineReport({
|
|
5988
|
+
id: "pre-publish",
|
|
5989
|
+
description: "Pre-publish guard: cannibalization risk and striking-distance peers for a candidate topic or URL.",
|
|
5990
|
+
defaultPeriod: "last-90d",
|
|
5991
|
+
defaultComparison: "none",
|
|
5992
|
+
argsSpec: {
|
|
5993
|
+
"topic": {
|
|
5994
|
+
type: "string",
|
|
5995
|
+
description: "Topic / keyword / URL slug to check",
|
|
5996
|
+
required: true
|
|
5997
|
+
},
|
|
5998
|
+
"max-findings": {
|
|
5999
|
+
type: "number",
|
|
6000
|
+
description: "Cap findings per section",
|
|
6001
|
+
default: DEFAULT_MAX$2
|
|
6002
|
+
}
|
|
6003
|
+
},
|
|
6004
|
+
plan: (params, window) => {
|
|
6005
|
+
if (!params.topic) throw new Error("pre-publish report requires --topic <topic-or-url>");
|
|
6006
|
+
const dates = {
|
|
6007
|
+
startDate: window.start,
|
|
6008
|
+
endDate: window.end
|
|
6009
|
+
};
|
|
6010
|
+
return [{
|
|
6011
|
+
key: "cannibalization",
|
|
6012
|
+
type: "cannibalization",
|
|
6013
|
+
params: {
|
|
6014
|
+
...dates,
|
|
6015
|
+
limit: 200
|
|
6016
|
+
}
|
|
6017
|
+
}, {
|
|
6018
|
+
key: "striking",
|
|
6019
|
+
type: "striking-distance",
|
|
6020
|
+
params: {
|
|
6021
|
+
...dates,
|
|
6022
|
+
limit: 200
|
|
6023
|
+
}
|
|
6024
|
+
}];
|
|
6025
|
+
},
|
|
6026
|
+
reduce: (results, ctx) => {
|
|
6027
|
+
const topic = (ctx.params.topic ?? "").trim().toLowerCase();
|
|
6028
|
+
const max = ctx.params.maxFindings ?? DEFAULT_MAX$2;
|
|
6029
|
+
const matches = (val) => !!val && val.toLowerCase().includes(topic);
|
|
6030
|
+
return { sections: [buildCannibalizationSection$1(results.cannibalization, matches, max), buildStrikingPeersSection(results.striking, matches, max, topic)] };
|
|
6031
|
+
}
|
|
6032
|
+
});
|
|
6033
|
+
function buildCannibalizationSection$1(res, matches, max) {
|
|
6034
|
+
const rows = (res?.results ?? []).filter((r) => matches(r.keyword) || (r.competitors ?? []).some((p) => matches(p.url))).sort((a, b) => b.totalClicks - a.totalClicks);
|
|
6035
|
+
const kept = rows.slice(0, max);
|
|
6036
|
+
const findings = kept.map((r) => {
|
|
6037
|
+
const pageCount = r.competitorCount ?? r.competitors?.length ?? 0;
|
|
6038
|
+
return {
|
|
6039
|
+
entity: {
|
|
6040
|
+
kind: "query",
|
|
6041
|
+
value: r.keyword
|
|
6042
|
+
},
|
|
6043
|
+
metrics: {
|
|
6044
|
+
pages: pageCount,
|
|
6045
|
+
totalClicks: r.totalClicks,
|
|
6046
|
+
totalImpressions: r.totalImpressions
|
|
6047
|
+
},
|
|
6048
|
+
why: `${pageCount} page(s) already targeting`
|
|
6049
|
+
};
|
|
6050
|
+
});
|
|
6051
|
+
return {
|
|
6052
|
+
id: "cannibalization-risk",
|
|
6053
|
+
title: "Cannibalization risk",
|
|
6054
|
+
severity: kept.length ? "high" : "info",
|
|
6055
|
+
summary: { magnitudeLabel: kept.length ? `${kept.length} existing competition` : "no existing competition" },
|
|
6056
|
+
findings,
|
|
6057
|
+
truncated: rows.length > max ? {
|
|
6058
|
+
kept: kept.length,
|
|
6059
|
+
total: rows.length
|
|
6060
|
+
} : void 0,
|
|
6061
|
+
coverage: res ? "full" : "partial",
|
|
6062
|
+
actions: kept.slice(0, 1).map(() => ({
|
|
6063
|
+
kind: "fix",
|
|
6064
|
+
rationale: "Decide before publishing: redirect existing page, target a different angle, or accept overlap."
|
|
6065
|
+
})),
|
|
6066
|
+
artifact: res ? {
|
|
6067
|
+
analyzer: "cannibalization",
|
|
6068
|
+
params: { type: "cannibalization" }
|
|
6069
|
+
} : void 0
|
|
6070
|
+
};
|
|
6071
|
+
}
|
|
6072
|
+
function buildStrikingPeersSection(res, matches, max, topic) {
|
|
6073
|
+
const rows = (res?.results ?? []).filter((r) => matches(r.keyword) || matches(r.page)).sort((a, b) => b.potentialClicks - a.potentialClicks);
|
|
6074
|
+
const kept = rows.slice(0, max);
|
|
6075
|
+
const findings = kept.map((r) => ({
|
|
6076
|
+
entity: {
|
|
6077
|
+
kind: "query",
|
|
6078
|
+
value: r.keyword
|
|
6079
|
+
},
|
|
6080
|
+
metrics: {
|
|
6081
|
+
position: r.position,
|
|
6082
|
+
impressions: r.impressions,
|
|
6083
|
+
potentialClicks: r.potentialClicks
|
|
6084
|
+
},
|
|
6085
|
+
why: r.page ? `currently on ${r.page}` : void 0
|
|
6086
|
+
}));
|
|
6087
|
+
return {
|
|
6088
|
+
id: "striking-peers",
|
|
6089
|
+
title: "Existing striking-distance peers",
|
|
6090
|
+
severity: kept.length ? "low" : "info",
|
|
6091
|
+
summary: { magnitudeLabel: kept.length ? `${kept.length} adjacent rankings for "${topic}"` : "no adjacent rankings" },
|
|
6092
|
+
findings,
|
|
6093
|
+
truncated: rows.length > max ? {
|
|
6094
|
+
kept: kept.length,
|
|
6095
|
+
total: rows.length
|
|
6096
|
+
} : void 0,
|
|
6097
|
+
coverage: res ? "full" : "partial",
|
|
6098
|
+
actions: [],
|
|
6099
|
+
artifact: res ? {
|
|
6100
|
+
analyzer: "striking-distance",
|
|
6101
|
+
params: { type: "striking-distance" }
|
|
6102
|
+
} : void 0
|
|
6103
|
+
};
|
|
6104
|
+
}
|
|
6105
|
+
const DEFAULT_LIMIT = 40;
|
|
6106
|
+
const DEFAULT_ACTIONS = 10;
|
|
6107
|
+
const priorityReport = defineReport({
|
|
6108
|
+
id: "priority",
|
|
6109
|
+
description: "Ranked priority actions composed from striking-distance, opportunity, cannibalization, ctr-anomaly, and change-point signals.",
|
|
6110
|
+
defaultPeriod: "last-28d",
|
|
6111
|
+
defaultComparison: "prev-period",
|
|
6112
|
+
argsSpec: {
|
|
6113
|
+
"limit": {
|
|
6114
|
+
type: "number",
|
|
6115
|
+
description: "Max actions in the section",
|
|
6116
|
+
default: DEFAULT_LIMIT
|
|
6117
|
+
},
|
|
6118
|
+
"max-actions": {
|
|
6119
|
+
type: "number",
|
|
6120
|
+
description: "Top-N rendered as ReportAction",
|
|
6121
|
+
default: DEFAULT_ACTIONS
|
|
6122
|
+
}
|
|
6123
|
+
},
|
|
6124
|
+
plan: (_params, window) => {
|
|
6125
|
+
const dates = {
|
|
6126
|
+
startDate: window.start,
|
|
6127
|
+
endDate: window.end
|
|
6128
|
+
};
|
|
6129
|
+
const cmp = window.comparison ? {
|
|
6130
|
+
prevStartDate: window.comparison.start,
|
|
6131
|
+
prevEndDate: window.comparison.end
|
|
6132
|
+
} : {};
|
|
6133
|
+
return DEFAULT_PRIORITY_SOURCES.map((source) => ({
|
|
6134
|
+
key: source,
|
|
6135
|
+
type: source,
|
|
6136
|
+
params: {
|
|
6137
|
+
...dates,
|
|
6138
|
+
...cmp,
|
|
6139
|
+
limit: 100
|
|
6140
|
+
},
|
|
6141
|
+
required: false
|
|
6142
|
+
}));
|
|
6143
|
+
},
|
|
6144
|
+
reduce: (results, ctx) => {
|
|
6145
|
+
const limit = ctx.params.limit ?? DEFAULT_LIMIT;
|
|
6146
|
+
const maxActions = ctx.params.maxActions ?? DEFAULT_ACTIONS;
|
|
6147
|
+
const all = [];
|
|
6148
|
+
let anyMissing = false;
|
|
6149
|
+
for (const source of DEFAULT_PRIORITY_SOURCES) {
|
|
6150
|
+
const r = results[source];
|
|
6151
|
+
if (!r) {
|
|
6152
|
+
anyMissing = true;
|
|
6153
|
+
continue;
|
|
6154
|
+
}
|
|
6155
|
+
all.push(...normalizePriorityActions(source, r));
|
|
6156
|
+
}
|
|
6157
|
+
const ranked = scorePriorityActions(mergePriorityActions(all)).slice(0, limit);
|
|
6158
|
+
const findings = ranked.map((a) => ({
|
|
6159
|
+
entity: {
|
|
6160
|
+
kind: "query",
|
|
6161
|
+
value: a.keyword
|
|
6162
|
+
},
|
|
6163
|
+
metrics: {
|
|
6164
|
+
priorityScore: a.priorityScore,
|
|
6165
|
+
severity: a.severity,
|
|
6166
|
+
impact: a.impact,
|
|
6167
|
+
impressions: a.impressions
|
|
6168
|
+
},
|
|
6169
|
+
why: `${a.title} — ${a.why} (page: ${a.page})`
|
|
6170
|
+
}));
|
|
6171
|
+
const actions = ranked.slice(0, maxActions).map((a) => {
|
|
6172
|
+
const primarySource = a.sources[0];
|
|
6173
|
+
return {
|
|
6174
|
+
kind: primarySource === "cannibalization" ? "fix" : "analyzer",
|
|
6175
|
+
target: {
|
|
6176
|
+
kind: "page",
|
|
6177
|
+
value: a.page
|
|
6178
|
+
},
|
|
6179
|
+
params: { type: primarySource },
|
|
6180
|
+
rationale: a.title
|
|
6181
|
+
};
|
|
6182
|
+
});
|
|
6183
|
+
const topSeverity = ranked[0]?.severity ?? 0;
|
|
6184
|
+
return { sections: [{
|
|
6185
|
+
id: "priority",
|
|
6186
|
+
title: "Priority actions",
|
|
6187
|
+
severity: topSeverity >= 70 ? "high" : topSeverity >= 40 ? "medium" : ranked.length ? "low" : "info",
|
|
6188
|
+
summary: { magnitudeLabel: `${ranked.length} actions ranked` },
|
|
6189
|
+
findings,
|
|
6190
|
+
truncated: all.length > ranked.length ? {
|
|
6191
|
+
kept: ranked.length,
|
|
6192
|
+
total: all.length
|
|
6193
|
+
} : void 0,
|
|
6194
|
+
coverage: anyMissing ? "partial" : "full",
|
|
6195
|
+
actions
|
|
6196
|
+
}] };
|
|
6197
|
+
}
|
|
6198
|
+
});
|
|
6199
|
+
const DEFAULT_MAX$1 = 5;
|
|
6200
|
+
const risksReport = defineReport({
|
|
6201
|
+
id: "risks",
|
|
6202
|
+
description: "Decay, cannibalization, dark-traffic and device-gap risks vs prior period.",
|
|
6203
|
+
defaultPeriod: "last-28d",
|
|
6204
|
+
defaultComparison: "prev-period",
|
|
6205
|
+
argsSpec: { "max-findings": {
|
|
6206
|
+
type: "number",
|
|
6207
|
+
description: "Cap findings per section",
|
|
6208
|
+
default: DEFAULT_MAX$1
|
|
6209
|
+
} },
|
|
6210
|
+
plan: (_params, window) => {
|
|
6211
|
+
if (!window.comparison) throw new Error("risks report requires a comparison window — pass --vs prev-period");
|
|
6212
|
+
const dates = {
|
|
6213
|
+
startDate: window.start,
|
|
6214
|
+
endDate: window.end
|
|
6215
|
+
};
|
|
6216
|
+
const prev = {
|
|
6217
|
+
prevStartDate: window.comparison.start,
|
|
6218
|
+
prevEndDate: window.comparison.end
|
|
6219
|
+
};
|
|
6220
|
+
return [
|
|
6221
|
+
{
|
|
6222
|
+
key: "decay",
|
|
6223
|
+
type: "decay",
|
|
6224
|
+
params: {
|
|
6225
|
+
...dates,
|
|
6226
|
+
...prev,
|
|
6227
|
+
limit: 100
|
|
6228
|
+
},
|
|
6229
|
+
required: true
|
|
6230
|
+
},
|
|
6231
|
+
{
|
|
6232
|
+
key: "cannibalization",
|
|
6233
|
+
type: "cannibalization",
|
|
6234
|
+
params: {
|
|
6235
|
+
...dates,
|
|
6236
|
+
limit: 50
|
|
6237
|
+
}
|
|
6238
|
+
},
|
|
6239
|
+
{
|
|
6240
|
+
key: "dark-traffic",
|
|
6241
|
+
type: "dark-traffic",
|
|
6242
|
+
params: {
|
|
6243
|
+
...dates,
|
|
6244
|
+
limit: 50
|
|
6245
|
+
}
|
|
6246
|
+
},
|
|
6247
|
+
{
|
|
6248
|
+
key: "device-gap",
|
|
6249
|
+
type: "device-gap",
|
|
6250
|
+
params: {
|
|
6251
|
+
...dates,
|
|
6252
|
+
limit: 50
|
|
6253
|
+
}
|
|
6254
|
+
}
|
|
6255
|
+
];
|
|
6256
|
+
},
|
|
6257
|
+
reduce: (results, ctx) => {
|
|
6258
|
+
const max = ctx.params.maxFindings ?? DEFAULT_MAX$1;
|
|
6259
|
+
return { sections: [
|
|
6260
|
+
buildDecaySection(results.decay, max),
|
|
6261
|
+
buildCannibalizationSection(results.cannibalization, max),
|
|
6262
|
+
buildDarkTrafficSection(results["dark-traffic"], max),
|
|
6263
|
+
buildDeviceGapSection(results["device-gap"], max)
|
|
6264
|
+
] };
|
|
6265
|
+
}
|
|
6266
|
+
});
|
|
6267
|
+
function buildDecaySection(res, max) {
|
|
6268
|
+
const rows = (res?.results ?? []).sort((a, b) => b.lostClicks - a.lostClicks);
|
|
6269
|
+
const kept = rows.slice(0, max);
|
|
6270
|
+
const totalLost = kept.reduce((s, r) => s + r.lostClicks, 0);
|
|
6271
|
+
const findings = kept.map((r) => ({
|
|
6272
|
+
entity: {
|
|
6273
|
+
kind: "page",
|
|
6274
|
+
value: r.page
|
|
6275
|
+
},
|
|
6276
|
+
metrics: {
|
|
6277
|
+
lostClicks: r.lostClicks,
|
|
6278
|
+
currentClicks: r.currentClicks,
|
|
6279
|
+
declinePercent: r.declinePercent
|
|
6280
|
+
},
|
|
6281
|
+
delta: {
|
|
6282
|
+
metric: "clicks",
|
|
6283
|
+
prior: r.previousClicks,
|
|
6284
|
+
current: r.currentClicks,
|
|
6285
|
+
pct: -r.declinePercent * 100
|
|
6286
|
+
}
|
|
6287
|
+
}));
|
|
6288
|
+
return {
|
|
6289
|
+
id: "decay",
|
|
6290
|
+
title: "Decaying pages",
|
|
6291
|
+
severity: totalLost >= 200 ? "high" : totalLost >= 50 ? "medium" : "low",
|
|
6292
|
+
summary: {
|
|
6293
|
+
delta: -totalLost,
|
|
6294
|
+
direction: totalLost > 0 ? "down" : "flat",
|
|
6295
|
+
magnitudeLabel: `${Math.round(totalLost)} clicks lost`
|
|
6296
|
+
},
|
|
6297
|
+
findings,
|
|
6298
|
+
truncated: rows.length > max ? {
|
|
6299
|
+
kept: kept.length,
|
|
6300
|
+
total: rows.length
|
|
6301
|
+
} : void 0,
|
|
6302
|
+
coverage: res ? "full" : "partial",
|
|
6303
|
+
actions: kept.slice(0, 1).map((r) => ({
|
|
6304
|
+
kind: "analyzer",
|
|
6305
|
+
target: {
|
|
6306
|
+
kind: "page",
|
|
6307
|
+
value: r.page
|
|
6308
|
+
},
|
|
6309
|
+
params: { type: "change-point" },
|
|
6310
|
+
rationale: "Investigate change-point on the worst-affected page"
|
|
6311
|
+
})),
|
|
6312
|
+
artifact: res ? {
|
|
6313
|
+
analyzer: "decay",
|
|
6314
|
+
params: { type: "decay" }
|
|
6315
|
+
} : void 0
|
|
6316
|
+
};
|
|
6317
|
+
}
|
|
6318
|
+
function buildCannibalizationSection(res, max) {
|
|
6319
|
+
const rows = (res?.results ?? []).sort((a, b) => b.totalClicks - a.totalClicks);
|
|
6320
|
+
const kept = rows.slice(0, max);
|
|
6321
|
+
const findings = kept.map((r) => {
|
|
6322
|
+
const pageCount = r.competitorCount ?? r.competitors?.length ?? 0;
|
|
6323
|
+
return {
|
|
6324
|
+
entity: {
|
|
6325
|
+
kind: "query",
|
|
6326
|
+
value: r.keyword
|
|
6327
|
+
},
|
|
6328
|
+
metrics: {
|
|
6329
|
+
pages: pageCount,
|
|
6330
|
+
totalClicks: r.totalClicks,
|
|
6331
|
+
totalImpressions: r.totalImpressions
|
|
6332
|
+
},
|
|
6333
|
+
why: `${pageCount} pages competing`
|
|
6334
|
+
};
|
|
6335
|
+
});
|
|
6336
|
+
return {
|
|
6337
|
+
id: "cannibalization",
|
|
6338
|
+
title: "Cannibalizing queries",
|
|
6339
|
+
severity: kept.length ? "medium" : "info",
|
|
6340
|
+
summary: {},
|
|
6341
|
+
findings,
|
|
6342
|
+
truncated: rows.length > max ? {
|
|
6343
|
+
kept: kept.length,
|
|
6344
|
+
total: rows.length
|
|
6345
|
+
} : void 0,
|
|
6346
|
+
coverage: res ? "full" : "partial",
|
|
6347
|
+
actions: [],
|
|
6348
|
+
artifact: res ? {
|
|
6349
|
+
analyzer: "cannibalization",
|
|
6350
|
+
params: { type: "cannibalization" }
|
|
6351
|
+
} : void 0
|
|
6352
|
+
};
|
|
6353
|
+
}
|
|
6354
|
+
function buildDarkTrafficSection(res, max) {
|
|
6355
|
+
const rows = (res?.results ?? []).sort((a, b) => b.darkClicks - a.darkClicks);
|
|
6356
|
+
const kept = rows.slice(0, max);
|
|
6357
|
+
const totalDark = kept.reduce((s, r) => s + r.darkClicks, 0);
|
|
6358
|
+
const findings = kept.map((r) => ({
|
|
6359
|
+
entity: {
|
|
6360
|
+
kind: "page",
|
|
6361
|
+
value: r.url
|
|
6362
|
+
},
|
|
6363
|
+
metrics: {
|
|
6364
|
+
darkClicks: r.darkClicks,
|
|
6365
|
+
darkPercent: r.darkPercent,
|
|
6366
|
+
totalClicks: r.totalClicks
|
|
6367
|
+
}
|
|
6368
|
+
}));
|
|
6369
|
+
return {
|
|
6370
|
+
id: "dark-traffic",
|
|
6371
|
+
title: "Dark traffic",
|
|
6372
|
+
severity: kept.length ? "low" : "info",
|
|
6373
|
+
summary: { magnitudeLabel: `${Math.round(totalDark)} unattributed clicks` },
|
|
6374
|
+
findings,
|
|
6375
|
+
truncated: rows.length > max ? {
|
|
6376
|
+
kept: kept.length,
|
|
6377
|
+
total: rows.length
|
|
6378
|
+
} : void 0,
|
|
6379
|
+
coverage: res ? "full" : "partial",
|
|
6380
|
+
actions: [],
|
|
6381
|
+
artifact: res ? {
|
|
6382
|
+
analyzer: "dark-traffic",
|
|
6383
|
+
params: { type: "dark-traffic" }
|
|
6384
|
+
} : void 0
|
|
6385
|
+
};
|
|
6386
|
+
}
|
|
6387
|
+
function buildDeviceGapSection(res, max) {
|
|
6388
|
+
const rows = (res?.results ?? []).sort((a, b) => Math.abs(b.gaps.positionGap) - Math.abs(a.gaps.positionGap));
|
|
6389
|
+
const kept = rows.slice(0, max);
|
|
6390
|
+
return {
|
|
6391
|
+
id: "device-gap",
|
|
6392
|
+
title: "Device gap",
|
|
6393
|
+
severity: "info",
|
|
6394
|
+
summary: {},
|
|
6395
|
+
findings: kept.map((r) => ({
|
|
6396
|
+
entity: {
|
|
6397
|
+
kind: "page",
|
|
6398
|
+
value: r.date
|
|
6399
|
+
},
|
|
6400
|
+
metrics: {
|
|
6401
|
+
ctrGap: r.gaps.ctrGap,
|
|
6402
|
+
positionGap: r.gaps.positionGap,
|
|
6403
|
+
desktopCtr: r.desktop.ctr,
|
|
6404
|
+
mobileCtr: r.mobile.ctr
|
|
6405
|
+
},
|
|
6406
|
+
why: `desktop vs mobile delta`
|
|
6407
|
+
})),
|
|
6408
|
+
truncated: rows.length > max ? {
|
|
6409
|
+
kept: kept.length,
|
|
6410
|
+
total: rows.length
|
|
6411
|
+
} : void 0,
|
|
6412
|
+
coverage: res ? "full" : "partial",
|
|
6413
|
+
actions: [],
|
|
6414
|
+
artifact: res ? {
|
|
6415
|
+
analyzer: "device-gap",
|
|
6416
|
+
params: { type: "device-gap" }
|
|
6417
|
+
} : void 0
|
|
6418
|
+
};
|
|
6419
|
+
}
|
|
6420
|
+
function resolveTarget(opts) {
|
|
6421
|
+
const needle = opts.input.trim();
|
|
6422
|
+
if (!needle) return {
|
|
6423
|
+
exact: null,
|
|
6424
|
+
matches: [],
|
|
6425
|
+
unresolved: true
|
|
6426
|
+
};
|
|
6427
|
+
const candidates = opts.candidates;
|
|
6428
|
+
if (!candidates || candidates.length === 0) return {
|
|
6429
|
+
exact: needle,
|
|
6430
|
+
matches: [needle],
|
|
6431
|
+
unresolved: false
|
|
6432
|
+
};
|
|
6433
|
+
const lower = needle.toLowerCase();
|
|
6434
|
+
let exact = null;
|
|
6435
|
+
const matches = [];
|
|
6436
|
+
for (const c of candidates) {
|
|
6437
|
+
const cl = c.toLowerCase();
|
|
6438
|
+
if (cl === lower) {
|
|
6439
|
+
exact = c;
|
|
6440
|
+
if (!matches.includes(c)) matches.unshift(c);
|
|
6441
|
+
continue;
|
|
6442
|
+
}
|
|
6443
|
+
if (cl.includes(lower)) matches.push(c);
|
|
6444
|
+
}
|
|
6445
|
+
return {
|
|
6446
|
+
exact,
|
|
6447
|
+
matches,
|
|
6448
|
+
unresolved: matches.length === 0
|
|
6449
|
+
};
|
|
6450
|
+
}
|
|
6451
|
+
const DEFAULT_MAX = 10;
|
|
6452
|
+
const triageReport = defineReport({
|
|
6453
|
+
id: "triage",
|
|
6454
|
+
description: "Focused investigation: change-points, query migration, and position volatility scoped to one page or query.",
|
|
6455
|
+
defaultPeriod: "last-90d",
|
|
6456
|
+
defaultComparison: "none",
|
|
6457
|
+
argsSpec: {
|
|
6458
|
+
"target": {
|
|
6459
|
+
type: "string",
|
|
6460
|
+
description: "Target page URL or query string",
|
|
6461
|
+
required: true
|
|
6462
|
+
},
|
|
6463
|
+
"target-kind": {
|
|
6464
|
+
type: "string",
|
|
6465
|
+
description: "page | query",
|
|
6466
|
+
default: "page"
|
|
6467
|
+
},
|
|
6468
|
+
"max-findings": {
|
|
6469
|
+
type: "number",
|
|
6470
|
+
description: "Cap findings per section",
|
|
6471
|
+
default: DEFAULT_MAX
|
|
6472
|
+
}
|
|
6473
|
+
},
|
|
6474
|
+
plan: (params, window) => {
|
|
6475
|
+
if (!params.target) throw new Error("triage report requires --target <page-or-query>");
|
|
6476
|
+
const dates = {
|
|
6477
|
+
startDate: window.start,
|
|
6478
|
+
endDate: window.end
|
|
6479
|
+
};
|
|
6480
|
+
return [
|
|
6481
|
+
{
|
|
6482
|
+
key: "change-point",
|
|
6483
|
+
type: "change-point",
|
|
6484
|
+
params: {
|
|
6485
|
+
...dates,
|
|
6486
|
+
limit: 200
|
|
6487
|
+
}
|
|
6488
|
+
},
|
|
6489
|
+
{
|
|
6490
|
+
key: "query-migration",
|
|
6491
|
+
type: "query-migration",
|
|
6492
|
+
params: {
|
|
6493
|
+
...dates,
|
|
6494
|
+
limit: 200
|
|
6495
|
+
}
|
|
6496
|
+
},
|
|
6497
|
+
{
|
|
6498
|
+
key: "position-volatility",
|
|
6499
|
+
type: "position-volatility",
|
|
6500
|
+
params: {
|
|
6501
|
+
...dates,
|
|
6502
|
+
limit: 200
|
|
6503
|
+
}
|
|
6504
|
+
}
|
|
6505
|
+
];
|
|
6506
|
+
},
|
|
6507
|
+
reduce: (results, ctx) => {
|
|
6508
|
+
const target = ctx.params.target;
|
|
6509
|
+
const kind = ctx.params.targetKind ?? "page";
|
|
6510
|
+
const max = ctx.params.maxFindings ?? DEFAULT_MAX;
|
|
6511
|
+
const needle = (resolveTarget({
|
|
6512
|
+
kind,
|
|
6513
|
+
input: target
|
|
6514
|
+
}).exact ?? target).toLowerCase();
|
|
6515
|
+
const matches = (val) => val.toLowerCase().includes(needle);
|
|
6516
|
+
return { sections: [
|
|
6517
|
+
buildChangePointSection(results["change-point"], kind, matches, max),
|
|
6518
|
+
buildMigrationSection(results["query-migration"], kind, matches, max, target),
|
|
6519
|
+
buildVolatilitySection(results["position-volatility"], kind, matches, max)
|
|
6520
|
+
] };
|
|
6521
|
+
}
|
|
6522
|
+
});
|
|
6523
|
+
function buildChangePointSection(res, kind, matches, max) {
|
|
6524
|
+
const rows = (res?.results ?? []).filter((r) => matches(kind === "page" ? r.page : r.keyword)).sort((a, b) => b.llr - a.llr);
|
|
6525
|
+
const kept = rows.slice(0, max);
|
|
6526
|
+
const findings = kept.map((r) => ({
|
|
6527
|
+
entity: {
|
|
6528
|
+
kind: kind === "page" ? "page" : "query",
|
|
6529
|
+
value: kind === "page" ? r.page : r.keyword
|
|
6530
|
+
},
|
|
6531
|
+
metrics: {
|
|
6532
|
+
llr: r.llr,
|
|
6533
|
+
delta: r.delta
|
|
6534
|
+
},
|
|
6535
|
+
why: `${r.direction} on ${r.changeDate}`
|
|
6536
|
+
}));
|
|
6537
|
+
return {
|
|
6538
|
+
id: "change-point",
|
|
6539
|
+
title: "Change-points",
|
|
6540
|
+
severity: kept.length ? "medium" : "info",
|
|
6541
|
+
summary: { magnitudeLabel: `${kept.length} change-point${kept.length === 1 ? "" : "s"}` },
|
|
6542
|
+
findings,
|
|
6543
|
+
truncated: rows.length > max ? {
|
|
6544
|
+
kept: kept.length,
|
|
6545
|
+
total: rows.length
|
|
6546
|
+
} : void 0,
|
|
6547
|
+
coverage: res ? "full" : "partial",
|
|
6548
|
+
actions: [],
|
|
6549
|
+
artifact: res ? {
|
|
6550
|
+
analyzer: "change-point",
|
|
6551
|
+
params: { type: "change-point" }
|
|
6552
|
+
} : void 0
|
|
6553
|
+
};
|
|
6554
|
+
}
|
|
6555
|
+
function buildMigrationSection(res, kind, matches, max, _rawTarget) {
|
|
6556
|
+
const rows = (res?.results ?? []).filter((r) => kind === "page" ? matches(r.sourcePage) || matches(r.targetPage) : false).sort((a, b) => b.weight - a.weight);
|
|
6557
|
+
const kept = rows.slice(0, max);
|
|
6558
|
+
const findings = kept.map((r) => ({
|
|
6559
|
+
entity: {
|
|
6560
|
+
kind: "page",
|
|
6561
|
+
value: r.targetPage
|
|
6562
|
+
},
|
|
6563
|
+
metrics: {
|
|
6564
|
+
weight: r.weight,
|
|
6565
|
+
queryCount: r.queryCount
|
|
6566
|
+
},
|
|
6567
|
+
why: `from ${r.sourcePage}`
|
|
6568
|
+
}));
|
|
6569
|
+
return {
|
|
6570
|
+
id: "query-migration",
|
|
6571
|
+
title: "Query migration",
|
|
6572
|
+
severity: "info",
|
|
6573
|
+
summary: { magnitudeLabel: kind === "page" ? `${kept.length} migration edges touching target` : "N/A for query target" },
|
|
6574
|
+
findings,
|
|
6575
|
+
truncated: rows.length > max ? {
|
|
6576
|
+
kept: kept.length,
|
|
6577
|
+
total: rows.length
|
|
6578
|
+
} : void 0,
|
|
6579
|
+
coverage: res ? "full" : "partial",
|
|
6580
|
+
actions: [],
|
|
6581
|
+
artifact: res ? {
|
|
6582
|
+
analyzer: "query-migration",
|
|
6583
|
+
params: { type: "query-migration" }
|
|
6584
|
+
} : void 0
|
|
6585
|
+
};
|
|
6586
|
+
}
|
|
6587
|
+
function buildVolatilitySection(res, kind, matches, max) {
|
|
6588
|
+
const rows = kind === "page" ? (res?.results ?? []).filter((r) => matches(r.page)) : [];
|
|
6589
|
+
const kept = rows.sort((a, b) => b.peakVolatility - a.peakVolatility).slice(0, max);
|
|
6590
|
+
const findings = kept.map((r) => ({
|
|
6591
|
+
entity: {
|
|
6592
|
+
kind: "page",
|
|
6593
|
+
value: r.page
|
|
6594
|
+
},
|
|
6595
|
+
metrics: {
|
|
6596
|
+
avgVolatility: r.avgVolatility,
|
|
6597
|
+
peakVolatility: r.peakVolatility,
|
|
6598
|
+
impressions: r.totalImpressions
|
|
6599
|
+
}
|
|
6600
|
+
}));
|
|
6601
|
+
return {
|
|
6602
|
+
id: "position-volatility",
|
|
6603
|
+
title: "Position volatility",
|
|
6604
|
+
severity: kept.length ? "low" : "info",
|
|
6605
|
+
summary: { magnitudeLabel: kind === "page" ? `${kept.length} volatile day${kept.length === 1 ? "" : "s"}` : "N/A for query target" },
|
|
6606
|
+
findings,
|
|
6607
|
+
truncated: rows.length > max ? {
|
|
6608
|
+
kept: kept.length,
|
|
6609
|
+
total: rows.length
|
|
6610
|
+
} : void 0,
|
|
6611
|
+
coverage: res ? "full" : "partial",
|
|
6612
|
+
actions: [],
|
|
6613
|
+
artifact: res ? {
|
|
6614
|
+
analyzer: "position-volatility",
|
|
6615
|
+
params: { type: "position-volatility" }
|
|
6616
|
+
} : void 0
|
|
6617
|
+
};
|
|
6618
|
+
}
|
|
6619
|
+
const REPORTS = [
|
|
6620
|
+
brandReport,
|
|
6621
|
+
growthReport,
|
|
6622
|
+
healthReport,
|
|
6623
|
+
moversReport,
|
|
6624
|
+
opportunitiesReport,
|
|
6625
|
+
prePublishReport,
|
|
6626
|
+
priorityReport,
|
|
6627
|
+
risksReport,
|
|
6628
|
+
triageReport
|
|
6629
|
+
];
|
|
6630
|
+
const defaultReportRegistry = createReportRegistry({
|
|
6631
|
+
reports: REPORTS,
|
|
6632
|
+
version: "0"
|
|
6633
|
+
});
|
|
6634
|
+
async function analyzeFromSource(source, params, registry) {
|
|
6635
|
+
return runAnalyzerFromSource$1(source, params, registry);
|
|
6636
|
+
}
|
|
6637
|
+
async function executeStep(source, analyzers, step) {
|
|
6638
|
+
return analyzeFromSource(source, {
|
|
6639
|
+
...step.params,
|
|
6640
|
+
type: step.type
|
|
6641
|
+
}, analyzers).then((result) => ({
|
|
6642
|
+
state: {
|
|
6643
|
+
key: step.key,
|
|
6644
|
+
type: step.type,
|
|
6645
|
+
status: "done"
|
|
6646
|
+
},
|
|
6647
|
+
result
|
|
6648
|
+
})).catch((err) => {
|
|
6649
|
+
const message = err?.message ?? String(err);
|
|
6650
|
+
return { state: {
|
|
6651
|
+
key: step.key,
|
|
6652
|
+
type: step.type,
|
|
6653
|
+
status: "error",
|
|
6654
|
+
error: message
|
|
6655
|
+
} };
|
|
6656
|
+
});
|
|
6657
|
+
}
|
|
6658
|
+
async function runReport(report, opts) {
|
|
6659
|
+
const startedAt = Date.now();
|
|
6660
|
+
const generatedAt = new Date(startedAt).toISOString();
|
|
6661
|
+
const inputHash = await computeInputHash({
|
|
6662
|
+
id: report.id,
|
|
6663
|
+
site: opts.ctx.site,
|
|
6664
|
+
window: opts.ctx.window,
|
|
6665
|
+
params: opts.ctx.params,
|
|
6666
|
+
registryVersion: opts.ctx.registryVersion
|
|
6667
|
+
});
|
|
6668
|
+
const steps = report.plan(opts.ctx.params, opts.ctx.window);
|
|
6669
|
+
const outcomes = await Promise.all(steps.map((s) => executeStep(opts.source, opts.analyzers, s)));
|
|
6670
|
+
const required = new Map(steps.filter((s) => s.required).map((s) => [s.key, s]));
|
|
6671
|
+
const errored = outcomes.filter((o) => o.state.status === "error");
|
|
6672
|
+
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}`);
|
|
6673
|
+
const resultsByKey = {};
|
|
6674
|
+
for (const o of outcomes) if (o.result) resultsByKey[o.state.key] = o.result;
|
|
6675
|
+
const sections = report.reduce(resultsByKey, opts.ctx).sections;
|
|
6676
|
+
const degraded = errored.length > 0;
|
|
6677
|
+
const stepStates = outcomes.map((o) => o.state);
|
|
6678
|
+
return {
|
|
6679
|
+
id: report.id,
|
|
6680
|
+
site: opts.ctx.site,
|
|
6681
|
+
inputHash,
|
|
6682
|
+
generatedAt,
|
|
6683
|
+
window: opts.ctx.window,
|
|
6684
|
+
sections,
|
|
6685
|
+
meta: {
|
|
6686
|
+
durationMs: Date.now() - startedAt,
|
|
6687
|
+
rowsScanned: 0,
|
|
6688
|
+
degraded,
|
|
6689
|
+
steps: stepStates
|
|
6690
|
+
}
|
|
6691
|
+
};
|
|
6692
|
+
}
|
|
6693
|
+
async function dryRunReport(report, ctx) {
|
|
6694
|
+
return {
|
|
6695
|
+
steps: report.plan(ctx.params, ctx.window).map((s) => ({
|
|
6696
|
+
key: s.key,
|
|
6697
|
+
type: s.type
|
|
6698
|
+
})),
|
|
6699
|
+
windowResolved: {
|
|
6700
|
+
start: ctx.window.start,
|
|
6701
|
+
end: ctx.window.end,
|
|
6702
|
+
days: ctx.window.days
|
|
6703
|
+
}
|
|
6704
|
+
};
|
|
6705
|
+
}
|
|
5171
6706
|
function createCompositeSource(opts) {
|
|
5172
6707
|
const { engine, live, site } = opts;
|
|
5173
6708
|
function rangeCovered(state) {
|
|
@@ -5345,4 +6880,4 @@ async function analyzeDecayFromSource(source, periods, options) {
|
|
|
5345
6880
|
async function analyzeMoversFromSource(source, periods, options) {
|
|
5346
6881
|
return runPortableAnalyzer(source, PORTABLE_ANALYZERS.movers, periods, options);
|
|
5347
6882
|
}
|
|
5348
|
-
export { AnalyzerCapabilityError, ENGINE_QUERY_CAPABILITIES, IN_MEMORY_DEFAULT_CAPABILITIES, ROW_ANALYZERS, SQL_ANALYZERS,
|
|
6883
|
+
export { AnalyzerCapabilityError, DEFAULT_PRIORITY_SOURCES, ENGINE_QUERY_CAPABILITIES, IN_MEMORY_DEFAULT_CAPABILITIES, REPORTS, ROW_ANALYZERS, SQL_ANALYZERS, analyzeBrandSegmentation, analyzeBrandSegmentationFromSource, analyzeCannibalization, analyzeClustering, analyzeClusteringFromSource, analyzeConcentration, analyzeDecay, analyzeDecayFromSource, analyzeFromSource, analyzeInBrowser, analyzeKeywordConcentration, analyzeKeywordConcentrationFromSource, analyzeMovers, analyzeMoversFromSource, analyzeOpportunityFromSource, analyzePageConcentration, analyzePageConcentrationFromSource, analyzeSeasonality, analyzeSeasonalityFromSource, analyzeStrikingDistance, analyzeStrikingDistanceFromSource, comparisonOf, createAnalyzerRegistry, createCompositeSource, createEngineQuerySource, createInMemoryQuerySource, createSorter, defaultAnalyzerRegistry, defaultReportRegistry, defineAnalyzer, dryRunReport, formatReport, isSqlQuerySource, mergePriorityActions, normalizePriorityActions, normalizeQuery, num, padTimeseries, periodOf, queryAnalyticsFromSource, queryComparisonFromSource, queryComparisonRows, queryRows, resolveWindow, rewriteForTableSource, runAnalyzerFromSource, runAnalyzerWithEngine, runReport, scorePriorityActions, typedQuery, windowToComparisonPeriod, windowToPeriod };
|