@gscdump/analysis 0.37.2 → 0.37.4

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.
@@ -5,7 +5,7 @@ import { METRIC_EXPR } from "@gscdump/engine/sql-fragments";
5
5
  import { num } from "@gscdump/engine/analysis-types";
6
6
  import { err, ok, unwrapResult } from "gscdump/result";
7
7
  import { between, date, extractDateRange, gsc, page, query } from "gscdump/query";
8
- import { MS_PER_DAY, daysAgo, toIsoDate } from "gscdump";
8
+ import { MS_PER_DAY, daysAgoUtc, toIsoDate } from "gscdump/dates";
9
9
  import { buildExtrasQueries, buildTotalsSql, mergeExtras, resolveComparisonSQL, resolveToSQL, resolveToSQLOptimized } from "@gscdump/engine/resolver";
10
10
  function rowNumber(value) {
11
11
  if (typeof value === "number") return Number.isFinite(value) ? value : 0;
@@ -514,13 +514,25 @@ function analyzeBrandSegmentation(keywords, options) {
514
514
  const lowerBrandTerms = brandTerms.map((t) => t.toLowerCase());
515
515
  const brand = [];
516
516
  const nonBrand = [];
517
+ let brandClicks = 0;
518
+ let nonBrandClicks = 0;
519
+ let brandImpressions = 0;
520
+ let nonBrandImpressions = 0;
517
521
  for (const row of keywords) {
518
- if (num(row.impressions) < minImpressions) continue;
519
- if (lowerBrandTerms.some((term) => row.query.toLowerCase().includes(term))) brand.push(row);
520
- else nonBrand.push(row);
522
+ const impressions = num(row.impressions);
523
+ if (impressions < minImpressions) continue;
524
+ const clicks = num(row.clicks);
525
+ const query = row.query.toLowerCase();
526
+ if (lowerBrandTerms.some((term) => query.includes(term))) {
527
+ brand.push(row);
528
+ brandClicks += clicks;
529
+ brandImpressions += impressions;
530
+ } else {
531
+ nonBrand.push(row);
532
+ nonBrandClicks += clicks;
533
+ nonBrandImpressions += impressions;
534
+ }
521
535
  }
522
- const brandClicks = brand.reduce((sum, k) => sum + num(k.clicks), 0);
523
- const nonBrandClicks = nonBrand.reduce((sum, k) => sum + num(k.clicks), 0);
524
536
  const totalClicks = brandClicks + nonBrandClicks;
525
537
  return {
526
538
  brand,
@@ -529,8 +541,8 @@ function analyzeBrandSegmentation(keywords, options) {
529
541
  brandClicks,
530
542
  nonBrandClicks,
531
543
  brandShare: totalClicks > 0 ? brandClicks / totalClicks : 0,
532
- brandImpressions: brand.reduce((sum, k) => sum + num(k.impressions), 0),
533
- nonBrandImpressions: nonBrand.reduce((sum, k) => sum + num(k.impressions), 0)
544
+ brandImpressions,
545
+ nonBrandImpressions
534
546
  }
535
547
  };
536
548
  }
@@ -679,15 +691,24 @@ function analyzeCannibalization(rows, options = {}) {
679
691
  const results = [];
680
692
  for (const [query, pages] of queryMap) {
681
693
  if (pages.length < minPages) continue;
682
- pages.sort((a, b) => b.clicks - a.clicks);
683
- const positions = pages.map((p) => p.position);
684
- const positionSpread = Math.max(...positions) - Math.min(...positions);
694
+ let minPosition = Number.POSITIVE_INFINITY;
695
+ let maxPosition = Number.NEGATIVE_INFINITY;
696
+ let totalClicks = 0;
697
+ let totalImpressions = 0;
698
+ for (const page of pages) {
699
+ minPosition = Math.min(minPosition, page.position);
700
+ maxPosition = Math.max(maxPosition, page.position);
701
+ totalClicks += page.clicks;
702
+ totalImpressions += page.impressions;
703
+ }
704
+ const positionSpread = maxPosition - minPosition;
685
705
  if (positionSpread > maxPositionSpread) continue;
706
+ pages.sort((a, b) => b.clicks - a.clicks);
686
707
  results.push({
687
708
  query,
688
709
  pages,
689
- totalClicks: pages.reduce((sum, p) => sum + p.clicks, 0),
690
- totalImpressions: pages.reduce((sum, p) => sum + p.impressions, 0),
710
+ totalClicks,
711
+ totalImpressions,
691
712
  positionSpread
692
713
  });
693
714
  }
@@ -906,7 +927,7 @@ const changePointAnalyzer = defineAnalyzer({
906
927
  id: "change-point",
907
928
  buildSql(params) {
908
929
  const endDate = params.endDate ?? defaultEndDate();
909
- const startDate = params.startDate ?? daysAgo(93);
930
+ const startDate = params.startDate ?? daysAgoUtc(93);
910
931
  const minDays = 21;
911
932
  const minSide = 7;
912
933
  const threshold = params.threshold ?? 10;
@@ -1141,9 +1162,9 @@ function analyzeClustering(keywords, options = {}) {
1141
1162
  }
1142
1163
  }
1143
1164
  if (clusterBy === "prefix" || clusterBy === "both") {
1144
- const unclustered = filtered.filter((kw) => !clusteredKeywords.has(kw.query));
1145
1165
  const prefixMap = /* @__PURE__ */ new Map();
1146
- for (const kw of unclustered) {
1166
+ for (const kw of filtered) {
1167
+ if (clusteredKeywords.has(kw.query)) continue;
1147
1168
  const prefix = extractWordPrefix(kw.query);
1148
1169
  if (prefix) {
1149
1170
  const existing = prefixMap.get(prefix);
@@ -1156,23 +1177,29 @@ function analyzeClustering(keywords, options = {}) {
1156
1177
  type: "prefix",
1157
1178
  keywords: kws
1158
1179
  });
1159
- kws.forEach((kw) => clusteredKeywords.add(kw.query));
1180
+ for (const kw of kws) clusteredKeywords.add(kw.query);
1160
1181
  }
1161
1182
  }
1162
1183
  const clusters = [];
1163
1184
  for (const [name, data] of clusterMap) {
1164
1185
  if (data.keywords.length < minClusterSize) continue;
1165
- const totalClicks = data.keywords.reduce((sum, k) => sum + num(k.clicks), 0);
1166
- const totalImpressions = data.keywords.reduce((sum, k) => sum + num(k.impressions), 0);
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) => {
1186
+ let totalClicks = 0;
1187
+ let totalImpressions = 0;
1188
+ let weightedPositionSum = 0;
1189
+ let positionWeight = 0;
1190
+ let positionSum = 0;
1191
+ for (const k of data.keywords) {
1172
1192
  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;
1193
+ const position = num(k.position);
1194
+ totalClicks += num(k.clicks);
1195
+ totalImpressions += impressions;
1196
+ positionSum += position;
1197
+ if (impressions > 0) {
1198
+ weightedPositionSum += (position - 1) * impressions;
1199
+ positionWeight += impressions;
1200
+ }
1201
+ }
1202
+ const avgPosition = positionWeight > 0 ? weightedPositionSum / positionWeight + 1 : positionSum / data.keywords.length;
1176
1203
  clusters.push({
1177
1204
  clusterName: name,
1178
1205
  clusterType: data.type,
@@ -1295,19 +1322,6 @@ const clusteringAnalyzer = defineAnalyzer({
1295
1322
  };
1296
1323
  }
1297
1324
  });
1298
- function calculateGini(values) {
1299
- if (values.length === 0) return 0;
1300
- const sorted = [...values].sort((a, b) => a - b);
1301
- const n = sorted.length;
1302
- const sum = sorted.reduce((a, b) => a + b, 0);
1303
- if (sum === 0) return 0;
1304
- let weightedSum = 0;
1305
- for (let i = 0; i < n; i++) weightedSum += (2 * (i + 1) - n - 1) * sorted[i];
1306
- return weightedSum / (n * sum);
1307
- }
1308
- function calculateHHI(shares) {
1309
- return shares.reduce((sum, share) => sum + (share * 100) ** 2, 0);
1310
- }
1311
1325
  function analyzeConcentration(items, options = {}) {
1312
1326
  const { topN = 10 } = options;
1313
1327
  if (items.length === 0) return {
@@ -1320,11 +1334,18 @@ function analyzeConcentration(items, options = {}) {
1320
1334
  riskLevel: "low"
1321
1335
  };
1322
1336
  const sorted = [...items].sort((a, b) => b.clicks - a.clicks);
1323
- const totalClicks = sorted.reduce((sum, item) => sum + item.clicks, 0);
1324
- const clickValues = sorted.map((i) => i.clicks);
1325
- const shares = totalClicks > 0 ? sorted.map((i) => i.clicks / totalClicks) : [];
1326
- const giniCoefficient = calculateGini(clickValues);
1327
- const hhi = calculateHHI(shares);
1337
+ const n = sorted.length;
1338
+ let totalClicks = 0;
1339
+ let squaredClicks = 0;
1340
+ let weightedSum = 0;
1341
+ for (let i = 0; i < n; i++) {
1342
+ const clicks = sorted[i].clicks;
1343
+ totalClicks += clicks;
1344
+ squaredClicks += clicks * clicks;
1345
+ weightedSum += (n - 2 * i - 1) * clicks;
1346
+ }
1347
+ const giniCoefficient = totalClicks === 0 ? 0 : weightedSum / (n * totalClicks);
1348
+ const hhi = totalClicks > 0 ? squaredClicks / (totalClicks * totalClicks) * 1e4 : 0;
1328
1349
  const topNItems = sorted.slice(0, topN).map((item) => ({
1329
1350
  key: item.key,
1330
1351
  clicks: item.clicks,
@@ -1558,7 +1579,7 @@ const ctrAnomalyAnalyzer = defineAnalyzer({
1558
1579
  id: "ctr-anomaly",
1559
1580
  buildSql(params) {
1560
1581
  const endDate = params.endDate ?? defaultEndDate();
1561
- const startDate = params.startDate ?? daysAgo(93);
1582
+ const startDate = params.startDate ?? daysAgoUtc(93);
1562
1583
  const minDailyImpressions = params.minImpressions ?? 5;
1563
1584
  const minRollingN = 14;
1564
1585
  const zThreshold = params.threshold ?? 2;
@@ -2746,7 +2767,7 @@ const intentAtlasAnalyzer = defineAnalyzer({
2746
2767
  id: "intent-atlas",
2747
2768
  buildSql(params) {
2748
2769
  const endDate = params.endDate ?? defaultEndDate();
2749
- const startDate = params.startDate ?? daysAgo(90);
2770
+ const startDate = params.startDate ?? daysAgoUtc(90);
2750
2771
  const minQueryImpressions = params.minImpressions ?? 20;
2751
2772
  const minClusterSize = params.minClusterSize ?? 3;
2752
2773
  const minTokenImpressions = 50;
@@ -2970,22 +2991,23 @@ const keywordBreadthAnalyzer = defineAnalyzer({
2970
2991
  }
2971
2992
  });
2972
2993
  function downsampleLogRank(points) {
2973
- const all = points.map((p) => ({
2994
+ const toPoint = (p) => ({
2974
2995
  rank: num(p.rank),
2975
2996
  impressions: num(p.impressions),
2976
2997
  clicks: num(p.clicks),
2977
2998
  query: rowString(p.query)
2978
- }));
2979
- if (all.length <= 80) return all;
2980
- const top = all.slice(0, 10);
2981
- const rest = all.slice(10);
2982
- const stepped = [];
2999
+ });
3000
+ if (points.length <= 80) return points.map(toPoint);
3001
+ const sampled = points.slice(0, 10).map(toPoint);
2983
3002
  let nextThreshold = 1.15;
2984
- for (const p of rest) if (p.rank >= nextThreshold) {
2985
- stepped.push(p);
2986
- nextThreshold *= 1.15;
3003
+ for (let i = 10; i < points.length; i++) {
3004
+ const point = points[i];
3005
+ if (num(point.rank) >= nextThreshold) {
3006
+ sampled.push(toPoint(point));
3007
+ nextThreshold *= 1.15;
3008
+ }
2987
3009
  }
2988
- return [...top, ...stepped];
3010
+ return sampled;
2989
3011
  }
2990
3012
  const longTailAnalyzer = defineAnalyzer({
2991
3013
  id: "long-tail",
@@ -4078,7 +4100,7 @@ const stlDecomposeAnalyzer = defineAnalyzer({
4078
4100
  id: "stl-decompose",
4079
4101
  buildSql(params) {
4080
4102
  const endDate = params.endDate ?? defaultEndDate();
4081
- const startDate = params.startDate ?? daysAgo(93);
4103
+ const startDate = params.startDate ?? daysAgoUtc(93);
4082
4104
  const minImpressions = params.minImpressions ?? 100;
4083
4105
  const minDays = 21;
4084
4106
  const metric = params.metric === "clicks" ? "clicks" : "impressions";
@@ -4327,7 +4349,7 @@ const survivalAnalyzer = defineAnalyzer({
4327
4349
  id: "survival",
4328
4350
  buildSql(params) {
4329
4351
  const endDate = params.endDate ?? defaultEndDate();
4330
- const startDate = params.startDate ?? daysAgo(183);
4352
+ const startDate = params.startDate ?? daysAgoUtc(183);
4331
4353
  const minImpressions = params.minImpressions ?? 5;
4332
4354
  return {
4333
4355
  sql: `
@@ -4472,7 +4494,7 @@ const survivalAnalyzer = defineAnalyzer({
4472
4494
  reduceSql(rows, params) {
4473
4495
  const arr = Array.isArray(rows) ? rows : [];
4474
4496
  const endDate = params.endDate ?? defaultEndDate();
4475
- const startDate = params.startDate ?? daysAgo(183);
4497
+ const startDate = params.startDate ?? daysAgoUtc(183);
4476
4498
  const windowDays = Math.round((new Date(endDate).getTime() - new Date(startDate).getTime()) / MS_PER_DAY) + 1;
4477
4499
  const results = arr.map((r) => {
4478
4500
  const curve = parseJsonRows(r.curveJson).map((p) => ({
@@ -5,7 +5,7 @@ import { METRIC_EXPR } from "@gscdump/engine/sql-fragments";
5
5
  import { num } from "@gscdump/engine/analysis-types";
6
6
  import { err, ok, unwrapResult } from "gscdump/result";
7
7
  import { between, date, extractDateRange, gsc, page, query } from "gscdump/query";
8
- import { MS_PER_DAY, daysAgo, toIsoDate } from "gscdump";
8
+ import { MS_PER_DAY, daysAgoUtc, toIsoDate } from "gscdump/dates";
9
9
  import { buildExtrasQueries, buildTotalsSql, mergeExtras, resolveComparisonSQL, resolveToSQL, resolveToSQLOptimized } from "@gscdump/engine/resolver";
10
10
  function rowNumber(value) {
11
11
  if (typeof value === "number") return Number.isFinite(value) ? value : 0;
@@ -514,13 +514,25 @@ function analyzeBrandSegmentation(keywords, options) {
514
514
  const lowerBrandTerms = brandTerms.map((t) => t.toLowerCase());
515
515
  const brand = [];
516
516
  const nonBrand = [];
517
+ let brandClicks = 0;
518
+ let nonBrandClicks = 0;
519
+ let brandImpressions = 0;
520
+ let nonBrandImpressions = 0;
517
521
  for (const row of keywords) {
518
- if (num(row.impressions) < minImpressions) continue;
519
- if (lowerBrandTerms.some((term) => row.query.toLowerCase().includes(term))) brand.push(row);
520
- else nonBrand.push(row);
522
+ const impressions = num(row.impressions);
523
+ if (impressions < minImpressions) continue;
524
+ const clicks = num(row.clicks);
525
+ const query = row.query.toLowerCase();
526
+ if (lowerBrandTerms.some((term) => query.includes(term))) {
527
+ brand.push(row);
528
+ brandClicks += clicks;
529
+ brandImpressions += impressions;
530
+ } else {
531
+ nonBrand.push(row);
532
+ nonBrandClicks += clicks;
533
+ nonBrandImpressions += impressions;
534
+ }
521
535
  }
522
- const brandClicks = brand.reduce((sum, k) => sum + num(k.clicks), 0);
523
- const nonBrandClicks = nonBrand.reduce((sum, k) => sum + num(k.clicks), 0);
524
536
  const totalClicks = brandClicks + nonBrandClicks;
525
537
  return {
526
538
  brand,
@@ -529,8 +541,8 @@ function analyzeBrandSegmentation(keywords, options) {
529
541
  brandClicks,
530
542
  nonBrandClicks,
531
543
  brandShare: totalClicks > 0 ? brandClicks / totalClicks : 0,
532
- brandImpressions: brand.reduce((sum, k) => sum + num(k.impressions), 0),
533
- nonBrandImpressions: nonBrand.reduce((sum, k) => sum + num(k.impressions), 0)
544
+ brandImpressions,
545
+ nonBrandImpressions
534
546
  }
535
547
  };
536
548
  }
@@ -679,15 +691,24 @@ function analyzeCannibalization(rows, options = {}) {
679
691
  const results = [];
680
692
  for (const [query, pages] of queryMap) {
681
693
  if (pages.length < minPages) continue;
682
- pages.sort((a, b) => b.clicks - a.clicks);
683
- const positions = pages.map((p) => p.position);
684
- const positionSpread = Math.max(...positions) - Math.min(...positions);
694
+ let minPosition = Number.POSITIVE_INFINITY;
695
+ let maxPosition = Number.NEGATIVE_INFINITY;
696
+ let totalClicks = 0;
697
+ let totalImpressions = 0;
698
+ for (const page of pages) {
699
+ minPosition = Math.min(minPosition, page.position);
700
+ maxPosition = Math.max(maxPosition, page.position);
701
+ totalClicks += page.clicks;
702
+ totalImpressions += page.impressions;
703
+ }
704
+ const positionSpread = maxPosition - minPosition;
685
705
  if (positionSpread > maxPositionSpread) continue;
706
+ pages.sort((a, b) => b.clicks - a.clicks);
686
707
  results.push({
687
708
  query,
688
709
  pages,
689
- totalClicks: pages.reduce((sum, p) => sum + p.clicks, 0),
690
- totalImpressions: pages.reduce((sum, p) => sum + p.impressions, 0),
710
+ totalClicks,
711
+ totalImpressions,
691
712
  positionSpread
692
713
  });
693
714
  }
@@ -906,7 +927,7 @@ const changePointAnalyzer = defineAnalyzer({
906
927
  id: "change-point",
907
928
  buildSql(params) {
908
929
  const endDate = params.endDate ?? defaultEndDate();
909
- const startDate = params.startDate ?? daysAgo(93);
930
+ const startDate = params.startDate ?? daysAgoUtc(93);
910
931
  const minDays = 21;
911
932
  const minSide = 7;
912
933
  const threshold = params.threshold ?? 10;
@@ -1141,9 +1162,9 @@ function analyzeClustering(keywords, options = {}) {
1141
1162
  }
1142
1163
  }
1143
1164
  if (clusterBy === "prefix" || clusterBy === "both") {
1144
- const unclustered = filtered.filter((kw) => !clusteredKeywords.has(kw.query));
1145
1165
  const prefixMap = /* @__PURE__ */ new Map();
1146
- for (const kw of unclustered) {
1166
+ for (const kw of filtered) {
1167
+ if (clusteredKeywords.has(kw.query)) continue;
1147
1168
  const prefix = extractWordPrefix(kw.query);
1148
1169
  if (prefix) {
1149
1170
  const existing = prefixMap.get(prefix);
@@ -1156,23 +1177,29 @@ function analyzeClustering(keywords, options = {}) {
1156
1177
  type: "prefix",
1157
1178
  keywords: kws
1158
1179
  });
1159
- kws.forEach((kw) => clusteredKeywords.add(kw.query));
1180
+ for (const kw of kws) clusteredKeywords.add(kw.query);
1160
1181
  }
1161
1182
  }
1162
1183
  const clusters = [];
1163
1184
  for (const [name, data] of clusterMap) {
1164
1185
  if (data.keywords.length < minClusterSize) continue;
1165
- const totalClicks = data.keywords.reduce((sum, k) => sum + num(k.clicks), 0);
1166
- const totalImpressions = data.keywords.reduce((sum, k) => sum + num(k.impressions), 0);
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) => {
1186
+ let totalClicks = 0;
1187
+ let totalImpressions = 0;
1188
+ let weightedPositionSum = 0;
1189
+ let positionWeight = 0;
1190
+ let positionSum = 0;
1191
+ for (const k of data.keywords) {
1172
1192
  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;
1193
+ const position = num(k.position);
1194
+ totalClicks += num(k.clicks);
1195
+ totalImpressions += impressions;
1196
+ positionSum += position;
1197
+ if (impressions > 0) {
1198
+ weightedPositionSum += (position - 1) * impressions;
1199
+ positionWeight += impressions;
1200
+ }
1201
+ }
1202
+ const avgPosition = positionWeight > 0 ? weightedPositionSum / positionWeight + 1 : positionSum / data.keywords.length;
1176
1203
  clusters.push({
1177
1204
  clusterName: name,
1178
1205
  clusterType: data.type,
@@ -1295,19 +1322,6 @@ const clusteringAnalyzer = defineAnalyzer({
1295
1322
  };
1296
1323
  }
1297
1324
  });
1298
- function calculateGini(values) {
1299
- if (values.length === 0) return 0;
1300
- const sorted = [...values].sort((a, b) => a - b);
1301
- const n = sorted.length;
1302
- const sum = sorted.reduce((a, b) => a + b, 0);
1303
- if (sum === 0) return 0;
1304
- let weightedSum = 0;
1305
- for (let i = 0; i < n; i++) weightedSum += (2 * (i + 1) - n - 1) * sorted[i];
1306
- return weightedSum / (n * sum);
1307
- }
1308
- function calculateHHI(shares) {
1309
- return shares.reduce((sum, share) => sum + (share * 100) ** 2, 0);
1310
- }
1311
1325
  function analyzeConcentration(items, options = {}) {
1312
1326
  const { topN = 10 } = options;
1313
1327
  if (items.length === 0) return {
@@ -1320,11 +1334,18 @@ function analyzeConcentration(items, options = {}) {
1320
1334
  riskLevel: "low"
1321
1335
  };
1322
1336
  const sorted = [...items].sort((a, b) => b.clicks - a.clicks);
1323
- const totalClicks = sorted.reduce((sum, item) => sum + item.clicks, 0);
1324
- const clickValues = sorted.map((i) => i.clicks);
1325
- const shares = totalClicks > 0 ? sorted.map((i) => i.clicks / totalClicks) : [];
1326
- const giniCoefficient = calculateGini(clickValues);
1327
- const hhi = calculateHHI(shares);
1337
+ const n = sorted.length;
1338
+ let totalClicks = 0;
1339
+ let squaredClicks = 0;
1340
+ let weightedSum = 0;
1341
+ for (let i = 0; i < n; i++) {
1342
+ const clicks = sorted[i].clicks;
1343
+ totalClicks += clicks;
1344
+ squaredClicks += clicks * clicks;
1345
+ weightedSum += (n - 2 * i - 1) * clicks;
1346
+ }
1347
+ const giniCoefficient = totalClicks === 0 ? 0 : weightedSum / (n * totalClicks);
1348
+ const hhi = totalClicks > 0 ? squaredClicks / (totalClicks * totalClicks) * 1e4 : 0;
1328
1349
  const topNItems = sorted.slice(0, topN).map((item) => ({
1329
1350
  key: item.key,
1330
1351
  clicks: item.clicks,
@@ -1558,7 +1579,7 @@ const ctrAnomalyAnalyzer = defineAnalyzer({
1558
1579
  id: "ctr-anomaly",
1559
1580
  buildSql(params) {
1560
1581
  const endDate = params.endDate ?? defaultEndDate();
1561
- const startDate = params.startDate ?? daysAgo(93);
1582
+ const startDate = params.startDate ?? daysAgoUtc(93);
1562
1583
  const minDailyImpressions = params.minImpressions ?? 5;
1563
1584
  const minRollingN = 14;
1564
1585
  const zThreshold = params.threshold ?? 2;
@@ -2740,7 +2761,7 @@ const intentAtlasAnalyzer = defineAnalyzer({
2740
2761
  id: "intent-atlas",
2741
2762
  buildSql(params) {
2742
2763
  const endDate = params.endDate ?? defaultEndDate();
2743
- const startDate = params.startDate ?? daysAgo(90);
2764
+ const startDate = params.startDate ?? daysAgoUtc(90);
2744
2765
  const minQueryImpressions = params.minImpressions ?? 20;
2745
2766
  const minClusterSize = params.minClusterSize ?? 3;
2746
2767
  const minTokenImpressions = 50;
@@ -2964,22 +2985,23 @@ const keywordBreadthAnalyzer = defineAnalyzer({
2964
2985
  }
2965
2986
  });
2966
2987
  function downsampleLogRank(points) {
2967
- const all = points.map((p) => ({
2988
+ const toPoint = (p) => ({
2968
2989
  rank: num(p.rank),
2969
2990
  impressions: num(p.impressions),
2970
2991
  clicks: num(p.clicks),
2971
2992
  query: rowString(p.query)
2972
- }));
2973
- if (all.length <= 80) return all;
2974
- const top = all.slice(0, 10);
2975
- const rest = all.slice(10);
2976
- const stepped = [];
2993
+ });
2994
+ if (points.length <= 80) return points.map(toPoint);
2995
+ const sampled = points.slice(0, 10).map(toPoint);
2977
2996
  let nextThreshold = 1.15;
2978
- for (const p of rest) if (p.rank >= nextThreshold) {
2979
- stepped.push(p);
2980
- nextThreshold *= 1.15;
2997
+ for (let i = 10; i < points.length; i++) {
2998
+ const point = points[i];
2999
+ if (num(point.rank) >= nextThreshold) {
3000
+ sampled.push(toPoint(point));
3001
+ nextThreshold *= 1.15;
3002
+ }
2981
3003
  }
2982
- return [...top, ...stepped];
3004
+ return sampled;
2983
3005
  }
2984
3006
  const longTailAnalyzer = defineAnalyzer({
2985
3007
  id: "long-tail",
@@ -4072,7 +4094,7 @@ const stlDecomposeAnalyzer = defineAnalyzer({
4072
4094
  id: "stl-decompose",
4073
4095
  buildSql(params) {
4074
4096
  const endDate = params.endDate ?? defaultEndDate();
4075
- const startDate = params.startDate ?? daysAgo(93);
4097
+ const startDate = params.startDate ?? daysAgoUtc(93);
4076
4098
  const minImpressions = params.minImpressions ?? 100;
4077
4099
  const minDays = 21;
4078
4100
  const metric = params.metric === "clicks" ? "clicks" : "impressions";
@@ -4321,7 +4343,7 @@ const survivalAnalyzer = defineAnalyzer({
4321
4343
  id: "survival",
4322
4344
  buildSql(params) {
4323
4345
  const endDate = params.endDate ?? defaultEndDate();
4324
- const startDate = params.startDate ?? daysAgo(183);
4346
+ const startDate = params.startDate ?? daysAgoUtc(183);
4325
4347
  const minImpressions = params.minImpressions ?? 5;
4326
4348
  return {
4327
4349
  sql: `
@@ -4466,7 +4488,7 @@ const survivalAnalyzer = defineAnalyzer({
4466
4488
  reduceSql(rows, params) {
4467
4489
  const arr = Array.isArray(rows) ? rows : [];
4468
4490
  const endDate = params.endDate ?? defaultEndDate();
4469
- const startDate = params.startDate ?? daysAgo(183);
4491
+ const startDate = params.startDate ?? daysAgoUtc(183);
4470
4492
  const windowDays = Math.round((new Date(endDate).getTime() - new Date(startDate).getTime()) / MS_PER_DAY) + 1;
4471
4493
  const results = arr.map((r) => {
4472
4494
  const curve = parseJsonRows(r.curveJson).map((p) => ({
package/dist/index.mjs CHANGED
@@ -5,7 +5,7 @@ import { METRIC_EXPR } from "@gscdump/engine/sql-fragments";
5
5
  import { num, num as num$1 } from "@gscdump/engine/analysis-types";
6
6
  import { err, ok, unwrapResult } from "gscdump/result";
7
7
  import { between, date, extractDateRange, gsc, page, query } from "gscdump/query";
8
- import { MS_PER_DAY, daysAgo, toIsoDate } from "gscdump";
8
+ import { MS_PER_DAY, daysAgoUtc, toIsoDate } from "gscdump/dates";
9
9
  import { buildExtrasQueries, buildTotalsSql, mergeExtras, pgResolverAdapter, resolveComparisonSQL, resolveToSQL, resolveToSQLOptimized } from "@gscdump/engine/resolver";
10
10
  import pluralize from "pluralize";
11
11
  import { ENGINE_QUERY_CAPABILITIES, createAttachedTableSource, createEngineQuerySource, queryComparisonRows, queryRows, rewriteForTableSource, runAnalyzerWithEngine, typedQuery } from "@gscdump/engine/source";
@@ -746,13 +746,25 @@ function analyzeBrandSegmentation(keywords, options) {
746
746
  const lowerBrandTerms = brandTerms.map((t) => t.toLowerCase());
747
747
  const brand = [];
748
748
  const nonBrand = [];
749
+ let brandClicks = 0;
750
+ let nonBrandClicks = 0;
751
+ let brandImpressions = 0;
752
+ let nonBrandImpressions = 0;
749
753
  for (const row of keywords) {
750
- if (num$1(row.impressions) < minImpressions) continue;
751
- if (lowerBrandTerms.some((term) => row.query.toLowerCase().includes(term))) brand.push(row);
752
- else nonBrand.push(row);
754
+ const impressions = num$1(row.impressions);
755
+ if (impressions < minImpressions) continue;
756
+ const clicks = num$1(row.clicks);
757
+ const query = row.query.toLowerCase();
758
+ if (lowerBrandTerms.some((term) => query.includes(term))) {
759
+ brand.push(row);
760
+ brandClicks += clicks;
761
+ brandImpressions += impressions;
762
+ } else {
763
+ nonBrand.push(row);
764
+ nonBrandClicks += clicks;
765
+ nonBrandImpressions += impressions;
766
+ }
753
767
  }
754
- const brandClicks = brand.reduce((sum, k) => sum + num$1(k.clicks), 0);
755
- const nonBrandClicks = nonBrand.reduce((sum, k) => sum + num$1(k.clicks), 0);
756
768
  const totalClicks = brandClicks + nonBrandClicks;
757
769
  return {
758
770
  brand,
@@ -761,8 +773,8 @@ function analyzeBrandSegmentation(keywords, options) {
761
773
  brandClicks,
762
774
  nonBrandClicks,
763
775
  brandShare: totalClicks > 0 ? brandClicks / totalClicks : 0,
764
- brandImpressions: brand.reduce((sum, k) => sum + num$1(k.impressions), 0),
765
- nonBrandImpressions: nonBrand.reduce((sum, k) => sum + num$1(k.impressions), 0)
776
+ brandImpressions,
777
+ nonBrandImpressions
766
778
  }
767
779
  };
768
780
  }
@@ -911,15 +923,24 @@ function analyzeCannibalization(rows, options = {}) {
911
923
  const results = [];
912
924
  for (const [query, pages] of queryMap) {
913
925
  if (pages.length < minPages) continue;
914
- pages.sort((a, b) => b.clicks - a.clicks);
915
- const positions = pages.map((p) => p.position);
916
- const positionSpread = Math.max(...positions) - Math.min(...positions);
926
+ let minPosition = Number.POSITIVE_INFINITY;
927
+ let maxPosition = Number.NEGATIVE_INFINITY;
928
+ let totalClicks = 0;
929
+ let totalImpressions = 0;
930
+ for (const page of pages) {
931
+ minPosition = Math.min(minPosition, page.position);
932
+ maxPosition = Math.max(maxPosition, page.position);
933
+ totalClicks += page.clicks;
934
+ totalImpressions += page.impressions;
935
+ }
936
+ const positionSpread = maxPosition - minPosition;
917
937
  if (positionSpread > maxPositionSpread) continue;
938
+ pages.sort((a, b) => b.clicks - a.clicks);
918
939
  results.push({
919
940
  query,
920
941
  pages,
921
- totalClicks: pages.reduce((sum, p) => sum + p.clicks, 0),
922
- totalImpressions: pages.reduce((sum, p) => sum + p.impressions, 0),
942
+ totalClicks,
943
+ totalImpressions,
923
944
  positionSpread
924
945
  });
925
946
  }
@@ -1138,7 +1159,7 @@ const changePointAnalyzer = defineAnalyzer$1({
1138
1159
  id: "change-point",
1139
1160
  buildSql(params) {
1140
1161
  const endDate = params.endDate ?? defaultEndDate();
1141
- const startDate = params.startDate ?? daysAgo(93);
1162
+ const startDate = params.startDate ?? daysAgoUtc(93);
1142
1163
  const minDays = 21;
1143
1164
  const minSide = 7;
1144
1165
  const threshold = params.threshold ?? 10;
@@ -1373,9 +1394,9 @@ function analyzeClustering(keywords, options = {}) {
1373
1394
  }
1374
1395
  }
1375
1396
  if (clusterBy === "prefix" || clusterBy === "both") {
1376
- const unclustered = filtered.filter((kw) => !clusteredKeywords.has(kw.query));
1377
1397
  const prefixMap = /* @__PURE__ */ new Map();
1378
- for (const kw of unclustered) {
1398
+ for (const kw of filtered) {
1399
+ if (clusteredKeywords.has(kw.query)) continue;
1379
1400
  const prefix = extractWordPrefix(kw.query);
1380
1401
  if (prefix) {
1381
1402
  const existing = prefixMap.get(prefix);
@@ -1388,23 +1409,29 @@ function analyzeClustering(keywords, options = {}) {
1388
1409
  type: "prefix",
1389
1410
  keywords: kws
1390
1411
  });
1391
- kws.forEach((kw) => clusteredKeywords.add(kw.query));
1412
+ for (const kw of kws) clusteredKeywords.add(kw.query);
1392
1413
  }
1393
1414
  }
1394
1415
  const clusters = [];
1395
1416
  for (const [name, data] of clusterMap) {
1396
1417
  if (data.keywords.length < minClusterSize) continue;
1397
- const totalClicks = data.keywords.reduce((sum, k) => sum + num$1(k.clicks), 0);
1398
- const totalImpressions = data.keywords.reduce((sum, k) => sum + num$1(k.impressions), 0);
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) => {
1418
+ let totalClicks = 0;
1419
+ let totalImpressions = 0;
1420
+ let weightedPositionSum = 0;
1421
+ let positionWeight = 0;
1422
+ let positionSum = 0;
1423
+ for (const k of data.keywords) {
1404
1424
  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;
1425
+ const position = num$1(k.position);
1426
+ totalClicks += num$1(k.clicks);
1427
+ totalImpressions += impressions;
1428
+ positionSum += position;
1429
+ if (impressions > 0) {
1430
+ weightedPositionSum += (position - 1) * impressions;
1431
+ positionWeight += impressions;
1432
+ }
1433
+ }
1434
+ const avgPosition = positionWeight > 0 ? weightedPositionSum / positionWeight + 1 : positionSum / data.keywords.length;
1408
1435
  clusters.push({
1409
1436
  clusterName: name,
1410
1437
  clusterType: data.type,
@@ -1527,19 +1554,6 @@ const clusteringAnalyzer = defineAnalyzer$1({
1527
1554
  };
1528
1555
  }
1529
1556
  });
1530
- function calculateGini(values) {
1531
- if (values.length === 0) return 0;
1532
- const sorted = [...values].sort((a, b) => a - b);
1533
- const n = sorted.length;
1534
- const sum = sorted.reduce((a, b) => a + b, 0);
1535
- if (sum === 0) return 0;
1536
- let weightedSum = 0;
1537
- for (let i = 0; i < n; i++) weightedSum += (2 * (i + 1) - n - 1) * sorted[i];
1538
- return weightedSum / (n * sum);
1539
- }
1540
- function calculateHHI(shares) {
1541
- return shares.reduce((sum, share) => sum + (share * 100) ** 2, 0);
1542
- }
1543
1557
  function analyzeConcentration(items, options = {}) {
1544
1558
  const { topN = 10 } = options;
1545
1559
  if (items.length === 0) return {
@@ -1552,11 +1566,18 @@ function analyzeConcentration(items, options = {}) {
1552
1566
  riskLevel: "low"
1553
1567
  };
1554
1568
  const sorted = [...items].sort((a, b) => b.clicks - a.clicks);
1555
- const totalClicks = sorted.reduce((sum, item) => sum + item.clicks, 0);
1556
- const clickValues = sorted.map((i) => i.clicks);
1557
- const shares = totalClicks > 0 ? sorted.map((i) => i.clicks / totalClicks) : [];
1558
- const giniCoefficient = calculateGini(clickValues);
1559
- const hhi = calculateHHI(shares);
1569
+ const n = sorted.length;
1570
+ let totalClicks = 0;
1571
+ let squaredClicks = 0;
1572
+ let weightedSum = 0;
1573
+ for (let i = 0; i < n; i++) {
1574
+ const clicks = sorted[i].clicks;
1575
+ totalClicks += clicks;
1576
+ squaredClicks += clicks * clicks;
1577
+ weightedSum += (n - 2 * i - 1) * clicks;
1578
+ }
1579
+ const giniCoefficient = totalClicks === 0 ? 0 : weightedSum / (n * totalClicks);
1580
+ const hhi = totalClicks > 0 ? squaredClicks / (totalClicks * totalClicks) * 1e4 : 0;
1560
1581
  const topNItems = sorted.slice(0, topN).map((item) => ({
1561
1582
  key: item.key,
1562
1583
  clicks: item.clicks,
@@ -1790,7 +1811,7 @@ const ctrAnomalyAnalyzer = defineAnalyzer$1({
1790
1811
  id: "ctr-anomaly",
1791
1812
  buildSql(params) {
1792
1813
  const endDate = params.endDate ?? defaultEndDate();
1793
- const startDate = params.startDate ?? daysAgo(93);
1814
+ const startDate = params.startDate ?? daysAgoUtc(93);
1794
1815
  const minDailyImpressions = params.minImpressions ?? 5;
1795
1816
  const minRollingN = 14;
1796
1817
  const zThreshold = params.threshold ?? 2;
@@ -3304,7 +3325,7 @@ const intentAtlasAnalyzer = defineAnalyzer$1({
3304
3325
  id: "intent-atlas",
3305
3326
  buildSql(params) {
3306
3327
  const endDate = params.endDate ?? defaultEndDate();
3307
- const startDate = params.startDate ?? daysAgo(90);
3328
+ const startDate = params.startDate ?? daysAgoUtc(90);
3308
3329
  const minQueryImpressions = params.minImpressions ?? 20;
3309
3330
  const minClusterSize = params.minClusterSize ?? 3;
3310
3331
  const minTokenImpressions = 50;
@@ -3528,22 +3549,23 @@ const keywordBreadthAnalyzer = defineAnalyzer$1({
3528
3549
  }
3529
3550
  });
3530
3551
  function downsampleLogRank(points) {
3531
- const all = points.map((p) => ({
3552
+ const toPoint = (p) => ({
3532
3553
  rank: num$1(p.rank),
3533
3554
  impressions: num$1(p.impressions),
3534
3555
  clicks: num$1(p.clicks),
3535
3556
  query: rowString(p.query)
3536
- }));
3537
- if (all.length <= 80) return all;
3538
- const top = all.slice(0, 10);
3539
- const rest = all.slice(10);
3540
- const stepped = [];
3557
+ });
3558
+ if (points.length <= 80) return points.map(toPoint);
3559
+ const sampled = points.slice(0, 10).map(toPoint);
3541
3560
  let nextThreshold = 1.15;
3542
- for (const p of rest) if (p.rank >= nextThreshold) {
3543
- stepped.push(p);
3544
- nextThreshold *= 1.15;
3561
+ for (let i = 10; i < points.length; i++) {
3562
+ const point = points[i];
3563
+ if (num$1(point.rank) >= nextThreshold) {
3564
+ sampled.push(toPoint(point));
3565
+ nextThreshold *= 1.15;
3566
+ }
3545
3567
  }
3546
- return [...top, ...stepped];
3568
+ return sampled;
3547
3569
  }
3548
3570
  const longTailAnalyzer = defineAnalyzer$1({
3549
3571
  id: "long-tail",
@@ -4632,7 +4654,7 @@ const stlDecomposeAnalyzer = defineAnalyzer$1({
4632
4654
  id: "stl-decompose",
4633
4655
  buildSql(params) {
4634
4656
  const endDate = params.endDate ?? defaultEndDate();
4635
- const startDate = params.startDate ?? daysAgo(93);
4657
+ const startDate = params.startDate ?? daysAgoUtc(93);
4636
4658
  const minImpressions = params.minImpressions ?? 100;
4637
4659
  const minDays = 21;
4638
4660
  const metric = params.metric === "clicks" ? "clicks" : "impressions";
@@ -4881,7 +4903,7 @@ const survivalAnalyzer = defineAnalyzer$1({
4881
4903
  id: "survival",
4882
4904
  buildSql(params) {
4883
4905
  const endDate = params.endDate ?? defaultEndDate();
4884
- const startDate = params.startDate ?? daysAgo(183);
4906
+ const startDate = params.startDate ?? daysAgoUtc(183);
4885
4907
  const minImpressions = params.minImpressions ?? 5;
4886
4908
  return {
4887
4909
  sql: `
@@ -5026,7 +5048,7 @@ const survivalAnalyzer = defineAnalyzer$1({
5026
5048
  reduceSql(rows, params) {
5027
5049
  const arr = Array.isArray(rows) ? rows : [];
5028
5050
  const endDate = params.endDate ?? defaultEndDate();
5029
- const startDate = params.startDate ?? daysAgo(183);
5051
+ const startDate = params.startDate ?? daysAgoUtc(183);
5030
5052
  const windowDays = Math.round((new Date(endDate).getTime() - new Date(startDate).getTime()) / MS_PER_DAY) + 1;
5031
5053
  const results = arr.map((r) => {
5032
5054
  const curve = parseJsonRows(r.curveJson).map((p) => ({
@@ -1,4 +1,30 @@
1
- import { AnalysisQuerySource } from "@gscdump/engine/source";
1
+ import { AnalysisQuerySource, QueryRow } from "@gscdump/engine/source";
2
+ type ContentGapDevice = 'webgpu' | 'wasm';
3
+ type ContentGapEmbeddingRole = 'query' | 'passage';
4
+ type ContentGapExtractor = (texts: string[], opts: {
5
+ pooling: 'mean';
6
+ normalize: boolean;
7
+ }) => Promise<{
8
+ data: Float32Array;
9
+ dims: number[];
10
+ }>;
11
+ interface EmbeddedContentGapTexts {
12
+ vectors: Float32Array[];
13
+ hits: number;
14
+ misses: number;
15
+ }
16
+ interface ContentGapEmbeddingCache {
17
+ getMany: (role: ContentGapEmbeddingRole, texts: string[]) => Promise<Map<string, Float32Array>>;
18
+ putMany: (role: ContentGapEmbeddingRole, entries: Array<[string, Float32Array]>) => Promise<void>;
19
+ }
20
+ declare function createMemoryContentGapCache(): ContentGapEmbeddingCache;
21
+ interface ContentGapEmbeddingRuntime {
22
+ modelId: string;
23
+ queryPrefix: string;
24
+ selectDevice: (requested?: ContentGapDevice) => Promise<ContentGapDevice>;
25
+ loadExtractor: (device: ContentGapDevice) => Promise<ContentGapExtractor>;
26
+ embed: (extractor: ContentGapExtractor, role: ContentGapEmbeddingRole, texts: string[], transform: (text: string) => string, onProgress: (done: number, total: number) => void) => Promise<EmbeddedContentGapTexts>;
27
+ }
2
28
  interface ContentGapQueryCandidate {
3
29
  query: string;
4
30
  impressions: number;
@@ -6,6 +32,18 @@ interface ContentGapQueryCandidate {
6
32
  avgPosition: number;
7
33
  currentUrl: string;
8
34
  }
35
+ interface ContentGapInputOptions {
36
+ maxQueries: number;
37
+ maxUrls: number;
38
+ minImpressions: number;
39
+ }
40
+ type ExecuteSql = (sql: string, params?: unknown[]) => Promise<QueryRow[]>;
41
+ type NormalizeUrl = (url: string) => string;
42
+ declare function fetchContentGapInputs(executeSql: ExecuteSql, options: ContentGapInputOptions, normalizeUrl: NormalizeUrl, now?: () => number): Promise<{
43
+ queries: ContentGapQueryCandidate[];
44
+ urls: string[];
45
+ sqlMs: number;
46
+ }>;
9
47
  interface ContentGapResult {
10
48
  query: string;
11
49
  impressions: number;
@@ -62,9 +100,18 @@ interface ContentGapAnalysis {
62
100
  modelId: string;
63
101
  };
64
102
  }
103
+ interface ContentGapAnalyzer {
104
+ analyze: (source: AnalysisQuerySource, opts?: ContentGapOptions) => Promise<ContentGapAnalysis>;
105
+ }
106
+ interface CreateContentGapAnalyzerOptions {
107
+ embeddings?: ContentGapEmbeddingRuntime;
108
+ loadInputs?: typeof fetchContentGapInputs;
109
+ now?: () => number;
110
+ }
65
111
  declare function normalizeUrl(u: string): string;
66
112
  declare function deriveUrlText(url: string): string;
67
113
  declare function cosineNormalized(a: Float32Array, b: Float32Array): number;
68
114
  declare function rankContentGaps(queries: ContentGapQueryCandidate[], urls: string[], queryEmbeddings: Float32Array[], urlEmbeddings: Float32Array[], minDivergence: number): ContentGapResult[];
115
+ declare function createContentGapAnalyzer(opts?: CreateContentGapAnalyzerOptions): ContentGapAnalyzer;
69
116
  declare function analyzeContentGap(source: AnalysisQuerySource, opts?: ContentGapOptions): Promise<ContentGapAnalysis>;
70
- export { type ContentGapAnalysis, type ContentGapOptions, type ContentGapProgress, type ContentGapResult, ContentGapSourceUnsupportedError, analyzeContentGap, cosineNormalized, deriveUrlText, normalizeUrl, rankContentGaps };
117
+ export { type ContentGapAnalysis, type ContentGapAnalyzer, type ContentGapEmbeddingCache, type ContentGapEmbeddingRuntime, type ContentGapOptions, type ContentGapProgress, type ContentGapResult, ContentGapSourceUnsupportedError, type CreateContentGapAnalyzerOptions, analyzeContentGap, cosineNormalized, createContentGapAnalyzer, createMemoryContentGapCache, deriveUrlText, normalizeUrl, rankContentGaps };
@@ -2,59 +2,78 @@ const CONTENT_GAP_MODEL_ID = "Xenova/bge-base-en-v1.5";
2
2
  const CONTENT_GAP_QUERY_PREFIX = "Represent this sentence for searching relevant passages: ";
3
3
  const DB_NAME = "content-gap-embeddings";
4
4
  const STORE = "vectors";
5
- let dbPromise = null;
6
- function openDb() {
7
- if (dbPromise != null) return dbPromise;
8
- dbPromise = new Promise((resolve, reject) => {
9
- const req = indexedDB.open(DB_NAME, 1);
10
- req.onupgradeneeded = () => {
11
- const db = req.result;
12
- if (!db.objectStoreNames.contains(STORE)) db.createObjectStore(STORE);
13
- };
14
- req.onsuccess = () => resolve(req.result);
15
- req.onerror = () => reject(req.error ?? /* @__PURE__ */ new Error("indexedDB open failed"));
16
- });
17
- return dbPromise;
18
- }
19
5
  function cacheKey(role, text) {
20
6
  return `${CONTENT_GAP_MODEL_ID}|${role}|${text}`;
21
7
  }
22
- async function cacheGetMany(role, texts) {
23
- const db = await openDb().catch(() => null);
24
- if (db == null) return /* @__PURE__ */ new Map();
25
- return new Promise((resolve) => {
26
- const store = db.transaction(STORE, "readonly").objectStore(STORE);
27
- const out = /* @__PURE__ */ new Map();
28
- let pending = texts.length;
29
- if (pending === 0) {
30
- resolve(out);
31
- return;
32
- }
33
- for (const t of texts) {
34
- const req = store.get(cacheKey(role, t));
35
- req.onsuccess = () => {
36
- const v = req.result;
37
- if (v instanceof Float32Array) out.set(t, v);
38
- pending -= 1;
39
- if (pending === 0) resolve(out);
40
- };
41
- req.onerror = () => {
42
- pending -= 1;
43
- if (pending === 0) resolve(out);
8
+ function createIndexedDbContentGapCache() {
9
+ let dbPromise = null;
10
+ function openDb() {
11
+ if (dbPromise != null) return dbPromise;
12
+ dbPromise = new Promise((resolve, reject) => {
13
+ const req = indexedDB.open(DB_NAME, 1);
14
+ req.onupgradeneeded = () => {
15
+ const db = req.result;
16
+ if (!db.objectStoreNames.contains(STORE)) db.createObjectStore(STORE);
44
17
  };
18
+ req.onsuccess = () => resolve(req.result);
19
+ req.onerror = () => reject(req.error ?? /* @__PURE__ */ new Error("indexedDB open failed"));
20
+ });
21
+ return dbPromise;
22
+ }
23
+ return {
24
+ async getMany(role, texts) {
25
+ const db = await openDb().catch(() => null);
26
+ if (db == null) return /* @__PURE__ */ new Map();
27
+ return new Promise((resolve) => {
28
+ const store = db.transaction(STORE, "readonly").objectStore(STORE);
29
+ const out = /* @__PURE__ */ new Map();
30
+ let pending = texts.length;
31
+ if (pending === 0) {
32
+ resolve(out);
33
+ return;
34
+ }
35
+ for (const text of texts) {
36
+ const req = store.get(cacheKey(role, text));
37
+ req.onsuccess = () => {
38
+ if (req.result instanceof Float32Array) out.set(text, req.result);
39
+ pending -= 1;
40
+ if (pending === 0) resolve(out);
41
+ };
42
+ req.onerror = () => {
43
+ pending -= 1;
44
+ if (pending === 0) resolve(out);
45
+ };
46
+ }
47
+ });
48
+ },
49
+ async putMany(role, entries) {
50
+ const db = await openDb().catch(() => null);
51
+ if (db == null) return;
52
+ await new Promise((resolve) => {
53
+ const tx = db.transaction(STORE, "readwrite");
54
+ for (const [text, vector] of entries) tx.objectStore(STORE).put(vector, cacheKey(role, text));
55
+ tx.oncomplete = () => resolve();
56
+ tx.onerror = () => resolve();
57
+ tx.onabort = () => resolve();
58
+ });
45
59
  }
46
- });
60
+ };
47
61
  }
48
- async function cachePutMany(role, entries) {
49
- const db = await openDb().catch(() => null);
50
- if (db == null) return;
51
- await new Promise((resolve) => {
52
- const tx = db.transaction(STORE, "readwrite");
53
- for (const [text, vec] of entries) tx.objectStore(STORE).put(vec, cacheKey(role, text));
54
- tx.oncomplete = () => resolve();
55
- tx.onerror = () => resolve();
56
- tx.onabort = () => resolve();
57
- });
62
+ function createMemoryContentGapCache() {
63
+ const values = /* @__PURE__ */ new Map();
64
+ return {
65
+ async getMany(role, texts) {
66
+ const out = /* @__PURE__ */ new Map();
67
+ for (const text of texts) {
68
+ const value = values.get(cacheKey(role, text));
69
+ if (value) out.set(text, value);
70
+ }
71
+ return out;
72
+ },
73
+ async putMany(role, entries) {
74
+ for (const [text, vector] of entries) values.set(cacheKey(role, text), vector);
75
+ }
76
+ };
58
77
  }
59
78
  async function embedRawBatch(extractor, texts, onProgress, batchSize = 32) {
60
79
  const result = [];
@@ -91,8 +110,8 @@ async function loadContentGapExtractor(device) {
91
110
  dtype: "fp32"
92
111
  });
93
112
  }
94
- async function embedContentGapTexts(extractor, role, texts, transform, onProgress) {
95
- const cached = await cacheGetMany(role, texts);
113
+ async function embedContentGapTexts(extractor, role, texts, transform, onProgress, cache) {
114
+ const cached = await cache.getMany(role, texts);
96
115
  const vectors = Array.from({ length: texts.length });
97
116
  const missIdx = [];
98
117
  const missTexts = [];
@@ -117,7 +136,7 @@ async function embedContentGapTexts(extractor, role, texts, transform, onProgres
117
136
  vectors[i] = embedded[m];
118
137
  toPersist.push([texts[i], embedded[m]]);
119
138
  }
120
- await cachePutMany(role, toPersist);
139
+ await cache.putMany(role, toPersist);
121
140
  }
122
141
  return {
123
142
  vectors,
@@ -125,8 +144,19 @@ async function embedContentGapTexts(extractor, role, texts, transform, onProgres
125
144
  misses
126
145
  };
127
146
  }
128
- async function fetchContentGapInputs(executeSql, options, normalizeUrl) {
129
- const t1 = performance.now();
147
+ function createContentGapEmbeddingRuntime(cache = createIndexedDbContentGapCache()) {
148
+ return {
149
+ modelId: CONTENT_GAP_MODEL_ID,
150
+ queryPrefix: CONTENT_GAP_QUERY_PREFIX,
151
+ selectDevice: selectContentGapDevice,
152
+ loadExtractor: loadContentGapExtractor,
153
+ embed(extractor, role, texts, transform, onProgress) {
154
+ return embedContentGapTexts(extractor, role, texts, transform, onProgress, cache);
155
+ }
156
+ };
157
+ }
158
+ async function fetchContentGapInputs(executeSql, options, normalizeUrl, now = () => performance.now()) {
159
+ const t1 = now();
130
160
  const queryRows = await executeSql(`
131
161
  WITH query_totals AS (
132
162
  SELECT query,
@@ -164,7 +194,7 @@ async function fetchContentGapInputs(executeSql, options, normalizeUrl) {
164
194
  ORDER BY impressions DESC
165
195
  LIMIT ?
166
196
  `, [Number(options.maxUrls)]);
167
- const sqlMs = performance.now() - t1;
197
+ const sqlMs = now() - t1;
168
198
  const queries = queryRows.map((row) => ({
169
199
  query: String(row.query),
170
200
  impressions: Number(row.impressions),
@@ -237,15 +267,25 @@ function rankContentGaps(queries, urls, queryEmbeddings, urlEmbeddings, minDiver
237
267
  const qr = queries[i];
238
268
  const qEmb = queryEmbeddings[i];
239
269
  const currentIdx = urlIndex.get(qr.currentUrl);
240
- const scored = Array.from({ length: urls.length });
241
- for (let j = 0; j < urls.length; j++) scored[j] = {
242
- url: urls[j],
243
- similarity: cosineNormalized(qEmb, urlEmbeddings[j])
244
- };
245
- scored.sort((a, b) => b.similarity - a.similarity);
246
- const currentSimilarity = currentIdx != null ? cosineNormalized(qEmb, urlEmbeddings[currentIdx]) : 0;
247
- const suggestedUrl = scored[0].url;
248
- const suggestedSimilarity = scored[0].similarity;
270
+ const top = [];
271
+ let currentSimilarity = 0;
272
+ for (let j = 0; j < urls.length; j++) {
273
+ const similarity = cosineNormalized(qEmb, urlEmbeddings[j]);
274
+ if (j === currentIdx) currentSimilarity = similarity;
275
+ let insertAt = top.length;
276
+ while (insertAt > 0 && similarity > top[insertAt - 1].similarity) insertAt--;
277
+ if (insertAt < 4) {
278
+ top.splice(insertAt, 0, {
279
+ url: urls[j],
280
+ similarity
281
+ });
282
+ if (top.length > 4) top.pop();
283
+ }
284
+ }
285
+ const suggested = top[0];
286
+ if (!suggested) continue;
287
+ const suggestedUrl = suggested.url;
288
+ const suggestedSimilarity = suggested.similarity;
249
289
  if (suggestedUrl === qr.currentUrl) continue;
250
290
  const divergence = suggestedSimilarity - currentSimilarity;
251
291
  if (divergence < minDivergence) continue;
@@ -258,7 +298,7 @@ function rankContentGaps(queries, urls, queryEmbeddings, urlEmbeddings, minDiver
258
298
  currentSimilarity,
259
299
  suggestedUrl,
260
300
  suggestedSimilarity,
261
- alternatives: scored.slice(1, 4),
301
+ alternatives: top.slice(1),
262
302
  divergence,
263
303
  impact: qr.impressions * divergence
264
304
  });
@@ -266,7 +306,7 @@ function rankContentGaps(queries, urls, queryEmbeddings, urlEmbeddings, minDiver
266
306
  gaps.sort((a, b) => b.impact - a.impact);
267
307
  return gaps;
268
308
  }
269
- async function analyzeContentGap(source, opts = {}) {
309
+ async function runContentGapAnalysis(source, opts = {}, runtime) {
270
310
  if (!source.executeSql) throw new ContentGapSourceUnsupportedError(source.name ?? "unknown");
271
311
  const executeSql = source.executeSql.bind(source);
272
312
  const { maxQueries = 1500, maxUrls = 400, minImpressions = 50, minDivergence = .12, device, onProgress } = opts;
@@ -274,24 +314,24 @@ async function analyzeContentGap(source, opts = {}) {
274
314
  phase: "loading-model",
275
315
  message: "Checking device..."
276
316
  });
277
- const t0 = performance.now();
278
- const chosenDevice = await selectContentGapDevice(device);
317
+ const t0 = runtime.now();
318
+ const chosenDevice = await runtime.embeddings.selectDevice(device);
279
319
  notify(onProgress, {
280
320
  phase: "loading-model",
281
- message: `Loading ${CONTENT_GAP_MODEL_ID} on ${chosenDevice} (~110MB, cached after first run)...`
321
+ message: `Loading ${runtime.embeddings.modelId} on ${chosenDevice} (~110MB, cached after first run)...`
282
322
  });
283
- const extractor = await loadContentGapExtractor(chosenDevice);
284
- const modelMs = performance.now() - t0;
323
+ const extractor = await runtime.embeddings.loadExtractor(chosenDevice);
324
+ const modelMs = runtime.now() - t0;
285
325
  notify(onProgress, {
286
326
  phase: "fetching-data",
287
327
  message: `Running SQL (device: ${chosenDevice})...`,
288
328
  modelMs
289
329
  });
290
- const { queries, urls, sqlMs } = await fetchContentGapInputs(executeSql, {
330
+ const { queries, urls, sqlMs } = await runtime.loadInputs(executeSql, {
291
331
  maxQueries,
292
332
  maxUrls,
293
333
  minImpressions
294
- }, normalizeUrl);
334
+ }, normalizeUrl, runtime.now);
295
335
  if (queries.length === 0 || urls.length === 0) {
296
336
  notify(onProgress, {
297
337
  phase: "done",
@@ -309,7 +349,7 @@ async function analyzeContentGap(source, opts = {}) {
309
349
  cacheHits: 0,
310
350
  totalInputs: 0,
311
351
  device: chosenDevice,
312
- modelId: CONTENT_GAP_MODEL_ID
352
+ modelId: runtime.embeddings.modelId
313
353
  }
314
354
  };
315
355
  }
@@ -323,8 +363,8 @@ async function analyzeContentGap(source, opts = {}) {
323
363
  modelMs,
324
364
  sqlMs
325
365
  });
326
- const t2 = performance.now();
327
- const queryEmbed = await embedContentGapTexts(extractor, "query", queryTexts, (t) => CONTENT_GAP_QUERY_PREFIX + t, (done, total) => {
366
+ const t2 = runtime.now();
367
+ const queryEmbed = await runtime.embeddings.embed(extractor, "query", queryTexts, (t) => runtime.embeddings.queryPrefix + t, (done, total) => {
328
368
  notify(onProgress, {
329
369
  phase: "embedding-queries",
330
370
  message: `Embedding ${queryTexts.length} queries on ${chosenDevice}...`,
@@ -342,7 +382,7 @@ async function analyzeContentGap(source, opts = {}) {
342
382
  modelMs,
343
383
  sqlMs
344
384
  });
345
- const urlEmbed = await embedContentGapTexts(extractor, "passage", urlTexts, (t) => t, (done, total) => {
385
+ const urlEmbed = await runtime.embeddings.embed(extractor, "passage", urlTexts, (t) => t, (done, total) => {
346
386
  notify(onProgress, {
347
387
  phase: "embedding-urls",
348
388
  message: `Embedding ${urls.length} URLs...`,
@@ -352,7 +392,7 @@ async function analyzeContentGap(source, opts = {}) {
352
392
  sqlMs
353
393
  });
354
394
  });
355
- const embedMs = performance.now() - t2;
395
+ const embedMs = runtime.now() - t2;
356
396
  notify(onProgress, {
357
397
  phase: "computing-gaps",
358
398
  message: "Computing semantic similarities...",
@@ -360,9 +400,9 @@ async function analyzeContentGap(source, opts = {}) {
360
400
  sqlMs,
361
401
  embedMs
362
402
  });
363
- const t3 = performance.now();
403
+ const t3 = runtime.now();
364
404
  const gaps = rankContentGaps(queries, urls, queryEmbed.vectors, urlEmbed.vectors, minDivergence);
365
- const computeMs = performance.now() - t3;
405
+ const computeMs = runtime.now() - t3;
366
406
  const totalHits = queryEmbed.hits + urlEmbed.hits;
367
407
  const totalInputs = queryTexts.length + urls.length;
368
408
  const cacheNote = totalHits > 0 ? ` · ${totalHits}/${totalInputs} cache hits` : "";
@@ -384,8 +424,21 @@ async function analyzeContentGap(source, opts = {}) {
384
424
  cacheHits: totalHits,
385
425
  totalInputs,
386
426
  device: chosenDevice,
387
- modelId: CONTENT_GAP_MODEL_ID
427
+ modelId: runtime.embeddings.modelId
388
428
  }
389
429
  };
390
430
  }
391
- export { ContentGapSourceUnsupportedError, analyzeContentGap, cosineNormalized, deriveUrlText, normalizeUrl, rankContentGaps };
431
+ function createContentGapAnalyzer(opts = {}) {
432
+ const runtime = {
433
+ embeddings: opts.embeddings ?? createContentGapEmbeddingRuntime(),
434
+ loadInputs: opts.loadInputs ?? fetchContentGapInputs,
435
+ now: opts.now ?? (() => performance.now())
436
+ };
437
+ return { analyze(source, options) {
438
+ return runContentGapAnalysis(source, options, runtime);
439
+ } };
440
+ }
441
+ function analyzeContentGap(source, opts = {}) {
442
+ return createContentGapAnalyzer().analyze(source, opts);
443
+ }
444
+ export { ContentGapSourceUnsupportedError, analyzeContentGap, cosineNormalized, createContentGapAnalyzer, createMemoryContentGapCache, deriveUrlText, normalizeUrl, rankContentGaps };
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@gscdump/analysis",
3
3
  "type": "module",
4
- "version": "0.37.2",
4
+ "version": "0.37.4",
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-gsc-api": "0.37.2",
80
- "gscdump": "0.37.2",
81
- "@gscdump/engine": "0.37.2"
79
+ "@gscdump/engine": "0.37.4",
80
+ "gscdump": "0.37.4",
81
+ "@gscdump/engine-gsc-api": "0.37.4"
82
82
  },
83
83
  "devDependencies": {
84
84
  "vitest": "^4.1.9"