gsc-cli 2.1.0 → 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 (100) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +410 -414
  3. data/bin/gsc +28067 -5661
  4. data/dist/gsc +29121 -5046
  5. data/lib/gsc/aio_hunter.rb +343 -0
  6. data/lib/gsc/answer_synthesizer.rb +157 -0
  7. data/lib/gsc/api.rb +53 -1
  8. data/lib/gsc/auth.rb +26 -0
  9. data/lib/gsc/brand_segmenter.rb +140 -0
  10. data/lib/gsc/cache_manager.rb +806 -0
  11. data/lib/gsc/cannibalization_analyzer.rb +141 -0
  12. data/lib/gsc/canonical_chains.rb +367 -0
  13. data/lib/gsc/citation_simulator.rb +339 -0
  14. data/lib/gsc/cli/aio_hunter.rb +154 -0
  15. data/lib/gsc/cli/analytics.rb +788 -0
  16. data/lib/gsc/cli/audit.rb +1976 -0
  17. data/lib/gsc/cli/base.rb +384 -0
  18. data/lib/gsc/cli/cache.rb +266 -0
  19. data/lib/gsc/cli/canonical.rb +223 -0
  20. data/lib/gsc/cli/citation_simulator.rb +152 -0
  21. data/lib/gsc/cli/dashboard.rb +354 -0
  22. data/lib/gsc/cli/doctor.rb +129 -0
  23. data/lib/gsc/cli/eeat.rb +125 -0
  24. data/lib/gsc/cli/ga4.rb +852 -0
  25. data/lib/gsc/cli/growth.rb +650 -0
  26. data/lib/gsc/cli/hreflang.rb +164 -0
  27. data/lib/gsc/cli/image_seo.rb +162 -0
  28. data/lib/gsc/cli/indexing.rb +458 -0
  29. data/lib/gsc/cli/intent_shift.rb +125 -0
  30. data/lib/gsc/cli/keyword_value.rb +134 -0
  31. data/lib/gsc/cli/keywords.rb +795 -0
  32. data/lib/gsc/cli/landing_roi.rb +308 -0
  33. data/lib/gsc/cli/low_ctr.rb +213 -0
  34. data/lib/gsc/cli/mobile_parity.rb +150 -0
  35. data/lib/gsc/cli/report.rb +100 -0
  36. data/lib/gsc/cli/rich_results.rb +172 -0
  37. data/lib/gsc/cli/schema_generate.rb +149 -0
  38. data/lib/gsc/cli/seasonal.rb +232 -0
  39. data/lib/gsc/cli/security.rb +153 -0
  40. data/lib/gsc/cli/setup.rb +1291 -0
  41. data/lib/gsc/cli/sitemap_tree.rb +143 -0
  42. data/lib/gsc/cli/skill_pack.rb +62 -0
  43. data/lib/gsc/cli/soft_404.rb +199 -0
  44. data/lib/gsc/cli/sparkline.rb +227 -0
  45. data/lib/gsc/cli/watchdog.rb +150 -0
  46. data/lib/gsc/cli/zombie_purger.rb +208 -0
  47. data/lib/gsc/cli.rb +707 -5265
  48. data/lib/gsc/cli_advanced.rb +987 -44
  49. data/lib/gsc/client.rb +17 -2
  50. data/lib/gsc/color.rb +16 -1
  51. data/lib/gsc/command_registry.rb +47 -9
  52. data/lib/gsc/config.rb +2 -2
  53. data/lib/gsc/ctr_curve.rb +115 -0
  54. data/lib/gsc/decay_predictor.rb +322 -0
  55. data/lib/gsc/doctor.rb +434 -0
  56. data/lib/gsc/eeat_auditor.rb +428 -0
  57. data/lib/gsc/entity_auditor.rb +229 -0
  58. data/lib/gsc/firewall_scanner.rb +733 -0
  59. data/lib/gsc/geo_auditor.rb +368 -0
  60. data/lib/gsc/google_trends.rb +8 -1
  61. data/lib/gsc/heading_validator.rb +283 -0
  62. data/lib/gsc/hreflang_validator.rb +412 -0
  63. data/lib/gsc/image_seo.rb +286 -0
  64. data/lib/gsc/indexing_queue.rb +179 -0
  65. data/lib/gsc/indexnow.rb +93 -0
  66. data/lib/gsc/intent_shift.rb +188 -0
  67. data/lib/gsc/internal_links.rb +153 -36
  68. data/lib/gsc/keyword_value.rb +191 -0
  69. data/lib/gsc/landing_roi.rb +195 -0
  70. data/lib/gsc/llms_generator.rb +343 -22
  71. data/lib/gsc/low_ctr_rewriter.rb +408 -0
  72. data/lib/gsc/mobile_parity.rb +222 -0
  73. data/lib/gsc/network_tracer.rb +8 -1
  74. data/lib/gsc/page_analyzer.rb +47 -7
  75. data/lib/gsc/prompts.rb +38 -29
  76. data/lib/gsc/questions_harvester.rb +178 -0
  77. data/lib/gsc/report_generator.rb +461 -0
  78. data/lib/gsc/rich_results.rb +388 -0
  79. data/lib/gsc/robots_checker.rb +46 -15
  80. data/lib/gsc/schema_generator.rb +788 -0
  81. data/lib/gsc/schema_validator.rb +36 -38
  82. data/lib/gsc/seasonal_predictor.rb +381 -0
  83. data/lib/gsc/security_scanner.rb +496 -0
  84. data/lib/gsc/serp_feature_detector.rb +359 -0
  85. data/lib/gsc/serp_preview.rb +108 -22
  86. data/lib/gsc/site_crawler.rb +113 -21
  87. data/lib/gsc/sitemap_loader.rb +15 -4
  88. data/lib/gsc/sitemap_tree.rb +301 -0
  89. data/lib/gsc/skill_pack.rb +195 -0
  90. data/lib/gsc/soft_404_analyzer.rb +385 -0
  91. data/lib/gsc/sparkline.rb +171 -0
  92. data/lib/gsc/speed_correlator.rb +416 -0
  93. data/lib/gsc/striking_playbook.rb +190 -0
  94. data/lib/gsc/title_optimizer.rb +420 -0
  95. data/lib/gsc/vault.rb +260 -0
  96. data/lib/gsc/version.rb +1 -1
  97. data/lib/gsc/watchdog.rb +235 -0
  98. data/lib/gsc/zombie_purger.rb +366 -0
  99. data/lib/gsc.rb +118 -0
  100. metadata +75 -1
