@gscdump/analysis 1.4.0 → 1.4.2
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/action-priority.d.mts +34 -0
- package/dist/action-priority.mjs +201 -0
- package/dist/analyzer/adapt-rows.mjs +12 -0
- package/dist/analyzer/all.mjs +61 -0
- package/dist/analyzer/paginate.mjs +85 -0
- package/dist/analyzer/row-analyzers.d.mts +3 -0
- package/dist/analyzer/row-analyzers.mjs +3 -0
- package/dist/analyzer/row-values.mjs +22 -0
- package/dist/analyzers/bayesian-ctr.mjs +177 -0
- package/dist/analyzers/bipartite-pagerank.mjs +243 -0
- package/dist/analyzers/brand.d.mts +33 -0
- package/dist/analyzers/brand.mjs +154 -0
- package/dist/analyzers/cannibalization.d.mts +58 -0
- package/dist/analyzers/cannibalization.mjs +267 -0
- package/dist/analyzers/change-point.mjs +197 -0
- package/dist/analyzers/clustering.d.mts +29 -0
- package/dist/analyzers/clustering.mjs +217 -0
- package/dist/analyzers/concentration.d.mts +42 -0
- package/dist/analyzers/concentration.mjs +180 -0
- package/dist/analyzers/content-velocity.mjs +87 -0
- package/dist/analyzers/ctr-anomaly.mjs +189 -0
- package/dist/analyzers/ctr-curve.mjs +128 -0
- package/dist/analyzers/dark-traffic.mjs +130 -0
- package/dist/analyzers/data-detail.mjs +44 -0
- package/dist/analyzers/data-query.mjs +44 -0
- package/dist/analyzers/decay.d.mts +38 -0
- package/dist/analyzers/decay.mjs +187 -0
- package/dist/analyzers/device-gap.mjs +135 -0
- package/dist/analyzers/intent-atlas.mjs +175 -0
- package/dist/analyzers/keyword-breadth.mjs +115 -0
- package/dist/analyzers/long-tail.mjs +150 -0
- package/dist/analyzers/movers.d.mts +43 -0
- package/dist/analyzers/movers.mjs +283 -0
- package/dist/analyzers/opportunity.d.mts +32 -0
- package/dist/analyzers/opportunity.mjs +212 -0
- package/dist/analyzers/position-distribution.mjs +57 -0
- package/dist/analyzers/position-volatility.mjs +143 -0
- package/dist/analyzers/query-migration.mjs +178 -0
- package/dist/analyzers/seasonality.d.mts +28 -0
- package/dist/analyzers/seasonality.mjs +136 -0
- package/dist/analyzers/stl-decompose.mjs +187 -0
- package/dist/analyzers/striking-distance.d.mts +54 -0
- package/dist/analyzers/striking-distance.mjs +82 -0
- package/dist/analyzers/survival.mjs +200 -0
- package/dist/analyzers/trends.mjs +156 -0
- package/dist/analyzers/zero-click.d.mts +17 -0
- package/dist/analyzers/zero-click.mjs +122 -0
- package/dist/browser.d.mts +5 -0
- package/dist/browser.mjs +11 -0
- package/dist/default-registry.d.mts +3 -1
- package/dist/default-registry.mjs +5 -1
- package/dist/errors.d.mts +51 -1
- package/dist/index.d.mts +30 -494
- package/dist/index.mjs +28 -49
- package/dist/query/analyzers.mjs +301 -0
- package/dist/query/index.mjs +4 -0
- package/dist/query/intent.d.mts +20 -0
- package/dist/query/intent.mjs +139 -0
- package/dist/query/normalize.d.mts +15 -0
- package/dist/query/normalize.mjs +204 -0
- package/dist/report/format.d.mts +7 -0
- package/dist/report/format.mjs +45 -0
- package/dist/report/index.d.mts +4 -1
- package/dist/report/index.mjs +4 -1
- package/dist/report/registry.d.mts +4 -0
- package/dist/report/registry.mjs +26 -0
- package/dist/report/reports/brand.mjs +104 -0
- package/dist/report/reports/growth.mjs +144 -0
- package/dist/report/reports/health.mjs +141 -0
- package/dist/report/reports/movers.mjs +203 -0
- package/dist/report/reports/opportunities.mjs +184 -0
- package/dist/report/reports/pre-publish.mjs +111 -0
- package/dist/report/reports/priority.mjs +97 -0
- package/dist/report/reports/risks.mjs +201 -0
- package/dist/report/reports/triage.mjs +155 -0
- package/dist/report/require.mjs +15 -0
- package/dist/report/resolve-target.d.mts +29 -0
- package/dist/report/resolve-target.mjs +32 -0
- package/dist/{_chunks/index.d.mts → report/runtime.d.mts} +2 -37
- package/dist/report/runtime.mjs +80 -0
- package/dist/report/sections.mjs +22 -0
- package/dist/sitemap-health.d.mts +34 -0
- package/dist/sitemap-health.mjs +37 -0
- package/dist/{_chunks/index2.d.mts → source/composite.d.mts} +2 -13
- package/dist/{_chunks/source.mjs → source/composite.mjs} +1 -17
- package/dist/source/in-memory.d.mts +14 -0
- package/dist/source/in-memory.mjs +17 -0
- package/dist/source/index.d.mts +2 -1
- package/dist/source/index.mjs +2 -1
- package/dist/sql-analyzers.d.mts +3 -0
- package/dist/sql-analyzers.mjs +3 -0
- package/dist/types.d.mts +34 -0
- package/dist/types.mjs +21 -0
- package/package.json +4 -4
- package/dist/_chunks/default-registry.d.mts +0 -5
- package/dist/_chunks/default-registry.mjs +0 -5183
- package/dist/_chunks/errors.d.mts +0 -52
- package/dist/_chunks/report.mjs +0 -1710
- /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 };
|