@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.
package/README.md CHANGED
@@ -114,9 +114,10 @@ After creating `source` above, run a Report supported by that Source:
114
114
  ```ts
115
115
  import { defaultReportRegistry, runReport } from '@gscdump/analysis/report'
116
116
  import { resolveWindow } from '@gscdump/engine/period'
117
+ import { getLatestGscDate } from 'gscdump/dates'
117
118
 
118
119
  const report = defaultReportRegistry.getReport('movers')!
119
- const window = resolveWindow({ preset: 'last-28d', comparison: 'prev-period' })
120
+ const window = resolveWindow({ preset: 'last-28d', anchor: getLatestGscDate(), comparison: 'prev-period' })
120
121
  const result = await runReport(report, {
121
122
  source,
122
123
  analyzers: defaultAnalyzerRegistry,
@@ -155,12 +156,25 @@ Analyzer support depends on the Source's SQL dialect and capabilities.
155
156
  ```ts
156
157
  import { resolveWindow } from '@gscdump/analysis'
157
158
 
158
- const window = resolveWindow({ preset: 'last-30d', comparison: 'yoy' })
159
+ const window = resolveWindow({ preset: 'last-30d', anchor: '2026-08-28', comparison: 'yoy' })
159
160
  console.log(window.start, window.end, window.comparison)
