@gscdump/analysis 0.35.2 → 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.
@@ -338,9 +338,11 @@ interface DecayResult {
338
338
  previousClicks: number;
339
339
  lostClicks: number;
340
340
  declinePercent: number;
341
- currentPosition: number;
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
- positionDrop: number;
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
- baselinePosition: number;
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
- positionChange: number;
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;
@@ -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(value) AS sum_total,
942
- SUM(value * value) AS sumsq_total
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(value) OVER w AS sum_left,
958
- SUM(value * value) OVER w AS sumsq_left
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(n_left, 0))
973
- - (sum_left / NULLIF(n_left, 0)) * (sum_left / NULLIF(n_left, 0)),
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(n_total - n_left, 0))
978
- - ((sum_total - sum_left) / NULLIF(n_total - n_left, 0))
979
- * ((sum_total - sum_left) / NULLIF(n_total - n_left, 0)),
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(n_total, 0))
984
- - (sum_total / NULLIF(n_total, 0)) * (sum_total / NULLIF(n_total, 0)),
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(n_left, 0), llr) AS left_mean,
1005
- arg_max((sum_total - sum_left) / NULLIF(n_total - n_left, 0), llr) AS right_mean,
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 avgPosition = data.keywords.reduce((sum, k) => sum + num(k.position), 0) / data.keywords.length;
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
- AVG(position) AS avgPosition,
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
- AVG(day_position) OVER w AS rolling_position,
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
- AVG(rolling_position) FILTER (WHERE rolling_n >= ${Number(minRollingN)}) AS baseline_position,
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
- AVG(avg_pos) AS medianPosition,
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
- clicks: 0,
2385
- position: 0
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: curr.clicks,
2408
+ currentClicks,
2392
2409
  previousClicks: prev.clicks,
2393
2410
  lostClicks,
2394
2411
  declinePercent,
2395
- currentPosition: curr.position,
2412
+ currentPosition,
2396
2413
  previousPosition: prev.position,
2397
- positionDrop: curr.position - prev.position
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
- COALESCE(c.position, 0.0) AS currentPosition,
2476
+ c.position AS currentPosition,
2460
2477
  p.position AS previousPosition,
2461
- (COALESCE(c.position, 0.0) - p.position) AS positionDrop,
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
- AVG(position) AS avgPosition,
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
- clicks: 0,
3155
- impressions: 0,
3156
- position: 0,
3157
- page: null
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(baseline.clicks),
3168
- baselineImpressions: Math.round(baseline.impressions),
3169
- baselinePosition: baseline.position,
3170
- clicksChange: clicks - Math.round(baseline.clicks),
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 - baseline.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
- COALESCE(p.position, 0.0) AS baselinePosition,
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 - COALESCE(p.position, 0.0)) AS positionChange,
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(value) AS sum_total,
942
- SUM(value * value) AS sumsq_total
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(value) OVER w AS sum_left,
958
- SUM(value * value) OVER w AS sumsq_left
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(n_left, 0))
973
- - (sum_left / NULLIF(n_left, 0)) * (sum_left / NULLIF(n_left, 0)),
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(n_total - n_left, 0))
978
- - ((sum_total - sum_left) / NULLIF(n_total - n_left, 0))
979
- * ((sum_total - sum_left) / NULLIF(n_total - n_left, 0)),
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(n_total, 0))
984
- - (sum_total / NULLIF(n_total, 0)) * (sum_total / NULLIF(n_total, 0)),
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(n_left, 0), llr) AS left_mean,
1005
- arg_max((sum_total - sum_left) / NULLIF(n_total - n_left, 0), llr) AS right_mean,
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 avgPosition = data.keywords.reduce((sum, k) => sum + num(k.position), 0) / data.keywords.length;
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
- AVG(position) AS avgPosition,
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
- AVG(day_position) OVER w AS rolling_position,
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
- AVG(rolling_position) FILTER (WHERE rolling_n >= ${Number(minRollingN)}) AS baseline_position,
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
- AVG(avg_pos) AS medianPosition,
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
- clicks: 0,
2379
- position: 0
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: curr.clicks,
2402
+ currentClicks,
2386
2403
  previousClicks: prev.clicks,