@@ -2,11 +2,28 @@
2
2
  # frozen_string_literal: true
3
3
 
4
4
  require 'json'
5
+ require_relative 'schema_generator'
5
6
 
6
7
  module GSC
7
8
  class SchemaValidator
8
9
  attr_reader :url, :schemas, :validation_results
9
10
 
11
+ AVAILABLE_TEMPLATES = [
12
+ { type: 'faq', name: 'FAQPage', feature: 'Interactive FAQ Accordion Drop-down' },
13
+ { type: 'product', name: 'Product', feature: 'Merchant Rich Card, Star Ratings & Prices' },
14
+ { type: 'software', name: 'SoftwareApplication', feature: 'App Card, OS Badge, Pricing & Rating' },
15
+ { type: 'breadcrumb', name: 'BreadcrumbList', feature: 'Hierarchical SERP Navigation Path' },
16
+ { type: 'article', name: 'Article', feature: 'Google Top Stories Carousel & Author E-E-A-T' },
17
+ { type: 'howto', name: 'HowTo', feature: 'Step-by-Step Guided Instructions' },
18
+ { type: 'course', name: 'Course', feature: 'Course Carousel & Provider Card' },
19
+ { type: 'job', name: 'JobPosting', feature: 'Google for Jobs Interactive Listing' },
20
+ { type: 'event', name: 'Event', feature: 'Event Schedule, Venue & Ticket Offers' },
21
+ { type: 'localbusiness', name: 'LocalBusiness', feature: 'Google Maps & Local 3-Pack with Hours/Phone' },
22
+ { type: 'video', name: 'VideoObject', feature: 'Video SERP Thumbnail & Key Moments' },
23
+ { type: 'recipe', name: 'Recipe', feature: 'Recipe Card with Cook Time & Nutrition' },
24
+ { type: 'organization', name: 'Organization', feature: 'Brand Knowledge Graph Panel & Logo' }
25
+ ].freeze
26
+
10
27
  def initialize(url)
