@gscdump/analysis 0.8.2 → 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/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
- async function analyzeFromSource(source, params, registry) {
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
- async function analyzeActionPriority(analyzer, options = {}) {
228
- const { sources = DEFAULT_SOURCES, limit = 40, continueOnError = true, paramsBySource = {}, onSourceStatus } = options;
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$1) {
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$1) {
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, analyzeActionPriority, analyzeActionPriorityFromSource, 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, defineAnalyzer, isSqlQuerySource, mergePriorityActions, normalizePriorityActions, normalizeQuery, num, padTimeseries, periodOf, queryAnalyticsFromSource, queryComparisonFromSource, queryComparisonRows, queryRows, resolveWindow, rewriteForTableSource, runAnalyzerFromSource, runAnalyzerWithEngine, scorePriorityActions, typedQuery, windowToComparisonPeriod, windowToPeriod };
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 };