gscdump 0.0.1 → 0.1.1

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/index.mjs CHANGED
@@ -17,134 +17,6 @@ function createSorter(getValue, defaultMetric, defaultOrder = "desc") {
17
17
  };
18
18
  }
19
19
 
20
- //#endregion
21
- //#region src/analysis/striking-distance.ts
22
- const sortResults$4 = createSorter((item, metric) => item[metric], "potentialClicks");
23
- /**
24
- * Finds striking distance keywords - high impressions, low CTR, position 4-20.
25
- * These are "quick wins" that could gain significant traffic with small ranking improvements.
26
- */
27
- function analyzeStrikingDistance(keywords, options = {}) {
28
- const { minPosition = 4, maxPosition = 20, minImpressions = 100, maxCtr = .05, sortBy = "potentialClicks", sortOrder = "desc" } = options;
29
- const results = [];
30
- for (const row of keywords) {
31
- const position = num(row.position);
32
- const impressions = num(row.impressions);
33
- const ctr = num(row.ctr);
34
- const clicks = num(row.clicks);
35
- if (position < minPosition || position > maxPosition) continue;
36
- if (impressions < minImpressions) continue;
37
- if (ctr > maxCtr) continue;
38
- const potentialClicks = Math.round(impressions * .15);
39
- results.push({
40
- keyword: row.keyword,
41
- page: row.page ?? null,
42
- clicks,
43
- impressions,
44
- ctr,
45
- position,
46
- potentialClicks
47
- });
48
- }
49
- return sortResults$4(results, sortBy, sortOrder);
50
- }
51
-
52
- //#endregion
53
- //#region src/analysis/opportunity.ts
54
- const EXPECTED_CTR_BY_POSITION = {
55
- 1: .3,
56
- 2: .15,
57
- 3: .1,
58
- 4: .07,
59
- 5: .05,
60
- 6: .04,
61
- 7: .03,
62
- 8: .025,
63
- 9: .02,
64
- 10: .015
65
- };
66
- function getExpectedCtr(position) {
67
- return EXPECTED_CTR_BY_POSITION[Math.round(Math.max(1, Math.min(position, 10)))] || .01;
68
- }
69
- /**
70
- * Position score: higher for positions 4-20 (improvable range).
71
- * Peak opportunity at positions 8-15.
72
- */
73
- function calculatePositionScore(position) {
74
- if (position <= 3) return .2;
75
- if (position > 50) return .1;
76
- const distance = Math.abs(position - 11);
77
- return Math.max(0, 1 - distance / 15);
78
- }
79
- /**
80
- * Impression score using log scale.
81
- * Higher impressions = more traffic potential.
82
- */
83
- function calculateImpressionScore(impressions) {
84
- if (impressions <= 0) return 0;
85
- return Math.min(Math.log10(impressions) / 5, 1);
86
- }
87
- /**
88
- * CTR gap score: difference between actual and expected CTR.
89
- * Higher gap = more opportunity for improvement.
90
- */
91
- function calculateCtrGapScore(actualCtr, position) {
92
- const expectedCtr = getExpectedCtr(position);
93
- if (actualCtr >= expectedCtr) return 0;
94
- const gap = expectedCtr - actualCtr;
95
- return Math.min(gap / expectedCtr, 1);
96
- }
97
- const SORT_ORDER$1 = {
98
- opportunityScore: "desc",
99
- potentialClicks: "desc",
100
- impressions: "desc",
101
- position: "asc"
102
- };
103
- const sortResults$3 = createSorter((item, metric) => item[metric], "opportunityScore");
104
- /**
105
- * Scores keywords by optimization opportunity.
106
- * Composite score combining position, impressions, and CTR gap factors.
107
- */
108
- function analyzeOpportunity(keywords, options = {}) {
109
- const { minImpressions = 100, weights = {}, sortBy = "opportunityScore" } = options;
110
- const positionWeight = weights.position ?? 1;
111
- const impressionsWeight = weights.impressions ?? 1;
112
- const ctrGapWeight = weights.ctrGap ?? 1;
113
- const results = [];
114
- for (const row of keywords) {
115
- const impressions = num(row.impressions);
116
- const position = num(row.position);
117
- const ctr = num(row.ctr);
118
- const clicks = num(row.clicks);
119
- if (impressions < minImpressions) continue;
120
- const positionScore = calculatePositionScore(position);
121
- const impressionScore = calculateImpressionScore(impressions);
122
- const ctrGapScore = calculateCtrGapScore(ctr, position);
123
- const weightedProduct = positionScore ** positionWeight * impressionScore ** impressionsWeight * ctrGapScore ** ctrGapWeight;
124
- const totalWeight = positionWeight + impressionsWeight + ctrGapWeight;
125
- const geometricMean = Math.pow(weightedProduct, 1 / totalWeight);
126
- const opportunityScore = Math.round(geometricMean * 100);
127
- const targetCtr = getExpectedCtr(Math.min(3, position));
128
- const potentialClicks = Math.round(impressions * targetCtr);
129
- results.push({
130
- keyword: row.keyword,
131
- page: row.page ?? null,
132
- clicks,
133
- impressions,
134
- ctr,
135
- position,
136
- opportunityScore,
137
- potentialClicks,
138
- factors: {
139
- positionScore,
140
- impressionScore,
141
- ctrGapScore
142
- }
143
- });
144
- }
145
- return sortResults$3(results, sortBy, SORT_ORDER$1[sortBy]);
146
- }
147
-
148
20
  //#endregion