160
161
  ```
161
162
 
162
- Presets: `last-7d`, `last-28d`, `last-30d`, `last-90d`, `last-180d`, `last-365d`, `mtd`, `ytd`, and `custom`.
163
- Comparisons: `none`, `prev-period`, and `yoy`.
163
+ Every preset except `custom` needs an `anchor`: the last date the window includes.
164
+ Pass the newest complete date in your data, such as the Store's newest synced day or `getLatestGscDate()` from `gscdump/dates`.
165
+ `resolveWindow` never reads the clock.
166
+
167
+ Presets: `last-7d`, `last-28d`, `last-30d`, `last-90d`, `last-180d`, `last-365d`, `mtd`, `qtd`, `ytd`, `last-quarter`, and `custom`.
168
+ Comparisons: `none`, `prev-period`, and `yoy`. `yoy` shifts the window back 364 days, so each day compares with the same weekday.
169
+
170
+ ## Fetch budget and output limit
171
+
172
+ `limit` caps the rows an Analyzer returns. It never caps the rows it reads.
173
+ Row plans read at most `fetchBudget` rows per query: 25,000 (one GSC page) by default, up to 100,000.
174
+ `meta.total` counts every match on every Analyzer with a row plan, not only the returned rows.
175
+ The SQL-only Analyzers are the exception. They still report `meta.total` after `limit`: `bayesian-ctr`, `bipartite-pagerank`, `change-point`, `ctr-anomaly`, `intent-atlas`, `long-tail`, `query-migration`, `stl-decompose`, and `trends`.
176
+ `meta.coverage` is `{ kind: 'complete' }`, or `{ kind: 'truncated', fetched }` when a query reached its fetch budget.
177
+ A truncated run can miss rows, so treat its totals and rankings as partial.
164
178
 
165
179
  ## Public API
166
180
 
@@ -1,12 +1,11 @@
1
1
  import { between, date, gsc, page, query } from "gscdump/query";
2
- const DEFAULT_LIMIT = 25e3;
3
- function queriesQueryState(period, limit = DEFAULT_LIMIT) {
4
- return gsc.select(query, page).where(between(date, period.startDate, period.endDate)).limit(limit).getState();
2
+ function queriesQueryState(period, budget) {
3
+ return gsc.select(query, page).where(between(date, period.startDate, period.endDate)).limit(budget).getState();
5
4
  }
6
- function pagesQueryState(period, limit = DEFAULT_LIMIT) {
7
- return gsc.select(page).where(between(date, period.startDate, period.endDate)).limit(limit).getState();
5
+ function pagesQueryState(period, budget) {
6
+ return gsc.select(page).where(between(date, period.startDate, period.endDate)).limit(budget).getState();
8
7
  }
9
- function datesQueryState(period, limit = DEFAULT_LIMIT) {
10
- return gsc.select(date).where(between(date, period.startDate, period.endDate)).limit(limit).getState();
8
+ function datesQueryState(period, budget) {
9
+ return gsc.select(date).where(between(date, period.startDate, period.endDate)).limit(budget).getState();
11
10
  }
12
11
  export { datesQueryState, pagesQueryState, queriesQueryState };
@@ -82,4 +82,15 @@ function paginateSortedInMemory(rows, input, compare) {
82
82
  if (invalidComparison) return fullSort();
83
83
  return heap.slice(offset, offset + limit).map((entry) => entry.value);
84
84
  }
85
- export { clampLimit, clampOffset, paginateClause, paginateInMemory, paginateSortedInMemory };
85
+ function resolveSort(input, allowed, defaults) {
86
+ return {
87
+ sortBy: input.sortBy && allowed.includes(input.sortBy) ? input.sortBy : defaults.sortBy,
88
+ sortDir: input.sortDir === "asc" || input.sortDir === "desc" ? input.sortDir : defaults.sortDir
89
+ };
90
+ }
91
+ const TOTAL_COUNT_SELECT = "CAST(COUNT(*) OVER () AS DOUBLE) AS totalCount";
92
+ function totalCountOf(rows) {
93
+ const first = rows[0];
94
+ return first?.totalCount != null ? Number(first.totalCount) : rows.length;
95
+ }
96
+ export { TOTAL_COUNT_SELECT, clampLimit, clampOffset, paginateClause, paginateInMemory, paginateSortedInMemory, resolveSort, totalCountOf };
@@ -1,7 +1,8 @@
1
1
  import { queriesQueryState } from "../analyzer/adapt-rows.mjs";
2
+ import { TOTAL_COUNT_SELECT, paginateClause, paginateSortedInMemory, totalCountOf } from "../analyzer/paginate.mjs";
2
3
  import { rowString } from "../analyzer/row-values.mjs";
3
4
  import { analysisErrorToException, analysisErrors } from "../errors.mjs";
4
- import { num } from "@gscdump/engine/analysis-types";
5
+ import { fetchBudgetOf, num } from "@gscdump/engine/analysis-types";
5
6
  import { defineAnalyzer } from "@gscdump/engine/analyzer";
6
7
  import { periodOf } from "@gscdump/engine/period";
7
8
  import { enumeratePartitions } from "@gscdump/engine/planner";
@@ -74,13 +75,26 @@ const brandAnalyzer = defineAnalyzer({
74
75
  WHERE date >= ? AND date <= ?
75
76
  GROUP BY query, url
76
77
  HAVING SUM(impressions) >= ?
78
+ ),
79
+ segmented AS (
80
+ SELECT
81
+ query, page, clicks, impressions, ctr, position,
82
+ CASE WHEN regexp_matches(LOWER(query), ?) THEN 'brand' ELSE 'non-brand' END AS segment
83
+ FROM agg
77
84
  )
78
85
  SELECT
79
- query, page, clicks, impressions, ctr, position,
80
- CASE WHEN regexp_matches(LOWER(query), ?) THEN 'brand' ELSE 'non-brand' END AS segment
81
- FROM agg
86
+ query, page, clicks, impressions, ctr, position, segment,
87
+ SUM(CASE WHEN segment = 'brand' THEN clicks ELSE 0 END) OVER () AS brandClicks,
88
+ SUM(CASE WHEN segment = 'brand' THEN 0 ELSE clicks END) OVER () AS nonBrandClicks,
89
+ SUM(CASE WHEN segment = 'brand' THEN impressions ELSE 0 END) OVER () AS brandImpressions,
90
+ SUM(CASE WHEN segment = 'brand' THEN 0 ELSE impressions END) OVER () AS nonBrandImpressions,
91
+ ${TOTAL_COUNT_SELECT}
92
+ FROM segmented
82
93
  ORDER BY clicks DESC
83
- LIMIT ${Number(limit)}
94
+ ${paginateClause({
95
+ limit,
96
+ offset: params.offset
97
+ })}
84
98
  `,
