@gscdump/analysis 0.37.3 → 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.
- package/dist/analyzer/index.mjs +82 -60
- package/dist/default-registry.mjs +82 -60
- package/dist/index.mjs +82 -60
- package/dist/semantic/index.d.mts +49 -2
- package/dist/semantic/index.mjs +133 -80
- package/package.json +4 -4
package/dist/analyzer/index.mjs
CHANGED
|
@@ -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,
|
|
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
|
-
|
|
519
|
-
if (
|
|
520
|
-
|
|
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
|
|
533
|
-
nonBrandImpressions
|
|
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
|
-
|
|
683
|
-
|
|
684
|
-
|
|
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
|
|
690
|
-
totalImpressions
|
|
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 ??
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
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
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
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
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
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 ??
|
|
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 ??
|
|
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
|
|
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 (
|
|
2980
|
-
const
|
|
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 (
|
|
2985
|
-
|
|
2986
|
-
nextThreshold
|
|
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
|
|
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 ??
|
|
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 ??
|
|
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 ??
|
|
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,
|
|
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
|
-
|
|
519
|
-
if (
|
|
520
|
-
|
|
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
|
|
533
|
-
nonBrandImpressions
|
|
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
|
-
|
|
683
|
-
|
|
684
|
-
|
|
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
|
|
690
|
-
totalImpressions
|
|
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 ??
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
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
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
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
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
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 ??
|
|
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 ??
|
|
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
|
|
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 (
|
|
2974
|
-
const
|
|
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 (
|
|
2979
|
-
|
|
2980
|
-
nextThreshold
|
|
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
|
|
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 ??
|
|
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 ??
|
|
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 ??
|
|
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,
|
|
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
|
-
|
|
751
|
-
if (
|
|
752
|
-
|
|
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
|
|
765
|
-
nonBrandImpressions
|
|
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
|
-
|
|
915
|
-
|
|
916
|
-
|
|
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
|
|
922
|
-
totalImpressions
|
|
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 ??
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
1398
|
-
|
|
1399
|
-
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
|
|
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
|
-
|
|
1406
|
-
|
|
1407
|
-
|
|
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
|
|
1556
|
-
|
|
1557
|
-
|
|
1558
|
-
|
|
1559
|
-
|
|
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 ??
|
|
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 ??
|
|
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
|
|
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 (
|
|
3538
|
-
const
|
|
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 (
|
|
3543
|
-
|
|
3544
|
-
|
|
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
|
|
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 ??
|
|
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 ??
|
|
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 ??
|
|
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 };
|
package/dist/semantic/index.mjs
CHANGED
|
@@ -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
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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
|
-
|
|
49
|
-
const
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
129
|
-
|
|
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 =
|
|
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
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
similarity
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
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:
|
|
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
|
|
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 =
|
|
278
|
-
const chosenDevice = await
|
|
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 ${
|
|
321
|
+
message: `Loading ${runtime.embeddings.modelId} on ${chosenDevice} (~110MB, cached after first run)...`
|
|
282
322
|
});
|
|
283
|
-
const extractor = await
|
|
284
|
-
const modelMs =
|
|
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
|
|
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:
|
|
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 =
|
|
327
|
-
const queryEmbed = await
|
|
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
|
|
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 =
|
|
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 =
|
|
403
|
+
const t3 = runtime.now();
|
|
364
404
|
const gaps = rankContentGaps(queries, urls, queryEmbed.vectors, urlEmbed.vectors, minDivergence);
|
|
365
|
-
const computeMs =
|
|
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:
|
|
427
|
+
modelId: runtime.embeddings.modelId
|
|
388
428
|
}
|
|
389
429
|
};
|
|
390
430
|
}
|
|
391
|
-
|
|
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.
|
|
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": "0.37.
|
|
80
|
-
"
|
|
81
|
-
"gscdump": "0.37.
|
|
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"
|