149
21
  //#region src/analysis/brand.ts
150
22
  /**
@@ -177,6 +49,153 @@ function analyzeBrandSegmentation(keywords, options) {
177
49
  };
178
50
  }
179
51
 
52
+ //#endregion
53
+ //#region src/analysis/cannibalization.ts
54
+ const sortResults$4 = createSorter((item, metric) => {
55
+ switch (metric) {
56
+ case "clicks": return item.totalClicks;
57
+ case "impressions": return item.totalImpressions;
58
+ case "positionSpread": return item.positionSpread;
59
+ case "pageCount": return item.pages.length;
60
+ }
61
+ }, "clicks");
62
+ /**
63
+ * Detects keyword cannibalization - queries ranking for multiple pages.
64
+ * Returns queries where multiple pages compete for the same search term.
65
+ *
66
+ * @param rows Query+page data rows
67
+ * @param options Filtering and sorting options
68
+ */
69
+ function analyzeCannibalization(rows, options = {}) {
70
+ const { minImpressions = 10, maxPositionSpread = 10, minPages = 2, sortBy = "clicks", sortOrder = "desc" } = options;
71
+ const queryMap = /* @__PURE__ */ new Map();
72
+ for (const row of rows) {
73
+ if (row.impressions < minImpressions) continue;
74
+ const pages = queryMap.get(row.query) || [];
75
+ pages.push({
76
+ page: row.page,
77
+ clicks: row.clicks,
78
+ impressions: row.impressions,
79
+ ctr: row.ctr,
80
+ position: row.position
81
+ });
82
+ queryMap.set(row.query, pages);
83
+ }
84
+ const results = [];
85
+ for (const [query$1, pages] of queryMap) {
86
+ if (pages.length < minPages) continue;
87
+ pages.sort((a, b) => b.clicks - a.clicks);
88
+ const positions = pages.map((p) => p.position);
89
+ const positionSpread = Math.max(...positions) - Math.min(...positions);
90
+ if (positionSpread > maxPositionSpread) continue;
91
+ results.push({
92
+ query: query$1,
93
+ pages,
94
+ totalClicks: pages.reduce((sum, p) => sum + p.clicks, 0),
95
+ totalImpressions: pages.reduce((sum, p) => sum + p.impressions, 0),
96
+ positionSpread
97
+ });
98
+ }
99
+ return sortResults$4(results, sortBy, sortOrder);
100
+ }
101
+
102
+ //#endregion
103
+ //#region src/analysis/clustering.ts
104
+ const INTENT_PREFIXES = [
105
+ "how to",
106
+ "what is",
107
+ "what are",
108
+ "why is",
109
+ "why do",
110
+ "where to",
111
+ "when to",
112
+ "best",
113
+ "top",
114
+ "vs",
115
+ "versus",
116
+ "compare",
117
+ "review",
118
+ "buy",
119
+ "cheap",
120
+ "free",
121
+ "near me"
122
+ ];
123
+ function extractIntentPrefix(keyword) {
124
+ const lower = keyword.toLowerCase();
125
+ for (const prefix of INTENT_PREFIXES) if (lower.startsWith(`${prefix} `) || lower.startsWith(prefix)) return prefix;
126
+ return null;
127
+ }
128
+ function extractWordPrefix(keyword, wordCount = 2) {
129
+ const words = keyword.toLowerCase().split(/\s+/).filter(Boolean);
130
+ if (words.length < wordCount + 1) return null;
131
+ return words.slice(0, wordCount).join(" ");
132
+ }
133
+ /**
134
+ * Clusters keywords by intent prefix or common word prefix.
135
+ * Simple regex/prefix approach - no external NLP dependencies.
136
+ *
137
+ * @param keywords Array of keyword data
138
+ * @param options Clustering options
139
+ */
140
+ function analyzeClustering(keywords, options = {}) {
141
+ const { minClusterSize = 2, minImpressions = 10, clusterBy = "both" } = options;
142
+ const filtered = keywords.filter((k) => num(k.impressions) >= minImpressions);
143
+ const clusterMap = /* @__PURE__ */ new Map();
144
+ const clusteredKeywords = /* @__PURE__ */ new Set();
145
+ if (clusterBy === "intent" || clusterBy === "both") for (const kw of filtered) {
146
+ const intent = extractIntentPrefix(kw.keyword);
147
+ if (intent) {
148
+ const existing = clusterMap.get(intent);
149
+ if (existing) existing.keywords.push(kw);
150
+ else clusterMap.set(intent, {
151
+ type: "intent",
152
+ keywords: [kw]
153
+ });
154
+ clusteredKeywords.add(kw.keyword);
155
+ }
156
+ }
157
+ if (clusterBy === "prefix" || clusterBy === "both") {
158
+ const unclustered = filtered.filter((kw) => !clusteredKeywords.has(kw.keyword));
159
+ const prefixMap = /* @__PURE__ */ new Map();
160
+ for (const kw of unclustered) {
161
+ const prefix = extractWordPrefix(kw.keyword);
162
+ if (prefix) {
163
+ const existing = prefixMap.get(prefix);
164
+ if (existing) existing.push(kw);
165
+ else prefixMap.set(prefix, [kw]);
166
+ }
167
+ }
168
+ for (const [prefix, kws] of prefixMap) if (kws.length >= minClusterSize) {
169
+ clusterMap.set(prefix, {
170
+ type: "prefix",
171
+ keywords: kws
172
+ });
173
+ kws.forEach((kw) => clusteredKeywords.add(kw.keyword));
174
+ }
175
+ }
176
+ const clusters = [];
177
+ for (const [name, data] of clusterMap) {
178
+ if (data.keywords.length < minClusterSize) continue;
179
+ const totalClicks = data.keywords.reduce((sum, k) => sum + num(k.clicks), 0);
180
+ const totalImpressions = data.keywords.reduce((sum, k) => sum + num(k.impressions), 0);
181
+ const avgPosition = data.keywords.reduce((sum, k) => sum + num(k.position), 0) / data.keywords.length;
182
+ clusters.push({
183
+ clusterName: name,
184
+ clusterType: data.type,
185
+ keywords: data.keywords,
186
+ totalClicks,
187
+ totalImpressions,
188
+ avgPosition,
189
+ keywordCount: data.keywords.length
190
+ });
191
+ }
192
+ clusters.sort((a, b) => b.totalClicks - a.totalClicks);
193
+ return {
194
+ clusters,
195
+ unclustered: filtered.filter((kw) => !clusteredKeywords.has(kw.keyword))
196
+ };
197
+ }
198
+
180
199
  //#endregion
