@gscdump/analysis 0.38.1 → 0.39.0
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 +89 -37
- package/dist/default-registry.mjs +89 -37
- package/dist/index.mjs +115 -43
- package/package.json +4 -4
package/dist/analyzer/index.mjs
CHANGED
|
@@ -6,7 +6,7 @@ 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
8
|
import { MS_PER_DAY, daysAgoUtc, toIsoDate } from "gscdump/dates";
|
|
9
|
-
import { buildExtrasQueries, buildTotalsSql, mergeExtras, resolveComparisonSQL,
|
|
9
|
+
import { buildExtrasQueries, buildTotalsSql, mergeExtras, resolveComparisonSQL, resolveToSQLOptimized } from "@gscdump/engine/resolver";
|
|
10
10
|
function rowNumber(value) {
|
|
11
11
|
if (typeof value === "number") return Number.isFinite(value) ? value : 0;
|
|
12
12
|
if (typeof value === "bigint") return Number(value);
|
|
@@ -665,7 +665,7 @@ function createMetricSorter(defaultMetric, orderByMetric) {
|
|
|
665
665
|
return [...items].sort((a, b) => (a[sortBy] - b[sortBy]) * mult);
|
|
666
666
|
};
|
|
667
667
|
}
|
|
668
|
-
const sortRowResults
|
|
668
|
+
const sortRowResults = createSorter((item, metric) => {
|
|
669
669
|
switch (metric) {
|
|
670
670
|
case "clicks": return item.totalClicks;
|
|
671
671
|
case "impressions": return item.totalImpressions;
|
|
@@ -712,7 +712,7 @@ function analyzeCannibalization(rows, options = {}) {
|
|
|
712
712
|
positionSpread
|
|
713
713
|
});
|
|
714
714
|
}
|
|
715
|
-
return sortRowResults
|
|
715
|
+
return sortRowResults(results, sortBy, sortOrder);
|
|
716
716
|
}
|
|
717
717
|
const cannibalizationAnalyzer = defineAnalyzer({
|
|
718
718
|
id: "cannibalization",
|
|
@@ -2250,14 +2250,9 @@ function shapeDataQueryRows(rows, params, extras) {
|
|
|
2250
2250
|
function buildDataDetailPlan(params, options) {
|
|
2251
2251
|
const state = requireBuilderState(params.q, "data-detail");
|
|
2252
2252
|
if (!state.dimensions.includes("date")) throw new Error("data-detail: `date` dimension is required");
|
|
2253
|
-
const main =
|
|
2254
|
-
const totals = buildTotalsSql(state, options);
|
|
2253
|
+
const main = resolveToSQLOptimized(state, options);
|
|
2255
2254
|
const prev = optionalBuilderState(params.qc, "data-detail", "qc");
|
|
2256
|
-
const extraQueries = [
|
|
2257
|
-
name: "totals",
|
|
2258
|
-
sql: totals.sql,
|
|
2259
|
-
params: totals.params
|
|
2260
|
-
}];
|
|
2255
|
+
const extraQueries = [];
|
|
2261
2256
|
if (prev) {
|
|
2262
2257
|
const previousTotals = buildTotalsSql(prev, options);
|
|
2263
2258
|
extraQueries.push({
|
|
@@ -2275,18 +2270,22 @@ function buildDataDetailPlan(params, options) {
|
|
|
2275
2270
|
}
|
|
2276
2271
|
function shapeDataDetailRows(rows, params, extras) {
|
|
2277
2272
|
const { startDate: rangeStart, endDate: rangeEnd } = extractDateRange(requireBuilderState(params.q, "data-detail").filter);
|
|
2278
|
-
const
|
|
2273
|
+
const first = rows[0];
|
|
2274
|
+
const totals = {
|
|
2275
|
+
clicks: Number(first?.totalClicks ?? 0),
|
|
2276
|
+
impressions: Number(first?.totalImpressions ?? 0),
|
|
2277
|
+
ctr: Number(first?.totalCtr ?? 0),
|
|
2278
|
+
position: Number(first?.totalPosition ?? 0)
|
|
2279
|
+
};
|
|
2280
|
+
const coerced = rows.map((raw) => {
|
|
2281
|
+
const { totalCount: _tc, totalClicks: _tclk, totalImpressions: _timp, totalCtr: _tctr, totalPosition: _tpos, sum_position: _sp, ...rest } = raw;
|
|
2282
|
+
return coerceNumericCols(rest);
|
|
2283
|
+
});
|
|
2279
2284
|
const daily = rangeStart && rangeEnd ? padTimeseries(coerced, {
|
|
2280
2285
|
startDate: rangeStart,
|
|
2281
2286
|
endDate: rangeEnd
|
|
2282
2287
|
}) : coerced;
|
|
2283
|
-
const
|
|
2284
|
-
const meta = { totals: {
|
|
2285
|
-
clicks: Number(totalsRow.clicks ?? 0),
|
|
2286
|
-
impressions: Number(totalsRow.impressions ?? 0),
|
|
2287
|
-
ctr: Number(totalsRow.ctr ?? 0),
|
|
2288
|
-
position: Number(totalsRow.position ?? 0)
|
|
2289
|
-
} };
|
|
2288
|
+
const meta = { totals };
|
|
2290
2289
|
if (extras?.prevTotals) {
|
|
2291
2290
|
const previousTotalsRow = extras.prevTotals[0] ?? {};
|
|
2292
2291
|
meta.previousTotals = {
|
|
@@ -2403,13 +2402,75 @@ function paginateInMemory(rows, input) {
|
|
|
2403
2402
|
const o = clampOffset(input.offset);
|
|
2404
2403
|
return rows.slice(o, o + l);
|
|
2405
2404
|
}
|
|
2405
|
+
function paginateSortedInMemory(rows, input, compare) {
|
|
2406
|
+
const limit = clampLimit(input.limit, rows.length);
|
|
2407
|
+
const offset = clampOffset(input.offset);
|
|
2408
|
+
const selectedCount = Math.min(rows.length, offset + limit);
|
|
2409
|
+
if (limit === 0 || offset >= rows.length || selectedCount === 0) return [];
|
|
2410
|
+
const fullSort = () => [...rows].sort(compare).slice(offset, offset + limit);
|
|
2411
|
+
if (selectedCount >= rows.length / 2) return fullSort();
|
|
2412
|
+
let invalidComparison = false;
|
|
2413
|
+
const compareStable = (left, right) => {
|
|
2414
|
+
const order = compare(left.value, right.value);
|
|
2415
|
+
if (Number.isNaN(order)) {
|
|
2416
|
+
invalidComparison = true;
|
|
2417
|
+
return left.index - right.index;
|
|
2418
|
+
}
|
|
2419
|
+
return order || left.index - right.index;
|
|
2420
|
+
};
|
|
2421
|
+
const isWorse = (left, right) => compareStable(left, right) > 0;
|
|
2422
|
+
const heap = [];
|
|
2423
|
+
const siftUp = (start) => {
|
|
2424
|
+
let index = start;
|
|
2425
|
+
while (index > 0) {
|
|
2426
|
+
const parent = index - 1 >>> 1;
|
|
2427
|
+
if (!isWorse(heap[index], heap[parent])) break;
|
|
2428
|
+
const swap = heap[parent];
|
|
2429
|
+
heap[parent] = heap[index];
|
|
2430
|
+
heap[index] = swap;
|
|
2431
|
+
index = parent;
|
|
2432
|
+
}
|
|
2433
|
+
};
|
|
2434
|
+
const siftDown = () => {
|
|
2435
|
+
let index = 0;
|
|
2436
|
+
for (;;) {
|
|
2437
|
+
const left = index * 2 + 1;
|
|
2438
|
+
if (left >= heap.length) return;
|
|
2439
|
+
const right = left + 1;
|
|
2440
|
+
let worse = left;
|
|
2441
|
+
if (right < heap.length && isWorse(heap[right], heap[left])) worse = right;
|
|
2442
|
+
if (!isWorse(heap[worse], heap[index])) return;
|
|
2443
|
+
const swap = heap[index];
|
|
2444
|
+
heap[index] = heap[worse];
|
|
2445
|
+
heap[worse] = swap;
|
|
2446
|
+
index = worse;
|
|
2447
|
+
}
|
|
2448
|
+
};
|
|
2449
|
+
for (let index = 0; index < rows.length; index++) {
|
|
2450
|
+
const candidate = {
|
|
2451
|
+
value: rows[index],
|
|
2452
|
+
index
|
|
2453
|
+
};
|
|
2454
|
+
if (heap.length < selectedCount) {
|
|
2455
|
+
heap.push(candidate);
|
|
2456
|
+
siftUp(heap.length - 1);
|
|
2457
|
+
} else if (compareStable(candidate, heap[0]) < 0) {
|
|
2458
|
+
heap[0] = candidate;
|
|
2459
|
+
siftDown();
|
|
2460
|
+
}
|
|
2461
|
+
}
|
|
2462
|
+
if (invalidComparison) return fullSort();
|
|
2463
|
+
heap.sort(compareStable);
|
|
2464
|
+
if (invalidComparison) return fullSort();
|
|
2465
|
+
return heap.slice(offset, offset + limit).map((entry) => entry.value);
|
|
2466
|
+
}
|
|
2406
2467
|
function resolveSort(input, allowed, defaults) {
|
|
2407
2468
|
return {
|
|
2408
2469
|
sortBy: input.sortBy && allowed.includes(input.sortBy) ? input.sortBy : defaults.sortBy,
|
|
2409
2470
|
sortDir: input.sortDir === "asc" || input.sortDir === "desc" ? input.sortDir : defaults.sortDir
|
|
2410
2471
|
};
|
|
2411
2472
|
}
|
|
2412
|
-
const sortResults
|
|
2473
|
+
const sortResults = createMetricSorter("lostClicks", {
|
|
2413
2474
|
lostClicks: "desc",
|
|
2414
2475
|
declinePercent: "desc",
|
|
2415
2476
|
currentClicks: "asc"
|
|
@@ -2442,7 +2503,7 @@ function analyzeDecay(input, options = {}) {
|
|
|
2442
2503
|
positionDrop: currentPosition != null ? currentPosition - prev.position : null
|
|
2443
2504
|
});
|
|
2444
2505
|
}
|
|
2445
|
-
return sortResults
|
|
2506
|
+
return sortResults(results, sortBy);
|
|
2446
2507
|
}
|
|
2447
2508
|
const decayAnalyzer = defineAnalyzer({
|
|
2448
2509
|
id: "decay",
|
|
@@ -3441,12 +3502,6 @@ function calculateCtrGapScore(actualCtr, position) {
|
|
|
3441
3502
|
const gap = expectedCtr - actualCtr;
|
|
3442
3503
|
return Math.min(gap / expectedCtr, 1);
|
|
3443
3504
|
}
|
|
3444
|
-
const sortResults = createMetricSorter("opportunityScore", {
|
|
3445
|
-
opportunityScore: "desc",
|
|
3446
|
-
potentialClicks: "desc",
|
|
3447
|
-
impressions: "desc",
|
|
3448
|
-
position: "asc"
|
|
3449
|
-
});
|
|
3450
3505
|
const opportunityAnalyzer = defineAnalyzer({
|
|
3451
3506
|
id: "opportunity",
|
|
3452
3507
|
buildSql(params) {
|
|
@@ -3596,15 +3651,14 @@ const opportunityAnalyzer = defineAnalyzer({
|
|
|
3596
3651
|
}
|
|
3597
3652
|
});
|
|
3598
3653
|
}
|
|
3599
|
-
const
|
|
3600
|
-
const paged = paginateInMemory(sorted, {
|
|
3654
|
+
const paged = paginateSortedInMemory(results, {
|
|
3601
3655
|
limit: params.limit,
|
|
3602
3656
|
offset: params.offset
|
|
3603
|
-
});
|
|
3657
|
+
}, (left, right) => right[sortBy] - left[sortBy]);
|
|
3604
3658
|
return {
|
|
3605
3659
|
results: paged,
|
|
3606
3660
|
meta: {
|
|
3607
|
-
total:
|
|
3661
|
+
total: results.length,
|
|
3608
3662
|
returned: paged.length
|
|
3609
3663
|
}
|
|
3610
3664
|
};
|
|
@@ -4306,11 +4360,10 @@ const strikingDistanceAnalyzer = defineAnalyzer({
|
|
|
4306
4360
|
id: "striking-distance",
|
|
4307
4361
|
reduce(rows, params) {
|
|
4308
4362
|
const results = filterStrikingDistance(Array.isArray(rows) ? rows : [], params);
|
|
4309
|
-
|
|
4310
|
-
const paged = paginateInMemory(results, {
|
|
4363
|
+
const paged = paginateSortedInMemory(results, {
|
|
4311
4364
|
limit: params.limit ?? 1e3,
|
|
4312
4365
|
offset: params.offset
|
|
4313
|
-
});
|
|
4366
|
+
}, (left, right) => right.potentialClicks - left.potentialClicks);
|
|
4314
4367
|
return {
|
|
4315
4368
|
results: paged,
|
|
4316
4369
|
meta: {
|
|
@@ -4686,7 +4739,6 @@ const trendsAnalyzer = defineAnalyzer({
|
|
|
4686
4739
|
}
|
|
4687
4740
|
});
|
|
4688
4741
|
const DEFAULT_ROW_LIMIT = 25e3;
|
|
4689
|
-
const sortRowResults = createSorter((item) => item.impressions, "impressions");
|
|
4690
4742
|
const zeroClickAnalyzer = defineAnalyzer({
|
|
4691
4743
|
id: "zero-click",
|
|
4692
4744
|
buildSql(params) {
|
|
@@ -4789,11 +4841,11 @@ const zeroClickAnalyzer = defineAnalyzer({
|
|
|
4789
4841
|
position: row.position
|
|
4790
4842
|
});
|
|
4791
4843
|
}
|
|
4792
|
-
const results =
|
|
4793
|
-
const paged =
|
|
4844
|
+
const results = Array.from(queryMap.values());
|
|
4845
|
+
const paged = paginateSortedInMemory(results, {
|
|
4794
4846
|
limit: params.limit,
|
|
4795
4847
|
offset: params.offset
|
|
4796
|
-
});
|
|
4848
|
+
}, (left, right) => right.impressions - left.impressions);
|
|
4797
4849
|
return {
|
|
4798
4850
|
results: paged,
|
|
4799
4851
|
meta: {
|
|
@@ -6,7 +6,7 @@ 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
8
|
import { MS_PER_DAY, daysAgoUtc, toIsoDate } from "gscdump/dates";
|
|
9
|
-
import { buildExtrasQueries, buildTotalsSql, mergeExtras, resolveComparisonSQL,
|
|
9
|
+
import { buildExtrasQueries, buildTotalsSql, mergeExtras, resolveComparisonSQL, resolveToSQLOptimized } from "@gscdump/engine/resolver";
|
|
10
10
|
function rowNumber(value) {
|
|
11
11
|
if (typeof value === "number") return Number.isFinite(value) ? value : 0;
|
|
12
12
|
if (typeof value === "bigint") return Number(value);
|
|
@@ -665,7 +665,7 @@ function createMetricSorter(defaultMetric, orderByMetric) {
|
|
|
665
665
|
return [...items].sort((a, b) => (a[sortBy] - b[sortBy]) * mult);
|
|
666
666
|
};
|
|
667
667
|
}
|
|
668
|
-
const sortRowResults
|
|
668
|
+
const sortRowResults = createSorter((item, metric) => {
|
|
669
669
|
switch (metric) {
|
|
670
670
|
case "clicks": return item.totalClicks;
|
|
671
671
|
case "impressions": return item.totalImpressions;
|
|
@@ -712,7 +712,7 @@ function analyzeCannibalization(rows, options = {}) {
|
|
|
712
712
|
positionSpread
|
|
713
713
|
});
|
|
714
714
|
}
|
|
715
|
-
return sortRowResults
|
|
715
|
+
return sortRowResults(results, sortBy, sortOrder);
|
|
716
716
|
}
|
|
717
717
|
const cannibalizationAnalyzer = defineAnalyzer({
|
|
718
718
|
id: "cannibalization",
|
|
@@ -2250,14 +2250,9 @@ function shapeDataQueryRows(rows, params, extras) {
|
|
|
2250
2250
|
function buildDataDetailPlan(params, options) {
|
|
2251
2251
|
const state = requireBuilderState(params.q, "data-detail");
|
|
2252
2252
|
if (!state.dimensions.includes("date")) throw new Error("data-detail: `date` dimension is required");
|
|
2253
|
-
const main =
|
|
2254
|
-
const totals = buildTotalsSql(state, options);
|
|
2253
|
+
const main = resolveToSQLOptimized(state, options);
|
|
2255
2254
|
const prev = optionalBuilderState(params.qc, "data-detail", "qc");
|
|
2256
|
-
const extraQueries = [
|
|
2257
|
-
name: "totals",
|
|
2258
|
-
sql: totals.sql,
|
|
2259
|
-
params: totals.params
|
|
2260
|
-
}];
|
|
2255
|
+
const extraQueries = [];
|
|
2261
2256
|
if (prev) {
|
|
2262
2257
|
const previousTotals = buildTotalsSql(prev, options);
|
|
2263
2258
|
extraQueries.push({
|
|
@@ -2275,18 +2270,22 @@ function buildDataDetailPlan(params, options) {
|
|
|
2275
2270
|
}
|
|
2276
2271
|
function shapeDataDetailRows(rows, params, extras) {
|
|
2277
2272
|
const { startDate: rangeStart, endDate: rangeEnd } = extractDateRange(requireBuilderState(params.q, "data-detail").filter);
|
|
2278
|
-
const
|
|
2273
|
+
const first = rows[0];
|
|
2274
|
+
const totals = {
|
|
2275
|
+
clicks: Number(first?.totalClicks ?? 0),
|
|
2276
|
+
impressions: Number(first?.totalImpressions ?? 0),
|
|
2277
|
+
ctr: Number(first?.totalCtr ?? 0),
|
|
2278
|
+
position: Number(first?.totalPosition ?? 0)
|
|
2279
|
+
};
|
|
2280
|
+
const coerced = rows.map((raw) => {
|
|
2281
|
+
const { totalCount: _tc, totalClicks: _tclk, totalImpressions: _timp, totalCtr: _tctr, totalPosition: _tpos, sum_position: _sp, ...rest } = raw;
|
|
2282
|
+
return coerceNumericCols(rest);
|
|
2283
|
+
});
|
|
2279
2284
|
const daily = rangeStart && rangeEnd ? padTimeseries(coerced, {
|
|
2280
2285
|
startDate: rangeStart,
|
|
2281
2286
|
endDate: rangeEnd
|
|
2282
2287
|
}) : coerced;
|
|
2283
|
-
const
|
|
2284
|
-
const meta = { totals: {
|
|
2285
|
-
clicks: Number(totalsRow.clicks ?? 0),
|
|
2286
|
-
impressions: Number(totalsRow.impressions ?? 0),
|
|
2287
|
-
ctr: Number(totalsRow.ctr ?? 0),
|
|
2288
|
-
position: Number(totalsRow.position ?? 0)
|
|
2289
|
-
} };
|
|
2288
|
+
const meta = { totals };
|
|
2290
2289
|
if (extras?.prevTotals) {
|
|
2291
2290
|
const previousTotalsRow = extras.prevTotals[0] ?? {};
|
|
2292
2291
|
meta.previousTotals = {
|
|
@@ -2403,7 +2402,69 @@ function paginateInMemory(rows, input) {
|
|
|
2403
2402
|
const o = clampOffset(input.offset);
|
|
2404
2403
|
return rows.slice(o, o + l);
|
|
2405
2404
|
}
|
|
2406
|
-
|
|
2405
|
+
function paginateSortedInMemory(rows, input, compare) {
|
|
2406
|
+
const limit = clampLimit(input.limit, rows.length);
|
|
2407
|
+
const offset = clampOffset(input.offset);
|
|
2408
|
+
const selectedCount = Math.min(rows.length, offset + limit);
|
|
2409
|
+
if (limit === 0 || offset >= rows.length || selectedCount === 0) return [];
|
|
2410
|
+
const fullSort = () => [...rows].sort(compare).slice(offset, offset + limit);
|
|
2411
|
+
if (selectedCount >= rows.length / 2) return fullSort();
|
|
2412
|
+
let invalidComparison = false;
|
|
2413
|
+
const compareStable = (left, right) => {
|
|
2414
|
+
const order = compare(left.value, right.value);
|
|
2415
|
+
if (Number.isNaN(order)) {
|
|
2416
|
+
invalidComparison = true;
|
|
2417
|
+
return left.index - right.index;
|
|
2418
|
+
}
|
|
2419
|
+
return order || left.index - right.index;
|
|
2420
|
+
};
|
|
2421
|
+
const isWorse = (left, right) => compareStable(left, right) > 0;
|
|
2422
|
+
const heap = [];
|
|
2423
|
+
const siftUp = (start) => {
|
|
2424
|
+
let index = start;
|
|
2425
|
+
while (index > 0) {
|
|
2426
|
+
const parent = index - 1 >>> 1;
|
|
2427
|
+
if (!isWorse(heap[index], heap[parent])) break;
|
|
2428
|
+
const swap = heap[parent];
|
|
2429
|
+
heap[parent] = heap[index];
|
|
2430
|
+
heap[index] = swap;
|
|
2431
|
+
index = parent;
|
|
2432
|
+
}
|
|
2433
|
+
};
|
|
2434
|
+
const siftDown = () => {
|
|
2435
|
+
let index = 0;
|
|
2436
|
+
for (;;) {
|
|
2437
|
+
const left = index * 2 + 1;
|
|
2438
|
+
if (left >= heap.length) return;
|
|
2439
|
+
const right = left + 1;
|
|
2440
|
+
let worse = left;
|
|
2441
|
+
if (right < heap.length && isWorse(heap[right], heap[left])) worse = right;
|
|
2442
|
+
if (!isWorse(heap[worse], heap[index])) return;
|
|
2443
|
+
const swap = heap[index];
|
|
2444
|
+
heap[index] = heap[worse];
|
|
2445
|
+
heap[worse] = swap;
|
|
2446
|
+
index = worse;
|
|
2447
|
+
}
|
|
2448
|
+
};
|
|
2449
|
+
for (let index = 0; index < rows.length; index++) {
|
|
2450
|
+
const candidate = {
|
|
2451
|
+
value: rows[index],
|
|
2452
|
+
index
|
|
2453
|
+
};
|
|
2454
|
+
if (heap.length < selectedCount) {
|
|
2455
|
+
heap.push(candidate);
|
|
2456
|
+
siftUp(heap.length - 1);
|
|
2457
|
+
} else if (compareStable(candidate, heap[0]) < 0) {
|
|
2458
|
+
heap[0] = candidate;
|
|
2459
|
+
siftDown();
|
|
2460
|
+
}
|
|
2461
|
+
}
|
|
2462
|
+
if (invalidComparison) return fullSort();
|
|
2463
|
+
heap.sort(compareStable);
|
|
2464
|
+
if (invalidComparison) return fullSort();
|
|
2465
|
+
return heap.slice(offset, offset + limit).map((entry) => entry.value);
|
|
2466
|
+
}
|
|
2467
|
+
const sortResults = createMetricSorter("lostClicks", {
|
|
2407
2468
|
lostClicks: "desc",
|
|
2408
2469
|
declinePercent: "desc",
|
|
2409
2470
|
currentClicks: "asc"
|
|
@@ -2436,7 +2497,7 @@ function analyzeDecay(input, options = {}) {
|
|
|
2436
2497
|
positionDrop: currentPosition != null ? currentPosition - prev.position : null
|
|
2437
2498
|
});
|
|
2438
2499
|
}
|
|
2439
|
-
return sortResults
|
|
2500
|
+
return sortResults(results, sortBy);
|
|
2440
2501
|
}
|
|
2441
2502
|
const decayAnalyzer = defineAnalyzer({
|
|
2442
2503
|
id: "decay",
|
|
@@ -3435,12 +3496,6 @@ function calculateCtrGapScore(actualCtr, position) {
|
|
|
3435
3496
|
const gap = expectedCtr - actualCtr;
|
|
3436
3497
|
return Math.min(gap / expectedCtr, 1);
|
|
3437
3498
|
}
|
|
3438
|
-
const sortResults = createMetricSorter("opportunityScore", {
|
|
3439
|
-
opportunityScore: "desc",
|
|
3440
|
-
potentialClicks: "desc",
|
|
3441
|
-
impressions: "desc",
|
|
3442
|
-
position: "asc"
|
|
3443
|
-
});
|
|
3444
3499
|
const opportunityAnalyzer = defineAnalyzer({
|
|
3445
3500
|
id: "opportunity",
|
|
3446
3501
|
buildSql(params) {
|
|
@@ -3590,15 +3645,14 @@ const opportunityAnalyzer = defineAnalyzer({
|
|
|
3590
3645
|
}
|
|
3591
3646
|
});
|
|
3592
3647
|
}
|
|
3593
|
-
const
|
|
3594
|
-
const paged = paginateInMemory(sorted, {
|
|
3648
|
+
const paged = paginateSortedInMemory(results, {
|
|
3595
3649
|
limit: params.limit,
|
|
3596
3650
|
offset: params.offset
|
|
3597
|
-
});
|
|
3651
|
+
}, (left, right) => right[sortBy] - left[sortBy]);
|
|
3598
3652
|
return {
|
|
3599
3653
|
results: paged,
|
|
3600
3654
|
meta: {
|
|
3601
|
-
total:
|
|
3655
|
+
total: results.length,
|
|
3602
3656
|
returned: paged.length
|
|
3603
3657
|
}
|
|
3604
3658
|
};
|
|
@@ -4300,11 +4354,10 @@ const strikingDistanceAnalyzer = defineAnalyzer({
|
|
|
4300
4354
|
id: "striking-distance",
|
|
4301
4355
|
reduce(rows, params) {
|
|
4302
4356
|
const results = filterStrikingDistance(Array.isArray(rows) ? rows : [], params);
|
|
4303
|
-
|
|
4304
|
-
const paged = paginateInMemory(results, {
|
|
4357
|
+
const paged = paginateSortedInMemory(results, {
|
|
4305
4358
|
limit: params.limit ?? 1e3,
|
|
4306
4359
|
offset: params.offset
|
|
4307
|
-
});
|
|
4360
|
+
}, (left, right) => right.potentialClicks - left.potentialClicks);
|
|
4308
4361
|
return {
|
|
4309
4362
|
results: paged,
|
|
4310
4363
|
meta: {
|
|
@@ -4680,7 +4733,6 @@ const trendsAnalyzer = defineAnalyzer({
|
|
|
4680
4733
|
}
|
|
4681
4734
|
});
|
|
4682
4735
|
const DEFAULT_ROW_LIMIT = 25e3;
|
|
4683
|
-
const sortRowResults = createSorter((item) => item.impressions, "impressions");
|
|
4684
4736
|
const defaultAnalyzerRegistry = createAnalyzerRegistry({ defined: [
|
|
4685
4737
|
bayesianCtrAnalyzer,
|
|
4686
4738
|
bipartitePagerankAnalyzer,
|
|
@@ -4812,11 +4864,11 @@ const defaultAnalyzerRegistry = createAnalyzerRegistry({ defined: [
|
|
|
4812
4864
|
position: row.position
|
|
4813
4865
|
});
|
|
4814
4866
|
}
|
|
4815
|
-
const results =
|
|
4816
|
-
const paged =
|
|
4867
|
+
const results = Array.from(queryMap.values());
|
|
4868
|
+
const paged = paginateSortedInMemory(results, {
|
|
4817
4869
|
limit: params.limit,
|
|
4818
4870
|
offset: params.offset
|
|
4819
|
-
});
|
|
4871
|
+
}, (left, right) => right.impressions - left.impressions);
|
|
4820
4872
|
return {
|
|
4821
4873
|
results: paged,
|
|
4822
4874
|
meta: {
|
package/dist/index.mjs
CHANGED
|
@@ -6,7 +6,7 @@ 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
8
|
import { MS_PER_DAY, daysAgoUtc, toIsoDate } from "gscdump/dates";
|
|
9
|
-
import { buildExtrasQueries, buildTotalsSql, mergeExtras, pgResolverAdapter, resolveComparisonSQL,
|
|
9
|
+
import { buildExtrasQueries, buildTotalsSql, mergeExtras, pgResolverAdapter, resolveComparisonSQL, 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";
|
|
12
12
|
import { computeInputHash, createReportRegistry, defineReport } from "@gscdump/engine/report";
|
|
@@ -897,7 +897,7 @@ function createMetricSorter(defaultMetric, orderByMetric) {
|
|
|
897
897
|
return [...items].sort((a, b) => (a[sortBy] - b[sortBy]) * mult);
|
|
898
898
|
};
|
|
899
899
|
}
|
|
900
|
-
const sortRowResults
|
|
900
|
+
const sortRowResults = createSorter((item, metric) => {
|
|
901
901
|
switch (metric) {
|
|
902
902
|
case "clicks": return item.totalClicks;
|
|
903
903
|
case "impressions": return item.totalImpressions;
|
|
@@ -944,7 +944,7 @@ function analyzeCannibalization(rows, options = {}) {
|
|
|
944
944
|
positionSpread
|
|
945
945
|
});
|
|
946
946
|
}
|
|
947
|
-
return sortRowResults
|
|
947
|
+
return sortRowResults(results, sortBy, sortOrder);
|
|
948
948
|
}
|
|
949
949
|
const cannibalizationAnalyzer = defineAnalyzer$1({
|
|
950
950
|
id: "cannibalization",
|
|
@@ -2482,14 +2482,9 @@ function shapeDataQueryRows(rows, params, extras) {
|
|
|
2482
2482
|
function buildDataDetailPlan(params, options) {
|
|
2483
2483
|
const state = requireBuilderState(params.q, "data-detail");
|
|
2484
2484
|
if (!state.dimensions.includes("date")) throw new Error("data-detail: `date` dimension is required");
|
|
2485
|
-
const main =
|
|
2486
|
-
const totals = buildTotalsSql(state, options);
|
|
2485
|
+
const main = resolveToSQLOptimized(state, options);
|
|
2487
2486
|
const prev = optionalBuilderState(params.qc, "data-detail", "qc");
|
|
2488
|
-
const extraQueries = [
|
|
2489
|
-
name: "totals",
|
|
2490
|
-
sql: totals.sql,
|
|
2491
|
-
params: totals.params
|
|
2492
|
-
}];
|
|
2487
|
+
const extraQueries = [];
|
|
2493
2488
|
if (prev) {
|
|
2494
2489
|
const previousTotals = buildTotalsSql(prev, options);
|
|
2495
2490
|
extraQueries.push({
|
|
@@ -2507,18 +2502,22 @@ function buildDataDetailPlan(params, options) {
|
|
|
2507
2502
|
}
|
|
2508
2503
|
function shapeDataDetailRows(rows, params, extras) {
|
|
2509
2504
|
const { startDate: rangeStart, endDate: rangeEnd } = extractDateRange(requireBuilderState(params.q, "data-detail").filter);
|
|
2510
|
-
const
|
|
2505
|
+
const first = rows[0];
|
|
2506
|
+
const totals = {
|
|
2507
|
+
clicks: Number(first?.totalClicks ?? 0),
|
|
2508
|
+
impressions: Number(first?.totalImpressions ?? 0),
|
|
2509
|
+
ctr: Number(first?.totalCtr ?? 0),
|
|
2510
|
+
position: Number(first?.totalPosition ?? 0)
|
|
2511
|
+
};
|
|
2512
|
+
const coerced = rows.map((raw) => {
|
|
2513
|
+
const { totalCount: _tc, totalClicks: _tclk, totalImpressions: _timp, totalCtr: _tctr, totalPosition: _tpos, sum_position: _sp, ...rest } = raw;
|
|
2514
|
+
return coerceNumericCols(rest);
|
|
2515
|
+
});
|
|
2511
2516
|
const daily = rangeStart && rangeEnd ? padTimeseries$1(coerced, {
|
|
2512
2517
|
startDate: rangeStart,
|
|
2513
2518
|
endDate: rangeEnd
|
|
2514
2519
|
}) : coerced;
|
|
2515
|
-
const
|
|
2516
|
-
const meta = { totals: {
|
|
2517
|
-
clicks: Number(totalsRow.clicks ?? 0),
|
|
2518
|
-
impressions: Number(totalsRow.impressions ?? 0),
|
|
2519
|
-
ctr: Number(totalsRow.ctr ?? 0),
|
|
2520
|
-
position: Number(totalsRow.position ?? 0)
|
|
2521
|
-
} };
|
|
2520
|
+
const meta = { totals };
|
|
2522
2521
|
if (extras?.prevTotals) {
|
|
2523
2522
|
const previousTotalsRow = extras.prevTotals[0] ?? {};
|
|
2524
2523
|
meta.previousTotals = {
|
|
@@ -2647,10 +2646,12 @@ const SEPARATOR_RE$1 = /[-_/.@#:+]+/g;
|
|
|
2647
2646
|
const WHITESPACE_RE$1 = /\s+/g;
|
|
2648
2647
|
const DIACRITICS_RE$1 = /\p{Diacritic}/gu;
|
|
2649
2648
|
function tokenize(query) {
|
|
2650
|
-
|
|
2649
|
+
let index = 0;
|
|
2650
|
+
while (index < query.length && query.charCodeAt(index) <= 127) index++;
|
|
2651
|
+
const text = (index === query.length ? query : query.normalize("NFKD").replace(DIACRITICS_RE$1, "")).toLowerCase().replace(SEPARATOR_RE$1, " ").replace(WHITESPACE_RE$1, " ").trim();
|
|
2651
2652
|
return {
|
|
2652
2653
|
text,
|
|
2653
|
-
tokens: text.split(" ")
|
|
2654
|
+
tokens: text.length === 0 ? [] : text.split(" ")
|
|
2654
2655
|
};
|
|
2655
2656
|
}
|
|
2656
2657
|
function classifyQueryIntent(query) {
|
|
@@ -2816,15 +2817,27 @@ const NO_STRIP_S = /* @__PURE__ */ new Set([
|
|
|
2816
2817
|
"nervous",
|
|
2817
2818
|
"conscious"
|
|
2818
2819
|
]);
|
|
2820
|
+
const DEPLURALIZED_CACHE_MAX = 4096;
|
|
2821
|
+
const depluralizedCache = /* @__PURE__ */ new Map();
|
|
2819
2822
|
function depluralize(token) {
|
|
2820
2823
|
if (token.length <= 3 || NO_STRIP_S.has(token) || token.endsWith("sis")) return token;
|
|
2821
|
-
|
|
2824
|
+
const last = token.charCodeAt(token.length - 1);
|
|
2825
|
+
if (last < 97 || last > 122) return token;
|
|
2826
|
+
const cached = depluralizedCache.get(token);
|
|
2827
|
+
if (cached !== void 0) return cached;
|
|
2828
|
+
const singular = pluralize.singular(token);
|
|
2829
|
+
if (depluralizedCache.size >= DEPLURALIZED_CACHE_MAX) depluralizedCache.clear();
|
|
2830
|
+
depluralizedCache.set(token, singular);
|
|
2831
|
+
return singular;
|
|
2822
2832
|
}
|
|
2823
2833
|
const SEPARATOR_RE = /[-_/.@#:+]+/g;
|
|
2824
2834
|
const WHITESPACE_RE = /\s+/g;
|
|
2825
2835
|
const DIACRITICS_RE = /\p{Diacritic}/gu;
|
|
2826
2836
|
const NORMALIZER_VERSION = 2;
|
|
2827
2837
|
function foldUnicode(s) {
|
|
2838
|
+
let index = 0;
|
|
2839
|
+
while (index < s.length && s.charCodeAt(index) <= 127) index++;
|
|
2840
|
+
if (index === s.length) return s;
|
|
2828
2841
|
return s.normalize("NFKD").replace(DIACRITICS_RE, "");
|
|
2829
2842
|
}
|
|
2830
2843
|
const DIRECTIONAL_CONNECTORS = /* @__PURE__ */ new Set([
|
|
@@ -2860,9 +2873,15 @@ function isOrderSensitive(tokens) {
|
|
|
2860
2873
|
return false;
|
|
2861
2874
|
}
|
|
2862
2875
|
function normalizeQuery(query) {
|
|
2863
|
-
const
|
|
2864
|
-
|
|
2865
|
-
const
|
|
2876
|
+
const normalized = foldUnicode(query).toLowerCase().replace(SEPARATOR_RE, " ").replace(WHITESPACE_RE, " ").trim();
|
|
2877
|
+
if (normalized.length === 0) return "";
|
|
2878
|
+
const cleaned = normalized.split(" ");
|
|
2879
|
+
const mapped = [];
|
|
2880
|
+
for (const token of cleaned) {
|
|
2881
|
+
const synonym = SYNONYMS[token] ?? token;
|
|
2882
|
+
if (synonym) mapped.push(depluralize(synonym));
|
|
2883
|
+
}
|
|
2884
|
+
const tokens = mapped.length > 0 ? mapped : cleaned.map(depluralize);
|
|
2866
2885
|
return (isOrderSensitive(tokens) ? tokens : tokens.sort()).join(" ");
|
|
2867
2886
|
}
|
|
2868
2887
|
const dataDetailAnalyzer = defineAnalyzer$1({
|
|
@@ -2967,7 +2986,69 @@ function paginateInMemory(rows, input) {
|
|
|
2967
2986
|
const o = clampOffset(input.offset);
|
|
2968
2987
|
return rows.slice(o, o + l);
|
|
2969
2988
|
}
|
|
2970
|
-
|
|
2989
|
+
function paginateSortedInMemory(rows, input, compare) {
|
|
2990
|
+
const limit = clampLimit(input.limit, rows.length);
|
|
2991
|
+
const offset = clampOffset(input.offset);
|
|
2992
|
+
const selectedCount = Math.min(rows.length, offset + limit);
|
|
2993
|
+
if (limit === 0 || offset >= rows.length || selectedCount === 0) return [];
|
|
2994
|
+
const fullSort = () => [...rows].sort(compare).slice(offset, offset + limit);
|
|
2995
|
+
if (selectedCount >= rows.length / 2) return fullSort();
|
|
2996
|
+
let invalidComparison = false;
|
|
2997
|
+
const compareStable = (left, right) => {
|
|
2998
|
+
const order = compare(left.value, right.value);
|
|
2999
|
+
if (Number.isNaN(order)) {
|
|
3000
|
+
invalidComparison = true;
|
|
3001
|
+
return left.index - right.index;
|
|
3002
|
+
}
|
|
3003
|
+
return order || left.index - right.index;
|
|
3004
|
+
};
|
|
3005
|
+
const isWorse = (left, right) => compareStable(left, right) > 0;
|
|
3006
|
+
const heap = [];
|
|
3007
|
+
const siftUp = (start) => {
|
|
3008
|
+
let index = start;
|
|
3009
|
+
while (index > 0) {
|
|
3010
|
+
const parent = index - 1 >>> 1;
|
|
3011
|
+
if (!isWorse(heap[index], heap[parent])) break;
|
|
3012
|
+
const swap = heap[parent];
|
|
3013
|
+
heap[parent] = heap[index];
|
|
3014
|
+
heap[index] = swap;
|
|
3015
|
+
index = parent;
|
|
3016
|
+
}
|
|
3017
|
+
};
|
|
3018
|
+
const siftDown = () => {
|
|
3019
|
+
let index = 0;
|
|
3020
|
+
for (;;) {
|
|
3021
|
+
const left = index * 2 + 1;
|
|
3022
|
+
if (left >= heap.length) return;
|
|
3023
|
+
const right = left + 1;
|
|
3024
|
+
let worse = left;
|
|
3025
|
+
if (right < heap.length && isWorse(heap[right], heap[left])) worse = right;
|
|
3026
|
+
if (!isWorse(heap[worse], heap[index])) return;
|
|
3027
|
+
const swap = heap[index];
|
|
3028
|
+
heap[index] = heap[worse];
|
|
3029
|
+
heap[worse] = swap;
|
|
3030
|
+
index = worse;
|
|
3031
|
+
}
|
|
3032
|
+
};
|
|
3033
|
+
for (let index = 0; index < rows.length; index++) {
|
|
3034
|
+
const candidate = {
|
|
3035
|
+
value: rows[index],
|
|
3036
|
+
index
|
|
3037
|
+
};
|
|
3038
|
+
if (heap.length < selectedCount) {
|
|
3039
|
+
heap.push(candidate);
|
|
3040
|
+
siftUp(heap.length - 1);
|
|
3041
|
+
} else if (compareStable(candidate, heap[0]) < 0) {
|
|
3042
|
+
heap[0] = candidate;
|
|
3043
|
+
siftDown();
|
|
3044
|
+
}
|
|
3045
|
+
}
|
|
3046
|
+
if (invalidComparison) return fullSort();
|
|
3047
|
+
heap.sort(compareStable);
|
|
3048
|
+
if (invalidComparison) return fullSort();
|
|
3049
|
+
return heap.slice(offset, offset + limit).map((entry) => entry.value);
|
|
3050
|
+
}
|
|
3051
|
+
const sortResults = createMetricSorter("lostClicks", {
|
|
2971
3052
|
lostClicks: "desc",
|
|
2972
3053
|
declinePercent: "desc",
|
|
2973
3054
|
currentClicks: "asc"
|
|
@@ -3000,7 +3081,7 @@ function analyzeDecay(input, options = {}) {
|
|
|
3000
3081
|
positionDrop: currentPosition != null ? currentPosition - prev.position : null
|
|
3001
3082
|
});
|
|
3002
3083
|
}
|
|
3003
|
-
return sortResults
|
|
3084
|
+
return sortResults(results, sortBy);
|
|
3004
3085
|
}
|
|
3005
3086
|
const decayAnalyzer = defineAnalyzer$1({
|
|
3006
3087
|
id: "decay",
|
|
@@ -3995,12 +4076,6 @@ function calculateCtrGapScore(actualCtr, position) {
|
|
|
3995
4076
|
const gap = expectedCtr - actualCtr;
|
|
3996
4077
|
return Math.min(gap / expectedCtr, 1);
|
|
3997
4078
|
}
|
|
3998
|
-
const sortResults = createMetricSorter("opportunityScore", {
|
|
3999
|
-
opportunityScore: "desc",
|
|
4000
|
-
potentialClicks: "desc",
|
|
4001
|
-
impressions: "desc",
|
|
4002
|
-
position: "asc"
|
|
4003
|
-
});
|
|
4004
4079
|
const opportunityAnalyzer = defineAnalyzer$1({
|
|
4005
4080
|
id: "opportunity",
|
|
4006
4081
|
buildSql(params) {
|
|
@@ -4150,15 +4225,14 @@ const opportunityAnalyzer = defineAnalyzer$1({
|
|
|
4150
4225
|
}
|
|
4151
4226
|
});
|
|
4152
4227
|
}
|
|
4153
|
-
const
|
|
4154
|
-
const paged = paginateInMemory(sorted, {
|
|
4228
|
+
const paged = paginateSortedInMemory(results, {
|
|
4155
4229
|
limit: params.limit,
|
|
4156
4230
|
offset: params.offset
|
|
4157
|
-
});
|
|
4231
|
+
}, (left, right) => right[sortBy] - left[sortBy]);
|
|
4158
4232
|
return {
|
|
4159
4233
|
results: paged,
|
|
4160
4234
|
meta: {
|
|
4161
|
-
total:
|
|
4235
|
+
total: results.length,
|
|
4162
4236
|
returned: paged.length
|
|
4163
4237
|
}
|
|
4164
4238
|
};
|
|
@@ -4860,11 +4934,10 @@ const strikingDistanceAnalyzer = defineAnalyzer$1({
|
|
|
4860
4934
|
id: "striking-distance",
|
|
4861
4935
|
reduce(rows, params) {
|
|
4862
4936
|
const results = filterStrikingDistance(Array.isArray(rows) ? rows : [], params);
|
|
4863
|
-
|
|
4864
|
-
const paged = paginateInMemory(results, {
|
|
4937
|
+
const paged = paginateSortedInMemory(results, {
|
|
4865
4938
|
limit: params.limit ?? 1e3,
|
|
4866
4939
|
offset: params.offset
|
|
4867
|
-
});
|
|
4940
|
+
}, (left, right) => right.potentialClicks - left.potentialClicks);
|
|
4868
4941
|
return {
|
|
4869
4942
|
results: paged,
|
|
4870
4943
|
meta: {
|
|
@@ -5240,7 +5313,6 @@ const trendsAnalyzer = defineAnalyzer$1({
|
|
|
5240
5313
|
}
|
|
5241
5314
|
});
|
|
5242
5315
|
const DEFAULT_ROW_LIMIT = 25e3;
|
|
5243
|
-
const sortRowResults = createSorter((item) => item.impressions, "impressions");
|
|
5244
5316
|
const ALL_ANALYZERS = [
|
|
5245
5317
|
bayesianCtrAnalyzer,
|
|
5246
5318
|
bipartitePagerankAnalyzer,
|
|
@@ -5372,11 +5444,11 @@ const ALL_ANALYZERS = [
|
|
|
5372
5444
|
position: row.position
|
|
5373
5445
|
});
|
|
5374
5446
|
}
|
|
5375
|
-
const results =
|
|
5376
|
-
const paged =
|
|
5447
|
+
const results = Array.from(queryMap.values());
|
|
5448
|
+
const paged = paginateSortedInMemory(results, {
|
|
5377
5449
|
limit: params.limit,
|
|
5378
5450
|
offset: params.offset
|
|
5379
|
-
});
|
|
5451
|
+
}, (left, right) => right.impressions - left.impressions);
|
|
5380
5452
|
return {
|
|
5381
5453
|
results: paged,
|
|
5382
5454
|
meta: {
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gscdump/analysis",
|
|
3
3
|
"type": "module",
|
|
4
|
-
"version": "0.
|
|
4
|
+
"version": "0.39.0",
|
|
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
|
|
80
|
-
"@gscdump/engine": "0.
|
|
81
|
-
"gscdump": "0.
|
|
79
|
+
"@gscdump/engine": "0.39.0",
|
|
80
|
+
"@gscdump/engine-gsc-api": "0.39.0",
|
|
81
|
+
"gscdump": "0.39.0"
|
|
82
82
|
},
|
|
83
83
|
"devDependencies": {
|
|
84
84
|
"vitest": "^4.1.10"
|