gsc-cli 2.0.2 → 2.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.
Files changed (108) hide show
  1. checksums.yaml +4 -4
  2. data/AUTH.md +205 -0
  3. data/FUNDING.md +120 -0
  4. data/README.md +463 -299
  5. data/bin/gsc +29158 -4921
  6. data/dist/gsc +29158 -4921
  7. data/lib/gsc/aio_hunter.rb +343 -0
  8. data/lib/gsc/answer_synthesizer.rb +157 -0
  9. data/lib/gsc/api.rb +53 -1
  10. data/lib/gsc/auth.rb +26 -0
  11. data/lib/gsc/backlinks_manager.rb +96 -0
  12. data/lib/gsc/brand_segmenter.rb +140 -0
  13. data/lib/gsc/cache_manager.rb +806 -0
  14. data/lib/gsc/cannibalization_analyzer.rb +141 -0
  15. data/lib/gsc/canonical_chains.rb +367 -0
  16. data/lib/gsc/citation_simulator.rb +339 -0
  17. data/lib/gsc/cli/aio_hunter.rb +154 -0
  18. data/lib/gsc/cli/analytics.rb +788 -0
  19. data/lib/gsc/cli/audit.rb +1976 -0
  20. data/lib/gsc/cli/base.rb +384 -0
  21. data/lib/gsc/cli/cache.rb +266 -0
  22. data/lib/gsc/cli/canonical.rb +223 -0
  23. data/lib/gsc/cli/citation_simulator.rb +152 -0
  24. data/lib/gsc/cli/dashboard.rb +354 -0
  25. data/lib/gsc/cli/doctor.rb +129 -0
  26. data/lib/gsc/cli/eeat.rb +125 -0
  27. data/lib/gsc/cli/ga4.rb +852 -0
  28. data/lib/gsc/cli/growth.rb +650 -0
  29. data/lib/gsc/cli/hreflang.rb +164 -0
  30. data/lib/gsc/cli/image_seo.rb +162 -0
  31. data/lib/gsc/cli/indexing.rb +458 -0
  32. data/lib/gsc/cli/intent_shift.rb +125 -0
  33. data/lib/gsc/cli/keyword_value.rb +134 -0
  34. data/lib/gsc/cli/keywords.rb +795 -0
  35. data/lib/gsc/cli/landing_roi.rb +308 -0
  36. data/lib/gsc/cli/low_ctr.rb +213 -0
  37. data/lib/gsc/cli/mobile_parity.rb +150 -0
  38. data/lib/gsc/cli/report.rb +100 -0
  39. data/lib/gsc/cli/rich_results.rb +172 -0
  40. data/lib/gsc/cli/schema_generate.rb +149 -0
  41. data/lib/gsc/cli/seasonal.rb +232 -0
  42. data/lib/gsc/cli/security.rb +153 -0
  43. data/lib/gsc/cli/setup.rb +1291 -0
  44. data/lib/gsc/cli/sitemap_tree.rb +143 -0
  45. data/lib/gsc/cli/skill_pack.rb +62 -0
  46. data/lib/gsc/cli/soft_404.rb +199 -0
  47. data/lib/gsc/cli/sparkline.rb +227 -0
  48. data/lib/gsc/cli/watchdog.rb +150 -0
  49. data/lib/gsc/cli/zombie_purger.rb +208 -0
  50. data/lib/gsc/cli.rb +706 -5111
  51. data/lib/gsc/cli_advanced.rb +1513 -0
  52. data/lib/gsc/client.rb +17 -2
  53. data/lib/gsc/color.rb +16 -1
  54. data/lib/gsc/command_registry.rb +47 -9
  55. data/lib/gsc/config.rb +11 -2
  56. data/lib/gsc/content_gap.rb +112 -0
  57. data/lib/gsc/ctr_curve.rb +115 -0
  58. data/lib/gsc/decay_predictor.rb +322 -0
  59. data/lib/gsc/doctor.rb +434 -0
  60. data/lib/gsc/eeat_auditor.rb +428 -0
  61. data/lib/gsc/entity_auditor.rb +229 -0
  62. data/lib/gsc/firewall_scanner.rb +733 -0
  63. data/lib/gsc/geo_auditor.rb +368 -0
  64. data/lib/gsc/google_suggest.rb +109 -0
  65. data/lib/gsc/google_trends.rb +8 -1
  66. data/lib/gsc/heading_validator.rb +283 -0
  67. data/lib/gsc/hreflang_validator.rb +412 -0
  68. data/lib/gsc/image_seo.rb +286 -0
  69. data/lib/gsc/indexing_queue.rb +179 -0
  70. data/lib/gsc/indexnow.rb +93 -0
  71. data/lib/gsc/intent_shift.rb +188 -0
  72. data/lib/gsc/internal_links.rb +249 -0
  73. data/lib/gsc/keyword_value.rb +191 -0
  74. data/lib/gsc/landing_roi.rb +195 -0
  75. data/lib/gsc/llms_generator.rb +425 -0
  76. data/lib/gsc/low_ctr_rewriter.rb +408 -0
  77. data/lib/gsc/mobile_parity.rb +222 -0
  78. data/lib/gsc/network_tracer.rb +93 -0
  79. data/lib/gsc/open_page_rank.rb +72 -0
  80. data/lib/gsc/page_analyzer.rb +47 -7
  81. data/lib/gsc/page_comparator.rb +108 -0
  82. data/lib/gsc/page_speed.rb +110 -0
  83. data/lib/gsc/prompts.rb +38 -29
  84. data/lib/gsc/questions_harvester.rb +178 -0
  85. data/lib/gsc/report_generator.rb +461 -0
  86. data/lib/gsc/rich_results.rb +388 -0
  87. data/lib/gsc/robots_checker.rb +114 -0
  88. data/lib/gsc/schema_generator.rb +788 -0
  89. data/lib/gsc/schema_validator.rb +120 -0
  90. data/lib/gsc/seasonal_predictor.rb +381 -0
  91. data/lib/gsc/security_scanner.rb +496 -0
  92. data/lib/gsc/serp_feature_detector.rb +359 -0
  93. data/lib/gsc/serp_preview.rb +152 -0
  94. data/lib/gsc/site_crawler.rb +113 -21
  95. data/lib/gsc/sitemap_loader.rb +15 -4
  96. data/lib/gsc/sitemap_tree.rb +301 -0
  97. data/lib/gsc/skill_pack.rb +195 -0
  98. data/lib/gsc/soft_404_analyzer.rb +385 -0
  99. data/lib/gsc/sparkline.rb +171 -0
  100. data/lib/gsc/speed_correlator.rb +416 -0
  101. data/lib/gsc/striking_playbook.rb +190 -0
  102. data/lib/gsc/title_optimizer.rb +420 -0
  103. data/lib/gsc/vault.rb +260 -0
  104. data/lib/gsc/version.rb +1 -1
  105. data/lib/gsc/watchdog.rb +235 -0
  106. data/lib/gsc/zombie_purger.rb +366 -0
  107. data/lib/gsc.rb +144 -0
  108. metadata +91 -2