11
28
  @url = url.to_s.strip
12
29
  end
@@ -14,7 +31,8 @@ module GSC
14
31
  def audit
15
32
  pa = GSC::PageAnalyzer.new(@url)
16
33
  data = pa.fetch_and_analyze
17
- @schemas = data.dig(:structured_data, :schemas) || []
34
+ raw_schemas = data[:schema] || data.dig(:structured_data, :schemas) || []
35
+ @schemas = self.class.flatten_schemas(raw_schemas)
18
36
 
19
37
  results = []
20
38
  @schemas.each_with_index do |schema, idx|
@@ -28,6 +46,22 @@ module GSC
28
46
  }
29
47
  end
30
48
 
49
+ def self.flatten_schemas(items)
50
+ return [] unless items.is_a?(Array)
51
+ items.flat_map do |item|
52
+ target = item.is_a?(Hash) && (item[:data] || item['data']) ? (item[:data] || item['data']) : item
53
+ if target.is_a?(Hash) && target['@graph'].is_a?(Array)
54
+ flatten_schemas(target['@graph'])
55
+ elsif target.is_a?(Array)
56
+ flatten_schemas(target)
57
+ elsif target.is_a?(Hash)
58
+ [target]
59
+ else
60
+ []
61
+ end
62
+ end
63
+ end
64
+
31
65
  def validate_single_schema(schema, idx)
32
66
  type = schema['@type'] || 'Unknown'
33
67
  errors = []
@@ -80,43 +114,7 @@ module GSC
80
114
  end
81
115
 
82
116
  def self.generate_template(type, params = {})
83
- case type.to_s.downcase
84
- when 'faq'
85
- {
86
- '@context' => 'https://schema.org',
87
- '@type' => 'FAQPage',
88
- 'mainEntity' => [
89
- {
90
- '@type' => 'Question',
91
- 'name' => params[:question] || 'What is PackingLog?',
92
- 'acceptedAnswer' => {
93
- '@type' => 'Answer',
94
- 'text' => params[:answer] || 'PackingLog is a free moving box and inventory management system.'
95
- }
96
- }
97
- ]
98
- }
99
- when 'software', 'app'
100
- {
101
- '@context' => 'https://schema.org',
102
- '@type' => 'SoftwareApplication',
103
- 'name' => params[:name] || 'PackingLog',
104
- 'applicationCategory' => params[:category] || 'UtilitiesApplication',
105
- 'operatingSystem' => 'Web, iOS, Android',
106
- 'offers' => {
107
- '@type' => 'Offer',
108
- 'price' => '0',
109
- 'priceCurrency' => 'USD'
110
- }
111
- }
112
- else
113
- {
114
- '@context' => 'https://schema.org',
115
- '@type' => 'Organization',
116
- 'name' => params[:name] || 'PackingLog',
117
- 'url' => params[:url] || 'https://packinglog.com'
118
- }
119
- end
117
+ GSC::SchemaGenerator.new.generate(type, params)[:schema]
120
118
  end
121
119
  end
122
120
  end
