@gscdump/analysis 3.8.0 → 4.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,5 +1,5 @@
1
1
  import { requireComparisonWindow } from "../require.mjs";
2
- import { reportRows, sectionArtifact, sectionCoverage, truncation } from "../sections.mjs";
2
+ import { reportRows, resultTotal, sectionArtifact, sectionCoverage, truncation } from "../sections.mjs";
3
3
  import { defineReport } from "@gscdump/engine/report";
4
4
  const DEFAULT_MAX = 5;
5
5
  const DEFAULT_MIN_CHANGE = 5;
@@ -32,15 +32,28 @@ const moversReport = defineReport({
32
32
  };
33
33
  return [
34
34
  {
35
- key: "movers",
35
+ key: "movers-rising",
36
36
  type: "movers",
37
37
  params: {
38
38
  ...cur,
39
39
  ...prev,
40
+ direction: "rising",
40
41
  limit: 200
41
42
  },
42
43
  required: true,
43
- feeds: ["rising", "decliners"]
44
+ feeds: ["rising"]
45
+ },
46
+ {
47
+ key: "movers-declining",
48
+ type: "movers",
49
+ params: {
50
+ ...cur,
51
+ ...prev,
52
+ direction: "declining",
53
+ limit: 200
54
+ },
55
+ required: true,
56
+ feeds: ["decliners"]
44
57
  },
45
58
  {
46
59
  key: "decay",
@@ -67,18 +80,19 @@ const moversReport = defineReport({
67
80
  const max = ctx.params.maxFindings ?? DEFAULT_MAX;
68
81
  const minChange = ctx.params.minClicksChange ?? DEFAULT_MIN_CHANGE;
69
82
  const sections = [];
70
- const moversRes = results.movers;
83
+ const risingRes = results["movers-rising"];
84
+ const decliningRes = results["movers-declining"];
71
85
  const decayRes = results.decay;
72
86
  const strikingRes = results.striking;
73
- sections.push(buildMoversSection(moversRes, "rising", max, minChange));
74
- sections.push(buildDeclinersSection(moversRes, decayRes, max, minChange));
87
+ sections.push(buildMoversSection(risingRes, "rising", max, minChange));
88
+ sections.push(buildDeclinersSection(decliningRes, decayRes, max, minChange));
75
89
  sections.push(buildStrikingSection(strikingRes, max));
76
90
  return { sections };
77
91
  }
78
92
  });
79
93
  function buildMoversSection(res, direction, max, minChange) {
80
94
  const rows = reportRows(res).filter((r) => r.direction === direction && Math.abs(r.clicksChange) >= minChange).sort((a, b) => Math.abs(b.clicksChange) - Math.abs(a.clicksChange));
81
- const total = rows.length;
95
+ const total = resultTotal(res, rows.length);
82
96
  const kept = rows.slice(0, max);
83
97
  const findings = kept.map((r) => ({
84
98
  entity: {
@@ -115,7 +129,8 @@ function buildMoversSection(res, direction, max, minChange) {
115
129
  };
116
130
  }
117
131
  function buildDeclinersSection(moversRes, decayRes, max, minChange) {
118
- const decliningQueries = reportRows(moversRes).filter((r) => r.direction === "declining" && Math.abs(r.clicksChange) >= minChange).slice(0, max);
132
+ const decliningRows = reportRows(moversRes).filter((r) => r.direction === "declining" && Math.abs(r.clicksChange) >= minChange).sort((a, b) => Math.abs(b.clicksChange) - Math.abs(a.clicksChange));
133
+ const decliningQueries = decliningRows.slice(0, max);
119
134
  const lostPages = reportRows(decayRes).sort((a, b) => b.lostClicks - a.lostClicks).slice(0, max);
120
135
  const findings = [...decliningQueries.map((r) => ({
121
136
  entity: {
@@ -161,12 +176,14 @@ function buildDeclinersSection(moversRes, decayRes, max, minChange) {
161
176
  magnitudeLabel: `${Math.round(totalLost)} clicks lost`
162
177
  },
163
178
  findings,
164
- coverage: decayRes && moversRes ? "full" : "partial"
179
+ truncated: truncation(resultTotal(moversRes, decliningRows.length), decliningQueries.length),
180
+ coverage: sectionCoverage(moversRes) === "full" && sectionCoverage(decayRes) === "full" ? "full" : "partial"
165
181
  };
166
182
  }
167
183
  function buildStrikingSection(res, max) {
168
184
  const rows = reportRows(res).sort((a, b) => b.potentialClicks - a.potentialClicks);
169
185
  const kept = rows.slice(0, max);
186
+ const total = resultTotal(res, rows.length);
170
187
  const findings = kept.map((r) => ({
171
188
  entity: {
172
189
  kind: "query",
@@ -186,7 +203,7 @@ function buildStrikingSection(res, max) {
186
203
  severity: "low",
187
204
  summary: { magnitudeLabel: `${kept.reduce((s, r) => s + r.potentialClicks, 0)} potential clicks` },
188
205
  findings,
189
- truncated: truncation(rows.length, kept.length),
206
+ truncated: truncation(total, kept.length),
190
207
  coverage: sectionCoverage(res),
191
208
  artifact: sectionArtifact(res, "striking-distance")
192
209
  };
@@ -1,4 +1,4 @@
1
- import { reportRows, sectionArtifact, sectionCoverage, truncation } from "../sections.mjs";
1
+ import { reportRows, resultTotal, sectionArtifact, sectionCoverage, truncation } from "../sections.mjs";
2
2
  import { defineReport } from "@gscdump/engine/report";
3
3
  const DEFAULT_MAX = 5;
4
4
  const opportunitiesReport = defineReport({
@@ -87,7 +87,7 @@ function buildStrikingSection(res, max) {
87
87
  severity: "low",
88
88
  summary: { magnitudeLabel: `${Math.round(totalPotential)} potential clicks` },
89
89
  findings,
90
- truncated: truncation(rows.length, kept.length),
90
+ truncated: truncation(resultTotal(res, rows.length), kept.length),
91
91
  coverage: sectionCoverage(res),
92
92
  artifact: sectionArtifact(res, "striking-distance")
93
93
  };
@@ -114,11 +114,14 @@ function buildOpportunitySection(res, max) {
114
114
  },
115
115
  why: r.page ? `low CTR on ${r.page}` : "low CTR vs position"
116
116
  })),
117
- truncated: truncation(rows.length, kept.length),
117
+ truncated: truncation(resultTotal(res, rows.length), kept.length),
118
118
  coverage: sectionCoverage(res),
119
119
  artifact: sectionArtifact(res, "opportunity")
120
120
  };
121
121
  }
122
+ function zeroClickWhy(r) {
123
+ return `${`${(r.ctr * 100).toFixed(1)}% CTR`} at position ${r.position.toFixed(1)} on ${r.page}`;
124
+ }
122
125
  function buildZeroClickSection(res, max) {
123
126
  const rows = reportRows(res).sort((a, b) => b.impressions - a.impressions);
124
127
  const kept = rows.slice(0, max);
@@ -129,18 +132,19 @@ function buildZeroClickSection(res, max) {
129
132
  },
130
133
  metrics: {
131
134
  impressions: r.impressions,
135
+ clicks: r.clicks,
132
136
  position: r.position,
133
137
  ctr: r.ctr
134
138
  },
135
- why: `0 clicks on ${r.page}`
139
+ why: zeroClickWhy(r)
136
140
  }));
137
141
  return {
138
142
  id: "zero-click",
139
143
  title: "Zero-click queries",
140
144
  severity: "info",
141
- summary: { magnitudeLabel: `${kept.reduce((s, r) => s + r.impressions, 0)} impressions wasted` },
145
+ summary: { magnitudeLabel: `${kept.reduce((s, r) => s + r.impressions, 0)} impressions at low CTR` },
142
146
  findings,
143
- truncated: truncation(rows.length, kept.length),
147
+ truncated: truncation(resultTotal(res, rows.length), kept.length),
144
148
  coverage: sectionCoverage(res),
145
149
  artifact: sectionArtifact(res, "zero-click")
146
150
  };
@@ -164,7 +168,7 @@ function buildMigrationSection(res, max) {
164
168
  },
165
169
  why: `migrating from ${r.sourcePage}`
166
170
  })),
167
- truncated: truncation(rows.length, kept.length),
171
+ truncated: truncation(resultTotal(res, rows.length), kept.length),
168
172
  coverage: sectionCoverage(res),
169
173
  artifact: sectionArtifact(res, "query-migration")
170
174
  };
@@ -2,6 +2,7 @@ import { requireReportParam } from "../require.mjs";
2
2
  import { reportRows, sectionArtifact, sectionCoverage, truncation } from "../sections.mjs";
3
3
  import { defineReport } from "@gscdump/engine/report";
4
4
  const DEFAULT_MAX = 10;
5
+ const FULL_OUTPUT = 5e4;
5
6
  const prePublishReport = defineReport({
6
7
  id: "pre-publish",
7
8
  description: "Pre-publish guard: cannibalization risk and striking-distance peers for a candidate topic or URL.",
@@ -30,7 +31,7 @@ const prePublishReport = defineReport({
30
31
  type: "cannibalization",
31
32
  params: {
32
33
  ...dates,
33
- limit: 200
34
+ limit: FULL_OUTPUT
34
35
  },
35
36
  feeds: ["cannibalization-risk"]
36
37
  }, {
@@ -38,7 +39,7 @@ const prePublishReport = defineReport({
38
39
  type: "striking-distance",
39
40
  params: {
40
41
  ...dates,
41
- limit: 200
42
+ limit: FULL_OUTPUT
42
43
  },
43
44
  feeds: ["striking-peers"]
44
45
  }];
@@ -1,5 +1,5 @@
1
1
  import { requireComparisonWindow } from "../require.mjs";
2
- import { reportRows, sectionArtifact, sectionCoverage, truncation } from "../sections.mjs";
2
+ import { reportRows, resultTotal, sectionArtifact, sectionCoverage, truncation } from "../sections.mjs";
3
3
  import { defineReport } from "@gscdump/engine/report";
4
4
  const DEFAULT_MAX = 5;
5
5
  const risksReport = defineReport({
@@ -100,7 +100,7 @@ function buildDecaySection(res, max) {
100
100
  magnitudeLabel: `${Math.round(totalLost)} clicks lost`
101
101
  },
102
102
  findings,
103
- truncated: truncation(rows.length, kept.length),
103
+ truncated: truncation(resultTotal(res, rows.length), kept.length),
104
104
  coverage: sectionCoverage(res),
105
105
  artifact: sectionArtifact(res, "decay")
106
106
  };
@@ -129,7 +129,7 @@ function buildCannibalizationSection(res, max) {
129
129
  severity: kept.length ? "medium" : "info",
130
130
  summary: {},
131
131
  findings,
132
- truncated: truncation(rows.length, kept.length),
132
+ truncated: truncation(resultTotal(res, rows.length), kept.length),
133
133
  coverage: sectionCoverage(res),
134
134
  artifact: sectionArtifact(res, "cannibalization")
135
135
  };
@@ -155,7 +155,7 @@ function buildDarkTrafficSection(res, max) {
155
155
  severity: kept.length ? "low" : "info",
156
156
  summary: { magnitudeLabel: `${Math.round(totalDark)} unattributed clicks` },
157
157
  findings,
158
- truncated: truncation(rows.length, kept.length),
158
+ truncated: truncation(resultTotal(res, rows.length), kept.length),
159
159
  coverage: sectionCoverage(res),
160
160
  artifact: sectionArtifact(res, "dark-traffic")
161
161
  };
@@ -181,7 +181,7 @@ function buildDeviceGapSection(res, max) {
181
181
  },
182
182
  why: `desktop vs mobile delta`
183
183
  })),
184
- truncated: truncation(rows.length, kept.length),
184
+ truncated: truncation(resultTotal(res, rows.length), kept.length),
185
185
  coverage: sectionCoverage(res),
186
186
  artifact: sectionArtifact(res, "device-gap")
187
187
  };
@@ -2,16 +2,18 @@ import { analysisErrorToException, analysisErrors } from "../errors.mjs";
2
2
  import { runAnalyzerFromSource } from "@gscdump/engine/analyzer";
3
3
  import { err, ok, unwrapResult } from "gscdump/result";
4
4
  import { computeInputHash } from "@gscdump/engine/report";
5
- async function executeStep(source, analyzers, step) {
5
+ async function executeStep(source, analyzers, step, fetchBudget) {
6
6
  const params = {
7
7
  ...step.params,
8
+ ...fetchBudget != null ? { fetchBudget } : {},
8
9
  type: step.type
9
10
  };
10
11
  return runAnalyzerFromSource(source, params, analyzers).then((result) => ({
11
12
  state: {
12
13
  key: step.key,
13
14
  type: step.type,
14
- status: "done"
15
+ status: "done",
16
+ ...result.meta.coverage ? { coverage: result.meta.coverage } : {}
15
17
  },
16
18
  result
17
19
  })).catch((thrown) => {
@@ -63,7 +65,7 @@ async function runReportResult(report, opts) {
63
65
  registryVersion: opts.ctx.registryVersion
64
66
  });
65
67
  const steps = report.plan(opts.ctx.params, opts.ctx.window);
66
- const outcomes = await Promise.all(steps.map((s) => executeStep(opts.source, opts.analyzers, s)));
68
+ const outcomes = await Promise.all(steps.map((s) => executeStep(opts.source, opts.analyzers, s, opts.ctx.fetchBudget)));
67
69
  const required = new Map(steps.filter((s) => s.required).map((s) => [s.key, s]));
68
70
  const errored = outcomes.filter((o) => o.state.status === "error");
69
71
  for (const o of errored) if (required.has(o.state.key)) return err(analysisErrors.requiredStepFailed(report.id, o.state.key, o.state.error ?? "unknown error", o.cause));
@@ -2,7 +2,11 @@ function reportRows(result) {
2
2
  return result?.results ?? [];
3
3
  }
4
4
  function sectionCoverage(result) {
5
- return result ? "full" : "partial";
5
+ return result && result.meta.coverage?.kind !== "truncated" ? "full" : "partial";
6
+ }
7
+ function resultTotal(result, fallback) {
8
+ const total = result?.meta.total;
9
+ return typeof total === "number" ? Math.max(total, fallback) : fallback;
6
10
  }
7
11
  function sectionArtifact(result, analyzer, params = {}) {
8
12
  return result ? {
@@ -19,4 +23,4 @@ function truncation(total, kept) {
19
23
  total
20
24
  } : void 0;
21
25
  }
22
- export { reportRows, sectionArtifact, sectionCoverage, truncation };
26
+ export { reportRows, resultTotal, sectionArtifact, sectionCoverage, truncation };
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@gscdump/analysis",
3
3
  "type": "module",
4
- "version": "3.8.0",
4
+ "version": "4.0.1",
5
5
  "description": "GSC analyzers — striking-distance, opportunity, movers, decay, brand, clustering, concentration, seasonality. Pure row-based + DuckDB-native.",
6
6
  "author": {
7
7
  "name": "Harlan Wilton",
@@ -58,16 +58,16 @@
58
58
  "dist"
59
59
  ],
60
60
  "engines": {
61
- "node": ">=22"
61
+ "node": ">=22.13.0"
62
62
  },
63
63
  "dependencies": {
64
- "@gscdump/engine": "^3.8.0",
65
- "@gscdump/engine-gsc-api": "^3.8.0",
66
- "gscdump": "^3.8.0",
64
+ "@gscdump/engine": "^4.0.1",
65
+ "@gscdump/engine-gsc-api": "^4.0.1",
66
+ "gscdump": "^4.0.1",
67
67
  "pluralize": "^8.0.0"
68
68
  },
69
69
  "devDependencies": {
70
- "vitest": "^5.0.0"
70
+ "vitest": "^5.0.1"
71
71
  },
72
72
  "scripts": {
73
73
  "build": "obuild",