85
99
  params: [
86
100
  startDate,
@@ -95,7 +109,8 @@ const brandAnalyzer = defineAnalyzer({
95
109
  };
96
110
  },
97
111
  reduceSql(rows) {
98
- const normalized = (Array.isArray(rows) ? rows : []).map((r) => ({
112
+ const arr = Array.isArray(rows) ? rows : [];
113
+ const normalized = arr.map((r) => ({
99
114
  query: rowString(r.query),
100
115
  page: r.page == null ? void 0 : rowString(r.page),
101
116
  clicks: num(r.clicks),
@@ -104,34 +119,27 @@ const brandAnalyzer = defineAnalyzer({
104
119
  position: num(r.position),
105
120
  segment: rowString(r.segment)
106
121
  }));
107
- let brandClicks = 0;
108
- let nonBrandClicks = 0;
109
- let brandImpressions = 0;
110
- let nonBrandImpressions = 0;
111
- for (const r of normalized) if (r.segment === "brand") {
112
- brandClicks += r.clicks;
113
- brandImpressions += r.impressions;
114
- } else {
115
- nonBrandClicks += r.clicks;
116
- nonBrandImpressions += r.impressions;
117
- }
122
+ const first = arr[0];
123
+ const brandClicks = num(first?.brandClicks);
124
+ const nonBrandClicks = num(first?.nonBrandClicks);
118
125
  const totalClicks = brandClicks + nonBrandClicks;
119
126
  return {
120
127
  results: normalized,
121
128
  meta: {
122
- total: normalized.length,
129
+ total: totalCountOf(arr),
130
+ returned: normalized.length,
123
131
  summary: {
124
132
  brandClicks,
125
133
  nonBrandClicks,
126
134
  brandShare: totalClicks > 0 ? brandClicks / totalClicks : 0,
127
- brandImpressions,
128
- nonBrandImpressions
135
+ brandImpressions: num(first?.brandImpressions),
136
+ nonBrandImpressions: num(first?.nonBrandImpressions)
129
137
  }
130
138
  }
131
139
  };
132
140
  },
133
141
  buildRows(params) {
134
- return { queries: queriesQueryState(periodOf(params), params.limit) };
142
+ return { queries: queriesQueryState(periodOf(params), fetchBudgetOf(params)) };
135
143
  },
136
144
  reduceRows(rows, params) {
137
145
  const brandTerms = requireBrandTerms(params.brandTerms);
@@ -139,15 +147,24 @@ const brandAnalyzer = defineAnalyzer({
139
147
  brandTerms,
140
148
  minImpressions: params.minImpressions
141
149
  });
150
+ const segmented = [...result.brand.map((r) => ({
151
+ ...r,
152
+ segment: "brand"
153
+ })), ...result.nonBrand.map((r) => ({
154
+ ...r,
155
+ segment: "non-brand"
156
+ }))];
157
+ const paged = paginateSortedInMemory(segmented, {
158
+ limit: params.limit,
159
+ offset: params.offset
160
+ }, (left, right) => right.clicks - left.clicks);
142
161
  return {
143
- results: [...result.brand.map((r) => ({
144
- ...r,
145
- segment: "brand"
146
- })), ...result.nonBrand.map((r) => ({
147
- ...r,
148
- segment: "non-brand"
149
- }))],
150
- meta: { summary: result.summary }
162
+ results: paged,
163
+ meta: {
164
+ total: segmented.length,
165
+ returned: paged.length,
166
+ summary: result.summary
167
+ }
151
168
  };
152
169
  }
153
170
  });
@@ -1,7 +1,8 @@
1
1
  import { queriesQueryState } from "../analyzer/adapt-rows.mjs";
2
+ import { TOTAL_COUNT_SELECT, paginateInMemory, totalCountOf } from "../analyzer/paginate.mjs";
2
3
  import { parseJsonRows, rowString } from "../analyzer/row-values.mjs";
3
4
  import { createSorter } from "../types.mjs";
4
- import { num } from "@gscdump/engine/analysis-types";
5
+ import { fetchBudgetOf, num } from "@gscdump/engine/analysis-types";
5
6
  import { defineAnalyzer } from "@gscdump/engine/analyzer";
6
7
  import { periodOf } from "@gscdump/engine/period";
7
8
  import { enumeratePartitions } from "@gscdump/engine/planner";
@@ -154,7 +155,10 @@ const cannibalizationAnalyzer = defineAnalyzer({
154
155
  * LEAST(LOG10(GREATEST(t.total_impressions, 10.0)) / 5.0, 1.0),
155
156
  1.0 / 3.0
156
157
  )
157
- )) AS DOUBLE) AS severity
158
+ )) AS DOUBLE) AS severity,
159
+ SUM(e.stolen_clicks) OVER () AS allStolenClicks,
160
+ AVG(GREATEST(0.0, 1.0 - e.hhi / 10000.0)) OVER () AS allFragmentation,
161
+ ${TOTAL_COUNT_SELECT}
158
162
  FROM events e
