@gscdump/analysis 0.35.1 → 0.35.3
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/analyzer/index.d.mts +9 -5
- package/dist/analyzer/index.mjs +66 -51
- package/dist/default-registry.mjs +66 -51
- package/dist/index.d.mts +8 -4
- package/dist/index.mjs +67 -52
- package/dist/report/index.mjs +1 -1
- package/package.json +4 -4
|
@@ -338,9 +338,11 @@ interface DecayResult {
|
|
|
338
338
|
previousClicks: number;
|
|
339
339
|
lostClicks: number;
|
|
340
340
|
declinePercent: number;
|
|
341
|
-
|
|
341
|
+
/** `null` when the page had no ranking data in the current period (not "position 0"). */
|
|
342
|
+
currentPosition: number | null;
|
|
342
343
|
previousPosition: number;
|
|
343
|
-
|
|
344
|
+
/** `null` unless both currentPosition and previousPosition exist. */
|
|
345
|
+
positionDrop: number | null;
|
|
344
346
|
series?: DecaySeriesPoint[];
|
|
345
347
|
}
|
|
346
348
|
declare const decayAnalyzer: import("@gscdump/engine/analyzer").DefinedAnalyzer;
|
|
@@ -443,11 +445,13 @@ interface MoverData {
|
|
|
443
445
|
recentPosition: number;
|
|
444
446
|
baselineClicks: number;
|
|
445
447
|
baselineImpressions: number;
|
|
446
|
-
|
|
448
|
+
/** `null` when the keyword has no previous-period data (a genuinely new query), not "position 0". */
|
|
449
|
+
baselinePosition: number | null;
|
|
447
450
|
clicksChange: number;
|
|
448
451
|
clicksChangePercent: number;
|
|
449
452
|
impressionsChangePercent: number;
|
|
450
|
-
|
|
453
|
+
/** `null` unless both recentPosition and baselinePosition exist. */
|
|
454
|
+
positionChange: number | null;
|
|
451
455
|
}
|
|
452
456
|
interface MoversResult {
|
|
453
457
|
rising: MoverData[];
|
|
@@ -467,7 +471,7 @@ interface MoversResultRow extends MoverData {
|
|
|
467
471
|
position: number;
|
|
468
472
|
prevClicks: number;
|
|
469
473
|
prevImpressions: number;
|
|
470
|
-
prevPosition: number;
|
|
474
|
+
prevPosition: number | null;
|
|
471
475
|
series?: MoversSeriesPoint[];
|
|
472
476
|
}
|
|
473
477
|
declare const moversAnalyzer: import("@gscdump/engine/analyzer").DefinedAnalyzer;
|
package/dist/analyzer/index.mjs
CHANGED
|
@@ -914,6 +914,7 @@ const changePointAnalyzer = defineAnalyzer({
|
|
|
914
914
|
const metric = params.metric === "clicks" || params.metric === "impressions" ? params.metric : "position";
|
|
915
915
|
const limit = params.limit ?? 100;
|
|
916
916
|
const valueExpr = metric === "position" ? METRIC_EXPR.position : `CAST(SUM(${metric}) AS DOUBLE)`;
|
|
917
|
+
const weightExpr = metric === "position" ? METRIC_EXPR.impressions : "1.0";
|
|
917
918
|
return {
|
|
918
919
|
sql: `
|
|
919
920
|
WITH daily AS (
|
|
@@ -926,7 +927,8 @@ const changePointAnalyzer = defineAnalyzer({
|
|
|
926
927
|
CAST(date AS DATE) AS date,
|
|
927
928
|
${METRIC_EXPR.clicks} AS clicks,
|
|
928
929
|
${METRIC_EXPR.impressions} AS impressions,
|
|
929
|
-
${valueExpr} AS value
|
|
930
|
+
${valueExpr} AS value,
|
|
931
|
+
${weightExpr} AS weight
|
|
930
932
|
FROM read_parquet({{FILES}}, union_by_name = true)
|
|
931
933
|
WHERE date >= ? AND date <= ?
|
|
932
934
|
AND query IS NOT NULL AND query <> ''
|
|
@@ -938,8 +940,9 @@ const changePointAnalyzer = defineAnalyzer({
|
|
|
938
940
|
SELECT query, page,
|
|
939
941
|
COUNT(*) AS n_total,
|
|
940
942
|
SUM(impressions) AS total_impressions,
|
|
941
|
-
SUM(
|
|
942
|
-
SUM(value *
|
|
943
|
+
SUM(weight) AS w_total,
|
|
944
|
+
SUM(value * weight) AS sum_total,
|
|
945
|
+
SUM(value * value * weight) AS sumsq_total
|
|
943
946
|
FROM daily
|
|
944
947
|
GROUP BY query, page
|
|
945
948
|
HAVING COUNT(*) >= ${Number(minDays)}
|
|
@@ -947,15 +950,16 @@ const changePointAnalyzer = defineAnalyzer({
|
|
|
947
950
|
),
|
|
948
951
|
filtered AS (
|
|
949
952
|
SELECT d.*,
|
|
950
|
-
e.n_total, e.sum_total, e.sumsq_total, e.total_impressions
|
|
953
|
+
e.n_total, e.w_total, e.sum_total, e.sumsq_total, e.total_impressions
|
|
951
954
|
FROM daily d
|
|
952
955
|
JOIN entity_stats e USING (query, page)
|
|
953
956
|
),
|
|
954
957
|
cumulated AS (
|
|
955
958
|
SELECT *,
|
|
956
959
|
COUNT(*) OVER w AS n_left,
|
|
957
|
-
SUM(
|
|
958
|
-
SUM(value *
|
|
960
|
+
SUM(weight) OVER w AS w_left,
|
|
961
|
+
SUM(value * weight) OVER w AS sum_left,
|
|
962
|
+
SUM(value * value * weight) OVER w AS sumsq_left
|
|
959
963
|
FROM filtered
|
|
960
964
|
WINDOW w AS (
|
|
961
965
|
PARTITION BY query, page
|
|
@@ -966,22 +970,23 @@ const changePointAnalyzer = defineAnalyzer({
|
|
|
966
970
|
llr_scored AS (
|
|
967
971
|
SELECT *,
|
|
968
972
|
(n_total - n_left) AS n_right,
|
|
973
|
+
(w_total - w_left) AS w_right,
|
|
969
974
|
(sum_total - sum_left) AS sum_right,
|
|
970
975
|
(sumsq_total - sumsq_left) AS sumsq_right,
|
|
971
976
|
GREATEST(
|
|
972
|
-
(sumsq_left / NULLIF(
|
|
973
|
-
- (sum_left / NULLIF(
|
|
977
|
+
(sumsq_left / NULLIF(w_left, 0))
|
|
978
|
+
- (sum_left / NULLIF(w_left, 0)) * (sum_left / NULLIF(w_left, 0)),
|
|
974
979
|
1e-9
|
|
975
980
|
) AS var_left,
|
|
976
981
|
GREATEST(
|
|
977
|
-
((sumsq_total - sumsq_left) / NULLIF(
|
|
978
|
-
- ((sum_total - sum_left) / NULLIF(
|
|
979
|
-
* ((sum_total - sum_left) / NULLIF(
|
|
982
|
+
((sumsq_total - sumsq_left) / NULLIF(w_total - w_left, 0))
|
|
983
|
+
- ((sum_total - sum_left) / NULLIF(w_total - w_left, 0))
|
|
984
|
+
* ((sum_total - sum_left) / NULLIF(w_total - w_left, 0)),
|
|
980
985
|
1e-9
|
|
981
986
|
) AS var_right,
|
|
982
987
|
GREATEST(
|
|
983
|
-
(sumsq_total / NULLIF(
|
|
984
|
-
- (sum_total / NULLIF(
|
|
988
|
+
(sumsq_total / NULLIF(w_total, 0))
|
|
989
|
+
- (sum_total / NULLIF(w_total, 0)) * (sum_total / NULLIF(w_total, 0)),
|
|
985
990
|
1e-9
|
|
986
991
|
) AS var_single
|
|
987
992
|
FROM cumulated
|
|
@@ -1001,8 +1006,8 @@ const changePointAnalyzer = defineAnalyzer({
|
|
|
1001
1006
|
SELECT query, page, n_total, total_impressions,
|
|
1002
1007
|
arg_max(date, llr) AS change_date,
|
|
1003
1008
|
MAX(llr) AS best_llr,
|
|
1004
|
-
arg_max(sum_left / NULLIF(
|
|
1005
|
-
arg_max((sum_total - sum_left) / NULLIF(
|
|
1009
|
+
arg_max(sum_left / NULLIF(w_left, 0), llr) AS left_mean,
|
|
1010
|
+
arg_max((sum_total - sum_left) / NULLIF(w_total - w_left, 0), llr) AS right_mean,
|
|
1006
1011
|
arg_max(sqrt(var_left), llr) AS left_std,
|
|
1007
1012
|
arg_max(sqrt(var_right), llr) AS right_std
|
|
1008
1013
|
FROM llr
|
|
@@ -1159,7 +1164,15 @@ function analyzeClustering(keywords, options = {}) {
|
|
|
1159
1164
|
if (data.keywords.length < minClusterSize) continue;
|
|
1160
1165
|
const totalClicks = data.keywords.reduce((sum, k) => sum + num(k.clicks), 0);
|
|
1161
1166
|
const totalImpressions = data.keywords.reduce((sum, k) => sum + num(k.impressions), 0);
|
|
1162
|
-
const
|
|
1167
|
+
const weightedPositionSum = data.keywords.reduce((sum, k) => {
|
|
1168
|
+
const impressions = num(k.impressions);
|
|
1169
|
+
return impressions > 0 ? sum + (num(k.position) - 1) * impressions : sum;
|
|
1170
|
+
}, 0);
|
|
1171
|
+
const positionWeight = data.keywords.reduce((sum, k) => {
|
|
1172
|
+
const impressions = num(k.impressions);
|
|
1173
|
+
return impressions > 0 ? sum + impressions : sum;
|
|
1174
|
+
}, 0);
|
|
1175
|
+
const avgPosition = positionWeight > 0 ? weightedPositionSum / positionWeight + 1 : data.keywords.reduce((sum, k) => sum + num(k.position), 0) / data.keywords.length;
|
|
1163
1176
|
clusters.push({
|
|
1164
1177
|
clusterName: name,
|
|
1165
1178
|
clusterType: data.type,
|
|
@@ -1224,7 +1237,7 @@ const clusteringAnalyzer = defineAnalyzer({
|
|
|
1224
1237
|
CAST(COUNT(*) AS DOUBLE) AS keywordCount,
|
|
1225
1238
|
${METRIC_EXPR.clicks} AS totalClicks,
|
|
1226
1239
|
${METRIC_EXPR.impressions} AS totalImpressions,
|
|
1227
|
-
|
|
1240
|
+
SUM((position - 1) * impressions) / NULLIF(SUM(impressions), 0) + 1 AS avgPosition,
|
|
1228
1241
|
to_json(list({ 'query': query, 'clicks': clicks, 'impressions': impressions, 'ctr': ctr, 'position': position })) AS keywords
|
|
1229
1242
|
FROM keyed
|
|
1230
1243
|
GROUP BY cluster_name
|
|
@@ -1577,7 +1590,11 @@ const ctrAnomalyAnalyzer = defineAnalyzer({
|
|
|
1577
1590
|
SELECT *,
|
|
1578
1591
|
AVG(day_ctr) OVER w AS rolling_ctr,
|
|
1579
1592
|
STDDEV_POP(day_ctr) OVER w AS rolling_stddev,
|
|
1580
|
-
|
|
1593
|
+
-- Impression-weighted rolling mean: day_position is already a recovered
|
|
1594
|
+
-- 1-based daily weighted mean, so weighting by day impressions directly
|
|
1595
|
+
-- is algebraically exact (no -1/+1 dance needed).
|
|
1596
|
+
SUM(day_position * day_impressions) OVER w
|
|
1597
|
+
/ NULLIF(SUM(day_impressions) OVER w, 0) AS rolling_position,
|
|
1581
1598
|
COUNT(*) OVER w AS rolling_n
|
|
1582
1599
|
FROM daily
|
|
1583
1600
|
WINDOW w AS (
|
|
@@ -1626,7 +1643,8 @@ const ctrAnomalyAnalyzer = defineAnalyzer({
|
|
|
1626
1643
|
END) AS severity_raw,
|
|
1627
1644
|
MAX(CASE WHEN is_breach THEN ABS(z_score) ELSE 0.0 END) AS max_z,
|
|
1628
1645
|
AVG(rolling_ctr) FILTER (WHERE rolling_n >= ${Number(minRollingN)}) AS baseline_ctr,
|
|
1629
|
-
|
|
1646
|
+
SUM(rolling_position * day_impressions) FILTER (WHERE rolling_n >= ${Number(minRollingN)})
|
|
1647
|
+
/ NULLIF(SUM(day_impressions) FILTER (WHERE rolling_n >= ${Number(minRollingN)}), 0) AS baseline_position,
|
|
1630
1648
|
SUM(day_impressions) AS total_impressions,
|
|
1631
1649
|
SUM(day_clicks) AS total_clicks
|
|
1632
1650
|
FROM breaches
|
|
@@ -1746,7 +1764,7 @@ const ctrCurveAnalyzer = defineAnalyzer({
|
|
|
1746
1764
|
ELSE '20+'
|
|
1747
1765
|
END AS bucket,
|
|
1748
1766
|
AVG(CAST(clicks AS DOUBLE) / NULLIF(impressions, 0)) AS avgCtr,
|
|
1749
|
-
|
|
1767
|
+
(SUM(sum_position) / NULLIF(SUM(impressions), 0) + 1) AS medianPosition,
|
|
1750
1768
|
CAST(COUNT(DISTINCT query) AS DOUBLE) AS keywordCount,
|
|
1751
1769
|
${METRIC_EXPR.clicks} AS totalClicks,
|
|
1752
1770
|
${METRIC_EXPR.impressions} AS totalImpressions
|
|
@@ -2380,21 +2398,20 @@ function analyzeDecay(input, options = {}) {
|
|
|
2380
2398
|
}), (r) => num(r.clicks) >= minPreviousClicks);
|
|
2381
2399
|
const results = [];
|
|
2382
2400
|
for (const [page, prev] of previousMap) {
|
|
2383
|
-
const curr = currentMap.get(page)
|
|
2384
|
-
|
|
2385
|
-
|
|
2386
|
-
|
|
2387
|
-
const lostClicks = prev.clicks - curr.clicks;
|
|
2401
|
+
const curr = currentMap.get(page);
|
|
2402
|
+
const currentClicks = curr?.clicks ?? 0;
|
|
2403
|
+
const currentPosition = curr?.position ?? null;
|
|
2404
|
+
const lostClicks = prev.clicks - currentClicks;
|
|
2388
2405
|
const declinePercent = prev.clicks > 0 ? lostClicks / prev.clicks : 0;
|
|
2389
2406
|
if (declinePercent >= threshold && lostClicks > 0) results.push({
|
|
2390
2407
|
page,
|
|
2391
|
-
currentClicks
|
|
2408
|
+
currentClicks,
|
|
2392
2409
|
previousClicks: prev.clicks,
|
|
2393
2410
|
lostClicks,
|
|
2394
2411
|
declinePercent,
|
|
2395
|
-
currentPosition
|
|
2412
|
+
currentPosition,
|
|
2396
2413
|
previousPosition: prev.position,
|
|
2397
|
-
positionDrop:
|
|
2414
|
+
positionDrop: currentPosition != null ? currentPosition - prev.position : null
|
|
2398
2415
|
});
|
|
2399
2416
|
}
|
|
2400
2417
|
return sortResults$1(results, sortBy);
|
|
@@ -2456,9 +2473,9 @@ const decayAnalyzer = defineAnalyzer({
|
|
|
2456
2473
|
p.clicks AS previousClicks,
|
|
2457
2474
|
(p.clicks - COALESCE(c.clicks, 0.0)) AS lostClicks,
|
|
2458
2475
|
(p.clicks - COALESCE(c.clicks, 0.0)) / NULLIF(p.clicks, 0) AS declinePercent,
|
|
2459
|
-
|
|
2476
|
+
c.position AS currentPosition,
|
|
2460
2477
|
p.position AS previousPosition,
|
|
2461
|
-
|
|
2478
|
+
CASE WHEN c.position IS NOT NULL THEN (c.position - p.position) ELSE NULL END AS positionDrop,
|
|
2462
2479
|
s.seriesJson
|
|
2463
2480
|
FROM prev p
|
|
2464
2481
|
LEFT JOIN cur c ON p.url = c.url
|
|
@@ -2499,9 +2516,9 @@ const decayAnalyzer = defineAnalyzer({
|
|
|
2499
2516
|
previousClicks: num(r.previousClicks),
|
|
2500
2517
|
lostClicks: num(r.lostClicks),
|
|
2501
2518
|
declinePercent: num(r.declinePercent),
|
|
2502
|
-
currentPosition: num(r.currentPosition),
|
|
2519
|
+
currentPosition: r.currentPosition == null ? null : num(r.currentPosition),
|
|
2503
2520
|
previousPosition: num(r.previousPosition),
|
|
2504
|
-
positionDrop: num(r.positionDrop),
|
|
2521
|
+
positionDrop: r.positionDrop == null ? null : num(r.positionDrop),
|
|
2505
2522
|
series: parseJsonRows(r.seriesJson).map((s) => ({
|
|
2506
2523
|
week: rowString(s.week),
|
|
2507
2524
|
clicks: num(s.clicks),
|
|
@@ -2786,7 +2803,7 @@ const intentAtlasAnalyzer = defineAnalyzer({
|
|
|
2786
2803
|
SUM(impressions) AS totalImpressions,
|
|
2787
2804
|
SUM(clicks) AS totalClicks,
|
|
2788
2805
|
SUM(clicks) / NULLIF(SUM(impressions), 0) AS ctr,
|
|
2789
|
-
|
|
2806
|
+
SUM((position - 1) * impressions) / NULLIF(SUM(impressions), 0) + 1 AS avgPosition,
|
|
2790
2807
|
to_json(list({
|
|
2791
2808
|
'query': query,
|
|
2792
2809
|
'impressions': impressions,
|
|
@@ -3150,27 +3167,25 @@ function analyzeMovers(input, options = {}) {
|
|
|
3150
3167
|
const clicks = num(row.clicks);
|
|
3151
3168
|
const position = num(row.position);
|
|
3152
3169
|
if (impressions < minImpressions) continue;
|
|
3153
|
-
const baseline = baselineMap.get(row.query)
|
|
3154
|
-
|
|
3155
|
-
|
|
3156
|
-
|
|
3157
|
-
|
|
3158
|
-
|
|
3159
|
-
const clicksChangePercent = percentDifference(clicks, baseline.clicks);
|
|
3160
|
-
const impressionsChangePercent = percentDifference(impressions, baseline.impressions);
|
|
3170
|
+
const baseline = baselineMap.get(row.query);
|
|
3171
|
+
const baselineClicksRaw = baseline?.clicks ?? 0;
|
|
3172
|
+
const baselineImpressionsRaw = baseline?.impressions ?? 0;
|
|
3173
|
+
const baselinePosition = baseline?.position ?? null;
|
|
3174
|
+
const clicksChangePercent = percentDifference(clicks, baselineClicksRaw);
|
|
3175
|
+
const impressionsChangePercent = percentDifference(impressions, baselineImpressionsRaw);
|
|
3161
3176
|
const data = {
|
|
3162
3177
|
keyword: row.query,
|
|
3163
3178
|
page: pageMap.get(row.query) ?? null,
|
|
3164
3179
|
recentClicks: clicks,
|
|
3165
3180
|
recentImpressions: impressions,
|
|
3166
3181
|
recentPosition: position,
|
|
3167
|
-
baselineClicks: Math.round(
|
|
3168
|
-
baselineImpressions: Math.round(
|
|
3169
|
-
baselinePosition
|
|
3170
|
-
clicksChange: clicks - Math.round(
|
|
3182
|
+
baselineClicks: Math.round(baselineClicksRaw),
|
|
3183
|
+
baselineImpressions: Math.round(baselineImpressionsRaw),
|
|
3184
|
+
baselinePosition,
|
|
3185
|
+
clicksChange: clicks - Math.round(baselineClicksRaw),
|
|
3171
3186
|
clicksChangePercent,
|
|
3172
3187
|
impressionsChangePercent,
|
|
3173
|
-
positionChange: position -
|
|
3188
|
+
positionChange: baselinePosition != null ? position - baselinePosition : null
|
|
3174
3189
|
};
|
|
3175
3190
|
const absChange = Math.abs(clicksChangePercent / 100);
|
|
3176
3191
|
if (clicksChangePercent > 0 && absChange >= changeThreshold) rising.push(data);
|
|
@@ -3183,7 +3198,7 @@ function analyzeMovers(input, options = {}) {
|
|
|
3183
3198
|
case "impressions": return b.recentImpressions - a.recentImpressions;
|
|
3184
3199
|
case "clicksChange": return Math.abs(b.clicksChangePercent) - Math.abs(a.clicksChangePercent);
|
|
3185
3200
|
case "impressionsChange": return Math.abs(b.impressionsChangePercent) - Math.abs(a.impressionsChangePercent);
|
|
3186
|
-
case "positionChange": return Math.abs(b.positionChange) - Math.abs(a.positionChange);
|
|
3201
|
+
case "positionChange": return Math.abs(b.positionChange ?? 0) - Math.abs(a.positionChange ?? 0);
|
|
3187
3202
|
default: return Math.abs(b.clicksChangePercent) - Math.abs(a.clicksChangePercent);
|
|
3188
3203
|
}
|
|
3189
3204
|
};
|
|
@@ -3257,7 +3272,7 @@ const moversAnalyzer = defineAnalyzer({
|
|
|
3257
3272
|
c.position AS recentPosition,
|
|
3258
3273
|
COALESCE(p.clicks, 0.0) AS baselineClicks,
|
|
3259
3274
|
COALESCE(p.impressions, 0.0) AS baselineImpressions,
|
|
3260
|
-
|
|
3275
|
+
p.position AS baselinePosition,
|
|
3261
3276
|
(c.clicks - COALESCE(p.clicks, 0.0)) AS clicksChange,
|
|
3262
3277
|
CASE
|
|
3263
3278
|
WHEN COALESCE(p.clicks, 0.0) = 0 THEN CASE WHEN c.clicks > 0 THEN 100.0 ELSE 0.0 END
|
|
@@ -3267,7 +3282,7 @@ const moversAnalyzer = defineAnalyzer({
|
|
|
3267
3282
|
WHEN COALESCE(p.impressions, 0.0) = 0 THEN CASE WHEN c.impressions > 0 THEN 100.0 ELSE 0.0 END
|
|
3268
3283
|
ELSE (c.impressions - p.impressions) * 100.0 / p.impressions
|
|
3269
3284
|
END AS impressionsChangePercent,
|
|
3270
|
-
(c.position -
|
|
3285
|
+
CASE WHEN p.position IS NOT NULL THEN (c.position - p.position) ELSE NULL END AS positionChange,
|
|
3271
3286
|
s.seriesJson
|
|
3272
3287
|
FROM cur c
|
|
3273
3288
|
LEFT JOIN prev p ON c.query = p.query AND c.url = p.url
|
|
@@ -3314,7 +3329,7 @@ const moversAnalyzer = defineAnalyzer({
|
|
|
3314
3329
|
const recentPosition = num(r.recentPosition);
|
|
3315
3330
|
const baselineClicks = Math.round(num(r.baselineClicks));
|
|
3316
3331
|
const baselineImpressions = Math.round(num(r.baselineImpressions));
|
|
3317
|
-
const baselinePosition = num(r.baselinePosition);
|
|
3332
|
+
const baselinePosition = r.baselinePosition == null ? null : num(r.baselinePosition);
|
|
3318
3333
|
return {
|
|
3319
3334
|
keyword: rowString(r.keyword),
|
|
3320
3335
|
page: r.page == null ? null : rowString(r.page),
|
|
@@ -3327,7 +3342,7 @@ const moversAnalyzer = defineAnalyzer({
|
|
|
3327
3342
|
clicksChange: num(r.clicksChange),
|
|
3328
3343
|
clicksChangePercent: num(r.clicksChangePercent),
|
|
3329
3344
|
impressionsChangePercent: num(r.impressionsChangePercent),
|
|
3330
|
-
positionChange: num(r.positionChange),
|
|
3345
|
+
positionChange: r.positionChange == null ? null : num(r.positionChange),
|
|
3331
3346
|
direction: rowString(r.direction),
|
|
3332
3347
|
series: parseJsonRows(r.seriesJson).map((s) => ({
|
|
3333
3348
|
week: rowString(s.week),
|
|
@@ -914,6 +914,7 @@ const changePointAnalyzer = defineAnalyzer({
|
|
|
914
914
|
const metric = params.metric === "clicks" || params.metric === "impressions" ? params.metric : "position";
|
|
915
915
|
const limit = params.limit ?? 100;
|
|
916
916
|
const valueExpr = metric === "position" ? METRIC_EXPR.position : `CAST(SUM(${metric}) AS DOUBLE)`;
|
|
917
|
+
const weightExpr = metric === "position" ? METRIC_EXPR.impressions : "1.0";
|
|
917
918
|
return {
|
|
918
919
|
sql: `
|
|
919
920
|
WITH daily AS (
|
|
@@ -926,7 +927,8 @@ const changePointAnalyzer = defineAnalyzer({
|
|
|
926
927
|
CAST(date AS DATE) AS date,
|
|
927
928
|
${METRIC_EXPR.clicks} AS clicks,
|
|
928
929
|
${METRIC_EXPR.impressions} AS impressions,
|
|
929
|
-
${valueExpr} AS value
|
|
930
|
+
${valueExpr} AS value,
|
|
931
|
+
${weightExpr} AS weight
|
|
930
932
|
FROM read_parquet({{FILES}}, union_by_name = true)
|
|
931
933
|
WHERE date >= ? AND date <= ?
|
|
932
934
|
AND query IS NOT NULL AND query <> ''
|
|
@@ -938,8 +940,9 @@ const changePointAnalyzer = defineAnalyzer({
|
|
|
938
940
|
SELECT query, page,
|
|
939
941
|
COUNT(*) AS n_total,
|
|
940
942
|
SUM(impressions) AS total_impressions,
|
|
941
|
-
SUM(
|
|
942
|
-
SUM(value *
|
|
943
|
+
SUM(weight) AS w_total,
|
|
944
|
+
SUM(value * weight) AS sum_total,
|
|
945
|
+
SUM(value * value * weight) AS sumsq_total
|
|
943
946
|
FROM daily
|
|
944
947
|
GROUP BY query, page
|
|
945
948
|
HAVING COUNT(*) >= ${Number(minDays)}
|
|
@@ -947,15 +950,16 @@ const changePointAnalyzer = defineAnalyzer({
|
|
|
947
950
|
),
|
|
948
951
|
filtered AS (
|
|
949
952
|
SELECT d.*,
|
|
950
|
-
e.n_total, e.sum_total, e.sumsq_total, e.total_impressions
|
|
953
|
+
e.n_total, e.w_total, e.sum_total, e.sumsq_total, e.total_impressions
|
|
951
954
|
FROM daily d
|
|
952
955
|
JOIN entity_stats e USING (query, page)
|
|
953
956
|
),
|
|
954
957
|
cumulated AS (
|
|
955
958
|
SELECT *,
|
|
956
959
|
COUNT(*) OVER w AS n_left,
|
|
957
|
-
SUM(
|
|
958
|
-
SUM(value *
|
|
960
|
+
SUM(weight) OVER w AS w_left,
|
|
961
|
+
SUM(value * weight) OVER w AS sum_left,
|
|
962
|
+
SUM(value * value * weight) OVER w AS sumsq_left
|
|
959
963
|
FROM filtered
|
|
960
964
|
WINDOW w AS (
|
|
961
965
|
PARTITION BY query, page
|
|
@@ -966,22 +970,23 @@ const changePointAnalyzer = defineAnalyzer({
|
|
|
966
970
|
llr_scored AS (
|
|
967
971
|
SELECT *,
|
|
968
972
|
(n_total - n_left) AS n_right,
|
|
973
|
+
(w_total - w_left) AS w_right,
|
|
969
974
|
(sum_total - sum_left) AS sum_right,
|
|
970
975
|
(sumsq_total - sumsq_left) AS sumsq_right,
|
|
971
976
|
GREATEST(
|
|
972
|
-
(sumsq_left / NULLIF(
|
|
973
|
-
- (sum_left / NULLIF(
|
|
977
|
+
(sumsq_left / NULLIF(w_left, 0))
|
|
978
|
+
- (sum_left / NULLIF(w_left, 0)) * (sum_left / NULLIF(w_left, 0)),
|
|
974
979
|
1e-9
|
|
975
980
|
) AS var_left,
|
|
976
981
|
GREATEST(
|
|
977
|
-
((sumsq_total - sumsq_left) / NULLIF(
|
|
978
|
-
- ((sum_total - sum_left) / NULLIF(
|
|
979
|
-
* ((sum_total - sum_left) / NULLIF(
|
|
982
|
+
((sumsq_total - sumsq_left) / NULLIF(w_total - w_left, 0))
|
|
983
|
+
- ((sum_total - sum_left) / NULLIF(w_total - w_left, 0))
|
|
984
|
+
* ((sum_total - sum_left) / NULLIF(w_total - w_left, 0)),
|
|
980
985
|
1e-9
|
|
981
986
|
) AS var_right,
|
|
982
987
|
GREATEST(
|
|
983
|
-
(sumsq_total / NULLIF(
|
|
984
|
-
- (sum_total / NULLIF(
|
|
988
|
+
(sumsq_total / NULLIF(w_total, 0))
|
|
989
|
+
- (sum_total / NULLIF(w_total, 0)) * (sum_total / NULLIF(w_total, 0)),
|
|
985
990
|
1e-9
|
|
986
991
|
) AS var_single
|
|
987
992
|
FROM cumulated
|
|
@@ -1001,8 +1006,8 @@ const changePointAnalyzer = defineAnalyzer({
|
|
|
1001
1006
|
SELECT query, page, n_total, total_impressions,
|
|
1002
1007
|
arg_max(date, llr) AS change_date,
|
|
1003
1008
|
MAX(llr) AS best_llr,
|
|
1004
|
-
arg_max(sum_left / NULLIF(
|
|
1005
|
-
arg_max((sum_total - sum_left) / NULLIF(
|
|
1009
|
+
arg_max(sum_left / NULLIF(w_left, 0), llr) AS left_mean,
|
|
1010
|
+
arg_max((sum_total - sum_left) / NULLIF(w_total - w_left, 0), llr) AS right_mean,
|
|
1006
1011
|
arg_max(sqrt(var_left), llr) AS left_std,
|
|
1007
1012
|
arg_max(sqrt(var_right), llr) AS right_std
|
|
1008
1013
|
FROM llr
|
|
@@ -1159,7 +1164,15 @@ function analyzeClustering(keywords, options = {}) {
|
|
|
1159
1164
|
if (data.keywords.length < minClusterSize) continue;
|
|
1160
1165
|
const totalClicks = data.keywords.reduce((sum, k) => sum + num(k.clicks), 0);
|
|
1161
1166
|
const totalImpressions = data.keywords.reduce((sum, k) => sum + num(k.impressions), 0);
|
|
1162
|
-
const
|
|
1167
|
+
const weightedPositionSum = data.keywords.reduce((sum, k) => {
|
|
1168
|
+
const impressions = num(k.impressions);
|
|
1169
|
+
return impressions > 0 ? sum + (num(k.position) - 1) * impressions : sum;
|
|
1170
|
+
}, 0);
|
|
1171
|
+
const positionWeight = data.keywords.reduce((sum, k) => {
|
|
1172
|
+
const impressions = num(k.impressions);
|
|
1173
|
+
return impressions > 0 ? sum + impressions : sum;
|
|
1174
|
+
}, 0);
|
|
1175
|
+
const avgPosition = positionWeight > 0 ? weightedPositionSum / positionWeight + 1 : data.keywords.reduce((sum, k) => sum + num(k.position), 0) / data.keywords.length;
|
|
1163
1176
|
clusters.push({
|
|
1164
1177
|
clusterName: name,
|
|
1165
1178
|
clusterType: data.type,
|
|
@@ -1224,7 +1237,7 @@ const clusteringAnalyzer = defineAnalyzer({
|
|
|
1224
1237
|
CAST(COUNT(*) AS DOUBLE) AS keywordCount,
|
|
1225
1238
|
${METRIC_EXPR.clicks} AS totalClicks,
|
|
1226
1239
|
${METRIC_EXPR.impressions} AS totalImpressions,
|
|
1227
|
-
|
|
1240
|
+
SUM((position - 1) * impressions) / NULLIF(SUM(impressions), 0) + 1 AS avgPosition,
|
|
1228
1241
|
to_json(list({ 'query': query, 'clicks': clicks, 'impressions': impressions, 'ctr': ctr, 'position': position })) AS keywords
|
|
1229
1242
|
FROM keyed
|
|
1230
1243
|
GROUP BY cluster_name
|
|
@@ -1577,7 +1590,11 @@ const ctrAnomalyAnalyzer = defineAnalyzer({
|
|
|
1577
1590
|
SELECT *,
|
|
1578
1591
|
AVG(day_ctr) OVER w AS rolling_ctr,
|
|
1579
1592
|
STDDEV_POP(day_ctr) OVER w AS rolling_stddev,
|
|
1580
|
-
|
|
1593
|
+
-- Impression-weighted rolling mean: day_position is already a recovered
|
|
1594
|
+
-- 1-based daily weighted mean, so weighting by day impressions directly
|
|
1595
|
+
-- is algebraically exact (no -1/+1 dance needed).
|
|
1596
|
+
SUM(day_position * day_impressions) OVER w
|
|
1597
|
+
/ NULLIF(SUM(day_impressions) OVER w, 0) AS rolling_position,
|
|
1581
1598
|
COUNT(*) OVER w AS rolling_n
|
|
1582
1599
|
FROM daily
|
|
1583
1600
|
WINDOW w AS (
|
|
@@ -1626,7 +1643,8 @@ const ctrAnomalyAnalyzer = defineAnalyzer({
|
|
|
1626
1643
|
END) AS severity_raw,
|
|
1627
1644
|
MAX(CASE WHEN is_breach THEN ABS(z_score) ELSE 0.0 END) AS max_z,
|
|
1628
1645
|
AVG(rolling_ctr) FILTER (WHERE rolling_n >= ${Number(minRollingN)}) AS baseline_ctr,
|
|
1629
|
-
|
|
1646
|
+
SUM(rolling_position * day_impressions) FILTER (WHERE rolling_n >= ${Number(minRollingN)})
|
|
1647
|
+
/ NULLIF(SUM(day_impressions) FILTER (WHERE rolling_n >= ${Number(minRollingN)}), 0) AS baseline_position,
|
|
1630
1648
|
SUM(day_impressions) AS total_impressions,
|
|
1631
1649
|
SUM(day_clicks) AS total_clicks
|
|
1632
1650
|
FROM breaches
|
|
@@ -1746,7 +1764,7 @@ const ctrCurveAnalyzer = defineAnalyzer({
|
|
|
1746
1764
|
ELSE '20+'
|
|
1747
1765
|
END AS bucket,
|
|
1748
1766
|
AVG(CAST(clicks AS DOUBLE) / NULLIF(impressions, 0)) AS avgCtr,
|
|
1749
|
-
|
|
1767
|
+
(SUM(sum_position) / NULLIF(SUM(impressions), 0) + 1) AS medianPosition,
|
|
1750
1768
|
CAST(COUNT(DISTINCT query) AS DOUBLE) AS keywordCount,
|
|
1751
1769
|
${METRIC_EXPR.clicks} AS totalClicks,
|
|
1752
1770
|
${METRIC_EXPR.impressions} AS totalImpressions
|
|
@@ -2374,21 +2392,20 @@ function analyzeDecay(input, options = {}) {
|
|
|
2374
2392
|
}), (r) => num(r.clicks) >= minPreviousClicks);
|
|
2375
2393
|
const results = [];
|
|
2376
2394
|
for (const [page, prev] of previousMap) {
|
|
2377
|
-
const curr = currentMap.get(page)
|
|
2378
|
-
|
|
2379
|
-
|
|
2380
|
-
|
|
2381
|
-
const lostClicks = prev.clicks - curr.clicks;
|
|
2395
|
+
const curr = currentMap.get(page);
|
|
2396
|
+
const currentClicks = curr?.clicks ?? 0;
|
|
2397
|
+
const currentPosition = curr?.position ?? null;
|
|
2398
|
+
const lostClicks = prev.clicks - currentClicks;
|
|
2382
2399
|
const declinePercent = prev.clicks > 0 ? lostClicks / prev.clicks : 0;
|
|
2383
2400
|
if (declinePercent >= threshold && lostClicks > 0) results.push({
|
|
2384
2401
|
page,
|
|
2385
|
-
currentClicks
|
|
2402
|
+
currentClicks,
|
|
2386
2403
|
previousClicks: prev.clicks,
|
|
2387
2404
|
lostClicks,
|
|
2388
2405
|
declinePercent,
|
|
2389
|
-
currentPosition
|
|
2406
|
+
currentPosition,
|
|
2390
2407
|
previousPosition: prev.position,
|
|
2391
|
-
positionDrop:
|
|
2408
|
+
positionDrop: currentPosition != null ? currentPosition - prev.position : null
|
|
2392
2409
|
});
|
|
2393
2410
|
}
|
|
2394
2411
|
return sortResults$1(results, sortBy);
|
|
@@ -2450,9 +2467,9 @@ const decayAnalyzer = defineAnalyzer({
|
|
|
2450
2467
|
p.clicks AS previousClicks,
|
|
2451
2468
|
(p.clicks - COALESCE(c.clicks, 0.0)) AS lostClicks,
|
|
2452
2469
|
(p.clicks - COALESCE(c.clicks, 0.0)) / NULLIF(p.clicks, 0) AS declinePercent,
|
|
2453
|
-
|
|
2470
|
+
c.position AS currentPosition,
|
|
2454
2471
|
p.position AS previousPosition,
|
|
2455
|
-
|
|
2472
|
+
CASE WHEN c.position IS NOT NULL THEN (c.position - p.position) ELSE NULL END AS positionDrop,
|
|
2456
2473
|
s.seriesJson
|
|
2457
2474
|
FROM prev p
|
|
2458
2475
|
LEFT JOIN cur c ON p.url = c.url
|
|
@@ -2493,9 +2510,9 @@ const decayAnalyzer = defineAnalyzer({
|
|
|
2493
2510
|
previousClicks: num(r.previousClicks),
|
|
2494
2511
|
lostClicks: num(r.lostClicks),
|
|
2495
2512
|
declinePercent: num(r.declinePercent),
|
|
2496
|
-
currentPosition: num(r.currentPosition),
|
|
2513
|
+
currentPosition: r.currentPosition == null ? null : num(r.currentPosition),
|
|
2497
2514
|
previousPosition: num(r.previousPosition),
|
|
2498
|
-
positionDrop: num(r.positionDrop),
|
|
2515
|
+
positionDrop: r.positionDrop == null ? null : num(r.positionDrop),
|
|
2499
2516
|
series: parseJsonRows(r.seriesJson).map((s) => ({
|
|
2500
2517
|
week: rowString(s.week),
|
|
2501
2518
|
clicks: num(s.clicks),
|
|
@@ -2780,7 +2797,7 @@ const intentAtlasAnalyzer = defineAnalyzer({
|
|
|
2780
2797
|
SUM(impressions) AS totalImpressions,
|
|
2781
2798
|
SUM(clicks) AS totalClicks,
|
|
2782
2799
|
SUM(clicks) / NULLIF(SUM(impressions), 0) AS ctr,
|
|
2783
|
-
|
|
2800
|
+
SUM((position - 1) * impressions) / NULLIF(SUM(impressions), 0) + 1 AS avgPosition,
|
|
2784
2801
|
to_json(list({
|
|
2785
2802
|
'query': query,
|
|
2786
2803
|
'impressions': impressions,
|
|
@@ -3144,27 +3161,25 @@ function analyzeMovers(input, options = {}) {
|
|
|
3144
3161
|
const clicks = num(row.clicks);
|
|
3145
3162
|
const position = num(row.position);
|
|
3146
3163
|
if (impressions < minImpressions) continue;
|
|
3147
|
-
const baseline = baselineMap.get(row.query)
|
|
3148
|
-
|
|
3149
|
-
|
|
3150
|
-
|
|
3151
|
-
|
|
3152
|
-
|
|
3153
|
-
const clicksChangePercent = percentDifference(clicks, baseline.clicks);
|
|
3154
|
-
const impressionsChangePercent = percentDifference(impressions, baseline.impressions);
|
|
3164
|
+
const baseline = baselineMap.get(row.query);
|
|
3165
|
+
const baselineClicksRaw = baseline?.clicks ?? 0;
|
|
3166
|
+
const baselineImpressionsRaw = baseline?.impressions ?? 0;
|
|
3167
|
+
const baselinePosition = baseline?.position ?? null;
|
|
3168
|
+
const clicksChangePercent = percentDifference(clicks, baselineClicksRaw);
|
|
3169
|
+
const impressionsChangePercent = percentDifference(impressions, baselineImpressionsRaw);
|
|
3155
3170
|
const data = {
|
|
3156
3171
|
keyword: row.query,
|
|
3157
3172
|
page: pageMap.get(row.query) ?? null,
|
|
3158
3173
|
recentClicks: clicks,
|
|
3159
3174
|
recentImpressions: impressions,
|
|
3160
3175
|
recentPosition: position,
|
|
3161
|
-
baselineClicks: Math.round(
|
|
3162
|
-
baselineImpressions: Math.round(
|
|
3163
|
-
baselinePosition
|
|
3164
|
-
clicksChange: clicks - Math.round(
|
|
3176
|
+
baselineClicks: Math.round(baselineClicksRaw),
|
|
3177
|
+
baselineImpressions: Math.round(baselineImpressionsRaw),
|
|
3178
|
+
baselinePosition,
|
|
3179
|
+
clicksChange: clicks - Math.round(baselineClicksRaw),
|
|
3165
3180
|
clicksChangePercent,
|
|
3166
3181
|
impressionsChangePercent,
|
|
3167
|
-
positionChange: position -
|
|
3182
|
+
positionChange: baselinePosition != null ? position - baselinePosition : null
|
|
3168
3183
|
};
|
|
3169
3184
|
const absChange = Math.abs(clicksChangePercent / 100);
|
|
3170
3185
|
if (clicksChangePercent > 0 && absChange >= changeThreshold) rising.push(data);
|
|
@@ -3177,7 +3192,7 @@ function analyzeMovers(input, options = {}) {
|
|
|
3177
3192
|
case "impressions": return b.recentImpressions - a.recentImpressions;
|
|
3178
3193
|
case "clicksChange": return Math.abs(b.clicksChangePercent) - Math.abs(a.clicksChangePercent);
|
|
3179
3194
|
case "impressionsChange": return Math.abs(b.impressionsChangePercent) - Math.abs(a.impressionsChangePercent);
|
|
3180
|
-
case "positionChange": return Math.abs(b.positionChange) - Math.abs(a.positionChange);
|
|
3195
|
+
case "positionChange": return Math.abs(b.positionChange ?? 0) - Math.abs(a.positionChange ?? 0);
|
|
3181
3196
|
default: return Math.abs(b.clicksChangePercent) - Math.abs(a.clicksChangePercent);
|
|
3182
3197
|
}
|
|
3183
3198
|
};
|
|
@@ -3251,7 +3266,7 @@ const moversAnalyzer = defineAnalyzer({
|
|
|
3251
3266
|
c.position AS recentPosition,
|
|
3252
3267
|
COALESCE(p.clicks, 0.0) AS baselineClicks,
|
|
3253
3268
|
COALESCE(p.impressions, 0.0) AS baselineImpressions,
|
|
3254
|
-
|
|
3269
|
+
p.position AS baselinePosition,
|
|
3255
3270
|
(c.clicks - COALESCE(p.clicks, 0.0)) AS clicksChange,
|
|
3256
3271
|
CASE
|
|
3257
3272
|
WHEN COALESCE(p.clicks, 0.0) = 0 THEN CASE WHEN c.clicks > 0 THEN 100.0 ELSE 0.0 END
|
|
@@ -3261,7 +3276,7 @@ const moversAnalyzer = defineAnalyzer({
|
|
|
3261
3276
|
WHEN COALESCE(p.impressions, 0.0) = 0 THEN CASE WHEN c.impressions > 0 THEN 100.0 ELSE 0.0 END
|
|
3262
3277
|
ELSE (c.impressions - p.impressions) * 100.0 / p.impressions
|
|
3263
3278
|
END AS impressionsChangePercent,
|
|
3264
|
-
(c.position -
|
|
3279
|
+
CASE WHEN p.position IS NOT NULL THEN (c.position - p.position) ELSE NULL END AS positionChange,
|
|
3265
3280
|
s.seriesJson
|
|
3266
3281
|
FROM cur c
|
|
3267
3282
|
LEFT JOIN prev p ON c.query = p.query AND c.url = p.url
|
|
@@ -3308,7 +3323,7 @@ const moversAnalyzer = defineAnalyzer({
|
|
|
3308
3323
|
const recentPosition = num(r.recentPosition);
|
|
3309
3324
|
const baselineClicks = Math.round(num(r.baselineClicks));
|
|
3310
3325
|
const baselineImpressions = Math.round(num(r.baselineImpressions));
|
|
3311
|
-
const baselinePosition = num(r.baselinePosition);
|
|
3326
|
+
const baselinePosition = r.baselinePosition == null ? null : num(r.baselinePosition);
|
|
3312
3327
|
return {
|
|
3313
3328
|
keyword: rowString(r.keyword),
|
|
3314
3329
|
page: r.page == null ? null : rowString(r.page),
|
|
@@ -3321,7 +3336,7 @@ const moversAnalyzer = defineAnalyzer({
|
|
|
3321
3336
|
clicksChange: num(r.clicksChange),
|
|
3322
3337
|
clicksChangePercent: num(r.clicksChangePercent),
|
|
3323
3338
|
impressionsChangePercent: num(r.impressionsChangePercent),
|
|
3324
|
-
positionChange: num(r.positionChange),
|
|
3339
|
+
positionChange: r.positionChange == null ? null : num(r.positionChange),
|
|
3325
3340
|
direction: rowString(r.direction),
|
|
3326
3341
|
series: parseJsonRows(r.seriesJson).map((s) => ({
|
|
3327
3342
|
week: rowString(s.week),
|
package/dist/index.d.mts
CHANGED
|
@@ -242,9 +242,11 @@ interface DecayResult {
|
|
|
242
242
|
previousClicks: number;
|
|
243
243
|
lostClicks: number;
|
|
244
244
|
declinePercent: number;
|
|
245
|
-
|
|
245
|
+
/** `null` when the page had no ranking data in the current period (not "position 0"). */
|
|
246
|
+
currentPosition: number | null;
|
|
246
247
|
previousPosition: number;
|
|
247
|
-
|
|
248
|
+
/** `null` unless both currentPosition and previousPosition exist. */
|
|
249
|
+
positionDrop: number | null;
|
|
248
250
|
series?: DecaySeriesPoint[];
|
|
249
251
|
}
|
|
250
252
|
/**
|
|
@@ -275,11 +277,13 @@ interface MoverData {
|
|
|
275
277
|
recentPosition: number;
|
|
276
278
|
baselineClicks: number;
|
|
277
279
|
baselineImpressions: number;
|
|
278
|
-
|
|
280
|
+
/** `null` when the keyword has no previous-period data (a genuinely new query), not "position 0". */
|
|
281
|
+
baselinePosition: number | null;
|
|
279
282
|
clicksChange: number;
|
|
280
283
|
clicksChangePercent: number;
|
|
281
284
|
impressionsChangePercent: number;
|
|
282
|
-
|
|
285
|
+
/** `null` unless both recentPosition and baselinePosition exist. */
|
|
286
|
+
positionChange: number | null;
|
|
283
287
|
}
|
|
284
288
|
interface MoversResult {
|
|
285
289
|
rising: MoverData[];
|
package/dist/index.mjs
CHANGED
|
@@ -1146,6 +1146,7 @@ const changePointAnalyzer = defineAnalyzer$1({
|
|
|
1146
1146
|
const metric = params.metric === "clicks" || params.metric === "impressions" ? params.metric : "position";
|
|
1147
1147
|
const limit = params.limit ?? 100;
|
|
1148
1148
|
const valueExpr = metric === "position" ? METRIC_EXPR.position : `CAST(SUM(${metric}) AS DOUBLE)`;
|
|
1149
|
+
const weightExpr = metric === "position" ? METRIC_EXPR.impressions : "1.0";
|
|
1149
1150
|
return {
|
|
1150
1151
|
sql: `
|
|
1151
1152
|
WITH daily AS (
|
|
@@ -1158,7 +1159,8 @@ const changePointAnalyzer = defineAnalyzer$1({
|
|
|
1158
1159
|
CAST(date AS DATE) AS date,
|
|
1159
1160
|
${METRIC_EXPR.clicks} AS clicks,
|
|
1160
1161
|
${METRIC_EXPR.impressions} AS impressions,
|
|
1161
|
-
${valueExpr} AS value
|
|
1162
|
+
${valueExpr} AS value,
|
|
1163
|
+
${weightExpr} AS weight
|
|
1162
1164
|
FROM read_parquet({{FILES}}, union_by_name = true)
|
|
1163
1165
|
WHERE date >= ? AND date <= ?
|
|
1164
1166
|
AND query IS NOT NULL AND query <> ''
|
|
@@ -1170,8 +1172,9 @@ const changePointAnalyzer = defineAnalyzer$1({
|
|
|
1170
1172
|
SELECT query, page,
|
|
1171
1173
|
COUNT(*) AS n_total,
|
|
1172
1174
|
SUM(impressions) AS total_impressions,
|
|
1173
|
-
SUM(
|
|
1174
|
-
SUM(value *
|
|
1175
|
+
SUM(weight) AS w_total,
|
|
1176
|
+
SUM(value * weight) AS sum_total,
|
|
1177
|
+
SUM(value * value * weight) AS sumsq_total
|
|
1175
1178
|
FROM daily
|
|
1176
1179
|
GROUP BY query, page
|
|
1177
1180
|
HAVING COUNT(*) >= ${Number(minDays)}
|
|
@@ -1179,15 +1182,16 @@ const changePointAnalyzer = defineAnalyzer$1({
|
|
|
1179
1182
|
),
|
|
1180
1183
|
filtered AS (
|
|
1181
1184
|
SELECT d.*,
|
|
1182
|
-
e.n_total, e.sum_total, e.sumsq_total, e.total_impressions
|
|
1185
|
+
e.n_total, e.w_total, e.sum_total, e.sumsq_total, e.total_impressions
|
|
1183
1186
|
FROM daily d
|
|
1184
1187
|
JOIN entity_stats e USING (query, page)
|
|
1185
1188
|
),
|
|
1186
1189
|
cumulated AS (
|
|
1187
1190
|
SELECT *,
|
|
1188
1191
|
COUNT(*) OVER w AS n_left,
|
|
1189
|
-
SUM(
|
|
1190
|
-
SUM(value *
|
|
1192
|
+
SUM(weight) OVER w AS w_left,
|
|
1193
|
+
SUM(value * weight) OVER w AS sum_left,
|
|
1194
|
+
SUM(value * value * weight) OVER w AS sumsq_left
|
|
1191
1195
|
FROM filtered
|
|
1192
1196
|
WINDOW w AS (
|
|
1193
1197
|
PARTITION BY query, page
|
|
@@ -1198,22 +1202,23 @@ const changePointAnalyzer = defineAnalyzer$1({
|
|
|
1198
1202
|
llr_scored AS (
|
|
1199
1203
|
SELECT *,
|
|
1200
1204
|
(n_total - n_left) AS n_right,
|
|
1205
|
+
(w_total - w_left) AS w_right,
|
|
1201
1206
|
(sum_total - sum_left) AS sum_right,
|
|
1202
1207
|
(sumsq_total - sumsq_left) AS sumsq_right,
|
|
1203
1208
|
GREATEST(
|
|
1204
|
-
(sumsq_left / NULLIF(
|
|
1205
|
-
- (sum_left / NULLIF(
|
|
1209
|
+
(sumsq_left / NULLIF(w_left, 0))
|
|
1210
|
+
- (sum_left / NULLIF(w_left, 0)) * (sum_left / NULLIF(w_left, 0)),
|
|
1206
1211
|
1e-9
|
|
1207
1212
|
) AS var_left,
|
|
1208
1213
|
GREATEST(
|
|
1209
|
-
((sumsq_total - sumsq_left) / NULLIF(
|
|
1210
|
-
- ((sum_total - sum_left) / NULLIF(
|
|
1211
|
-
* ((sum_total - sum_left) / NULLIF(
|
|
1214
|
+
((sumsq_total - sumsq_left) / NULLIF(w_total - w_left, 0))
|
|
1215
|
+
- ((sum_total - sum_left) / NULLIF(w_total - w_left, 0))
|
|
1216
|
+
* ((sum_total - sum_left) / NULLIF(w_total - w_left, 0)),
|
|
1212
1217
|
1e-9
|
|
1213
1218
|
) AS var_right,
|
|
1214
1219
|
GREATEST(
|
|
1215
|
-
(sumsq_total / NULLIF(
|
|
1216
|
-
- (sum_total / NULLIF(
|
|
1220
|
+
(sumsq_total / NULLIF(w_total, 0))
|
|
1221
|
+
- (sum_total / NULLIF(w_total, 0)) * (sum_total / NULLIF(w_total, 0)),
|
|
1217
1222
|
1e-9
|
|
1218
1223
|
) AS var_single
|
|
1219
1224
|
FROM cumulated
|
|
@@ -1233,8 +1238,8 @@ const changePointAnalyzer = defineAnalyzer$1({
|
|
|
1233
1238
|
SELECT query, page, n_total, total_impressions,
|
|
1234
1239
|
arg_max(date, llr) AS change_date,
|
|
1235
1240
|
MAX(llr) AS best_llr,
|
|
1236
|
-
arg_max(sum_left / NULLIF(
|
|
1237
|
-
arg_max((sum_total - sum_left) / NULLIF(
|
|
1241
|
+
arg_max(sum_left / NULLIF(w_left, 0), llr) AS left_mean,
|
|
1242
|
+
arg_max((sum_total - sum_left) / NULLIF(w_total - w_left, 0), llr) AS right_mean,
|
|
1238
1243
|
arg_max(sqrt(var_left), llr) AS left_std,
|
|
1239
1244
|
arg_max(sqrt(var_right), llr) AS right_std
|
|
1240
1245
|
FROM llr
|
|
@@ -1391,7 +1396,15 @@ function analyzeClustering(keywords, options = {}) {
|
|
|
1391
1396
|
if (data.keywords.length < minClusterSize) continue;
|
|
1392
1397
|
const totalClicks = data.keywords.reduce((sum, k) => sum + num$1(k.clicks), 0);
|
|
1393
1398
|
const totalImpressions = data.keywords.reduce((sum, k) => sum + num$1(k.impressions), 0);
|
|
1394
|
-
const
|
|
1399
|
+
const weightedPositionSum = data.keywords.reduce((sum, k) => {
|
|
1400
|
+
const impressions = num$1(k.impressions);
|
|
1401
|
+
return impressions > 0 ? sum + (num$1(k.position) - 1) * impressions : sum;
|
|
1402
|
+
}, 0);
|
|
1403
|
+
const positionWeight = data.keywords.reduce((sum, k) => {
|
|
1404
|
+
const impressions = num$1(k.impressions);
|
|
1405
|
+
return impressions > 0 ? sum + impressions : sum;
|
|
1406
|
+
}, 0);
|
|
1407
|
+
const avgPosition = positionWeight > 0 ? weightedPositionSum / positionWeight + 1 : data.keywords.reduce((sum, k) => sum + num$1(k.position), 0) / data.keywords.length;
|
|
1395
1408
|
clusters.push({
|
|
1396
1409
|
clusterName: name,
|
|
1397
1410
|
clusterType: data.type,
|
|
@@ -1456,7 +1469,7 @@ const clusteringAnalyzer = defineAnalyzer$1({
|
|
|
1456
1469
|
CAST(COUNT(*) AS DOUBLE) AS keywordCount,
|
|
1457
1470
|
${METRIC_EXPR.clicks} AS totalClicks,
|
|
1458
1471
|
${METRIC_EXPR.impressions} AS totalImpressions,
|
|
1459
|
-
|
|
1472
|
+
SUM((position - 1) * impressions) / NULLIF(SUM(impressions), 0) + 1 AS avgPosition,
|
|
1460
1473
|
to_json(list({ 'query': query, 'clicks': clicks, 'impressions': impressions, 'ctr': ctr, 'position': position })) AS keywords
|
|
1461
1474
|
FROM keyed
|
|
1462
1475
|
GROUP BY cluster_name
|
|
@@ -1809,7 +1822,11 @@ const ctrAnomalyAnalyzer = defineAnalyzer$1({
|
|
|
1809
1822
|
SELECT *,
|
|
1810
1823
|
AVG(day_ctr) OVER w AS rolling_ctr,
|
|
1811
1824
|
STDDEV_POP(day_ctr) OVER w AS rolling_stddev,
|
|
1812
|
-
|
|
1825
|
+
-- Impression-weighted rolling mean: day_position is already a recovered
|
|
1826
|
+
-- 1-based daily weighted mean, so weighting by day impressions directly
|
|
1827
|
+
-- is algebraically exact (no -1/+1 dance needed).
|
|
1828
|
+
SUM(day_position * day_impressions) OVER w
|
|
1829
|
+
/ NULLIF(SUM(day_impressions) OVER w, 0) AS rolling_position,
|
|
1813
1830
|
COUNT(*) OVER w AS rolling_n
|
|
1814
1831
|
FROM daily
|
|
1815
1832
|
WINDOW w AS (
|
|
@@ -1858,7 +1875,8 @@ const ctrAnomalyAnalyzer = defineAnalyzer$1({
|
|
|
1858
1875
|
END) AS severity_raw,
|
|
1859
1876
|
MAX(CASE WHEN is_breach THEN ABS(z_score) ELSE 0.0 END) AS max_z,
|
|
1860
1877
|
AVG(rolling_ctr) FILTER (WHERE rolling_n >= ${Number(minRollingN)}) AS baseline_ctr,
|
|
1861
|
-
|
|
1878
|
+
SUM(rolling_position * day_impressions) FILTER (WHERE rolling_n >= ${Number(minRollingN)})
|
|
1879
|
+
/ NULLIF(SUM(day_impressions) FILTER (WHERE rolling_n >= ${Number(minRollingN)}), 0) AS baseline_position,
|
|
1862
1880
|
SUM(day_impressions) AS total_impressions,
|
|
1863
1881
|
SUM(day_clicks) AS total_clicks
|
|
1864
1882
|
FROM breaches
|
|
@@ -1978,7 +1996,7 @@ const ctrCurveAnalyzer = defineAnalyzer$1({
|
|
|
1978
1996
|
ELSE '20+'
|
|
1979
1997
|
END AS bucket,
|
|
1980
1998
|
AVG(CAST(clicks AS DOUBLE) / NULLIF(impressions, 0)) AS avgCtr,
|
|
1981
|
-
|
|
1999
|
+
(SUM(sum_position) / NULLIF(SUM(impressions), 0) + 1) AS medianPosition,
|
|
1982
2000
|
CAST(COUNT(DISTINCT query) AS DOUBLE) AS keywordCount,
|
|
1983
2001
|
${METRIC_EXPR.clicks} AS totalClicks,
|
|
1984
2002
|
${METRIC_EXPR.impressions} AS totalImpressions
|
|
@@ -2938,21 +2956,20 @@ function analyzeDecay(input, options = {}) {
|
|
|
2938
2956
|
}), (r) => num$1(r.clicks) >= minPreviousClicks);
|
|
2939
2957
|
const results = [];
|
|
2940
2958
|
for (const [page, prev] of previousMap) {
|
|
2941
|
-
const curr = currentMap.get(page)
|
|
2942
|
-
|
|
2943
|
-
|
|
2944
|
-
|
|
2945
|
-
const lostClicks = prev.clicks - curr.clicks;
|
|
2959
|
+
const curr = currentMap.get(page);
|
|
2960
|
+
const currentClicks = curr?.clicks ?? 0;
|
|
2961
|
+
const currentPosition = curr?.position ?? null;
|
|
2962
|
+
const lostClicks = prev.clicks - currentClicks;
|
|
2946
2963
|
const declinePercent = prev.clicks > 0 ? lostClicks / prev.clicks : 0;
|
|
2947
2964
|
if (declinePercent >= threshold && lostClicks > 0) results.push({
|
|
2948
2965
|
page,
|
|
2949
|
-
currentClicks
|
|
2966
|
+
currentClicks,
|
|
2950
2967
|
previousClicks: prev.clicks,
|
|
2951
2968
|
lostClicks,
|
|
2952
2969
|
declinePercent,
|
|
2953
|
-
currentPosition
|
|
2970
|
+
currentPosition,
|
|
2954
2971
|
previousPosition: prev.position,
|
|
2955
|
-
positionDrop:
|
|
2972
|
+
positionDrop: currentPosition != null ? currentPosition - prev.position : null
|
|
2956
2973
|
});
|
|
2957
2974
|
}
|
|
2958
2975
|
return sortResults$1(results, sortBy);
|
|
@@ -3014,9 +3031,9 @@ const decayAnalyzer = defineAnalyzer$1({
|
|
|
3014
3031
|
p.clicks AS previousClicks,
|
|
3015
3032
|
(p.clicks - COALESCE(c.clicks, 0.0)) AS lostClicks,
|
|
3016
3033
|
(p.clicks - COALESCE(c.clicks, 0.0)) / NULLIF(p.clicks, 0) AS declinePercent,
|
|
3017
|
-
|
|
3034
|
+
c.position AS currentPosition,
|
|
3018
3035
|
p.position AS previousPosition,
|
|
3019
|
-
|
|
3036
|
+
CASE WHEN c.position IS NOT NULL THEN (c.position - p.position) ELSE NULL END AS positionDrop,
|
|
3020
3037
|
s.seriesJson
|
|
3021
3038
|
FROM prev p
|
|
3022
3039
|
LEFT JOIN cur c ON p.url = c.url
|
|
@@ -3057,9 +3074,9 @@ const decayAnalyzer = defineAnalyzer$1({
|
|
|
3057
3074
|
previousClicks: num$1(r.previousClicks),
|
|
3058
3075
|
lostClicks: num$1(r.lostClicks),
|
|
3059
3076
|
declinePercent: num$1(r.declinePercent),
|
|
3060
|
-
currentPosition: num$1(r.currentPosition),
|
|
3077
|
+
currentPosition: r.currentPosition == null ? null : num$1(r.currentPosition),
|
|
3061
3078
|
previousPosition: num$1(r.previousPosition),
|
|
3062
|
-
positionDrop: num$1(r.positionDrop),
|
|
3079
|
+
positionDrop: r.positionDrop == null ? null : num$1(r.positionDrop),
|
|
3063
3080
|
series: parseJsonRows(r.seriesJson).map((s) => ({
|
|
3064
3081
|
week: rowString(s.week),
|
|
3065
3082
|
clicks: num$1(s.clicks),
|
|
@@ -3344,7 +3361,7 @@ const intentAtlasAnalyzer = defineAnalyzer$1({
|
|
|
3344
3361
|
SUM(impressions) AS totalImpressions,
|
|
3345
3362
|
SUM(clicks) AS totalClicks,
|
|
3346
3363
|
SUM(clicks) / NULLIF(SUM(impressions), 0) AS ctr,
|
|
3347
|
-
|
|
3364
|
+
SUM((position - 1) * impressions) / NULLIF(SUM(impressions), 0) + 1 AS avgPosition,
|
|
3348
3365
|
to_json(list({
|
|
3349
3366
|
'query': query,
|
|
3350
3367
|
'impressions': impressions,
|
|
@@ -3704,27 +3721,25 @@ function analyzeMovers(input, options = {}) {
|
|
|
3704
3721
|
const clicks = num$1(row.clicks);
|
|
3705
3722
|
const position = num$1(row.position);
|
|
3706
3723
|
if (impressions < minImpressions) continue;
|
|
3707
|
-
const baseline = baselineMap.get(row.query)
|
|
3708
|
-
|
|
3709
|
-
|
|
3710
|
-
|
|
3711
|
-
|
|
3712
|
-
|
|
3713
|
-
const clicksChangePercent = percentDifference(clicks, baseline.clicks);
|
|
3714
|
-
const impressionsChangePercent = percentDifference(impressions, baseline.impressions);
|
|
3724
|
+
const baseline = baselineMap.get(row.query);
|
|
3725
|
+
const baselineClicksRaw = baseline?.clicks ?? 0;
|
|
3726
|
+
const baselineImpressionsRaw = baseline?.impressions ?? 0;
|
|
3727
|
+
const baselinePosition = baseline?.position ?? null;
|
|
3728
|
+
const clicksChangePercent = percentDifference(clicks, baselineClicksRaw);
|
|
3729
|
+
const impressionsChangePercent = percentDifference(impressions, baselineImpressionsRaw);
|
|
3715
3730
|
const data = {
|
|
3716
3731
|
keyword: row.query,
|
|
3717
3732
|
page: pageMap.get(row.query) ?? null,
|
|
3718
3733
|
recentClicks: clicks,
|
|
3719
3734
|
recentImpressions: impressions,
|
|
3720
3735
|
recentPosition: position,
|
|
3721
|
-
baselineClicks: Math.round(
|
|
3722
|
-
baselineImpressions: Math.round(
|
|
3723
|
-
baselinePosition
|
|
3724
|
-
clicksChange: clicks - Math.round(
|
|
3736
|
+
baselineClicks: Math.round(baselineClicksRaw),
|
|
3737
|
+
baselineImpressions: Math.round(baselineImpressionsRaw),
|
|
3738
|
+
baselinePosition,
|
|
3739
|
+
clicksChange: clicks - Math.round(baselineClicksRaw),
|
|
3725
3740
|
clicksChangePercent,
|
|
3726
3741
|
impressionsChangePercent,
|
|
3727
|
-
positionChange: position -
|
|
3742
|
+
positionChange: baselinePosition != null ? position - baselinePosition : null
|
|
3728
3743
|
};
|
|
3729
3744
|
const absChange = Math.abs(clicksChangePercent / 100);
|
|
3730
3745
|
if (clicksChangePercent > 0 && absChange >= changeThreshold) rising.push(data);
|
|
@@ -3737,7 +3752,7 @@ function analyzeMovers(input, options = {}) {
|
|
|
3737
3752
|
case "impressions": return b.recentImpressions - a.recentImpressions;
|
|
3738
3753
|
case "clicksChange": return Math.abs(b.clicksChangePercent) - Math.abs(a.clicksChangePercent);
|
|
3739
3754
|
case "impressionsChange": return Math.abs(b.impressionsChangePercent) - Math.abs(a.impressionsChangePercent);
|
|
3740
|
-
case "positionChange": return Math.abs(b.positionChange) - Math.abs(a.positionChange);
|
|
3755
|
+
case "positionChange": return Math.abs(b.positionChange ?? 0) - Math.abs(a.positionChange ?? 0);
|
|
3741
3756
|
default: return Math.abs(b.clicksChangePercent) - Math.abs(a.clicksChangePercent);
|
|
3742
3757
|
}
|
|
3743
3758
|
};
|
|
@@ -3811,7 +3826,7 @@ const moversAnalyzer = defineAnalyzer$1({
|
|
|
3811
3826
|
c.position AS recentPosition,
|
|
3812
3827
|
COALESCE(p.clicks, 0.0) AS baselineClicks,
|
|
3813
3828
|
COALESCE(p.impressions, 0.0) AS baselineImpressions,
|
|
3814
|
-
|
|
3829
|
+
p.position AS baselinePosition,
|
|
3815
3830
|
(c.clicks - COALESCE(p.clicks, 0.0)) AS clicksChange,
|
|
3816
3831
|
CASE
|
|
3817
3832
|
WHEN COALESCE(p.clicks, 0.0) = 0 THEN CASE WHEN c.clicks > 0 THEN 100.0 ELSE 0.0 END
|
|
@@ -3821,7 +3836,7 @@ const moversAnalyzer = defineAnalyzer$1({
|
|
|
3821
3836
|
WHEN COALESCE(p.impressions, 0.0) = 0 THEN CASE WHEN c.impressions > 0 THEN 100.0 ELSE 0.0 END
|
|
3822
3837
|
ELSE (c.impressions - p.impressions) * 100.0 / p.impressions
|
|
3823
3838
|
END AS impressionsChangePercent,
|
|
3824
|
-
(c.position -
|
|
3839
|
+
CASE WHEN p.position IS NOT NULL THEN (c.position - p.position) ELSE NULL END AS positionChange,
|
|
3825
3840
|
s.seriesJson
|
|
3826
3841
|
FROM cur c
|
|
3827
3842
|
LEFT JOIN prev p ON c.query = p.query AND c.url = p.url
|
|
@@ -3868,7 +3883,7 @@ const moversAnalyzer = defineAnalyzer$1({
|
|
|
3868
3883
|
const recentPosition = num$1(r.recentPosition);
|
|
3869
3884
|
const baselineClicks = Math.round(num$1(r.baselineClicks));
|
|
3870
3885
|
const baselineImpressions = Math.round(num$1(r.baselineImpressions));
|
|
3871
|
-
const baselinePosition = num$1(r.baselinePosition);
|
|
3886
|
+
const baselinePosition = r.baselinePosition == null ? null : num$1(r.baselinePosition);
|
|
3872
3887
|
return {
|
|
3873
3888
|
keyword: rowString(r.keyword),
|
|
3874
3889
|
page: r.page == null ? null : rowString(r.page),
|
|
@@ -3881,7 +3896,7 @@ const moversAnalyzer = defineAnalyzer$1({
|
|
|
3881
3896
|
clicksChange: num$1(r.clicksChange),
|
|
3882
3897
|
clicksChangePercent: num$1(r.clicksChangePercent),
|
|
3883
3898
|
impressionsChangePercent: num$1(r.impressionsChangePercent),
|
|
3884
|
-
positionChange: num$1(r.positionChange),
|
|
3899
|
+
positionChange: r.positionChange == null ? null : num$1(r.positionChange),
|
|
3885
3900
|
direction: rowString(r.direction),
|
|
3886
3901
|
series: parseJsonRows(r.seriesJson).map((s) => ({
|
|
3887
3902
|
week: rowString(s.week),
|
|
@@ -5893,7 +5908,7 @@ function buildMoversSection(res, direction, max, minChange) {
|
|
|
5893
5908
|
clicks: r.recentClicks,
|
|
5894
5909
|
clicksChange: r.clicksChange,
|
|
5895
5910
|
clicksChangePercent: r.clicksChangePercent,
|
|
5896
|
-
positionChange: r.positionChange
|
|
5911
|
+
...r.positionChange != null ? { positionChange: r.positionChange } : {}
|
|
5897
5912
|
},
|
|
5898
5913
|
delta: {
|
|
5899
5914
|
metric: "clicks",
|
package/dist/report/index.mjs
CHANGED
|
@@ -596,7 +596,7 @@ function buildMoversSection(res, direction, max, minChange) {
|
|
|
596
596
|
clicks: r.recentClicks,
|
|
597
597
|
clicksChange: r.clicksChange,
|
|
598
598
|
clicksChangePercent: r.clicksChangePercent,
|
|
599
|
-
positionChange: r.positionChange
|
|
599
|
+
...r.positionChange != null ? { positionChange: r.positionChange } : {}
|
|
600
600
|
},
|
|
601
601
|
delta: {
|
|
602
602
|
metric: "clicks",
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gscdump/analysis",
|
|
3
3
|
"type": "module",
|
|
4
|
-
"version": "0.35.
|
|
4
|
+
"version": "0.35.3",
|
|
5
5
|
"description": "GSC analyzers — striking-distance, opportunity, movers, decay, brand, clustering, concentration, seasonality. Pure row-based + DuckDB-native.",
|
|
6
6
|
"author": {
|
|
7
7
|
"name": "Harlan Wilton",
|
|
@@ -76,9 +76,9 @@
|
|
|
76
76
|
"dependencies": {
|
|
77
77
|
"drizzle-orm": "1.0.0-rc.3",
|
|
78
78
|
"pluralize": "^8.0.0",
|
|
79
|
-
"@gscdump/engine": "0.35.
|
|
80
|
-
"@gscdump/engine
|
|
81
|
-
"gscdump": "0.35.
|
|
79
|
+
"@gscdump/engine-gsc-api": "0.35.3",
|
|
80
|
+
"@gscdump/engine": "0.35.3",
|
|
81
|
+
"gscdump": "0.35.3"
|
|
82
82
|
},
|
|
83
83
|
"devDependencies": {
|
|
84
84
|
"vitest": "^4.1.9"
|