181
200
  //#region src/analysis/concentration.ts
182
201
  /**
@@ -270,12 +289,12 @@ function analyzeKeywordConcentration(keywords, options) {
270
289
 
271
290
  //#endregion
272
291
  //#region src/analysis/decay.ts
273
- const SORT_ORDER = {
292
+ const SORT_ORDER$1 = {
274
293
  lostClicks: "desc",
275
294
  declinePercent: "desc",
276
295
  currentClicks: "asc"
277
296
  };
278
- const sortResults$2 = createSorter((item, metric) => item[metric], "lostClicks");
297
+ const sortResults$3 = createSorter((item, metric) => item[metric], "lostClicks");
279
298
  /**
280
299
  * Identifies "decaying" content - pages that have lost significant traffic.
281
300
  * Useful for finding old content that needs updating.
@@ -317,7 +336,7 @@ function analyzeDecay(input, options = {}) {
317
336
  positionDrop: curr.position - prev.position
318
337
  });
319
338
  }
320
- return sortResults$2(results, sortBy, SORT_ORDER[sortBy]);
339
+ return sortResults$3(results, sortBy, SORT_ORDER$1[sortBy]);
321
340
  }
322
341
 
323
342
  //#endregion
@@ -402,84 +421,97 @@ function analyzeMovers(input, options = {}) {
402
421
  }
403
422
 
404
423
  //#endregion
405
- //#region src/analysis/cannibalization.ts
406
- const sortResults$1 = createSorter((item, metric) => {
407
- switch (metric) {
408
- case "clicks": return item.totalClicks;
409
- case "impressions": return item.totalImpressions;
410
- case "positionSpread": return item.positionSpread;
411
- case "pageCount": return item.pages.length;
412
- }
413
- }, "clicks");
424
+ //#region src/analysis/opportunity.ts
425
+ const EXPECTED_CTR_BY_POSITION = {
426
+ 1: .3,
427
+ 2: .15,
428
+ 3: .1,
429
+ 4: .07,
430
+ 5: .05,
431
+ 6: .04,
432
+ 7: .03,
433
+ 8: .025,
434
+ 9: .02,
435
+ 10: .015
436
+ };
437
+ function getExpectedCtr(position) {
438
+ return EXPECTED_CTR_BY_POSITION[Math.round(Math.max(1, Math.min(position, 10)))] || .01;
439
+ }
414
440
  /**
415
- * Detects keyword cannibalization - queries ranking for multiple pages.
416
- * Returns queries where multiple pages compete for the same search term.
417
- *
418
- * @param rows Query+page data rows
419
- * @param options Filtering and sorting options
441
+ * Position score: higher for positions 4-20 (improvable range).
442
+ * Peak opportunity at positions 8-15.
420
443
  */
