@gscdump/analysis 1.4.0 → 1.4.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.
Files changed (99) hide show
  1. package/dist/action-priority.d.mts +34 -0
  2. package/dist/action-priority.mjs +201 -0
  3. package/dist/analyzer/adapt-rows.mjs +12 -0
  4. package/dist/analyzer/all.mjs +61 -0
  5. package/dist/analyzer/paginate.mjs +85 -0
  6. package/dist/analyzer/row-analyzers.d.mts +3 -0
  7. package/dist/analyzer/row-analyzers.mjs +3 -0
  8. package/dist/analyzer/row-values.mjs +22 -0
  9. package/dist/analyzers/bayesian-ctr.mjs +177 -0
  10. package/dist/analyzers/bipartite-pagerank.mjs +243 -0
  11. package/dist/analyzers/brand.d.mts +33 -0
  12. package/dist/analyzers/brand.mjs +154 -0
  13. package/dist/analyzers/cannibalization.d.mts +58 -0
  14. package/dist/analyzers/cannibalization.mjs +267 -0
  15. package/dist/analyzers/change-point.mjs +197 -0
  16. package/dist/analyzers/clustering.d.mts +29 -0
  17. package/dist/analyzers/clustering.mjs +217 -0
  18. package/dist/analyzers/concentration.d.mts +42 -0
  19. package/dist/analyzers/concentration.mjs +180 -0
  20. package/dist/analyzers/content-velocity.mjs +87 -0
  21. package/dist/analyzers/ctr-anomaly.mjs +189 -0
  22. package/dist/analyzers/ctr-curve.mjs +128 -0
  23. package/dist/analyzers/dark-traffic.mjs +130 -0
  24. package/dist/analyzers/data-detail.mjs +44 -0
  25. package/dist/analyzers/data-query.mjs +44 -0
  26. package/dist/analyzers/decay.d.mts +38 -0
  27. package/dist/analyzers/decay.mjs +187 -0
  28. package/dist/analyzers/device-gap.mjs +135 -0
  29. package/dist/analyzers/intent-atlas.mjs +175 -0
  30. package/dist/analyzers/keyword-breadth.mjs +115 -0
  31. package/dist/analyzers/long-tail.mjs +150 -0
  32. package/dist/analyzers/movers.d.mts +43 -0
  33. package/dist/analyzers/movers.mjs +283 -0
  34. package/dist/analyzers/opportunity.d.mts +32 -0
  35. package/dist/analyzers/opportunity.mjs +212 -0
  36. package/dist/analyzers/position-distribution.mjs +57 -0
  37. package/dist/analyzers/position-volatility.mjs +143 -0
  38. package/dist/analyzers/query-migration.mjs +178 -0
  39. package/dist/analyzers/seasonality.d.mts +28 -0
  40. package/dist/analyzers/seasonality.mjs +136 -0
  41. package/dist/analyzers/stl-decompose.mjs +187 -0
  42. package/dist/analyzers/striking-distance.d.mts +54 -0
  43. package/dist/analyzers/striking-distance.mjs +82 -0
  44. package/dist/analyzers/survival.mjs +200 -0
  45. package/dist/analyzers/trends.mjs +156 -0
  46. package/dist/analyzers/zero-click.d.mts +17 -0
  47. package/dist/analyzers/zero-click.mjs +122 -0
  48. package/dist/browser.d.mts +5 -0
  49. package/dist/browser.mjs +11 -0
  50. package/dist/default-registry.d.mts +3 -1
  51. package/dist/default-registry.mjs +5 -1
  52. package/dist/errors.d.mts +51 -1
  53. package/dist/index.d.mts +30 -494
  54. package/dist/index.mjs +28 -49
  55. package/dist/query/analyzers.mjs +301 -0
  56. package/dist/query/index.mjs +4 -0
  57. package/dist/query/intent.d.mts +20 -0
  58. package/dist/query/intent.mjs +139 -0
  59. package/dist/query/normalize.d.mts +15 -0
  60. package/dist/query/normalize.mjs +204 -0
  61. package/dist/report/format.d.mts +7 -0
  62. package/dist/report/format.mjs +45 -0
  63. package/dist/report/index.d.mts +4 -1
  64. package/dist/report/index.mjs +4 -1
  65. package/dist/report/registry.d.mts +4 -0
  66. package/dist/report/registry.mjs +26 -0
  67. package/dist/report/reports/brand.mjs +104 -0
  68. package/dist/report/reports/growth.mjs +144 -0
  69. package/dist/report/reports/health.mjs +141 -0
  70. package/dist/report/reports/movers.mjs +203 -0
  71. package/dist/report/reports/opportunities.mjs +184 -0
  72. package/dist/report/reports/pre-publish.mjs +111 -0
  73. package/dist/report/reports/priority.mjs +97 -0
  74. package/dist/report/reports/risks.mjs +201 -0
  75. package/dist/report/reports/triage.mjs +155 -0
  76. package/dist/report/require.mjs +15 -0
  77. package/dist/report/resolve-target.d.mts +29 -0
  78. package/dist/report/resolve-target.mjs +32 -0
  79. package/dist/{_chunks/index.d.mts → report/runtime.d.mts} +2 -37
  80. package/dist/report/runtime.mjs +80 -0
  81. package/dist/report/sections.mjs +22 -0
  82. package/dist/sitemap-health.d.mts +34 -0
  83. package/dist/sitemap-health.mjs +37 -0
  84. package/dist/{_chunks/index2.d.mts → source/composite.d.mts} +2 -13
  85. package/dist/{_chunks/source.mjs → source/composite.mjs} +1 -17
  86. package/dist/source/in-memory.d.mts +14 -0
  87. package/dist/source/in-memory.mjs +17 -0
  88. package/dist/source/index.d.mts +2 -1
  89. package/dist/source/index.mjs +2 -1
  90. package/dist/sql-analyzers.d.mts +3 -0
  91. package/dist/sql-analyzers.mjs +3 -0
  92. package/dist/types.d.mts +34 -0
  93. package/dist/types.mjs +21 -0
  94. package/package.json +4 -4
  95. package/dist/_chunks/default-registry.d.mts +0 -5
  96. package/dist/_chunks/default-registry.mjs +0 -5183
  97. package/dist/_chunks/errors.d.mts +0 -52
  98. package/dist/_chunks/report.mjs +0 -1710
  99. /package/dist/{_chunks/scoring.mjs → scoring.mjs} +0 -0