@@ -0,0 +1,322 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'date'
4
+
5
+ module GSC
6
+ class DecayPredictor
7
+ # Minimum impressions over the 28-day window to evaluate decay
8
+ DEFAULT_MIN_IMP = 10
9
+
10
+ def self.analyze_timeseries(rows, dimension: 'page', min_imp: DEFAULT_MIN_IMP)
11
+ # rows is array of { "keys" => [entity, "YYYY-MM-DD"], "clicks" => n, "impressions" => n, "position" => f }
12
+ # Group by entity
13
+ entity_days = Hash.new { |h, k| h[k] = {} }
14
+ all_dates = []
15
+
16
+ rows.each do |r|
17
+ entity = r['keys'][0]
18
+ date_str = r['keys'][1]
19
+ all_dates << date_str
20
+ entity_days[entity][date_str] = {
21
+ clicks: (r['clicks'] || 0).to_i,
22
+ impressions: (r['impressions'] || 0).to_i,
23
+ position: (r['position'] || 0.0).to_f
24
+ }
25
+ end
26
+
27
+ all_dates = all_dates.uniq.sort
28
+ return empty_result(dimension) if all_dates.empty?
29
+
30
+ # Partition the dates into 4 chronological buckets (oldest to newest)
31
+ # total dates usually 28 (or whatever was queried)
32
+ chunk_size = (all_dates.size / 4.0).ceil
33
+ chunk_size = 1 if chunk_size < 1
34
+
35
+ w4_dates = all_dates[0...chunk_size] || []
36
+ w3_dates = all_dates[chunk_size...(chunk_size * 2)] || []
37
+ w2_dates = all_dates[(chunk_size * 2)...(chunk_size * 3)] || []
38
+ w1_dates = all_dates[(chunk_size * 3)..] || []
39
+
40
+ results = []
41
+
42
+ entity_days.each do |entity, days|
43
+ w4 = sum_window(days, w4_dates)
44
+ w3 = sum_window(days, w3_dates)
45
+ w2 = sum_window(days, w2_dates)
46
+ w1 = sum_window(days, w1_dates)
47
+
48
+ total_imp = w1[:impressions] + w2[:impressions] + w3[:impressions] + w4[:impressions]
49
+ total_clicks = w1[:clicks] + w2[:clicks] + w3[:clicks] + w4[:clicks]
50
+
51
+ next if total_imp < min_imp
52
+
53
+ # 7-day velocity: Compare W1 (latest 7d) vs W2 (prior 7d)
54
+ w1_clicks = w1[:clicks]
55
+ w2_clicks = w2[:clicks]
56
+ w1_imp = w1[:impressions]
57
+ w2_imp = w2[:impressions]
58
+
59
+ vel_7d_clicks_pct = calc_pct_change(w1_clicks, w2_clicks)
60
+ vel_7d_imp_pct = calc_pct_change(w1_imp, w2_imp)
61
+
62
+ # 14-day velocity: (W1 + W2) vs (W3 + W4)
63
+ h1_clicks = w1_clicks + w2_clicks
64
+ h2_clicks = w3[:clicks] + w4[:clicks]
65
+ h1_imp = w1_imp + w2_imp
66
+ h2_imp = w3[:impressions] + w4[:impressions]
67
+
68
+ vel_14d_clicks_pct = calc_pct_change(h1_clicks, h2_clicks)
69
+ vel_14d_imp_pct = calc_pct_change(h1_imp, h2_imp)
70
+
71
+ # 28-day Macro Velocity: W1 vs W4
72
+ vel_28d_clicks_pct = calc_pct_change(w1_clicks, w4[:clicks])
73
+ vel_28d_imp_pct = calc_pct_change(w1_imp, w4[:impressions])
74
+
75
+ # Historical linear regression slope across the 4 weeks:
76
+ # x = [1, 2, 3, 4], y = [w4_imp, w3_imp, w2_imp, w1_imp]
77
+ imp_series = [w4[:impressions], w3[:impressions], w2[:impressions], w1[:impressions]]
78
+ click_series = [w4[:clicks], w3[:clicks], w2[:clicks], w1[:clicks]]
79
+ pos_series = [w4[:position], w3[:position], w2[:position], w1[:position]].compact
80
+
81
+ imp_slope = calc_slope(imp_series)
82
+ click_slope = calc_slope(click_series)
83
+ pos_drift = (w1[:position] && w4[:position]) ? (w1[:position] - w4[:position]).round(1) : 0.0
84
+
85
+ # Classification
86
+ classification, severity = classify_decay(
87
+ vel_7d_imp_pct: vel_7d_imp_pct,
88
+ vel_14d_imp_pct: vel_14d_imp_pct,
89
+ vel_28d_imp_pct: vel_28d_imp_pct,
90
+ imp_slope: imp_slope,
91
+ pos_drift: pos_drift,
92
+ total_imp: total_imp,
93
+ w1: w1,
94
+ w4: w4
95
+ )
96
+
97
+ # Projected loss if trend continues for 30 days
98
+ projected_monthly_clicks_lost = 0
99
+ if click_slope.negative?
100
+ projected_monthly_clicks_lost = [(-click_slope * 4).round, w1_clicks * 4].min
101
+ end
102
+
103
+ prescription = formulate_prescription(
104
+ classification: classification,
105
+ pos_drift: pos_drift,
106
+ vel_7d_imp_pct: vel_7d_imp_pct,
107
+ vel_14d_imp_pct: vel_14d_imp_pct,
108
+ w1_imp: w1_imp,
109
+ w1_clicks: w1_clicks,
110
+ entity: entity,
111
+ dimension: dimension
112
+ )
113
+
114
+ results << {
115
+ entity: entity,
116
+ dimension: dimension,
117
+ classification: classification,
118
+ severity: severity,
119
+ total_impressions: total_imp,
120
+ total_clicks: total_clicks,
121
+ current_7d_impressions: w1_imp,
122
+ prior_7d_impressions: w2_imp,
123
+ current_7d_clicks: w1_clicks,
124
+ prior_7d_clicks: w2_clicks,
125
+ vel_7d_imp_pct: vel_7d_imp_pct,
126
+ vel_7d_clicks_pct: vel_7d_clicks_pct,
127
+ vel_14d_imp_pct: vel_14d_imp_pct,
128
+ vel_14d_clicks_pct: vel_14d_clicks_pct,
129
+ vel_28d_imp_pct: vel_28d_imp_pct,
130
+ vel_28d_clicks_pct: vel_28d_clicks_pct,
131
+ weekly_impressions: imp_series,
132
+ weekly_clicks: click_series,
133
+ impression_slope: imp_slope.round(2),
134
+ click_slope: click_slope.round(2),
135
+ current_position: w1[:position] ? w1[:position].round(1) : nil,
136
+ prior_position: w4[:position] ? w4[:position].round(1) : nil,
137
+ position_drift: pos_drift,
138
+ projected_monthly_clicks_lost: projected_monthly_clicks_lost,
139
+ prescription: prescription
140
+ }
141
+ end
142
+
143
+ # Sort: Critical decay first, then by negative slope
144
+ decaying_items = results.select { |r| %w[CRITICAL_COLLAPSE ACCELERATING_DECAY STEADY_EROSION].include?(r[:classification]) }
145
+ .sort_by { |r| [severity_weight(r[:severity]), r[:impression_slope]] }
146
+
147
+ surging_items = results.select { |r| r[:classification] == 'SURGING' }
148
+ .sort_by { |r| -r[:vel_14d_imp_pct] }
149
+
150
+ stable_items = results.select { |r| r[:classification] == 'STABLE' }
151
+
152
+ health_score = calculate_health_score(results)
153
+
154
+ {
155
+ dimension: dimension,
156
+ total_evaluated: results.size,
157
+ decaying_count: decaying_items.size,
158
+ surging_count: surging_items.size,
159
+ stable_count: stable_items.size,
160
+ health_score: health_score,
161
+ health_grade: grade_for_score(health_score),
162
+ decaying: decaying_items,
163
+ surging: surging_items,
164
+ date_range: {
165
+ oldest: all_dates.first,
166
+ newest: all_dates.last,
167
+ total_days: all_dates.size
168
+ }
169
+ }
170
+ end
171
+
172
+ def self.sum_window(days_map, dates)
173
+ clicks = 0
174
+ impressions = 0
175
+ positions = []
176
+ weighted_pos_sum = 0.0
177
+ weighted_pos_imp = 0
178
+
179
+ dates.each do |d|
180
+ entry = days_map[d]
181
+ next unless entry
182
+
183
+ clicks += entry[:clicks]
184
+ impressions += entry[:impressions]
185
+ if entry[:position] && entry[:position] > 0
186
+ positions << entry[:position]
187
+ if entry[:impressions] > 0
188
+ weighted_pos_sum += (entry[:position] * entry[:impressions])
189
+ weighted_pos_imp += entry[:impressions]
190
+ end
191
+ end
192
+ end
193
+
194
+ avg_pos = if weighted_pos_imp > 0
195
+ weighted_pos_sum / weighted_pos_imp.to_f
196
+ elsif positions.any?
197
+ positions.sum / positions.size.to_f
198
+ else
199
+ nil
200
+ end
201
+
202
+ { clicks: clicks, impressions: impressions, position: avg_pos }
203
+ end
204
+
205
+ def self.calc_pct_change(curr, prior)
206
+ return 0.0 if prior.to_f.zero? && curr.to_f.zero?
207
+ return 100.0 if prior.to_f.zero? && curr.positive?
208
+ return -100.0 if curr.to_f.zero? && prior.positive?
209
+
210
+ (((curr - prior) / prior.to_f) * 100.0).round(1)
211
+ end
212
+
213
+ def self.calc_slope(series)
214
+ # x = [1, 2, 3, 4], y = series
215
+ n = series.size
216
+ return 0.0 if n < 2
217
+
218
+ x_coords = (1..n).to_a
219
+ x_mean = x_coords.sum / n.to_f
220
+ y_mean = series.sum / n.to_f
221
+
222
+ numerator = 0.0
223
+ denominator = 0.0
224
+
225
+ n.times do |i|
226
+ x_diff = x_coords[i] - x_mean
227
+ y_diff = series[i] - y_mean
228
+ numerator += (x_diff * y_diff)
229
+ denominator += (x_diff**2)
230
+ end
231
+
232
+ return 0.0 if denominator.zero?
233
+
234
+ numerator / denominator
235
+ end
236
+
237
+ def self.classify_decay(vel_7d_imp_pct:, vel_14d_imp_pct:, vel_28d_imp_pct:, imp_slope:, pos_drift:, total_imp:, w1:, w4:)
238
+ # pos_drift > 0 means current position number is higher (e.g. dropped from 5 to 9 => +4 drift)
239
+ if (vel_14d_imp_pct <= -50.0 && total_imp >= 20) || (w1[:impressions].zero? && w4[:impressions] >= 10)
240
+ ['CRITICAL_COLLAPSE', 'CRITICAL']
241
+ elsif vel_7d_imp_pct <= -35.0 && vel_7d_imp_pct < (vel_14d_imp_pct - 15.0)
242
+ ['ACCELERATING_DECAY', 'HIGH']
243
+ elsif imp_slope.negative? && (vel_14d_imp_pct <= -20.0 || pos_drift >= 3.0)
244
+ ['STEADY_EROSION', 'MODERATE']
245
+ elsif vel_14d_imp_pct >= 25.0 || imp_slope >= 5.0
246
+ ['SURGING', 'INFO']
247
+ else
248
+ ['STABLE', 'LOW']
249
+ end
250
+ end
251
+
252
+ def self.formulate_prescription(classification:, pos_drift:, vel_7d_imp_pct:, vel_14d_imp_pct:, w1_imp:, w1_clicks:, entity:, dimension:)
253
+ case classification
254
+ when 'CRITICAL_COLLAPSE'
255
+ if pos_drift >= 5.0
256
+ "🚨 Severe SERP Ranking Drop (+#{pos_drift} pos): Inspect for indexability errors, manual actions, or lost canonical tag."
257
+ elsif w1_imp.zero?
258
+ "💀 Zero Search Impressions in Past 7 Days: Run 'gsc inspect #{entity}' to verify Googlebot coverage."
259
+ else
260
+ "⚠️ High-Volume Collapse (#{vel_14d_imp_pct}% over 14d): Prioritize emergency content refresh and re-index via 'gsc index #{entity}'."
261
+ end
262
+ when 'ACCELERATING_DECAY'
263
+ "⚡ Rapid 7-Day Traffic Drop (#{vel_7d_imp_pct}%): Competitor surge detected. Enhance headline hook and verify schema."
264
+ when 'STEADY_EROSION'
265
+ if pos_drift >= 2.0
266
+ "📉 Gradual Position Drift (+#{pos_drift}): Update dated statistics, expand FAQ answers, and add 2-3 internal links."
267
+ else
268
+ "📉 Search Demand Erosion (#{vel_14d_imp_pct}%): Content freshness fatigue. Add 2026 insights and interactive widgets."
269
+ end
270
+ when 'SURGING'
271
+ "🚀 Surging Velocity (+#{vel_14d_imp_pct}%): Momentum window! Add related internal links to capture additional cluster queries."
272
+ else
273
+ "✅ Consistent Performance: Maintain current on-page content and monitor monthly."
274
+ end
275
+ end
276
+
277
+ def self.severity_weight(sev)
278
+ case sev
279
+ when 'CRITICAL' then 1
280
+ when 'HIGH' then 2
281
+ when 'MODERATE' then 3
282
+ else 4
283
+ end
284
+ end
285
+
286
+ def self.calculate_health_score(results)
287
+ return 100 if results.empty?
288
+
289
+ critical = results.count { |r| r[:severity] == 'CRITICAL' }
290
+ high = results.count { |r| r[:severity] == 'HIGH' }
291
+ moderate = results.count { |r| r[:severity] == 'MODERATE' }
292
+
293
+ score = 100 - (critical * 25) - (high * 15) - (moderate * 5)
294
+ [[score, 0].max, 100].min
295
+ end
296
+
297
+ def self.grade_for_score(score)
298
+ case score
299
+ when 90..100 then 'A'
300
+ when 80..89 then 'B'
301
+ when 70..79 then 'C'
302
+ when 60..69 then 'D'
303
+ else 'F'
304
+ end
305
+ end
306
+
307
+ def self.empty_result(dim)
308
+ {
309
+ dimension: dim,
310
+ total_evaluated: 0,
311
+ decaying_count: 0,
312
+ surging_count: 0,
313
+ stable_count: 0,
314
+ health_score: 100,
315
+ health_grade: 'A',
316
+ decaying: [],
317
+ surging: [],
318
+ date_range: {}
319
+ }
320
+ end
321
+ end
322
+ end