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,1513 @@
1
+ # encoding: utf-8
2
+ # frozen_string_literal: true
3
+
4
+ require 'json'
5
+ require 'uri'
6
+
7
+ module GSC
8
+ class CLI
9
+ # 1. Google Suggest
10
+ def self.handle_suggest_command(target, options)
11
+ query = target.to_s.strip
12
+ if query.empty?
13
+ puts Color.c("āŒ Error: Query required. Example: gsc suggest \"seo audit\"", Color::RED)
14
+ return
15
+ end
16
+
17
+ alphabet = options[:alphabet] || false
18
+ suggest = GSC::GoogleSuggest.new(query, options)
19
+ results = suggest.fetch(alphabet: alphabet, questions: false)
20
+
21
+ if options[:json]
22
+ puts JSON.pretty_generate(results)
23
+ return
24
+ end
25
+
26
+ puts BANNER unless options[:in_dashboard]
27
+ puts "šŸ’” #{Color::BOLD}GOOGLE SEARCH SUGGESTIONS:#{Color::RESET} #{Color.c(query, Color::CYAN)}"
28
+ puts "─" * 70
29
+
30
+ if alphabet
31
+ results.each do |key, list|
32
+ next if list.empty?
33
+ prefix = (key == 'root') ? "Root" : "+ #{key.upcase}"
34
+ puts "\n#{Color.c(prefix, Color::BOLD, Color::YELLOW)}:"
35
+ list.each do |item|
36
+ puts " • #{item[:term]}"
37
+ end
38
+ end
39
+ else
40
+ if results.empty?
41
+ puts " (No search suggestions returned)"
42
+ else
43
+ results.each_with_index do |item, idx|
44
+ puts " #{Color.c((idx + 1).to_s.rjust(2), Color::DIM)}. #{Color.c(item[:term], Color::BOLD)}"
45
+ end
46
+ end
47
+ end
48
+ puts ""
49
+ end
50
+
51
+ # 2. Questions / PAA
52
+ def self.handle_questions_command(target, options)
53
+ query = target.to_s.strip
54
+ if query.empty?
55
+ puts Color.c("āŒ Error: Query required. Example: gsc questions \"seo audit\"", Color::RED)
56
+ return
57
+ end
58
+
59
+ suggest = GSC::GoogleSuggest.new(query, options)
60
+ results = suggest.fetch(questions: true)
61
+
62
+ if options[:json]
63
+ puts JSON.pretty_generate(results)
64
+ return
65
+ end
66
+
67
+ puts BANNER unless options[:in_dashboard]
68
+ puts "ā“ #{Color::BOLD}SEARCH INTENT QUESTIONS & FAQs:#{Color::RESET} #{Color.c(query, Color::CYAN)}"
69
+ puts "─" * 70
70
+
71
+ total_found = 0
72
+ results.each do |prefix, list|
73
+ next if list.empty?
74
+ puts "\n#{Color.c(prefix.upcase, Color::BOLD, Color::CYAN)}:"
75
+ list.each do |item|
76
+ total_found += 1
77
+ puts " • #{item[:term]}"
78
+ end
79
+ end
80
+
81
+ if total_found.zero?
82
+ puts " (No question suggestions found for \"#{query}\")"
83
+ end
84
+ puts ""
85
+ end
86
+
87
+ # 3. OpenPageRank Domain Authority
88
+ def self.handle_authority_command(target, extra, options)
89
+ domains = [target, extra].flatten.compact.reject { |d| d.to_s.strip.empty? }
90
+ domains << Config.default_domain if domains.empty?
91
+ domains = domains.compact
92
+
93
+ if domains.empty?
94
+ puts Color.c("āŒ Error: Domain required. Example: gsc authority example.com", Color::RED)
95
+ return
96
+ end
97
+
98
+ opr = GSC::OpenPageRank.new
99
+ unless opr.configured?
100
+ puts Color.c("āš ļø OpenPageRank API key not configured.", Color::YELLOW, Color::BOLD)
101
+ puts " Get a 100% free key (300,000 free queries/month) at: #{Color.c('https://openpagerank.com', Color::CYAN)}"
102
+ puts " Then run: #{Color.c('gsc config set opr_api_key <YOUR_KEY>', Color::GREEN)}"
103
+ puts " Or pass: #{Color.c('OPENPAGERANK_API_KEY=<KEY> gsc authority ...', Color::DIM)}"
104
+ return
105
+ end
106
+
107
+ data = opr.check_domains(domains)
108
+
109
+ if options[:json]
110
+ puts JSON.pretty_generate(data)
111
+ return
112
+ end
113
+
114
+ puts BANNER unless options[:in_dashboard]
115
+ puts "🌐 #{Color::BOLD}OPEN PAGERANK & DOMAIN AUTHORITY (Common Crawl Graph):#{Color::RESET}"
116
+ puts "─" * 75
117
+
118
+ if data[:status] == 'error'
119
+ puts Color.c("āŒ Error: #{data[:message]}", Color::RED)
120
+ return
121
+ end
122
+
123
+ puts "#{'DOMAIN'.ljust(35)} #{'PAGERANK'.ljust(12)} #{'GLOBAL RANK'.ljust(18)} #{'STATUS'}"
124
+ puts "─" * 75
125
+
126
+ (data[:records] || []).each do |rec|
127
+ d_name = rec[:domain].to_s.ljust(35)
128
+ pr = sprintf("%.2f / 10", rec[:page_rank_decimal]).ljust(12)
129
+ gr = rec[:rank] ? "##{rec[:rank].to_s.reverse.gsub(/(\d{3})(?=\d)/, '\\1,').reverse}".ljust(18) : "N/A".ljust(18)
130
+ st = (rec[:status_code] == 200) ? Color.c("200 OK", Color::GREEN) : Color.c(rec[:status_code].to_s, Color::YELLOW)
131
+
132
+ puts "#{Color.c(d_name, Color::BOLD)} #{Color.c(pr, Color::CYAN)} #{Color.c(gr, Color::YELLOW)} #{st}"
133
+ end
134
+ puts ""
135
+ end
136
+
137
+ # 4. PageSpeed Core Web Vitals
138
+ def self.handle_speed_command(target, options)
139
+ raw = target || (Config.default_domain ? "https://#{Config.default_domain}" : nil)
140
+ if raw.nil? || raw.empty?
141
+ puts Color.c("āŒ Error: Target URL or domain required. Example: gsc speed https://mybrand.com", Color::RED)
142
+ return
143
+ end
144
+ url = raw.to_s.start_with?('http') ? raw : "https://#{raw}"
145
+ strategy = options[:strategy] || 'mobile'
146
+
147
+ puts BANNER unless options[:json] || options[:in_dashboard]
148
+ puts "⚔ Measuring Core Web Vitals via Google PageSpeed Insights (#{strategy.upcase}):" unless options[:json]
149
+ puts " #{Color.c(url, Color::CYAN)}\n" unless options[:json]
150
+
151
+ ps = GSC::PageSpeed.new(url, strategy: strategy)
152
+ data = ps.run
153
+
154
+ if options[:json]
155
+ puts JSON.pretty_generate(data)
156
+ return
157
+ end
158
+
159
+ if data[:error]
160
+ puts Color.c("āŒ PageSpeed API Error: #{data[:message]}", Color::RED)
161
+ return
162
+ end
163
+
164
+ perf_score = data[:performance_score]
165
+ score_color = perf_score >= 90 ? Color::GREEN : (perf_score >= 50 ? Color::YELLOW : Color::RED)
166
+
167
+ puts "╔══════════════════════════════════════════════════════════════╗"
168
+ puts "ā•‘ Lighthouse Performance Score: #{Color.c(perf_score.to_s.rjust(3) + ' / 100', score_color, Color::BOLD)} ā•‘"
169
+ puts "ā•‘ Lighthouse SEO Score : #{Color.c(data[:seo_score].to_s.rjust(3) + ' / 100', Color::GREEN, Color::BOLD)} ā•‘"
170
+ puts "ā•šā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•"
171
+
172
+ m = data[:metrics] || {}
173
+ puts "\n#{Color::BOLD}šŸ“Š CORE WEB VITALS (Lab Metrics):#{Color::RESET}"
174
+ puts " • LCP (Largest Contentful Paint) : #{Color.c(m[:lcp] || 'N/A', Color::BOLD)}"
175
+ puts " • FCP (First Contentful Paint) : #{Color.c(m[:fcp] || 'N/A', Color::BOLD)}"
176
+ puts " • CLS (Cumulative Layout Shift) : #{Color.c(m[:cls] || 'N/A', Color::BOLD)}"
177
+ puts " • TBT (Total Blocking Time) : #{Color.c(m[:tbt] || 'N/A', Color::BOLD)}"
178
+ puts " • Speed Index : #{Color.c(m[:speed_index] || 'N/A', Color::BOLD)}"
179
+
180
+ opps = data[:opportunities] || []
181
+ unless opps.empty?
182
+ puts "\n#{Color::BOLD}šŸ’” TOP SPEED OPPORTUNITIES:#{Color::RESET}"
183
+ opps.each do |opp|
184
+ puts " • #{opp[:title]}: #{Color.c(opp[:display] || "#{opp[:savings_ms]}ms savings", Color::YELLOW)}"
185
+ end
186
+ end
187
+ puts ""
188
+ end
189
+
190
+ # 4b. Core Web Vitals & GSC Performance Correlator
191
+ def self.handle_speed_correlate_command(target, options)
192
+ domain = options[:domain] || Config.default_domain
193
+ if (domain.nil? || domain.empty?) && (target.nil? || target.empty?)
194
+ puts Color.c("āŒ Error: Target URL or domain required. Example: gsc speed-correlate https://mybrand.com", Color::RED)
195
+ return
196
+ end
197
+ url = target || "https://#{domain}/"
198
+ url = "https://#{url}" unless url =~ %r{^https?://}
199
+ strategy = options[:strategy] || 'mobile'
200
+ days = (options[:days] || 28).to_i
201
+
202
+ # Attempt to get GSC API client if authenticated
203
+ api = nil
204
+ sa = GSC::Auth.find_service_account(options[:key], domain)
205
+ if sa && sa[:data]
206
+ token = GSC::Auth.fetch_access_token(sa[:data])
207
+ client = GSC::Client.new(token: token)
208
+ api = GSC::API.new(client)
209
+ end
210
+
211
+ correlator = GSC::SpeedCorrelator.new(url, api: api, domain: domain, days: days, strategy: strategy, options: options)
212
+ data = correlator.correlate
213
+
214
+ if options[:json]
215
+ puts JSON.pretty_generate(data)
216
+ return
217
+ end
218
+
219
+ puts BANNER unless options[:in_dashboard]
220
+ puts "⚔ #{Color::BOLD}CORE WEB VITALS & SEARCH CONSOLE PERFORMANCE CORRELATOR:#{Color::RESET}"
221
+ puts " Target URL: #{Color.c(url, Color::CYAN, Color::BOLD)} [Strategy: #{strategy.upcase}]"
222
+ puts "─" * 80
223
+
224
+ cwv = data[:core_web_vitals] || {}
225
+ gsc = data[:gsc_performance] || {}
226
+ diag = data[:correlation_diagnosis] || {}
227
+ proj = data[:projections] || {}
228
+
229
+ perf_score = cwv[:performance_score] || 0
230
+ score_color = perf_score >= 90 ? Color::GREEN : (perf_score >= 50 ? Color::YELLOW : Color::RED)
231
+
232
+ puts "šŸ“± #{Color::BOLD}LIGHTHOUSE PERFORMANCE:#{Color::RESET} #{Color.c("#{perf_score}/100", score_color, Color::BOLD)} [Source: #{cwv[:source]}]"
233
+ puts " • LCP (Largest Contentful Paint) : #{Color.c(cwv[:lcp_display].to_s, Color::BOLD)} [Status: #{diag[:lcp_status]}]"
234
+ puts " • CLS (Cumulative Layout Shift) : #{Color.c(cwv[:cls_display].to_s, Color::BOLD)} [Status: #{diag[:cls_status]}]"
235
+ puts " • FCP (First Contentful Paint) : #{Color.c(cwv[:fcp_display].to_s, Color::BOLD)}"
236
+ puts " • TBT (Total Blocking Time) : #{Color.c(cwv[:tbt_display].to_s, Color::BOLD)}"
237
+
238
+ puts "\nšŸ“ˆ #{Color::BOLD}SEARCH CONSOLE TRACTION (Past #{days} Days):#{Color::RESET}"
239
+ puts " • Impressions : #{Color.c(gsc[:impressions].to_s, Color::CYAN, Color::BOLD)}"
240
+ puts " • Clicks : #{Color.c(gsc[:clicks].to_s, Color::GREEN, Color::BOLD)}"
241
+ puts " • CTR : #{Color.c("#{gsc[:ctr]}%", Color::BOLD)}"
242
+ puts " • Avg Position: #{Color.c(gsc[:position].to_s, Color::BOLD)}"
243
+
244
+ puts "\n🧠 #{Color::BOLD}ALGORITHMIC CWV IMPACT DIAGNOSIS:#{Color::RESET}"
245
+ diag_color = diag[:overall_cwv_pass] ? Color::GREEN : Color::YELLOW
246
+ puts " • CWV Overall Verdict : [#{Color.c(diag[:overall_cwv_pass] ? 'PASSED' : 'NEEDS OPTIMIZATION', diag_color, Color::BOLD)}]"
247
+ puts " • Algorithmic Drag : #{Color.c(diag[:algorithmic_status], Color::BOLD)}"
248
+
249
+ puts "\nšŸš€ #{Color::BOLD}PROJECTED SEARCH IMPRESSION & TRAFFIC SURGE (Upon Reaching 'Good' CWV):#{Color::RESET}"
250
+ puts " • Potential Impression Lift : #{Color.c("+#{proj[:potential_lift_percentage]}%", Color::GREEN, Color::BOLD)} (+#{proj[:incremental_impressions_gain]} impressions)"
251
+ puts " • Projected Impressions Target: #{Color.c(proj[:projected_impressions].to_s, Color::CYAN, Color::BOLD)}"
252
+ puts " • Projected Rank Lift : #{Color.c(proj[:estimated_position_improvement].to_s, Color::GREEN, Color::BOLD)} (Pos #{proj[:current_position]} -> Pos #{proj[:projected_position]})"
253
+ puts " • Projected Monthly Click Gain: #{Color.c("+#{proj[:projected_incremental_monthly_clicks]} clicks/mo", Color::GREEN, Color::BOLD)}"
254
+
255
+ action_plan = data[:action_plan] || []
256
+ if action_plan.any?
257
+ puts "\nšŸ› ļø #{Color::BOLD}PRIORITIZED ENGINEERING FIXES FOR SPEED & RANKINGS:#{Color::RESET}"
258
+ action_plan.each_with_index do |act, idx|
259
+ puts " #{idx + 1}. [#{Color.c(act[:priority], Color::BOLD)}] [#{act[:metric]}]: #{act[:recommendation]}"
260
+ end
261
+ end
262
+
263
+ opps = cwv[:opportunities] || []
264
+ if opps.any?
265
+ puts "\nšŸ’” #{Color::BOLD}IDENTIFIED BOTTLENECK OPPORTUNITIES:#{Color::RESET}"
266
+ opps.each do |o|
267
+ puts " • #{o[:title]} (#{Color.c(o[:display] || "#{o[:savings_ms]}ms savings", Color::YELLOW)})"
268
+ end
269
+ end
270
+
271
+ puts "\n" + ("─" * 80) + "\n"
272
+ end
273
+
274
+ # 5. Page Comparison
275
+ def self.handle_compare_command(target, extra, options)
276
+ url1 = target
277
+ url2 = extra
278
+
279
+ if url1.nil? || url2.nil?
280
+ puts Color.c("āŒ Error: Two URLs required. Example: gsc compare https://site.com/p1 https://competitor.com/p2", Color::RED)
281
+ return
282
+ end
283
+
284
+ comp = GSC::PageComparator.new(url1, url2)
285
+ data = comp.compare
286
+
287
+ if options[:json]
288
+ puts JSON.pretty_generate(data)
289
+ return
290
+ end
291
+
292
+ puts BANNER unless options[:in_dashboard]
293
+ puts "🄊 #{Color::BOLD}HEAD-TO-HEAD SEO ON-PAGE COMPARISON:#{Color::RESET}"
294
+ puts " Page 1 (Target): #{Color.c(url1, Color::CYAN)}"
295
+ puts " Page 2 (Competitor): #{Color.c(url2, Color::YELLOW)}"
296
+ puts "─" * 80
297
+
298
+ c = data[:comparison] || {}
299
+
300
+ # Meta Titles
301
+ t1 = c.dig(:meta, :title, :page1) || {}
302
+ t2 = c.dig(:meta, :title, :page2) || {}
303
+ puts "\n#{Color::BOLD}šŸ“‘ TITLE TAG:#{Color::RESET}"
304
+ puts " P1: #{t1[:text]} (#{t1[:length]} chars) [#{t1[:optimal] ? Color.c('Optimal', Color::GREEN) : Color.c('Review', Color::YELLOW)}]"
305
+ puts " P2: #{t2[:text]} (#{t2[:length]} chars) [#{t2[:optimal] ? Color.c('Optimal', Color::GREEN) : Color.c('Review', Color::YELLOW)}]"
306
+
307
+ # Headings
308
+ h = c[:headings] || {}
309
+ puts "\n#{Color::BOLD}šŸ·ļø HEADINGS H1 / H2:#{Color::RESET}"
310
+ puts " P1: #{h.dig(:h1_count, :page1)} H1s | #{h.dig(:h2_count, :page1)} H2s"
311
+ puts " P2: #{h.dig(:h1_count, :page2)} H1s | #{h.dig(:h2_count, :page2)} H2s"
312
+
313
+ # Images
314
+ img = c[:images] || {}
315
+ puts "\n#{Color::BOLD}šŸ–¼ļø IMAGES & ACCESSIBILITY:#{Color::RESET}"
316
+ puts " P1: #{img.dig(:total_images, :page1)} images (#{img.dig(:missing_alt, :page1)} missing alt)"
317
+ puts " P2: #{img.dig(:total_images, :page2)} images (#{img.dig(:missing_alt, :page2)} missing alt)"
318
+
319
+ # Links
320
+ l = c[:links] || {}
321
+ puts "\n#{Color::BOLD}šŸ”— LINK COUNTS:#{Color::RESET}"
322
+ puts " P1: #{l.dig(:internal, :page1)} internal | #{l.dig(:external, :page1)} external"
323
+ puts " P2: #{l.dig(:internal, :page2)} internal | #{l.dig(:external, :page2)} external"
324
+
325
+ # Schema
326
+ s = c[:structured_data] || {}
327
+ puts "\n#{Color::BOLD}šŸ“¦ STRUCTURED DATA (JSON-LD):#{Color::RESET}"
328
+ puts " P1: #{s.dig(:schema_count, :page1)} schemas #{(s.dig(:schema_types, :page1) || []).inspect}"
329
+ puts " P2: #{s.dig(:schema_count, :page2)} schemas #{(s.dig(:schema_types, :page2) || []).inspect}"
330
+
331
+ # Speed
332
+ p_time = c[:performance] || {}
333
+ puts "\n#{Color::BOLD}⚔ RESPONSE TIME:#{Color::RESET}"
334
+ puts " P1: #{p_time.dig(:response_time_ms, :page1)}ms | P2: #{p_time.dig(:response_time_ms, :page2)}ms"
335
+ puts ""
336
+ end
337
+
338
+ # 6. Content Gap
339
+ def self.handle_content_gap_command(target, extra, options)
340
+ url1 = target
341
+ url2 = extra
342
+
343
+ if url1.nil? || url2.nil?
344
+ puts Color.c("āŒ Error: Two URLs required. Example: gsc content-gap https://mysite.com https://competitor.com", Color::RED)
345
+ return
346
+ end
347
+
348
+ gap = GSC::ContentGap.new(url1, url2)
349
+ data = gap.analyze
350
+
351
+ if options[:json]
352
+ puts JSON.pretty_generate(data)
353
+ return
354
+ end
355
+
356
+ puts BANNER unless options[:in_dashboard]
357
+ puts "šŸ” #{Color::BOLD}CONTENT & TOPICAL KEYWORD GAP (SurferSEO Style):#{Color::RESET}"
358
+ puts " My URL: #{Color.c(url1, Color::CYAN)} (#{data[:page1][:word_count]} words)"
359
+ puts " Competitor URL: #{Color.c(url2, Color::YELLOW)} (#{data[:page2][:word_count]} words)"
360
+ puts "─" * 80
361
+
362
+ puts "\n#{Color::BOLD}šŸŽÆ HIGH-FREQUENCY PHRASES IN COMPETITOR MISSING IN YOUR CONTENT:#{Color::RESET}"
363
+ unigrams = data[:missing_unigrams] || []
364
+ bigrams = data[:missing_bigrams] || []
365
+
366
+ if unigrams.empty? && bigrams.empty?
367
+ puts " (No major content gap detected! Your page covers competitor terminology well.)"
368
+ else
369
+ puts "\n #{Color.c('Top Missing 2-Word Keyphrases:', Color::BOLD, Color::YELLOW)}"
370
+ bigrams.first(8).each do |b|
371
+ puts " • \"#{Color.c(b[:term], Color::BOLD)}\" (Competitor uses #{b[:competitor_count]}x, You: #{b[:your_count]}x)"
372
+ end
373
+
374
+ puts "\n #{Color.c('Top Missing Keywords:', Color::BOLD, Color::CYAN)}"
375
+ unigrams.first(8).each do |u|
376
+ puts " • \"#{Color.c(u[:term], Color::BOLD)}\" (Competitor uses #{u[:competitor_count]}x, You: #{u[:your_count]}x)"
377
+ end
378
+ end
379
+
380
+ headings = data[:missing_headings] || []
381
+ unless headings.empty?
382
+ puts "\n#{Color::BOLD}šŸ“‘ COMPETITOR HEADINGS / TOPICS YOU OMITTED:#{Color::RESET}"
383
+ headings.each do |h|
384
+ puts " • #{h}"
385
+ end
386
+ end
387
+ puts ""
388
+ end
389
+
390
+ # 7. Internal Links Audit & Orphan Page Rescue Engine
391
+ def self.handle_internal_links_command(target, options)
392
+ raw = target || (Config.default_domain ? "https://#{Config.default_domain}" : nil)
393
+ if raw.nil? || raw.empty?
394
+ puts Color.c("āŒ Error: Target URL or domain required. Example: gsc internal-links https://mybrand.com", Color::RED)
395
+ return
396
+ end
397
+ base_url = raw.to_s.start_with?('http') ? raw : "https://#{raw}"
398
+ conc = options[:concurrency] || 5
399
+ il = GSC::InternalLinks.new(base_url, limit: options[:limit] || 50, concurrency: conc)
400
+
401
+ puts BANNER unless options[:json] || options[:in_dashboard]
402
+ puts "šŸ•øļø Auditing Internal Links & Orphan Pages for: #{Color.c(base_url, Color::CYAN)} (#{conc} worker threads)...\n" unless options[:json]
403
+
404
+ data = il.audit
405
+
406
+ if options[:json]
407
+ puts JSON.pretty_generate(data)
408
+ return
409
+ end
410
+
411
+ score_color = case data[:health_score]
412
+ when 85..100 then Color::GREEN
413
+ when 70..84 then Color::YELLOW
414
+ else Color::RED
415
+ end
416
+
417
+ puts "šŸ“ Domain: #{Color.c(data[:base_url], Color::CYAN, Color::BOLD)}"
418
+ puts "⚔ Crawl Performance: #{data[:total_pages]} pages mapped in #{data[:crawl_duration_s]}s"
419
+ puts "šŸ† Link Equity Score: #{Color.c("#{data[:health_score]}/100 (Grade #{data[:health_grade]})", score_color, Color::BOLD)}"
420
+ puts "🚨 True Orphans: #{Color.c(data[:orphans].length.to_s, data[:orphans].empty? ? Color::GREEN : Color::RED, Color::BOLD)} (0 incoming internal links)"
421
+ puts "āš ļø Weakly Linked: #{Color.c(data[:weak_pages].length.to_s, Color::YELLOW, Color::BOLD)} (Only 1 incoming internal link)"
422
+ puts "šŸ§— Buried Pages: #{Color.c(data[:deep_pages].length.to_s, data[:deep_pages].empty? ? Color::GREEN : Color::YELLOW, Color::BOLD)} (Click depth >= 4)"
423
+ puts "─" * 80
424
+
425
+ orphan_details = data[:orphan_details] || []
426
+ unless orphan_details.empty?
427
+ puts "\n#{Color::BOLD}🚨 ORPHAN PAGE RESCUE PLAYBOOK (#{orphan_details.size} crawl dead-ends):#{Color::RESET}"
428
+ orphan_details.each_with_index do |orp, idx|
429
+ puts "\n #{Color::BOLD}#{idx + 1}. #{Color.c(orp[:url], Color::RED, Color::BOLD)}#{Color::RESET} [Depth: #{orp[:depth]}]"
430
+ puts " #{Color::BOLD}Rescue Prescriptions:#{Color::RESET}"
431
+ orp[:suggested_rescues].each do |rec|
432
+ puts " • #{Color.c(rec[:action], Color::CYAN)}"
433
+ end
434
+ end
435
+ else
436
+ puts "\n#{Color.c('āœ… EXCELLENT! No orphan pages detected on site.', Color::GREEN, Color::BOLD)}"
437
+ end
438
+
439
+ weak = data[:weak_pages] || []
440
+ unless weak.empty?
441
+ puts "\n#{Color::BOLD}āš ļø WEAKLY LINKED PAGES (Vulnerable to De-indexation):#{Color::RESET}"
442
+ weak.first(5).each do |w|
443
+ puts " • #{w[:url]} (Linked only from: #{Color.c(w[:source].to_s, Color::GRAY)})"
444
+ end
445
+ puts " ... and #{weak.size - 5} more weak pages" if weak.size > 5
446
+ end
447
+
448
+ deep = data[:deep_pages] || []
449
+ unless deep.empty?
450
+ puts "\n#{Color::BOLD}šŸ§— BURIED PAGES (Crawl Depth >= 4 clicks):#{Color::RESET}"
451
+ deep.first(5).each do |dp|
452
+ puts " • #{dp[:url]} (#{Color.c("#{dp[:depth]} clicks deep", Color::YELLOW)})"
453
+ end
454
+ end
455
+
456
+ puts "\n#{Color::BOLD}šŸ” TOP LINK EQUITY HUBS (High-Authority Internal Sources):#{Color::RESET}"
457
+ (data[:top_linked] || []).first(6).each do |top|
458
+ title_str = top[:title].to_s.empty? ? '' : " — \"#{top[:title][0..35]}...\""
459
+ puts " • #{Color.c(top[:url], Color::BOLD)}#{title_str} (#{Color.c(top[:incoming_count].to_s, Color::CYAN)} in / #{top[:outgoing_count]} out)"
460
+ end
461
+
462
+ if options[:csv]
463
+ csv_data = orphan_details.map do |o|
464
+ [o[:url], o[:depth], o[:suggested_rescues].map { |r| r[:source_url] }.join('; ')]
465
+ end
466
+ write_csv(options[:csv], %w[OrphanURL Depth RecommendedRescueSources], csv_data)
467
+ puts Color.c("\nšŸ“ Exported orphan rescue playbook to #{options[:csv]}", Color::CYAN)
468
+ end
469
+
470
+ puts "\n" + ("─" * 80) + "\n"
471
+ end
472
+
473
+ # 8. Schema Validator & Generator
474
+ def self.handle_schema_command(target, extra, options)
475
+ if target == 'generate' || target == 'gen'
476
+ schema_type = extra || 'faq'
477
+ tpl = GSC::SchemaValidator.generate_template(schema_type)
478
+ if options[:json]
479
+ puts JSON.pretty_generate(tpl)
480
+ else
481
+ puts Color.c("šŸ“‹ Generated JSON-LD Schema (#{schema_type}):", Color::GREEN, Color::BOLD)
482
+ puts "<script type=\"application/ld+json\">"
483
+ puts JSON.pretty_generate(tpl)
484
+ puts "</script>"
485
+ end
486
+ return
487
+ end
488
+
489
+ raw = target || (Config.default_domain ? "https://#{Config.default_domain}" : nil)
490
+ if raw.nil? || raw.empty?
491
+ puts Color.c("āŒ Error: Target URL or domain required. Example: gsc schema https://mybrand.com", Color::RED)
492
+ return
493
+ end
494
+ url = raw.to_s.start_with?('http') ? raw : "https://#{raw}"
495
+ sv = GSC::SchemaValidator.new(url)
496
+ data = sv.audit
497
+
498
+ if options[:json]
499
+ puts JSON.pretty_generate(data)
500
+ return
501
+ end
502
+
503
+ puts BANNER unless options[:in_dashboard]
504
+ puts "šŸ“¦ #{Color::BOLD}STRUCTURED DATA & RICH SNIPPET VALIDATION:#{Color::RESET} #{Color.c(url, Color::CYAN)}"
505
+ puts "─" * 75
506
+
507
+ schemas = data[:schemas] || []
508
+ if schemas.empty?
509
+ puts " (No JSON-LD structured data schemas found on this page)"
510
+ puts " Tip: Run `gsc schema generate faq` to create valid JSON-LD schema."
511
+ else
512
+ schemas.each do |sc|
513
+ valid_badge = sc[:valid] ? Color.c("VALID", Color::GREEN, Color::BOLD) : Color.c("INVALID", Color::RED, Color::BOLD)
514
+ puts "\nSchema ##{sc[:index] + 1}: #{Color.c(sc[:type], Color::BOLD)} [#{valid_badge}]"
515
+ (sc[:errors] || []).each { |e| puts " āŒ Error: #{Color.c(e, Color::RED)}" }
516
+ (sc[:warnings] || []).each { |w| puts " āš ļø Warning: #{Color.c(w, Color::YELLOW)}" }
517
+ end
518
+ end
519
+ puts ""
520
+ end
521
+
522
+ # 9. LLMS.txt & AI Search
523
+ def self.handle_llms_command(target, extra, options)
524
+ raw = target || (Config.default_domain ? "https://#{Config.default_domain}" : nil)
525
+ if raw.nil? || raw.empty?
526
+ puts Color.c("āŒ Error: Target URL or domain required. Example: gsc llms https://mybrand.com", Color::RED)
527
+ return
528
+ end
529
+ base_url = raw.to_s.start_with?('http') ? raw : "https://#{raw}"
530
+ llms = GSC::LlmsGenerator.new(base_url)
531
+
532
+ if extra == 'audit' || options[:audit]
533
+ data = llms.audit_ai_readability(base_url)
534
+ if options[:json]
535
+ puts JSON.pretty_generate(data)
536
+ else
537
+ puts BANNER unless options[:in_dashboard]
538
+ puts "šŸ¤– #{Color::BOLD}AI SEARCH ENGINE / LLM READABILITY AUDIT:#{Color::RESET} #{Color.c(base_url, Color::CYAN)}"
539
+ puts "─" * 70
540
+ puts "AI Citation Readiness Score: #{Color.c(data[:ai_readability_score].to_s + '/100', Color::GREEN, Color::BOLD)} [Grade: #{data[:grade]}]"
541
+ puts "\nFeatures Detected:"
542
+ puts " • Single H1 Heading : #{data.dig(:features, :h1_count) == 1 ? 'āœ… Yes' : 'āŒ No'}"
543
+ puts " • Tables for Data : #{data.dig(:features, :has_tables) ? 'āœ… Yes' : 'āŒ No'}"
544
+ puts " • Bullet Lists : #{data.dig(:features, :has_lists) ? 'āœ… Yes' : 'āŒ No'}"
545
+ puts " • Structured Data : #{data.dig(:features, :schemas_found)} schemas"
546
+ unless data[:issues].empty?
547
+ puts "\nOptimizations for Perplexity & ChatGPT:"
548
+ data[:issues].each { |iss| puts " • #{Color.c(iss, Color::YELLOW)}" }
549
+ end
550
+ puts ""
551
+ end
552
+ else
553
+ content = llms.generate_llms_txt
554
+ if options[:save]
555
+ File.write("llms.txt", content)
556
+ puts Color.c("āœ… Successfully wrote llms.txt to current directory!", Color::GREEN)
557
+ else
558
+ puts content
559
+ end
560
+ end
561
+ end
562
+
563
+ # 10. SERP & Social Preview / Live SERP Feature Detector
564
+ def self.handle_preview_command(target, options)
565
+ is_url = target.to_s.start_with?('http://', 'https://')
566
+ has_title_or_url = is_url || options[:url] || options[:title] || options[:desc] || options[:description] || options[:preview] || (target.to_s =~ /[:|—]/)
567
+
568
+ if options[:live] || options[:features] || (!has_title_or_url && !target.to_s.empty?)
569
+ return handle_serp_features_command(target, options)
570
+ end
571
+
572
+ url = options[:url] || (target if is_url)
573
+ url ||= (Config.default_domain ? "https://#{Config.default_domain}" : nil)
574
+ title = options[:title] || (!is_url && !target.to_s.empty? ? target.to_s : nil)
575
+ desc = options[:desc] || options[:description]
576
+
577
+ if url.nil? && (title.nil? || title.empty?)
578
+ puts Color.c("āŒ Error: Target URL or title required for SERP preview. Example: gsc preview https://mybrand.com/page", Color::RED)
579
+ return
580
+ end
581
+ url ||= "https://#{title.to_s.downcase.gsub(/[^a-z0-9]+/, '-')}.local"
582
+
583
+ sp = GSC::SerpPreview.new(url, title: title, desc: desc)
584
+ data = sp.generate
585
+
586
+ if options[:json]
587
+ puts JSON.pretty_generate(data)
588
+ return
589
+ end
590
+
591
+ puts BANNER unless options[:in_dashboard]
592
+ m = data[:metrics] || {}
593
+
594
+ puts "šŸ–„ļø #{Color::BOLD}GOOGLE SERP PREVIEW (Desktop Viewport):#{Color::RESET}"
595
+ puts "ā”Œā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”"
596
+ puts "│ #{Color.c(data.dig(:desktop_serp, :breadcrumb).to_s.ljust(71), Color::DIM)}│"
597
+ puts "│ #{Color.c(data.dig(:desktop_serp, :title).to_s.ljust(71), Color::BLUE, Color::BOLD)}│"
598
+ puts "│ #{Color.c(data.dig(:desktop_serp, :snippet).to_s[0..70].ljust(71), Color::DIM)}│"
599
+ puts "ā””ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”˜"
600
+
601
+ t_status = if m[:title_truncated]
602
+ Color.c("āš ļø TRUNCATED (+#{(m[:title_pixel_est] - m[:title_desktop_limit]).round(1)}px over 580px limit)", Color::YELLOW, Color::BOLD)
603
+ else
604
+ Color.c('āœ… OPTIMAL (< 580px desktop limit)', Color::GREEN, Color::BOLD)
605
+ end
606
+
607
+ d_status = if m[:desc_truncated]
608
+ Color.c("āš ļø TRUNCATED (+#{(m[:desc_pixel_est] - m[:desc_desktop_limit]).round(1)}px over 960px limit)", Color::YELLOW, Color::BOLD)
609
+ else
610
+ Color.c('āœ… OPTIMAL (< 960px desktop limit)', Color::GREEN, Color::BOLD)
611
+ end
612
+
613
+ puts " • Title Width: #{m[:title_chars]} chars / ~#{m[:title_pixel_est]}px [#{t_status}]"
614
+ puts " • Description Width: #{m[:desc_chars]} chars / ~#{m[:desc_pixel_est]}px [#{d_status}]"
615
+
616
+ puts "\nšŸ“± #{Color::BOLD}GOOGLE SERP PREVIEW (Mobile Viewport):#{Color::RESET}"
617
+ puts "ā”Œā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”"
618
+ puts "│ #{Color.c(data.dig(:mobile_serp, :breadcrumb).to_s.ljust(71), Color::DIM)}│"
619
+ puts "│ #{Color.c(data.dig(:mobile_serp, :title).to_s.ljust(71), Color::BLUE, Color::BOLD)}│"
620
+ puts "│ #{Color.c(data.dig(:mobile_serp, :snippet).to_s[0..70].ljust(71), Color::DIM)}│"
621
+ puts "ā””ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”˜"
622
+
623
+ soc = data[:social] || {}
624
+ if soc[:og_title] || soc[:og_image]
625
+ puts "\n🌐 #{Color::BOLD}OPEN GRAPH / SOCIAL CARD PREVIEW:#{Color::RESET}"
626
+ puts " • Title : #{soc[:og_title]}"
627
+ puts " • Description : #{soc[:og_description]}"
628
+ puts " • Card Image : #{soc[:og_image] || '(No og:image specified)'}"
629
+ end
630
+ puts ""
631
+ end
632
+
633
+ # 10a. Live SERP Feature Detector & Zero-Click Threat Analysis
634
+ def self.handle_serp_features_command(query, options)
635
+ query_str = query.to_s.sub(/^(query|live):/, '').strip
636
+ query_str = "what is #{Config.default_domain || 'technical seo'}" if query_str.empty?
637
+
638
+ detector = GSC::SerpFeatureDetector.new(query_str, options)
639
+ data = detector.detect
640
+
641
+ if options[:json]
642
+ puts JSON.pretty_generate(data)
643
+ return
644
+ end
645
+
646
+ puts BANNER unless options[:in_dashboard]
647
+ puts "šŸ” #{Color::BOLD}LIVE SERP FEATURE & ZERO-CLICK THREAT DETECTOR:#{Color::RESET} #{Color.c(query_str, Color::CYAN, Color::BOLD)}"
648
+ puts "─" * 80
649
+
650
+ zt = data[:zero_click_threat] || {}
651
+ threat_color = case zt[:level]
652
+ when 'LOW' then Color::GREEN
653
+ when 'MODERATE' then Color::YELLOW
654
+ else Color::RED
655
+ end
656
+
657
+ puts "šŸŽÆ Search Intent: #{Color.c(data.dig(:intent, :primary).to_s, Color::BOLD)} (#{data.dig(:intent, :description)})"
658
+ puts "🚨 Zero-Click Threat: #{Color.c("#{zt[:level]} (#{zt[:score]}/100)", threat_color, Color::BOLD)} [Organic CTR Impact: #{zt[:estimated_organic_ctr_suppression]}]"
659
+ puts "─" * 80
660
+
661
+ f = data[:features] || {}
662
+ puts "\n#{Color::BOLD}šŸ“Š DETECTED SERP REAL ESTATE FEATURES:#{Color::RESET}"
663
+
664
+ # AI Overview
665
+ aio = f[:ai_overview] || {}
666
+ aio_badge = aio[:detected] ? Color.c("ACTIVE (#{aio[:probability_pct]}% prob)", Color::RED, Color::BOLD) : Color.c("Unlikely (#{aio[:probability_pct]}%)", Color::GRAY)
667
+ puts " • Google AI Overview (Gemini) : [#{aio_badge}]"
668
+ puts " └─ #{Color.c(aio[:impact], Color::DIM)}" if aio[:detected]
669
+
670
+ # Featured Snippet
671
+ fs = f[:featured_snippet] || {}
672
+ fs_badge = fs[:detected] ? Color.c("OPPORTUNITY (#{fs[:target_format].capitalize} - #{fs[:probability_pct]}%)", Color::GREEN, Color::BOLD) : Color.c("None", Color::GRAY)
673
+ puts " • Featured Snippet Box : [#{fs_badge}]"
674
+ puts " └─ Target Recipe: #{Color.c(fs[:capture_prescription], Color::CYAN)}" if fs[:detected]
675
+
676
+ # People Also Ask
677
+ paa = f[:people_also_ask] || {}
678
+ paa_badge = paa[:detected] ? Color.c("#{paa[:count]} Live Questions Extracted", Color::CYAN, Color::BOLD) : Color.c("None", Color::GRAY)
679
+ puts " • People Also Ask (PAA) : [#{paa_badge}]"
680
+ if paa[:detected] && (paa[:questions] || []).any?
681
+ paa[:questions].first(5).each do |q_text|
682
+ puts " ā“ \"#{Color.c(q_text, Color::YELLOW)}\""
683
+ end
684
+ end
685
+
686
+ # Local Pack
687
+ lp = f[:local_3_pack] || {}
688
+ lp_badge = lp[:detected] ? Color.c("DETECTED (Google Maps 3-Pack)", Color::YELLOW, Color::BOLD) : Color.c("None", Color::GRAY)
689
+ puts " • Local Maps 3-Pack : [#{lp_badge}]"
690
+
691
+ # Video Carousel
692
+ vc = f[:video_carousel] || {}
693
+ vc_badge = vc[:detected] ? Color.c("DETECTED (Video Carousel)", Color::YELLOW, Color::BOLD) : Color.c("None", Color::GRAY)
694
+ puts " • Video Carousel / YouTube : [#{vc_badge}]"
695
+
696
+ # Discussions & Forums
697
+ df = f[:discussions_and_forums] || {}
698
+ df_badge = df[:detected] ? Color.c("DETECTED (Reddit/Quora Modules)", Color::CYAN, Color::BOLD) : Color.c("None", Color::GRAY)
699
+ puts " • Discussions & Forums : [#{df_badge}]"
700
+
701
+ # Shopping Pack
702
+ sp = f[:shopping_pack] || {}
703
+ sp_badge = sp[:detected] ? Color.c("DETECTED (Shopping Product Grid)", Color::YELLOW, Color::BOLD) : Color.c("None", Color::GRAY)
704
+ puts " • Google Shopping Grid : [#{sp_badge}]"
705
+
706
+ # Playbook
707
+ playbook = data[:playbook] || []
708
+ if playbook.any?
709
+ puts "\n#{Color::BOLD}šŸ† RICH SNIPPET & CITATION CAPTURE PLAYBOOK:#{Color::RESET}"
710
+ playbook.each_with_index do |p, idx|
711
+ puts " #{idx + 1}. [#{Color.c(p[:priority], Color::BOLD)}] #{Color.c(p[:target], Color::CYAN)}:"
712
+ puts " šŸ‘‰ #{p[:action]}"
713
+ end
714
+ end
715
+
716
+ # Live SERP Sampling
717
+ serp = data[:serp_sampling] || []
718
+ if serp.any?
719
+ puts "\n#{Color::BOLD}🌐 LIVE SERP SAMPLING (Top Competitor Results):#{Color::RESET}"
720
+ serp.each do |r|
721
+ puts " #{r[:position]}. #{Color.c(r[:title], Color::BOLD)} [#{Color.c(r[:domain], Color::GRAY)}]"
722
+ puts " └─ #{Color.c(r[:url], Color::BLUE)}"
723
+ end
724
+ end
725
+
726
+ puts "\n" + ("─" * 80) + "\n"
727
+ end
728
+
729
+ # 10b. IndexNow Multi-Engine Instant Indexing (Bing, Yandex, Seznam, Naver)
730
+ def self.handle_indexnow_command(target, extra, options)
731
+ if target == 'key' || target == 'connect'
732
+ if extra && !extra.strip.empty?
733
+ key = IndexNow.set_key(extra)
734
+ puts Color.c("āœ… Successfully set IndexNow API key to: #{key}", Color::GREEN, Color::BOLD)
735
+ else
736
+ key = IndexNow.get_or_create_key
737
+ host = Config.default_domain || 'yourdomain.com'
738
+ puts "šŸ”‘ #{Color::BOLD}INDEXNOW API KEY & HOST SETUP:#{Color::RESET}"
739
+ puts " • Active Key: #{Color.c(key, Color::CYAN, Color::BOLD)}"
740
+ puts " • Host Verification: #{Color.c("https://#{host}/#{key}.txt", Color::BLUE)}"
741
+ puts " • Required Content: #{Color.c(key, Color::BOLD)}"
742
+ puts "\nšŸ’” #{Color.c('Verification Tip:', Color::YELLOW)} Create a plain text file at the root of your web server named '#{key}.txt' with '#{key}' as the content."
743
+ end
744
+ return
745
+ end
746
+
747
+ if target == 'sitemap' || target.to_s.end_with?('.xml')
748
+ sitemap_target = (target == 'sitemap') ? extra : target
749
+ handle_indexnow_sitemap_command(sitemap_target, options)
750
+ return
751
+ end
752
+
753
+ target_url = target || options[:url]
754
+ unless target_url
755
+ if Config.default_domain
756
+ target_url = "https://#{Config.default_domain}"
757
+ else
758
+ raise 'Please provide a URL to index (e.g. gsc indexnow https://example.com/new-page) or connect a default domain.'
759
+ end
760
+ end
761
+
762
+ urls = [target_url]
763
+ urls << extra if extra && extra.start_with?('http')
764
+
765
+ key = options[:key] || IndexNow.get_or_create_key
766
+ res = IndexNow.submit(urls, key: key)
767
+
768
+ if options[:json]
769
+ puts JSON.pretty_generate(res)
770
+ return
771
+ end
772
+
773
+ puts BANNER unless options[:in_dashboard]
774
+ puts "⚔ #{Color::BOLD}INDEXNOW MULTI-SEARCH ENGINE SUBMISSION:#{Color::RESET}"
775
+ puts "─" * 75
776
+ status_color = res[:success] ? Color::GREEN : Color::RED
777
+ puts "Status : #{Color.c(res[:message], status_color, Color::BOLD)}"
778
+ puts "Submitted URLs : #{Color.c(res[:submitted_urls].to_s, Color::CYAN, Color::BOLD)}"
779
+ puts "Host : #{res[:host]}"
780
+ puts "Key Location : #{res[:key_location]}"
781
+ puts "Engines Notified: #{Color.c('Microsoft Bing, Yandex, Seznam, Naver', Color::BOLD)}"
782
+ puts "─" * 75
783
+
784
+ urls.each_with_index do |u, idx|
785
+ puts " #{idx + 1}. #{Color.c(u, Color::CYAN)}"
786
+ end
787
+
788
+ unless res[:success]
789
+ puts "\n#{Color.c('āš ļø Note:', Color::YELLOW)} Ensure you have created #{res[:key_location]} with content: #{res[:key]}"
790
+ end
791
+ puts ""
792
+ end
793
+
794
+ def self.handle_indexnow_sitemap_command(target, options)
795
+ sitemap = target || options[:sitemap] || 'sitemap.xml'
796
+ key = options[:key] || IndexNow.get_or_create_key
797
+ limit = options[:limit] ? options[:limit].to_i : nil
798
+
799
+ puts BANNER unless options[:in_dashboard]
800
+ puts "šŸ“„ #{Color::BOLD}BATCH INDEXNOW SITEMAP SUBMISSION:#{Color::RESET} #{Color.c(sitemap, Color::CYAN)}"
801
+ puts "─" * 75
802
+
803
+ res = IndexNow.submit_sitemap(sitemap, key: key, limit: limit)
804
+
805
+ if options[:json]
806
+ puts JSON.pretty_generate(res)
807
+ return
808
+ end
809
+
810
+ status_color = res[:success] ? Color::GREEN : Color::RED
811
+ puts "Status : #{Color.c(res[:message], status_color, Color::BOLD)}"
812
+ puts "URLs Submitted : #{Color.c(res[:submitted_urls].to_s, Color::GREEN, Color::BOLD)}"
813
+ puts "Host : #{res[:host]}"
814
+ puts "Engines Notified: #{Color.c('Microsoft Bing, Yandex, Seznam, Naver', Color::BOLD)}"
815
+ puts "─" * 75
816
+ puts "✨ All URLs pushed to IndexNow instant crawl queue."
817
+ puts ""
818
+ end
819
+
820
+ # 11. Network & Redirect Tracer
821
+ def self.handle_trace_command(target, options)
822
+ url = target || (Config.default_domain ? "https://#{Config.default_domain}" : nil)
823
+ if url.nil? || url.empty?
824
+ puts Color.c("āŒ Error: Target URL or domain required. Example: gsc trace https://mybrand.com", Color::RED)
825
+ return
826
+ end
827
+ nt = GSC::NetworkTracer.new(url)
828
+ data = nt.trace
829
+
830
+ if options[:json]
831
+ puts JSON.pretty_generate(data)
832
+ return
833
+ end
834
+
835
+ puts BANNER unless options[:in_dashboard]
836
+ puts "šŸ›¤ļø #{Color::BOLD}REDIRECT CHAIN & HTTP HEADER TRACE:#{Color::RESET} #{Color.c(url, Color::CYAN)}"
837
+ puts "─" * 75
838
+ puts "Total Hops: #{data[:total_hops]} | Duration: #{data[:total_duration_ms]}ms"
839
+
840
+ (data[:hops] || []).each do |hop|
841
+ status_c = (hop[:status_code] == 200) ? Color::GREEN : Color::YELLOW
842
+ puts "\nHop ##{hop[:hop]}: #{Color.c(hop[:status_code].to_s, status_c, Color::BOLD)} (#{hop[:duration_ms]}ms)"
843
+ puts " URL: #{hop[:url]}"
844
+ puts " X-Robots-Tag: #{Color.c(hop[:x_robots_tag], Color::RED)}" if hop[:x_robots_tag]
845
+ puts " Canonical: #{hop[:canonical_header]}" if hop[:canonical_header]
846
+ puts " HSTS: #{hop[:hsts] ? 'Enabled' : 'Disabled'}"
847
+ end
848
+ puts ""
849
+ end
850
+
851
+ # 12. Robots.txt Checker
852
+ def self.handle_robots_command(target, extra, options)
853
+ raw = target || (Config.default_domain ? "https://#{Config.default_domain}" : nil)
854
+ if raw.nil? || raw.empty?
855
+ puts Color.c("āŒ Error: Target URL or domain required. Example: gsc robots https://mybrand.com", Color::RED)
856
+ return
857
+ end
858
+ url = raw.to_s.start_with?('http') ? raw : "https://#{raw}"
859
+ path = extra || '/'
860
+ bot = options[:bot] || 'googlebot'
861
+
862
+ rc = GSC::RobotsChecker.new(url)
863
+ data = rc.check(path, bot)
864
+
865
+ if options[:json]
866
+ puts JSON.pretty_generate(data)
867
+ return
868
+ end
869
+
870
+ puts BANNER unless options[:in_dashboard]
871
+ puts "šŸ¤– #{Color::BOLD}ROBOTS.TXT CRAWLER SIMULATOR:#{Color::RESET}"
872
+ puts " Robots URL : #{data[:robots_url]}"
873
+ puts " User Agent : #{Color.c(bot, Color::CYAN)}"
874
+ puts " Test Path : #{Color.c(path, Color::BOLD)}"
875
+ puts "─" * 70
876
+
877
+ status_badge = data[:allowed] ? Color.c("āœ… ALLOWED", Color::GREEN, Color::BOLD) : Color.c("āŒ BLOCKED (DISALLOW)", Color::RED, Color::BOLD)
878
+ puts "Crawl Verdict : #{status_badge}"
879
+ if data[:matched_rule]
880
+ puts "Matched Rule : #{data[:matched_rule][:type].to_s.upcase}: #{data[:matched_rule][:path]}"
881
+ end
882
+ puts ""
883
+ end
884
+
885
+ # 13. Backlinks & GSC Links Ingestion
886
+ def self.handle_backlinks_command(target, extra, options)
887
+ domain = (target unless target == 'import') || Config.default_domain
888
+ if domain.nil? || domain.empty?
889
+ puts Color.c("āŒ Error: Target domain required. Example: gsc backlinks mybrand.com", Color::RED)
890
+ return
891
+ end
892
+ bm = GSC::BacklinksManager.new(domain)
893
+
894
+ if target == 'import' || extra == 'import'
895
+ source = (target == 'import') ? extra : target
896
+ content = if source == 'clip' || source == 'clipboard'
897
+ `pbpaste 2>/dev/null`
898
+ elsif source && File.exist?(source)
899
+ File.read(source)
900
+ else
901
+ nil
902
+ end
903
+
904
+ if content.nil? || content.strip.empty?
905
+ puts Color.c("āŒ Error: No content provided. Usage: gsc backlinks import [file.csv|clip]", Color::RED)
906
+ return
907
+ end
908
+
909
+ res = bm.import_csv(content)
910
+ if options[:json]
911
+ puts JSON.pretty_generate(res)
912
+ else
913
+ puts Color.c("āœ… Successfully imported GSC backlink export!", Color::GREEN, Color::BOLD)
914
+ puts " Referring Domains : #{res[:sources_count]}"
915
+ puts " Target Pages : #{res[:targets_count]}"
916
+ end
917
+ return
918
+ end
919
+
920
+ data = bm.summary
921
+
922
+ if options[:json]
923
+ puts JSON.pretty_generate(data)
924
+ return
925
+ end
926
+
927
+ puts BANNER unless options[:in_dashboard]
928
+ puts "šŸ”— #{Color::BOLD}GSC BACKLINK & REFERRING DOMAIN INTELLIGENCE:#{Color::RESET} #{Color.c(domain, Color::CYAN)}"
929
+ puts "─" * 75
930
+ puts "Total Referring Domains : #{Color.c(data[:total_referring_domains].to_s, Color::BOLD)}"
931
+ puts "Total External Links : #{Color.c(data[:total_external_links].to_s, Color::BOLD)}"
932
+ puts "Last Updated : #{data[:updated_at] || 'Never (Run `gsc backlinks import` to ingest GSC export)'}"
933
+ puts "─" * 75
934
+
935
+ sources = data[:top_referring_domains] || []
936
+ unless sources.empty?
937
+ puts "\n#{Color::BOLD}🌐 TOP REFERRING SITES:#{Color::RESET}"
938
+ sources.each do |s|
939
+ puts " • #{s['domain'].to_s.ljust(45)} #{Color.c(s['links_count'].to_s.rjust(6) + ' links', Color::CYAN)}"
940
+ end
941
+ end
942
+
943
+ targets = data[:top_target_pages] || []
944
+ unless targets.empty?
945
+ puts "\n#{Color::BOLD}šŸŽÆ TOP LINKED LANDING PAGES:#{Color::RESET}"
946
+ targets.each do |t|
947
+ puts " • #{t['target_url'].to_s.ljust(45)} #{Color.c(t['incoming_count'].to_s.rjust(6) + ' links', Color::GREEN)}"
948
+ end
949
+ end
950
+ puts ""
951
+ end
952
+
953
+ # 14. Generative Engine Optimization (GEO / AEO) Citability Auditor
954
+ def self.handle_geo_command(target, options)
955
+ raw = target || (Config.default_domain ? "https://#{Config.default_domain}" : nil)
956
+ if raw.nil? || raw.empty?
957
+ puts Color.c("āŒ Error: Target URL or domain required. Example: gsc geo https://mybrand.com", Color::RED)
958
+ return
959
+ end
960
+ url = raw.to_s.start_with?('http') ? raw : "https://#{raw}"
961
+ auditor = GSC::GeoAuditor.new(url)
962
+
963
+ puts "šŸ¤– Analyzing Generative Engine Optimization (GEO) & Citability for #{Color.c(url, Color::CYAN)}...\n" unless options[:json]
964
+
965
+ data = auditor.audit
966
+ if data[:error]
967
+ if options[:json]
968
+ puts JSON.pretty_generate(data)
969
+ else
970
+ puts Color.c("āŒ Error: #{data[:message]}", Color::RED, Color::BOLD)
971
+ end
972
+ return
973
+ end
974
+
975
+ if options[:json]
976
+ puts JSON.pretty_generate(data)
977
+ return
978
+ end
979
+
980
+ puts BANNER unless options[:in_dashboard]
981
+ puts "šŸ¤– #{Color::BOLD}GENERATIVE ENGINE OPTIMIZATION (GEO / AEO) AUDIT#{Color::RESET}"
982
+ puts "šŸŽÆ Target URL : #{Color.c(data[:url], Color::CYAN)}"
983
+ puts "─" * 75
984
+
985
+ score_color = case data[:score]
986
+ when 85..100 then Color::GREEN
987
+ when 70..84 then Color::CYAN
988
+ when 50..69 then Color::YELLOW
989
+ else Color::RED
990
+ end
991
+
992
+ puts "šŸ† #{Color::BOLD}AI CITABILITY SCORE:#{Color::RESET} #{Color.c("#{data[:score]}/100", score_color, Color::BOLD)} — #{data[:grade]}"
993
+ puts " • AI Bot Access (Robots.txt) : #{data[:category_scores][:ai_bot_access][:score]}/25"
994
+ puts " • Direct Question Answers : #{data[:category_scores][:direct_answers][:score]}/25"
995
+ puts " • Factual & Data Citability : #{data[:category_scores][:facts_and_data][:score]}/25"
996
+ puts " • Entity Schema & Authority : #{data[:category_scores][:entity_schema][:score]}/25"
997
+ puts "─" * 75
998
+
999
+ puts "\nšŸ¤– #{Color::BOLD}AI SEARCH CRAWLER ACCESS (Robots.txt):#{Color::RESET}"
1000
+ data[:ai_crawlers][:bots].each do |bot, info|
1001
+ badge = info[:allowed] ? Color.c("āœ… ALLOWED", Color::GREEN) : Color.c("āŒ BLOCKED", Color::RED, Color::BOLD)
1002
+ puts " • #{bot.ljust(20)} [#{info[:provider]}]: #{badge} (#{info[:rule]})"
1003
+ end
1004
+
1005
+ puts "\nšŸ“ #{Color::BOLD}DIRECT ANSWER & CITABILITY DENSITY:#{Color::RESET}"
1006
+ puts " • Question Headings Detected : #{data[:direct_answers][:total_question_headings]}"
1007
+ puts " • Direct Answer Paragraphs : #{data[:direct_answers][:direct_answer_blocks_found]}"
1008
+ puts " • Optimal Length Answers (30-80w): #{data[:direct_answers][:optimal_answers_count]}"
1009
+
1010
+ samples = data[:direct_answers][:samples] || []
1011
+ if samples.any?
1012
+ puts " #{Color::BOLD}Sample Answer Pairs:#{Color::RESET}"
1013
+ samples.each do |s|
1014
+ puts " Q: #{Color.c(s[:question], Color::CYAN)}"
1015
+ puts " A: #{s[:answer_preview]} (#{s[:word_count]} words)"
1016
+ end
1017
+ end
1018
+
1019
+ puts "\nšŸ“Š #{Color::BOLD}FACTUAL & INFORMATION GAIN SIGNALS:#{Color::RESET}"
1020
+ puts " • Statistics & Percentages : #{data[:facts_and_citability][:percentage_mentions]}"
1021
+ puts " • Currency / Pricing Signals : #{data[:facts_and_citability][:currency_mentions]}"
1022
+ puts " • Structured Bullet Items (<li>): #{data[:facts_and_citability][:bullet_list_items]}"
1023
+ puts " • Comparison Table Rows (<tr>) : #{data[:facts_and_citability][:comparison_table_rows]}"
1024
+ puts " • Recent Year Signals (2024-26): #{data[:facts_and_citability][:recent_year_mentions]}"
1025
+
1026
+ puts "\nšŸ›ļø #{Color::BOLD}ENTITY KNOWLEDGE GRAPH & SCHEMA:#{Color::RESET}"
1027
+ puts " • Schemas Found : #{data[:entity_knowledge_graph][:schema_count]} (#{data[:entity_knowledge_graph][:schema_types].join(', ')})"
1028
+ puts " • Organization / Brand Schema : #{data[:entity_knowledge_graph][:has_organization_or_brand] ? Color.c("Yes", Color::GREEN) : Color.c("Missing", Color::YELLOW)}"
1029
+ puts " • FAQ / HowTo Rich Schema : #{data[:entity_knowledge_graph][:has_faq_or_howto] ? Color.c("Yes", Color::GREEN) : Color.c("None", Color::YELLOW)}"
1030
+ puts " • Wikidata / Wikipedia Linked : #{data[:entity_knowledge_graph][:wikidata_or_wikipedia_linked] ? Color.c("Yes", Color::GREEN) : Color.c("No", Color::YELLOW)}"
1031
+ if data[:entity_knowledge_graph][:same_as_domains].any?
1032
+ puts " • Social Authority Entity Links: #{data[:entity_knowledge_graph][:same_as_domains].join(', ')}"
1033
+ end
1034
+
1035
+ recs = data[:recommendations] || []
1036
+ if recs.any?
1037
+ puts "\nšŸ’” #{Color::BOLD}AI CITABILITY OPTIMIZATION RECOMMENDATIONS:#{Color::RESET}"
1038
+ recs.each_with_index do |r, i|
1039
+ puts " #{i + 1}. #{Color.c(r, Color::YELLOW)}"
1040
+ end
1041
+ end
1042
+ puts ""
1043
+ end
1044
+
1045
+ # 15. Knowledge Graph Entity & sameAs Disambiguation Auditor
1046
+ def self.handle_entity_command(target, options)
1047
+ raw = target || (Config.default_domain ? "https://#{Config.default_domain}" : nil)
1048
+ if raw.nil? || raw.empty?
1049
+ puts Color.c("āŒ Error: Target URL or domain required. Example: gsc entity https://mybrand.com", Color::RED)
1050
+ return
1051
+ end
1052
+ url = raw.to_s.start_with?('http') ? raw : "https://#{raw}"
1053
+
1054
+ puts "šŸ›ļø Auditing Knowledge Graph Entity & sameAs Identity for #{Color.c(url, Color::CYAN)}...\n" unless options[:json]
1055
+
1056
+ data = GSC::EntityAuditor.audit(url)
1057
+ if data[:error]
1058
+ if options[:json]
1059
+ puts JSON.pretty_generate(data)
1060
+ else
1061
+ puts Color.c("āŒ Error: #{data[:error]}", Color::RED, Color::BOLD)
1062
+ end
1063
+ return
1064
+ end
1065
+
1066
+ if options[:json]
1067
+ puts JSON.pretty_generate(data)
1068
+ return
1069
+ end
1070
+
1071
+ puts BANNER unless options[:in_dashboard]
1072
+ puts "šŸ›ļø #{Color::BOLD}KNOWLEDGE GRAPH ENTITY & sameAs DISAMBIGUATION AUDIT#{Color::RESET}"
1073
+ puts "šŸŽÆ Target URL : #{Color.c(data[:url], Color::CYAN)}"
1074
+ puts "─" * 75
1075
+
1076
+ score_color = case data[:score]
1077
+ when 85..100 then Color::GREEN
1078
+ when 70..84 then Color::CYAN
1079
+ when 50..69 then Color::YELLOW
1080
+ else Color::RED
1081
+ end
1082
+
1083
+ puts "šŸ† #{Color::BOLD}ENTITY AUTHORITY SCORE:#{Color::RESET} #{Color.c("#{data[:score]}/100", score_color, Color::BOLD)} — Grade #{data[:grade]}"
1084
+ puts "šŸ“¦ Detected Entities : #{data[:entities_found]}"
1085
+ puts "─" * 75
1086
+
1087
+ if data[:findings].any?
1088
+ puts "\nšŸ”Ž #{Color::BOLD}ENTITY SIGNALS DETECTED:#{Color::RESET}"
1089
+ data[:findings].each do |f|
1090
+ puts " • #{Color.c(f, Color::GREEN)}"
1091
+ end
1092
+ end
1093
+
1094
+ if data[:authoritative_links].any?
1095
+ puts "\n🌐 #{Color::BOLD}HIGH-AUTHORITY SAMEAS IDENTITY LINKS:#{Color::RESET}"
1096
+ data[:authoritative_links].each do |source, link|
1097
+ puts " • #{source.to_s.capitalize.ljust(15)}: #{Color.c(link, Color::CYAN)}"
1098
+ end
1099
+ end
1100
+
1101
+ if data[:recommendations].any?
1102
+ puts "\nšŸ’” #{Color::BOLD}OPTIMIZATION DIRECTIVES:#{Color::RESET}"
1103
+ data[:recommendations].each_with_index do |r, i|
1104
+ puts " #{i + 1}. #{Color.c(r, Color::YELLOW)}"
1105
+ end
1106
+ end
1107
+
1108
+ if options[:generate] || data[:score] < 70
1109
+ puts "\nšŸ“‹ #{Color::BOLD}RECOMMENDED ENHANCED JSON-LD SCHEMA SNIPPET:#{Color::RESET}"
1110
+ puts "<!-- Paste inside <head> on your homepage/about page -->"
1111
+ puts "<script type=\"application/ld+json\">"
1112
+ puts JSON.pretty_generate(data[:recommended_json_ld])
1113
+ puts "</script>"
1114
+ end
1115
+ puts ""
1116
+ end
1117
+
1118
+ # 16. Multi-Domain Agency Vault & Key Manager
1119
+ def self.handle_vault_command(subcmd, target, extra, options)
1120
+ action = subcmd.to_s.downcase
1121
+ action = 'list' if action.empty? || action == 'vault'
1122
+
1123
+ case action
1124
+ when 'status'
1125
+ stat = GSC::Vault.status
1126
+ if options[:json]
1127
+ puts JSON.pretty_generate(stat)
1128
+ else
1129
+ puts BANNER unless options[:in_dashboard]
1130
+ puts "šŸ” #{Color::BOLD}AGENCY CREDENTIAL VAULT SECURITY STATUS#{Color::RESET}"
1131
+ puts "─" * 70
1132
+ puts " • Vault Directory: #{stat[:vault_dir]}"
1133
+ puts " • Encryption Algorithm: #{Color.c(stat[:encryption_algorithm], Color::GREEN, Color::BOLD)}"
1134
+ puts " • Master Key Present: #{stat[:master_key_present] ? Color.c('Yes (Stored locally)', Color::GREEN) : Color.c('Generated on demand', Color::YELLOW)}"
1135
+ puts " • Stored Client Keys: #{Color.c(stat[:keys_stored].to_s, Color::CYAN, Color::BOLD)}"
1136
+ puts " • Domains Mapped: #{Color.c(stat[:domains_mapped].to_s, Color::CYAN)}"
1137
+ puts " • POSIX 0700 Security: #{stat[:permissions_secure] ? Color.c('ENFORCED', Color::GREEN) : Color.c('PERMISSIVE', Color::YELLOW)}"
1138
+ puts " • Active Target Domain: #{Color.c(stat[:active_domain] || 'None', Color::GREEN, Color::BOLD)}"
1139
+ puts "─" * 70
1140
+ puts "šŸ’” Switch active client anytime: #{Color.c('gsc switch <domain|alias|#>', Color::CYAN)}"
1141
+ puts ""
1142
+ end
1143
+
1144
+ when 'add', 'import'
1145
+ key_path = target
1146
+ unless key_path && File.file?(File.expand_path(key_path))
1147
+ puts Color.c("āŒ Error: Valid service account JSON path required. Example: gsc vault add ./client-key.json --domain client.com", Color::RED)
1148
+ return
1149
+ end
1150
+
1151
+ domain = options[:domain] || extra
1152
+ alias_name = options[:alias]
1153
+ ga4_id = options[:property]
1154
+
1155
+ begin
1156
+ entry = GSC::Vault.add_key(key_path, domain: domain, alias_name: alias_name, ga4_id: ga4_id)
1157
+ if options[:json]
1158
+ puts JSON.pretty_generate({ status: 'ok', entry: entry })
1159
+ else
1160
+ puts Color.c("āœ… Successfully encrypted and added key to Agency Vault!", Color::GREEN, Color::BOLD)
1161
+ puts " • Client Email: #{Color.c(entry['client_email'], Color::CYAN)}"
1162
+ puts " • Domain: #{Color.c(entry['domain'] || 'Not set', Color::GREEN)}"
1163
+ puts " • Alias: #{Color.c(entry['alias'] || 'Not set', Color::YELLOW)}"
1164
+ puts " • Key Vault: #{entry['key_file']} (AES-256-GCM Encrypted)"
1165
+ end
1166
+ rescue StandardError => e
1167
+ puts Color.c("āŒ Vault Error: #{e.message}", Color::RED)
1168
+ end
1169
+
1170
+ when 'remove', 'rm', 'delete'
1171
+ target_query = target
1172
+ unless target_query
1173
+ puts Color.c("āŒ Error: Specify domain or alias to remove. Example: gsc vault remove client.com", Color::RED)
1174
+ return
1175
+ end
1176
+
1177
+ ok = GSC::Vault.remove_entry(target_query)
1178
+ if ok
1179
+ puts Color.c("āœ… Removed #{target_query} from Agency Vault.", Color::GREEN)
1180
+ else
1181
+ puts Color.c("āš ļø Entry not found in Agency Vault: #{target_query}", Color::YELLOW)
1182
+ end
1183
+
1184
+ when 'list', 'ls'
1185
+ entries = GSC::Vault.list_entries
1186
+ if options[:json]
1187
+ puts JSON.pretty_generate({ vault: GSC::Vault.status, entries: entries })
1188
+ return
1189
+ end
1190
+
1191
+ puts BANNER unless options[:in_dashboard]
1192
+ puts "šŸ” #{Color::BOLD}AGENCY CREDENTIAL VAULT (AES-256-GCM Secure Store)#{Color::RESET}"
1193
+ puts "─" * 75
1194
+
1195
+ if entries.empty?
1196
+ puts Color.c("No client keys registered in Agency Vault yet.", Color::YELLOW)
1197
+ puts "To add a key: #{Color.c('gsc vault add /path/to/key.json --domain client.com --alias client', Color::CYAN)}"
1198
+ puts "─" * 75
1199
+ return
1200
+ end
1201
+
1202
+ puts "#{Color::BOLD} # | Domain | Alias | Client Account / Email#{Color::RESET}"
1203
+ puts "─" * 75
1204
+ entries.each_with_index do |e, i|
1205
+ num_str = "[#{i + 1}]".ljust(4)
1206
+ dom_str = (e['domain'] || 'unassigned').ljust(28)
1207
+ alias_str = (e['alias'] || '-').ljust(10)
1208
+ email_str = e['client_email'].to_s
1209
+ marker = e['active'] ? " #{Color.c('šŸ‘ˆ [ACTIVE]', Color::GREEN, Color::BOLD)}" : ""
1210
+
1211
+ puts "#{Color.c(num_str, Color::CYAN)}| #{dom_str} | #{Color.c(alias_str, Color::YELLOW)} | #{email_str}#{marker}"
1212
+ end
1213
+ puts "─" * 75
1214
+ puts "šŸ’” Switch active client anytime: #{Color.c('gsc switch <domain|alias|#>', Color::CYAN)}"
1215
+ puts ""
1216
+ else
1217
+ puts Color.c("Unknown vault action: #{action}. Valid actions: list, add, remove, status", Color::YELLOW)
1218
+ end
1219
+ end
1220
+
1221
+ # 18. Direct Answer & Information Gain Snippet Synthesizer
1222
+ def self.handle_answer_command(query, options = {})
1223
+ if query.nil? || query.to_s.strip.empty?
1224
+ puts Color.c("āŒ Error: Query required. Usage: gsc answer <query> [--brand <name>] [--type <definition|steps|comparison|faq>] [--generate] [--json]", Color::RED, Color::BOLD)
1225
+ return
1226
+ end
1227
+
1228
+ data = GSC::AnswerSynthesizer.synthesize(query, options)
1229
+
1230
+ if options[:json]
1231
+ puts JSON.pretty_generate(data)
1232
+ return
1233
+ end
1234
+
1235
+ puts BANNER unless options[:in_dashboard]
1236
+ puts "šŸ—£ļø #{Color::BOLD}DIRECT ANSWER & INFORMATION GAIN SNIPPET (GEO / AEO)#{Color::RESET}"
1237
+ puts "šŸŽÆ Target Query : #{Color.c("\"#{data[:query]}\"", Color::YELLOW, Color::BOLD)}"
1238
+ puts "šŸ·ļø Intent Type : [#{Color.c(data[:intent_type].upcase, Color::MAGENTA, Color::BOLD)}]"
1239
+ puts "šŸ“Š Word Count : #{data[:word_count]} words (#{Color.c("Optimal: #{data[:target_optimal_range]}", Color::GREEN)})"
1240
+ puts "šŸ† Citability : #{Color.c("#{data[:citability_score]}/100", Color::GREEN, Color::BOLD)} (Optimized for Featured Snippets, ChatGPT & Perplexity)"
1241
+ puts "─" * 75
1242
+
1243
+ puts "\n#{Color::BOLD}šŸ’¬ OPTIMIZED DIRECT ANSWER PARAGRAPH:#{Color::RESET}"
1244
+ puts " #{Color.c(data[:direct_answer], Color::BOLD)}"
1245
+
1246
+ puts "\n#{Color::BOLD}šŸ“Œ STRUCTURED ATTRIBUTES & KEY POINTS:#{Color::RESET}"
1247
+ data[:key_points].each do |kp|
1248
+ clean_kp = kp.gsub(/\*\*(.*?)\*\*/, "#{Color::BOLD}\\1#{Color::RESET}")
1249
+ puts " • #{clean_kp}"
1250
+ end
1251
+
1252
+ puts "\n#{Color::BOLD}šŸ“ˆ INFORMATION GAIN FACTOID:#{Color::RESET}"
1253
+ puts " šŸ’” #{Color.c(data[:information_gain_stat], Color::CYAN)}"
1254
+
1255
+ if options[:generate]
1256
+ puts "\n#{Color::BOLD}šŸ’» READY-TO-PASTE HTML SNIPPET:#{Color::RESET}"
1257
+ puts Color.c(data[:html_markup], Color::DIM)
1258
+
1259
+ puts "\n#{Color::BOLD}šŸ’Ž SCHEMA.ORG JSON-LD SCRIPT BLOCK:#{Color::RESET}"
1260
+ puts Color.c("<script type=\"application/ld+json\">\n#{JSON.pretty_generate(data[:schema_jsonld])}\n</script>", Color::CYAN)
1261
+ else
1262
+ puts "\nšŸ’” #{Color.c('Tip: Pass --generate to print copy-paste HTML and Schema.org JSON-LD blocks.', Color::GRAY)}"
1263
+ end
1264
+ puts "─" * 75 + "\n"
1265
+ end
1266
+
1267
+ # 19. AI Bot Robots.txt & Cloudflare Firewall Scanner
1268
+ def self.handle_firewall_command(target, options = {})
1269
+ scanner = GSC::FirewallScanner.new(target, options)
1270
+ data = scanner.scan
1271
+
1272
+ if options[:json]
1273
+ puts JSON.pretty_generate(data)
1274
+ return
1275
+ end
1276
+
1277
+ puts BANNER unless options[:in_dashboard]
1278
+ puts "šŸ›”ļø #{Color::BOLD}AI BOT ACCESSIBILITY & CLOUDFLARE/WAF FIREWALL AUDITOR#{Color::RESET}"
1279
+ puts "šŸŽÆ Target URL : #{Color.c(data[:url], Color::YELLOW, Color::BOLD)}"
1280
+ puts "🌐 Hostname : #{data[:host]}"
1281
+
1282
+ # Edge Infrastructure
1283
+ edge_names = data[:edge_infrastructure].map { |i| "#{i[:name]} (#{i[:role]})" }.join(' | ')
1284
+ puts "ā˜ļø Edge CDN/WAF : #{Color.c(edge_names, Color::CYAN)}"
1285
+
1286
+ # Score & Grade
1287
+ grade_color = case data[:grade]
1288
+ when 'A' then Color::GREEN
1289
+ when 'B' then Color::CYAN
1290
+ when 'C' then Color::YELLOW
1291
+ when 'D' then Color::RED
1292
+ else Color::RED
1293
+ end
1294
+
1295
+ score_str = "#{data[:score]}/100 [Grade #{data[:grade]}]"
1296
+ puts "šŸ† AI Access : #{Color.c(score_str, Color::BOLD, grade_color)} - #{data[:verdict]}"
1297
+ puts "─" * 80
1298
+
1299
+ # Section 1: Live HTTP Probes
1300
+ puts "\n#{Color::BOLD}šŸ“” LIVE EDGE WAF MULTI-USER-AGENT PROBES (LIVE PASS-THROUGH):#{Color::RESET}"
1301
+ puts "#{Color::BOLD}%-18s %-24s %-12s %-10s %-14s %s#{Color::RESET}" % ['Persona', 'Client Name', 'HTTP Status', 'Latency', 'WAF Verdict', 'Challenge / Block']
1302
+ puts "─" * 80
1303
+
1304
+ data[:live_probes].each do |probe|
1305
+ status_col = case probe[:status]
1306
+ when 200..299 then Color.c("#{probe[:status]} OK", Color::GREEN)
1307
+ when 300..399 then Color.c("#{probe[:status]} REDIR", Color::YELLOW)
1308
+ when 403 then Color.c("403 FORBID", Color::RED, Color::BOLD)
1309
+ when 503 then Color.c("503 UNAVAIL", Color::RED, Color::BOLD)
1310
+ else Color.c(probe[:status].to_s, Color::RED)
1311
+ end
1312
+
1313
+ verdict_col = case probe[:verdict]
1314
+ when :pass then Color.c("PASS", Color::GREEN)
1315
+ when :redirect then Color.c("REDIRECT", Color::YELLOW)
1316
+ when :challenge then Color.c("CHALLENGE", Color::RED, Color::BOLD)
1317
+ when :blocked then Color.c("BLOCKED", Color::RED, Color::BOLD)
1318
+ else Color.c("ERROR", Color::RED)
1319
+ end
1320
+
1321
+ challenges_str = probe[:challenges].empty? ? '-' : probe[:challenges].join(', ')
1322
+ challenges_col = probe[:challenges].empty? ? Color.c(challenges_str, Color::GRAY) : Color.c(challenges_str, Color::RED, Color::BOLD)
1323
+
1324
+ puts "%-18s %-24s %-21s %-10s %-23s %s" % [
1325
+ probe[:persona],
1326
+ probe[:name],
1327
+ status_col,
1328
+ "#{probe[:duration_ms]}ms",
1329
+ verdict_col,
1330
+ challenges_col
1331
+ ]
1332
+ end
1333
+
1334
+ # Section 2: Robots.txt Crawler Governance
1335
+ puts "\n#{Color::BOLD}šŸ¤– ROBOTS.TXT AI SEARCH & TRAINING CRAWLER DIRECTIVES:#{Color::RESET}"
1336
+ puts "#{Color::BOLD}%-20s %-15s %-12s %-12s %s#{Color::RESET}" % ['Bot User-Agent', 'Provider', 'Role', 'Status', 'Matched Rule / Reason']
1337
+ puts "─" * 80
1338
+
1339
+ data[:robots_txt][:bots].each do |bot_name, bot_info|
1340
+ status_col = case bot_info[:status]
1341
+ when :allowed then Color.c("ALLOWED", Color::GREEN, Color::BOLD)
1342
+ when :restricted then Color.c("RESTRICTED", Color::YELLOW)
1343
+ when :blocked then Color.c("BLOCKED", Color::RED, Color::BOLD)
1344
+ else Color.c("UNKNOWN", Color::GRAY)
1345
+ end
1346
+
1347
+ category_label = bot_info[:category].to_s.sub('_', ' ').capitalize
1348
+
1349
+ puts "%-20s %-15s %-12s %-21s %s" % [
1350
+ bot_name,
1351
+ bot_info[:provider],
1352
+ category_label,
1353
+ status_col,
1354
+ Color.c(bot_info[:reason].to_s, Color::GRAY)
1355
+ ]
1356
+ end
1357
+
1358
+ # Section 3: Blockade Diagnosis
1359
+ puts "\n#{Color::BOLD}šŸ” SILENT BOT BLOCKADE DIAGNOSIS:#{Color::RESET}"
1360
+ diag = data[:blockade_diagnosis]
1361
+ if diag[:silent_blockade_detected]
1362
+ puts Color.c(" 🚨 #{diag[:summary]}", Color::RED, Color::BOLD)
1363
+ puts " āš ļø Browser pass: #{diag[:browser_accessible]} | Googlebot pass: #{diag[:googlebot_accessible]}"
1364
+ puts " āŒ Blocked AI crawlers: #{diag[:blocked_probes].map { |p| "#{p[:name]} (#{p[:status]})" }.join(', ')}"
1365
+ else
1366
+ puts Color.c(" #{diag[:summary]}", Color::GREEN)
1367
+ end
1368
+
1369
+ # Section 4: Prescriptive WAF & Robots.txt Remediation Recipes
1370
+ recipes = data[:remediation_recipes]
1371
+ if recipes[:cloudflare_waf_rule]
1372
+ puts "\n#{Color::BOLD}šŸ› ļø RECOMMENDED CLOUDFLARE CUSTOM WAF BYPASS RULE:#{Color::RESET}"
1373
+ puts " Rule Name: #{Color.c(recipes[:cloudflare_waf_rule][:name], Color::CYAN, Color::BOLD)}"
1374
+ puts " Expression:\n #{Color.c(recipes[:cloudflare_waf_rule][:filter_expression], Color::YELLOW)}"
1375
+ puts " Action: #{Color.c('Skip', Color::GREEN, Color::BOLD)} (Skip WAF Managed Rules, Super Bot Fight Mode, Rate Limiting)"
1376
+ puts "\n #{Color::BOLD}Dashboard Deployment Steps:#{Color::RESET}"
1377
+ recipes[:cloudflare_waf_rule][:dashboard_instructions].each do |step|
1378
+ puts " #{Color.c(step, Color::GRAY)}"
1379
+ end
1380
+ end
1381
+
1382
+ if options[:generate] || diag[:silent_blockade_detected] || diag[:robots_critical_blocked].any?
1383
+ puts "\n#{Color::BOLD}šŸ“„ RECOMMENDED ROBOTS.TXT CONFIGURATION FOR AI SEARCH:#{Color::RESET}"
1384
+ puts Color.c(recipes[:recommended_robots_txt], Color::DIM)
1385
+ else
1386
+ puts "\nšŸ’” #{Color.c('Tip: Pass --generate to display copy-paste robots.txt configuration.', Color::GRAY)}"
1387
+ end
1388
+
1389
+ puts "─" * 80 + "\n"
1390
+ end
1391
+
1392
+ # 20. Title Tag Length & Pixel Width Batch Optimizer
1393
+ def self.handle_titles_command(target, options = {})
1394
+ target_site = target.to_s.strip
1395
+ target_site = Config.default_domain || '' if target_site.empty?
1396
+ if target_site.empty?
1397
+ puts Color.c("āŒ Error: Target domain or sitemap required. Example: gsc titles mybrand.com", Color::RED)
1398
+ return
1399
+ end
1400
+
1401
+ optimizer = GSC::TitleOptimizer.new(target_site, options)
1402
+
1403
+ unless options[:json] || options[:csv]
1404
+ puts BANNER unless options[:in_dashboard]
1405
+ puts "šŸ“ #{Color::BOLD}GOOGLE SERP TITLE LENGTH & PIXEL OVERFLOW AUDITOR#{Color::RESET}"
1406
+ puts "šŸŽÆ Target: #{Color.c(target_site, Color::YELLOW, Color::BOLD)}"
1407
+ print "šŸ”„ Crawling and calculating pixel metrics across pages... "
1408
+ $stdout.flush
1409
+ end
1410
+
1411
+ data = optimizer.audit do |_url, current, total|
1412
+ unless options[:json] || options[:csv]
1413
+ print "\ršŸ”„ Crawling and calculating pixel metrics across pages... [#{current}/#{total}]"
1414
+ $stdout.flush
1415
+ end
1416
+ end
1417
+
1418
+ unless options[:json] || options[:csv]
1419
+ puts " Done!\n"
1420
+ end
1421
+
1422
+ if options[:json]
1423
+ puts JSON.pretty_generate(data)
1424
+ return
1425
+ end
1426
+
1427
+ if options[:csv]
1428
+ require 'csv'
1429
+ puts "URL,Characters,Pixel_Width,Status,Current_Title,Desktop_Preview,Suggested_Rewrite_1,Suggested_Rewrite_2"
1430
+ data[:pages].each do |p|
1431
+ r1 = p[:suggested_rewrites][0] ? p[:suggested_rewrites][0][:title].gsub('"', '""') : ''
1432
+ r2 = p[:suggested_rewrites][1] ? p[:suggested_rewrites][1][:title].gsub('"', '""') : ''
1433
+ title_clean = p[:title].to_s.gsub('"', '""')
1434
+ prev_clean = p[:desktop_preview].to_s.gsub('"', '""')
1435
+ puts "\"#{p[:url]}\",#{p[:char_count]},#{p[:pixel_width]},#{p[:status]},\"#{title_clean}\",\"#{prev_clean}\",\"#{r1}\",\"#{r2}\""
1436
+ end
1437
+ return
1438
+ end
1439
+
1440
+ # Health Grade & Color
1441
+ grade_color = case data[:grade]
1442
+ when 'A' then Color::GREEN
1443
+ when 'B' then Color::CYAN
1444
+ when 'C' then Color::YELLOW
1445
+ when 'D' then Color::RED
1446
+ else Color::RED
1447
+ end
1448
+
1449
+ puts "šŸ† Title Health : #{Color.c("#{data[:health_score]}/100 [Grade #{data[:grade]}]", Color::BOLD, grade_color)} - #{data[:verdict]}"
1450
+ puts "šŸ“Š Overview : #{data[:total_pages]} Pages Audited | #{Color.c("#{data[:percentages][:optimal_pct]}% Optimal", Color::GREEN)} | #{Color.c("#{data[:percentages][:overflow_pct]}% Overflowing", Color::YELLOW)}"
1451
+ puts "šŸ“ˆ Breakdown : āœ… #{data[:counts][:optimal]} Optimal | āš ļø #{data[:counts][:desktop_overflow]} Desktop Overflow | 🚨 #{data[:counts][:critical_overflow]} Critical Overflow | ā„¹ļø #{data[:counts][:too_short]} Too Short | āŒ #{data[:counts][:missing]} Missing"
1452
+ puts "─" * 80
1453
+
1454
+ pages_to_display = options[:overflow_only] ? data[:pages].select { |p| [:desktop_overflow, :critical_overflow].include?(p[:status]) } : data[:pages]
1455
+
1456
+ if pages_to_display.empty?
1457
+ puts "\nšŸŽ‰ #{Color.c('All audited pages have optimal title tags with zero pixel overflow!', Color::GREEN, Color::BOLD)}"
1458
+ else
1459
+ puts "\n#{Color::BOLD}%-16s %-7s %-10s %s#{Color::RESET}" % ['Status', 'Chars', 'Width', 'URL & Title Tag']
1460
+ puts "─" * 80
1461
+
1462
+ pages_to_display.each do |page|
1463
+ status_col = case page[:status]
1464
+ when :optimal then Color.c("OPTIMAL", Color::GREEN)
1465
+ when :desktop_overflow then Color.c("DESK OVERFLOW", Color::YELLOW, Color::BOLD)
1466
+ when :critical_overflow then Color.c("CRIT OVERFLOW", Color::RED, Color::BOLD)
1467
+ when :too_short then Color.c("TOO SHORT", Color::CYAN)
1468
+ when :missing then Color.c("MISSING", Color::RED, Color::BOLD)
1469
+ end
1470
+
1471
+ px_str = "#{page[:pixel_width]}px"
1472
+ px_col = page[:pixel_width] > 580.0 ? Color.c(px_str, Color::RED, Color::BOLD) : Color.c(px_str, Color::GREEN)
1473
+
1474
+ puts "%-23s %-7s %-18s %s" % [
1475
+ status_col,
1476
+ "#{page[:char_count]}c",
1477
+ px_col,
1478
+ Color.c(page[:url], Color::BOLD)
1479
+ ]
1480
+ puts " ↳ #{Color.c(page[:title].empty? ? '(No Title Tag)' : page[:title], Color::DIM)}"
1481
+ end
1482
+ end
1483
+
1484
+ # Section: Title Rewrite Optimization Playbook
1485
+ overflowing_pages = data[:pages].reject { |p| p[:suggested_rewrites].empty? }
1486
+
1487
+ if overflowing_pages.any?
1488
+ puts "\n\n#{Color::BOLD}šŸ“ TITLE REWRITE & CTR OPTIMIZATION PLAYBOOK (#{overflowing_pages.size} Pages Flagged):#{Color::RESET}"
1489
+ puts "─" * 80
1490
+
1491
+ overflowing_pages.first(8).each_with_index do |p, idx|
1492
+ puts "\n#{Color.c("[#{idx + 1}]", Color::CYAN, Color::BOLD)} #{Color.c(p[:url], Color::BOLD)}"
1493
+ puts " āŒ Current Title : #{Color.c("\"#{p[:title]}\"", Color::RED)} (#{p[:pixel_width]}px, #{p[:char_count]} chars)"
1494
+ puts " šŸ‘€ Desktop SERP : #{Color.c(p[:desktop_preview], Color::GRAY)}"
1495
+
1496
+ puts " šŸ’” #{Color::BOLD}Synthesized Non-Truncating Title Rewrites (<560px):#{Color::RESET}"
1497
+ p[:suggested_rewrites].each do |rewrite|
1498
+ puts " • [#{Color.c(rewrite[:type], Color::YELLOW)}] #{Color.c(rewrite[:title], Color::GREEN, Color::BOLD)}"
1499
+ puts " ↳ #{rewrite[:pixel_width]}px | #{rewrite[:char_count]} chars | Desktop SERP: #{Color.c('FITS CLEANLY', Color::GREEN)}"
1500
+ end
1501
+ end
1502
+
1503
+ if overflowing_pages.size > 8
1504
+ puts "\n... and #{overflowing_pages.size - 8} more pages flagged. Run with #{Color.c('--csv', Color::CYAN)} or #{Color.c('--json', Color::CYAN)} to export all rewrites."
1505
+ end
1506
+ end
1507
+
1508
+ puts "\n" + ("─" * 80) + "\n"
1509
+ end
1510
+ end
1511
+ end
1512
+
1513
+