@gscdump/analysis 0.39.0 → 0.40.2

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/README.md CHANGED
@@ -89,7 +89,9 @@ const registry = createAnalyzerRegistry({ rows: ROW_ANALYZERS, sql: SQL_ANALYZER
89
89
  const result = await runAnalyzerFromSource(source, { type: 'striking-distance', minImpressions: 100 }, registry)
90
90
  ```
91
91
 
92
- `attachParquetIndex` and `attachSnapshotIndex` from `@gscdump/engine-duckdb-node` wire parquet files (per-day, per-month, or pre-baked `.duckdb` snapshots) into a DuckDB session. Pair with `analyzeInBrowser` from `@gscdump/analysis` for the attached-table dispatch path.
92
+ `attachParquetIndex` and `attachSnapshotIndex` from `@gscdump/engine/node`
93
+ wire parquet files (per-day, per-month, or pre-baked `.duckdb` snapshots) into
94
+ a Node DuckDB session.
93
95
 
94
96
  ## Browser (DuckDB-WASM)
95
97
 
@@ -98,7 +100,6 @@ import { analyzeInBrowser } from '@gscdump/analysis'
98
100
  // Compose your own narrow registry instead of pulling the kitchen-sink default
99
101
  // (which statically imports every SQL analyzer). For demo only:
100
102
  import { defaultAnalyzerRegistry } from '@gscdump/analysis/registry'
101
- import { createEngine } from '@gscdump/engine-duckdb-wasm'
102
103
 
103
104
  const result = await analyzeInBrowser(
104
105
  runner,
@@ -110,7 +111,10 @@ const result = await analyzeInBrowser(
110
111
 
111
112
  `analyzeInBrowser` wraps any runner with `query(sql, params, signal?)` in an `AnalysisQuerySource` with the `attachedTables` capability and dispatches via `runAnalyzerFromSource`.
112
113
 
113
- For typed drizzle-style access, `@gscdump/engine-duckdb-wasm` exports `createEngine` (a `SqlQuerySource`) plus `bootDuckDBWasm`, `attachParquetUrlTables`, `createBrowserAnalysisRuntime`, and `resolveWindow` (re-export from `@gscdump/engine/period`).
114
+ `@gscdump/engine-duckdb-wasm` exports `bootDuckDBWasm`,
115
+ `attachParquetUrlTables`, `createBrowserAnalysisRuntime`, and `resolveWindow`
116
+ (re-exported from `@gscdump/engine/period`). Browser analysis uses attached
117
+ tables rather than a canonical-schema `createEngine`; see ADR-0001.
114
118
 
115
119
  ## SQLite (D1 / Cloudflare Workers)
116
120
 
@@ -148,7 +152,7 @@ Available source factories:
148
152
  - `createCompositeSource({ engine, gsc })` — `@gscdump/analysis/source`; engine first, GSC fallback
149
153
  - `createInMemoryQuerySource({ queryRows })` — `@gscdump/analysis/source`
150
154
  - `createEngineQuerySource({ engine, ctx })` — `@gscdump/engine/source`
151
- - `createEngine({ ... })` — `@gscdump/engine-duckdb-wasm`, `@gscdump/engine-sqlite`, `@gscdump/engine-duckdb-node`
155
+ - `createEngine({ ... })` — `@gscdump/engine-sqlite`
152
156
 
153
157
  Portable analyzers currently cover the row-based tools:
154
158
  `striking-distance`, `opportunity`, `brand`, `clustering`, `concentration`,
@@ -192,7 +196,7 @@ Presets: `last-7d`, `last-28d`, `last-30d`, `last-90d`, `last-180d`, `last-365d`
192
196
 
193
197
  - [`gscdump`](../gscdump) — REST client + query builder (edge-safe).
194
198
  - [`@gscdump/engine`](../engine) — Parquet/DuckDB storage engine + analyzer/source/period contracts.
195
- - [`@gscdump/engine-duckdb-node`](../engine-duckdb-node) — Node DuckDB engine factory + parquet/snapshot attach helpers.
199
+ - [`@gscdump/engine/node`](../engine) — Node DuckDB handle + parquet/snapshot attach helpers.
196
200
  - [`@gscdump/engine-duckdb-wasm`](../engine-duckdb-wasm) — DuckDB-WASM browser runtime + drizzle adapter.
197
201
  - [`@gscdump/engine-sqlite`](../engine-sqlite) — SQLite / D1 dialect adapter.
198
202
  - [`@gscdump/engine-gsc-api`](../engine-gsc-api) — GSC live-API engine adapter.
@@ -81,6 +81,13 @@ interface BrandSegmentationOptions {
81
81
  /** Minimum impressions for a keyword to be included. Default: 10 */
82
82
  minImpressions?: number;
83
83
  }
84
+ interface BrandSegmentationRow {
85
+ query?: string;
86
+ keyword?: string;
87
+ variants?: readonly string[];
88
+ clicks?: number | null;
89
+ impressions?: number | null;
90
+ }
84
91
  interface BrandSummary {
85
92
  brandClicks: number;
86
93
  nonBrandClicks: number;
@@ -88,9 +95,9 @@ interface BrandSummary {
88
95
  brandImpressions: number;
89
96
  nonBrandImpressions: number;
90
97
  }
91
- interface BrandSegmentationResult {
92
- brand: QueriesRow[];
93
- nonBrand: QueriesRow[];
98
+ interface BrandSegmentationResult<T extends BrandSegmentationRow = QueriesRow> {
99
+ brand: T[];
100
+ nonBrand: T[];
94
101
  summary: BrandSummary;
95
102
  }
96
103
  interface BrandResultRow {
@@ -698,12 +705,6 @@ interface TrendsResult {
698
705
  series: TrendSeriesPoint[];
699
706
  }
700
707
  declare const trendsAnalyzer: import("@gscdump/engine/analyzer").DefinedAnalyzer;
701
- /**
702
- * `zero-click` — high impressions, low CTR, good ranking. SQL groups by
703
- * (query, url) and applies HAVING/WHERE pushdown; row reducer dedupes to the
704
- * best-positioned page per query (different semantics, kept for parity with
705
- * the existing live-GSC behavior).
706
- */
707
708
  interface ZeroClickResult {
708
709
  query: string;
709
710
  page: string;
@@ -522,8 +522,7 @@ function analyzeBrandSegmentation(keywords, options) {
522
522
  const impressions = num(row.impressions);
523
523
  if (impressions < minImpressions) continue;
524
524
  const clicks = num(row.clicks);
525
- const query = row.query.toLowerCase();
526
- if (lowerBrandTerms.some((term) => query.includes(term))) {
525
+ if ((row.variants?.length ? row.variants : [row.query ?? row.keyword ?? ""]).some((query) => lowerBrandTerms.some((term) => query.toLowerCase().includes(term)))) {
527
526
  brand.push(row);
528
527
  brandClicks += clicks;
529
528
  brandImpressions += impressions;
@@ -3502,6 +3501,52 @@ function calculateCtrGapScore(actualCtr, position) {
3502
3501
  const gap = expectedCtr - actualCtr;
3503
3502
  return Math.min(gap / expectedCtr, 1);
3504
3503
  }
3504
+ const SORT_DIR = {
3505
+ opportunityScore: "desc",
3506
+ potentialClicks: "desc",
3507
+ impressions: "desc",
3508
+ position: "asc"
3509
+ };
3510
+ function analyzeOpportunity(keywords, options = {}) {
3511
+ const minImpressions = options.minImpressions ?? 100;
3512
+ const positionWeight = options.weights?.position ?? 1;
3513
+ const impressionsWeight = options.weights?.impressions ?? 1;
3514
+ const ctrGapWeight = options.weights?.ctrGap ?? 1;
3515
+ const totalWeight = positionWeight + impressionsWeight + ctrGapWeight;
3516
+ const sortBy = options.sortBy ?? "opportunityScore";
3517
+ const results = [];
3518
+ for (const row of keywords) {
3519
+ const impressions = num(row.impressions);
3520
+ if (impressions < minImpressions) continue;
3521
+ const position = num(row.position);
3522
+ const ctr = num(row.ctr);
3523
+ const clicks = num(row.clicks);
3524
+ const positionScore = calculatePositionScore(position);
3525
+ const impressionScore = calculateImpressionScore(impressions);
3526
+ const ctrGapScore = calculateCtrGapScore(ctr, position);
3527
+ const weightedProduct = positionScore ** positionWeight * impressionScore ** impressionsWeight * ctrGapScore ** ctrGapWeight;
3528
+ const opportunityScore = Math.round(weightedProduct ** (1 / totalWeight) * 100);
3529
+ const potentialClicks = Math.round(impressions * getExpectedCtr(Math.min(3, position)));
3530
+ results.push({
3531
+ keyword: row.query,
3532
+ page: row.page ?? null,
3533
+ clicks,
3534
+ impressions,
3535
+ ctr,
3536
+ position,
3537
+ opportunityScore,
3538
+ potentialClicks,
3539
+ factors: {
3540
+ positionScore,
3541
+ impressionScore,
3542
+ ctrGapScore
3543
+ }
3544
+ });
3545
+ }
3546
+ const direction = SORT_DIR[sortBy];
3547
+ results.sort((left, right) => direction === "asc" ? left[sortBy] - right[sortBy] : right[sortBy] - left[sortBy]);
3548
+ return options.limit ? results.slice(0, options.limit) : results;
3549
+ }
3505
3550
  const opportunityAnalyzer = defineAnalyzer({
3506
3551
  id: "opportunity",
3507
3552
  buildSql(params) {
@@ -3615,46 +3660,11 @@ const opportunityAnalyzer = defineAnalyzer({
3615
3660
  return { queries: queriesQueryState(periodOf(params), params.limit) };
3616
3661
  },
3617
3662
  reduceRows(rows, params) {
3618
- const keywords = (Array.isArray(rows) ? rows : []) ?? [];
3619
- const minImpressions = params.minImpressions ?? 100;
3620
- const positionWeight = 1;
3621
- const impressionsWeight = 1;
3622
- const ctrGapWeight = 1;
3623
- const sortBy = "opportunityScore";
3624
- const results = [];
3625
- for (const row of keywords) {
3626
- const impressions = num(row.impressions);
3627
- const position = num(row.position);
3628
- const ctr = num(row.ctr);
3629
- const clicks = num(row.clicks);
3630
- if (impressions < minImpressions) continue;
3631
- const positionScore = calculatePositionScore(position);
3632
- const impressionScore = calculateImpressionScore(impressions);
3633
- const ctrGapScore = calculateCtrGapScore(ctr, position);
3634
- const geometricMean = (positionScore ** positionWeight * impressionScore ** impressionsWeight * ctrGapScore ** ctrGapWeight) ** (1 / 3);
3635
- const opportunityScore = Math.round(geometricMean * 100);
3636
- const targetCtr = getExpectedCtr(Math.min(3, position));
3637
- const potentialClicks = Math.round(impressions * targetCtr);
3638
- results.push({
3639
- keyword: row.query,
3640
- page: row.page ?? null,
3641
- clicks,
3642
- impressions,
3643
- ctr,
3644
- position,
3645
- opportunityScore,
3646
- potentialClicks,
3647
- factors: {
3648
- positionScore,
3649
- impressionScore,
3650
- ctrGapScore
3651
- }
3652
- });
3653
- }
3663
+ const results = analyzeOpportunity((Array.isArray(rows) ? rows : []) ?? [], { minImpressions: params.minImpressions });
3654
3664
  const paged = paginateSortedInMemory(results, {
3655
3665
  limit: params.limit,
3656
3666
  offset: params.offset
3657
- }, (left, right) => right[sortBy] - left[sortBy]);
3667
+ }, (left, right) => right.opportunityScore - left.opportunityScore);
3658
3668
  return {
3659
3669
  results: paged,
3660
3670
  meta: {
@@ -4330,6 +4340,12 @@ const stlDecomposeAnalyzer = defineAnalyzer({
4330
4340
  }
4331
4341
  });
4332
4342
  const DEFAULT_ROW_LIMIT$1 = 25e3;
4343
+ function paginateStrikingDistance(results, options) {
4344
+ return paginateSortedInMemory(results, {
4345
+ limit: options.limit ?? 1e3,
4346
+ offset: options.offset
4347
+ }, (left, right) => right.potentialClicks - left.potentialClicks);
4348
+ }
4333
4349
  function filterStrikingDistance(rows, options = {}) {
4334
4350
  const minPosition = options.minPosition ?? 4;
4335
4351
  const maxPosition = options.maxPosition ?? 20;
@@ -4360,10 +4376,7 @@ const strikingDistanceAnalyzer = defineAnalyzer({
4360
4376
  id: "striking-distance",
4361
4377
  reduce(rows, params) {
4362
4378
  const results = filterStrikingDistance(Array.isArray(rows) ? rows : [], params);
4363
- const paged = paginateSortedInMemory(results, {
4364
- limit: params.limit ?? 1e3,
4365
- offset: params.offset
4366
- }, (left, right) => right.potentialClicks - left.potentialClicks);
4379
+ const paged = paginateStrikingDistance(results, params);
4367
4380
  return {
4368
4381
  results: paged,
4369
4382
  meta: {
@@ -4739,6 +4752,18 @@ const trendsAnalyzer = defineAnalyzer({
4739
4752
  }
4740
4753
  });
4741
4754
  const DEFAULT_ROW_LIMIT = 25e3;
4755
+ function analyzeZeroClick(rows, options = {}) {
4756
+ const minImpressions = options.minImpressions ?? 1e3;
4757
+ const maxCtr = options.maxCtr ?? .03;
4758
+ const maxPosition = options.maxPosition ?? 10;
4759
+ const queryMap = /* @__PURE__ */ new Map();
4760
+ for (const row of rows) {
4761
+ if (row.impressions < minImpressions || row.position > maxPosition || row.ctr > maxCtr) continue;
4762
+ const existing = queryMap.get(row.query);
4763
+ if (!existing || row.position < existing.position) queryMap.set(row.query, { ...row });
4764
+ }
4765
+ return [...queryMap.values()].sort((left, right) => right.impressions - left.impressions);
4766
+ }
4742
4767
  const zeroClickAnalyzer = defineAnalyzer({
4743
4768
  id: "zero-click",
4744
4769
  buildSql(params) {
@@ -4822,26 +4847,11 @@ const zeroClickAnalyzer = defineAnalyzer({
4822
4847
  return { rows: gsc.select(query, page).where(between(date, period.startDate, period.endDate)).limit(limit).getState() };
4823
4848
  },
4824
4849
  reduceRows(rows, params) {
4825
- const arr = Array.isArray(rows) ? rows : [];
4826
- const minImpressions = params.minImpressions ?? 1e3;
4827
- const maxCtr = params.maxCtr ?? .03;
4828
- const maxPosition = params.maxPosition ?? 10;
4829
- const queryMap = /* @__PURE__ */ new Map();
4830
- for (const row of arr) {
4831
- if (row.impressions < minImpressions) continue;
4832
- if (row.position > maxPosition) continue;
4833
- if (row.ctr > maxCtr) continue;
4834
- const existing = queryMap.get(row.query);
4835
- if (!existing || row.position < existing.position) queryMap.set(row.query, {
4836
- query: row.query,
4837
- page: row.page,
4838
- clicks: row.clicks,
4839
- impressions: row.impressions,
4840
- ctr: row.ctr,
4841
- position: row.position
4842
- });
4843
- }
4844
- const results = Array.from(queryMap.values());
4850
+ const results = analyzeZeroClick(Array.isArray(rows) ? rows : [], {
4851
+ minImpressions: params.minImpressions ?? 1e3,
4852
+ maxCtr: params.maxCtr ?? .03,
4853
+ maxPosition: params.maxPosition ?? 10
4854
+ });
4845
4855
  const paged = paginateSortedInMemory(results, {
4846
4856
  limit: params.limit,
4847
4857
  offset: params.offset
@@ -522,8 +522,7 @@ function analyzeBrandSegmentation(keywords, options) {
522
522
  const impressions = num(row.impressions);
523
523
  if (impressions < minImpressions) continue;
524
524
  const clicks = num(row.clicks);
525
- const query = row.query.toLowerCase();
526
- if (lowerBrandTerms.some((term) => query.includes(term))) {
525
+ if ((row.variants?.length ? row.variants : [row.query ?? row.keyword ?? ""]).some((query) => lowerBrandTerms.some((term) => query.toLowerCase().includes(term)))) {
527
526
  brand.push(row);
528
527
  brandClicks += clicks;
529
528
  brandImpressions += impressions;
@@ -3496,6 +3495,52 @@ function calculateCtrGapScore(actualCtr, position) {
3496
3495
  const gap = expectedCtr - actualCtr;
3497
3496
  return Math.min(gap / expectedCtr, 1);
3498
3497
  }
3498
+ const SORT_DIR = {
3499
+ opportunityScore: "desc",
3500
+ potentialClicks: "desc",
3501
+ impressions: "desc",
3502
+ position: "asc"
3503
+ };
3504
+ function analyzeOpportunity(keywords, options = {}) {
3505
+ const minImpressions = options.minImpressions ?? 100;
3506
+ const positionWeight = options.weights?.position ?? 1;
3507
+ const impressionsWeight = options.weights?.impressions ?? 1;
3508
+ const ctrGapWeight = options.weights?.ctrGap ?? 1;
3509
+ const totalWeight = positionWeight + impressionsWeight + ctrGapWeight;
3510
+ const sortBy = options.sortBy ?? "opportunityScore";
3511
+ const results = [];
3512
+ for (const row of keywords) {
3513
+ const impressions = num(row.impressions);
3514
+ if (impressions < minImpressions) continue;
3515
+ const position = num(row.position);
3516
+ const ctr = num(row.ctr);
3517
+ const clicks = num(row.clicks);
3518
+ const positionScore = calculatePositionScore(position);
3519
+ const impressionScore = calculateImpressionScore(impressions);
3520
+ const ctrGapScore = calculateCtrGapScore(ctr, position);
3521
+ const weightedProduct = positionScore ** positionWeight * impressionScore ** impressionsWeight * ctrGapScore ** ctrGapWeight;
3522
+ const opportunityScore = Math.round(weightedProduct ** (1 / totalWeight) * 100);
3523
+ const potentialClicks = Math.round(impressions * getExpectedCtr(Math.min(3, position)));
3524
+ results.push({
3525
+ keyword: row.query,
3526
+ page: row.page ?? null,
3527
+ clicks,
3528
+ impressions,
3529
+ ctr,
3530
+ position,
3531
+ opportunityScore,
3532
+ potentialClicks,
3533
+ factors: {
3534
+ positionScore,
3535
+ impressionScore,
3536
+ ctrGapScore
3537
+ }
3538
+ });
3539
+ }
3540
+ const direction = SORT_DIR[sortBy];
3541
+ results.sort((left, right) => direction === "asc" ? left[sortBy] - right[sortBy] : right[sortBy] - left[sortBy]);
3542
+ return options.limit ? results.slice(0, options.limit) : results;
3543
+ }
3499
3544
  const opportunityAnalyzer = defineAnalyzer({
3500
3545
  id: "opportunity",
3501
3546
  buildSql(params) {
@@ -3609,46 +3654,11 @@ const opportunityAnalyzer = defineAnalyzer({
3609
3654
  return { queries: queriesQueryState(periodOf(params), params.limit) };
3610
3655
  },
3611
3656
  reduceRows(rows, params) {
3612
- const keywords = (Array.isArray(rows) ? rows : []) ?? [];
3613
- const minImpressions = params.minImpressions ?? 100;
3614
- const positionWeight = 1;
3615
- const impressionsWeight = 1;
3616
- const ctrGapWeight = 1;
3617
- const sortBy = "opportunityScore";
3618
- const results = [];
3619
- for (const row of keywords) {
3620
- const impressions = num(row.impressions);
3621
- const position = num(row.position);
3622
- const ctr = num(row.ctr);
3623
- const clicks = num(row.clicks);
3624
- if (impressions < minImpressions) continue;
3625
- const positionScore = calculatePositionScore(position);
3626
- const impressionScore = calculateImpressionScore(impressions);
3627
- const ctrGapScore = calculateCtrGapScore(ctr, position);
3628
- const geometricMean = (positionScore ** positionWeight * impressionScore ** impressionsWeight * ctrGapScore ** ctrGapWeight) ** (1 / 3);
3629
- const opportunityScore = Math.round(geometricMean * 100);
3630
- const targetCtr = getExpectedCtr(Math.min(3, position));
3631
- const potentialClicks = Math.round(impressions * targetCtr);
3632
- results.push({
3633
- keyword: row.query,
3634
- page: row.page ?? null,
3635
- clicks,
3636
- impressions,
3637
- ctr,
3638
- position,
3639
- opportunityScore,
3640
- potentialClicks,
3641
- factors: {
3642
- positionScore,
3643
- impressionScore,
3644
- ctrGapScore
3645
- }
3646
- });
3647
- }
3657
+ const results = analyzeOpportunity((Array.isArray(rows) ? rows : []) ?? [], { minImpressions: params.minImpressions });
3648
3658
  const paged = paginateSortedInMemory(results, {
3649
3659
  limit: params.limit,
3650
3660
  offset: params.offset
3651
- }, (left, right) => right[sortBy] - left[sortBy]);
3661
+ }, (left, right) => right.opportunityScore - left.opportunityScore);
3652
3662
  return {
3653
3663
  results: paged,
3654
3664
  meta: {
@@ -4324,6 +4334,12 @@ const stlDecomposeAnalyzer = defineAnalyzer({
4324
4334
  }
4325
4335
  });
4326
4336
  const DEFAULT_ROW_LIMIT$1 = 25e3;
4337
+ function paginateStrikingDistance(results, options) {
4338
+ return paginateSortedInMemory(results, {
4339
+ limit: options.limit ?? 1e3,
4340
+ offset: options.offset
4341
+ }, (left, right) => right.potentialClicks - left.potentialClicks);
4342
+ }
4327
4343
  function filterStrikingDistance(rows, options = {}) {
4328
4344
  const minPosition = options.minPosition ?? 4;
4329
4345
  const maxPosition = options.maxPosition ?? 20;
@@ -4354,10 +4370,7 @@ const strikingDistanceAnalyzer = defineAnalyzer({
4354
4370
  id: "striking-distance",
4355
4371
  reduce(rows, params) {
4356
4372
  const results = filterStrikingDistance(Array.isArray(rows) ? rows : [], params);
4357
- const paged = paginateSortedInMemory(results, {
4358
- limit: params.limit ?? 1e3,
4359
- offset: params.offset
4360
- }, (left, right) => right.potentialClicks - left.potentialClicks);
4373
+ const paged = paginateStrikingDistance(results, params);
4361
4374
  return {
4362
4375
  results: paged,
4363
4376
  meta: {
@@ -4733,6 +4746,18 @@ const trendsAnalyzer = defineAnalyzer({
4733
4746
  }
4734
4747
  });
4735
4748
  const DEFAULT_ROW_LIMIT = 25e3;
4749
+ function analyzeZeroClick(rows, options = {}) {
4750
+ const minImpressions = options.minImpressions ?? 1e3;
4751
+ const maxCtr = options.maxCtr ?? .03;
4752
+ const maxPosition = options.maxPosition ?? 10;
4753
+ const queryMap = /* @__PURE__ */ new Map();
4754
+ for (const row of rows) {
4755
+ if (row.impressions < minImpressions || row.position > maxPosition || row.ctr > maxCtr) continue;
4756
+ const existing = queryMap.get(row.query);
4757
+ if (!existing || row.position < existing.position) queryMap.set(row.query, { ...row });
4758
+ }
4759
+ return [...queryMap.values()].sort((left, right) => right.impressions - left.impressions);
4760
+ }
4736
4761
  const defaultAnalyzerRegistry = createAnalyzerRegistry({ defined: [
4737
4762
  bayesianCtrAnalyzer,
4738
4763
  bipartitePagerankAnalyzer,
@@ -4845,26 +4870,11 @@ const defaultAnalyzerRegistry = createAnalyzerRegistry({ defined: [
4845
4870
  return { rows: gsc.select(query, page).where(between(date, period.startDate, period.endDate)).limit(limit).getState() };
4846
4871
  },
4847
4872
  reduceRows(rows, params) {
4848
- const arr = Array.isArray(rows) ? rows : [];
4849
- const minImpressions = params.minImpressions ?? 1e3;
4850
- const maxCtr = params.maxCtr ?? .03;
4851
- const maxPosition = params.maxPosition ?? 10;
4852
- const queryMap = /* @__PURE__ */ new Map();
4853
- for (const row of arr) {
4854
- if (row.impressions < minImpressions) continue;
4855
- if (row.position > maxPosition) continue;
4856
- if (row.ctr > maxCtr) continue;
4857
- const existing = queryMap.get(row.query);
4858
- if (!existing || row.position < existing.position) queryMap.set(row.query, {
4859
- query: row.query,
4860
- page: row.page,
4861
- clicks: row.clicks,
4862
- impressions: row.impressions,
4863
- ctr: row.ctr,
4864
- position: row.position
4865
- });
4866
- }
4867
- const results = Array.from(queryMap.values());
4873
+ const results = analyzeZeroClick(Array.isArray(rows) ? rows : [], {
4874
+ minImpressions: params.minImpressions ?? 1e3,
4875
+ maxCtr: params.maxCtr ?? .03,
4876
+ maxPosition: params.maxPosition ?? 10
4877
+ });
4868
4878
  const paged = paginateSortedInMemory(results, {
4869
4879
  limit: params.limit,
4870
4880
  offset: params.offset
package/dist/index.d.mts CHANGED
@@ -1,9 +1,9 @@
1
1
  import { Analyzer, Analyzer as Analyzer$1, AnalyzerCapabilityError, AnalyzerRegistry, AnalyzerRegistry as AnalyzerRegistry$1, AnalyzerRegistryInit, AnalyzerVariants, DefineAnalyzerOptions, DefinedAnalyzer, Plan, ReduceContext, ReduceCtx, Reducer, RequiredCapability, RowQueriesPlan, SqlExtraQuery, SqlPlan, SqlPlanSpec, TypedRowQuery, createAnalyzerRegistry, defineAnalyzer, runAnalyzerFromSource } from "@gscdump/engine/analyzer";
2
- import { AnalysisPeriod, ComparisonMode, ComparisonPeriod, PadTimeseriesOptions, ResolveWindowOptions, ResolvedWindow, WindowPreset, comparisonOf, padTimeseries, periodOf, resolveWindow, windowToComparisonPeriod, windowToPeriod } from "@gscdump/engine/period";
2
+ import { AnalysisPeriod, ComparisonMode, ComparisonPeriod, PadTimeseriesOptions, ResolveWindowOptions, ResolvedWindow, WindowPreset, comparisonOf, padTimeseries, periodOf, resolveWindow } from "@gscdump/engine/period";
3
3
  import { AnalysisParams, AnalysisParams as AnalysisParams$1, AnalysisResult, AnalysisResult as AnalysisResult$1, AnalysisTool, num } from "@gscdump/engine/analysis-types";
4
4
  import { Result } from "gscdump/result";
5
5
  import { BuilderState } from "gscdump/query";
6
- import { AnalysisQuerySource, AnalysisQuerySource as AnalysisQuerySource$1, AnalysisSourceKind, AttachedTableRunner, AttachedTableRunner as AnalyzerRunner, AttachedTableSourceOptions, AttachedTableSourceOptions as BrowserAnalyzeOptions, ENGINE_QUERY_CAPABILITIES, EngineQuerySourceOptions, ExecuteSqlOptions, FileSet, QueryRow, QueryRow as QueryRow$1, SourceCapabilities, TypedQuery, createEngineQuerySource, queryComparisonRows, queryRows, rewriteForTableSource, runAnalyzerWithEngine, typedQuery } from "@gscdump/engine/source";
6
+ import { AnalysisQuerySource, AnalysisQuerySource as AnalysisQuerySource$1, AnalysisSourceKind, AttachedTableRunner, AttachedTableRunner as AnalyzerRunner, AttachedTableSourceOptions, AttachedTableSourceOptions as BrowserAnalyzeOptions, ENGINE_QUERY_CAPABILITIES, EngineQuerySourceOptions, ExecuteSqlOptions, FileSet, QueryRow, QueryRow as QueryRow$1, SourceCapabilities, createEngineQuerySource, queryRows, rewriteForTableSource, runAnalyzerWithEngine } from "@gscdump/engine/source";
7
7
  import { DefineReportOptions, DefinedReport, DefinedReport as DefinedReport$1, ReportAction, ReportContext, ReportContext as ReportContext$1, ReportFinding, ReportParams, ReportPlanStep, ReportResult, ReportResult as ReportResult$1, ReportSection } from "@gscdump/engine/report";
8
8
  import { PlannerCapabilities } from "gscdump/query/plan";
9
9
  type ActionSource = 'cannibalization' | 'striking-distance' | 'ctr-anomaly' | 'change-point' | 'opportunity';
@@ -78,6 +78,13 @@ interface BrandSegmentationOptions {
78
78
  /** Minimum impressions for a keyword to be included. Default: 10 */
79
79
  minImpressions?: number;
80
80
  }
81
+ interface BrandSegmentationRow {
82
+ query?: string;
83
+ keyword?: string;
84
+ variants?: readonly string[];
85
+ clicks?: number | null;
86
+ impressions?: number | null;
87
+ }
81
88
  interface BrandSummary {
82
89
  brandClicks: number;
83
90
  nonBrandClicks: number;
@@ -85,16 +92,17 @@ interface BrandSummary {
85
92
  brandImpressions: number;
86
93
  nonBrandImpressions: number;
87
94
  }
88
- interface BrandSegmentationResult {
89
- brand: QueriesRow[];
90
- nonBrand: QueriesRow[];
95
+ interface BrandSegmentationResult<T extends BrandSegmentationRow = QueriesRow> {
96
+ brand: T[];
97
+ nonBrand: T[];
91
98
  summary: BrandSummary;
92
99
  }
93
100
  /**
94
101
  * Pure helper: segment keywords into brand and non-brand based on provided
95
102
  * brand terms. Re-exported from `@gscdump/analysis` for portable callers.
96
103
  */
97
- declare function analyzeBrandSegmentation(keywords: QueriesRow[], options: BrandSegmentationOptions): BrandSegmentationResult;
104
+ declare function analyzeBrandSegmentation(keywords: QueriesRow[], options: BrandSegmentationOptions): BrandSegmentationResult<QueriesRow>;
105
+ declare function analyzeBrandSegmentation<T extends BrandSegmentationRow>(keywords: T[], options: BrandSegmentationOptions): BrandSegmentationResult<T>;
98
106
  type CannibalizationSortMetric = 'clicks' | 'impressions' | 'positionSpread' | 'pageCount';
99
107
  interface CannibalizationOptions {
100
108
  /** Minimum impressions for a query to be considered. Default: 10 */
@@ -295,6 +303,7 @@ interface MoversResult {
295
303
  * recent changes between two periods.
296
304
  */
297
305
  declare function analyzeMovers(input: MoversInput, options?: MoversOptions): MoversResult;
306
+ type OpportunitySortMetric = 'opportunityScore' | 'potentialClicks' | 'impressions' | 'position';
298
307
  interface OpportunityFactors {
299
308
  positionScore: number;
300
309
  impressionScore: number;
@@ -311,6 +320,19 @@ interface OpportunityResult {
311
320
  potentialClicks: number;
312
321
  factors: OpportunityFactors;
313
322
  }
323
+ interface OpportunityWeights {
324
+ position?: number;
325
+ impressions?: number;
326
+ ctrGap?: number;
327
+ }
328
+ interface OpportunityOptions {
329
+ minImpressions?: number;
330
+ weights?: OpportunityWeights;
331
+ sortBy?: OpportunitySortMetric;
332
+ limit?: number;
333
+ }
334
+ /** Pure opportunity scorer shared by hosted and package analyzer callers. */
335
+ declare function analyzeOpportunity(keywords: QueriesRow[], options?: OpportunityOptions): OpportunityResult[];
314
336
  type SeasonalityMetric = 'clicks' | 'impressions';
315
337
  interface SeasonalityOptions {
316
338
  /** Metric to analyze for seasonality. Default: clicks */
@@ -371,12 +393,25 @@ interface StrikingDistanceResult {
371
393
  /** Estimated clicks at ~15% CTR (the average for positions 1–3). */
372
394
  potentialClicks: number;
373
395
  }
374
- /**
375
- * `zero-click` — high impressions, low CTR, good ranking. SQL groups by
376
- * (query, url) and applies HAVING/WHERE pushdown; row reducer dedupes to the
377
- * best-positioned page per query (different semantics, kept for parity with
378
- * the existing live-GSC behavior).
379
- */
396
+ interface StrikingDistanceFilterOptions {
397
+ minPosition?: number;
398
+ maxPosition?: number;
399
+ minImpressions?: number;
400
+ maxCtr?: number;
401
+ }
402
+ interface StrikingDistanceOptions extends StrikingDistanceFilterOptions {
403
+ limit?: number;
404
+ offset?: number;
405
+ }
406
+ /** Pure striking-distance analysis for consumers that already own the rows. */
407
+ declare function analyzeStrikingDistance(rows: readonly {
408
+ query: unknown;
409
+ page?: unknown;
410
+ clicks: unknown;
411
+ impressions: unknown;
412
+ ctr: unknown;
413
+ position: unknown;
414
+ }[], options?: StrikingDistanceOptions): StrikingDistanceResult[];
380
415
  interface ZeroClickResult {
381
416
  query: string;
382
417
  page: string;
@@ -385,6 +420,13 @@ interface ZeroClickResult {
385
420
  ctr: number;
386
421
  position: number;
387
422
  }
423
+ interface ZeroClickOptions {
424
+ minImpressions?: number;
425
+ maxCtr?: number;
426
+ maxPosition?: number;
427
+ }
428
+ /** Pure zero-click detector shared by hosted and package analyzer callers. */
429
+ declare function analyzeZeroClick(rows: QueryPageRow[], options?: ZeroClickOptions): ZeroClickResult[];
388
430
  declare function analyzeInBrowser(runner: AttachedTableRunner, opts: AttachedTableSourceOptions, params: AnalysisParams$1, registry: AnalyzerRegistry$1): Promise<AnalysisResult$1>;
389
431
  /**
390
432
  * Default analyzer registry built from every in-tree analyzer. Convenience
@@ -604,4 +646,4 @@ interface InMemoryQuerySourceOptions {
604
646
  }
605
647
  declare function createInMemoryQuerySource(options: InMemoryQuerySourceOptions): AnalysisQuerySource$1;
606
648
  declare const SQL_ANALYZERS: readonly Analyzer$1[];
607
- export { type ActionPriorityResult, type ActionPrioritySourceState, type ActionPrioritySourceStatus, type ActionSource, type AnalysisError, type AnalysisErrorKind, type AnalysisParams, type AnalysisPeriod, type AnalysisQuerySource, type AnalysisResult, type AnalysisSourceKind, type AnalysisTool, type Analyzer, AnalyzerCapabilityError, type AnalyzerRegistry, type AnalyzerRegistryInit, type AnalyzerRunner, type AnalyzerVariants, type BaseMetrics, type BrandSegmentationOptions, type BrandSegmentationResult, type BrandSummary, type BrowserAnalyzeOptions, type CannibalizationCompetitor, type CannibalizationEvent, type CannibalizationOptions, type CannibalizationPage, type CannibalizationResult, type CannibalizationSortMetric, type ClusterType, type ClusteringOptions, type ClusteringResult, type ComparisonMode, type ComparisonPeriod, type CompositeSourceOptions, type ConcentrationInput, type ConcentrationItem, type ConcentrationOptions, type ConcentrationResult, type ConcentrationRiskLevel, DEFAULT_PRIORITY_SOURCES, type DateRow, type DecayInput, type DecayOptions, type DecayResult, type DecaySeriesPoint, type DecaySortMetric, type DefineAnalyzerOptions, type DefineReportOptions, type DefinedAnalyzer, type DefinedReport, type DryRunReportResult, ENGINE_QUERY_CAPABILITIES, type Effort, type EngineQuerySourceOptions, type ExecuteSqlOptions, type FileSet, type FormatReportOptions, INTENT_CLASSIFIER_VERSION, IN_MEMORY_DEFAULT_CAPABILITIES, type InMemoryQuerySourceOptions, type IntentClassification, type KeywordCluster, type MonthlyData, type MoverData, type MoversInput, type MoversOptions, type MoversResult, type MoversSortMetric, NORMALIZER_VERSION, type OpportunityResult, type PadTimeseriesOptions, type PageRow, type Plan, type PriorityAction, type QueriesRow, type QueryPageRow, type QueryRow, REPORTS, ROW_ANALYZERS, type ReduceContext, type ReduceCtx, type Reducer, type ReportAction, type ReportContext, type ReportFinding, type ReportPlanStep, type ReportResult, type ReportSection, type RequiredCapability, type ResolveWindowOptions, type ResolvedWindow, type RowQueriesPlan, type RunReportOptions, SEARCH_INTENT_CODE, SQL_ANALYZERS, type SearchIntent, type SeasonalityMetric, type SeasonalityOptions, type SeasonalityResult, type SitemapDelta, type SitemapHealthDiff, type SitemapHealthInput, type SitemapHealthRow, type SitemapHealthTotals, type SortOrder, type SourceCapabilities, type SqlExtraQuery, type SqlPlan, type SqlPlanSpec, type StrikingDistanceInputRow, type StrikingDistanceResult, type TypedQuery, type TypedRowQuery, type WindowPreset, type ZeroClickResult, analysisErrorToException, analysisErrors, analyzeBrandSegmentation, analyzeCannibalization, analyzeClustering, analyzeConcentration, analyzeDecay, analyzeInBrowser, analyzeKeywordConcentration, analyzeMovers, analyzePageConcentration, analyzeSeasonality, classifyQueryIntent, comparisonOf, createAnalyzerRegistry, createCompositeSource, createEngineQuerySource, createInMemoryQuerySource, createSorter, decodeIntent, defaultAnalyzerRegistry, defaultReportRegistry, defineAnalyzer, diffSitemapHealth, dryRunReport, encodeIntent, formatAnalysisError, formatReport, isAnalysisError, mergePriorityActions, normalizePriorityActions, normalizeQuery, num, padTimeseries, periodOf, queryComparisonRows, queryRows, resolveWindow, rewriteForTableSource, runAnalyzerFromSource, runAnalyzerWithEngine, runReport, runReportResult, scorePriorityActions, typedQuery, windowToComparisonPeriod, windowToPeriod };
649
+ export { type ActionPriorityResult, type ActionPrioritySourceState, type ActionPrioritySourceStatus, type ActionSource, type AnalysisError, type AnalysisErrorKind, type AnalysisParams, type AnalysisPeriod, type AnalysisQuerySource, type AnalysisResult, type AnalysisSourceKind, type AnalysisTool, type Analyzer, AnalyzerCapabilityError, type AnalyzerRegistry, type AnalyzerRegistryInit, type AnalyzerRunner, type AnalyzerVariants, type BaseMetrics, type BrandSegmentationOptions, type BrandSegmentationResult, type BrandSegmentationRow, type BrandSummary, type BrowserAnalyzeOptions, type CannibalizationCompetitor, type CannibalizationEvent, type CannibalizationOptions, type CannibalizationPage, type CannibalizationResult, type CannibalizationSortMetric, type ClusterType, type ClusteringOptions, type ClusteringResult, type ComparisonMode, type ComparisonPeriod, type CompositeSourceOptions, type ConcentrationInput, type ConcentrationItem, type ConcentrationOptions, type ConcentrationResult, type ConcentrationRiskLevel, DEFAULT_PRIORITY_SOURCES, type DateRow, type DecayInput, type DecayOptions, type DecayResult, type DecaySeriesPoint, type DecaySortMetric, type DefineAnalyzerOptions, type DefineReportOptions, type DefinedAnalyzer, type DefinedReport, type DryRunReportResult, ENGINE_QUERY_CAPABILITIES, type Effort, type EngineQuerySourceOptions, type ExecuteSqlOptions, type FileSet, type FormatReportOptions, INTENT_CLASSIFIER_VERSION, IN_MEMORY_DEFAULT_CAPABILITIES, type InMemoryQuerySourceOptions, type IntentClassification, type KeywordCluster, type MonthlyData, type MoverData, type MoversInput, type MoversOptions, type MoversResult, type MoversSortMetric, NORMALIZER_VERSION, type OpportunityFactors, type OpportunityOptions, type OpportunityResult, type OpportunitySortMetric, type OpportunityWeights, type PadTimeseriesOptions, type PageRow, type Plan, type PriorityAction, type QueriesRow, type QueryPageRow, type QueryRow, REPORTS, ROW_ANALYZERS, type ReduceContext, type ReduceCtx, type Reducer, type ReportAction, type ReportContext, type ReportFinding, type ReportPlanStep, type ReportResult, type ReportSection, type RequiredCapability, type ResolveWindowOptions, type ResolvedWindow, type RowQueriesPlan, type RunReportOptions, SEARCH_INTENT_CODE, SQL_ANALYZERS, type SearchIntent, type SeasonalityMetric, type SeasonalityOptions, type SeasonalityResult, type SitemapDelta, type SitemapHealthDiff, type SitemapHealthInput, type SitemapHealthRow, type SitemapHealthTotals, type SortOrder, type SourceCapabilities, type SqlExtraQuery, type SqlPlan, type SqlPlanSpec, type StrikingDistanceInputRow, type StrikingDistanceOptions, type StrikingDistanceResult, type TypedRowQuery, type WindowPreset, type ZeroClickOptions, type ZeroClickResult, analysisErrorToException, analysisErrors, analyzeBrandSegmentation, analyzeCannibalization, analyzeClustering, analyzeConcentration, analyzeDecay, analyzeInBrowser, analyzeKeywordConcentration, analyzeMovers, analyzeOpportunity, analyzePageConcentration, analyzeSeasonality, analyzeStrikingDistance, analyzeZeroClick, classifyQueryIntent, comparisonOf, createAnalyzerRegistry, createCompositeSource, createEngineQuerySource, createInMemoryQuerySource, createSorter, decodeIntent, defaultAnalyzerRegistry, defaultReportRegistry, defineAnalyzer, diffSitemapHealth, dryRunReport, encodeIntent, formatAnalysisError, formatReport, isAnalysisError, mergePriorityActions, normalizePriorityActions, normalizeQuery, num, padTimeseries, periodOf, queryRows, resolveWindow, rewriteForTableSource, runAnalyzerFromSource, runAnalyzerWithEngine, runReport, runReportResult, scorePriorityActions };
package/dist/index.mjs CHANGED
@@ -1,5 +1,5 @@
1
1
  import { AnalyzerCapabilityError, createAnalyzerRegistry, createAnalyzerRegistry as createAnalyzerRegistry$1, defineAnalyzer, defineAnalyzer as defineAnalyzer$1, requireAdapter, runAnalyzerFromSource, runAnalyzerFromSource as runAnalyzerFromSource$1 } from "@gscdump/engine/analyzer";
2
- import { comparisonOf, comparisonOf as comparisonOf$1, defaultEndDate, padTimeseries, padTimeseries as padTimeseries$1, periodOf, periodOf as periodOf$1, resolveWindow, windowToComparisonPeriod, windowToPeriod } from "@gscdump/engine/period";
2
+ import { comparisonOf, comparisonOf as comparisonOf$1, defaultEndDate, padTimeseries, padTimeseries as padTimeseries$1, periodOf, periodOf as periodOf$1, resolveWindow } from "@gscdump/engine/period";
3
3
  import { enumeratePartitions } from "@gscdump/engine/planner";
4
4
  import { METRIC_EXPR } from "@gscdump/engine/sql-fragments";
5
5
  import { num, num as num$1 } from "@gscdump/engine/analysis-types";
@@ -8,7 +8,7 @@ import { between, date, extractDateRange, gsc, page, query } from "gscdump/query
8
8
  import { MS_PER_DAY, daysAgoUtc, toIsoDate } from "gscdump/dates";
9
9
  import { buildExtrasQueries, buildTotalsSql, mergeExtras, pgResolverAdapter, resolveComparisonSQL, resolveToSQLOptimized } from "@gscdump/engine/resolver";
10
10
  import pluralize from "pluralize";
11
- import { ENGINE_QUERY_CAPABILITIES, createAttachedTableSource, createEngineQuerySource, queryComparisonRows, queryRows, rewriteForTableSource, runAnalyzerWithEngine, typedQuery } from "@gscdump/engine/source";
11
+ import { ENGINE_QUERY_CAPABILITIES, createAttachedTableSource, createEngineQuerySource, queryRows, rewriteForTableSource, runAnalyzerWithEngine } from "@gscdump/engine/source";
12
12
  import { computeInputHash, createReportRegistry, defineReport } from "@gscdump/engine/report";
13
13
  import { canProxyToGsc } from "@gscdump/engine-gsc-api";
14
14
  import { isStateResolvable } from "gscdump/query/plan";
@@ -754,8 +754,7 @@ function analyzeBrandSegmentation(keywords, options) {
754
754
  const impressions = num$1(row.impressions);
755
755
  if (impressions < minImpressions) continue;
756
756
  const clicks = num$1(row.clicks);
757
- const query = row.query.toLowerCase();
758
- if (lowerBrandTerms.some((term) => query.includes(term))) {
757
+ if ((row.variants?.length ? row.variants : [row.query ?? row.keyword ?? ""]).some((query) => lowerBrandTerms.some((term) => query.toLowerCase().includes(term)))) {
759
758
  brand.push(row);
760
759
  brandClicks += clicks;
761
760
  brandImpressions += impressions;
@@ -4076,6 +4075,52 @@ function calculateCtrGapScore(actualCtr, position) {
4076
4075
  const gap = expectedCtr - actualCtr;
4077
4076
  return Math.min(gap / expectedCtr, 1);
4078
4077
  }
4078
+ const SORT_DIR = {
4079
+ opportunityScore: "desc",
4080
+ potentialClicks: "desc",
4081
+ impressions: "desc",
4082
+ position: "asc"
4083
+ };
4084
+ function analyzeOpportunity(keywords, options = {}) {
4085
+ const minImpressions = options.minImpressions ?? 100;
4086
+ const positionWeight = options.weights?.position ?? 1;
4087
+ const impressionsWeight = options.weights?.impressions ?? 1;
4088
+ const ctrGapWeight = options.weights?.ctrGap ?? 1;
4089
+ const totalWeight = positionWeight + impressionsWeight + ctrGapWeight;
4090
+ const sortBy = options.sortBy ?? "opportunityScore";
4091
+ const results = [];
4092
+ for (const row of keywords) {
4093
+ const impressions = num$1(row.impressions);
4094
+ if (impressions < minImpressions) continue;
4095
+ const position = num$1(row.position);
4096
+ const ctr = num$1(row.ctr);
4097
+ const clicks = num$1(row.clicks);
4098
+ const positionScore = calculatePositionScore(position);
4099
+ const impressionScore = calculateImpressionScore(impressions);
4100
+ const ctrGapScore = calculateCtrGapScore(ctr, position);
4101
+ const weightedProduct = positionScore ** positionWeight * impressionScore ** impressionsWeight * ctrGapScore ** ctrGapWeight;
4102
+ const opportunityScore = Math.round(weightedProduct ** (1 / totalWeight) * 100);
4103
+ const potentialClicks = Math.round(impressions * getExpectedCtr(Math.min(3, position)));
4104
+ results.push({
4105
+ keyword: row.query,
4106
+ page: row.page ?? null,
4107
+ clicks,
4108
+ impressions,
4109
+ ctr,
4110
+ position,
4111
+ opportunityScore,
4112
+ potentialClicks,
4113
+ factors: {
4114
+ positionScore,
4115
+ impressionScore,
4116
+ ctrGapScore
4117
+ }
4118
+ });
4119
+ }
4120
+ const direction = SORT_DIR[sortBy];
4121
+ results.sort((left, right) => direction === "asc" ? left[sortBy] - right[sortBy] : right[sortBy] - left[sortBy]);
4122
+ return options.limit ? results.slice(0, options.limit) : results;
4123
+ }
4079
4124
  const opportunityAnalyzer = defineAnalyzer$1({
4080
4125
  id: "opportunity",
4081
4126
  buildSql(params) {
@@ -4189,46 +4234,11 @@ const opportunityAnalyzer = defineAnalyzer$1({
4189
4234
  return { queries: queriesQueryState(periodOf$1(params), params.limit) };
4190
4235
  },
4191
4236
  reduceRows(rows, params) {
4192
- const keywords = (Array.isArray(rows) ? rows : []) ?? [];
4193
- const minImpressions = params.minImpressions ?? 100;
4194
- const positionWeight = 1;
4195
- const impressionsWeight = 1;
4196
- const ctrGapWeight = 1;
4197
- const sortBy = "opportunityScore";
4198
- const results = [];
4199
- for (const row of keywords) {
4200
- const impressions = num$1(row.impressions);
4201
- const position = num$1(row.position);
4202
- const ctr = num$1(row.ctr);
4203
- const clicks = num$1(row.clicks);
4204
- if (impressions < minImpressions) continue;
4205
- const positionScore = calculatePositionScore(position);
4206
- const impressionScore = calculateImpressionScore(impressions);
4207
- const ctrGapScore = calculateCtrGapScore(ctr, position);
4208
- const geometricMean = (positionScore ** positionWeight * impressionScore ** impressionsWeight * ctrGapScore ** ctrGapWeight) ** (1 / 3);
4209
- const opportunityScore = Math.round(geometricMean * 100);
4210
- const targetCtr = getExpectedCtr(Math.min(3, position));
4211
- const potentialClicks = Math.round(impressions * targetCtr);
4212
- results.push({
4213
- keyword: row.query,
4214
- page: row.page ?? null,
4215
- clicks,
4216
- impressions,
4217
- ctr,
4218
- position,
4219
- opportunityScore,
4220
- potentialClicks,
4221
- factors: {
4222
- positionScore,
4223
- impressionScore,
4224
- ctrGapScore
4225
- }
4226
- });
4227
- }
4237
+ const results = analyzeOpportunity((Array.isArray(rows) ? rows : []) ?? [], { minImpressions: params.minImpressions });
4228
4238
  const paged = paginateSortedInMemory(results, {
4229
4239
  limit: params.limit,
4230
4240
  offset: params.offset
4231
- }, (left, right) => right[sortBy] - left[sortBy]);
4241
+ }, (left, right) => right.opportunityScore - left.opportunityScore);
4232
4242
  return {
4233
4243
  results: paged,
4234
4244
  meta: {
@@ -4904,6 +4914,12 @@ const stlDecomposeAnalyzer = defineAnalyzer$1({
4904
4914
  }
4905
4915
  });
4906
4916
  const DEFAULT_ROW_LIMIT$1 = 25e3;
4917
+ function paginateStrikingDistance(results, options) {
4918
+ return paginateSortedInMemory(results, {
4919
+ limit: options.limit ?? 1e3,
4920
+ offset: options.offset
4921
+ }, (left, right) => right.potentialClicks - left.potentialClicks);
4922
+ }
4907
4923
  function filterStrikingDistance(rows, options = {}) {
4908
4924
  const minPosition = options.minPosition ?? 4;
4909
4925
  const maxPosition = options.maxPosition ?? 20;
@@ -4930,14 +4946,14 @@ function filterStrikingDistance(rows, options = {}) {
4930
4946
  }
4931
4947
  return results;
4932
4948
  }
4949
+ function analyzeStrikingDistance(rows, options = {}) {
4950
+ return paginateStrikingDistance(filterStrikingDistance(rows, options), options);
4951
+ }
4933
4952
  const strikingDistanceAnalyzer = defineAnalyzer$1({
4934
4953
  id: "striking-distance",
4935
4954
  reduce(rows, params) {
4936
4955
  const results = filterStrikingDistance(Array.isArray(rows) ? rows : [], params);
4937
- const paged = paginateSortedInMemory(results, {
4938
- limit: params.limit ?? 1e3,
4939
- offset: params.offset
4940
- }, (left, right) => right.potentialClicks - left.potentialClicks);
4956
+ const paged = paginateStrikingDistance(results, params);
4941
4957
  return {
4942
4958
  results: paged,
4943
4959
  meta: {
@@ -5313,6 +5329,18 @@ const trendsAnalyzer = defineAnalyzer$1({
5313
5329
  }
5314
5330
  });
5315
5331
  const DEFAULT_ROW_LIMIT = 25e3;
5332
+ function analyzeZeroClick(rows, options = {}) {
5333
+ const minImpressions = options.minImpressions ?? 1e3;
5334
+ const maxCtr = options.maxCtr ?? .03;
5335
+ const maxPosition = options.maxPosition ?? 10;
5336
+ const queryMap = /* @__PURE__ */ new Map();
5337
+ for (const row of rows) {
5338
+ if (row.impressions < minImpressions || row.position > maxPosition || row.ctr > maxCtr) continue;
5339
+ const existing = queryMap.get(row.query);
5340
+ if (!existing || row.position < existing.position) queryMap.set(row.query, { ...row });
5341
+ }
5342
+ return [...queryMap.values()].sort((left, right) => right.impressions - left.impressions);
5343
+ }
5316
5344
  const ALL_ANALYZERS = [
5317
5345
  bayesianCtrAnalyzer,
5318
5346
  bipartitePagerankAnalyzer,
@@ -5425,26 +5453,11 @@ const ALL_ANALYZERS = [
5425
5453
  return { rows: gsc.select(query, page).where(between(date, period.startDate, period.endDate)).limit(limit).getState() };
5426
5454
  },
5427
5455
  reduceRows(rows, params) {
5428
- const arr = Array.isArray(rows) ? rows : [];
5429
- const minImpressions = params.minImpressions ?? 1e3;
5430
- const maxCtr = params.maxCtr ?? .03;
5431
- const maxPosition = params.maxPosition ?? 10;
5432
- const queryMap = /* @__PURE__ */ new Map();
5433
- for (const row of arr) {
5434
- if (row.impressions < minImpressions) continue;
5435
- if (row.position > maxPosition) continue;
5436
- if (row.ctr > maxCtr) continue;
5437
- const existing = queryMap.get(row.query);
5438
- if (!existing || row.position < existing.position) queryMap.set(row.query, {
5439
- query: row.query,
5440
- page: row.page,
5441
- clicks: row.clicks,
5442
- impressions: row.impressions,
5443
- ctr: row.ctr,
5444
- position: row.position
5445
- });
5446
- }
5447
- const results = Array.from(queryMap.values());
5456
+ const results = analyzeZeroClick(Array.isArray(rows) ? rows : [], {
5457
+ minImpressions: params.minImpressions ?? 1e3,
5458
+ maxCtr: params.maxCtr ?? .03,
5459
+ maxPosition: params.maxPosition ?? 10
5460
+ });
5448
5461
  const paged = paginateSortedInMemory(results, {
5449
5462
  limit: params.limit,
5450
5463
  offset: params.offset
@@ -7066,4 +7079,4 @@ function createInMemoryQuerySource(options) {
7066
7079
  };
7067
7080
  }
7068
7081
  const SQL_ANALYZERS = ALL_ANALYZERS.flatMap((d) => d.sql ? [d.sql] : []);
7069
- export { AnalyzerCapabilityError, DEFAULT_PRIORITY_SOURCES, ENGINE_QUERY_CAPABILITIES, INTENT_CLASSIFIER_VERSION, IN_MEMORY_DEFAULT_CAPABILITIES, NORMALIZER_VERSION, REPORTS, ROW_ANALYZERS, SEARCH_INTENT_CODE, SQL_ANALYZERS, analysisErrorToException, analysisErrors, analyzeBrandSegmentation, analyzeCannibalization, analyzeClustering, analyzeConcentration, analyzeDecay, analyzeInBrowser, analyzeKeywordConcentration, analyzeMovers, analyzePageConcentration, analyzeSeasonality, classifyQueryIntent, comparisonOf, createAnalyzerRegistry, createCompositeSource, createEngineQuerySource, createInMemoryQuerySource, createSorter, decodeIntent, defaultAnalyzerRegistry, defaultReportRegistry, defineAnalyzer, diffSitemapHealth, dryRunReport, encodeIntent, formatAnalysisError, formatReport, isAnalysisError, mergePriorityActions, normalizePriorityActions, normalizeQuery, num, padTimeseries, periodOf, queryComparisonRows, queryRows, resolveWindow, rewriteForTableSource, runAnalyzerFromSource, runAnalyzerWithEngine, runReport, runReportResult, scorePriorityActions, typedQuery, windowToComparisonPeriod, windowToPeriod };
7082
+ export { AnalyzerCapabilityError, DEFAULT_PRIORITY_SOURCES, ENGINE_QUERY_CAPABILITIES, INTENT_CLASSIFIER_VERSION, IN_MEMORY_DEFAULT_CAPABILITIES, NORMALIZER_VERSION, REPORTS, ROW_ANALYZERS, SEARCH_INTENT_CODE, SQL_ANALYZERS, analysisErrorToException, analysisErrors, analyzeBrandSegmentation, analyzeCannibalization, analyzeClustering, analyzeConcentration, analyzeDecay, analyzeInBrowser, analyzeKeywordConcentration, analyzeMovers, analyzeOpportunity, analyzePageConcentration, analyzeSeasonality, analyzeStrikingDistance, analyzeZeroClick, classifyQueryIntent, comparisonOf, createAnalyzerRegistry, createCompositeSource, createEngineQuerySource, createInMemoryQuerySource, createSorter, decodeIntent, defaultAnalyzerRegistry, defaultReportRegistry, defineAnalyzer, diffSitemapHealth, dryRunReport, encodeIntent, formatAnalysisError, formatReport, isAnalysisError, mergePriorityActions, normalizePriorityActions, normalizeQuery, num, padTimeseries, periodOf, queryRows, resolveWindow, rewriteForTableSource, runAnalyzerFromSource, runAnalyzerWithEngine, runReport, runReportResult, scorePriorityActions };
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@gscdump/analysis",
3
3
  "type": "module",
4
- "version": "0.39.0",
4
+ "version": "0.40.2",
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",
@@ -74,11 +74,10 @@
74
74
  }
75
75
  },
76
76
  "dependencies": {
77
- "drizzle-orm": "1.0.0-rc.3",
78
77
  "pluralize": "^8.0.0",
79
- "@gscdump/engine": "0.39.0",
80
- "@gscdump/engine-gsc-api": "0.39.0",
81
- "gscdump": "0.39.0"
78
+ "@gscdump/engine": "0.40.2",
79
+ "@gscdump/engine-gsc-api": "0.40.2",
80
+ "gscdump": "0.40.2"
82
81
  },
83
82
  "devDependencies": {
84
83
  "vitest": "^4.1.10"