gscdump 0.1.3 → 0.2.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,810 @@
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 = num(row.clicks);
233
+ if (clicks >= minPreviousClicks) previousMap.set(row.page, {
234
+ clicks,
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 QUERY_PARAMS = ["searchType"];
270
+ function isDateOperator(op) {
271
+ return DATE_OPERATORS.includes(op);
272
+ }
273
+ function isQueryParam(dim) {
274
+ return QUERY_PARAMS.includes(dim);
275
+ }
276
+ function addDays(dateStr, days) {
277
+ const d = new Date(dateStr);
278
+ d.setDate(d.getDate() + days);
279
+ return d.toISOString().split("T")[0];
280
+ }
281
+ function extractSpecialFilters(filter) {
282
+ if (!filter) return {};
283
+ let startDate;
284
+ let endDate;
285
+ let searchType$1;
286
+ const otherFilters = [];
287
+ const cleanedNestedGroups = [];
288
+ for (const f of filter._filters) if (f.dimension === "date" && isDateOperator(f.operator)) switch (f.operator) {
289
+ case "gte":
290
+ startDate = f.expression;
291
+ break;
292
+ case "gt":
293
+ startDate = addDays(f.expression, 1);
294
+ break;
295
+ case "lte":
296
+ endDate = f.expression;
297
+ break;
298
+ case "lt":
299
+ endDate = addDays(f.expression, -1);
300
+ break;
301
+ case "between":
302
+ startDate = f.expression;
303
+ endDate = f.expression2;
304
+ break;
305
+ }
306
+ else if (isQueryParam(f.dimension)) {
307
+ if (f.dimension === "searchType") searchType$1 = f.expression;
308
+ } else otherFilters.push(f);
309
+ if (filter._nestedGroups) for (const nested of filter._nestedGroups) {
310
+ const extracted = extractSpecialFilters(nested);
311
+ if (extracted.startDate) startDate = extracted.startDate;
312
+ if (extracted.endDate) endDate = extracted.endDate;
313
+ if (extracted.searchType) searchType$1 = extracted.searchType;
314
+ if (extracted.dimensionFilter) cleanedNestedGroups.push(extracted.dimensionFilter);
315
+ }
316
+ const dimensionFilter = otherFilters.length > 0 || cleanedNestedGroups.length > 0 ? {
317
+ ...filter,
318
+ _filters: otherFilters,
319
+ _nestedGroups: cleanedNestedGroups.length > 0 ? cleanedNestedGroups : void 0
320
+ } : void 0;
321
+ return {
322
+ startDate,
323
+ endDate,
324
+ searchType: searchType$1,
325
+ dimensionFilter
326
+ };
327
+ }
328
+ function resolveToBody(state) {
329
+ const { startDate, endDate, searchType: searchType$1, dimensionFilter } = extractSpecialFilters(state.filter);
330
+ if (!startDate || !endDate) throw new Error("Date range required: use .where(between(date, start, end)) or .where(and(gte(date, start), lte(date, end)))");
331
+ const body = {
332
+ dimensions: state.dimensions,
333
+ startDate,
334
+ endDate
335
+ };
336
+ if (searchType$1) body.searchType = searchType$1;
337
+ if (state.rowLimit) body.rowLimit = state.rowLimit;
338
+ if (state.startRow) body.startRow = state.startRow;
339
+ const filterGroups = resolveFilter(dimensionFilter);
340
+ if (filterGroups.length > 0) body.dimensionFilterGroups = filterGroups;
341
+ return body;
342
+ }
343
+ function resolveFilter(filter) {
344
+ if (!filter) return [];
345
+ const groups = [];
346
+ if ((filter._groupType ?? "and") === "or") {
347
+ if (filter._filters.length > 0) groups.push({
348
+ groupType: "or",
349
+ filters: filter._filters.map((f) => ({
350
+ dimension: f.dimension,
351
+ operator: f.operator,
352
+ expression: f.expression
353
+ }))
354
+ });
355
+ } else if (filter._filters.length > 0) groups.push({ filters: filter._filters.map((f) => ({
356
+ dimension: f.dimension,
357
+ operator: f.operator,
358
+ expression: f.expression
359
+ })) });
360
+ if (filter._nestedGroups) for (const nested of filter._nestedGroups) groups.push(...resolveFilter(nested));
361
+ return groups;
362
+ }
363
+
364
+ //#endregion
365
+ //#region src/query/builder.ts
366
+ function createBuilder(state) {
367
+ return {
368
+ select(...dims) {
369
+ return createBuilder({
370
+ ...state,
371
+ dimensions: dims
372
+ });
373
+ },
374
+ where(filter) {
375
+ return createBuilder({
376
+ ...state,
377
+ filter
378
+ });
379
+ },
380
+ limit(n) {
381
+ return createBuilder({
382
+ ...state,
383
+ rowLimit: n
384
+ });
385
+ },
386
+ offset(n) {
387
+ return createBuilder({
388
+ ...state,
389
+ startRow: n
390
+ });
391
+ },
392
+ toBody() {
393
+ return resolveToBody(state);
394
+ },
395
+ getState() {
396
+ return { ...state };
397
+ }
398
+ };
399
+ }
400
+ const gsc = createBuilder({ dimensions: [] });
401
+
402
+ //#endregion
403
+ //#region src/query/columns.ts
404
+ function createColumn(dimension) {
405
+ return { dimension };
406
+ }
407
+ function createQueryParam(param) {
408
+ return { param };
409
+ }
410
+ const page = createColumn("page");
411
+ const query = createColumn("query");
412
+ const device = createColumn("device");
413
+ const country = createColumn("country");
414
+ const searchAppearance = createColumn("searchAppearance");
415
+ const date = createColumn("date");
416
+ const searchType = createQueryParam("searchType");
417
+
418
+ //#endregion
419
+ //#region src/query/operators.ts
420
+ function between(column, start, end) {
421
+ return {
422
+ _constraints: {},
423
+ _filters: [{
424
+ dimension: column.dimension,
425
+ operator: "between",
426
+ expression: String(start),
427
+ expression2: String(end)
428
+ }]
429
+ };
430
+ }
431
+
432
+ //#endregion
433
+ //#region src/analysis/movers.ts
434
+ function percentDifference(current, previous) {
435
+ if (previous === 0) return current > 0 ? 100 : 0;
436
+ return (current - previous) / previous * 100;
437
+ }
438
+ /**
439
+ * Identifies movers and shakers - keywords with significant recent changes.
440
+ */
441
+ function analyzeMovers(input, options = {}) {
442
+ const { changeThreshold = .2, minImpressions = 50, sortBy = "clicksChange" } = options;
443
+ const normFactor = input.normalizationFactor ?? 1;
444
+ const baselineMap = /* @__PURE__ */ new Map();
445
+ for (const row of input.previous) baselineMap.set(row.query, {
446
+ clicks: num(row.clicks) / normFactor,
447
+ impressions: num(row.impressions) / normFactor,
448
+ position: num(row.position),
449
+ page: row.page ?? null
450
+ });
451
+ const pageMap = /* @__PURE__ */ new Map();
452
+ for (const row of input.current) if (!pageMap.has(row.query) && row.page) pageMap.set(row.query, row.page);
453
+ for (const row of input.previous) if (!pageMap.has(row.query) && row.page) pageMap.set(row.query, row.page);
454
+ const rising = [];
455
+ const declining = [];
456
+ const stable = [];
457
+ for (const row of input.current) {
458
+ const impressions = num(row.impressions);
459
+ const clicks = num(row.clicks);
460
+ const position = num(row.position);
461
+ if (impressions < minImpressions) continue;
462
+ const baseline = baselineMap.get(row.query) || {
463
+ clicks: 0,
464
+ impressions: 0,
465
+ position: 0,
466
+ page: null
467
+ };
468
+ const clicksChangePercent = percentDifference(clicks, baseline.clicks);
469
+ const impressionsChangePercent = percentDifference(impressions, baseline.impressions);
470
+ const data = {
471
+ keyword: row.query,
472
+ page: pageMap.get(row.query) ?? null,
473
+ recentClicks: clicks,
474
+ recentImpressions: impressions,
475
+ recentPosition: position,
476
+ baselineClicks: Math.round(baseline.clicks),
477
+ baselineImpressions: Math.round(baseline.impressions),
478
+ baselinePosition: baseline.position,
479
+ clicksChange: clicks - Math.round(baseline.clicks),
480
+ clicksChangePercent,
481
+ impressionsChangePercent,
482
+ positionChange: position - baseline.position
483
+ };
484
+ const absChange = Math.abs(clicksChangePercent / 100);
485
+ if (clicksChangePercent > 0 && absChange >= changeThreshold) rising.push(data);
486
+ else if (clicksChangePercent < 0 && absChange >= changeThreshold) declining.push(data);
487
+ else stable.push(data);
488
+ }
489
+ const sortFn = (a, b) => {
490
+ switch (sortBy) {
491
+ case "clicks": return b.recentClicks - a.recentClicks;
492
+ case "impressions": return b.recentImpressions - a.recentImpressions;
493
+ case "clicksChange": return Math.abs(b.clicksChangePercent) - Math.abs(a.clicksChangePercent);
494
+ case "impressionsChange": return Math.abs(b.impressionsChangePercent) - Math.abs(a.impressionsChangePercent);
495
+ case "positionChange": return Math.abs(b.positionChange) - Math.abs(a.positionChange);
496
+ default: return Math.abs(b.clicksChangePercent) - Math.abs(a.clicksChangePercent);
497
+ }
498
+ };
499
+ rising.sort(sortFn);
500
+ declining.sort(sortFn);
501
+ stable.sort((a, b) => b.recentClicks - a.recentClicks);
502
+ return {
503
+ rising,
504
+ declining,
505
+ stable
506
+ };
507
+ }
508
+
509
+ //#endregion
510
+ //#region src/analysis/opportunity.ts
511
+ const EXPECTED_CTR_BY_POSITION = {
512
+ 1: .3,
513
+ 2: .15,
514
+ 3: .1,
515
+ 4: .07,
516
+ 5: .05,
517
+ 6: .04,
518
+ 7: .03,
519
+ 8: .025,
520
+ 9: .02,
521
+ 10: .015
522
+ };
523
+ function getExpectedCtr(position) {
524
+ return EXPECTED_CTR_BY_POSITION[Math.round(Math.max(1, Math.min(position, 10)))] || .01;
525
+ }
526
+ function calculatePositionScore(position) {
527
+ if (position <= 3) return .2;
528
+ if (position > 50) return .1;
529
+ const distance = Math.abs(position - 11);
530
+ return Math.max(0, 1 - distance / 15);
531
+ }
532
+ function calculateImpressionScore(impressions) {
533
+ if (impressions <= 0) return 0;
534
+ return Math.min(Math.log10(impressions) / 5, 1);
535
+ }
536
+ function calculateCtrGapScore(actualCtr, position) {
537
+ const expectedCtr = getExpectedCtr(position);
538
+ if (actualCtr >= expectedCtr) return 0;
539
+ const gap = expectedCtr - actualCtr;
540
+ return Math.min(gap / expectedCtr, 1);
541
+ }
542
+ const SORT_ORDER = {
543
+ opportunityScore: "desc",
544
+ potentialClicks: "desc",
545
+ impressions: "desc",
546
+ position: "asc"
547
+ };
548
+ const sortResults$1 = createSorter((item, metric) => item[metric], "opportunityScore");
549
+ /**
550
+ * Scores keywords by optimization opportunity.
551
+ * Composite score combining position, impressions, and CTR gap factors.
552
+ */
553
+ function analyzeOpportunity(keywords, options = {}) {
554
+ const { minImpressions = 100, weights = {}, sortBy = "opportunityScore" } = options;
555
+ const positionWeight = weights.position ?? 1;
556
+ const impressionsWeight = weights.impressions ?? 1;
557
+ const ctrGapWeight = weights.ctrGap ?? 1;
558
+ const results = [];
559
+ for (const row of keywords) {
560
+ const impressions = num(row.impressions);
561
+ const position = num(row.position);
562
+ const ctr = num(row.ctr);
563
+ const clicks = num(row.clicks);
564
+ if (impressions < minImpressions) continue;
565
+ const positionScore = calculatePositionScore(position);
566
+ const impressionScore = calculateImpressionScore(impressions);
567
+ const ctrGapScore = calculateCtrGapScore(ctr, position);
568
+ const geometricMean = (positionScore ** positionWeight * impressionScore ** impressionsWeight * ctrGapScore ** ctrGapWeight) ** (1 / (positionWeight + impressionsWeight + ctrGapWeight));
569
+ const opportunityScore = Math.round(geometricMean * 100);
570
+ const targetCtr = getExpectedCtr(Math.min(3, position));
571
+ const potentialClicks = Math.round(impressions * targetCtr);
572
+ results.push({
573
+ keyword: row.query,
574
+ page: row.page ?? null,
575
+ clicks,
576
+ impressions,
577
+ ctr,
578
+ position,
579
+ opportunityScore,
580
+ potentialClicks,
581
+ factors: {
582
+ positionScore,
583
+ impressionScore,
584
+ ctrGapScore
585
+ }
586
+ });
587
+ }
588
+ return sortResults$1(results, sortBy, SORT_ORDER[sortBy]);
589
+ }
590
+
591
+ //#endregion
592
+ //#region src/analysis/seasonality.ts
593
+ function calculateCV(values) {
594
+ if (values.length === 0) return 0;
595
+ const mean = values.reduce((a, b) => a + b, 0) / values.length;
596
+ if (mean === 0) return 0;
597
+ const variance = values.reduce((sum, v) => sum + (v - mean) ** 2, 0) / values.length;
598
+ const stdDev = Math.sqrt(variance);
599
+ return Math.min(stdDev / mean, 1);
600
+ }
601
+ /**
602
+ * Detects seasonality patterns by analyzing monthly traffic variation.
603
+ */
604
+ function analyzeSeasonality(dates, options = {}) {
605
+ const { metric = "clicks" } = options;
606
+ if (dates.length === 0) return {
607
+ hasSeasonality: false,
608
+ strength: 0,
609
+ peakMonths: [],
610
+ troughMonths: [],
611
+ monthlyBreakdown: [],
612
+ insufficientData: true
613
+ };
614
+ const monthlyMap = /* @__PURE__ */ new Map();
615
+ for (const row of dates) {
616
+ const month = row.date.substring(0, 7);
617
+ const value = metric === "clicks" ? row.clicks : row.impressions;
618
+ monthlyMap.set(month, (monthlyMap.get(month) || 0) + value);
619
+ }
620
+ const months = Array.from(monthlyMap.keys()).sort();
621
+ const values = months.map((m) => monthlyMap.get(m) || 0);
622
+ const insufficientData = months.length < 12;
623
+ const totalValue = values.reduce((a, b) => a + b, 0);
624
+ const avgValue = values.length > 0 ? totalValue / values.length : 0;
625
+ const monthlyBreakdown = months.map((month, i) => {
626
+ const value = values[i];
627
+ const vsAverage = avgValue > 0 ? value / avgValue : 0;
628
+ return {
629
+ month,
630
+ value,
631
+ vsAverage,
632
+ isPeak: vsAverage > 1.5,
633
+ isTrough: vsAverage < .5
634
+ };
635
+ });
636
+ const peakMonths = [...new Set(monthlyBreakdown.filter((m) => m.isPeak).map((m) => m.month.substring(5, 7)))];
637
+ const troughMonths = [...new Set(monthlyBreakdown.filter((m) => m.isTrough).map((m) => m.month.substring(5, 7)))];
638
+ const strength = calculateCV(values);
639
+ return {
640
+ hasSeasonality: peakMonths.length > 0 || troughMonths.length > 0 || strength > .3,
641
+ strength,
642
+ peakMonths,
643
+ troughMonths,
644
+ monthlyBreakdown,
645
+ insufficientData
646
+ };
647
+ }
648
+
649
+ //#endregion
650
+ //#region src/analysis/striking-distance.ts
651
+ const sortResults = createSorter((item, metric) => item[metric], "potentialClicks");
652
+ /**
653
+ * Finds striking distance keywords - high impressions, low CTR, position 4-20.
654
+ * These are "quick wins" that could gain significant traffic with small ranking improvements.
655
+ */
656
+ function analyzeStrikingDistance(keywords, options = {}) {
657
+ const { minPosition = 4, maxPosition = 20, minImpressions = 100, maxCtr = .05, sortBy = "potentialClicks", sortOrder = "desc" } = options;
658
+ const results = [];
659
+ for (const row of keywords) {
660
+ const position = num(row.position);
661
+ const impressions = num(row.impressions);
662
+ const ctr = num(row.ctr);
663
+ const clicks = num(row.clicks);
664
+ if (position < minPosition || position > maxPosition) continue;
665
+ if (impressions < minImpressions) continue;
666
+ if (ctr > maxCtr) continue;
667
+ const potentialClicks = Math.round(impressions * .15);
668
+ results.push({
669
+ keyword: row.query,
670
+ page: row.page ?? null,
671
+ clicks,
672
+ impressions,
673
+ ctr,
674
+ position,
675
+ potentialClicks
676
+ });
677
+ }
678
+ return sortResults(results, sortBy, sortOrder);
679
+ }
680
+
681
+ //#endregion
682
+ //#region src/analysis/fetch.ts
683
+ /** Collect all rows from async generator */
684
+ async function collectRows(generator) {
685
+ const rows = [];
686
+ for await (const batch of generator) rows.push(...batch);
687
+ return rows;
688
+ }
689
+ /** Fetch keywords (query+page dimensions) for a period */
690
+ async function fetchKeywordsInternal(client, siteUrl, period, limit = 25e3) {
691
+ const builder = gsc.select(query, page).where(between(date, period.startDate, period.endDate)).limit(limit);
692
+ return collectRows(client.query(siteUrl, builder));
693
+ }
694
+ /** Fetch pages (page dimension) for a period */
695
+ async function fetchPagesInternal(client, siteUrl, period, limit = 25e3) {
696
+ const builder = gsc.select(page).where(between(date, period.startDate, period.endDate)).limit(limit);
697
+ return collectRows(client.query(siteUrl, builder));
698
+ }
699
+ /** Fetch dates (date dimension) for a period */
700
+ async function fetchDatesInternal(client, siteUrl, period, limit = 25e3) {
701
+ const builder = gsc.select(date).where(between(date, period.startDate, period.endDate)).limit(limit);
702
+ return collectRows(client.query(siteUrl, builder));
703
+ }
704
+ /**
705
+ * Query search analytics data for a single period.
706
+ * Returns keywords, pages, and dates data.
707
+ * API calls: 3 (one per dimension)
708
+ */
709
+ async function queryAnalytics(client, siteUrl, period, options = {}) {
710
+ const limit = options.limit ?? 25e3;
711
+ const [keywords, pages, dates] = await Promise.all([
712
+ fetchKeywordsInternal(client, siteUrl, period, limit),
713
+ fetchPagesInternal(client, siteUrl, period, limit),
714
+ fetchDatesInternal(client, siteUrl, period, limit)
715
+ ]);
716
+ return {
717
+ keywords,
718
+ pages,
719
+ dates
720
+ };
721
+ }
722
+ /**
723
+ * Query search analytics data for current and previous periods.
724
+ * Returns comparison data for all dimensions.
725
+ * API calls: 6 (3 per period)
726
+ */
727
+ async function queryComparison(client, siteUrl, periods, options = {}) {
728
+ const [current, previous] = await Promise.all([queryAnalytics(client, siteUrl, periods.current, options), queryAnalytics(client, siteUrl, periods.previous, options)]);
729
+ return {
730
+ current,
731
+ previous
732
+ };
733
+ }
734
+ const fetchKeywords = fetchKeywordsInternal;
735
+ const fetchPages = fetchPagesInternal;
736
+ const fetchDates = fetchDatesInternal;
737
+ /**
738
+ * Fetch keywords and analyze striking distance opportunities.
739
+ * API calls: 1
740
+ */
741
+ async function fetchStrikingDistance(client, siteUrl, period, options) {
742
+ return analyzeStrikingDistance(await fetchKeywords(client, siteUrl, period), options);
743
+ }
744
+ /**
745
+ * Fetch keywords and analyze optimization opportunities.
746
+ * API calls: 1
747
+ */
748
+ async function fetchOpportunity(client, siteUrl, period, options) {
749
+ return analyzeOpportunity(await fetchKeywords(client, siteUrl, period), options);
750
+ }
751
+ /**
752
+ * Fetch keywords and analyze brand vs non-brand segmentation.
753
+ * API calls: 1
754
+ */
755
+ async function fetchBrandSegmentation(client, siteUrl, period, options) {
756
+ return analyzeBrandSegmentation(await fetchKeywords(client, siteUrl, period), options);
757
+ }
758
+ /**
759
+ * Fetch pages and analyze traffic concentration.
760
+ * API calls: 1
761
+ */
762
+ async function fetchPageConcentration(client, siteUrl, period, options) {
763
+ return analyzePageConcentration(await fetchPages(client, siteUrl, period), options);
764
+ }
765
+ /**
766
+ * Fetch keywords and analyze traffic concentration.
767
+ * API calls: 1
768
+ */
769
+ async function fetchKeywordConcentration(client, siteUrl, period, options) {
770
+ return analyzeKeywordConcentration(await fetchKeywords(client, siteUrl, period), options);
771
+ }
772
+ /**
773
+ * Fetch keywords and analyze clusters.
774
+ * API calls: 1
775
+ */
776
+ async function fetchClustering(client, siteUrl, period, options) {
777
+ return analyzeClustering(await fetchKeywords(client, siteUrl, period), options);
778
+ }
779
+ /**
780
+ * Fetch dates and analyze seasonality patterns.
781
+ * API calls: 1
782
+ */
783
+ async function fetchSeasonality(client, siteUrl, period, options) {
784
+ return analyzeSeasonality(await fetchDates(client, siteUrl, period), options);
785
+ }
786
+ /**
787
+ * Fetch pages for both periods and analyze content decay.
788
+ * API calls: 2
789
+ */
790
+ async function fetchDecay(client, siteUrl, periods, options) {
791
+ const [current, previous] = await Promise.all([fetchPages(client, siteUrl, periods.current), fetchPages(client, siteUrl, periods.previous)]);
792
+ return analyzeDecay({
793
+ current,
794
+ previous
795
+ }, options);
796
+ }
797
+ /**
798
+ * Fetch keywords for both periods and analyze movers/shakers.
799
+ * API calls: 2
800
+ */
801
+ async function fetchMovers(client, siteUrl, periods, options) {
802
+ const [current, previous] = await Promise.all([fetchKeywords(client, siteUrl, periods.current), fetchKeywords(client, siteUrl, periods.previous)]);
803
+ return analyzeMovers({
804
+ current,
805
+ previous
806
+ }, options);
807
+ }
808
+
809
+ //#endregion
810
+ export { analyzeBrandSegmentation, analyzeClustering, analyzeConcentration, analyzeDecay, analyzeKeywordConcentration, analyzeMovers, analyzeOpportunity, analyzePageConcentration, analyzeSeasonality, analyzeStrikingDistance, createSorter, fetchBrandSegmentation, fetchClustering, fetchDecay, fetchKeywordConcentration, fetchMovers, fetchOpportunity, fetchPageConcentration, fetchSeasonality, fetchStrikingDistance, num, queryAnalytics, queryComparison };