421
- function analyzeCannibalization(rows, options = {}) {
422
- const { minImpressions = 10, maxPositionSpread = 10, minPages = 2, sortBy = "clicks", sortOrder = "desc" } = options;
423
- const queryMap = /* @__PURE__ */ new Map();
424
- for (const row of rows) {
425
- if (row.impressions < minImpressions) continue;
426
- const pages = queryMap.get(row.query) || [];
427
- pages.push({
428
- page: row.page,
429
- clicks: row.clicks,
430
- impressions: row.impressions,
431
- ctr: row.ctr,
432
- position: row.position
433
- });
434
- queryMap.set(row.query, pages);
435
- }
436
- const results = [];
437
- for (const [query$1, pages] of queryMap) {
438
- if (pages.length < minPages) continue;
439
- pages.sort((a, b) => b.clicks - a.clicks);
440
- const positions = pages.map((p) => p.position);
441
- const positionSpread = Math.max(...positions) - Math.min(...positions);
442
- if (positionSpread > maxPositionSpread) continue;
443
- results.push({
444
- query: query$1,
445
- pages,
446
- totalClicks: pages.reduce((sum, p) => sum + p.clicks, 0),
447
- totalImpressions: pages.reduce((sum, p) => sum + p.impressions, 0),
448
- positionSpread
449
- });
450
- }
451
- return sortResults$1(results, sortBy, sortOrder);
444
+ function calculatePositionScore(position) {
445
+ if (position <= 3) return .2;
446
+ if (position > 50) return .1;
447
+ const distance = Math.abs(position - 11);
448
+ return Math.max(0, 1 - distance / 15);
452
449
  }
453
-
454
- //#endregion
455
- //#region src/analysis/zero-click.ts
456
- const sortResults = createSorter((item) => item.impressions, "impressions");
457
450
  /**
458
- * Identifies potential "Zero-Click" queries.
459
- * These are high-volume queries where you rank well but get few clicks,
460
- * often due to SERP features (Answer Boxes, Knowledge Panels, AI Overviews).
461
- *
462
- * @param rows Query+page data rows
463
- * @param options Filtering options
451
+ * Impression score using log scale.
452
+ * Higher impressions = more traffic potential.
464
453
  */
465
- function analyzeZeroClick(rows, options = {}) {
466
- const { minImpressions = 1e3, maxCtr = .03, maxPosition = 10 } = options;
467
- const queryMap = /* @__PURE__ */ new Map();
468
- for (const row of rows) {
469
- if (row.impressions < minImpressions) continue;
470
- if (row.position > maxPosition) continue;
471
- if (row.ctr > maxCtr) continue;
472
- const existing = queryMap.get(row.query);
473
- if (!existing || row.position < existing.position) queryMap.set(row.query, {
474
- query: row.query,
475
- page: row.page,
476
- clicks: row.clicks,
477
- impressions: row.impressions,
478
- ctr: row.ctr,
479
- position: row.position
454
+ function calculateImpressionScore(impressions) {
455
+ if (impressions <= 0) return 0;
456
+ return Math.min(Math.log10(impressions) / 5, 1);
457
+ }
458
+ /**
459
+ * CTR gap score: difference between actual and expected CTR.
460
+ * Higher gap = more opportunity for improvement.
461
+ */
462
+ function calculateCtrGapScore(actualCtr, position) {
463
+ const expectedCtr = getExpectedCtr(position);
464
+ if (actualCtr >= expectedCtr) return 0;
465
+ const gap = expectedCtr - actualCtr;
466
+ return Math.min(gap / expectedCtr, 1);
467
+ }
468
+ const SORT_ORDER = {
469
+ opportunityScore: "desc",
470
+ potentialClicks: "desc",
471
+ impressions: "desc",
472
+ position: "asc"
473
+ };
474
+ const sortResults$2 = createSorter((item, metric) => item[metric], "opportunityScore");
475
+ /**
476
+ * Scores keywords by optimization opportunity.
477
+ * Composite score combining position, impressions, and CTR gap factors.
478
+ */
479
+ function analyzeOpportunity(keywords, options = {}) {
480
+ const { minImpressions = 100, weights = {}, sortBy = "opportunityScore" } = options;
481
+ const positionWeight = weights.position ?? 1;
482
+ const impressionsWeight = weights.impressions ?? 1;
483
+ const ctrGapWeight = weights.ctrGap ?? 1;
484
+ const results = [];
485
+ for (const row of keywords) {
486
+ const impressions = num(row.impressions);
487
+ const position = num(row.position);
488
+ const ctr = num(row.ctr);
489
+ const clicks = num(row.clicks);
490
+ if (impressions < minImpressions) continue;
491
+ const positionScore = calculatePositionScore(position);
492
+ const impressionScore = calculateImpressionScore(impressions);
493
+ const ctrGapScore = calculateCtrGapScore(ctr, position);
494
+ const geometricMean = (positionScore ** positionWeight * impressionScore ** impressionsWeight * ctrGapScore ** ctrGapWeight) ** (1 / (positionWeight + impressionsWeight + ctrGapWeight));
495
+ const opportunityScore = Math.round(geometricMean * 100);
496
+ const targetCtr = getExpectedCtr(Math.min(3, position));
497
+ const potentialClicks = Math.round(impressions * targetCtr);
498
+ results.push({
499
+ keyword: row.keyword,
500
+ page: row.page ?? null,
501
+ clicks,
502
+ impressions,
503
+ ctr,
504
+ position,
505
+ opportunityScore,
506
+ potentialClicks,
507
+ factors: {
508
+ positionScore,
509
+ impressionScore,
510
+ ctrGapScore
511
+ }
480
512
  });
481
513
  }
482
- return sortResults(Array.from(queryMap.values()), "impressions", "desc");
514
+ return sortResults$2(results, sortBy, SORT_ORDER[sortBy]);
483
515
  }
484
516
 
485
517
  //#endregion
@@ -549,100 +581,66 @@ function analyzeSeasonality(dates, options = {}) {
549
581
  }
550
582
 
551
583
  //#endregion
552
- //#region src/analysis/clustering.ts
553
- const INTENT_PREFIXES = [
554
- "how to",
555
- "what is",
556
- "what are",
557
- "why is",
558
- "why do",
559
- "where to",
560
- "when to",
561
- "best",
562
- "top",
563
- "vs",
564
- "versus",
565
- "compare",
566
- "review",
567
- "buy",
568
- "cheap",
569
- "free",
570
- "near me"
571
- ];
572
- function extractIntentPrefix(keyword) {
573
- const lower = keyword.toLowerCase();
574
- for (const prefix of INTENT_PREFIXES) if (lower.startsWith(prefix + " ") || lower.startsWith(prefix)) return prefix;
575
- return null;
576
- }
577
- function extractWordPrefix(keyword, wordCount = 2) {
578
- const words = keyword.toLowerCase().split(/\s+/).filter(Boolean);
579
- if (words.length < wordCount + 1) return null;
580
- return words.slice(0, wordCount).join(" ");
581
- }
584
+ //#region src/analysis/striking-distance.ts
585
+ const sortResults$1 = createSorter((item, metric) => item[metric], "potentialClicks");
586
+ /**
587
+ * Finds striking distance keywords - high impressions, low CTR, position 4-20.
588
+ * These are "quick wins" that could gain significant traffic with small ranking improvements.
589
+ */
590
+ function analyzeStrikingDistance(keywords, options = {}) {
591
+ const { minPosition = 4, maxPosition = 20, minImpressions = 100, maxCtr = .05, sortBy = "potentialClicks", sortOrder = "desc" } = options;
592
+ const results = [];
593
+ for (const row of keywords) {
594
+ const position = num(row.position);
595
+ const impressions = num(row.impressions);
596
+ const ctr = num(row.ctr);
597
+ const clicks = num(row.clicks);
598
+ if (position < minPosition || position > maxPosition) continue;
599
+ if (impressions < minImpressions) continue;
600
+ if (ctr > maxCtr) continue;
601
+ const potentialClicks = Math.round(impressions * .15);
602
+ results.push({
603
+ keyword: row.keyword,
604
+ page: row.page ?? null,
605
+ clicks,
606
+ impressions,
607
+ ctr,
608
+ position,
609
+ potentialClicks
610
+ });
611
+ }
612
+ return sortResults$1(results, sortBy, sortOrder);
613
+ }
614
+
615
+ //#endregion
616
+ //#region src/analysis/zero-click.ts
617
+ const sortResults = createSorter((item) => item.impressions, "impressions");
582
618
  /**
583
- * Clusters keywords by intent prefix or common word prefix.
584
- * Simple regex/prefix approach - no external NLP dependencies.
619
+ * Identifies potential "Zero-Click" queries.
620
+ * These are high-volume queries where you rank well but get few clicks,
621
+ * often due to SERP features (Answer Boxes, Knowledge Panels, AI Overviews).
585
622
  *
586
- * @param keywords Array of keyword data
587
- * @param options Clustering options
623
+ * @param rows Query+page data rows
624
+ * @param options Filtering options
588
625
  */
589
- function analyzeClustering(keywords, options = {}) {
590
- const { minClusterSize = 2, minImpressions = 10, clusterBy = "both" } = options;
591
- const filtered = keywords.filter((k) => num(k.impressions) >= minImpressions);
592
- const clusterMap = /* @__PURE__ */ new Map();
593
- const clusteredKeywords = /* @__PURE__ */ new Set();
594
- if (clusterBy === "intent" || clusterBy === "both") for (const kw of filtered) {
595
- const intent = extractIntentPrefix(kw.keyword);
596
- if (intent) {
597
- const existing = clusterMap.get(intent);
598
- if (existing) existing.keywords.push(kw);
599
- else clusterMap.set(intent, {
600
- type: "intent",
601
- keywords: [kw]
602
- });
603
- clusteredKeywords.add(kw.keyword);
604
- }
605
- }
606
- if (clusterBy === "prefix" || clusterBy === "both") {
607
- const unclustered = filtered.filter((kw) => !clusteredKeywords.has(kw.keyword));
608
- const prefixMap = /* @__PURE__ */ new Map();
609
- for (const kw of unclustered) {
610
- const prefix = extractWordPrefix(kw.keyword);
611
- if (prefix) {
612
- const existing = prefixMap.get(prefix);
613
- if (existing) existing.push(kw);
614
- else prefixMap.set(prefix, [kw]);
615
- }
616
- }
617
- for (const [prefix, kws] of prefixMap) if (kws.length >= minClusterSize) {
618
- clusterMap.set(prefix, {
619
- type: "prefix",
620
- keywords: kws
621
- });
622
- kws.forEach((kw) => clusteredKeywords.add(kw.keyword));
623
- }
624
- }
625
- const clusters = [];
626
- for (const [name, data] of clusterMap) {
627
- if (data.keywords.length < minClusterSize) continue;
628
- const totalClicks = data.keywords.reduce((sum, k) => sum + num(k.clicks), 0);
629
- const totalImpressions = data.keywords.reduce((sum, k) => sum + num(k.impressions), 0);
630
- const avgPosition = data.keywords.reduce((sum, k) => sum + num(k.position), 0) / data.keywords.length;
631
- clusters.push({
632
- clusterName: name,
633
- clusterType: data.type,
634
- keywords: data.keywords,
635
- totalClicks,
636
- totalImpressions,
637
- avgPosition,
638
- keywordCount: data.keywords.length
626
+ function analyzeZeroClick(rows, options = {}) {
627
+ const { minImpressions = 1e3, maxCtr = .03, maxPosition = 10 } = options;
628
+ const queryMap = /* @__PURE__ */ new Map();
629
+ for (const row of rows) {
630
+ if (row.impressions < minImpressions) continue;
631
+ if (row.position > maxPosition) continue;
632
+ if (row.ctr > maxCtr) continue;
633
+ const existing = queryMap.get(row.query);
634
+ if (!existing || row.position < existing.position) queryMap.set(row.query, {
635
+ query: row.query,
636
+ page: row.page,
637
+ clicks: row.clicks,
638
+ impressions: row.impressions,
639
+ ctr: row.ctr,
640
+ position: row.position
639
641
  });
640
642
  }
641
- clusters.sort((a, b) => b.totalClicks - a.totalClicks);
642
- return {
643
- clusters,
644
- unclustered: filtered.filter((kw) => !clusteredKeywords.has(kw.keyword))
645
- };
643
+ return sortResults(Array.from(queryMap.values()), "impressions", "desc");
646
644
  }
647
645
 
648
646
  //#endregion
@@ -721,52 +719,6 @@ async function batchInspectUrls(client, siteUrl, urls, options = {}) {
721
719
  return results;
722
720
  }
723
721
 
724
- //#endregion
725
- //#region src/api/sites.ts
726
- /**
727
- * Fetches all sites the authenticated user has access to in Google Search Console.
728
- */
729
- async function fetchSites(client) {
730
- return client.sites.list().then((res) => res?.siteEntry || []);
731
- }
732
- /**
733
- * Fetches sitemaps for a site.
734
- */
735
- async function fetchSitemaps(client, siteUrl) {
736
- return client.sitemaps.list(siteUrl).then((res) => res.sitemap || []);
737
- }
738
- /**
739
- * Gets details for a specific sitemap.
740
- */
741
- async function fetchSitemap(client, siteUrl, feedpath) {
742
- return client.sitemaps.get(siteUrl, feedpath);
743
- }
744
- /**
745
- * Submits a sitemap to Google Search Console.
746
- */
747
- async function submitSitemap(client, siteUrl, feedpath) {
748
- return client.sitemaps.submit(siteUrl, feedpath);
749
- }
750
- /**
751
- * Deletes a sitemap from Google Search Console.
752
- */
753
- async function deleteSitemap(client, siteUrl, feedpath) {
754
- return client.sitemaps.delete(siteUrl, feedpath);
755
- }
756
- /**
757
- * Fetches all verified sites with their sitemaps from Google Search Console.
758
- */
759
- async function fetchSitesWithSitemaps(client) {
760
- const sites = (await client.sites.list().then((res) => res.siteEntry || [])).filter((s) => !!s.siteUrl && s.permissionLevel !== "siteUnverifiedUser");
761
- return Promise.all(sites.map(async (site) => {
762
- const sitemaps = site.permissionLevel === "siteOwner" ? await client.sitemaps.list(site.siteUrl).then((res) => res.sitemap || []) : [];
763
- return {
764
- ...site,
765
- sitemaps
766
- };
767
- }));
768
- }
769
-
770
722
  //#endregion
771
723
  //#region src/utils/dayjs.ts
772
724
  _dayjs.extend(utc);
@@ -774,6 +726,12 @@ _dayjs.extend(timezone);
774
726
  function dayjs(date$1) {
775
727
  return _dayjs(date$1);
776
728
  }
729
+ function currentPstDate() {
730
+ return dayjs().tz("America/Los_Angeles").hour(12).minute(0).second(0).format("YYYY-MM-DD");
731
+ }
732
+ function dayjsPst() {
733
+ return dayjs().tz("America/Los_Angeles").hour(12).minute(0).second(0);
734
+ }
777
735
 
778
736
  //#endregion
779
737
  //#region src/utils/format.ts
@@ -798,59 +756,6 @@ function percentDifference(a, b) {
798
756
  if (!b || !a) return 0;
799
757
  return (a - b) / ((a + b) / 2) * 100;
800
758
  }
801
- /**
802
- * Resolves a period string or object into start/end dates for current and previous periods.
803
- * @param periodRange - Period string (e.g., '30d', '3mo', 'all') or Period object with start/end dates
804
- * @returns Resolved period ranges with timestamps and formatted dates for both current and previous periods
805
- */
806
- function userPeriodRange(periodRange = "30d") {
807
- let startPeriod;
808
- let endPeriod;
809
- let startPrevPeriod;
810
- let endPrevPeriod;
811
- if (typeof periodRange === "string") {
812
- endPeriod = dayjs();
813
- if (periodRange === "all") {
814
- startPeriod = dayjs().subtract(100, "year");
815
- startPrevPeriod = dayjs().subtract(200, "year");
816
- endPrevPeriod = dayjs().subtract(100, "year").subtract(1, "day");
817
- } else if (periodRange === "max") {
818
- const maxDays = 480;
819
- startPeriod = endPeriod.clone().subtract(maxDays, "day");
820
- startPrevPeriod = endPeriod.clone().subtract(maxDays * 2, "day");
821
- endPrevPeriod = endPeriod.clone().subtract(maxDays + 1, "day");
822
- } else {
823
- const periodDays = periodRange.includes("d") ? Number.parseInt(periodRange.replace("d", "")) : Number.parseInt(periodRange.replace("mo", "")) * 30;
824
- startPeriod = endPeriod.clone().subtract(periodDays, "day");
825
- startPrevPeriod = endPeriod.clone().subtract(periodDays * 2, "day");
826
- endPrevPeriod = endPeriod.clone().subtract(periodDays + 1, "day");
827
- }
828
- } else {
829
- startPeriod = dayjs(periodRange.start);
830
- endPeriod = dayjs(periodRange.end);
831
- const dayDiff = endPeriod.diff(startPeriod, "day");
832
- startPrevPeriod = dayjs(periodRange.start).subtract(dayDiff, "day");
833
- endPrevPeriod = dayjs(periodRange.end).subtract(dayDiff, "day");
834
- }
835
- return {
836
- period: {
837
- startTimestamp: startPeriod.valueOf(),
838
- start: startPeriod.toDate(),
839
- startDateTime: startPeriod.format("YYYY-MM-DD HH:mm:ss"),
840
- startDate: startPeriod.format("YYYY-MM-DD"),
841
- end: endPeriod.toDate(),
842
- endDate: endPeriod.format("YYYY-MM-DD"),
843
- endTimestamp: endPeriod.valueOf(),
844
- endDateTime: endPeriod.format("YYYY-MM-DD HH:mm:ss")
845
- },
846
- prevPeriod: {
847
- start: startPrevPeriod.toDate(),
848
- startDate: startPrevPeriod.format("YYYY-MM-DD"),
849
- end: endPrevPeriod.toDate(),
850
- endDate: endPrevPeriod.format("YYYY-MM-DD")
851
- }
852
- };
853
- }
854
759
 
855
760
  //#endregion
856
761
  //#region src/api/search-analytics/query.ts
@@ -3209,6 +3114,52 @@ async function collectStream(gen) {
3209
3114
  return all;
3210
3115
  }
3211
3116
 
3117
+ //#endregion
3118
+ //#region src/api/sites.ts
3119
+ /**
3120
+ * Fetches all sites the authenticated user has access to in Google Search Console.
3121
+ */
3122
+ async function fetchSites(client) {
3123
+ return client.sites.list().then((res) => res?.siteEntry || []);
3124
+ }
3125
+ /**
3126
+ * Fetches sitemaps for a site.
3127
+ */
3128
+ async function fetchSitemaps(client, siteUrl) {
3129
+ return client.sitemaps.list(siteUrl).then((res) => res.sitemap || []);
3130
+ }
3131
+ /**
3132
+ * Gets details for a specific sitemap.
3133
+ */
3134
+ async function fetchSitemap(client, siteUrl, feedpath) {
3135
+ return client.sitemaps.get(siteUrl, feedpath);
3136
+ }
3137
+ /**
3138
+ * Submits a sitemap to Google Search Console.
3139
+ */
3140
+ async function submitSitemap(client, siteUrl, feedpath) {
3141
+ return client.sitemaps.submit(siteUrl, feedpath);
3142
+ }
3143
+ /**
3144
+ * Deletes a sitemap from Google Search Console.
3145
+ */
3146
+ async function deleteSitemap(client, siteUrl, feedpath) {
3147
+ return client.sitemaps.delete(siteUrl, feedpath);
3148
+ }
3149
+ /**
3150
+ * Fetches all verified sites with their sitemaps from Google Search Console.
3151
+ */
3152
+ async function fetchSitesWithSitemaps(client) {
3153
+ const sites = (await client.sites.list().then((res) => res.siteEntry || [])).filter((s) => !!s.siteUrl && s.permissionLevel !== "siteUnverifiedUser");
3154
+ return Promise.all(sites.map(async (site) => {
3155
+ const sitemaps = site.permissionLevel === "siteOwner" ? await client.sitemaps.list(site.siteUrl).then((res) => res.sitemap || []) : [];
3156
+ return {
3157
+ ...site,
3158
+ sitemaps
3159
+ };
3160
+ }));
3161
+ }
3162
+
3212
3163
  //#endregion
3213
3164
  //#region src/core/client.ts
3214
3165
  const GSC_API = "https://searchconsole.googleapis.com";
@@ -3645,6 +3596,15 @@ const country = createColumn("country");
3645
3596
  const searchAppearance = createColumn("searchAppearance");
3646
3597
  const date = createColumn("date");
3647
3598
 
3599
+ //#endregion
3600
+ //#region src/query/constants.ts
3601
+ const Device = {
3602
+ MOBILE: "MOBILE",
3603
+ DESKTOP: "DESKTOP",
3604
+ TABLET: "TABLET"
3605
+ };
3606
+ const Country = Object.fromEntries(countries_default.map((c) => [c["alpha-3"], c["alpha-3"].toLowerCase()]));
3607
+
3648
3608
  //#endregion
3649
3609
  //#region src/query/operators.ts
3650
3610
  function eq(column, value) {
@@ -3811,13 +3771,4 @@ function between(column, start, end) {
3811
3771
  }
3812
3772
 
3813
3773
  //#endregion
3814
- //#region src/query/constants.ts
3815
- const Device = {
3816
- MOBILE: "MOBILE",
3817
- DESKTOP: "DESKTOP",
3818
- TABLET: "TABLET"
3819
- };
3820
- const Country = Object.fromEntries(countries_default.map((c) => [c["alpha-3"], c["alpha-3"].toLowerCase()]));
3821
-
3822
- //#endregion
3823
- export { Country, Device, GSC_QUOTAS, analyzeBrandSegmentation, analyzeCannibalization, analyzeClustering, analyzeConcentration, analyzeDecay, analyzeError, analyzeKeywordConcentration, analyzeMovers, analyzeOpportunity, analyzePageConcentration, analyzeSeasonality, analyzeStrikingDistance, analyzeZeroClick, and, batchInspectUrls, batchRequestIndexing, between, collectStream, contains, countries_default as countries, country, createAuth, createFetch, createQueryBody, createSorter, date, dayjs, deleteSitemap, device, eq, extractDateRange, fetchAnalyticsWithComparison, fetchCountries, fetchCountriesWithComparison, fetchDates, fetchDatesWithComparison, fetchDevices, fetchDevicesWithComparison, fetchKeyword, fetchKeywords, fetchKeywordsWithComparison, fetchPage, fetchPages, fetchPagesWithComparison, fetchSearchAppearance, fetchSearchAppearanceWithComparison, fetchSitemap, fetchSitemaps, fetchSites, fetchSitesWithSitemaps, fetchYoYComparison, formatDateGsc, formatErrorForCli, getErrorCode, getErrorMessage, getIndexingMetadata, getRetryAfter, googleSearchConsole, gsc, gt, gte, inArray, inspectUrl, isAuthError, isQuotaError, isRateLimitError, like, lt, lte, ne, not, notRegex, num, or, page, percentDifference, query, queryRecursive, queryRecursiveStream, regex, requestIndexing, searchAppearance, submitSitemap, userPeriodRange, withDataType, withFinalData, withFreshData, withPropertyAggregation, withSearchAppearance };
3774
+ export { Country, Device, GSC_QUOTAS, analyzeBrandSegmentation, analyzeCannibalization, analyzeClustering, analyzeConcentration, analyzeDecay, analyzeError, analyzeKeywordConcentration, analyzeMovers, analyzeOpportunity, analyzePageConcentration, analyzeSeasonality, analyzeStrikingDistance, analyzeZeroClick, and, batchInspectUrls, batchRequestIndexing, between, collectStream, contains, countries_default as countries, country, createAuth, createFetch, createQueryBody, createSorter, currentPstDate, date, dayjs, dayjsPst, deleteSitemap, device, eq, extractDateRange, fetchAnalyticsWithComparison, fetchCountries, fetchCountriesWithComparison, fetchDates, fetchDatesWithComparison, fetchDevices, fetchDevicesWithComparison, fetchKeyword, fetchKeywords, fetchKeywordsWithComparison, fetchPage, fetchPages, fetchPagesWithComparison, fetchSearchAppearance, fetchSearchAppearanceWithComparison, fetchSitemap, fetchSitemaps, fetchSites, fetchSitesWithSitemaps, fetchYoYComparison, formatDateGsc, formatErrorForCli, getErrorCode, getErrorMessage, getIndexingMetadata, getRetryAfter, googleSearchConsole, gsc, gt, gte, inArray, inspectUrl, isAuthError, isQuotaError, isRateLimitError, like, lt, lte, ne, not, notRegex, num, or, page, percentDifference, query, queryRecursive, queryRecursiveStream, regex, requestIndexing, searchAppearance, submitSitemap, withDataType, withFinalData, withFreshData, withPropertyAggregation, withSearchAppearance };