@gscdump/analysis 3.7.0 → 4.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +18 -4
- package/dist/analyzer/adapt-rows.mjs +6 -7
- package/dist/analyzer/paginate.mjs +12 -1
- package/dist/analyzers/brand.mjs +46 -29
- package/dist/analyzers/cannibalization.mjs +22 -9
- package/dist/analyzers/clustering.mjs +20 -6
- package/dist/analyzers/concentration.mjs +3 -3
- package/dist/analyzers/decay.mjs +27 -15
- package/dist/analyzers/movers.d.mts +19 -7
- package/dist/analyzers/movers.mjs +226 -149
- package/dist/analyzers/opportunity.mjs +9 -5
- package/dist/analyzers/seasonality.mjs +6 -3
- package/dist/analyzers/striking-distance.mjs +2 -3
- package/dist/analyzers/zero-click.mjs +8 -9
- package/dist/index.d.mts +2 -2
- package/dist/index.mjs +2 -2
- package/dist/report/reports/movers.mjs +27 -10
- package/dist/report/reports/opportunities.mjs +11 -7
- package/dist/report/reports/pre-publish.mjs +3 -2
- package/dist/report/reports/risks.mjs +5 -5
- package/dist/report/runtime.mjs +5 -3
- package/dist/report/sections.mjs +6 -2
- package/package.json +6 -6
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
|
-
|
|
163
|
-
|
|
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
|
-
|
|
3
|
-
|
|
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,
|
|
7
|
-
return gsc.select(page).where(between(date, period.startDate, period.endDate)).limit(
|
|
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,
|
|
10
|
-
return gsc.select(date).where(between(date, period.startDate, period.endDate)).limit(
|
|
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
|
-
|
|
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 };
|
package/dist/analyzers/brand.mjs
CHANGED
|
@@ -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
|
|
81
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
108
|
-
|
|
109
|
-
|
|
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:
|
|
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
|
|
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:
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
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
|
|
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 =
|
|
238
|
-
const totalStolenClicks =
|
|
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:
|
|
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
|
|
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: {
|
|
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:
|
|
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
|
|
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:
|
|
213
|
-
meta: {
|
|
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
|
|
168
|
-
else out.queries = queriesQueryState(period, params
|
|
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) {
|
package/dist/analyzers/decay.mjs
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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
|
|
139
|
-
const
|
|
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:
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
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
|
|
166
|
-
previous: pagesQueryState(previous, params
|
|
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: {
|
|
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
|
-
|
|
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
|
|
11
|
+
/** Minimum impressions in the larger of the two periods. Default: 50 */
|
|
7
12
|
minImpressions?: number;
|
|
8
|
-
/** Metric to sort results by. Default:
|
|
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
|
-
|
|
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
|
|
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"
|
|
40
|
-
*
|
|
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;
|