@@ -0,0 +1,267 @@
1
+ import { parseJsonRows, rowString } from "../analyzer/row-values.mjs";
2
+ import { queriesQueryState } from "../analyzer/adapt-rows.mjs";
3
+ import { createSorter } from "../types.mjs";
4
+ import { defineAnalyzer } from "@gscdump/engine/analyzer";
5
+ import { periodOf } from "@gscdump/engine/period";
6
+ import { enumeratePartitions } from "@gscdump/engine/planner";
7
+ import { METRIC_EXPR } from "@gscdump/engine/sql-fragments";
8
+ import { num } from "@gscdump/engine/analysis-types";
9
+ const sortRowResults = createSorter((item, metric) => {
10
+ switch (metric) {
11
+ case "clicks": return item.totalClicks;
12
+ case "impressions": return item.totalImpressions;
13
+ case "positionSpread": return item.positionSpread;
14
+ case "pageCount": return item.pages.length;
15
+ }
16
+ }, "clicks");
17
+ function analyzeCannibalization(rows, options = {}) {
18
+ const { minImpressions = 10, maxPositionSpread = 10, minPages = 2, sortBy = "clicks", sortOrder = "desc" } = options;
19
+ const queryMap = /* @__PURE__ */ new Map();
20
+ for (const row of rows) {
21
+ if (row.impressions < minImpressions) continue;
22
+ const pages = queryMap.get(row.query) || [];
23
+ pages.push({
24
+ page: row.page,
25
+ clicks: row.clicks,
26
+ impressions: row.impressions,
27
+ ctr: row.ctr,
28
+ position: row.position
29
+ });
30
+ queryMap.set(row.query, pages);
31
+ }
32
+ const results = [];
33
+ for (const [query, pages] of queryMap) {
34
+ if (pages.length < minPages) continue;
35
+ let minPosition = Number.POSITIVE_INFINITY;
36
+ let maxPosition = Number.NEGATIVE_INFINITY;
37
+ let totalClicks = 0;
38
+ let totalImpressions = 0;
39
+ for (const page of pages) {
40
+ minPosition = Math.min(minPosition, page.position);
41
+ maxPosition = Math.max(maxPosition, page.position);
42
+ totalClicks += page.clicks;
43
+ totalImpressions += page.impressions;
44
+ }
45
+ const positionSpread = maxPosition - minPosition;
46
+ if (positionSpread > maxPositionSpread) continue;
47
+ pages.sort((a, b) => b.clicks - a.clicks);
48
+ results.push({
49
+ query,
50
+ pages,
51
+ totalClicks,
52
+ totalImpressions,
53
+ positionSpread
54
+ });
55
+ }
56
+ return sortRowResults(results, sortBy, sortOrder);
57
+ }
58
+ const cannibalizationAnalyzer = defineAnalyzer({
59
+ id: "cannibalization",
60
+ buildSql(params) {
61
+ const { startDate, endDate } = periodOf(params);
62
+ const minImpressions = params.minImpressions ?? 50;
63
+ const minCompetitors = 2;
64
+ const minQueryImpressions = (params.minImpressions ?? 50) * 2;
65
+ const limit = params.limit ?? 200;
66
+ return {
67
+ sql: `
68
+ WITH agg AS (
69
+ SELECT
70
+ query,
71
+ url,
72
+ ${METRIC_EXPR.clicks} AS clicks,
73
+ ${METRIC_EXPR.impressions} AS impressions,
74
+ ${METRIC_EXPR.ctr} AS ctr,
75
+ ${METRIC_EXPR.position} AS position
76
+ FROM read_parquet({{FILES}}, union_by_name = true)
77
+ WHERE date >= ? AND date <= ?
78
+ AND query IS NOT NULL AND query <> ''
79
+ AND url IS NOT NULL AND url <> ''
80
+ GROUP BY query, url
81
+ HAVING SUM(impressions) >= ?
82
+ ),
83
+ query_totals AS (
84
+ SELECT
85
+ query,
86
+ SUM(impressions) AS total_impressions,
87
+ SUM(clicks) AS total_clicks,
88
+ COUNT(*) AS competitor_count
89
+ FROM agg
90
+ GROUP BY query
91
+ HAVING COUNT(*) >= ? AND SUM(impressions) >= ?
92
+ ),
93
+ ranked AS (
94
+ SELECT
95
+ a.query,
96
+ a.url,
97
+ a.clicks,
98
+ a.impressions,
99
+ a.ctr,
100
+ a.position,
101
+ a.impressions / NULLIF(t.total_impressions, 0) AS share,
102
+ ROW_NUMBER() OVER (
103
+ PARTITION BY a.query
104
+ ORDER BY a.impressions DESC, a.clicks DESC, a.url ASC
105
+ ) AS rnk
106
+ FROM agg a
107
+ JOIN query_totals t USING (query)
108
+ ),
109
+ leader AS (
110
+ SELECT query, url AS leader_url, ctr AS leader_ctr, position AS leader_position
111
+ FROM ranked WHERE rnk = 1
112
+ ),
113
+ events AS (
114
+ SELECT
115
+ r.query,
116
+ any_value(l.leader_url) AS leader_url,
117
+ any_value(l.leader_ctr) AS leader_ctr,
118
+ any_value(l.leader_position) AS leader_position,
119
+ SUM(POWER(r.share * 100.0, 2)) AS hhi,
120
+ SUM(CASE
121
+ WHEN r.rnk > 1 AND l.leader_ctr > r.ctr
122
+ THEN (l.leader_ctr - r.ctr) * r.impressions
123
+ ELSE 0.0
124
+ END) AS stolen_clicks,
125
+ to_json(list({
126
+ 'url': r.url,
127
+ 'clicks': r.clicks,
128
+ 'impressions': r.impressions,
129
+ 'ctr': r.ctr,
130
+ 'position': r.position,
131
+ 'share': r.share,
132
+ 'rank': r.rnk
133
+ } ORDER BY r.rnk)) AS competitors
134
+ FROM ranked r
135
+ JOIN leader l USING (query)
136
+ GROUP BY r.query
137
+ )
138
+ SELECT
139
+ e.query AS keyword,
140
+ t.total_impressions AS totalImpressions,
141
+ t.total_clicks AS totalClicks,
142
+ t.competitor_count AS competitorCount,
143
+ e.leader_url AS leaderUrl,
144
+ e.leader_ctr AS leaderCtr,
145
+ e.leader_position AS leaderPosition,
146
+ e.hhi AS hhi,
147
+ GREATEST(0.0, 1.0 - e.hhi / 10000.0) AS fragmentation,
148
+ e.stolen_clicks AS stolenClicks,
149
+ e.competitors AS competitors,
150
+ CAST(ROUND(LEAST(100.0,
151
+ 100.0 * POWER(
152
+ GREATEST(1.0 - e.hhi / 10000.0, 0.0)
153
+ * LEAST(e.stolen_clicks / GREATEST(t.total_clicks + e.stolen_clicks, 1.0), 1.0)
154
+ * LEAST(LOG10(GREATEST(t.total_impressions, 10.0)) / 5.0, 1.0),
155
+ 1.0 / 3.0
156
+ )
157
+ )) AS DOUBLE) AS severity
158
+ FROM events e
159
+ JOIN query_totals t USING (query)
160
+ ORDER BY severity DESC, stolenClicks DESC
161
+ LIMIT ${Number(limit)}
162
+ `,
163
+ params: [
164
+ startDate,
165
+ endDate,
166
+ minImpressions,
167
+ minCompetitors,
168
+ minQueryImpressions
169
+ ],
170
+ current: {
171
+ table: "page_queries",
172
+ partitions: enumeratePartitions(startDate, endDate)
173
+ }
174
+ };
175
+ },
176
+ reduceSql(rows) {
177
+ const events = (Array.isArray(rows) ? rows : []).map((r) => ({
178
+ keyword: rowString(r.keyword),
179
+ totalImpressions: num(r.totalImpressions),
180
+ totalClicks: num(r.totalClicks),
181
+ competitorCount: num(r.competitorCount),
182
+ leaderUrl: rowString(r.leaderUrl),
183
+ leaderCtr: num(r.leaderCtr),
184
+ leaderPosition: num(r.leaderPosition),
185
+ hhi: num(r.hhi),
186
+ fragmentation: num(r.fragmentation),
187
+ stolenClicks: num(r.stolenClicks),
188
+ severity: num(r.severity),
189
+ competitors: parseJsonRows(r.competitors).map((c) => ({
190
+ url: rowString(c.url),
191
+ clicks: num(c.clicks),
192
+ impressions: num(c.impressions),
193
+ ctr: num(c.ctr),
194
+ position: num(c.position),
195
+ share: num(c.share),
196
+ rank: num(c.rank)
197
+ }))
198
+ }));
199
+ const nodeAgg = /* @__PURE__ */ new Map();
200
+ const edgeAgg = /* @__PURE__ */ new Map();
201
+ for (const ev of events) {
202
+ for (const c of ev.competitors) {
203
+ const n = nodeAgg.get(c.url) ?? {
204
+ impressions: 0,
205
+ clicks: 0,
206
+ queries: /* @__PURE__ */ new Set()
207
+ };
208
+ n.impressions += c.impressions;
209
+ n.clicks += c.clicks;
210
+ n.queries.add(ev.keyword);
211
+ nodeAgg.set(c.url, n);
212
+ }
213
+ for (let i = 0; i < ev.competitors.length; i++) for (let j = i + 1; j < ev.competitors.length; j++) {
214
+ const a = ev.competitors[i];
215
+ const b = ev.competitors[j];
216
+ const [src, tgt] = a.url < b.url ? [a.url, b.url] : [b.url, a.url];
217
+ const key = `${src}${tgt}`;
218
+ const weight = Math.min(a.impressions, b.impressions);
219
+ const edge = edgeAgg.get(key) ?? {
220
+ source: src,
221
+ target: tgt,
222
+ weight: 0,
223
+ queries: 0
224
+ };
225
+ edge.weight += weight;
226
+ edge.queries += 1;
227
+ edgeAgg.set(key, edge);
228
+ }
229
+ }
230
+ const nodes = [...nodeAgg.entries()].map(([url, n]) => ({
231
+ url,
232
+ impressions: n.impressions,
233
+ clicks: n.clicks,
234
+ queryCount: n.queries.size
235
+ }));
236
+ 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);
239
+ return {
240
+ results: events,
241
+ meta: {
242
+ total: events.length,
243
+ totalStolenClicks,
244
+ avgFragmentation,
245
+ graph: {
246
+ nodes,
247
+ edges
248
+ }
249
+ }
250
+ };
251
+ },
252
+ buildRows(params) {
253
+ return { rows: queriesQueryState(periodOf(params), params.limit) };
254
+ },
255
+ reduceRows(rows, params) {
256
+ const results = analyzeCannibalization(Array.isArray(rows) ? rows : [], {
257
+ minImpressions: params.minImpressions,
258
+ maxPositionSpread: params.maxPositionSpread,
259
+ minPages: params.minPages
260
+ });
261
+ return {
262
+ results,
263
+ meta: { total: results.length }
264
+ };
265
+ }
266
+ });
267
+ export { analyzeCannibalization, cannibalizationAnalyzer };
@@ -0,0 +1,197 @@
1
+ import { parseJsonRows, rowNumber, rowString } from "../analyzer/row-values.mjs";
2
+ import { defineAnalyzer } from "@gscdump/engine/analyzer";
3
+ import { defaultEndDate } from "@gscdump/engine/period";
4
+ import { enumeratePartitions } from "@gscdump/engine/planner";
5
+ import { METRIC_EXPR } from "@gscdump/engine/sql-fragments";
6
+ import { daysAgoUtc } from "gscdump/dates";
7
+ const changePointAnalyzer = defineAnalyzer({
8
+ id: "change-point",
9
+ buildSql(params) {
10
+ const endDate = params.endDate ?? defaultEndDate();
11
+ const startDate = params.startDate ?? daysAgoUtc(93);
12
+ const minDays = 21;
13
+ const minSide = 7;
14
+ const threshold = params.threshold ?? 10;
15
+ const minImpressions = params.minImpressions ?? 50;
16
+ const metric = params.metric === "clicks" || params.metric === "impressions" ? params.metric : "position";
17
+ const limit = params.limit ?? 100;
18
+ const valueExpr = metric === "position" ? METRIC_EXPR.position : `CAST(SUM(${metric}) AS DOUBLE)`;
19
+ const weightExpr = metric === "position" ? METRIC_EXPR.impressions : "1.0";
20
+ return {
21
+ sql: `
22
+ WITH daily AS (
23
+ SELECT
24
+ query,
25
+ url AS page,
26
+ -- Normalize at the source CTE: union_by_name=true can coerce date to
27
+ -- VARCHAR across parquets with mixed schemas, which makes downstream
28
+ -- strftime(date, ...) binder-error.
29
+ CAST(date AS DATE) AS date,
30
+ ${METRIC_EXPR.clicks} AS clicks,
31
+ ${METRIC_EXPR.impressions} AS impressions,
32
+ ${valueExpr} AS value,
33
+ ${weightExpr} AS weight
34
+ FROM read_parquet({{FILES}}, union_by_name = true)
35
+ WHERE date >= ? AND date <= ?
36
+ AND query IS NOT NULL AND query <> ''
37
+ AND url IS NOT NULL AND url <> ''
38
+ GROUP BY query, url, date
39
+ HAVING SUM(impressions) >= 1
40
+ ),
41
+ entity_stats AS (
42
+ SELECT query, page,
43
+ COUNT(*) AS n_total,
44
+ SUM(impressions) AS total_impressions,
45
+ SUM(weight) AS w_total,
46
+ SUM(value * weight) AS sum_total,
47
+ SUM(value * value * weight) AS sumsq_total
48
+ FROM daily
49
+ GROUP BY query, page
50
+ HAVING COUNT(*) >= ${Number(minDays)}
51
+ AND SUM(impressions) >= ?
52
+ ),
53
+ filtered AS (
54
+ SELECT d.*,
55
+ e.n_total, e.w_total, e.sum_total, e.sumsq_total, e.total_impressions
56
+ FROM daily d
57
+ JOIN entity_stats e USING (query, page)
58
+ ),
59
+ cumulated AS (
60
+ SELECT *,
61
+ COUNT(*) OVER w AS n_left,
62
+ SUM(weight) OVER w AS w_left,
63
+ SUM(value * weight) OVER w AS sum_left,
64
+ SUM(value * value * weight) OVER w AS sumsq_left
65
+ FROM filtered
66
+ WINDOW w AS (
67
+ PARTITION BY query, page
68
+ ORDER BY date
69
+ ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
70
+ )
71
+ ),
72
+ llr_scored AS (
73
+ SELECT *,
74
+ (n_total - n_left) AS n_right,
75
+ (w_total - w_left) AS w_right,
76
+ (sum_total - sum_left) AS sum_right,
77
+ (sumsq_total - sumsq_left) AS sumsq_right,
78
+ GREATEST(
79
+ (sumsq_left / NULLIF(w_left, 0))
80
+ - (sum_left / NULLIF(w_left, 0)) * (sum_left / NULLIF(w_left, 0)),
81
+ 1e-9
82
+ ) AS var_left,
83
+ GREATEST(
84
+ ((sumsq_total - sumsq_left) / NULLIF(w_total - w_left, 0))
85
+ - ((sum_total - sum_left) / NULLIF(w_total - w_left, 0))
86
+ * ((sum_total - sum_left) / NULLIF(w_total - w_left, 0)),
87
+ 1e-9
88
+ ) AS var_right,
89
+ GREATEST(
90
+ (sumsq_total / NULLIF(w_total, 0))
91
+ - (sum_total / NULLIF(w_total, 0)) * (sum_total / NULLIF(w_total, 0)),
92
+ 1e-9
93
+ ) AS var_single
94
+ FROM cumulated
95
+ ),
96
+ llr AS (
97
+ SELECT *,
98
+ CASE
99
+ WHEN n_left >= ${Number(minSide)} AND (n_total - n_left) >= ${Number(minSide)}
100
+ THEN n_total * LN(var_single)
101
+ - n_left * LN(var_left)
102
+ - (n_total - n_left) * LN(var_right)
103
+ ELSE NULL
104
+ END AS llr
105
+ FROM llr_scored
106
+ ),
107
+ best AS (
108
+ SELECT query, page, n_total, total_impressions,
109
+ arg_max(date, llr) AS change_date,
110
+ MAX(llr) AS best_llr,
111
+ arg_max(sum_left / NULLIF(w_left, 0), llr) AS left_mean,
112
+ arg_max((sum_total - sum_left) / NULLIF(w_total - w_left, 0), llr) AS right_mean,
113
+ arg_max(sqrt(var_left), llr) AS left_std,
114
+ arg_max(sqrt(var_right), llr) AS right_std
115
+ FROM llr
116
+ WHERE llr IS NOT NULL
117
+ GROUP BY query, page, n_total, total_impressions
118
+ HAVING MAX(llr) > ${Number(threshold)}
119
+ ),
120
+ series AS (
121
+ SELECT query, page,
122
+ to_json(list({
123
+ 'date': strftime(date, '%Y-%m-%d'),
124
+ 'value': value
125
+ } ORDER BY date)) AS seriesJson
126
+ FROM daily
127
+ GROUP BY query, page
128
+ )
129
+ SELECT
130
+ b.query AS keyword,
131
+ b.page,
132
+ CAST(b.n_total AS DOUBLE) AS totalDays,
133
+ CAST(b.total_impressions AS DOUBLE) AS totalImpressions,
134
+ strftime(b.change_date, '%Y-%m-%d') AS changeDate,
135
+ b.best_llr AS llr,
136
+ b.left_mean AS leftMean,
137
+ b.right_mean AS rightMean,
138
+ (b.right_mean - b.left_mean) AS delta,
139
+ b.left_std AS leftStddev,
140
+ b.right_std AS rightStddev,
141
+ s.seriesJson
142
+ FROM best b
143
+ LEFT JOIN series s USING (query, page)
144
+ ORDER BY b.best_llr DESC
145
+ LIMIT ${Number(limit)}
146
+ `,
147
+ params: [
148
+ startDate,
149
+ endDate,
150
+ minImpressions
151
+ ],
152
+ current: {
153
+ table: "page_queries",
154
+ partitions: enumeratePartitions(startDate, endDate)
155
+ }
156
+ };
157
+ },
158
+ reduceSql(rows, params) {
159
+ const arr = Array.isArray(rows) ? rows : [];
160
+ const threshold = params.threshold ?? 10;
161
+ const metric = params.metric === "clicks" || params.metric === "impressions" ? params.metric : "position";
162
+ const lowerIsBetter = metric === "position";
163
+ const results = arr.map((r) => {
164
+ const delta = rowNumber(r.delta);
165
+ const improved = lowerIsBetter ? delta < 0 : delta > 0;
166
+ return {
167
+ keyword: rowString(r.keyword),
168
+ page: rowString(r.page),
169
+ totalDays: rowNumber(r.totalDays),
170
+ totalImpressions: rowNumber(r.totalImpressions),
171
+ changeDate: rowString(r.changeDate),
172
+ llr: rowNumber(r.llr),
173
+ leftMean: rowNumber(r.leftMean),
174
+ rightMean: rowNumber(r.rightMean),
175
+ delta,
176
+ leftStddev: rowNumber(r.leftStddev),
177
+ rightStddev: rowNumber(r.rightStddev),
178
+ direction: improved ? "improved" : "worsened",
179
+ series: parseJsonRows(r.seriesJson).map((s) => ({
180
+ date: rowString(s.date),
181
+ value: rowNumber(s.value)
182
+ }))
183
+ };
184
+ });
185
+ return {
186
+ results,
187
+ meta: {
188
+ total: results.length,
189
+ metric,
190
+ threshold,
191
+ improved: results.filter((r) => r.direction === "improved").length,
192
+ worsened: results.filter((r) => r.direction === "worsened").length
193
+ }
194
+ };
195
+ }
196
+ });
197
+ export { changePointAnalyzer };
@@ -0,0 +1,29 @@
1
+ import { QueriesRow } from "../types.mjs";
2
+ type ClusterType = 'prefix' | 'intent' | 'both';
3
+ interface ClusteringOptions {
4
+ /** Minimum keywords for a cluster to be reported. Default: 2 */
5
+ minClusterSize?: number;
6
+ /** Minimum impressions for a keyword to be included. Default: 10 */
7
+ minImpressions?: number;
8
+ /** Clustering method. Default: 'both' */
9
+ clusterBy?: ClusterType;
10
+ }
11
+ interface KeywordCluster {
12
+ clusterName: string;
13
+ clusterType: 'prefix' | 'intent';
14
+ keywords: QueriesRow[];
15
+ totalClicks: number;
16
+ totalImpressions: number;
17
+ avgPosition: number;
18
+ keywordCount: number;
19
+ }
20
+ interface ClusteringResult {
21
+ clusters: KeywordCluster[];
22
+ unclustered: QueriesRow[];
23
+ }
24
+ /**
25
+ * Pure helper: clusters keywords by intent prefix or common word prefix.
26
+ * Re-exported from `@gscdump/analysis` for portable callers.
27
+ */
28
+ declare function analyzeClustering(keywords: QueriesRow[], options?: ClusteringOptions): ClusteringResult;
29
+ export { ClusterType, ClusteringOptions, ClusteringResult, KeywordCluster, analyzeClustering };
@@ -0,0 +1,217 @@
1
+ import { parseJsonRows, rowString } from "../analyzer/row-values.mjs";
2
+ import { queriesQueryState } from "../analyzer/adapt-rows.mjs";
3
+ import { defineAnalyzer } from "@gscdump/engine/analyzer";
4
+ import { periodOf } from "@gscdump/engine/period";
5
+ import { enumeratePartitions } from "@gscdump/engine/planner";
6
+ import { METRIC_EXPR } from "@gscdump/engine/sql-fragments";
7
+ import { num } from "@gscdump/engine/analysis-types";
8
+ const INTENT_PREFIXES_REGEX = "^(how to|what is|what are|why is|why do|where to|when to|best|top|vs|versus|compare|review|buy|cheap|free|near me)(\\s|$)";
9
+ const INTENT_PREFIXES = [
10
+ "how to",
11
+ "what is",
12
+ "what are",
13
+ "why is",
14
+ "why do",
15
+ "where to",
16
+ "when to",
17
+ "best",
18
+ "top",
19
+ "vs",
20
+ "versus",
21
+ "compare",
22
+ "review",
23
+ "buy",
24
+ "cheap",
25
+ "free",
26
+ "near me"
27
+ ];
28
+ const WHITESPACE_RE = /\s+/;
29
+ function extractIntentPrefix(keyword) {
30
+ const lower = keyword.toLowerCase();
31
+ for (const prefix of INTENT_PREFIXES) if (lower.startsWith(`${prefix} `) || lower.startsWith(prefix)) return prefix;
32
+ return null;
33
+ }
34
+ function extractWordPrefix(keyword, wordCount = 2) {
35
+ const words = keyword.toLowerCase().split(WHITESPACE_RE).filter(Boolean);
36
+ if (words.length < wordCount + 1) return null;
37
+ return words.slice(0, wordCount).join(" ");
38
+ }
39
+ function analyzeClustering(keywords, options = {}) {
40
+ const { minClusterSize = 2, minImpressions = 10, clusterBy = "both" } = options;
41
+ const filtered = keywords.filter((k) => num(k.impressions) >= minImpressions);
42
+ const clusterMap = /* @__PURE__ */ new Map();
43
+ const clusteredKeywords = /* @__PURE__ */ new Set();
44
+ if (clusterBy === "intent" || clusterBy === "both") for (const kw of filtered) {
45
+ const intent = extractIntentPrefix(kw.query);
46
+ if (intent) {
47
+ const existing = clusterMap.get(intent);
48
+ if (existing) existing.keywords.push(kw);
49
+ else clusterMap.set(intent, {
50
+ type: "intent",
51
+ keywords: [kw]
52
+ });
53
+ clusteredKeywords.add(kw.query);
54
+ }
55
+ }
56
+ if (clusterBy === "prefix" || clusterBy === "both") {
57
+ const prefixMap = /* @__PURE__ */ new Map();
58
+ for (const kw of filtered) {
59
+ if (clusteredKeywords.has(kw.query)) continue;
60
+ const prefix = extractWordPrefix(kw.query);
61
+ if (prefix) {
62
+ const existing = prefixMap.get(prefix);
63
+ if (existing) existing.push(kw);
64
+ else prefixMap.set(prefix, [kw]);
65
+ }
66
+ }
67
+ for (const [prefix, kws] of prefixMap) if (kws.length >= minClusterSize) {
68
+ clusterMap.set(prefix, {
69
+ type: "prefix",
70
+ keywords: kws
71
+ });
72
+ for (const kw of kws) clusteredKeywords.add(kw.query);
73
+ }
74
+ }
75
+ const clusters = [];
76
+ for (const [name, data] of clusterMap) {
77
+ if (data.keywords.length < minClusterSize) continue;
78
+ let totalClicks = 0;
79
+ let totalImpressions = 0;
80
+ let weightedPositionSum = 0;
81
+ let positionWeight = 0;
82
+ let positionSum = 0;
83
+ for (const k of data.keywords) {
84
+ const impressions = num(k.impressions);
85
+ const position = num(k.position);
86
+ totalClicks += num(k.clicks);
87
+ totalImpressions += impressions;
88
+ positionSum += position;
89
+ if (impressions > 0) {
90
+ weightedPositionSum += (position - 1) * impressions;
91
+ positionWeight += impressions;
92
+ }
93
+ }
94
+ const avgPosition = positionWeight > 0 ? weightedPositionSum / positionWeight + 1 : positionSum / data.keywords.length;
95
+ clusters.push({
96
+ clusterName: name,
97
+ clusterType: data.type,
98
+ keywords: data.keywords,
99
+ totalClicks,
100
+ totalImpressions,
101
+ avgPosition,
102
+ keywordCount: data.keywords.length
103
+ });
104
+ }
105
+ clusters.sort((a, b) => b.totalClicks - a.totalClicks);
106
+ return {
107
+ clusters,
108
+ unclustered: filtered.filter((kw) => !clusteredKeywords.has(kw.query))
109
+ };
110
+ }
111
+ const clusteringAnalyzer = defineAnalyzer({
112
+ id: "clustering",
113
+ buildSql(params) {
114
+ const { startDate, endDate } = periodOf(params);
115
+ const minImpressions = params.minImpressions ?? 10;
116
+ const minClusterSize = params.minClusterSize ?? 2;
117
+ const clusterBy = params.clusterBy ?? "both";
118
+ const doIntent = clusterBy === "intent" || clusterBy === "both";
119
+ const doPrefix = clusterBy === "prefix" || clusterBy === "both";
120
+ const intentExpr = doIntent ? `NULLIF(regexp_extract(LOWER(query), '${INTENT_PREFIXES_REGEX}', 1), '')` : `CAST(NULL AS VARCHAR)`;
121
+ const prefixExpr = doPrefix ? `CASE WHEN len(regexp_split_to_array(LOWER(query), '\\s+')) >= 3
122
+ THEN array_to_string(list_slice(regexp_split_to_array(LOWER(query), '\\s+'), 1, 2), ' ')
123
+ ELSE CAST(NULL AS VARCHAR) END` : `CAST(NULL AS VARCHAR)`;
124
+ return {
125
+ sql: `
126
+ WITH agg AS (
127
+ SELECT
128
+ query,
129
+ ${METRIC_EXPR.clicks} AS clicks,
130
+ ${METRIC_EXPR.impressions} AS impressions,
131
+ ${METRIC_EXPR.ctr} AS ctr,
132
+ ${METRIC_EXPR.position} AS position
133
+ FROM read_parquet({{FILES}}, union_by_name = true)
134
+ WHERE date >= ? AND date <= ?
135
+ GROUP BY query
136
+ HAVING SUM(impressions) >= ?
137
+ ),
138
+ classified AS (
139
+ SELECT
140
+ query, clicks, impressions, ctr, position,
141
+ ${intentExpr} AS intent_prefix,
142
+ ${prefixExpr} AS word_prefix
143
+ FROM agg
144
+ ),
145
+ keyed AS (
146
+ SELECT
147
+ query, clicks, impressions, ctr, position,
148
+ COALESCE(intent_prefix, word_prefix) AS cluster_name,
149
+ CASE WHEN intent_prefix IS NOT NULL THEN 'intent' ELSE 'prefix' END AS cluster_type
150
+ FROM classified
151
+ WHERE COALESCE(intent_prefix, word_prefix) IS NOT NULL
152
+ )
153
+ SELECT
154
+ cluster_name AS clusterName,
155
+ any_value(cluster_type) AS clusterType,
156
+ CAST(COUNT(*) AS DOUBLE) AS keywordCount,
157
+ ${METRIC_EXPR.clicks} AS totalClicks,
158
+ ${METRIC_EXPR.impressions} AS totalImpressions,
159
+ SUM((position - 1) * impressions) / NULLIF(SUM(impressions), 0) + 1 AS avgPosition,
160
+ to_json(list({ 'query': query, 'clicks': clicks, 'impressions': impressions, 'ctr': ctr, 'position': position })) AS keywords
161
+ FROM keyed
162
+ GROUP BY cluster_name
163
+ HAVING COUNT(*) >= ?
164
+ ORDER BY totalClicks DESC
165
+ `,
166
+ params: [
167
+ startDate,
168
+ endDate,
169
+ minImpressions,
170
+ minClusterSize
171
+ ],
172
+ current: {
173
+ table: "queries",
174
+ partitions: enumeratePartitions(startDate, endDate)
175
+ }
176
+ };
177
+ },
178
+ reduceSql(rows) {
179
+ const clusters = (Array.isArray(rows) ? rows : []).map((r) => ({
180
+ clusterName: rowString(r.clusterName),
181
+ clusterType: rowString(r.clusterType),
182
+ keywordCount: num(r.keywordCount),
183
+ totalClicks: num(r.totalClicks),
184
+ totalImpressions: num(r.totalImpressions),
185
+ avgPosition: num(r.avgPosition),
186
+ keywords: parseJsonRows(r.keywords).map((k) => ({
187
+ query: rowString(k.query),
188
+ clicks: num(k.clicks),
189
+ impressions: num(k.impressions),
190
+ ctr: num(k.ctr),
191
+ position: num(k.position)
192
+ }))
193
+ }));
194
+ return {
195
+ results: clusters,
196
+ meta: {
197
+ total: clusters.length,
198
+ totalClusters: clusters.length
199
+ }
200
+ };
201
+ },
202
+ buildRows(params) {
203
+ return { queries: queriesQueryState(periodOf(params), params.limit) };
204
+ },
205
+ reduceRows(rows, params) {
206
+ const result = analyzeClustering(Array.isArray(rows) ? rows : [], {
207
+ clusterBy: params.clusterBy,
208
+ minClusterSize: params.minClusterSize,
209
+ minImpressions: params.minImpressions
210
+ });
211
+ return {
212
+ results: result.clusters,
213
+ meta: { totalClusters: result.clusters.length }
214
+ };
215
+ }
216
+ });
217
+ export { analyzeClustering, clusteringAnalyzer };