@@ -0,0 +1,381 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'date'
4
+ require 'time'
5
+ require 'json'
6
+ require_relative 'color'
7
+ require_relative 'google_trends'
8
+
9
+ module GSC
10
+ class SeasonalPredictor
11
+ MONTH_NAMES = %w[Jan Feb Mar Apr May Jun Jul Aug Sep Oct Nov Dec].freeze
12
+
13
+ attr_reader :keyword_or_domain, :options
14
+
15
+ def initialize(keyword_or_domain = nil, options = {})
16
+ @keyword_or_domain = keyword_or_domain
17
+ @options = options || {}
18
+ end
19
+
20
+ # Analyze single keyword seasonality using 5-year trends data
21
+ def analyze_keyword(keyword, geo: 'US', time: '5y')
22
+ trends_data = GoogleTrends.fetch(keyword, geo: geo, time: time)
23
+ points = trends_data[:points] || []
24
+
25
+ # If live trends returns empty or fails, report genuine status
26
+ if points.empty?
27
+ return {
28
+ ok: false,
29
+ error: "Google Trends data currently unavailable or rate-limited for '#{keyword}'.",
30
+ keyword: keyword,
31
+ geo: geo,
32
+ time: time,
33
+ points_count: 0
34
+ }
35
+ end
36
+
37
+ monthly_stats = decompose_monthly_seasonality(points)
38
+ runway = detect_runway_and_deadlines(monthly_stats)
39
+ classification = classify_seasonality_pattern(monthly_stats)
40
+
41
+ {
42
+ ok: true,
43
+ keyword: keyword,
44
+ geo: geo,
45
+ time: time,
46
+ sparkline: trends_data[:sparkline],
47
+ monthly_stats: monthly_stats,
48
+ runway: runway,
49
+ classification: classification,
50
+ points_count: points.size
51
+ }
52
+ end
53
+
54
+ # Group time series points into 12 calendar months and compute Seasonality Index (SI)
55
+ def decompose_monthly_seasonality(points)
56
+ buckets = Hash.new { |h, k| h[k] = [] }
57
+
58
+ points.each do |pt|
59
+ d_str = pt[:date].to_s
60
+ score = pt[:score].to_i
61
+
62
+ # Parse month from formatted date e.g. "2023-11-15", "Nov 2023", or Unix timestamp
63
+ month = parse_month_number(d_str)
64
+ buckets[month] << score if month && month >= 1 && month <= 12
65
+ end
66
+
67
+ # Ensure all 12 months exist
68
+ 1.upto(12) { |m| buckets[m] = [50] if buckets[m].empty? }
69
+
70
+ month_averages = {}
71
+ buckets.each do |m, scores|
72
+ month_averages[m] = scores.sum.to_f / [scores.size, 1].max
73
+ end
74
+
75
+ annual_baseline = month_averages.values.sum.to_f / 12.0
76
+ annual_baseline = 1.0 if annual_baseline.zero?
77
+
78
+ monthly_indices = {}
79
+ 1.upto(12) do |m|
80
+ avg_score = month_averages[m]
81
+ si = ((avg_score / annual_baseline) * 100).round
82
+ monthly_indices[m] = {
83
+ month: m,
84
+ month_name: MONTH_NAMES[m - 1],
85
+ avg_score: avg_score.round(1),
86
+ seasonality_index: si,
87
+ relative_multiplier: (si / 100.0).round(2),
88
+ sample_points: buckets[m].size
89
+ }
90
+ end
91
+
92
+ # Identify peak and trough months
93
+ peak_m = monthly_indices.values.max_by { |v| v[:seasonality_index] }
94
+ trough_m = monthly_indices.values.min_by { |v| v[:seasonality_index] }
95
+
96
+ {
97
+ months: monthly_indices,
98
+ annual_baseline: annual_baseline.round(1),
99
+ peak_month: peak_m[:month],
100
+ peak_month_name: peak_m[:month_name],
101
+ peak_index: peak_m[:seasonality_index],
102
+ peak_multiplier: peak_m[:relative_multiplier],
103
+ trough_month: trough_m[:month],
104
+ trough_month_name: trough_m[:month_name],
105
+ trough_index: trough_m[:seasonality_index]
106
+ }
107
+ end
108
+
109
+ # The 60-Day Runway & Publish Deadline Calculator
110
+ def detect_runway_and_deadlines(monthly_stats, current_date = Date.today)
111
+ curr_m = current_date.month
112
+ curr_y = current_date.year
113
+ months = monthly_stats[:months]
114
+
115
+ upcoming_3m = [1, 2, 3].map do |offset|
116
+ target_m = ((curr_m - 1 + offset) % 12) + 1
117
+ months[target_m]
118
+ end
119
+
120
+ # Find highest impending surge
121
+ spike_target = upcoming_3m.find { |m| m[:seasonality_index] >= 125 } || upcoming_3m.max_by { |m| m[:seasonality_index] }
122
+ is_significant_spike = spike_target[:seasonality_index] >= 120
123
+
124
+ target_month_num = spike_target[:month]
125
+ months_away = ((target_month_num - curr_m) % 12)
126
+ months_away = 12 if months_away.zero?
127
+
128
+ # Calculate estimated peak start date
129
+ target_year = (curr_m + months_away > 12) ? curr_y + 1 : curr_y
130
+ peak_start_date = Date.new(target_year, target_month_num, 1)
131
+
132
+ # Googlebot indexing runway: recommend publishing 35 days before peak starts
133
+ publish_deadline = peak_start_date - 35
134
+ days_to_deadline = (publish_deadline - current_date).to_i
135
+
136
+ urgency = if days_to_deadline <= 14
137
+ 'CRITICAL'
138
+ elsif days_to_deadline <= 45
139
+ 'HIGH'
140
+ elsif days_to_deadline <= 75
141
+ 'MODERATE'
142
+ else
143
+ 'EARLY_PLANNING'
144
+ end
145
+
146
+ {
147
+ current_month: curr_m,
148
+ current_month_name: MONTH_NAMES[curr_m - 1],
149
+ current_month_index: months[curr_m][:seasonality_index],
150
+ is_spiking_soon: is_significant_spike,
151
+ spike_month: target_month_num,
152
+ spike_month_name: spike_target[:month_name],
153
+ spike_index: spike_target[:seasonality_index],
154
+ spike_multiplier: spike_target[:relative_multiplier],
155
+ months_away: months_away,
156
+ peak_start_date: peak_start_date.iso8601,
157
+ publish_deadline: publish_deadline.iso8601,
158
+ days_to_deadline: [days_to_deadline, 0].max,
159
+ urgency: urgency,
160
+ recommendation: generate_action_recommendation(urgency, spike_target[:month_name], days_to_deadline, spike_target[:relative_multiplier])
161
+ }
162
+ end
163
+
164
+ # Classify seasonality profile
165
+ def classify_seasonality_pattern(monthly_stats)
166
+ peak_m = monthly_stats[:peak_month]
167
+ peak_idx = monthly_stats[:peak_index]
168
+ trough_idx = monthly_stats[:trough_index]
169
+ spread = peak_idx - trough_idx
170
+
171
+ if spread < 30
172
+ {
173
+ pattern: 'EVERGREEN',
174
+ label: '🌲 Evergreen / Consistent Demand',
175
+ description: 'Steady year-round search volume with minimal cyclical variation (<30% swing).'
176
+ }
177
+ elsif [10, 11, 12].include?(peak_m)
178
+ {
179
+ pattern: 'Q4_HOLIDAY',
180
+ label: '🎁 Q4 Holiday & Black Friday Peak',
181
+ description: 'Massive surge in November/December driven by holiday gift buying and year-end budgets.'
182
+ }
183
+ elsif [6, 7, 8].include?(peak_m)
184
+ {
185
+ pattern: 'Q3_SUMMER',
186
+ label: '☀️ Summer Surge (Q3)',
187
+ description: 'High summer demand peaking June–August; cooling rapidly by September.'
188
+ }
189
+ elsif [1, 2].include?(peak_m)
190
+ {
191
+ pattern: 'Q1_NEW_YEAR',
192
+ label: '🎯 New Year & Resolution Spike (Q1)',
193
+ description: 'January/February surge driven by new year resolutions, renewals, or fresh fiscal budgets.'
194
+ }
195
+ elsif [3, 4, 5].include?(peak_m)
196
+ {
197
+ pattern: 'Q2_SPRING',
198
+ label: '🌱 Spring Refresh & Tax Season (Q2)',
199
+ description: 'Demand rises through spring, peaking March through May.'
200
+ }
201
+ else
202
+ {
203
+ pattern: 'CYCLICAL',
204
+ label: '🔄 Seasonal Cyclical Demand',
205
+ description: "Distinct peak concentrated around #{monthly_stats[:peak_month_name]} (#{peak_idx} SI)."
206
+ }
207
+ end
208
+ end
209
+
210
+ # False-Flag Penalty vs Seasonal Trough Detector
211
+ def diagnose_traffic_drop(query, gsc_change_pct, current_month = Date.today.month, monthly_stats = nil)
212
+ stats = monthly_stats || analyze_keyword(query)[:monthly_stats]
213
+ months = stats[:months]
214
+ prev_m = current_month == 1 ? 12 : current_month - 1
215
+
216
+ curr_si = months[current_month][:seasonality_index] || 1.0
217
+ prev_si = months[prev_m][:seasonality_index] || 1.0
218
+ expected_seasonal_change = prev_si.to_f > 0 ? (((curr_si - prev_si).to_f / prev_si) * 100).round : 0
219
+
220
+ # If traffic dropped and historical trends drop similarly
221
+ is_seasonal_trough = (gsc_change_pct < -15 && expected_seasonal_change < -15)
222
+ deviation = (gsc_change_pct - expected_seasonal_change).round
223
+
224
+ verdict = if is_seasonal_trough && deviation.abs <= 25
225
+ :seasonal_cycle
226
+ elsif gsc_change_pct < -25 && expected_seasonal_change >= -10
227
+ :potential_algorithmic_penalty
228
+ elsif gsc_change_pct > 15 && expected_seasonal_change <= 0
229
+ :outperforming_seasonality
230
+ else
231
+ :normal_variance
232
+ end
233
+
234
+ {
235
+ query: query,
236
+ gsc_change_pct: gsc_change_pct,
237
+ expected_seasonal_change_pct: expected_seasonal_change,
238
+ deviation_pct: deviation,
239
+ verdict: verdict,
240
+ explanation: format_verdict_explanation(verdict, query, gsc_change_pct, expected_seasonal_change)
241
+ }
242
+ end
243
+
244
+ # Seasonal Striking-Distance ROI Multiplier
245
+ # Combines CTR Curve from Turn 4 with Seasonal Surge Multipliers
246
+ def calculate_seasonal_roi(gsc_clicks, gsc_impressions, gsc_position, peak_multiplier, target_pos: 3)
247
+ curr_pos = [gsc_position.to_f, 1.0].max
248
+ target = [target_pos.to_f, 1.0].max
249
+
250
+ # Turn 4 empirical CTR Curve model
251
+ ctr_curve = {
252
+ 1 => 0.285, 2 => 0.157, 3 => 0.110, 4 => 0.080, 5 => 0.055,
253
+ 6 => 0.042, 7 => 0.032, 8 => 0.024, 9 => 0.019, 10 => 0.014
254
+ }
255
+
256
+ curr_ctr = ctr_curve[curr_pos.round] || [0.01, (1.0 / (curr_pos * 8))].max
257
+ target_ctr = ctr_curve[target.round] || 0.110
258
+
259
+ # Standard 30-day baseline projection
260
+ baseline_lift = gsc_impressions * (target_ctr - curr_ctr)
261
+ baseline_lift = [baseline_lift, 0.0].max
262
+
263
+ # Seasonal Peak Surge Multiplier
264
+ peak_monthly_clicks = (baseline_lift * peak_multiplier).round
265
+ peak_90d_clicks = (peak_monthly_clicks * 2.5).round # Peak surge lasts ~2.5 months
266
+
267
+ {
268
+ current_position: curr_pos.round(1),
269
+ target_position: target.round,
270
+ current_ctr_pct: (curr_ctr * 100).round(2),
271
+ target_ctr_pct: (target_ctr * 100).round(2),
272
+ baseline_monthly_click_gain: baseline_lift.round,
273
+ peak_surge_multiplier: peak_multiplier,
274
+ seasonal_monthly_click_gain: peak_monthly_clicks,
275
+ peak_90d_click_harvest: peak_90d_clicks,
276
+ roi_multiplier: peak_multiplier
277
+ }
278
+ end
279
+
280
+ # Render a 12-Month ASCII Calendar Bar Chart
281
+ def render_ascii_calendar(monthly_stats, current_month = Date.today.month)
282
+ months = monthly_stats[:months]
283
+ peak_m = monthly_stats[:peak_month]
284
+ trough_m = monthly_stats[:trough_month]
285
+
286
+ out = []
287
+ out << " #{Color::BOLD}MONTH SEASONALITY INDEX (SI) DEMAND TRAJECTORY#{Color::RESET}"
288
+ out << " " + ("─" * 65)
289
+
290
+ 1.upto(12) do |m|
291
+ info = months[m]
292
+ si = info[:seasonality_index]
293
+ name = info[:month_name]
294
+
295
+ # Max bar width 28 chars (where 100 SI = 14 chars)
296
+ bar_len = [((si / 200.0) * 28).round, 1].max
297
+ bar_len = [bar_len, 32].min
298
+
299
+ is_current = (m == current_month)
300
+ is_peak = (m == peak_m)
301
+ is_trough = (m == trough_m)
302
+
303
+ bar_char = "█"
304
+ bar_str = bar_char * bar_len
305
+
306
+ colored_bar = if is_peak
307
+ Color.c(bar_str, Color::MAGENTA, Color::BOLD)
308
+ elsif si >= 125
309
+ Color.c(bar_str, Color::RED, Color::BOLD)
310
+ elsif si >= 100
311
+ Color.c(bar_str, Color::GREEN)
312
+ elsif is_trough
313
+ Color.c(bar_str, Color::BLUE)
314
+ else
315
+ Color.c(bar_str, Color::YELLOW)
316
+ end
317
+
318
+ badge = []
319
+ badge << Color.c("👈 CURRENT", Color::CYAN, Color::BOLD) if is_current
320
+ badge << Color.c("🏆 PEAK (#{info[:relative_multiplier]}x)", Color::MAGENTA, Color::BOLD) if is_peak
321
+ badge << Color.c("❄️ TROUGH", Color::BLUE) if is_trough && !is_peak
322
+
323
+ si_formatted = si.to_s.rjust(3)
324
+ m_display = is_current ? Color.c(name, Color::CYAN, Color::BOLD) : name
325
+
326
+ out << " #{m_display.ljust(6)} #{Color.c(si_formatted, Color::BOLD)} | #{colored_bar.ljust(35)} #{badge.join(' ')}"
327
+ end
328
+
329
+ out << " " + ("─" * 65)
330
+ out << " #{Color::DIM}100 = Annual Average Baseline | >125 = Surge Season | <75 = Low Trough#{Color::RESET}"
331
+ out.join("\n")
332
+ end
333
+
334
+ private
335
+
336
+ def parse_month_number(date_str)
337
+ # Format: "YYYY-MM-DD"
338
+ if date_str =~ /^\d{4}-(\d{2})-\d{2}/
339
+ return $1.to_i
340
+ end
341
+
342
+ # Format: "Jan 2023" or "Nov 15, 2023"
343
+ MONTH_NAMES.each_with_index do |name, idx|
344
+ return idx + 1 if date_str.downcase.include?(name.downcase)
345
+ end
346
+
347
+ # Epoch timestamp fallback
348
+ if date_str =~ /^\d{10,}$/
349
+ return Time.at(date_str.to_i).utc.month rescue 1
350
+ end
351
+
352
+ nil
353
+ end
354
+
355
+ def generate_action_recommendation(urgency, spike_month, days_left, multiplier)
356
+ case urgency
357
+ when 'CRITICAL'
358
+ "🚨 URGENT: Demand spikes in #{spike_month} (#{multiplier}x surge)! Only #{days_left} days before Googlebot indexing cutoff. Publish updated headings, FAQ schema, and internal links immediately."
359
+ when 'HIGH'
360
+ "⚡ OPTIMIZE NOW: #{days_left} days remaining until freeze. Begin title tag rewrites and expand product/landing page content before the #{spike_month} #{multiplier}x surge."
361
+ when 'MODERATE'
362
+ "📅 PREPARE BRIEFS: #{days_left} days left. Finalize seasonal keyword targets and schedule new content to publish 30 days ahead of the #{spike_month} peak."
363
+ else
364
+ "🌲 EVERGREEN/MONITOR: Steady demand ahead. Review quarterly to ensure stable rankings."
365
+ end
366
+ end
367
+
368
+ def format_verdict_explanation(verdict, query, gsc_change, expected_change)
369
+ case verdict
370
+ when :seasonal_cycle
371
+ "✅ False alarm! GSC clicks dropped #{gsc_change}%, matching the expected seasonal trough of #{expected_change}%. Do NOT panic or rewrite URLs; demand naturally rebounds."
372
+ when :potential_algorithmic_penalty
373
+ "⚠️ Potential ranking loss detected: GSC traffic plunged #{gsc_change}%, but historical search volume only dipped #{expected_change}%. Investigate search positions and technical crawl logs."
374
+ when :outperforming_seasonality
375
+ "🚀 Remarkable organic gain: GSC clicks climbed +#{gsc_change}% despite a flat/cooling seasonal macro trend (#{expected_change}%). Your market share is expanding!"
376
+ else
377
+ "ℹ️ Normal traffic variance within standard expected boundaries (#{gsc_change}% vs #{expected_change}% seasonal expectation)."
378
+ end
379
+ end
380
+ end
381
+ end