159
163
  JOIN query_totals t USING (query)
160
164
  ORDER BY severity DESC, stolenClicks DESC
@@ -174,7 +178,8 @@ const cannibalizationAnalyzer = defineAnalyzer({
174
178
  };
175
179
  },
176
180
  reduceSql(rows) {
177
- const events = (Array.isArray(rows) ? rows : []).map((r) => ({
181
+ const arr = Array.isArray(rows) ? rows : [];
182
+ const events = arr.map((r) => ({
178
183
  keyword: rowString(r.keyword),
179
184
  totalImpressions: num(r.totalImpressions),
180
185
  totalClicks: num(r.totalClicks),
@@ -234,12 +239,13 @@ const cannibalizationAnalyzer = defineAnalyzer({
234
239
  queryCount: n.queries.size
235
240
  }));
236
241
  const edges = [...edgeAgg.values()];
237
- const avgFragmentation = events.length > 0 ? events.reduce((s, e) => s + e.fragmentation, 0) / events.length : 0;
238
- const totalStolenClicks = events.reduce((s, e) => s + e.stolenClicks, 0);
242
+ const avgFragmentation = arr[0] ? num(arr[0].allFragmentation) : 0;
243
+ const totalStolenClicks = arr[0] ? num(arr[0].allStolenClicks) : 0;
239
244
  return {
240
245
  results: events,
241
246
  meta: {
242
- total: events.length,
247
+ total: totalCountOf(arr),
248
+ returned: events.length,
243
249
  totalStolenClicks,
244
250
  avgFragmentation,
245
251
  graph: {
@@ -250,7 +256,7 @@ const cannibalizationAnalyzer = defineAnalyzer({
250
256
  };
251
257
  },
252
258
  buildRows(params) {
253
- return { rows: queriesQueryState(periodOf(params), params.limit) };
259
+ return { rows: queriesQueryState(periodOf(params), fetchBudgetOf(params)) };
254
260
  },
255
261
  reduceRows(rows, params) {
256
262
  const results = analyzeCannibalization(Array.isArray(rows) ? rows : [], {
@@ -258,9 +264,16 @@ const cannibalizationAnalyzer = defineAnalyzer({
258
264
  maxPositionSpread: params.maxPositionSpread,
259
265
  minPages: params.minPages
260
266
  });
267
+ const paged = paginateInMemory(results, {
268
+ limit: params.limit,
269
+ offset: params.offset
270
+ });
261
271
  return {
262
- results,
263
- meta: { total: results.length }
272
+ results: paged,
273
+ meta: {
274
+ total: results.length,
275
+ returned: paged.length
276
+ }
264
277
  };
265
278
  }
266
279
  });
@@ -1,6 +1,7 @@
1
1
  import { queriesQueryState } from "../analyzer/adapt-rows.mjs";
2
+ import { paginateInMemory } from "../analyzer/paginate.mjs";
2
3
  import { parseJsonRows, rowString } from "../analyzer/row-values.mjs";
3
- import { num } from "@gscdump/engine/analysis-types";
4
+ import { fetchBudgetOf, num } from "@gscdump/engine/analysis-types";
4
5
  import { defineAnalyzer } from "@gscdump/engine/analyzer";
5
6
  import { periodOf } from "@gscdump/engine/period";
6
7
  import { enumeratePartitions } from "@gscdump/engine/planner";
@@ -175,7 +176,7 @@ const clusteringAnalyzer = defineAnalyzer({
175
176
  }
176
177
  };
177
178
  },
178
- reduceSql(rows) {
179
+ reduceSql(rows, params) {
179
180
  const clusters = (Array.isArray(rows) ? rows : []).map((r) => ({
180
181
  clusterName: rowString(r.clusterName),
181
182
  clusterType: rowString(r.clusterType),
@@ -191,16 +192,21 @@ const clusteringAnalyzer = defineAnalyzer({
191
192
  position: num(k.position)
192
193
  }))
193
194
  }));
195
+ const paged = paginateInMemory(clusters, {
196
+ limit: params.limit,
197
+ offset: params.offset
198
+ });
194
199
  return {
195
- results: clusters,
200
+ results: paged,
196
201
  meta: {
197
202
  total: clusters.length,
203
+ returned: paged.length,
198
204
  totalClusters: clusters.length
199
205
  }
200
206
  };
201
207
  },
202
208
  buildRows(params) {
203
- return { queries: queriesQueryState(periodOf(params), params.limit) };
209
+ return { queries: queriesQueryState(periodOf(params), fetchBudgetOf(params)) };
204
210
  },
205
211
  reduceRows(rows, params) {
206
212
  const result = analyzeClustering(Array.isArray(rows) ? rows : [], {
@@ -208,9 +214,17 @@ const clusteringAnalyzer = defineAnalyzer({
208
214
  minClusterSize: params.minClusterSize,
209
215
  minImpressions: params.minImpressions
210
216
  });
217
+ const paged = paginateInMemory(result.clusters, {
218
+ limit: params.limit,
219
+ offset: params.offset
220
+ });
211
221
  return {
212
- results: result.clusters,
213
- meta: { totalClusters: result.clusters.length }
222
+ results: paged,
223
+ meta: {
224
+ total: result.clusters.length,
225
+ returned: paged.length,
226
+ totalClusters: result.clusters.length
227
+ }
214
228
  };
215
229
  }
216
230
  });
@@ -1,6 +1,6 @@
1
1
  import { pagesQueryState, queriesQueryState } from "../analyzer/adapt-rows.mjs";
2
2
  import { parseJsonRows, rowString } from "../analyzer/row-values.mjs";
3
- import { num } from "@gscdump/engine/analysis-types";
3
+ import { fetchBudgetOf, num } from "@gscdump/engine/analysis-types";
4
4
  import { defineAnalyzer } from "@gscdump/engine/analyzer";
5
5
  import { periodOf } from "@gscdump/engine/period";
6
6
  import { enumeratePartitions } from "@gscdump/engine/planner";
@@ -164,8 +164,8 @@ const concentrationAnalyzer = defineAnalyzer({
164
164
  const dim = params.dimension || "pages";
165
165
  const period = periodOf(params);
166
166
  const out = {};
167
- if (dim === "pages") out.pages = pagesQueryState(period, params.limit);
168
- else out.queries = queriesQueryState(period, params.limit);
167
+ if (dim === "pages") out.pages = pagesQueryState(period, fetchBudgetOf(params));
168
+ else out.queries = queriesQueryState(period, fetchBudgetOf(params));
169
169
  return out;
170
170
  },
171
171
  reduceRows(rows, params) {
@@ -1,8 +1,8 @@
1
1
  import { pagesQueryState } from "../analyzer/adapt-rows.mjs";
2
+ import { TOTAL_COUNT_SELECT, paginateClause, paginateInMemory, totalCountOf } from "../analyzer/paginate.mjs";
2
3
  import { parseJsonRows, rowString } from "../analyzer/row-values.mjs";
3
4
  import { buildPeriodMap, createMetricSorter } from "../types.mjs";
4
- import { paginateInMemory } from "../analyzer/paginate.mjs";
5
- import { num } from "@gscdump/engine/analysis-types";
5
+ import { fetchBudgetOf, num } from "@gscdump/engine/analysis-types";
6
6
  import { defineAnalyzer } from "@gscdump/engine/analyzer";
7
7
  import { comparisonOf } from "@gscdump/engine/period";
8
8
  import { enumeratePartitions } from "@gscdump/engine/planner";
@@ -48,6 +48,7 @@ const decayAnalyzer = defineAnalyzer({
48
48
  const { current: cur, previous: prev } = comparisonOf(params);
49
49
  const minPreviousClicks = params.minPreviousClicks ?? 50;
50
50
  const threshold = params.threshold ?? .2;
51
+ const limit = params.limit ?? 2e3;
51
52
  return {
52
53
  sql: `
53
54
  WITH cur AS (
@@ -107,11 +108,14 @@ const decayAnalyzer = defineAnalyzer({
107
108
  LEFT JOIN cur c ON p.url = c.url
108
109
  LEFT JOIN series_by_url s ON p.url = s.url
109
110
  )
110
- SELECT *
111
+ SELECT *, ${TOTAL_COUNT_SELECT}
111
112
  FROM joined
112
113
  WHERE declinePercent >= ? AND lostClicks > 0
113
114
  ORDER BY lostClicks DESC
114
- LIMIT 2000
115
+ ${paginateClause({
116
+ limit,
117
+ offset: params.offset
118
+ })}
115
119
  `,
116
120
  params: [
117
121
  cur.startDate,
@@ -135,8 +139,9 @@ const decayAnalyzer = defineAnalyzer({
135
139
  }
136
140
  };
137
141
  },
138
- reduceSql(rows, params) {
139
- const mapped = (Array.isArray(rows) ? rows : []).map((r) => ({
142
+ reduceSql(rows) {
143
+ const arr = Array.isArray(rows) ? rows : [];
144
+ const mapped = arr.map((r) => ({
140
145
  page: rowString(r.page),
141
146
  currentClicks: num(r.currentClicks),
142
147
  previousClicks: num(r.previousClicks),
@@ -152,18 +157,18 @@ const decayAnalyzer = defineAnalyzer({
152
157
  }))
153
158
  }));
154
159
  return {
155
- results: paginateInMemory(mapped, {
156
- limit: params.limit ?? 2e3,
157
- offset: params.offset
158
- }),
159
- meta: { total: mapped.length }
160
+ results: mapped,
161
+ meta: {
162
+ total: totalCountOf(arr),
163
+ returned: mapped.length
164
+ }
160
165
  };
161
166
  },
162
167
  buildRows(params) {
163
168
  const { current, previous } = comparisonOf(params);
164
169
  return {
165
- current: pagesQueryState(current, params.limit),
166
- previous: pagesQueryState(previous, params.limit)
170
+ current: pagesQueryState(current, fetchBudgetOf(params)),
171
+ previous: pagesQueryState(previous, fetchBudgetOf(params))
167
172
  };
168
173
  },
169
174
  reduceRows(rows, params) {
@@ -178,9 +183,16 @@ const decayAnalyzer = defineAnalyzer({
178
183
  minPreviousClicks: params.minPreviousClicks,
179
184
  threshold: params.threshold
180
185
  });
186
+ const paged = paginateInMemory(results, {
187
+ limit: params.limit ?? 2e3,
188
+ offset: params.offset
189
+ });
181
190
  return {
182
- results,
183
- meta: { total: results.length }
191
+ results: paged,
192
+ meta: {
193
+ total: results.length,
194
+ returned: paged.length
195
+ }
184
196
  };
185
197
  }
186
198
  });
@@ -1,12 +1,19 @@
1
1
  import { QueriesRow } from "../types.mjs";
2
- export type MoversSortMetric = 'clicks' | 'impressions' | 'clicksChange' | 'impressionsChange' | 'positionChange';
2
+ /**
3
+ * Sort keys. `*Delta` sorts by the absolute change, `*DeltaPercent` by the
4
+ * percent change. Both sort by magnitude, so large drops rank with large gains.
5
+ */
6
+ export type MoversSortMetric = 'clicks' | 'impressions' | 'clicksDelta' | 'clicksDeltaPercent' | 'impressionsDelta' | 'impressionsDeltaPercent' | 'positionDelta';
7
+ export declare const MOVERS_SORT_METRICS: readonly MoversSortMetric[];
3
8
  export interface MoversOptions {
4
9
  /** Minimum change threshold to flag. Default: 0.2 (20%) */
5
10
  changeThreshold?: number;
6
- /** Minimum impressions in recent period. Default: 50 */
11
+ /** Minimum impressions in the larger of the two periods. Default: 50 */
7
12
  minImpressions?: number;
8
- /** Metric to sort results by. Default: clicksChange */
13
+ /** Metric to sort results by. Default: clicksDelta */
9
14
  sortBy?: MoversSortMetric;
15
+ /** `desc` puts the largest change first. Default: desc */
16
+ sortDir?: 'asc' | 'desc';
10
17
  }
11
18
  export interface MoversInput {
12
19
  current: QueriesRow[];
@@ -19,13 +26,15 @@ export interface MoverData {
19
26
  page: string | null;
20
27
  recentClicks: number;
21
28
  recentImpressions: number;
22
- recentPosition: number;
29
+ /** `null` when the (query, page) pair has no current-period data (it vanished). */
30
+ recentPosition: number | null;
23
31
  baselineClicks: number;
24
32
  baselineImpressions: number;
25
- /** `null` when the keyword has no previous-period data (a genuinely new query), not "position 0". */
33
+ /** `null` when the (query, page) pair has no previous-period data (it is new). */
26
34
  baselinePosition: number | null;
27
35
  clicksChange: number;
28
36
  clicksChangePercent: number;
37
+ impressionsChange: number;
29
38
  impressionsChangePercent: number;
30
39
  /** `null` unless both recentPosition and baselinePosition exist. */
31
40
  positionChange: number | null;
@@ -36,7 +45,10 @@ export interface MoversResult {
36
45
  stable: MoverData[];
37
46
  }
38
47
  /**
39
- * Pure helper: identify "movers and shakers" — keywords with significant
40
- * recent changes between two periods.
48
+ * Pure helper: identify "movers and shakers" between two periods. Rows join
49
+ * on the full `(query, page)` identity in both directions, so a pair that
50
+ * vanished from the current period reports as declining, and one query on
51
+ * two pages yields two movers. `minImpressions` applies to the larger of the
52
+ * two periods, so a drop below the floor still reports.
41
53
  */
42
54
  export declare function analyzeMovers(input: MoversInput, options?: MoversOptions): MoversResult;