2387
2404
  lostClicks,
2388
2405
  declinePercent,
2389
- currentPosition: curr.position,
2406
+ currentPosition,
2390
2407
  previousPosition: prev.position,
2391
- positionDrop: curr.position - prev.position
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
- COALESCE(c.position, 0.0) AS currentPosition,
2470
+ c.position AS currentPosition,
2454
2471
  p.position AS previousPosition,
2455
- (COALESCE(c.position, 0.0) - p.position) AS positionDrop,
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
- AVG(position) AS avgPosition,
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
- clicks: 0,
3149
- impressions: 0,
3150
- position: 0,
3151
- page: null
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(baseline.clicks),
3162
- baselineImpressions: Math.round(baseline.impressions),
3163
- baselinePosition: baseline.position,
3164
- clicksChange: clicks - Math.round(baseline.clicks),
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 - baseline.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
- COALESCE(p.position, 0.0) AS baselinePosition,
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 - COALESCE(p.position, 0.0)) AS positionChange,
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
- currentPosition: number;
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
- positionDrop: number;
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
- baselinePosition: number;
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
- positionChange: number;
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(value) AS sum_total,
1174
- SUM(value * value) AS sumsq_total
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(value) OVER w AS sum_left,
1190
- SUM(value * value) OVER w AS sumsq_left
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(n_left, 0))
1205
- - (sum_left / NULLIF(n_left, 0)) * (sum_left / NULLIF(n_left, 0)),
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(n_total - n_left, 0))
1210
- - ((sum_total - sum_left) / NULLIF(n_total - n_left, 0))
1211
- * ((sum_total - sum_left) / NULLIF(n_total - n_left, 0)),
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(n_total, 0))
1216
- - (sum_total / NULLIF(n_total, 0)) * (sum_total / NULLIF(n_total, 0)),
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(n_left, 0), llr) AS left_mean,
1237
- arg_max((sum_total - sum_left) / NULLIF(n_total - n_left, 0), llr) AS right_mean,
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 avgPosition = data.keywords.reduce((sum, k) => sum + num$1(k.position), 0) / data.keywords.length;
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
- AVG(position) AS avgPosition,
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
- AVG(day_position) OVER w AS rolling_position,
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
- AVG(rolling_position) FILTER (WHERE rolling_n >= ${Number(minRollingN)}) AS baseline_position,
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
- AVG(avg_pos) AS medianPosition,
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
- clicks: 0,
2943
- position: 0
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: curr.clicks,
2966
+ currentClicks,
2950
2967
  previousClicks: prev.clicks,
2951
2968
  lostClicks,
2952
2969
  declinePercent,
2953
- currentPosition: curr.position,
2970
+ currentPosition,
2954
2971
  previousPosition: prev.position,
2955
- positionDrop: curr.position - prev.position
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
- COALESCE(c.position, 0.0) AS currentPosition,
3034
+ c.position AS currentPosition,
3018
3035
  p.position AS previousPosition,
3019
- (COALESCE(c.position, 0.0) - p.position) AS positionDrop,
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
- AVG(position) AS avgPosition,
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
- clicks: 0,
3709
- impressions: 0,
3710
- position: 0,
3711
- page: null
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(baseline.clicks),
3722
- baselineImpressions: Math.round(baseline.impressions),
3723
- baselinePosition: baseline.position,
3724
- clicksChange: clicks - Math.round(baseline.clicks),
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 - baseline.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
- COALESCE(p.position, 0.0) AS baselinePosition,
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 - COALESCE(p.position, 0.0)) AS positionChange,
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",
@@ -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.2",
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.2",
80
- "@gscdump/engine-gsc-api": "0.35.2",
81
- "gscdump": "0.35.2"
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"