gscdump 0.1.3 → 0.3.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.
@@ -0,0 +1,872 @@
1
+ //#region src/analysis/types.ts
2
+ /** Coerce nullable number to number, defaulting to 0 */
3
+ function num(value) {
4
+ return value ?? 0;
5
+ }
6
+ /** Create a generic sorter for any metric type */
7
+ function createSorter(getValue, defaultMetric, defaultOrder = "desc") {
8
+ return (items, sortBy = defaultMetric, sortOrder = defaultOrder) => {
9
+ const mult = sortOrder === "desc" ? -1 : 1;
10
+ return [...items].sort((a, b) => (getValue(a, sortBy) - getValue(b, sortBy)) * mult);
11
+ };
12
+ }
13
+
14
+ //#endregion
15
+ //#region src/analysis/brand.ts
16
+ /**
17
+ * Segments keywords into brand and non-brand based on provided brand terms.
18
+ */
19
+ function analyzeBrandSegmentation(keywords, options) {
20
+ const { brandTerms, minImpressions = 10 } = options;
21
+ const lowerBrandTerms = brandTerms.map((t) => t.toLowerCase());
22
+ const brand = [];
23
+ const nonBrand = [];
24
+ for (const row of keywords) {
25
+ if (num(row.impressions) < minImpressions) continue;
26
+ if (lowerBrandTerms.some((term) => row.query.toLowerCase().includes(term))) brand.push(row);
27
+ else nonBrand.push(row);
28
+ }
29
+ const brandClicks = brand.reduce((sum, k) => sum + num(k.clicks), 0);
30
+ const nonBrandClicks = nonBrand.reduce((sum, k) => sum + num(k.clicks), 0);
31
+ const totalClicks = brandClicks + nonBrandClicks;
32
+ return {
33
+ brand,
34
+ nonBrand,
35
+ summary: {
36
+ brandClicks,
37
+ nonBrandClicks,
38
+ brandShare: totalClicks > 0 ? brandClicks / totalClicks : 0,
39
+ brandImpressions: brand.reduce((sum, k) => sum + num(k.impressions), 0),
40
+ nonBrandImpressions: nonBrand.reduce((sum, k) => sum + num(k.impressions), 0)
41
+ }
42
+ };
43
+ }
44
+
45
+ //#endregion
46
+ //#region src/analysis/clustering.ts
47
+ const INTENT_PREFIXES = [
48
+ "how to",
49
+ "what is",
50
+ "what are",
51
+ "why is",
52
+ "why do",
53
+ "where to",
54
+ "when to",
55
+ "best",
56
+ "top",
57
+ "vs",
58
+ "versus",
59
+ "compare",
60
+ "review",
61
+ "buy",
62
+ "cheap",
63
+ "free",
64
+ "near me"
65
+ ];
66
+ function extractIntentPrefix(keyword) {
67
+ const lower = keyword.toLowerCase();
68
+ for (const prefix of INTENT_PREFIXES) if (lower.startsWith(`${prefix} `) || lower.startsWith(prefix)) return prefix;
69
+ return null;
70
+ }
71
+ function extractWordPrefix(keyword, wordCount = 2) {
72
+ const words = keyword.toLowerCase().split(/\s+/).filter(Boolean);
73
+ if (words.length < wordCount + 1) return null;
74
+ return words.slice(0, wordCount).join(" ");
75
+ }
76
+ /**
77
+ * Clusters keywords by intent prefix or common word prefix.
78
+ */
79
+ function analyzeClustering(keywords, options = {}) {
80
+ const { minClusterSize = 2, minImpressions = 10, clusterBy = "both" } = options;
81
+ const filtered = keywords.filter((k) => num(k.impressions) >= minImpressions);
82
+ const clusterMap = /* @__PURE__ */ new Map();
83
+ const clusteredKeywords = /* @__PURE__ */ new Set();
84
+ if (clusterBy === "intent" || clusterBy === "both") for (const kw of filtered) {
85
+ const intent = extractIntentPrefix(kw.query);
86
+ if (intent) {
87
+ const existing = clusterMap.get(intent);
88
+ if (existing) existing.keywords.push(kw);
89
+ else clusterMap.set(intent, {
90
+ type: "intent",
91
+ keywords: [kw]
92
+ });
93
+ clusteredKeywords.add(kw.query);
94
+ }
95
+ }
96
+ if (clusterBy === "prefix" || clusterBy === "both") {
97
+ const unclustered = filtered.filter((kw) => !clusteredKeywords.has(kw.query));
98
+ const prefixMap = /* @__PURE__ */ new Map();
99
+ for (const kw of unclustered) {
100
+ const prefix = extractWordPrefix(kw.query);
101
+ if (prefix) {
102
+ const existing = prefixMap.get(prefix);
103
+ if (existing) existing.push(kw);
104
+ else prefixMap.set(prefix, [kw]);
105
+ }
106
+ }
107
+ for (const [prefix, kws] of prefixMap) if (kws.length >= minClusterSize) {
108
+ clusterMap.set(prefix, {
109
+ type: "prefix",
110
+ keywords: kws
111
+ });
112
+ kws.forEach((kw) => clusteredKeywords.add(kw.query));
113
+ }
114
+ }
115
+ const clusters = [];
116
+ for (const [name, data] of clusterMap) {
117
+ if (data.keywords.length < minClusterSize) continue;
118
+ const totalClicks = data.keywords.reduce((sum, k) => sum + num(k.clicks), 0);
119
+ const totalImpressions = data.keywords.reduce((sum, k) => sum + num(k.impressions), 0);
120
+ const avgPosition = data.keywords.reduce((sum, k) => sum + num(k.position), 0) / data.keywords.length;
121
+ clusters.push({
122
+ clusterName: name,
123
+ clusterType: data.type,
124
+ keywords: data.keywords,
125
+ totalClicks,
126
+ totalImpressions,
127
+ avgPosition,
128
+ keywordCount: data.keywords.length
129
+ });
130
+ }
131
+ clusters.sort((a, b) => b.totalClicks - a.totalClicks);
132
+ return {
133
+ clusters,
134
+ unclustered: filtered.filter((kw) => !clusteredKeywords.has(kw.query))
135
+ };
136
+ }
137
+
138
+ //#endregion
139
+ //#region src/analysis/concentration.ts
140
+ function calculateGini(values) {
141
+ if (values.length === 0) return 0;
142
+ const sorted = [...values].sort((a, b) => a - b);
143
+ const n = sorted.length;
144
+ const sum = sorted.reduce((a, b) => a + b, 0);
145
+ if (sum === 0) return 0;
146
+ let weightedSum = 0;
147
+ for (let i = 0; i < n; i++) weightedSum += (2 * (i + 1) - n - 1) * sorted[i];
148
+ return weightedSum / (n * sum);
149
+ }
150
+ function calculateHHI(shares) {
151
+ return shares.reduce((sum, share) => sum + (share * 100) ** 2, 0);
152
+ }
153
+ /**
154
+ * Analyzes traffic concentration across items (pages or keywords).
155
+ */
156
+ function analyzeConcentration(items, options = {}) {
157
+ const { topN = 10 } = options;
158
+ if (items.length === 0) return {
159
+ giniCoefficient: 0,
160
+ hhi: 0,
161
+ topNConcentration: 0,
162
+ topNItems: [],
163
+ totalItems: 0,
164
+ totalClicks: 0,
165
+ riskLevel: "low"
166
+ };
167
+ const sorted = [...items].sort((a, b) => b.clicks - a.clicks);
168
+ const totalClicks = sorted.reduce((sum, item) => sum + item.clicks, 0);
169
+ const clickValues = sorted.map((i) => i.clicks);
170
+ const shares = totalClicks > 0 ? sorted.map((i) => i.clicks / totalClicks) : [];
171
+ const giniCoefficient = calculateGini(clickValues);
172
+ const hhi = calculateHHI(shares);
173
+ const topNItems = sorted.slice(0, topN).map((item) => ({
174
+ key: item.key,
175
+ clicks: item.clicks,
176
+ share: totalClicks > 0 ? item.clicks / totalClicks : 0
177
+ }));
178
+ const topNClicks = topNItems.reduce((sum, item) => sum + item.clicks, 0);
179
+ const topNConcentration = totalClicks > 0 ? topNClicks / totalClicks : 0;
180
+ let riskLevel = "low";
181
+ if (hhi > 2500) riskLevel = "high";
182
+ else if (hhi > 1500) riskLevel = "medium";
183
+ return {
184
+ giniCoefficient,
185
+ hhi,
186
+ topNConcentration,
187
+ topNItems,
188
+ totalItems: items.length,
189
+ totalClicks,
190
+ riskLevel
191
+ };
192
+ }
193
+ /**
194
+ * Page concentration analysis.
195
+ */
196
+ function analyzePageConcentration(pages, options) {
197
+ return analyzeConcentration(pages.map((p) => ({
198
+ key: p.page,
199
+ clicks: num(p.clicks)
200
+ })), options);
201
+ }
202
+ /**
203
+ * Keyword concentration analysis.
204
+ */
205
+ function analyzeKeywordConcentration(keywords, options) {
206
+ return analyzeConcentration(keywords.map((k) => ({
207
+ key: k.query,
208
+ clicks: num(k.clicks)
209
+ })), options);
210
+ }
211
+
212
+ //#endregion
213
+ //#region src/analysis/decay.ts
214
+ const SORT_ORDER$1 = {
215
+ lostClicks: "desc",
216
+ declinePercent: "desc",
217
+ currentClicks: "asc"
218
+ };
219
+ const sortResults$2 = createSorter((item, metric) => item[metric], "lostClicks");
220
+ /**
221
+ * Identifies "decaying" content - pages that have lost significant traffic.
222
+ */
223
+ function analyzeDecay(input, options = {}) {
224
+ const { minPreviousClicks = 50, threshold = .2, sortBy = "lostClicks" } = options;
225
+ const currentMap = /* @__PURE__ */ new Map();
226
+ for (const row of input.current) currentMap.set(row.page, {
227
+ clicks: num(row.clicks),
228
+ position: num(row.position)
229
+ });
230
+ const previousMap = /* @__PURE__ */ new Map();
231
+ for (const row of input.previous) {
232
+ const clicks$1 = num(row.clicks);
233
+ if (clicks$1 >= minPreviousClicks) previousMap.set(row.page, {
234
+ clicks: clicks$1,
235
+ position: num(row.position)
236
+ });
237
+ }
238
+ const results = [];
239
+ for (const [page$1, prev] of previousMap) {
240
+ const curr = currentMap.get(page$1) || {
241
+ clicks: 0,
242
+ position: 0
243
+ };
244
+ const lostClicks = prev.clicks - curr.clicks;
245
+ const declinePercent = prev.clicks > 0 ? lostClicks / prev.clicks : 0;
246
+ if (declinePercent >= threshold && lostClicks > 0) results.push({
247
+ page: page$1,
248
+ currentClicks: curr.clicks,
249
+ previousClicks: prev.clicks,
250
+ lostClicks,
251
+ declinePercent,
252
+ currentPosition: curr.position,
253
+ previousPosition: prev.position,
254
+ positionDrop: curr.position - prev.position
255
+ });
256
+ }
257
+ return sortResults$2(results, sortBy, SORT_ORDER$1[sortBy]);
258
+ }
259
+
260
+ //#endregion
261
+ //#region src/query/resolver.ts
262
+ const DATE_OPERATORS = [
263
+ "gte",
264
+ "gt",
265
+ "lte",
266
+ "lt",
267
+ "between"
268
+ ];
269
+ const METRIC_OPERATORS = [
270
+ "metricGte",
271
+ "metricGt",
272
+ "metricLte",
273
+ "metricLt",
274
+ "metricBetween"
275
+ ];
276
+ const SPECIAL_OPERATORS = ["topLevel"];
277
+ const QUERY_PARAMS = ["searchType"];
278
+ function isMetricOperator(op) {
279
+ return METRIC_OPERATORS.includes(op);
280
+ }
281
+ function isSpecialOperator(op) {
282
+ return SPECIAL_OPERATORS.includes(op);
283
+ }
284
+ function isDateOperator(op) {
285
+ return DATE_OPERATORS.includes(op);
286
+ }
287
+ function isQueryParam(dim) {
288
+ return QUERY_PARAMS.includes(dim);
289
+ }
290
+ function addDays(dateStr, days) {
291
+ const d = new Date(dateStr);
292
+ d.setDate(d.getDate() + days);
293
+ return d.toISOString().split("T")[0];
294
+ }
295
+ function extractSpecialFilters(filter) {
296
+ if (!filter) return {};
297
+ let startDate;
298
+ let endDate;
299
+ let searchType$1;
300
+ const otherFilters = [];
301
+ const cleanedNestedGroups = [];
302
+ for (const f of filter._filters) if (f.dimension === "date" && isDateOperator(f.operator)) switch (f.operator) {
303
+ case "gte":
304
+ startDate = f.expression;
305
+ break;
306
+ case "gt":
307
+ startDate = addDays(f.expression, 1);
308
+ break;
309
+ case "lte":
310
+ endDate = f.expression;
311
+ break;
312
+ case "lt":
313
+ endDate = addDays(f.expression, -1);
314
+ break;
315
+ case "between":
316
+ startDate = f.expression;
317
+ endDate = f.expression2;
318
+ break;
319
+ }
320
+ else if (isQueryParam(f.dimension)) {
321
+ if (f.dimension === "searchType") searchType$1 = f.expression;
322
+ } else if (isMetricOperator(f.operator) || isSpecialOperator(f.operator)) otherFilters.push(f);
323
+ else otherFilters.push(f);
324
+ if (filter._nestedGroups) for (const nested of filter._nestedGroups) {
325
+ const extracted = extractSpecialFilters(nested);
326
+ if (extracted.startDate) startDate = extracted.startDate;
327
+ if (extracted.endDate) endDate = extracted.endDate;
328
+ if (extracted.searchType) searchType$1 = extracted.searchType;
329
+ if (extracted.dimensionFilter) cleanedNestedGroups.push(extracted.dimensionFilter);
330
+ }
331
+ const dimensionFilter = otherFilters.length > 0 || cleanedNestedGroups.length > 0 ? {
332
+ ...filter,
333
+ _filters: otherFilters,
334
+ _nestedGroups: cleanedNestedGroups.length > 0 ? cleanedNestedGroups : void 0
335
+ } : void 0;
336
+ return {
337
+ startDate,
338
+ endDate,
339
+ searchType: searchType$1,
340
+ dimensionFilter
341
+ };
342
+ }
343
+ function resolveToBody(state) {
344
+ const { startDate, endDate, searchType: searchType$1, dimensionFilter } = extractSpecialFilters(state.filter);
345
+ if (!startDate || !endDate) throw new Error("Date range required: use .where(between(date, start, end)) or .where(and(gte(date, start), lte(date, end)))");
346
+ const body = {
347
+ dimensions: state.dimensions,
348
+ startDate,
349
+ endDate
350
+ };
351
+ if (searchType$1) body.searchType = searchType$1;
352
+ if (state.rowLimit) body.rowLimit = state.rowLimit;
353
+ if (state.startRow) body.startRow = state.startRow;
354
+ const filterGroups = resolveFilter(dimensionFilter);
355
+ if (filterGroups.length > 0) body.dimensionFilterGroups = filterGroups;
356
+ return body;
357
+ }
358
+ function isApiFilter(f) {
359
+ return !isMetricOperator(f.operator) && !isSpecialOperator(f.operator);
360
+ }
361
+ function resolveFilter(filter) {
362
+ if (!filter) return [];
363
+ const groups = [];
364
+ const groupType = filter._groupType ?? "and";
365
+ const apiFilters = filter._filters.filter(isApiFilter);
366
+ if (groupType === "or") {
367
+ if (apiFilters.length > 0) groups.push({
368
+ groupType: "or",
369
+ filters: apiFilters.map((f) => ({
370
+ dimension: f.dimension,
371
+ operator: f.operator,
372
+ expression: f.expression
373
+ }))
374
+ });
375
+ } else if (apiFilters.length > 0) groups.push({ filters: apiFilters.map((f) => ({
376
+ dimension: f.dimension,
377
+ operator: f.operator,
378
+ expression: f.expression
379
+ })) });
380
+ if (filter._nestedGroups) for (const nested of filter._nestedGroups) groups.push(...resolveFilter(nested));
381
+ return groups;
382
+ }
383
+
384
+ //#endregion
385
+ //#region src/query/builder.ts
386
+ function isDimensionString(v) {
387
+ return typeof v === "string";
388
+ }
389
+ function isMetricColumn(v) {
390
+ return typeof v === "object" && v !== null && "metric" in v;
391
+ }
392
+ function isDimensionColumn(v) {
393
+ return typeof v === "object" && v !== null && "dimension" in v && !("metric" in v);
394
+ }
395
+ function createBuilder(state) {
396
+ return {
397
+ select(...args) {
398
+ const dimensions = [];
399
+ const metrics = [];
400
+ for (const arg of args) if (isDimensionString(arg)) dimensions.push(arg);
401
+ else if (isDimensionColumn(arg)) dimensions.push(arg.dimension);
402
+ else if (isMetricColumn(arg)) metrics.push(arg.metric);
403
+ return createBuilder({
404
+ ...state,
405
+ dimensions,
406
+ metrics: metrics.length > 0 ? metrics : void 0
407
+ });
408
+ },
409
+ where(filter) {
410
+ return createBuilder({
411
+ ...state,
412
+ filter
413
+ });
414
+ },
415
+ orderBy(col, dir) {
416
+ const column = isMetricColumn(col) ? col.metric : col.dimension;
417
+ return createBuilder({
418
+ ...state,
419
+ orderBy: {
420
+ column,
421
+ dir
422
+ }
423
+ });
424
+ },
425
+ limit(n) {
426
+ return createBuilder({
427
+ ...state,
428
+ rowLimit: n
429
+ });
430
+ },
431
+ offset(n) {
432
+ return createBuilder({
433
+ ...state,
434
+ startRow: n
435
+ });
436
+ },
437
+ toBody() {
438
+ return resolveToBody(state);
439
+ },
440
+ getState() {
441
+ return { ...state };
442
+ }
443
+ };
444
+ }
445
+ const gsc = createBuilder({ dimensions: [] });
446
+
447
+ //#endregion
448
+ //#region src/query/columns.ts
449
+ function createColumn(dimension) {
450
+ return { dimension };
451
+ }
452
+ function createMetricColumn(metric) {
453
+ return { metric };
454
+ }
455
+ function createQueryParam(param) {
456
+ return { param };
457
+ }
458
+ const page = createColumn("page");
459
+ const query = createColumn("query");
460
+ const queryCanonical = createColumn("queryCanonical");
461
+ const device = createColumn("device");
462
+ const country = createColumn("country");
463
+ const searchAppearance = createColumn("searchAppearance");
464
+ const date = createColumn("date");
465
+ const clicks = createMetricColumn("clicks");
466
+ const impressions = createMetricColumn("impressions");
467
+ const ctr = createMetricColumn("ctr");
468
+ const position = createMetricColumn("position");
469
+ const searchType = createQueryParam("searchType");
470
+
471
+ //#endregion
472
+ //#region src/query/operators.ts
473
+ function between(column, start, end) {
474
+ if ("metric" in column) return {
475
+ _constraints: {},
476
+ _filters: [{
477
+ dimension: column.metric,
478
+ operator: "metricBetween",
479
+ expression: String(start),
480
+ expression2: String(end)
481
+ }]
482
+ };
483
+ return {
484
+ _constraints: {},
485
+ _filters: [{
486
+ dimension: column.dimension,
487
+ operator: "between",
488
+ expression: String(start),
489
+ expression2: String(end)
490
+ }]
491
+ };
492
+ }
493
+
494
+ //#endregion
495
+ //#region src/analysis/movers.ts
496
+ function percentDifference(current, previous) {
497
+ if (previous === 0) return current > 0 ? 100 : 0;
498
+ return (current - previous) / previous * 100;
499
+ }
500
+ /**
501
+ * Identifies movers and shakers - keywords with significant recent changes.
502
+ */
503
+ function analyzeMovers(input, options = {}) {
504
+ const { changeThreshold = .2, minImpressions = 50, sortBy = "clicksChange" } = options;
505
+ const normFactor = input.normalizationFactor ?? 1;
506
+ const baselineMap = /* @__PURE__ */ new Map();
507
+ for (const row of input.previous) baselineMap.set(row.query, {
508
+ clicks: num(row.clicks) / normFactor,
509
+ impressions: num(row.impressions) / normFactor,
510
+ position: num(row.position),
511
+ page: row.page ?? null
512
+ });
513
+ const pageMap = /* @__PURE__ */ new Map();
514
+ for (const row of input.current) if (!pageMap.has(row.query) && row.page) pageMap.set(row.query, row.page);
515
+ for (const row of input.previous) if (!pageMap.has(row.query) && row.page) pageMap.set(row.query, row.page);
516
+ const rising = [];
517
+ const declining = [];
518
+ const stable = [];
519
+ for (const row of input.current) {
520
+ const impressions$1 = num(row.impressions);
521
+ const clicks$1 = num(row.clicks);
522
+ const position$1 = num(row.position);
523
+ if (impressions$1 < minImpressions) continue;
524
+ const baseline = baselineMap.get(row.query) || {
525
+ clicks: 0,
526
+ impressions: 0,
527
+ position: 0,
528
+ page: null
529
+ };
530
+ const clicksChangePercent = percentDifference(clicks$1, baseline.clicks);
531
+ const impressionsChangePercent = percentDifference(impressions$1, baseline.impressions);
532
+ const data = {
533
+ keyword: row.query,
534
+ page: pageMap.get(row.query) ?? null,
535
+ recentClicks: clicks$1,
536
+ recentImpressions: impressions$1,
537
+ recentPosition: position$1,
538
+ baselineClicks: Math.round(baseline.clicks),
539
+ baselineImpressions: Math.round(baseline.impressions),
540
+ baselinePosition: baseline.position,
541
+ clicksChange: clicks$1 - Math.round(baseline.clicks),
542
+ clicksChangePercent,
543
+ impressionsChangePercent,
544
+ positionChange: position$1 - baseline.position
545
+ };
546
+ const absChange = Math.abs(clicksChangePercent / 100);
547
+ if (clicksChangePercent > 0 && absChange >= changeThreshold) rising.push(data);
548
+ else if (clicksChangePercent < 0 && absChange >= changeThreshold) declining.push(data);
549
+ else stable.push(data);
550
+ }
551
+ const sortFn = (a, b) => {
552
+ switch (sortBy) {
553
+ case "clicks": return b.recentClicks - a.recentClicks;
554
+ case "impressions": return b.recentImpressions - a.recentImpressions;
555
+ case "clicksChange": return Math.abs(b.clicksChangePercent) - Math.abs(a.clicksChangePercent);
556
+ case "impressionsChange": return Math.abs(b.impressionsChangePercent) - Math.abs(a.impressionsChangePercent);
557
+ case "positionChange": return Math.abs(b.positionChange) - Math.abs(a.positionChange);
558
+ default: return Math.abs(b.clicksChangePercent) - Math.abs(a.clicksChangePercent);
559
+ }
560
+ };
561
+ rising.sort(sortFn);
562
+ declining.sort(sortFn);
563
+ stable.sort((a, b) => b.recentClicks - a.recentClicks);
564
+ return {
565
+ rising,
566
+ declining,
567
+ stable
568
+ };
569
+ }
570
+
571
+ //#endregion
572
+ //#region src/analysis/opportunity.ts
573
+ const EXPECTED_CTR_BY_POSITION = {
574
+ 1: .3,
575
+ 2: .15,
576
+ 3: .1,
577
+ 4: .07,
578
+ 5: .05,
579
+ 6: .04,
580
+ 7: .03,
581
+ 8: .025,
582
+ 9: .02,
583
+ 10: .015
584
+ };
585
+ function getExpectedCtr(position$1) {
586
+ return EXPECTED_CTR_BY_POSITION[Math.round(Math.max(1, Math.min(position$1, 10)))] || .01;
587
+ }
588
+ function calculatePositionScore(position$1) {
589
+ if (position$1 <= 3) return .2;
590
+ if (position$1 > 50) return .1;
591
+ const distance = Math.abs(position$1 - 11);
592
+ return Math.max(0, 1 - distance / 15);
593
+ }
594
+ function calculateImpressionScore(impressions$1) {
595
+ if (impressions$1 <= 0) return 0;
596
+ return Math.min(Math.log10(impressions$1) / 5, 1);
597
+ }
598
+ function calculateCtrGapScore(actualCtr, position$1) {
599
+ const expectedCtr = getExpectedCtr(position$1);
600
+ if (actualCtr >= expectedCtr) return 0;
601
+ const gap = expectedCtr - actualCtr;
602
+ return Math.min(gap / expectedCtr, 1);
603
+ }
604
+ const SORT_ORDER = {
605
+ opportunityScore: "desc",
606
+ potentialClicks: "desc",
607
+ impressions: "desc",
608
+ position: "asc"
609
+ };
610
+ const sortResults$1 = createSorter((item, metric) => item[metric], "opportunityScore");
611
+ /**
612
+ * Scores keywords by optimization opportunity.
613
+ * Composite score combining position, impressions, and CTR gap factors.
614
+ */
615
+ function analyzeOpportunity(keywords, options = {}) {
616
+ const { minImpressions = 100, weights = {}, sortBy = "opportunityScore" } = options;
617
+ const positionWeight = weights.position ?? 1;
618
+ const impressionsWeight = weights.impressions ?? 1;
619
+ const ctrGapWeight = weights.ctrGap ?? 1;
620
+ const results = [];
621
+ for (const row of keywords) {
622
+ const impressions$1 = num(row.impressions);
623
+ const position$1 = num(row.position);
624
+ const ctr$1 = num(row.ctr);
625
+ const clicks$1 = num(row.clicks);
626
+ if (impressions$1 < minImpressions) continue;
627
+ const positionScore = calculatePositionScore(position$1);
628
+ const impressionScore = calculateImpressionScore(impressions$1);
629
+ const ctrGapScore = calculateCtrGapScore(ctr$1, position$1);
630
+ const geometricMean = (positionScore ** positionWeight * impressionScore ** impressionsWeight * ctrGapScore ** ctrGapWeight) ** (1 / (positionWeight + impressionsWeight + ctrGapWeight));
631
+ const opportunityScore = Math.round(geometricMean * 100);
632
+ const targetCtr = getExpectedCtr(Math.min(3, position$1));
633
+ const potentialClicks = Math.round(impressions$1 * targetCtr);
634
+ results.push({
635
+ keyword: row.query,
636
+ page: row.page ?? null,
637
+ clicks: clicks$1,
638
+ impressions: impressions$1,
639
+ ctr: ctr$1,
640
+ position: position$1,
641
+ opportunityScore,
642
+ potentialClicks,
643
+ factors: {
644
+ positionScore,
645
+ impressionScore,
646
+ ctrGapScore
647
+ }
648
+ });
649
+ }
650
+ return sortResults$1(results, sortBy, SORT_ORDER[sortBy]);
651
+ }
652
+
653
+ //#endregion
654
+ //#region src/analysis/seasonality.ts
655
+ function calculateCV(values) {
656
+ if (values.length === 0) return 0;
657
+ const mean = values.reduce((a, b) => a + b, 0) / values.length;
658
+ if (mean === 0) return 0;
659
+ const variance = values.reduce((sum, v) => sum + (v - mean) ** 2, 0) / values.length;
660
+ const stdDev = Math.sqrt(variance);
661
+ return Math.min(stdDev / mean, 1);
662
+ }
663
+ /**
664
+ * Detects seasonality patterns by analyzing monthly traffic variation.
665
+ */
666
+ function analyzeSeasonality(dates, options = {}) {
667
+ const { metric = "clicks" } = options;
668
+ if (dates.length === 0) return {
669
+ hasSeasonality: false,
670
+ strength: 0,
671
+ peakMonths: [],
672
+ troughMonths: [],
673
+ monthlyBreakdown: [],
674
+ insufficientData: true
675
+ };
676
+ const monthlyMap = /* @__PURE__ */ new Map();
677
+ for (const row of dates) {
678
+ const month = row.date.substring(0, 7);
679
+ const value = metric === "clicks" ? row.clicks : row.impressions;
680
+ monthlyMap.set(month, (monthlyMap.get(month) || 0) + value);
681
+ }
682
+ const months = Array.from(monthlyMap.keys()).sort();
683
+ const values = months.map((m) => monthlyMap.get(m) || 0);
684
+ const insufficientData = months.length < 12;
685
+ const totalValue = values.reduce((a, b) => a + b, 0);
686
+ const avgValue = values.length > 0 ? totalValue / values.length : 0;
687
+ const monthlyBreakdown = months.map((month, i) => {
688
+ const value = values[i];
689
+ const vsAverage = avgValue > 0 ? value / avgValue : 0;
690
+ return {
691
+ month,
692
+ value,
693
+ vsAverage,
694
+ isPeak: vsAverage > 1.5,
695
+ isTrough: vsAverage < .5
696
+ };
697
+ });
698
+ const peakMonths = [...new Set(monthlyBreakdown.filter((m) => m.isPeak).map((m) => m.month.substring(5, 7)))];
699
+ const troughMonths = [...new Set(monthlyBreakdown.filter((m) => m.isTrough).map((m) => m.month.substring(5, 7)))];
700
+ const strength = calculateCV(values);
701
+ return {
702
+ hasSeasonality: peakMonths.length > 0 || troughMonths.length > 0 || strength > .3,
703
+ strength,
704
+ peakMonths,
705
+ troughMonths,
706
+ monthlyBreakdown,
707
+ insufficientData
708
+ };
709
+ }
710
+
711
+ //#endregion
712
+ //#region src/analysis/striking-distance.ts
713
+ const sortResults = createSorter((item, metric) => item[metric], "potentialClicks");
714
+ /**
715
+ * Finds striking distance keywords - high impressions, low CTR, position 4-20.
716
+ * These are "quick wins" that could gain significant traffic with small ranking improvements.
717
+ */
718
+ function analyzeStrikingDistance(keywords, options = {}) {
719
+ const { minPosition = 4, maxPosition = 20, minImpressions = 100, maxCtr = .05, sortBy = "potentialClicks", sortOrder = "desc" } = options;
720
+ const results = [];
721
+ for (const row of keywords) {
722
+ const position$1 = num(row.position);
723
+ const impressions$1 = num(row.impressions);
724
+ const ctr$1 = num(row.ctr);
725
+ const clicks$1 = num(row.clicks);
726
+ if (position$1 < minPosition || position$1 > maxPosition) continue;
727
+ if (impressions$1 < minImpressions) continue;
728
+ if (ctr$1 > maxCtr) continue;
729
+ const potentialClicks = Math.round(impressions$1 * .15);
730
+ results.push({
731
+ keyword: row.query,
732
+ page: row.page ?? null,
733
+ clicks: clicks$1,
734
+ impressions: impressions$1,
735
+ ctr: ctr$1,
736
+ position: position$1,
737
+ potentialClicks
738
+ });
739
+ }
740
+ return sortResults(results, sortBy, sortOrder);
741
+ }
742
+
743
+ //#endregion
744
+ //#region src/analysis/fetch.ts
745
+ /** Collect all rows from async generator */
746
+ async function collectRows(generator) {
747
+ const rows = [];
748
+ for await (const batch of generator) rows.push(...batch);
749
+ return rows;
750
+ }
751
+ /** Fetch keywords (query+page dimensions) for a period */
752
+ async function fetchKeywordsInternal(client, siteUrl, period, limit = 25e3) {
753
+ const builder = gsc.select(query, page).where(between(date, period.startDate, period.endDate)).limit(limit);
754
+ return collectRows(client.query(siteUrl, builder));
755
+ }
756
+ /** Fetch pages (page dimension) for a period */
757
+ async function fetchPagesInternal(client, siteUrl, period, limit = 25e3) {
758
+ const builder = gsc.select(page).where(between(date, period.startDate, period.endDate)).limit(limit);
759
+ return collectRows(client.query(siteUrl, builder));
760
+ }
761
+ /** Fetch dates (date dimension) for a period */
762
+ async function fetchDatesInternal(client, siteUrl, period, limit = 25e3) {
763
+ const builder = gsc.select(date).where(between(date, period.startDate, period.endDate)).limit(limit);
764
+ return collectRows(client.query(siteUrl, builder));
765
+ }
766
+ /**
767
+ * Query search analytics data for a single period.
768
+ * Returns keywords, pages, and dates data.
769
+ * API calls: 3 (one per dimension)
770
+ */
771
+ async function queryAnalytics(client, siteUrl, period, options = {}) {
772
+ const limit = options.limit ?? 25e3;
773
+ const [keywords, pages, dates] = await Promise.all([
774
+ fetchKeywordsInternal(client, siteUrl, period, limit),
775
+ fetchPagesInternal(client, siteUrl, period, limit),
776
+ fetchDatesInternal(client, siteUrl, period, limit)
777
+ ]);
778
+ return {
779
+ keywords,
780
+ pages,
781
+ dates
782
+ };
783
+ }
784
+ /**
785
+ * Query search analytics data for current and previous periods.
786
+ * Returns comparison data for all dimensions.
787
+ * API calls: 6 (3 per period)
788
+ */
789
+ async function queryComparison(client, siteUrl, periods, options = {}) {
790
+ const [current, previous] = await Promise.all([queryAnalytics(client, siteUrl, periods.current, options), queryAnalytics(client, siteUrl, periods.previous, options)]);
791
+ return {
792
+ current,
793
+ previous
794
+ };
795
+ }
796
+ const fetchKeywords = fetchKeywordsInternal;
797
+ const fetchPages = fetchPagesInternal;
798
+ const fetchDates = fetchDatesInternal;
799
+ /**
800
+ * Fetch keywords and analyze striking distance opportunities.
801
+ * API calls: 1
802
+ */
803
+ async function fetchStrikingDistance(client, siteUrl, period, options) {
804
+ return analyzeStrikingDistance(await fetchKeywords(client, siteUrl, period), options);
805
+ }
806
+ /**
807
+ * Fetch keywords and analyze optimization opportunities.
808
+ * API calls: 1
809
+ */
810
+ async function fetchOpportunity(client, siteUrl, period, options) {
811
+ return analyzeOpportunity(await fetchKeywords(client, siteUrl, period), options);
812
+ }
813
+ /**
814
+ * Fetch keywords and analyze brand vs non-brand segmentation.
815
+ * API calls: 1
816
+ */
817
+ async function fetchBrandSegmentation(client, siteUrl, period, options) {
818
+ return analyzeBrandSegmentation(await fetchKeywords(client, siteUrl, period), options);
819
+ }
820
+ /**
821
+ * Fetch pages and analyze traffic concentration.
822
+ * API calls: 1
823
+ */
824
+ async function fetchPageConcentration(client, siteUrl, period, options) {
825
+ return analyzePageConcentration(await fetchPages(client, siteUrl, period), options);
826
+ }
827
+ /**
828
+ * Fetch keywords and analyze traffic concentration.
829
+ * API calls: 1
830
+ */
831
+ async function fetchKeywordConcentration(client, siteUrl, period, options) {
832
+ return analyzeKeywordConcentration(await fetchKeywords(client, siteUrl, period), options);
833
+ }
834
+ /**
835
+ * Fetch keywords and analyze clusters.
836
+ * API calls: 1
837
+ */
838
+ async function fetchClustering(client, siteUrl, period, options) {
839
+ return analyzeClustering(await fetchKeywords(client, siteUrl, period), options);
840
+ }
841
+ /**
842
+ * Fetch dates and analyze seasonality patterns.
843
+ * API calls: 1
844
+ */
845
+ async function fetchSeasonality(client, siteUrl, period, options) {
846
+ return analyzeSeasonality(await fetchDates(client, siteUrl, period), options);
847
+ }
848
+ /**
849
+ * Fetch pages for both periods and analyze content decay.
850
+ * API calls: 2
851
+ */
852
+ async function fetchDecay(client, siteUrl, periods, options) {
853
+ const [current, previous] = await Promise.all([fetchPages(client, siteUrl, periods.current), fetchPages(client, siteUrl, periods.previous)]);
854
+ return analyzeDecay({
855
+ current,
856
+ previous
857
+ }, options);
858
+ }
859
+ /**
860
+ * Fetch keywords for both periods and analyze movers/shakers.
861
+ * API calls: 2
862
+ */
863
+ async function fetchMovers(client, siteUrl, periods, options) {
864
+ const [current, previous] = await Promise.all([fetchKeywords(client, siteUrl, periods.current), fetchKeywords(client, siteUrl, periods.previous)]);
865
+ return analyzeMovers({
866
+ current,
867
+ previous
868
+ }, options);
869
+ }
870
+
871
+ //#endregion
872
+ export { analyzeBrandSegmentation, analyzeClustering, analyzeConcentration, analyzeDecay, analyzeKeywordConcentration, analyzeMovers, analyzeOpportunity, analyzePageConcentration, analyzeSeasonality, analyzeStrikingDistance, createSorter, fetchBrandSegmentation, fetchClustering, fetchDecay, fetchKeywordConcentration, fetchMovers, fetchOpportunity, fetchPageConcentration, fetchSeasonality, fetchStrikingDistance, num, queryAnalytics, queryComparison };