@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,283 @@
|
|
|
1
|
+
import { percentDifference } from "../scoring.mjs";
|
|
2
|
+
import { parseJsonRows, rowString } from "../analyzer/row-values.mjs";
|
|
3
|
+
import { queriesQueryState } from "../analyzer/adapt-rows.mjs";
|
|
4
|
+
import { buildPeriodMap } from "../types.mjs";
|
|
5
|
+
import { defineAnalyzer } from "@gscdump/engine/analyzer";
|
|
6
|
+
import { comparisonOf } from "@gscdump/engine/period";
|
|
7
|
+
import { enumeratePartitions } from "@gscdump/engine/planner";
|
|
8
|
+
import { METRIC_EXPR } from "@gscdump/engine/sql-fragments";
|
|
9
|
+
import { num } from "@gscdump/engine/analysis-types";
|
|
10
|
+
function withCanonicalFields(r) {
|
|
11
|
+
return {
|
|
12
|
+
...r,
|
|
13
|
+
clicks: r.recentClicks,
|
|
14
|
+
impressions: r.recentImpressions,
|
|
15
|
+
ctr: r.recentImpressions > 0 ? r.recentClicks / r.recentImpressions : 0,
|
|
16
|
+
position: r.recentPosition,
|
|
17
|
+
prevClicks: r.baselineClicks,
|
|
18
|
+
prevImpressions: r.baselineImpressions,
|
|
19
|
+
prevPosition: r.baselinePosition
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
function selectMoversDirection(rising, declining, stable, params) {
|
|
23
|
+
if (params.direction) {
|
|
24
|
+
const selected = params.direction === "rising" ? rising : declining;
|
|
25
|
+
const total = selected.length;
|
|
26
|
+
const offset = params.offset ?? 0;
|
|
27
|
+
const limit = params.limit ?? total;
|
|
28
|
+
return {
|
|
29
|
+
results: selected.slice(offset, offset + limit),
|
|
30
|
+
meta: {
|
|
31
|
+
total,
|
|
32
|
+
rising: rising.length,
|
|
33
|
+
declining: declining.length,
|
|
34
|
+
stable: stable.length
|
|
35
|
+
}
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
const combined = [...rising, ...declining];
|
|
39
|
+
return {
|
|
40
|
+
results: combined,
|
|
41
|
+
meta: {
|
|
42
|
+
total: combined.length,
|
|
43
|
+
rising: rising.length,
|
|
44
|
+
declining: declining.length,
|
|
45
|
+
stable: stable.length
|
|
46
|
+
}
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
function analyzeMovers(input, options = {}) {
|
|
50
|
+
const { changeThreshold = .2, minImpressions = 50, sortBy = "clicksChange" } = options;
|
|
51
|
+
const normFactor = input.normalizationFactor ?? 1;
|
|
52
|
+
const baselineMap = buildPeriodMap(input.previous, (r) => r.query, (r) => ({
|
|
53
|
+
clicks: num(r.clicks) / normFactor,
|
|
54
|
+
impressions: num(r.impressions) / normFactor,
|
|
55
|
+
position: num(r.position),
|
|
56
|
+
page: r.page ?? null
|
|
57
|
+
}));
|
|
58
|
+
const pageMap = /* @__PURE__ */ new Map();
|
|
59
|
+
for (const row of input.current) if (!pageMap.has(row.query) && row.page) pageMap.set(row.query, row.page);
|
|
60
|
+
for (const row of input.previous) if (!pageMap.has(row.query) && row.page) pageMap.set(row.query, row.page);
|
|
61
|
+
const rising = [];
|
|
62
|
+
const declining = [];
|
|
63
|
+
const stable = [];
|
|
64
|
+
for (const row of input.current) {
|
|
65
|
+
const impressions = num(row.impressions);
|
|
66
|
+
const clicks = num(row.clicks);
|
|
67
|
+
const position = num(row.position);
|
|
68
|
+
if (impressions < minImpressions) continue;
|
|
69
|
+
const baseline = baselineMap.get(row.query);
|
|
70
|
+
const baselineClicksRaw = baseline?.clicks ?? 0;
|
|
71
|
+
const baselineImpressionsRaw = baseline?.impressions ?? 0;
|
|
72
|
+
const baselinePosition = baseline?.position ?? null;
|
|
73
|
+
const clicksChangePercent = percentDifference(clicks, baselineClicksRaw);
|
|
74
|
+
const impressionsChangePercent = percentDifference(impressions, baselineImpressionsRaw);
|
|
75
|
+
const data = {
|
|
76
|
+
keyword: row.query,
|
|
77
|
+
page: pageMap.get(row.query) ?? null,
|
|
78
|
+
recentClicks: clicks,
|
|
79
|
+
recentImpressions: impressions,
|
|
80
|
+
recentPosition: position,
|
|
81
|
+
baselineClicks: Math.round(baselineClicksRaw),
|
|
82
|
+
baselineImpressions: Math.round(baselineImpressionsRaw),
|
|
83
|
+
baselinePosition,
|
|
84
|
+
clicksChange: clicks - Math.round(baselineClicksRaw),
|
|
85
|
+
clicksChangePercent,
|
|
86
|
+
impressionsChangePercent,
|
|
87
|
+
positionChange: baselinePosition != null ? position - baselinePosition : null
|
|
88
|
+
};
|
|
89
|
+
const absChange = Math.abs(clicksChangePercent / 100);
|
|
90
|
+
if (clicksChangePercent > 0 && absChange >= changeThreshold) rising.push(data);
|
|
91
|
+
else if (clicksChangePercent < 0 && absChange >= changeThreshold) declining.push(data);
|
|
92
|
+
else stable.push(data);
|
|
93
|
+
}
|
|
94
|
+
const sortFn = (a, b) => {
|
|
95
|
+
switch (sortBy) {
|
|
96
|
+
case "clicks": return b.recentClicks - a.recentClicks;
|
|
97
|
+
case "impressions": return b.recentImpressions - a.recentImpressions;
|
|
98
|
+
case "clicksChange": return Math.abs(b.clicksChangePercent) - Math.abs(a.clicksChangePercent);
|
|
99
|
+
case "impressionsChange": return Math.abs(b.impressionsChangePercent) - Math.abs(a.impressionsChangePercent);
|
|
100
|
+
case "positionChange": return Math.abs(b.positionChange ?? 0) - Math.abs(a.positionChange ?? 0);
|
|
101
|
+
default: return Math.abs(b.clicksChangePercent) - Math.abs(a.clicksChangePercent);
|
|
102
|
+
}
|
|
103
|
+
};
|
|
104
|
+
rising.sort(sortFn);
|
|
105
|
+
declining.sort(sortFn);
|
|
106
|
+
stable.sort((a, b) => b.recentClicks - a.recentClicks);
|
|
107
|
+
return {
|
|
108
|
+
rising,
|
|
109
|
+
declining,
|
|
110
|
+
stable
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
const moversAnalyzer = defineAnalyzer({
|
|
114
|
+
id: "movers",
|
|
115
|
+
buildSql(params) {
|
|
116
|
+
const { current: cur, previous: prev } = comparisonOf(params);
|
|
117
|
+
const minImpressions = params.minImpressions ?? 50;
|
|
118
|
+
const changeThreshold = params.changeThreshold ?? .2;
|
|
119
|
+
const limit = params.direction ? 5e3 : params.limit ?? 2e3;
|
|
120
|
+
return {
|
|
121
|
+
sql: `
|
|
122
|
+
WITH cur AS (
|
|
123
|
+
SELECT
|
|
124
|
+
query, url,
|
|
125
|
+
${METRIC_EXPR.clicks} AS clicks,
|
|
126
|
+
${METRIC_EXPR.impressions} AS impressions,
|
|
127
|
+
${METRIC_EXPR.position} AS position
|
|
128
|
+
FROM read_parquet({{FILES}}, union_by_name = true)
|
|
129
|
+
WHERE date >= ? AND date <= ?
|
|
130
|
+
GROUP BY query, url
|
|
131
|
+
),
|
|
132
|
+
prev AS (
|
|
133
|
+
SELECT
|
|
134
|
+
query, url,
|
|
135
|
+
${METRIC_EXPR.clicks} AS clicks,
|
|
136
|
+
${METRIC_EXPR.impressions} AS impressions,
|
|
137
|
+
${METRIC_EXPR.position} AS position
|
|
138
|
+
FROM read_parquet({{FILES_PREV}}, union_by_name = true)
|
|
139
|
+
WHERE date >= ? AND date <= ?
|
|
140
|
+
GROUP BY query, url
|
|
141
|
+
),
|
|
142
|
+
weekly AS (
|
|
143
|
+
SELECT query, url, date_trunc('week', CAST(date AS DATE)) AS week,
|
|
144
|
+
${METRIC_EXPR.clicks} AS clicks,
|
|
145
|
+
${METRIC_EXPR.impressions} AS impressions
|
|
146
|
+
FROM (
|
|
147
|
+
SELECT query, url, date, clicks, impressions
|
|
148
|
+
FROM read_parquet({{FILES}}, union_by_name = true)
|
|
149
|
+
WHERE date >= ? AND date <= ?
|
|
150
|
+
UNION ALL
|
|
151
|
+
SELECT query, url, date, clicks, impressions
|
|
152
|
+
FROM read_parquet({{FILES_PREV}}, union_by_name = true)
|
|
153
|
+
WHERE date >= ? AND date <= ?
|
|
154
|
+
)
|
|
155
|
+
GROUP BY query, url, week
|
|
156
|
+
),
|
|
157
|
+
series_by_entity AS (
|
|
158
|
+
SELECT query, url, to_json(list({
|
|
159
|
+
'week': strftime(week, '%Y-%m-%d'),
|
|
160
|
+
'clicks': clicks,
|
|
161
|
+
'impressions': impressions
|
|
162
|
+
} ORDER BY week)) AS seriesJson
|
|
163
|
+
FROM weekly GROUP BY query, url
|
|
164
|
+
),
|
|
165
|
+
joined AS (
|
|
166
|
+
SELECT
|
|
167
|
+
c.query AS keyword,
|
|
168
|
+
c.url AS page,
|
|
169
|
+
c.clicks AS recentClicks,
|
|
170
|
+
c.impressions AS recentImpressions,
|
|
171
|
+
c.position AS recentPosition,
|
|
172
|
+
COALESCE(p.clicks, 0.0) AS baselineClicks,
|
|
173
|
+
COALESCE(p.impressions, 0.0) AS baselineImpressions,
|
|
174
|
+
p.position AS baselinePosition,
|
|
175
|
+
(c.clicks - COALESCE(p.clicks, 0.0)) AS clicksChange,
|
|
176
|
+
CASE
|
|
177
|
+
WHEN COALESCE(p.clicks, 0.0) = 0 THEN CASE WHEN c.clicks > 0 THEN 100.0 ELSE 0.0 END
|
|
178
|
+
ELSE (c.clicks - p.clicks) * 100.0 / p.clicks
|
|
179
|
+
END AS clicksChangePercent,
|
|
180
|
+
CASE
|
|
181
|
+
WHEN COALESCE(p.impressions, 0.0) = 0 THEN CASE WHEN c.impressions > 0 THEN 100.0 ELSE 0.0 END
|
|
182
|
+
ELSE (c.impressions - p.impressions) * 100.0 / p.impressions
|
|
183
|
+
END AS impressionsChangePercent,
|
|
184
|
+
CASE WHEN p.position IS NOT NULL THEN (c.position - p.position) ELSE NULL END AS positionChange,
|
|
185
|
+
s.seriesJson
|
|
186
|
+
FROM cur c
|
|
187
|
+
LEFT JOIN prev p ON c.query = p.query AND c.url = p.url
|
|
188
|
+
LEFT JOIN series_by_entity s ON c.query = s.query AND c.url = s.url
|
|
189
|
+
WHERE c.impressions >= ?
|
|
190
|
+
)
|
|
191
|
+
SELECT *,
|
|
192
|
+
CASE
|
|
193
|
+
WHEN clicksChangePercent > 0 AND ABS(clicksChangePercent) / 100.0 >= ? THEN 'rising'
|
|
194
|
+
WHEN clicksChangePercent < 0 AND ABS(clicksChangePercent) / 100.0 >= ? THEN 'declining'
|
|
195
|
+
ELSE 'stable'
|
|
196
|
+
END AS direction
|
|
197
|
+
FROM joined
|
|
198
|
+
ORDER BY ABS(clicksChangePercent) DESC
|
|
199
|
+
LIMIT ${Number(limit)}
|
|
200
|
+
`,
|
|
201
|
+
params: [
|
|
202
|
+
cur.startDate,
|
|
203
|
+
cur.endDate,
|
|
204
|
+
prev.startDate,
|
|
205
|
+
prev.endDate,
|
|
206
|
+
cur.startDate,
|
|
207
|
+
cur.endDate,
|
|
208
|
+
prev.startDate,
|
|
209
|
+
prev.endDate,
|
|
210
|
+
minImpressions,
|
|
211
|
+
changeThreshold,
|
|
212
|
+
changeThreshold
|
|
213
|
+
],
|
|
214
|
+
current: {
|
|
215
|
+
table: "page_queries",
|
|
216
|
+
partitions: enumeratePartitions(cur.startDate, cur.endDate)
|
|
217
|
+
},
|
|
218
|
+
previous: {
|
|
219
|
+
table: "page_queries",
|
|
220
|
+
partitions: enumeratePartitions(prev.startDate, prev.endDate)
|
|
221
|
+
}
|
|
222
|
+
};
|
|
223
|
+
},
|
|
224
|
+
reduceSql(rows, params) {
|
|
225
|
+
const normalized = (Array.isArray(rows) ? rows : []).map((r) => {
|
|
226
|
+
const recentClicks = num(r.recentClicks);
|
|
227
|
+
const recentImpressions = num(r.recentImpressions);
|
|
228
|
+
const recentPosition = num(r.recentPosition);
|
|
229
|
+
const baselineClicks = Math.round(num(r.baselineClicks));
|
|
230
|
+
const baselineImpressions = Math.round(num(r.baselineImpressions));
|
|
231
|
+
const baselinePosition = r.baselinePosition == null ? null : num(r.baselinePosition);
|
|
232
|
+
return {
|
|
233
|
+
keyword: rowString(r.keyword),
|
|
234
|
+
page: r.page == null ? null : rowString(r.page),
|
|
235
|
+
recentClicks,
|
|
236
|
+
recentImpressions,
|
|
237
|
+
recentPosition,
|
|
238
|
+
baselineClicks,
|
|
239
|
+
baselineImpressions,
|
|
240
|
+
baselinePosition,
|
|
241
|
+
clicksChange: num(r.clicksChange),
|
|
242
|
+
clicksChangePercent: num(r.clicksChangePercent),
|
|
243
|
+
impressionsChangePercent: num(r.impressionsChangePercent),
|
|
244
|
+
positionChange: r.positionChange == null ? null : num(r.positionChange),
|
|
245
|
+
direction: rowString(r.direction),
|
|
246
|
+
series: parseJsonRows(r.seriesJson).map((s) => ({
|
|
247
|
+
week: rowString(s.week),
|
|
248
|
+
clicks: num(s.clicks),
|
|
249
|
+
impressions: num(s.impressions)
|
|
250
|
+
}))
|
|
251
|
+
};
|
|
252
|
+
}).map(withCanonicalFields);
|
|
253
|
+
return selectMoversDirection(normalized.filter((r) => r.direction === "rising"), normalized.filter((r) => r.direction === "declining"), normalized.filter((r) => r.direction === "stable"), params);
|
|
254
|
+
},
|
|
255
|
+
buildRows(params) {
|
|
256
|
+
const { current, previous } = comparisonOf(params);
|
|
257
|
+
return {
|
|
258
|
+
current: queriesQueryState(current, params.limit),
|
|
259
|
+
previous: queriesQueryState(previous, params.limit)
|
|
260
|
+
};
|
|
261
|
+
},
|
|
262
|
+
reduceRows(rows, params) {
|
|
263
|
+
const map = rows && !Array.isArray(rows) ? rows : {
|
|
264
|
+
current: [],
|
|
265
|
+
previous: []
|
|
266
|
+
};
|
|
267
|
+
const result = analyzeMovers({
|
|
268
|
+
current: map.current ?? [],
|
|
269
|
+
previous: map.previous ?? []
|
|
270
|
+
}, {
|
|
271
|
+
changeThreshold: params.changeThreshold,
|
|
272
|
+
minImpressions: params.minImpressions
|
|
273
|
+
});
|
|
274
|
+
return selectMoversDirection(result.rising.map((r) => withCanonicalFields({
|
|
275
|
+
...r,
|
|
276
|
+
direction: "rising"
|
|
277
|
+
})), result.declining.map((r) => withCanonicalFields({
|
|
278
|
+
...r,
|
|
279
|
+
direction: "declining"
|
|
280
|
+
})), [], params);
|
|
281
|
+
}
|
|
282
|
+
});
|
|
283
|
+
export { analyzeMovers, moversAnalyzer };
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { QueriesRow } from "../types.mjs";
|
|
2
|
+
type OpportunitySortMetric = 'opportunityScore' | 'potentialClicks' | 'impressions' | 'position';
|
|
3
|
+
interface OpportunityFactors {
|
|
4
|
+
positionScore: number;
|
|
5
|
+
impressionScore: number;
|
|
6
|
+
ctrGapScore: number;
|
|
7
|
+
}
|
|
8
|
+
interface OpportunityResult {
|
|
9
|
+
keyword: string;
|
|
10
|
+
page: string | null;
|
|
11
|
+
clicks: number;
|
|
12
|
+
impressions: number;
|
|
13
|
+
ctr: number;
|
|
14
|
+
position: number;
|
|
15
|
+
opportunityScore: number;
|
|
16
|
+
potentialClicks: number;
|
|
17
|
+
factors: OpportunityFactors;
|
|
18
|
+
}
|
|
19
|
+
interface OpportunityWeights {
|
|
20
|
+
position?: number;
|
|
21
|
+
impressions?: number;
|
|
22
|
+
ctrGap?: number;
|
|
23
|
+
}
|
|
24
|
+
interface OpportunityOptions {
|
|
25
|
+
minImpressions?: number;
|
|
26
|
+
weights?: OpportunityWeights;
|
|
27
|
+
sortBy?: OpportunitySortMetric;
|
|
28
|
+
limit?: number;
|
|
29
|
+
}
|
|
30
|
+
/** Pure opportunity scorer shared by hosted and package analyzer callers. */
|
|
31
|
+
declare function analyzeOpportunity(keywords: QueriesRow[], options?: OpportunityOptions): OpportunityResult[];
|
|
32
|
+
export { OpportunityFactors, OpportunityOptions, OpportunityResult, OpportunitySortMetric, OpportunityWeights, analyzeOpportunity };
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
import { queriesQueryState } from "../analyzer/adapt-rows.mjs";
|
|
2
|
+
import { paginateClause, paginateSortedInMemory } from "../analyzer/paginate.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 EXPECTED_CTR_BY_POSITION = {
|
|
9
|
+
1: .3,
|
|
10
|
+
2: .15,
|
|
11
|
+
3: .1,
|
|
12
|
+
4: .07,
|
|
13
|
+
5: .05,
|
|
14
|
+
6: .04,
|
|
15
|
+
7: .03,
|
|
16
|
+
8: .025,
|
|
17
|
+
9: .02,
|
|
18
|
+
10: .015
|
|
19
|
+
};
|
|
20
|
+
function getExpectedCtr(position) {
|
|
21
|
+
return EXPECTED_CTR_BY_POSITION[Math.round(Math.max(1, Math.min(position, 10)))] || .01;
|
|
22
|
+
}
|
|
23
|
+
function calculatePositionScore(position) {
|
|
24
|
+
if (position <= 3) return .2;
|
|
25
|
+
if (position > 50) return .1;
|
|
26
|
+
const distance = Math.abs(position - 11);
|
|
27
|
+
return Math.max(0, 1 - distance / 15);
|
|
28
|
+
}
|
|
29
|
+
function calculateImpressionScore(impressions) {
|
|
30
|
+
if (impressions <= 0) return 0;
|
|
31
|
+
return Math.min(Math.log10(impressions) / 5, 1);
|
|
32
|
+
}
|
|
33
|
+
function calculateCtrGapScore(actualCtr, position) {
|
|
34
|
+
const expectedCtr = getExpectedCtr(position);
|
|
35
|
+
if (actualCtr >= expectedCtr) return 0;
|
|
36
|
+
const gap = expectedCtr - actualCtr;
|
|
37
|
+
return Math.min(gap / expectedCtr, 1);
|
|
38
|
+
}
|
|
39
|
+
const SORT_DIR = {
|
|
40
|
+
opportunityScore: "desc",
|
|
41
|
+
potentialClicks: "desc",
|
|
42
|
+
impressions: "desc",
|
|
43
|
+
position: "asc"
|
|
44
|
+
};
|
|
45
|
+
function analyzeOpportunity(keywords, options = {}) {
|
|
46
|
+
const minImpressions = options.minImpressions ?? 100;
|
|
47
|
+
const positionWeight = options.weights?.position ?? 1;
|
|
48
|
+
const impressionsWeight = options.weights?.impressions ?? 1;
|
|
49
|
+
const ctrGapWeight = options.weights?.ctrGap ?? 1;
|
|
50
|
+
const totalWeight = positionWeight + impressionsWeight + ctrGapWeight;
|
|
51
|
+
const sortBy = options.sortBy ?? "opportunityScore";
|
|
52
|
+
const results = [];
|
|
53
|
+
for (const row of keywords) {
|
|
54
|
+
const impressions = num(row.impressions);
|
|
55
|
+
if (impressions < minImpressions) continue;
|
|
56
|
+
const position = num(row.position);
|
|
57
|
+
const ctr = num(row.ctr);
|
|
58
|
+
const clicks = num(row.clicks);
|
|
59
|
+
const positionScore = calculatePositionScore(position);
|
|
60
|
+
const impressionScore = calculateImpressionScore(impressions);
|
|
61
|
+
const ctrGapScore = calculateCtrGapScore(ctr, position);
|
|
62
|
+
const weightedProduct = positionScore ** positionWeight * impressionScore ** impressionsWeight * ctrGapScore ** ctrGapWeight;
|
|
63
|
+
const opportunityScore = Math.round(weightedProduct ** (1 / totalWeight) * 100);
|
|
64
|
+
const potentialClicks = Math.round(impressions * getExpectedCtr(Math.min(3, position)));
|
|
65
|
+
results.push({
|
|
66
|
+
keyword: row.query,
|
|
67
|
+
page: row.page ?? null,
|
|
68
|
+
clicks,
|
|
69
|
+
impressions,
|
|
70
|
+
ctr,
|
|
71
|
+
position,
|
|
72
|
+
opportunityScore,
|
|
73
|
+
potentialClicks,
|
|
74
|
+
factors: {
|
|
75
|
+
positionScore,
|
|
76
|
+
impressionScore,
|
|
77
|
+
ctrGapScore
|
|
78
|
+
}
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
const direction = SORT_DIR[sortBy];
|
|
82
|
+
results.sort((left, right) => direction === "asc" ? left[sortBy] - right[sortBy] : right[sortBy] - left[sortBy]);
|
|
83
|
+
return options.limit ? results.slice(0, options.limit) : results;
|
|
84
|
+
}
|
|
85
|
+
const opportunityAnalyzer = defineAnalyzer({
|
|
86
|
+
id: "opportunity",
|
|
87
|
+
buildSql(params) {
|
|
88
|
+
const { startDate, endDate } = periodOf(params);
|
|
89
|
+
const minImpressions = params.minImpressions ?? 100;
|
|
90
|
+
const w1 = 1;
|
|
91
|
+
const w2 = 1;
|
|
92
|
+
const w3 = 1;
|
|
93
|
+
const totalW = 3;
|
|
94
|
+
const limit = params.limit ?? 1e3;
|
|
95
|
+
return {
|
|
96
|
+
sql: `
|
|
97
|
+
WITH agg AS (
|
|
98
|
+
SELECT
|
|
99
|
+
query AS keyword,
|
|
100
|
+
url AS page,
|
|
101
|
+
${METRIC_EXPR.clicks} AS clicks,
|
|
102
|
+
${METRIC_EXPR.impressions} AS impressions,
|
|
103
|
+
${METRIC_EXPR.ctr} AS ctr,
|
|
104
|
+
${METRIC_EXPR.position} AS position
|
|
105
|
+
FROM read_parquet({{FILES}}, union_by_name = true)
|
|
106
|
+
WHERE date >= ? AND date <= ?
|
|
107
|
+
GROUP BY query, url
|
|
108
|
+
HAVING SUM(impressions) >= ?
|
|
109
|
+
),
|
|
110
|
+
scored AS (
|
|
111
|
+
SELECT
|
|
112
|
+
keyword, page, clicks, impressions, ctr, position,
|
|
113
|
+
CASE
|
|
114
|
+
WHEN position <= 3 THEN 0.2
|
|
115
|
+
WHEN position > 50 THEN 0.1
|
|
116
|
+
ELSE GREATEST(0.0, 1.0 - ABS(position - 11.0) / 15.0)
|
|
117
|
+
END AS positionScore,
|
|
118
|
+
CASE WHEN impressions <= 0 THEN 0.0 ELSE LEAST(LOG10(impressions) / 5.0, 1.0) END AS impressionScore,
|
|
119
|
+
CASE CAST(ROUND(GREATEST(LEAST(position, 10.0), 1.0)) AS INTEGER)
|
|
120
|
+
WHEN 1 THEN 0.30
|
|
121
|
+
WHEN 2 THEN 0.15
|
|
122
|
+
WHEN 3 THEN 0.10
|
|
123
|
+
WHEN 4 THEN 0.07
|
|
124
|
+
WHEN 5 THEN 0.05
|
|
125
|
+
WHEN 6 THEN 0.04
|
|
126
|
+
WHEN 7 THEN 0.03
|
|
127
|
+
WHEN 8 THEN 0.025
|
|
128
|
+
WHEN 9 THEN 0.02
|
|
129
|
+
WHEN 10 THEN 0.015
|
|
130
|
+
ELSE 0.01
|
|
131
|
+
END AS expectedCtr
|
|
132
|
+
FROM agg
|
|
133
|
+
),
|
|
134
|
+
gapped AS (
|
|
135
|
+
SELECT
|
|
136
|
+
*,
|
|
137
|
+
CASE WHEN ctr >= expectedCtr THEN 0.0 ELSE LEAST((expectedCtr - ctr) / expectedCtr, 1.0) END AS ctrGapScore
|
|
138
|
+
FROM scored
|
|
139
|
+
)
|
|
140
|
+
SELECT
|
|
141
|
+
keyword, page, clicks, impressions, ctr, position,
|
|
142
|
+
CAST(ROUND(POWER(
|
|
143
|
+
POWER(positionScore, ${w1}) * POWER(impressionScore, ${w2}) * POWER(ctrGapScore, ${w3}),
|
|
144
|
+
1.0 / ${totalW}
|
|
145
|
+
) * 100) AS DOUBLE) AS opportunityScore,
|
|
146
|
+
CAST(ROUND(impressions * (
|
|
147
|
+
CASE CAST(ROUND(GREATEST(LEAST(position, 3.0), 1.0)) AS INTEGER)
|
|
148
|
+
WHEN 1 THEN 0.30
|
|
149
|
+
WHEN 2 THEN 0.15
|
|
150
|
+
WHEN 3 THEN 0.10
|
|
151
|
+
ELSE 0.10
|
|
152
|
+
END
|
|
153
|
+
)) AS DOUBLE) AS potentialClicks,
|
|
154
|
+
positionScore, impressionScore, ctrGapScore
|
|
155
|
+
FROM gapped
|
|
156
|
+
ORDER BY opportunityScore DESC
|
|
157
|
+
${paginateClause({
|
|
158
|
+
limit,
|
|
159
|
+
offset: params.offset
|
|
160
|
+
})}
|
|
161
|
+
`,
|
|
162
|
+
params: [
|
|
163
|
+
startDate,
|
|
164
|
+
endDate,
|
|
165
|
+
minImpressions
|
|
166
|
+
],
|
|
167
|
+
current: {
|
|
168
|
+
table: "page_queries",
|
|
169
|
+
partitions: enumeratePartitions(startDate, endDate)
|
|
170
|
+
}
|
|
171
|
+
};
|
|
172
|
+
},
|
|
173
|
+
reduceSql(rows) {
|
|
174
|
+
const arr = Array.isArray(rows) ? rows : [];
|
|
175
|
+
return {
|
|
176
|
+
results: arr.map((r) => ({
|
|
177
|
+
keyword: r.keyword == null ? "" : String(r.keyword),
|
|
178
|
+
page: r.page == null ? null : String(r.page),
|
|
179
|
+
clicks: num(r.clicks),
|
|
180
|
+
impressions: num(r.impressions),
|
|
181
|
+
ctr: num(r.ctr),
|
|
182
|
+
position: num(r.position),
|
|
183
|
+
opportunityScore: num(r.opportunityScore),
|
|
184
|
+
potentialClicks: num(r.potentialClicks),
|
|
185
|
+
factors: {
|
|
186
|
+
positionScore: num(r.positionScore),
|
|
187
|
+
impressionScore: num(r.impressionScore),
|
|
188
|
+
ctrGapScore: num(r.ctrGapScore)
|
|
189
|
+
}
|
|
190
|
+
})),
|
|
191
|
+
meta: { total: arr.length }
|
|
192
|
+
};
|
|
193
|
+
},
|
|
194
|
+
buildRows(params) {
|
|
195
|
+
return { queries: queriesQueryState(periodOf(params), params.limit) };
|
|
196
|
+
},
|
|
197
|
+
reduceRows(rows, params) {
|
|
198
|
+
const results = analyzeOpportunity((Array.isArray(rows) ? rows : []) ?? [], { minImpressions: params.minImpressions });
|
|
199
|
+
const paged = paginateSortedInMemory(results, {
|
|
200
|
+
limit: params.limit,
|
|
201
|
+
offset: params.offset
|
|
202
|
+
}, (left, right) => right.opportunityScore - left.opportunityScore);
|
|
203
|
+
return {
|
|
204
|
+
results: paged,
|
|
205
|
+
meta: {
|
|
206
|
+
total: results.length,
|
|
207
|
+
returned: paged.length
|
|
208
|
+
}
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
});
|
|
212
|
+
export { analyzeOpportunity, opportunityAnalyzer };
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { rowString } from "../analyzer/row-values.mjs";
|
|
2
|
+
import { defineAnalyzer } from "@gscdump/engine/analyzer";
|
|
3
|
+
import { periodOf } from "@gscdump/engine/period";
|
|
4
|
+
import { enumeratePartitions } from "@gscdump/engine/planner";
|
|
5
|
+
import { num } from "@gscdump/engine/analysis-types";
|
|
6
|
+
const positionDistributionAnalyzer = defineAnalyzer({
|
|
7
|
+
id: "position-distribution",
|
|
8
|
+
buildSql(params) {
|
|
9
|
+
const { startDate, endDate } = periodOf(params);
|
|
10
|
+
return {
|
|
11
|
+
sql: `
|
|
12
|
+
WITH pos AS (
|
|
13
|
+
SELECT
|
|
14
|
+
date,
|
|
15
|
+
(sum_position / NULLIF(impressions, 0) + 1) AS avg_pos
|
|
16
|
+
FROM read_parquet({{FILES}}, union_by_name = true)
|
|
17
|
+
WHERE date >= ? AND date <= ? AND impressions > 0
|
|
18
|
+
)
|
|
19
|
+
SELECT
|
|
20
|
+
date,
|
|
21
|
+
CAST(SUM(CASE WHEN avg_pos <= 3 THEN 1 ELSE 0 END) AS DOUBLE) AS pos_1_3,
|
|
22
|
+
CAST(SUM(CASE WHEN avg_pos > 3 AND avg_pos <= 10 THEN 1 ELSE 0 END) AS DOUBLE) AS pos_4_10,
|
|
23
|
+
CAST(SUM(CASE WHEN avg_pos > 10 AND avg_pos <= 20 THEN 1 ELSE 0 END) AS DOUBLE) AS pos_11_20,
|
|
24
|
+
CAST(SUM(CASE WHEN avg_pos > 20 THEN 1 ELSE 0 END) AS DOUBLE) AS pos_20_plus,
|
|
25
|
+
CAST(COUNT(*) AS DOUBLE) AS total
|
|
26
|
+
FROM pos
|
|
27
|
+
GROUP BY date
|
|
28
|
+
ORDER BY date ASC
|
|
29
|
+
`,
|
|
30
|
+
params: [startDate, endDate],
|
|
31
|
+
current: {
|
|
32
|
+
table: "queries",
|
|
33
|
+
partitions: enumeratePartitions(startDate, endDate)
|
|
34
|
+
}
|
|
35
|
+
};
|
|
36
|
+
},
|
|
37
|
+
reduceSql(rows, params) {
|
|
38
|
+
const arr = Array.isArray(rows) ? rows : [];
|
|
39
|
+
const { startDate, endDate } = periodOf(params);
|
|
40
|
+
return {
|
|
41
|
+
results: arr.map((r) => ({
|
|
42
|
+
date: rowString(r.date),
|
|
43
|
+
pos_1_3: num(r.pos_1_3),
|
|
44
|
+
pos_4_10: num(r.pos_4_10),
|
|
45
|
+
pos_11_20: num(r.pos_11_20),
|
|
46
|
+
pos_20_plus: num(r.pos_20_plus),
|
|
47
|
+
total: num(r.total)
|
|
48
|
+
})),
|
|
49
|
+
meta: {
|
|
50
|
+
total: arr.length,
|
|
51
|
+
startDate,
|
|
52
|
+
endDate
|
|
53
|
+
}
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
});
|
|
57
|
+
export { positionDistributionAnalyzer };
|