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
@@ -0,0 +1,1976 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'cgi'
5
+ require 'uri'
6
+ require_relative 'base'
7
+ require_relative '../open_pagerank' if File.exist?(File.expand_path('../open_pagerank.rb', __dir__))
8
+ require_relative '../pagespeed' if File.exist?(File.expand_path('../pagespeed.rb', __dir__))
9
+ require_relative '../speed_correlator' if File.exist?(File.expand_path('../speed_correlator.rb', __dir__))
10
+ require_relative '../page_comparator' if File.exist?(File.expand_path('../page_comparator.rb', __dir__))
11
+ require_relative '../content_gap' if File.exist?(File.expand_path('../content_gap.rb', __dir__))
12
+ require_relative '../internal_links' if File.exist?(File.expand_path('../internal_links.rb', __dir__))
13
+ require_relative '../schema_validator' if File.exist?(File.expand_path('../schema_validator.rb', __dir__))
14
+ require_relative '../llms_generator' if File.exist?(File.expand_path('../llms_generator.rb', __dir__))
15
+ require_relative '../network_tracer' if File.exist?(File.expand_path('../network_tracer.rb', __dir__))
16
+ require_relative '../robots_checker' if File.exist?(File.expand_path('../robots_checker.rb', __dir__))
17
+ require_relative '../backlinks_manager' if File.exist?(File.expand_path('../backlinks_manager.rb', __dir__))
18
+ require_relative '../geo_auditor' if File.exist?(File.expand_path('../geo_auditor.rb', __dir__))
19
+ require_relative '../entity_auditor' if File.exist?(File.expand_path('../entity_auditor.rb', __dir__))
20
+ require_relative '../firewall_scanner' if File.exist?(File.expand_path('../firewall_scanner.rb', __dir__))
21
+ require_relative '../title_optimizer' if File.exist?(File.expand_path('../title_optimizer.rb', __dir__))
22
+ require_relative '../page_analyzer' if File.exist?(File.expand_path('../page_analyzer.rb', __dir__))
23
+ require_relative '../site_crawler' if File.exist?(File.expand_path('../site_crawler.rb', __dir__))
24
+
25
+ module GSC
26
+ class CLI
27
+ module Audit
28
+ module_function
29
+
30
+ def run(command, target, extra, options, api = nil, site_url = nil, hostname = nil, https_origin = nil)
31
+ case command
32
+ when 'authority', 'opr', 'da', 'domain-authority'
33
+ handle_authority(target, extra, options)
34
+ when 'speed', 'vitals', 'pagespeed', 'psi'
35
+ handle_speed(target, options)
36
+ when 'speed-correlate', 'cwv-correlate', 'sc-perf'
37
+ handle_speed_correlate(target, options)
38
+ when 'compare', 'diff-seo', 'vs'
39
+ handle_compare(target, extra, options)
40
+ when 'content-gap', 'gap'
41
+ handle_content_gap(target, extra, options)
42
+ when 'internal-links', 'orphans', 'links-audit'
43
+ handle_internal_links(target, options)
44
+ when 'schema', 'rich-snippets', 'ld-json'
45
+ handle_schema(target, extra, options)
46
+ when 'llms', 'ai-ready'
47
+ handle_llms(target, extra, options, api, site_url)
48
+ when 'trace', 'redirects', 'hops'
49
+ handle_trace(target, options)
50
+ when 'robots', 'robots-txt'
51
+ handle_robots(target, extra, options)
52
+ when 'backlinks', 'links'
53
+ handle_backlinks(target, extra, options)
54
+ when 'geo', 'aeo', 'citability'
55
+ handle_geo(target, options)
56
+ when 'entity', 'kg', 'knowledge-graph'
57
+ handle_entity(target, options)
58
+ when 'firewall', 'ai-bots', 'waf-scan', 'bot-firewall'
59
+ handle_firewall(target, options)
60
+ when 'titles', 'title-opt', 'pixel-titles', 'title-tags'
61
+ handle_titles(target, options)
62
+ when 'headings', 'h1', 'heading-structure'
63
+ handle_headings(target, options)
64
+ when 'page', 'page-audit'
65
+ handle_page(target, site_url, api, options)
66
+ when 'site-audit', 'site-crawl', 'crawl'
67
+ handle_site_crawl(target, site_url, api, options)
68
+ when 'audit', 'a'
69
+ run_comprehensive_audit(api, site_url, hostname, https_origin, options)
70
+ else
71
+ raise "Unknown audit command: #{command}"
72
+ end
73
+ end
74
+
75
+ def handle_authority(target, extra, options)
76
+ domains = [target, extra].flatten.compact.reject { |d| d.to_s.strip.empty? }
77
+ domains << Config.default_domain if domains.empty?
78
+ domains = domains.compact
79
+
80
+ if domains.empty?
81
+ puts Color.c("❌ Error: Domain required. Example: gsc authority example.com", Color::RED)
82
+ return
83
+ end
84
+
85
+ opr = GSC::OpenPageRank.new
86
+ unless opr.configured?
87
+ puts Color.c("⚠️ OpenPageRank API key not configured.", Color::YELLOW, Color::BOLD)
88
+ puts " Get a 100% free key (300,000 free queries/month) at: #{Color.c('https://openpagerank.com', Color::CYAN)}"
89
+ puts " Then run: #{Color.c('gsc config set opr_api_key <YOUR_KEY>', Color::GREEN)}"
90
+ puts " Or pass: #{Color.c('OPENPAGERANK_API_KEY=<KEY> gsc authority ...', Color::DIM)}"
91
+ return
92
+ end
93
+
94
+ data = opr.check_domains(domains)
95
+
96
+ if options[:json]
97
+ puts JSON.pretty_generate(data)
98
+ return
99
+ end
100
+
101
+ puts Base::BANNER unless options[:in_dashboard]
102
+ puts "🌐 #{Color::BOLD}OPEN PAGERANK & DOMAIN AUTHORITY (Common Crawl Graph):#{Color::RESET}"
103
+ puts "─" * 75
104
+
105
+ if data[:status] == 'error'
106
+ puts Color.c("❌ Error: #{data[:message]}", Color::RED)
107
+ return
108
+ end
109
+
110
+ puts "#{'DOMAIN'.ljust(35)} #{'PAGERANK'.ljust(12)} #{'GLOBAL RANK'.ljust(18)} #{'STATUS'}"
111
+ puts "─" * 75
112
+
113
+ (data[:records] || []).each do |rec|
114
+ d_name = rec[:domain].to_s.ljust(35)
115
+ pr = sprintf("%.2f / 10", rec[:page_rank_decimal]).ljust(12)
116
+ gr = rec[:rank] ? "##{rec[:rank].to_s.reverse.gsub(/(\d{3})(?=\d)/, '\\1,').reverse}".ljust(18) : "N/A".ljust(18)
117
+ st = (rec[:status_code] == 200) ? Color.c("200 OK", Color::GREEN) : Color.c(rec[:status_code].to_s, Color::YELLOW)
118
+
119
+ puts "#{Color.c(d_name, Color::BOLD)} #{Color.c(pr, Color::CYAN)} #{Color.c(gr, Color::YELLOW)} #{st}"
120
+ end
121
+ puts ""
122
+ end
123
+
124
+ def handle_speed(target, options)
125
+ url = target || (Config.default_domain ? "https://#{Config.default_domain}" : nil)
126
+ unless url
127
+ if options[:json]
128
+ puts JSON.pretty_generate({ error: 'Target URL or domain required. Example: gsc speed https://mysite.com' })
129
+ else
130
+ puts Color.c("❌ Error: Target URL or domain required. Example: gsc speed https://mysite.com", Color::RED, Color::BOLD)
131
+ end
132
+ return
133
+ end
134
+ url = "https://#{url}" unless url =~ %r{^https?://}
135
+ strategy = options[:strategy] || 'mobile'
136
+
137
+ puts Base::BANNER unless options[:json] || options[:in_dashboard]
138
+ puts "⚡ Measuring Core Web Vitals via Google PageSpeed Insights (#{strategy.upcase}):" unless options[:json]
139
+ puts " #{Color.c(url, Color::CYAN)}\n" unless options[:json]
140
+
141
+ ps = GSC::PageSpeed.new(url, strategy: strategy)
142
+ data = ps.run
143
+
144
+ if options[:json]
145
+ puts JSON.pretty_generate(data)
146
+ return
147
+ end
148
+
149
+ if data[:error]
150
+ puts Color.c("❌ PageSpeed API Error: #{data[:message]}", Color::RED)
151
+ return
152
+ end
153
+
154
+ perf_score = data[:performance_score]
155
+ score_color = perf_score >= 90 ? Color::GREEN : (perf_score >= 50 ? Color::YELLOW : Color::RED)
156
+
157
+ puts "╔══════════════════════════════════════════════════════════════╗"
158
+ puts "║ Lighthouse Performance Score: #{Color.c(perf_score.to_s.rjust(3) + ' / 100', score_color, Color::BOLD)} ║"
159
+ puts "║ Lighthouse SEO Score : #{Color.c(data[:seo_score].to_s.rjust(3) + ' / 100', Color::GREEN, Color::BOLD)} ║"
160
+ puts "╚══════════════════════════════════════════════════════════════╝"
161
+
162
+ m = data[:metrics] || {}
163
+ puts "\n#{Color::BOLD}📊 CORE WEB VITALS (Lab Metrics):#{Color::RESET}"
164
+ puts " • LCP (Largest Contentful Paint) : #{Color.c(m[:lcp] || 'N/A', Color::BOLD)}"
165
+ puts " • FCP (First Contentful Paint) : #{Color.c(m[:fcp] || 'N/A', Color::BOLD)}"
166
+ puts " • CLS (Cumulative Layout Shift) : #{Color.c(m[:cls] || 'N/A', Color::BOLD)}"
167
+ puts " • TBT (Total Blocking Time) : #{Color.c(m[:tbt] || 'N/A', Color::BOLD)}"
168
+ puts " • Speed Index : #{Color.c(m[:speed_index] || 'N/A', Color::BOLD)}"
169
+
170
+ opps = data[:opportunities] || []
171
+ unless opps.empty?
172
+ puts "\n#{Color::BOLD}💡 TOP SPEED OPPORTUNITIES:#{Color::RESET}"
173
+ opps.each do |opp|
174
+ puts " • #{opp[:title]}: #{Color.c(opp[:display] || "#{opp[:savings_ms]}ms savings", Color::YELLOW)}"
175
+ end
176
+ end
177
+ puts ""
178
+ end
179
+
180
+ def handle_speed_correlate(target, options)
181
+ domain = options[:domain] || (target =~ /\.(com|org|net|io|app|co|dev|store)$/i ? target : nil) || Config.default_domain
182
+ url = target || (domain ? "https://#{domain}/" : nil)
183
+ unless url
184
+ if options[:json]
185
+ puts JSON.pretty_generate({ error: 'Target URL or domain required. Example: gsc speed-correlate https://mysite.com' })
186
+ else
187
+ puts Color.c("❌ Error: Target URL or domain required. Example: gsc speed-correlate https://mysite.com", Color::RED, Color::BOLD)
188
+ end
189
+ return
190
+ end
191
+ domain ||= URI.parse(url).host.to_s.sub(/^www\./, '') rescue 'site'
192
+ url = "https://#{url}" unless url =~ %r{^https?://}
193
+ strategy = options[:strategy] || 'mobile'
194
+ days = (options[:days] || 28).to_i
195
+
196
+ api = nil
197
+ sa = GSC::Auth.find_service_account(options[:key], domain)
198
+ if sa && sa[:data]
199
+ token = GSC::Auth.fetch_access_token(sa[:data])
200
+ client = GSC::Client.new(token: token)
201
+ api = GSC::API.new(client)
202
+ end
203
+
204
+ correlator = GSC::SpeedCorrelator.new(url, api: api, domain: domain, days: days, strategy: strategy, options: options)
205
+ data = correlator.correlate
206
+
207
+ if options[:json]
208
+ puts JSON.pretty_generate(data)
209
+ return
210
+ end
211
+
212
+ puts Base::BANNER unless options[:in_dashboard]
213
+ puts "⚡ #{Color::BOLD}CORE WEB VITALS & SEARCH CONSOLE PERFORMANCE CORRELATOR:#{Color::RESET}"
214
+ puts " Target URL: #{Color.c(url, Color::CYAN, Color::BOLD)} [Strategy: #{strategy.upcase}]"
215
+ puts "─" * 80
216
+
217
+ cwv = data[:core_web_vitals] || {}
218
+ gsc = data[:gsc_performance] || {}
219
+ diag = data[:correlation_diagnosis] || {}
220
+ proj = data[:projections] || {}
221
+
222
+ perf_score = cwv[:performance_score] || 0
223
+ score_color = perf_score >= 90 ? Color::GREEN : (perf_score >= 50 ? Color::YELLOW : Color::RED)
224
+
225
+ puts "📱 #{Color::BOLD}LIGHTHOUSE PERFORMANCE:#{Color::RESET} #{Color.c("#{perf_score}/100", score_color, Color::BOLD)} [Source: #{cwv[:source]}]"
226
+ puts " • LCP (Largest Contentful Paint) : #{Color.c(cwv[:lcp_display].to_s, Color::BOLD)} [Status: #{diag[:lcp_status]}]"
227
+ puts " • CLS (Cumulative Layout Shift) : #{Color.c(cwv[:cls_display].to_s, Color::BOLD)} [Status: #{diag[:cls_status]}]"
228
+ puts " • FCP (First Contentful Paint) : #{Color.c(cwv[:fcp_display].to_s, Color::BOLD)}"
229
+ puts " • TBT (Total Blocking Time) : #{Color.c(cwv[:tbt_display].to_s, Color::BOLD)}"
230
+
231
+ puts "\n📈 #{Color::BOLD}SEARCH CONSOLE TRACTION (Past #{days} Days):#{Color::RESET}"
232
+ puts " • Impressions : #{Color.c(gsc[:impressions].to_s, Color::CYAN, Color::BOLD)}"
233
+ puts " • Clicks : #{Color.c(gsc[:clicks].to_s, Color::GREEN, Color::BOLD)}"
234
+ puts " • CTR : #{Color.c("#{gsc[:ctr]}%", Color::BOLD)}"
235
+ puts " • Avg Position: #{Color.c(gsc[:position].to_s, Color::BOLD)}"
236
+
237
+ puts "\n🧠 #{Color::BOLD}ALGORITHMIC CWV IMPACT DIAGNOSIS:#{Color::RESET}"
238
+ diag_color = diag[:overall_cwv_pass] ? Color::GREEN : Color::YELLOW
239
+ puts " • CWV Overall Verdict : [#{Color.c(diag[:overall_cwv_pass] ? 'PASSED' : 'NEEDS OPTIMIZATION', diag_color, Color::BOLD)}]"
240
+ puts " • Algorithmic Drag : #{Color.c(diag[:algorithmic_status], Color::BOLD)}"
241
+
242
+ puts "\n🚀 #{Color::BOLD}PROJECTED SEARCH IMPRESSION & TRAFFIC SURGE (Upon Reaching 'Good' CWV):#{Color::RESET}"
243
+ puts " • Potential Impression Lift : #{Color.c("+#{proj[:potential_lift_percentage]}%", Color::GREEN, Color::BOLD)} (+#{proj[:incremental_impressions_gain]} impressions)"
244
+ puts " • Projected Impressions Target: #{Color.c(proj[:projected_impressions].to_s, Color::CYAN, Color::BOLD)}"
245
+ puts " • Projected Rank Lift : #{Color.c(proj[:estimated_position_improvement].to_s, Color::GREEN, Color::BOLD)} (Pos #{proj[:current_position]} -> Pos #{proj[:projected_position]})"
246
+ puts " • Projected Monthly Click Gain: #{Color.c("+#{proj[:projected_incremental_monthly_clicks]} clicks/mo", Color::GREEN, Color::BOLD)}"
247
+
248
+ action_plan = data[:action_plan] || []
249
+ if action_plan.any?
250
+ puts "\n🛠️ #{Color::BOLD}PRIORITIZED ENGINEERING FIXES FOR SPEED & RANKINGS:#{Color::RESET}"
251
+ action_plan.each_with_index do |act, idx|
252
+ puts " #{idx + 1}. [#{Color.c(act[:priority], Color::BOLD)}] [#{act[:metric]}]: #{act[:recommendation]}"
253
+ end
254
+ end
255
+
256
+ opps = cwv[:opportunities] || []
257
+ if opps.any?
258
+ puts "\n💡 #{Color::BOLD}IDENTIFIED BOTTLENECK OPPORTUNITIES:#{Color::RESET}"
259
+ opps.each do |o|
260
+ puts " • #{o[:title]} (#{Color.c(o[:display] || "#{o[:savings_ms]}ms savings", Color::YELLOW)})"
261
+ end
262
+ end
263
+
264
+ puts "\n" + ("─" * 80) + "\n"
265
+ end
266
+
267
+ def handle_compare(target, extra, options)
268
+ url1 = target
269
+ url2 = extra
270
+
271
+ if url1.nil? || url2.nil?
272
+ puts Color.c("❌ Error: Two URLs required. Example: gsc compare https://site.com/p1 https://competitor.com/p2", Color::RED)
273
+ return
274
+ end
275
+
276
+ comp = GSC::PageComparator.new(url1, url2)
277
+ data = comp.compare
278
+
279
+ if options[:json]
280
+ puts JSON.pretty_generate(data)
281
+ return
282
+ end
283
+
284
+ puts Base::BANNER unless options[:in_dashboard]
285
+ puts "🥊 #{Color::BOLD}HEAD-TO-HEAD SEO ON-PAGE COMPARISON:#{Color::RESET}"
286
+ puts " Page 1 (Target): #{Color.c(url1, Color::CYAN)}"
287
+ puts " Page 2 (Competitor): #{Color.c(url2, Color::YELLOW)}"
288
+ puts "─" * 80
289
+
290
+ c = data[:comparison] || {}
291
+
292
+ t1 = c.dig(:meta, :title, :page1) || {}
293
+ t2 = c.dig(:meta, :title, :page2) || {}
294
+ puts "\n#{Color::BOLD}📑 TITLE TAG:#{Color::RESET}"
295
+ puts " P1: #{t1[:text]} (#{t1[:length]} chars) [#{t1[:optimal] ? Color.c('Optimal', Color::GREEN) : Color.c('Review', Color::YELLOW)}]"
296
+ puts " P2: #{t2[:text]} (#{t2[:length]} chars) [#{t2[:optimal] ? Color.c('Optimal', Color::GREEN) : Color.c('Review', Color::YELLOW)}]"
297
+
298
+ h = c[:headings] || {}
299
+ puts "\n#{Color::BOLD}🏷️ HEADINGS H1 / H2:#{Color::RESET}"
300
+ puts " P1: #{h.dig(:h1_count, :page1)} H1s | #{h.dig(:h2_count, :page1)} H2s"
301
+ puts " P2: #{h.dig(:h1_count, :page2)} H1s | #{h.dig(:h2_count, :page2)} H2s"
302
+
303
+ img = c[:images] || {}
304
+ puts "\n#{Color::BOLD}🖼️ IMAGES & ACCESSIBILITY:#{Color::RESET}"
305
+ puts " P1: #{img.dig(:total_images, :page1)} images (#{img.dig(:missing_alt, :page1)} missing alt)"
306
+ puts " P2: #{img.dig(:total_images, :page2)} images (#{img.dig(:missing_alt, :page2)} missing alt)"
307
+
308
+ l = c[:links] || {}
309
+ puts "\n#{Color::BOLD}🔗 LINK COUNTS:#{Color::RESET}"
310
+ puts " P1: #{l.dig(:internal, :page1)} internal | #{l.dig(:external, :page1)} external"
311
+ puts " P2: #{l.dig(:internal, :page2)} internal | #{l.dig(:external, :page2)} external"
312
+
313
+ s = c[:structured_data] || {}
314
+ puts "\n#{Color::BOLD}📦 STRUCTURED DATA (JSON-LD):#{Color::RESET}"
315
+ puts " P1: #{s.dig(:schema_count, :page1)} schemas #{(s.dig(:schema_types, :page1) || []).inspect}"
316
+ puts " P2: #{s.dig(:schema_count, :page2)} schemas #{(s.dig(:schema_types, :page2) || []).inspect}"
317
+
318
+ p_time = c[:performance] || {}
319
+ puts "\n#{Color::BOLD}⚡ RESPONSE TIME:#{Color::RESET}"
320
+ puts " P1: #{p_time.dig(:response_time_ms, :page1)}ms | P2: #{p_time.dig(:response_time_ms, :page2)}ms"
321
+ puts ""
322
+ end
323
+
324
+ def handle_content_gap(target, extra, options)
325
+ url1 = target
326
+ url2 = extra
327
+
328
+ if url1.nil? || url2.nil?
329
+ puts Color.c("❌ Error: Two URLs required. Example: gsc content-gap https://mysite.com https://competitor.com", Color::RED)
330
+ return
331
+ end
332
+
333
+ gap = GSC::ContentGap.new(url1, url2)
334
+ data = gap.analyze
335
+
336
+ if options[:json]
337
+ puts JSON.pretty_generate(data)
338
+ return
339
+ end
340
+
341
+ puts Base::BANNER unless options[:in_dashboard]
342
+ puts "🔍 #{Color::BOLD}CONTENT & TOPICAL KEYWORD GAP (SurferSEO Style):#{Color::RESET}"
343
+ puts " My URL: #{Color.c(url1, Color::CYAN)} (#{data[:page1][:word_count]} words)"
344
+ puts " Competitor URL: #{Color.c(url2, Color::YELLOW)} (#{data[:page2][:word_count]} words)"
345
+ puts "─" * 80
346
+
347
+ puts "\n#{Color::BOLD}🎯 HIGH-FREQUENCY PHRASES IN COMPETITOR MISSING IN YOUR CONTENT:#{Color::RESET}"
348
+ unigrams = data[:missing_unigrams] || []
349
+ bigrams = data[:missing_bigrams] || []
350
+
351
+ if unigrams.empty? && bigrams.empty?
352
+ puts " (No major content gap detected! Your page covers competitor terminology well.)"
353
+ else
354
+ puts "\n #{Color.c('Top Missing 2-Word Keyphrases:', Color::BOLD, Color::YELLOW)}"
355
+ bigrams.first(8).each do |b|
356
+ puts " • \"#{Color.c(b[:term], Color::BOLD)}\" (Competitor uses #{b[:competitor_count]}x, You: #{b[:your_count]}x)"
357
+ end
358
+
359
+ puts "\n #{Color.c('Top Missing Keywords:', Color::BOLD, Color::CYAN)}"
360
+ unigrams.first(8).each do |u|
361
+ puts " • \"#{Color.c(u[:term], Color::BOLD)}\" (Competitor uses #{u[:competitor_count]}x, You: #{u[:your_count]}x)"
362
+ end
363
+ end
364
+
365
+ headings = data[:missing_headings] || []
366
+ unless headings.empty?
367
+ puts "\n#{Color::BOLD}📑 COMPETITOR HEADINGS / TOPICS YOU OMITTED:#{Color::RESET}"
368
+ headings.each do |h|
369
+ puts " • #{h}"
370
+ end
371
+ end
372
+ puts ""
373
+ end
374
+
375
+ def handle_internal_links(target, options)
376
+ base_url = target || (Config.default_domain ? "https://#{Config.default_domain}" : nil)
377
+ unless base_url
378
+ if options[:json]
379
+ puts JSON.pretty_generate({ error: 'Target URL or domain required. Example: gsc orphans https://mysite.com' })
380
+ else
381
+ puts Color.c("❌ Error: Target URL or domain required. Example: gsc orphans https://mysite.com", Color::RED, Color::BOLD)
382
+ end
383
+ return
384
+ end
385
+ conc = options[:concurrency] || 5
386
+ il = GSC::InternalLinks.new(base_url, limit: options[:limit] || 50, concurrency: conc)
387
+
388
+ puts Base::BANNER unless options[:json] || options[:in_dashboard]
389
+ puts "🕸️ Auditing Internal Links & Orphan Pages for: #{Color.c(base_url, Color::CYAN)} (#{conc} worker threads)...\n" unless options[:json]
390
+
391
+ data = il.audit
392
+
393
+ if options[:json]
394
+ puts JSON.pretty_generate(data)
395
+ return
396
+ end
397
+
398
+ score_color = case data[:health_score]
399
+ when 85..100 then Color::GREEN
400
+ when 70..84 then Color::YELLOW
401
+ else Color::RED
402
+ end
403
+
404
+ puts "📍 Domain: #{Color.c(data[:base_url], Color::CYAN, Color::BOLD)}"
405
+ puts "⚡ Crawl Performance: #{data[:total_pages]} pages mapped in #{data[:crawl_duration_s]}s"
406
+ puts "🏆 Link Equity Score: #{Color.c("#{data[:health_score]}/100 (Grade #{data[:health_grade]})", score_color, Color::BOLD)}"
407
+ puts "🚨 True Orphans: #{Color.c(data[:orphans].length.to_s, data[:orphans].empty? ? Color::GREEN : Color::RED, Color::BOLD)} (0 incoming internal links)"
408
+ puts "⚠️ Weakly Linked: #{Color.c(data[:weak_pages].length.to_s, Color::YELLOW, Color::BOLD)} (Only 1 incoming internal link)"
409
+ puts "🧗 Buried Pages: #{Color.c(data[:deep_pages].length.to_s, data[:deep_pages].empty? ? Color::GREEN : Color::YELLOW, Color::BOLD)} (Click depth >= 4)"
410
+ puts "─" * 80
411
+
412
+ orphan_details = data[:orphan_details] || []
413
+ unless orphan_details.empty?
414
+ puts "\n#{Color::BOLD}🚨 ORPHAN PAGE RESCUE PLAYBOOK (#{orphan_details.size} crawl dead-ends):#{Color::RESET}"
415
+ orphan_details.each_with_index do |orp, idx|
416
+ puts "\n #{Color::BOLD}#{idx + 1}. #{Color.c(orp[:url], Color::RED, Color::BOLD)}#{Color::RESET} [Depth: #{orp[:depth]}]"
417
+ puts " #{Color::BOLD}Rescue Prescriptions:#{Color::RESET}"
418
+ orp[:suggested_rescues].each do |rec|
419
+ puts " • #{Color.c(rec[:action], Color::CYAN)}"
420
+ end
421
+ end
422
+ else
423
+ puts "\n#{Color.c('✅ EXCELLENT! No orphan pages detected on site.', Color::GREEN, Color::BOLD)}"
424
+ end
425
+
426
+ weak = data[:weak_pages] || []
427
+ unless weak.empty?
428
+ puts "\n#{Color::BOLD}⚠️ WEAKLY LINKED PAGES (Vulnerable to De-indexation):#{Color::RESET}"
429
+ weak.first(5).each do |w|
430
+ puts " • #{w[:url]} (Linked only from: #{Color.c(w[:source].to_s, Color::GRAY)})"
431
+ end
432
+ puts " ... and #{weak.size - 5} more weak pages" if weak.size > 5
433
+ end
434
+
435
+ deep = data[:deep_pages] || []
436
+ unless deep.empty?
437
+ puts "\n#{Color::BOLD}🧗 BURIED PAGES (Crawl Depth >= 4 clicks):#{Color::RESET}"
438
+ deep.first(5).each do |dp|
439
+ puts " • #{dp[:url]} (#{Color.c("#{dp[:depth]} clicks deep", Color::YELLOW)})"
440
+ end
441
+ end
442
+
443
+ puts "\n#{Color::BOLD}🔝 TOP LINK EQUITY HUBS (High-Authority Internal Sources):#{Color::RESET}"
444
+ (data[:top_linked] || []).first(6).each do |top|
445
+ title_str = top[:title].to_s.empty? ? '' : " — \"#{top[:title][0..35]}...\""
446
+ puts " • #{Color.c(top[:url], Color::BOLD)}#{title_str} (#{Color.c(top[:incoming_count].to_s, Color::CYAN)} in / #{top[:outgoing_count]} out)"
447
+ end
448
+
449
+ if options[:csv]
450
+ csv_data = orphan_details.map do |o|
451
+ [o[:url], o[:depth], o[:suggested_rescues].map { |r| r[:source_url] }.join('; ')]
452
+ end
453
+ Base.write_csv(options[:csv], %w[OrphanURL Depth RecommendedRescueSources], csv_data)
454
+ puts Color.c("\n📁 Exported orphan rescue playbook to #{options[:csv]}", Color::CYAN)
455
+ end
456
+
457
+ puts "\n" + ("─" * 80) + "\n"
458
+ end
459
+
460
+ def handle_schema(target, extra, options)
461
+ type_str = target.to_s.downcase
462
+ supported_types = GSC::SchemaValidator::AVAILABLE_TEMPLATES.map { |t| t[:type] }
463
+
464
+ if %w[list templates types help].include?(type_str) || (target.nil? && !Config.default_domain)
465
+ render_schema_templates_list(options)
466
+ return
467
+ end
468
+
469
+ if target == 'generate' || target == 'gen' || supported_types.include?(type_str)
470
+ schema_type = (target == 'generate' || target == 'gen') ? (extra || 'faq') : target
471
+ tpl = GSC::SchemaValidator.generate_template(schema_type)
472
+ if options[:json]
473
+ puts JSON.pretty_generate(tpl)
474
+ else
475
+ puts Color.c("📋 Generated JSON-LD Schema (#{schema_type}):", Color::GREEN, Color::BOLD)
476
+ puts "<script type=\"application/ld+json\">"
477
+ puts JSON.pretty_generate(tpl)
478
+ puts "</script>"
479
+ puts "\n" + Color.gray("💡 Available templates: ") + Color.cyan(supported_types.join(', '))
480
+ puts Color.gray(" Run `gsc schema list` to view all templates and Google Rich Result features.")
481
+ puts Color.gray(" Tip: Run `gsc schema-gen <url> --type #{schema_type}` to extract and auto-fill from a live page.")
482
+ puts Color.gray(" Test live in Google's Rich Results Tool: https://search.google.com/test/rich-results")
483
+ if options[:open]
484
+ puts Color.green("\n🚀 Opening Google Rich Results Test (Code Tab) in your browser...")
485
+ Base.open_in_browser("https://search.google.com/test/rich-results")
486
+ end
487
+ puts
488
+ end
489
+ return
490
+ end
491
+
492
+ url = target || (Config.default_domain ? "https://#{Config.default_domain}" : nil)
493
+ unless url
494
+ if options[:json]
495
+ puts JSON.pretty_generate({ error: 'Target URL or schema type required. Example: gsc schema https://mysite.com' })
496
+ else
497
+ puts Color.c("❌ Error: Target URL or schema type required. Example: gsc schema https://mysite.com", Color::RED, Color::BOLD)
498
+ puts " Run `gsc schema list` to view all available JSON-LD templates."
499
+ end
500
+ return
501
+ end
502
+ sv = GSC::SchemaValidator.new(url)
503
+ data = sv.audit
504
+
505
+ rich_test_url = url =~ %r{^https?://} ? "https://search.google.com/test/rich-results?url=#{CGI.escape(url)}" : "https://search.google.com/test/rich-results"
506
+
507
+ if options[:json]
508
+ data[:google_rich_results_test_url] = rich_test_url
509
+ puts JSON.pretty_generate(data)
510
+ return
511
+ end
512
+
513
+ puts Base::BANNER unless options[:in_dashboard]
514
+ puts "📦 #{Color::BOLD}STRUCTURED DATA & RICH SNIPPET VALIDATION:#{Color::RESET} #{Color.c(url, Color::CYAN)}"
515
+ puts "─" * 75
516
+
517
+ schemas = data[:schemas] || []
518
+ if schemas.empty?
519
+ puts " (No JSON-LD structured data schemas found on this page)"
520
+ puts " Tip: Run `gsc schema generate faq` to create valid JSON-LD schema."
521
+ else
522
+ schemas.each do |sc|
523
+ valid_badge = sc[:valid] ? Color.c("VALID", Color::GREEN, Color::BOLD) : Color.c("INVALID", Color::RED, Color::BOLD)
524
+ puts "\nSchema ##{sc[:index] + 1}: #{Color.c(sc[:type], Color::BOLD)} [#{valid_badge}]"
525
+ (sc[:errors] || []).each { |e| puts " ❌ Error: #{Color.c(e, Color::RED)}" }
526
+ (sc[:warnings] || []).each { |w| puts " ⚠️ Warning: #{Color.c(w, Color::YELLOW)}" }
527
+ end
528
+ end
529
+
530
+ puts "\n" + Color.bold("🌐 OFFICIAL GOOGLE RICH RESULTS TEST TOOL:")
531
+ puts " #{Color.cyan(rich_test_url)}"
532
+ if options[:open]
533
+ puts Color.green("\n🚀 Opening Google Rich Results Test in your browser...")
534
+ Base.open_in_browser(rich_test_url)
535
+ end
536
+ puts ""
537
+ end
538
+
539
+ def render_schema_templates_list(options)
540
+ templates = GSC::SchemaValidator::AVAILABLE_TEMPLATES
541
+ if options[:json]
542
+ puts JSON.pretty_generate(templates)
543
+ return
544
+ end
545
+
546
+ puts "\n" + Color.cyan("╔" + "═" * 78 + "╗")
547
+ puts Color.cyan("║") + Color.bold(" 📦 AVAILABLE GOOGLE RICH SNIPPET SCHEMA TEMPLATES ") + Color.cyan("║")
548
+ puts Color.cyan("╚" + "═" * 78 + "╝")
549
+ puts " Generate ready-to-paste JSON-LD structured data or auto-fill from any live URL."
550
+ puts Color.cyan("─" * 80)
551
+ puts sprintf(" %-16s %-45s %s", Color.bold("Schema Type"), Color.bold("Google SERP Feature"), Color.bold("Command Example"))
552
+ puts Color.gray(" " + "─" * 76)
553
+
554
+ templates.each do |tpl|
555
+ type_col = Color.cyan(tpl[:type].ljust(14))
556
+ feature_col = tpl[:feature].ljust(43)
557
+ example_col = Color.gray("gsc schema #{tpl[:type]}")
558
+ puts " #{type_col} #{feature_col} #{example_col}"
559
+ end
560
+
561
+ puts Color.cyan("─" * 80)
562
+ puts Color.bold("💡 USAGE & WORKFLOWS:")
563
+ puts " 1. Generate Clean Template: " + Color.green("gsc schema <type>") + Color.gray(" (e.g. gsc schema product)")
564
+ puts " 2. Auto-Extract from Live URL: " + Color.green("gsc schema-gen <url> --type <type>")
565
+ puts " 3. Audit Page for Rich Schema: " + Color.green("gsc schema <url>") + Color.gray(" (or `gsc rich-results <url>`)")
566
+ puts " 4. Live Googlebot Verification: " + Color.green("gsc inspect <url>")
567
+ puts Color.cyan("═" * 80) + "\n"
568
+ end
569
+
570
+ def handle_llms(target, extra, options, api = nil, site_url = nil)
571
+ base_url = target || (Config.default_domain ? "https://#{Config.default_domain}" : nil)
572
+ unless base_url
573
+ if options[:json]
574
+ puts JSON.pretty_generate({ error: 'Target URL or domain required. Example: gsc llms https://mysite.com' })
575
+ else
576
+ puts Color.c("❌ Error: Target URL or domain required. Example: gsc llms https://mysite.com", Color::RED, Color::BOLD)
577
+ end
578
+ return
579
+ end
580
+ base_url = "https://#{base_url}" unless base_url =~ %r{^https?://}
581
+ llms = GSC::LlmsGenerator.new(base_url, options)
582
+
583
+ if extra == 'audit' || options[:audit]
584
+ data = llms.audit_ai_readability(base_url)
585
+ if options[:json]
586
+ puts JSON.pretty_generate(data)
587
+ else
588
+ puts Base::BANNER unless options[:in_dashboard]
589
+ puts "🤖 #{Color::BOLD}AI SEARCH ENGINE / LLM READABILITY AUDIT:#{Color::RESET} #{Color.c(base_url, Color::CYAN)}"
590
+ puts "─" * 70
591
+ puts "AI Citation Readiness Score: #{Color.c(data[:ai_readability_score].to_s + '/100', Color::GREEN, Color::BOLD)} [Grade: #{data[:grade]}]"
592
+ puts "\nFeatures Detected:"
593
+ puts " • Single H1 Heading : #{data.dig(:features, :h1_count) == 1 ? '✅ Yes' : '❌ No'}"
594
+ puts " • Tables for Data : #{data.dig(:features, :has_tables) ? '✅ Yes' : '❌ No'}"
595
+ puts " • Bullet Lists : #{data.dig(:features, :has_lists) ? '✅ Yes' : '❌ No'}"
596
+ puts " • Structured Data : #{data.dig(:features, :schemas_found)} schemas"
597
+ unless data[:issues].empty?
598
+ puts "\nOptimizations for Perplexity & ChatGPT:"
599
+ data[:issues].each { |iss| puts " • #{Color.c(iss, Color::YELLOW)}" }
600
+ end
601
+ puts ""
602
+ end
603
+ elsif options[:package] || options[:bundle] || extra == 'bundle' || extra == 'package'
604
+ # Fetch GSC search queries for FAQ injection if available
605
+ gsc_rows = nil
606
+ if api && site_url
607
+ res = api.query_analytics(
608
+ site_url: site_url,
609
+ start_date: (Date.today - 28).to_s,
610
+ end_date: Date.today.to_s,
611
+ dimensions: ['page', 'query'],
612
+ row_limit: 1000
613
+ ) rescue nil
614
+ gsc_rows = res.dig(:data, 'rows') || res['rows'] if res
615
+ end
616
+
617
+ output_dir = options[:write] || (options[:save] ? '.' : nil)
618
+ bundle = llms.package_agent_bundle(output_dir: output_dir, limit: (options[:limit] || 25).to_i, gsc_rows: gsc_rows)
619
+
620
+ if options[:json]
621
+ puts JSON.pretty_generate(bundle)
622
+ return
623
+ end
624
+
625
+ puts Base::BANNER unless options[:in_dashboard]
626
+ puts "📦 #{Color::BOLD}AI AGENT MULTI-PAGE KNOWLEDGE PACKAGER (Turn 23):#{Color::RESET} #{Color.c(base_url, Color::CYAN)}"
627
+ puts "─" * 76
628
+ puts " Base Domain: #{Color.c(base_url, Color::CYAN)}"
629
+ puts " Pages Packaged: #{Color.bold(bundle[:total_pages_packaged].to_s)}"
630
+ puts " /llms.txt Size: #{Color.green("#{bundle.dig(:llms_txt, :chars)} chars")} (#{bundle.dig(:llms_txt, :tokens)} tokens)"
631
+ puts " /llms-full.txt Size: #{Color.green("#{bundle.dig(:llms_full_txt, :chars)} chars")} (#{bundle.dig(:llms_full_txt, :tokens)} tokens)"
632
+ puts "\n LLM Context Window Feasibility:"
633
+ puts " • Claude 3.5 Sonnet (200k) : #{bundle.dig(:context_window_fit, :claude_3_5_sonnet_200k) ? '✅ Fits cleanly' : '⚠️ Exceeds context'}"
634
+ puts " • GPT-4o / GPT-5 (128k) : #{bundle.dig(:context_window_fit, :gpt_4o_128k) ? '✅ Fits cleanly' : '⚠️ Exceeds context'}"
635
+ puts " • Gemini 1.5 Pro (1M) : #{bundle.dig(:context_window_fit, :gemini_1_5_pro_1m) ? '✅ Fits cleanly' : '⚠️ Exceeds context'}"
636
+
637
+ if bundle[:saved_files].any?
638
+ puts "\n Saved Files:"
639
+ bundle[:saved_files].each do |f|
640
+ puts " ✓ #{f[:path]} (#{f[:size_bytes]} bytes)"
641
+ end
642
+ else
643
+ puts "\n Tip: Run with `--write` to export /llms.txt and /llms-full.txt directly to your project root."
644
+ end
645
+ puts "─" * 76
646
+ puts ""
647
+ elsif options[:full] || extra == 'full'
648
+ # Generate /llms-full.txt
649
+ gsc_rows = nil
650
+ if api && site_url
651
+ res = api.query_analytics(
652
+ site_url: site_url,
653
+ start_date: (Date.today - 28).to_s,
654
+ end_date: Date.today.to_s,
655
+ dimensions: ['page', 'query'],
656
+ row_limit: 1000
657
+ ) rescue nil
658
+ gsc_rows = res.dig(:data, 'rows') || res['rows'] if res
659
+ end
660
+
661
+ content = llms.generate_llms_full_txt(limit: (options[:limit] || 25).to_i, gsc_rows: gsc_rows)
662
+ if options[:save] || options[:write]
663
+ out_file = options[:write] && options[:write] != '.' ? File.join(options[:write], 'llms-full.txt') : 'llms-full.txt'
664
+ File.write(out_file, content, encoding: 'UTF-8')
665
+ puts Color.c("✅ Successfully wrote #{out_file} (#{content.length} chars, ~#{(content.length/4.0).ceil} tokens)!", Color::GREEN)
666
+ else
667
+ puts content
668
+ end
669
+ else
670
+ content = llms.generate_llms_txt(limit: (options[:limit] || 25).to_i)
671
+ if options[:save] || options[:write]
672
+ out_file = options[:write] && options[:write] != '.' ? File.join(options[:write], 'llms.txt') : 'llms.txt'
673
+ File.write(out_file, content, encoding: 'UTF-8')
674
+ puts Color.c("✅ Successfully wrote #{out_file} to current directory!", Color::GREEN)
675
+ else
676
+ puts content
677
+ end
678
+ end
679
+ end
680
+
681
+ def handle_trace(target, options)
682
+ url = target || (Config.default_domain ? "https://#{Config.default_domain}" : nil)
683
+ unless url
684
+ if options[:json]
685
+ puts JSON.pretty_generate({ error: 'Target URL required. Example: gsc trace https://mysite.com' })
686
+ else
687
+ puts Color.c("❌ Error: Target URL required. Example: gsc trace https://mysite.com", Color::RED, Color::BOLD)
688
+ end
689
+ return
690
+ end
691
+ nt = GSC::NetworkTracer.new(url)
692
+ data = nt.trace
693
+
694
+ if options[:json]
695
+ puts JSON.pretty_generate(data)
696
+ return
697
+ end
698
+
699
+ puts Base::BANNER unless options[:in_dashboard]
700
+ puts "🛤️ #{Color::BOLD}REDIRECT CHAIN & HTTP HEADER TRACE:#{Color::RESET} #{Color.c(url, Color::CYAN)}"
701
+ puts "─" * 75
702
+ puts "Total Hops: #{data[:total_hops]} | Duration: #{data[:total_duration_ms]}ms"
703
+
704
+ (data[:hops] || []).each do |hop|
705
+ status_c = (hop[:status_code] == 200) ? Color::GREEN : Color::YELLOW
706
+ puts "\nHop ##{hop[:hop]}: #{Color.c(hop[:status_code].to_s, status_c, Color::BOLD)} (#{hop[:duration_ms]}ms)"
707
+ puts " URL: #{hop[:url]}"
708
+ puts " X-Robots-Tag: #{Color.c(hop[:x_robots_tag], Color::RED)}" if hop[:x_robots_tag]
709
+ puts " Canonical: #{hop[:canonical_header]}" if hop[:canonical_header]
710
+ puts " HSTS: #{hop[:hsts] ? 'Enabled' : 'Disabled'}"
711
+ end
712
+ puts ""
713
+ end
714
+
715
+ def handle_robots(target, extra, options)
716
+ url = target || (Config.default_domain ? "https://#{Config.default_domain}" : nil)
717
+ unless url
718
+ if options[:json]
719
+ puts JSON.pretty_generate({ error: 'Target URL or domain required. Example: gsc robots https://mysite.com' })
720
+ else
721
+ puts Color.c("❌ Error: Target URL or domain required. Example: gsc robots https://mysite.com", Color::RED, Color::BOLD)
722
+ end
723
+ return
724
+ end
725
+ path = extra || '/'
726
+ bot = options[:bot] || 'googlebot'
727
+
728
+ rc = GSC::RobotsChecker.new(url)
729
+ data = rc.check(path, bot)
730
+
731
+ if options[:json]
732
+ puts JSON.pretty_generate(data)
733
+ return
734
+ end
735
+
736
+ puts Base::BANNER unless options[:in_dashboard]
737
+ puts "🤖 #{Color::BOLD}ROBOTS.TXT CRAWLER SIMULATOR:#{Color::RESET}"
738
+ puts " Robots URL : #{data[:robots_url]}"
739
+ puts " User Agent : #{Color.c(bot, Color::CYAN)}"
740
+ puts " Test Path : #{Color.c(path, Color::BOLD)}"
741
+ puts "─" * 70
742
+
743
+ status_badge = data[:allowed] ? Color.c("✅ ALLOWED", Color::GREEN, Color::BOLD) : Color.c("❌ BLOCKED (DISALLOW)", Color::RED, Color::BOLD)
744
+ puts "Crawl Verdict : #{status_badge}"
745
+ if data[:matched_rule]
746
+ puts "Matched Rule : #{data[:matched_rule][:type].to_s.upcase}: #{data[:matched_rule][:path]}"
747
+ end
748
+ puts ""
749
+ end
750
+
751
+ def handle_backlinks(target, extra, options)
752
+ domain = target || Config.default_domain
753
+ unless domain
754
+ if options[:json]
755
+ puts JSON.pretty_generate({ error: 'Target domain required. Example: gsc backlinks mysite.com' })
756
+ else
757
+ puts Color.c("❌ Error: Target domain required. Example: gsc backlinks mysite.com", Color::RED, Color::BOLD)
758
+ end
759
+ return
760
+ end
761
+ bm = GSC::BacklinksManager.new(domain)
762
+
763
+ if target == 'import' || extra == 'import'
764
+ source = (target == 'import') ? extra : target
765
+ content = if source == 'clip' || source == 'clipboard'
766
+ `pbpaste 2>/dev/null`
767
+ elsif source && File.exist?(source)
768
+ File.read(source)
769
+ else
770
+ nil
771
+ end
772
+
773
+ if content.nil? || content.strip.empty?
774
+ puts Color.c("❌ Error: No content provided. Usage: gsc backlinks import [file.csv|clip]", Color::RED)
775
+ return
776
+ end
777
+
778
+ res = bm.import_csv(content)
779
+ if options[:json]
780
+ puts JSON.pretty_generate(res)
781
+ else
782
+ puts Color.c("✅ Successfully imported GSC backlink export!", Color::GREEN, Color::BOLD)
783
+ puts " Referring Domains : #{res[:sources_count]}"
784
+ puts " Target Pages : #{res[:targets_count]}"
785
+ end
786
+ return
787
+ end
788
+
789
+ data = bm.summary
790
+
791
+ if options[:json]
792
+ puts JSON.pretty_generate(data)
793
+ return
794
+ end
795
+
796
+ puts Base::BANNER unless options[:in_dashboard]
797
+ puts "🔗 #{Color::BOLD}GSC BACKLINK & REFERRING DOMAIN INTELLIGENCE:#{Color::RESET} #{Color.c(domain, Color::CYAN)}"
798
+ puts "─" * 75
799
+ puts "Total Referring Domains : #{Color.c(data[:total_referring_domains].to_s, Color::BOLD)}"
800
+ puts "Total External Links : #{Color.c(data[:total_external_links].to_s, Color::BOLD)}"
801
+ puts "Last Updated : #{data[:updated_at] || 'Never (Run `gsc backlinks import` to ingest GSC export)'}"
802
+ puts "─" * 75
803
+
804
+ sources = data[:top_referring_domains] || []
805
+ unless sources.empty?
806
+ puts "\n#{Color::BOLD}🌐 TOP REFERRING SITES:#{Color::RESET}"
807
+ sources.each do |s|
808
+ puts " • #{s['domain'].to_s.ljust(45)} #{Color.c(s['links_count'].to_s.rjust(6) + ' links', Color::CYAN)}"
809
+ end
810
+ end
811
+
812
+ targets = data[:top_target_pages] || []
813
+ unless targets.empty?
814
+ puts "\n#{Color::BOLD}🎯 TOP LINKED LANDING PAGES:#{Color::RESET}"
815
+ targets.each do |t|
816
+ puts " • #{t['target_url'].to_s.ljust(45)} #{Color.c(t['incoming_count'].to_s.rjust(6) + ' links', Color::GREEN)}"
817
+ end
818
+ end
819
+ puts ""
820
+ end
821
+
822
+ def handle_geo(target, options)
823
+ url = target || (Config.default_domain ? "https://#{Config.default_domain}" : nil)
824
+ unless url
825
+ if options[:json]
826
+ puts JSON.pretty_generate({ error: 'Target URL required. Example: gsc geo https://mysite.com' })
827
+ else
828
+ puts Color.c("❌ Error: Target URL required. Example: gsc geo https://mysite.com", Color::RED, Color::BOLD)
829
+ end
830
+ return
831
+ end
832
+ auditor = GSC::GeoAuditor.new(url)
833
+
834
+ puts "🤖 Analyzing Generative Engine Optimization (GEO) & Citability for #{Color.c(url, Color::CYAN)}...\n" unless options[:json]
835
+
836
+ data = auditor.audit
837
+ if data[:error]
838
+ if options[:json]
839
+ puts JSON.pretty_generate(data)
840
+ else
841
+ puts Color.c("❌ Error: #{data[:message]}", Color::RED, Color::BOLD)
842
+ end
843
+ return
844
+ end
845
+
846
+ if options[:json]
847
+ puts JSON.pretty_generate(data)
848
+ return
849
+ end
850
+
851
+ puts Base::BANNER unless options[:in_dashboard]
852
+ puts "🤖 #{Color::BOLD}GENERATIVE ENGINE OPTIMIZATION (GEO / AEO) AUDIT#{Color::RESET}"
853
+ puts "🎯 Target URL : #{Color.c(data[:url], Color::CYAN)}"
854
+ puts "─" * 75
855
+
856
+ score_color = case data[:score]
857
+ when 85..100 then Color::GREEN
858
+ when 70..84 then Color::CYAN
859
+ when 50..69 then Color::YELLOW
860
+ else Color::RED
861
+ end
862
+
863
+ puts "🏆 #{Color::BOLD}AI CITABILITY SCORE:#{Color::RESET} #{Color.c("#{data[:score]}/100", score_color, Color::BOLD)} — #{data[:grade]}"
864
+ puts " • AI Bot Access (Robots.txt) : #{data[:category_scores][:ai_bot_access][:score]}/25"
865
+ puts " • Direct Question Answers : #{data[:category_scores][:direct_answers][:score]}/25"
866
+ puts " • Factual & Data Citability : #{data[:category_scores][:facts_and_data][:score]}/25"
867
+ puts " • Entity Schema & Authority : #{data[:category_scores][:entity_schema][:score]}/25"
868
+ puts "─" * 75
869
+
870
+ puts "\n🤖 #{Color::BOLD}AI SEARCH CRAWLER ACCESS (Robots.txt):#{Color::RESET}"
871
+ data[:ai_crawlers][:bots].each do |bot, info|
872
+ badge = info[:allowed] ? Color.c("✅ ALLOWED", Color::GREEN) : Color.c("❌ BLOCKED", Color::RED, Color::BOLD)
873
+ puts " • #{bot.ljust(20)} [#{info[:provider]}]: #{badge} (#{info[:rule]})"
874
+ end
875
+
876
+ puts "\n📝 #{Color::BOLD}DIRECT ANSWER & CITABILITY DENSITY:#{Color::RESET}"
877
+ puts " • Question Headings Detected : #{data[:direct_answers][:total_question_headings]}"
878
+ puts " • Direct Answer Paragraphs : #{data[:direct_answers][:direct_answer_blocks_found]}"
879
+ puts " • Optimal Length Answers (30-80w): #{data[:direct_answers][:optimal_answers_count]}"
880
+
881
+ samples = data[:direct_answers][:samples] || []
882
+ if samples.any?
883
+ puts " #{Color::BOLD}Sample Answer Pairs:#{Color::RESET}"
884
+ samples.each do |s|
885
+ puts " Q: #{Color.c(s[:question], Color::CYAN)}"
886
+ puts " A: #{s[:answer_preview]} (#{s[:word_count]} words)"
887
+ end
888
+ end
889
+
890
+ puts "\n📊 #{Color::BOLD}FACTUAL & INFORMATION GAIN SIGNALS:#{Color::RESET}"
891
+ puts " • Statistics & Percentages : #{data[:facts_and_citability][:percentage_mentions]}"
892
+ puts " • Currency / Pricing Signals : #{data[:facts_and_citability][:currency_mentions]}"
893
+ puts " • Structured Bullet Items (<li>): #{data[:facts_and_citability][:bullet_list_items]}"
894
+ puts " • Comparison Table Rows (<tr>) : #{data[:facts_and_citability][:comparison_table_rows]}"
895
+ puts " • Recent Year Signals (2024-26): #{data[:facts_and_citability][:recent_year_mentions]}"
896
+
897
+ puts "\n🏛️ #{Color::BOLD}ENTITY KNOWLEDGE GRAPH & SCHEMA:#{Color::RESET}"
898
+ puts " • Schemas Found : #{data[:entity_knowledge_graph][:schema_count]} (#{data[:entity_knowledge_graph][:schema_types].join(', ')})"
899
+ puts " • Organization / Brand Schema : #{data[:entity_knowledge_graph][:has_organization_or_brand] ? Color.c("Yes", Color::GREEN) : Color.c("Missing", Color::YELLOW)}"
900
+ puts " • FAQ / HowTo Rich Schema : #{data[:entity_knowledge_graph][:has_faq_or_howto] ? Color.c("Yes", Color::GREEN) : Color.c("None", Color::YELLOW)}"
901
+ puts " • Wikidata / Wikipedia Linked : #{data[:entity_knowledge_graph][:wikidata_or_wikipedia_linked] ? Color.c("Yes", Color::GREEN) : Color.c("No", Color::YELLOW)}"
902
+ if data[:entity_knowledge_graph][:same_as_domains].any?
903
+ puts " • Social Authority Entity Links: #{data[:entity_knowledge_graph][:same_as_domains].join(', ')}"
904
+ end
905
+
906
+ recs = data[:recommendations] || []
907
+ if recs.any?
908
+ puts "\n💡 #{Color::BOLD}AI CITABILITY OPTIMIZATION RECOMMENDATIONS:#{Color::RESET}"
909
+ recs.each_with_index do |r, i|
910
+ puts " #{i + 1}. #{Color.c(r, Color::YELLOW)}"
911
+ end
912
+ end
913
+ puts ""
914
+ end
915
+
916
+ def handle_entity(target, options)
917
+ url = target || (Config.default_domain ? "https://#{Config.default_domain}" : nil)
918
+ unless url
919
+ if options[:json]
920
+ puts JSON.pretty_generate({ error: 'Target URL required. Example: gsc entity https://mysite.com' })
921
+ else
922
+ puts Color.c("❌ Error: Target URL required. Example: gsc entity https://mysite.com", Color::RED, Color::BOLD)
923
+ end
924
+ return
925
+ end
926
+ url = "https://#{url}" unless url =~ %r{^https?://}
927
+
928
+ puts "🏛️ Auditing Knowledge Graph Entity & sameAs Identity for #{Color.c(url, Color::CYAN)}...\n" unless options[:json]
929
+
930
+ data = GSC::EntityAuditor.audit(url)
931
+ if data[:error]
932
+ if options[:json]
933
+ puts JSON.pretty_generate(data)
934
+ else
935
+ puts Color.c("❌ Error: #{data[:error]}", Color::RED, Color::BOLD)
936
+ end
937
+ return
938
+ end
939
+
940
+ if options[:json]
941
+ puts JSON.pretty_generate(data)
942
+ return
943
+ end
944
+
945
+ puts Base::BANNER unless options[:in_dashboard]
946
+ puts "🏛️ #{Color::BOLD}KNOWLEDGE GRAPH ENTITY & sameAs DISAMBIGUATION AUDIT#{Color::RESET}"
947
+ puts "🎯 Target URL : #{Color.c(data[:url], Color::CYAN)}"
948
+ puts "─" * 75
949
+
950
+ score_color = case data[:score]
951
+ when 85..100 then Color::GREEN
952
+ when 70..84 then Color::CYAN
953
+ when 50..69 then Color::YELLOW
954
+ else Color::RED
955
+ end
956
+
957
+ puts "🏆 #{Color::BOLD}ENTITY AUTHORITY SCORE:#{Color::RESET} #{Color.c("#{data[:score]}/100", score_color, Color::BOLD)} — Grade #{data[:grade]}"
958
+ puts "📦 Detected Entities : #{data[:entities_found]}"
959
+ puts "─" * 75
960
+
961
+ if data[:findings].any?
962
+ puts "\n🔎 #{Color::BOLD}ENTITY SIGNALS DETECTED:#{Color::RESET}"
963
+ data[:findings].each do |f|
964
+ puts " • #{Color.c(f, Color::GREEN)}"
965
+ end
966
+ end
967
+
968
+ if data[:authoritative_links].any?
969
+ puts "\n🌐 #{Color::BOLD}HIGH-AUTHORITY SAMEAS IDENTITY LINKS:#{Color::RESET}"
970
+ data[:authoritative_links].each do |source, link|
971
+ puts " • #{source.to_s.capitalize.ljust(15)}: #{Color.c(link, Color::CYAN)}"
972
+ end
973
+ end
974
+
975
+ if data[:recommendations].any?
976
+ puts "\n💡 #{Color::BOLD}OPTIMIZATION DIRECTIVES:#{Color::RESET}"
977
+ data[:recommendations].each_with_index do |r, i|
978
+ puts " #{i + 1}. #{Color.c(r, Color::YELLOW)}"
979
+ end
980
+ end
981
+
982
+ if options[:generate] || data[:score] < 70
983
+ puts "\n📋 #{Color::BOLD}RECOMMENDED ENHANCED JSON-LD SCHEMA SNIPPET:#{Color::RESET}"
984
+ puts "<!-- Paste inside <head> on your homepage/about page -->"
985
+ puts "<script type=\"application/ld+json\">"
986
+ puts JSON.pretty_generate(data[:recommended_json_ld])
987
+ puts "</script>"
988
+ end
989
+ puts ""
990
+ end
991
+
992
+ def handle_firewall(target, options = {})
993
+ scanner = GSC::FirewallScanner.new(target, options)
994
+ data = scanner.scan
995
+
996
+ if options[:json]
997
+ puts JSON.pretty_generate(data)
998
+ return
999
+ end
1000
+
1001
+ puts Base::BANNER unless options[:in_dashboard]
1002
+ puts "🛡️ #{Color::BOLD}AI BOT ACCESSIBILITY & CLOUDFLARE/WAF FIREWALL AUDITOR#{Color::RESET}"
1003
+ puts "🎯 Target URL : #{Color.c(data[:url], Color::YELLOW, Color::BOLD)}"
1004
+ puts "🌐 Hostname : #{data[:host]}"
1005
+
1006
+ edge_names = data[:edge_infrastructure].map { |i| "#{i[:name]} (#{i[:role]})" }.join(' | ')
1007
+ puts "☁️ Edge CDN/WAF : #{Color.c(edge_names, Color::CYAN)}"
1008
+
1009
+ grade_color = case data[:grade]
1010
+ when 'A' then Color::GREEN
1011
+ when 'B' then Color::CYAN
1012
+ when 'C' then Color::YELLOW
1013
+ when 'D' then Color::RED
1014
+ else Color::RED
1015
+ end
1016
+
1017
+ score_str = "#{data[:score]}/100 [Grade #{data[:grade]}]"
1018
+ puts "🏆 AI Access : #{Color.c(score_str, Color::BOLD, grade_color)} - #{data[:verdict]}"
1019
+ puts "─" * 80
1020
+
1021
+ puts "\n#{Color::BOLD}📡 LIVE EDGE WAF MULTI-USER-AGENT PROBES (LIVE PASS-THROUGH):#{Color::RESET}"
1022
+ puts "#{Color::BOLD}%-18s %-24s %-12s %-10s %-14s %s#{Color::RESET}" % ['Persona', 'Client Name', 'HTTP Status', 'Latency', 'WAF Verdict', 'Challenge / Block']
1023
+ puts "─" * 80
1024
+
1025
+ data[:live_probes].each do |probe|
1026
+ status_col = case probe[:status]
1027
+ when 200..299 then Color.c("#{probe[:status]} OK", Color::GREEN)
1028
+ when 300..399 then Color.c("#{probe[:status]} REDIR", Color::YELLOW)
1029
+ when 403 then Color.c("403 FORBID", Color::RED, Color::BOLD)
1030
+ when 503 then Color.c("503 UNAVAIL", Color::RED, Color::BOLD)
1031
+ else Color.c(probe[:status].to_s, Color::RED)
1032
+ end
1033
+
1034
+ verdict_col = case probe[:verdict]
1035
+ when :pass then Color.c("PASS", Color::GREEN)
1036
+ when :redirect then Color.c("REDIRECT", Color::YELLOW)
1037
+ when :challenge then Color.c("CHALLENGE", Color::RED, Color::BOLD)
1038
+ when :blocked then Color.c("BLOCKED", Color::RED, Color::BOLD)
1039
+ else Color.c("ERROR", Color::RED)
1040
+ end
1041
+
1042
+ challenges_str = probe[:challenges].empty? ? '-' : probe[:challenges].join(', ')
1043
+ challenges_col = probe[:challenges].empty? ? Color.c(challenges_str, Color::GRAY) : Color.c(challenges_str, Color::RED, Color::BOLD)
1044
+
1045
+ puts "%-18s %-24s %-21s %-10s %-23s %s" % [
1046
+ probe[:persona],
1047
+ probe[:name],
1048
+ status_col,
1049
+ "#{probe[:duration_ms]}ms",
1050
+ verdict_col,
1051
+ challenges_col
1052
+ ]
1053
+ end
1054
+
1055
+ puts "\n#{Color::BOLD}🤖 ROBOTS.TXT AI SEARCH & TRAINING CRAWLER DIRECTIVES:#{Color::RESET}"
1056
+ puts "#{Color::BOLD}%-20s %-15s %-12s %-12s %s#{Color::RESET}" % ['Bot User-Agent', 'Provider', 'Role', 'Status', 'Matched Rule / Reason']
1057
+ puts "─" * 80
1058
+
1059
+ data[:robots_txt][:bots].each do |bot_name, bot_info|
1060
+ status_col = case bot_info[:status]
1061
+ when :allowed then Color.c("ALLOWED", Color::GREEN, Color::BOLD)
1062
+ when :restricted then Color.c("RESTRICTED", Color::YELLOW)
1063
+ when :blocked then Color.c("BLOCKED", Color::RED, Color::BOLD)
1064
+ else Color.c("UNKNOWN", Color::GRAY)
1065
+ end
1066
+
1067
+ category_label = bot_info[:category].to_s.sub('_', ' ').capitalize
1068
+
1069
+ puts "%-20s %-15s %-12s %-21s %s" % [
1070
+ bot_name,
1071
+ bot_info[:provider],
1072
+ category_label,
1073
+ status_col,
1074
+ Color.c(bot_info[:reason].to_s, Color::GRAY)
1075
+ ]
1076
+ end
1077
+
1078
+ puts "\n#{Color::BOLD}🔍 SILENT BOT BLOCKADE DIAGNOSIS:#{Color::RESET}"
1079
+ diag = data[:blockade_diagnosis]
1080
+ if diag[:silent_blockade_detected]
1081
+ puts Color.c(" 🚨 #{diag[:summary]}", Color::RED, Color::BOLD)
1082
+ puts " ⚠️ Browser pass: #{diag[:browser_accessible]} | Googlebot pass: #{diag[:googlebot_accessible]}"
1083
+ puts " ❌ Blocked AI crawlers: #{diag[:blocked_probes].map { |p| "#{p[:name]} (#{p[:status]})" }.join(', ')}"
1084
+ else
1085
+ puts Color.c(" #{diag[:summary]}", Color::GREEN)
1086
+ end
1087
+
1088
+ recipes = data[:remediation_recipes]
1089
+ if recipes[:cloudflare_waf_rule]
1090
+ puts "\n#{Color::BOLD}🛠️ RECOMMENDED CLOUDFLARE CUSTOM WAF BYPASS RULE:#{Color::RESET}"
1091
+ puts " Rule Name: #{Color.c(recipes[:cloudflare_waf_rule][:name], Color::CYAN, Color::BOLD)}"
1092
+ puts " Expression:\n #{Color.c(recipes[:cloudflare_waf_rule][:filter_expression], Color::YELLOW)}"
1093
+ puts " Action: #{Color.c('Skip', Color::GREEN, Color::BOLD)} (Skip WAF Managed Rules, Super Bot Fight Mode, Rate Limiting)"
1094
+ puts "\n #{Color::BOLD}Dashboard Deployment Steps:#{Color::RESET}"
1095
+ recipes[:cloudflare_waf_rule][:dashboard_instructions].each do |step|
1096
+ puts " #{Color.c(step, Color::GRAY)}"
1097
+ end
1098
+ end
1099
+
1100
+ if options[:generate] || diag[:silent_blockade_detected] || diag[:robots_critical_blocked].any?
1101
+ puts "\n#{Color::BOLD}📄 RECOMMENDED ROBOTS.TXT CONFIGURATION FOR AI SEARCH:#{Color::RESET}"
1102
+ puts Color.c(recipes[:recommended_robots_txt], Color::DIM)
1103
+ else
1104
+ puts "\n💡 #{Color.c('Tip: Pass --generate to display copy-paste robots.txt configuration.', Color::GRAY)}"
1105
+ end
1106
+
1107
+ puts "─" * 80 + "\n"
1108
+ end
1109
+
1110
+ def handle_titles(target, options = {})
1111
+ target_site = target.to_s.strip
1112
+ target_site = Config.default_domain.to_s.strip if target_site.empty?
1113
+ if target_site.empty?
1114
+ if options[:json]
1115
+ puts JSON.pretty_generate({ error: 'Target domain required. Example: gsc titles mysite.com' })
1116
+ else
1117
+ puts Color.c("❌ Error: Target domain required. Example: gsc titles mysite.com", Color::RED, Color::BOLD)
1118
+ end
1119
+ return
1120
+ end
1121
+
1122
+ optimizer = GSC::TitleOptimizer.new(target_site, options)
1123
+
1124
+ unless options[:json] || options[:csv]
1125
+ puts Base::BANNER unless options[:in_dashboard]
1126
+ puts "📏 #{Color::BOLD}GOOGLE SERP TITLE LENGTH & PIXEL OVERFLOW AUDITOR#{Color::RESET}"
1127
+ puts "🎯 Target: #{Color.c(target_site, Color::YELLOW, Color::BOLD)}"
1128
+ print "🔄 Crawling and calculating pixel metrics across pages... "
1129
+ $stdout.flush
1130
+ end
1131
+
1132
+ data = optimizer.audit do |_url, current, total|
1133
+ unless options[:json] || options[:csv]
1134
+ print "\r🔄 Crawling and calculating pixel metrics across pages... [#{current}/#{total}]"
1135
+ $stdout.flush
1136
+ end
1137
+ end
1138
+
1139
+ unless options[:json] || options[:csv]
1140
+ puts " Done!\n"
1141
+ end
1142
+
1143
+ if options[:json]
1144
+ puts JSON.pretty_generate(data)
1145
+ return
1146
+ end
1147
+
1148
+ if options[:csv]
1149
+ require 'csv'
1150
+ puts "URL,Characters,Pixel_Width,Status,Current_Title,Desktop_Preview,Suggested_Rewrite_1,Suggested_Rewrite_2"
1151
+ data[:pages].each do |p|
1152
+ r1 = p[:suggested_rewrites][0] ? p[:suggested_rewrites][0][:title].gsub('"', '""') : ''
1153
+ r2 = p[:suggested_rewrites][1] ? p[:suggested_rewrites][1][:title].gsub('"', '""') : ''
1154
+ title_clean = p[:title].to_s.gsub('"', '""')
1155
+ prev_clean = p[:desktop_preview].to_s.gsub('"', '""')
1156
+ puts "\"#{p[:url]}\",#{p[:char_count]},#{p[:pixel_width]},#{p[:status]},\"#{title_clean}\",\"#{prev_clean}\",\"#{r1}\",\"#{r2}\""
1157
+ end
1158
+ return
1159
+ end
1160
+
1161
+ grade_color = case data[:grade]
1162
+ when 'A' then Color::GREEN
1163
+ when 'B' then Color::CYAN
1164
+ when 'C' then Color::YELLOW
1165
+ when 'D' then Color::RED
1166
+ else Color::RED
1167
+ end
1168
+
1169
+ puts "🏆 Title Health : #{Color.c("#{data[:health_score]}/100 [Grade #{data[:grade]}]", Color::BOLD, grade_color)} - #{data[:verdict]}"
1170
+ 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)}"
1171
+ 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"
1172
+ puts "─" * 80
1173
+
1174
+ pages_to_display = options[:overflow_only] ? data[:pages].select { |p| [:desktop_overflow, :critical_overflow].include?(p[:status]) } : data[:pages]
1175
+
1176
+ if pages_to_display.empty?
1177
+ puts "\n🎉 #{Color.c('All audited pages have optimal title tags with zero pixel overflow!', Color::GREEN, Color::BOLD)}"
1178
+ else
1179
+ puts "\n#{Color::BOLD}%-16s %-7s %-10s %s#{Color::RESET}" % ['Status', 'Chars', 'Width', 'URL & Title Tag']
1180
+ puts "─" * 80
1181
+
1182
+ pages_to_display.each do |page|
1183
+ status_col = case page[:status]
1184
+ when :optimal then Color.c("OPTIMAL", Color::GREEN)
1185
+ when :desktop_overflow then Color.c("DESK OVERFLOW", Color::YELLOW, Color::BOLD)
1186
+ when :critical_overflow then Color.c("CRIT OVERFLOW", Color::RED, Color::BOLD)
1187
+ when :too_short then Color.c("TOO SHORT", Color::CYAN)
1188
+ when :missing then Color.c("MISSING", Color::RED, Color::BOLD)
1189
+ end
1190
+
1191
+ px_str = "#{page[:pixel_width]}px"
1192
+ px_col = page[:pixel_width] > 580.0 ? Color.c(px_str, Color::RED, Color::BOLD) : Color.c(px_str, Color::GREEN)
1193
+
1194
+ puts "%-23s %-7s %-18s %s" % [
1195
+ status_col,
1196
+ "#{page[:char_count]}c",
1197
+ px_col,
1198
+ Color.c(page[:url], Color::BOLD)
1199
+ ]
1200
+ puts " ↳ #{Color.c(page[:title].empty? ? '(No Title Tag)' : page[:title], Color::DIM)}"
1201
+ end
1202
+ end
1203
+
1204
+ overflowing_pages = data[:pages].reject { |p| p[:suggested_rewrites].empty? }
1205
+
1206
+ if overflowing_pages.any?
1207
+ puts "\n\n#{Color::BOLD}📝 TITLE REWRITE & CTR OPTIMIZATION PLAYBOOK (#{overflowing_pages.size} Pages Flagged):#{Color::RESET}"
1208
+ puts "─" * 80
1209
+
1210
+ overflowing_pages.first(8).each_with_index do |p, idx|
1211
+ puts "\n#{Color.c("[#{idx + 1}]", Color::CYAN, Color::BOLD)} #{Color.c(p[:url], Color::BOLD)}"
1212
+ puts " ❌ Current Title : #{Color.c("\"#{p[:title]}\"", Color::RED)} (#{p[:pixel_width]}px, #{p[:char_count]} chars)"
1213
+ puts " 👀 Desktop SERP : #{Color.c(p[:desktop_preview], Color::GRAY)}"
1214
+
1215
+ puts " 💡 #{Color::BOLD}Synthesized Non-Truncating Title Rewrites (<560px):#{Color::RESET}"
1216
+ p[:suggested_rewrites].each do |rewrite|
1217
+ puts " • [#{Color.c(rewrite[:type], Color::YELLOW)}] #{Color.c(rewrite[:title], Color::GREEN, Color::BOLD)}"
1218
+ puts " ↳ #{rewrite[:pixel_width]}px | #{rewrite[:char_count]} chars | Desktop SERP: #{Color.c('FITS CLEANLY', Color::GREEN)}"
1219
+ end
1220
+ end
1221
+
1222
+ if overflowing_pages.size > 8
1223
+ 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."
1224
+ end
1225
+ end
1226
+
1227
+ puts "\n" + ("─" * 80) + "\n"
1228
+ end
1229
+
1230
+ def handle_headings(target, options = {})
1231
+ url_or_path = target || (Config.default_domain ? "https://#{Config.default_domain}" : nil)
1232
+ unless url_or_path
1233
+ if options[:json]
1234
+ puts JSON.pretty_generate({ error: 'Target URL or file required. Example: gsc headings https://mysite.com' })
1235
+ else
1236
+ puts Color.c("❌ Error: Target URL or file required. Example: gsc headings https://mysite.com", Color::RED, Color::BOLD)
1237
+ end
1238
+ return
1239
+ end
1240
+ puts Base::BANNER unless options[:json] || options[:in_dashboard]
1241
+
1242
+ analyzer = PageAnalyzer.new(url_or_path)
1243
+ puts "🔍 Validating Heading Hierarchy (H1-H6) for: #{Color.c(url_or_path, Color::CYAN)}...\n" unless options[:json]
1244
+
1245
+ data = analyzer.fetch_and_analyze
1246
+ h = data[:headings]
1247
+
1248
+ if options[:json]
1249
+ puts JSON.pretty_generate({
1250
+ url: data[:url],
1251
+ http_status: data[:http_status],
1252
+ score: h[:score],
1253
+ grade: h[:grade],
1254
+ depth: h[:depth],
1255
+ total_headings: h[:count],
1256
+ h1_count: h[:h1_count],
1257
+ counts: h[:counts],
1258
+ empty_count: h[:empty_count],
1259
+ violations: h[:violations],
1260
+ keyword_analysis: h[:keyword_analysis],
1261
+ headings: h[:list],
1262
+ ascii_tree: h[:ascii_tree]
1263
+ })
1264
+ return
1265
+ end
1266
+
1267
+ status_color = data[:http_status] == 200 ? Color::GREEN : Color::YELLOW
1268
+ score_color = (h[:score] || 0) >= 80 ? Color::GREEN : ((h[:score] || 0) >= 60 ? Color::YELLOW : Color::RED)
1269
+
1270
+ puts "📍 URL: #{Color.c(data[:url], Color::CYAN, Color::BOLD)}"
1271
+ puts "⚡ HTTP Status: #{Color.c(data[:http_status].to_s, status_color, Color::BOLD)} (#{data[:response_time_ms]}ms)"
1272
+ puts "🏆 Heading Health: #{Color.c("#{h[:score]}/100 (Grade #{h[:grade]})", score_color, Color::BOLD)}"
1273
+ puts "📊 Total Headings: #{h[:count]} (Max Depth: H#{h[:depth]})"
1274
+
1275
+ counts_summary = (h[:counts] || {}).select { |_k, v| v > 0 }.map { |k, v| "#{v}x #{k.upcase}" }.join(' | ')
1276
+ puts "🔢 Tag Distribution: #{counts_summary.empty? ? 'None' : counts_summary}"
1277
+ puts "─" * 80
1278
+
1279
+ puts "\n#{Color::BOLD}🌲 HEADING HIERARCHY TREE:#{Color::RESET}"
1280
+ if h[:color_tree] && !h[:color_tree].strip.empty?
1281
+ puts h[:color_tree]
1282
+ else
1283
+ puts " (No headings found on page)"
1284
+ end
1285
+
1286
+ if h[:violations] && !h[:violations].empty?
1287
+ puts "\n#{Color::BOLD}⚠️ SEMANTIC HIERARCHY VIOLATIONS (#{h[:violations].size} detected):#{Color::RESET}"
1288
+ h[:violations].each_with_index do |v, idx|
1289
+ badge = case v[:severity]
1290
+ when :critical then Color.c("[CRITICAL]", Color::RED, Color::BOLD)
1291
+ when :warning then Color.c("[WARNING]", Color::YELLOW, Color::BOLD)
1292
+ else Color.c("[INFO]", Color::GRAY)
1293
+ end
1294
+ puts " #{idx + 1}. #{badge} #{v[:message]}"
1295
+ end
1296
+ else
1297
+ puts "\n#{Color.c('✅ 100% Semantic Heading Structure Compliance (No hierarchy violations found)', Color::GREEN, Color::BOLD)}"
1298
+ end
1299
+
1300
+ if options[:keyword] && h[:keyword_analysis]
1301
+ ka = h[:keyword_analysis]
1302
+ puts "\n#{Color::BOLD}🎯 KEYWORD HEADING RELEVANCE (#{options[:keyword]}):#{Color::RESET}"
1303
+ h1_status = ka[:h1_has_keyword] ? Color.c("Found in H1 ✅", Color::GREEN) : Color.c("Missing from H1 ❌", Color::YELLOW)
1304
+ puts " H1 Optimization: #{h1_status}"
1305
+ puts " H2 Occurrences: #{ka[:h2_matched_count]} H2 sections contain target keyword"
1306
+ end
1307
+
1308
+ puts "\n" + ("─" * 80) + "\n"
1309
+ end
1310
+
1311
+ def handle_page(target, site_url, api, options = {})
1312
+ url_or_path = target || (Config.default_domain ? "https://#{Config.default_domain}" : nil)
1313
+ unless url_or_path
1314
+ if options[:json]
1315
+ puts JSON.pretty_generate({ error: 'Target URL required. Example: gsc page https://mysite.com' })
1316
+ else
1317
+ puts Color.c("❌ Error: Target URL required. Example: gsc page https://mysite.com", Color::RED, Color::BOLD)
1318
+ end
1319
+ return
1320
+ end
1321
+ puts Base::BANNER unless options[:json] || options[:in_dashboard]
1322
+
1323
+ analyzer = PageAnalyzer.new(url_or_path)
1324
+ puts "🔍 Auditing On-Page DOM & GSC Performance for: #{Color.c(url_or_path, Color::CYAN)}...\n" unless options[:json]
1325
+
1326
+ data = analyzer.fetch_and_analyze(
1327
+ check_links: options[:check_links] || false,
1328
+ gsc_api: api,
1329
+ active_domain: site_url
1330
+ )
1331
+
1332
+ if options[:json]
1333
+ puts JSON.pretty_generate(data)
1334
+ return
1335
+ end
1336
+
1337
+ status_color = data[:http_status] == 200 ? Color::GREEN : Color::YELLOW
1338
+ puts "📍 URL: #{Color.c(data[:url], Color::CYAN, Color::BOLD)}"
1339
+ puts "⚡ HTTP Status: #{Color.c(data[:http_status].to_s, status_color, Color::BOLD)} (#{data[:response_time_ms]}ms) | Indexability: #{Color.c(data[:indexability][:status], data[:indexability][:status] == 'INDEXABLE' ? Color::GREEN : Color::RED, Color::BOLD)}"
1340
+ puts "─" * 80
1341
+
1342
+ title = data[:title]
1343
+ t_color = title[:ok] ? Color::GREEN : Color::YELLOW
1344
+ puts "\n#{Color::BOLD}📑 ON-PAGE METADATA (Detailed SEO Engine):#{Color::RESET}"
1345
+ puts " Title: #{Color.c(title[:text].empty? ? '(Missing)' : title[:text], Color::BOLD)} [#{Color.c("#{title[:length]} chars / ~#{title[:pixel_est]}px", t_color)}]"
1346
+ meta = data[:meta_description]
1347
+ m_color = meta[:ok] ? Color::GREEN : Color::YELLOW
1348
+ puts " Meta Desc: #{meta[:text].empty? ? Color.c('(Missing)', Color::YELLOW) : meta[:text]} [#{Color.c("#{meta[:length]} chars", m_color)}]"
1349
+ canon = data[:canonical]
1350
+ canon_str = canon[:url] ? (canon[:self_referencing] ? "#{canon[:url]} (Self-Referencing ✅)" : "#{canon[:url]} (Canonicalised ⚠️)") : '(None)'
1351
+ puts " Canonical: #{canon_str}"
1352
+ puts " Word Count: #{data[:stats][:word_count]} words (~#{data[:stats][:reading_time_mins]} min read)"
1353
+
1354
+ h = data[:headings]
1355
+ h_badge = h[:h1_count] == 1 ? Color.c("1x H1 ✅", Color::GREEN) : Color.c("#{h[:h1_count]}x H1 ⚠️", Color::RED)
1356
+ score_badge = h[:score] ? " | Health Score: #{Color.c("#{h[:score]}/100", (h[:score] >= 80 ? Color::GREEN : Color::YELLOW), Color::BOLD)} (Grade #{h[:grade]})" : ""
1357
+ puts "\n#{Color::BOLD}🏷️ HEADINGS HIERARCHY (#{h[:count]} total, #{h_badge}#{score_badge}):#{Color::RESET}"
1358
+ if h[:color_tree] && !h[:color_tree].strip.empty?
1359
+ puts h[:color_tree]
1360
+ else
1361
+ h[:list].first(8).each do |heading|
1362
+ indent = " " + (" " * (heading[:tag][1].to_i - 1))
1363
+ tag_badge = Color.c("[#{heading[:tag].upcase}]", Color::MAGENTA)
1364
+ puts "#{indent}#{tag_badge} #{heading[:text]}"
1365
+ end
1366
+ puts " ... and #{h[:count] - 8} more headings" if h[:count] > 8
1367
+ end
1368
+
1369
+ img = data[:images]
1370
+ img_color = img[:missing_alt_count] == 0 ? Color::GREEN : Color::YELLOW
1371
+ puts "\n#{Color::BOLD}🖼️ IMAGES & LINKS:#{Color::RESET}"
1372
+ puts " Images: #{img[:total]} total (#{Color.c("#{img[:missing_alt_count]} missing alt tags", img_color)})"
1373
+ if img[:missing_alt_count] > 0
1374
+ img[:missing_alt_images].first(3).each do |i|
1375
+ puts " ⚠️ Missing Alt: #{Color.c(i[:src].to_s[0..60], Color::GRAY)}"
1376
+ end
1377
+ end
1378
+ lnk = data[:links]
1379
+ puts " Links: #{lnk[:total]} total (#{lnk[:internal_count]} Internal, #{lnk[:external_count]} External, #{lnk[:nofollow_count]} Nofollow)"
1380
+ if data.dig(:links, :verification)
1381
+ broken = data[:links][:verification].reject { |l| l[:ok] }
1382
+ if broken.empty?
1383
+ puts " Link Health: #{Color.c('All verified links OK (HTTP 200) ✅', Color::GREEN)}"
1384
+ else
1385
+ puts " Broken Links: #{Color.c("#{broken.size} dead links found 🔴", Color::RED, Color::BOLD)}"
1386
+ broken.each do |b|
1387
+ puts " ❌ [HTTP #{b[:status]}] #{b[:href]} (Anchor: #{b[:anchor]})"
1388
+ end
1389
+ end
1390
+ elsif !options[:check_links]
1391
+ puts " Link Health: #{Color.c('(Pass --check-links to test HTTP status of every link)', Color::GRAY)}"
1392
+ end
1393
+
1394
+ s_list = data[:schema]
1395
+ puts "\n#{Color::BOLD}💎 STRUCTURED DATA (Schema.org JSON-LD):#{Color::RESET}"
1396
+ if s_list.empty?
1397
+ puts " #{Color.c('No JSON-LD schema found on this page.', Color::YELLOW)}"
1398
+ else
1399
+ types = s_list.flat_map { |s| s[:types] }.compact.uniq
1400
+ puts " Detected Schemas: #{Color.c(types.join(', '), Color::GREEN, Color::BOLD)}"
1401
+ end
1402
+
1403
+ if data[:gsc]
1404
+ g = data[:gsc]
1405
+ puts "\n#{Color::BOLD}📊 GOOGLE SEARCH CONSOLE PERFORMANCE (Past 90 Days):#{Color::RESET}"
1406
+ puts " Total Clicks: #{Color.c(g[:total_clicks].to_s, Color::CYAN, Color::BOLD)}"
1407
+ puts " Total Impressions: #{Color.c(g[:total_impressions].to_s, Color::CYAN, Color::BOLD)}"
1408
+ puts " Average CTR: #{Color.c("#{g[:ctr]}%", Color::CYAN, Color::BOLD)}"
1409
+ puts " Average Position: #{Color.c(g[:avg_position].to_s, Color::CYAN, Color::BOLD)}"
1410
+ if g[:top_queries] && !g[:top_queries].empty?
1411
+ puts " Top Ranking Queries:"
1412
+ g[:top_queries].each do |q|
1413
+ puts " • #{Color.c(q[:query], Color::YELLOW)} (Pos #{q[:position]}, #{q[:impressions]} imp, #{q[:clicks]} clicks)"
1414
+ end
1415
+ end
1416
+ end
1417
+
1418
+ puts "\n" + ("─" * 80)
1419
+ if data[:issues].empty?
1420
+ puts Color.c("✅ EXCELLENT! No SEO defects or hierarchy flaws detected.", Color::GREEN, Color::BOLD)
1421
+ else
1422
+ puts "#{Color::BOLD}⚠️ ACTIONABLE ISSUES FOUND (#{data[:issues].size}):#{Color::RESET}"
1423
+ data[:issues].each do |issue|
1424
+ badge = issue[:level] == :error || issue[:level] == :critical ? Color.c("[ERROR]", Color::RED, Color::BOLD) : Color.c("[WARN]", Color::YELLOW, Color::BOLD)
1425
+ puts " #{badge} #{issue[:message]}"
1426
+ end
1427
+ end
1428
+ puts ("─" * 80) + "\n"
1429
+ end
1430
+
1431
+ def handle_site_crawl(target, site_url, api, options = {})
1432
+ sitemap_target = target || (Config.default_domain ? "https://#{Config.default_domain}/sitemap.xml" : nil)
1433
+ unless sitemap_target
1434
+ if options[:json]
1435
+ puts JSON.pretty_generate({ error: 'Target sitemap XML required. Example: gsc crawl https://mysite.com/sitemap.xml' })
1436
+ else
1437
+ puts Color.c("❌ Error: Target sitemap XML required. Example: gsc crawl https://mysite.com/sitemap.xml", Color::RED, Color::BOLD)
1438
+ end
1439
+ return
1440
+ end
1441
+ puts Base::BANNER unless options[:json] || options[:in_dashboard]
1442
+ puts "🕷️ Starting Comprehensive Site Audit & Broken Link Crawler..." unless options[:json]
1443
+ puts " Target: #{Color.c(sitemap_target, Color::CYAN)}" unless options[:json]
1444
+ conc = options[:concurrency] || 5
1445
+ puts " Concurrency: #{Color.c("#{conc} worker threads", Color::GREEN)}" unless options[:json]
1446
+ puts " Link Verification: #{options[:check_links] ? Color.c('Enabled (testing all links)', Color::GREEN) : Color.c('Disabled (use --check-links to verify)', Color::GRAY)}" unless options[:json]
1447
+ puts "" unless options[:json]
1448
+
1449
+ crawler = SiteCrawler.new(sitemap_target, options.merge(gsc_api: api, active_domain: site_url))
1450
+
1451
+ summary = crawler.run do |url, current, total|
1452
+ unless options[:json]
1453
+ print "\r\e[K⏳ [#{current}/#{total}] Crawling & auditing: #{Color.c(url[0..60], Color::CYAN)}"
1454
+ $stdout.flush
1455
+ end
1456
+ end
1457
+
1458
+ puts "\n" unless options[:json]
1459
+
1460
+ if options[:report]
1461
+ report_file = crawler.generate_markdown_report(options[:report])
1462
+ puts Color.c("📝 Exported Comprehensive Markdown Audit Report to: #{report_file}\n", Color::GREEN, Color::BOLD) unless options[:json]
1463
+ end
1464
+
1465
+ if options[:json]
1466
+ puts JSON.pretty_generate({
1467
+ summary: summary,
1468
+ broken_links: crawler.broken_links,
1469
+ missing_alts: crawler.missing_alts,
1470
+ heading_issues: crawler.heading_issues,
1471
+ title_issues: crawler.title_issues,
1472
+ canonical_issues: crawler.canonical_issues,
1473
+ results: crawler.results
1474
+ })
1475
+ return
1476
+ end
1477
+
1478
+ puts ("═" * 80)
1479
+ puts "#{Color::BOLD}📊 SITE AUDIT SUMMARY REPORT#{Color::RESET}"
1480
+ puts ("─" * 80)
1481
+ puts " Pages Audited: #{Color.c(summary[:total_pages].to_s, Color::BOLD)}"
1482
+ puts " Total Issues Detected: #{Color.c(summary[:total_issues].to_s, summary[:total_issues] == 0 ? Color::GREEN : Color::YELLOW, Color::BOLD)}"
1483
+ puts " Broken Links (404/500): #{Color.c(summary[:broken_links_count].to_s, summary[:broken_links_count] == 0 ? Color::GREEN : Color::RED, Color::BOLD)}"
1484
+ puts " Images Missing Alt: #{Color.c(summary[:missing_alts_count].to_s, summary[:missing_alts_count] == 0 ? Color::GREEN : Color::YELLOW, Color::BOLD)}"
1485
+ puts " H1 Heading Flaws: #{Color.c(summary[:heading_issues_count].to_s, summary[:heading_issues_count] == 0 ? Color::GREEN : Color::YELLOW, Color::BOLD)}"
1486
+ puts " Title/Meta Flaws: #{Color.c(summary[:title_meta_issues_count].to_s, summary[:title_meta_issues_count] == 0 ? Color::GREEN : Color::YELLOW, Color::BOLD)}"
1487
+ puts ("═" * 80)
1488
+
1489
+ if summary[:broken_links_count] > 0
1490
+ puts "\n#{Color::BOLD}🚨 BROKEN LINKS DETECTED:#{Color::RESET}"
1491
+ crawler.broken_links.first(10).each do |b|
1492
+ puts " ❌ [HTTP #{b[:status]}] #{b[:href]}"
1493
+ puts " Found on: #{Color.c(b[:source_page], Color::GRAY)}"
1494
+ end
1495
+ end
1496
+
1497
+ if summary[:missing_alts_count] > 0
1498
+ puts "\n#{Color::BOLD}🖼️ TOP IMAGES MISSING ALT ATTRIBUTES:#{Color::RESET}"
1499
+ crawler.missing_alts.first(5).each do |img|
1500
+ puts " ⚠️ #{Color.c(img[:src], Color::YELLOW)} (on #{img[:page_url]})"
1501
+ end
1502
+ end
1503
+
1504
+ puts "\n💡 Run with #{Color.c('--report docs/seo/site_audit.md', Color::CYAN)} to generate full AI-actionable Markdown report." unless options[:report]
1505
+ puts ""
1506
+ end
1507
+
1508
+ def run_comprehensive_audit(api, site_url, hostname, https_origin, options)
1509
+ days = options[:days] || 30
1510
+ puts "🔍 Running 360° Comprehensive SEO & Growth Audit for #{Color.c(hostname, Color::CYAN)} (Past #{days} days)...\n" unless options[:json]
1511
+
1512
+ sites_res = api.list_sites
1513
+ access_ok = false
1514
+ if sites_res[:ok]
1515
+ entries = sites_res.dig(:data, 'siteEntry') || []
1516
+ access_ok = entries.any? { |s| s['siteUrl'] == site_url || s['siteUrl'].include?(hostname) }
1517
+ end
1518
+
1519
+ perf_res = api.query_analytics(site_url, days: days, dimensions: [])
1520
+ perf_row = perf_res.dig(:data, 'rows', 0) || { 'clicks' => 0, 'impressions' => 0, 'ctr' => 0, 'position' => 0 }
1521
+ total_clicks = perf_row['clicks'] || 0
1522
+ total_imp = perf_row['impressions'] || 0
1523
+ avg_ctr = ((perf_row['ctr'] || 0) * 100).round(2)
1524
+ avg_pos = (perf_row['position'] || 0).round(1)
1525
+
1526
+ home_res = api.inspect_url("#{https_origin}/", site_url)
1527
+ idx_res = home_res.dig(:data, 'inspectionResult', 'indexStatusResult') || {}
1528
+ verdict = idx_res['verdict'] || 'UNKNOWN'
1529
+ coverage = idx_res['coverageState'] || 'Unknown'
1530
+ google_canonical = idx_res['googleCanonical'] || "#{https_origin}/"
1531
+ user_canonical = idx_res['userCanonical'] || "#{https_origin}/"
1532
+ crawled_as = idx_res['crawlingUserAgent'] || idx_res['crawledAs'] || 'Googlebot Mobile'
1533
+ last_crawl_raw = idx_res['lastCrawlTime']
1534
+ last_crawl = last_crawl_raw ? last_crawl_raw.split('T').first : 'Unknown'
1535
+ canonical_match = google_canonical.chomp('/') == user_canonical.chomp('/') || google_canonical.include?(hostname)
1536
+
1537
+ sm_res = api.list_sitemaps(site_url)
1538
+ sitemaps = sm_res[:ok] ? (sm_res.dig(:data, 'sitemap') || []) : []
1539
+ sm_errors = sitemaps.sum { |s| (s['errors'] || 0).to_i }
1540
+ sm_warnings = sitemaps.sum { |s| (s['warnings'] || 0).to_i }
1541
+
1542
+ qp_res = api.query_analytics(site_url, days: days, dimensions: %w[query page], row_limit: 500)
1543
+ qp_rows = qp_res[:ok] ? (qp_res.dig(:data, 'rows') || []) : []
1544
+
1545
+ opportunities = qp_rows.select { |r| r['position'] >= 7.0 && r['position'] <= 20.0 && r['impressions'] >= 5 }
1546
+ .map do |r|
1547
+ c = r['clicks'] || 0
1548
+ i = r['impressions'] || 0
1549
+ pos = r['position'].round(1)
1550
+ potential_clicks = [((i * 0.12) - c).round, 1].max
1551
+ score = (potential_clicks * (1.0 / [pos - 2.0, 1.0].max) * 10.0).round(1)
1552
+ { query: r['keys'][0], page: r['keys'][1], position: pos, impressions: i, clicks: c, potential_gain: potential_clicks, score: score }
1553
+ end.sort_by { |o| -o[:score] }.first(5)
1554
+
1555
+ underperformers = qp_rows.select { |r| r['position'] <= 10.0 && r['impressions'] >= 15 }
1556
+ .map do |r|
1557
+ pos_val = r['position'].round(1)
1558
+ actual = ((r['ctr'] || 0) * 100).round(2)
1559
+ exp = Base.expected_ctr_for(pos_val)
1560
+ gap = (exp - actual).round(2)
1561
+ lost = [((r['impressions'] * (gap / 100.0))).round, 1].max
1562
+ { query: r['keys'][0], page: r['keys'][1], position: pos_val, impressions: r['impressions'], clicks: r['clicks'], actual_ctr: actual, expected_ctr: exp, gap: gap, lost_clicks: lost }
1563
+ end.select { |u| u[:gap] >= 2.0 && u[:actual_ctr] < (u[:expected_ctr] * 0.70) }
1564
+ .sort_by { |u| -u[:lost_clicks] }.first(5)
1565
+
1566
+ by_query = qp_rows.group_by { |r| r['keys'][0] }
1567
+ cannibalized = by_query.select { |_q, list| list.map { |r| r['keys'][1] }.uniq.size > 1 }
1568
+ .map do |q, list|
1569
+ pages = list.map do |r|
1570
+ { page: r['keys'][1], impressions: r['impressions'], clicks: r['clicks'], position: r['position'].round(1) }
1571
+ end.sort_by { |p| -p[:impressions] }
1572
+ total_q_imp = pages.sum { |p| p[:impressions] }
1573
+ { query: q, total_impressions: total_q_imp, pages: pages }
1574
+ end.sort_by { |c| -c[:total_impressions] }.first(3)
1575
+
1576
+ dev_res = api.query_analytics(site_url, days: days, dimensions: ['device'])
1577
+ devices_data = (dev_res[:ok] ? (dev_res.dig(:data, 'rows') || []) : []).map do |r|
1578
+ c = r['clicks'] || 0
1579
+ i = r['impressions'] || 0
1580
+ ctr = ((r['ctr'] || 0) * 100).round(2)
1581
+ pos = (r['position'] || 0).round(1)
1582
+ share = total_clicks > 0 ? "#{(c.to_f / total_clicks * 100).round(1)}%" : "-"
1583
+ { device: r['keys'].first, clicks: c, impressions: i, ctr: ctr, position: pos, share: share }
1584
+ end.sort_by { |r| -r[:clicks] }
1585
+
1586
+ desk_dev = devices_data.find { |d| d[:device] == 'DESKTOP' }
1587
+ mob_dev = devices_data.find { |d| d[:device] == 'MOBILE' }
1588
+ mobile_deficit = desk_dev && mob_dev && desk_dev[:ctr] >= 1.0 && mob_dev[:ctr] < (desk_dev[:ctr] * 0.6)
1589
+
1590
+ snip_res = api.query_analytics(site_url, days: days, dimensions: ['searchAppearance'], row_limit: 10)
1591
+ snippets_data = (snip_res[:ok] ? (snip_res.dig(:data, 'rows') || []) : []).map do |r|
1592
+ { type: r['keys'].first, clicks: r['clicks'] || 0, impressions: r['impressions'] || 0, ctr: ((r['ctr'] || 0) * 100).round(2), position: (r['position'] || 0).round(1) }
1593
+ end
1594
+
1595
+ ga4_prop = options[:property] || Config.ga4_property_id(hostname)
1596
+ ga4_data = nil
1597
+ high_bounce_pages = []
1598
+ if ga4_prop
1599
+ ga4_res = api.query_ga4_report(ga4_prop, days: days, limit: 50, hostname: (options[:all_hosts] ? nil : hostname), site_only: options[:site_only])
1600
+ if ga4_res[:ok]
1601
+ raw_ga4 = ga4_res.dig(:data, 'rows') || []
1602
+ tot_sess = raw_ga4.sum { |r| r.dig('metricValues', 0, 'value').to_i }
1603
+ tot_bounce = raw_ga4.sum { |r| r.dig('metricValues', 3, 'value').to_f * r.dig('metricValues', 0, 'value').to_i }
1604
+ overall_bounce = tot_sess.positive? ? (tot_bounce / tot_sess * 100.0).round(1) : 0.0
1605
+ tot_dur = raw_ga4.sum { |r| r.dig('metricValues', 4, 'value').to_f * r.dig('metricValues', 0, 'value').to_i }
1606
+ overall_dur = tot_sess.positive? ? (tot_dur / tot_sess).round : 0
1607
+
1608
+ high_bounce_pages = raw_ga4.map do |r|
1609
+ path = r.dig('dimensionValues', 0, 'value')
1610
+ sess = r.dig('metricValues', 0, 'value').to_i
1611
+ bounce = ((r.dig('metricValues', 3, 'value') || 0).to_f * 100).round(1)
1612
+ dur = (r.dig('metricValues', 4, 'value') || 0).to_f.round
1613
+ { path: path, sessions: sess, bounce_rate: bounce, duration: Base.format_duration(dur) }
1614
+ end.select { |p| p[:sessions] >= 10 && p[:bounce_rate] >= 80.0 }.sort_by { |p| -p[:sessions] }.first(5)
1615
+
1616
+ ga4_data = {
1617
+ linked: true,
1618
+ property_id: ga4_prop,
1619
+ sessions: tot_sess,
1620
+ bounce_rate: overall_bounce,
1621
+ avg_duration_sec: overall_dur,
1622
+ avg_duration: Base.format_duration(overall_dur),
1623
+ high_bounce_pages: high_bounce_pages
1624
+ }
1625
+ end
1626
+ end
1627
+
1628
+ score = 100
1629
+ score -= 20 if verdict != 'PASS'
1630
+ score -= 10 if sitemaps.empty?
1631
+ score -= 10 if sm_errors > 0
1632
+ score -= 10 unless canonical_match
1633
+ score -= 15 if avg_ctr < 0.5
1634
+ score -= 10 if avg_ctr >= 0.5 && avg_ctr < 1.0
1635
+ score -= 10 if avg_pos > 30.0
1636
+ score -= [underperformers.size * 5, 10].min
1637
+ score -= [cannibalized.size * 5, 10].min
1638
+ score -= 5 if mobile_deficit
1639
+ if ga4_data && ga4_data[:sessions] > 0
1640
+ score -= 15 if ga4_data[:bounce_rate] > 85.0
1641
+ score -= 10 if ga4_data[:bounce_rate] > 75.0 && ga4_data[:bounce_rate] <= 85.0
1642
+ score += 5 if ga4_data[:bounce_rate] <= 60.0
1643
+ end
1644
+ score += 5 unless snippets_data.empty?
1645
+ score = [[score, 20].max, 100].min
1646
+
1647
+ grade = case score
1648
+ when 88..100 then 'A'
1649
+ when 75...88 then 'B'
1650
+ when 60...75 then 'C'
1651
+ else 'D'
1652
+ end
1653
+
1654
+ grade_label = case grade
1655
+ when 'A' then 'EXCELLENT - DOMINATING SERPS'
1656
+ when 'B' then 'GOOD - HIGH-LEVERAGE GROWTH WINS AVAILABLE'
1657
+ when 'C' then 'NEEDS ATTENTION - CLICK BLEED & CONFLICTS DETECTED'
1658
+ else 'CRITICAL - INDEXING, CANNIBALIZATION OR RETENTION FRICTION'
1659
+ end
1660
+
1661
+ action_plan = []
1662
+
1663
+ if cannibalized.any?
1664
+ top_c = cannibalized.first
1665
+ conflicting_pages = top_c[:pages].map { |p| p[:page].sub(%r{^https?://[^/]+}, '') }
1666
+ action_plan << {
1667
+ priority: 'high',
1668
+ title: "Consolidate Keyword Cannibalization on \"#{top_c[:query]}\"",
1669
+ detail: "Search intent is split across #{top_c[:pages].size} competing URLs (#{conflicting_pages.join(', ')}). Consolidate internal links and canonical signals to #{top_c[:pages].first[:page].sub(%r{^https?://[^/]+}, '')} to unify Page 1 ranking power."
1670
+ }
1671
+ end
1672
+
1673
+ if underperformers.any?
1674
+ top_u = underperformers.first
1675
+ action_plan << {
1676
+ priority: 'high',
1677
+ title: "Recover ~#{top_u[:lost_clicks]} Lost Clicks/mo on \"#{top_u[:query]}\"",
1678
+ detail: "Currently ranking Pos #{top_u[:position]} on #{top_u[:page].sub(%r{^https?://[^/]+}, '')} with #{top_u[:actual_ctr]}% CTR (expected benchmark ~#{top_u[:expected_ctr]}%). Rewrite `<title>` and meta description with numbers or benefit hooks to double click-through."
1679
+ }
1680
+ end
1681
+
1682
+ if sm_errors > 0
1683
+ err_sm = sitemaps.find { |s| (s['errors'] || 0).to_i > 0 }
1684
+ action_plan << {
1685
+ priority: 'high',
1686
+ title: "Resolve Sitemap Errors in Search Console",
1687
+ detail: "Sitemap #{err_sm['path']} has #{err_sm['errors']} errors. Clean up 404s or resubmit current sitemap-index.xml."
1688
+ }
1689
+ end
1690
+
1691
+ if verdict != 'PASS'
1692
+ action_plan << {
1693
+ priority: 'high',
1694
+ title: "Resolve Homepage Indexation Blocker",
1695
+ detail: "Homepage Googlebot verdict is #{verdict} (#{coverage}). Inspect URL in Search Console to resolve indexing eligibility."
1696
+ }
1697
+ end
1698
+
1699
+ if opportunities.any?
1700
+ top_o = opportunities.first
1701
+ action_plan << {
1702
+ priority: 'medium',
1703
+ title: "Push Striking-Distance Keyword \"#{top_o[:query]}\" to Top 3",
1704
+ detail: "Currently Pos #{top_o[:position]} with #{top_o[:impressions]} impressions on #{top_o[:page].sub(%r{^https?://[^/]+}, '')}. Add a dedicated H2 section and clear answering copy to unlock ~+#{top_o[:potential_gain]} clicks/mo."
1705
+ }
1706
+ end
1707
+
1708
+ if high_bounce_pages.any?
1709
+ top_hb = high_bounce_pages.first
1710
+ action_plan << {
1711
+ priority: 'medium',
1712
+ title: "Address High Bounce Rate (#{top_hb[:bounce_rate]}%) on #{top_hb[:path]}",
1713
+ detail: "Page received #{top_hb[:sessions]} sessions with #{top_hb[:duration]} average time. Audit above-the-fold LCP speed and ensure page content directly satisfies searcher intent."
1714
+ }
1715
+ end
1716
+
1717
+ if mobile_deficit
1718
+ action_plan << {
1719
+ priority: 'medium',
1720
+ title: "Audit Mobile Viewport & Responsive CTR",
1721
+ detail: "Mobile CTR (#{mob_dev[:ctr]}%) is significantly trailing Desktop (#{desk_dev[:ctr]}%). Test page on real devices for mobile layout shift, font readability, or button touch targets."
1722
+ }
1723
+ end
1724
+
1725
+ if snippets_data.empty?
1726
+ action_plan << {
1727
+ priority: 'low',
1728
+ title: "Implement Structured Data (Schema.org)",
1729
+ detail: "No rich snippet enhancements active. Add Product, FAQ, or Organization JSON-LD markup to earn rich SERP real estate."
1730
+ }
1731
+ else
1732
+ snippet_labels = snippets_data.map { |s| Base.format_appearance(s[:type]) }.join(', ')
1733
+ action_plan << {
1734
+ priority: 'low',
1735
+ title: "Maintain Rich Snippets",
1736
+ detail: "#{snippet_labels} currently active. Monitor Google Rich Results Test to maintain snippet coverage."
1737
+ }
1738
+ end
1739
+
1740
+ audit_payload = {
1741
+ domain: hostname,
1742
+ siteUrl: site_url,
1743
+ healthScore: score,
1744
+ grade: grade,
1745
+ gradeLabel: grade_label,
1746
+ days: days,
1747
+ totals: {
1748
+ clicks: total_clicks,
1749
+ impressions: total_imp,
1750
+ ctr: avg_ctr,
1751
+ position: avg_pos
1752
+ },
1753
+ technical: {
1754
+ homepageVerdict: verdict,
1755
+ coverageState: coverage,
1756
+ googleCanonical: google_canonical,
1757
+ userCanonical: user_canonical,
1758
+ canonicalMatch: canonical_match,
1759
+ crawledAs: crawled_as,
1760
+ lastCrawl: last_crawl,
1761
+ sitemapsCount: sitemaps.size,
1762
+ sitemapsErrors: sm_errors,
1763
+ sitemapsWarnings: sm_warnings,
1764
+ sitemaps: sitemaps
1765
+ },
1766
+ opportunities: opportunities,
1767
+ underperformers: underperformers,
1768
+ cannibalization: cannibalized,
1769
+ devices: devices_data,
1770
+ mobileDeficit: mobile_deficit,
1771
+ snippets: snippets_data,
1772
+ ga4: ga4_data,
1773
+ actionPlan: action_plan
1774
+ }
1775
+
1776
+ if options[:json]
1777
+ puts JSON.pretty_generate(audit_payload)
1778
+ else
1779
+ print_comprehensive_audit(audit_payload)
1780
+
1781
+ if options[:csv]
1782
+ csv_rows = action_plan.map { |a| [a[:priority], a[:title], a[:detail]] }
1783
+ Base.write_csv(options[:csv], %w[Priority Title Recommendation], csv_rows)
1784
+ puts Color.c("📁 Exported prioritized action plan to #{options[:csv]}\n", Color::CYAN)
1785
+ end
1786
+ end
1787
+ end
1788
+
1789
+ def print_comprehensive_audit(d)
1790
+ score = d[:healthScore]
1791
+ score_colored = if score >= 80
1792
+ Color.c("#{score} / 100", Color::GREEN, Color::BOLD)
1793
+ elsif score >= 65
1794
+ Color.c("#{score} / 100", Color::YELLOW, Color::BOLD)
1795
+ else
1796
+ Color.c("#{score} / 100", Color::RED, Color::BOLD)
1797
+ end
1798
+
1799
+ puts "#{Color::BOLD}══════════════════════════════════════════════════════════════════════════════#{Color::RESET}"
1800
+ puts "#{Color::BOLD}🎯 OVERALL SEO HEALTH SCORE: #{score_colored} #{Color::BOLD}[GRADE #{d[:grade]} - #{d[:gradeLabel]}]#{Color::RESET}"
1801
+ puts "#{Color::BOLD}══════════════════════════════════════════════════════════════════════════════#{Color::RESET}"
1802
+
1803
+ idx_status = d[:technical][:homepageVerdict] == 'PASS' ? Color.c('✅ PASS (100% Eligible)', Color::GREEN) : Color.c("⚠️ #{d[:technical][:homepageVerdict]}", Color::YELLOW)
1804
+ ctr_status = d[:totals][:ctr] >= 1.5 ? Color.c("✅ #{d[:totals][:ctr]}% CTR (Healthy)", Color::GREEN) : Color.c("⚠️ #{d[:totals][:ctr]}% CTR (Below Benchmark)", Color::YELLOW)
1805
+ cann_status = d[:cannibalization].empty? ? Color.c('✅ 0 Conflicts', Color::GREEN) : Color.c("🔴 #{d[:cannibalization].size} Query Conflicts Detected", Color::RED, Color::BOLD)
1806
+
1807
+ bounce_status = if d[:ga4] && d[:ga4][:linked] && d[:ga4][:sessions] > 0
1808
+ b = d[:ga4][:bounce_rate]
1809
+ b <= 65.0 ? Color.c("✅ #{b}% Bounce Rate (Strong)", Color::GREEN) : (b <= 80.0 ? Color.c("🟡 #{b}% Bounce Rate (Moderate)", Color::YELLOW) : Color.c("🔴 #{b}% Bounce Rate (High)", Color::RED, Color::BOLD))
1810
+ else
1811
+ Color.c('ℹ️ GA4 Not Linked', Color::GRAY)
1812
+ end
1813
+
1814
+ puts " • Technical Indexing: #{idx_status}"
1815
+ puts " • SERP Click Efficiency: #{ctr_status}"
1816
+ puts " • SERP Cannibalization: #{cann_status}"
1817
+ puts " • On-Site Retention: #{bounce_status}"
1818
+ puts "#{Color::BOLD}══════════════════════════════════════════════════════════════════════════════#{Color::RESET}\n"
1819
+
1820
+ puts "#{Color::BOLD}📊 30-DAY PERFORMANCE OVERVIEW#{Color::RESET}"
1821
+ puts " • Organic Search Clicks: #{Color.c(Base.format_number(d[:totals][:clicks]), Color::GREEN, Color::BOLD)}"
1822
+ puts " • Total SERP Impressions: #{Color.c(Base.format_number(d[:totals][:impressions]), Color::MAGENTA, Color::BOLD)}"
1823
+ puts " • Average Search Position: #{Color.c(d[:totals][:position].to_s, Color::YELLOW, Color::BOLD)}"
1824
+ puts " • Average SERP CTR: #{Color.c("#{d[:totals][:ctr]}%", Color::CYAN, Color::BOLD)}"
1825
+ if d[:ga4] && d[:ga4][:linked] && d[:ga4][:sessions] > 0
1826
+ puts " • GA4 Total Sessions: #{Color.c(Base.format_number(d[:ga4][:sessions]), Color::MAGENTA, Color::BOLD)} [Property #{d[:ga4][:property_id]}]"
1827
+ puts " • GA4 Average Bounce Rate: #{Color.c("#{d[:ga4][:bounce_rate]}%", Color::YELLOW, Color::BOLD)}"
1828
+ puts " • GA4 Avg Session Duration: #{Color.c(d[:ga4][:avg_duration].to_s, Color::CYAN, Color::BOLD)}"
1829
+ end
1830
+ puts
1831
+
1832
+ tech = d[:technical]
1833
+ puts "#{Color::BOLD}🛠️ 1. TECHNICAL CRAWL & INDEXATION HEALTH#{Color::RESET}"
1834
+ v_color = tech[:homepageVerdict] == 'PASS' ? Color.c('✅ PASS (Indexed & Eligible for SERPs)', Color::GREEN, Color::BOLD) : Color.c("⚠️ #{tech[:homepageVerdict]}", Color::RED, Color::BOLD)
1835
+ puts " • Homepage Googlebot Verdict: #{v_color}"
1836
+ puts " • Google Index Coverage State: #{tech[:coverageState]}"
1837
+ c_match = tech[:canonicalMatch] ? Color.c("Matches declared canonical", Color::GREEN) : Color.c("⚠️ Canonical Mismatch Detected", Color::YELLOW)
1838
+ puts " • Google-Selected Canonical: #{tech[:googleCanonical]} (#{c_match})"
1839
+ puts " • Crawl User-Agent: Googlebot #{tech[:crawledAs]} (Mobile-First Indexing)"
1840
+ puts " • Last Googlebot Crawl: #{tech[:lastCrawl]}"
1841
+ sm_text = tech[:sitemapsCount] > 0 ? Color.c("#{tech[:sitemapsCount]} registered in Search Console", Color::GREEN) : Color.c("⚠️ No sitemaps found", Color::YELLOW)
1842
+ puts " • XML Sitemaps: #{sm_text}"
1843
+ if tech[:sitemapsErrors] > 0
1844
+ err_sms = tech[:sitemaps].select { |s| (s['errors'] || 0).to_i > 0 }
1845
+ err_sms.each do |sm|
1846
+ last_dl = sm['lastDownloaded'] ? sm['lastDownloaded'].split('T').first : 'Unknown'
1847
+ puts " #{Color.c("⚠️ Notice: #{sm['path']} has #{sm['errors']} error (Last downloaded: #{last_dl}).", Color::YELLOW)}"
1848
+ end
1849
+ end
1850
+ puts
1851
+
1852
+ puts "#{Color::BOLD}🎯 2. HIGH-IMPACT STRIKING-DISTANCE KEYWORDS (Page 2 ➔ Page 1 Quick Wins)#{Color::RESET}"
1853
+ if d[:opportunities].empty?
1854
+ puts Color.c(" ℹ️ No striking-distance queries found (pos 7–20 with >= 5 impressions).", Color::GRAY)
1855
+ else
1856
+ puts "Pos | Impressions | Clicks | Est. Unlock | Target Query & Landing Page"
1857
+ puts "--------------------------------------------------------------------------------"
1858
+ d[:opportunities].each do |o|
1859
+ pos_str = o[:position].to_s.ljust(5)
1860
+ imp_str = Base.format_number(o[:impressions]).ljust(11)
1861
+ c_str = Base.format_number(o[:clicks]).ljust(6)
1862
+ gain_str = "+#{Base.format_number(o[:potential_gain])}/mo".ljust(12)
1863
+ page_short = o[:page].sub(%r{^https?://[^/]+}, '')
1864
+ page_short = '/' if page_short.empty?
1865
+ puts "#{Color.c(pos_str, Color::YELLOW)} | #{imp_str} | #{c_str} | #{Color.c(gain_str, Color::GREEN, Color::BOLD)} | #{Color.c(o[:query], Color::BOLD)} ➔ #{Color.c(page_short, Color::CYAN)}"
1866
+ end
1867
+ puts "💡 #{Color::BOLD}Action:#{Color::RESET} Add an H2 subheading and dedicated answering copy on these pages to push into the Top 3."
1868
+ end
1869
+ puts
1870
+
1871
+ puts "#{Color::BOLD}⚡ 3. CTR CLICK-BLEED & TITLE HOOK DEFICITS (Top 10 Rankings)#{Color::RESET}"
1872
+ if d[:underperformers].empty?
1873
+ puts Color.c(" ✅ No critical Top 10 CTR click-bleed detected. Existing Page 1 rankings are converting at or above expected benchmarks.", Color::GREEN)
1874
+ else
1875
+ puts "Pos | Impressions | Actual CTR | Expected | Lost Clicks | Target Query & Landing Page"
1876
+ puts "----------------------------------------------------------------------------------------"
1877
+ d[:underperformers].each do |u|
1878
+ pos_str = u[:position].to_s.ljust(5)
1879
+ imp_str = Base.format_number(u[:impressions]).ljust(11)
1880
+ act_str = "#{u[:actual_ctr]}%".ljust(10)
1881
+ exp_str = "~#{u[:expected_ctr]}%".ljust(8)
1882
+ lost_str = "~#{Base.format_number(u[:lost_clicks])}/mo".ljust(12)
1883
+ page_short = u[:page].sub(%r{^https?://[^/]+}, '')
1884
+ page_short = '/' if page_short.empty?
1885
+ puts "#{Color.c(pos_str, Color::GREEN)} | #{imp_str} | #{Color.c(act_str, Color::RED)} | #{exp_str} | #{Color.c(lost_str, Color::YELLOW, Color::BOLD)} | #{Color.c(u[:query], Color::BOLD)} ➔ #{Color.c(page_short, Color::CYAN)}"
1886
+ end
1887
+ puts "💡 #{Color::BOLD}Action:#{Color::RESET} Rewrite the <title> tag and meta description with numbers, brackets, or clear benefit hooks to recover lost clicks immediately."
1888
+ end
1889
+ puts
1890
+
1891
+ puts "#{Color::BOLD}🔀 4. KEYWORD CANNIBALIZATION (Multiple URLs Splitting SERP Equity)#{Color::RESET}"
1892
+ if d[:cannibalization].empty?
1893
+ puts Color.c(" ✅ Clean URL architecture! No internal keyword cannibalization detected.", Color::GREEN)
1894
+ else
1895
+ d[:cannibalization].each do |c|
1896
+ puts " #{Color.c('⚠️ Conflict:', Color::RED, Color::BOLD)} Query #{Color.c("\"#{c[:query]}\"", Color::BOLD)} (#{Base.format_number(c[:total_impressions])} total imp) split across #{c[:pages].size} competing URLs:"
1897
+ c[:pages].each do |p|
1898
+ p_short = p[:page].sub(%r{^https?://[^/]+}, '')
1899
+ p_short = '/' if p_short.empty?
1900
+ puts " ↳ Pos #{p[:position].to_s.ljust(4)} (#{Base.format_number(p[:impressions]).rjust(4)} imp, #{p[:clicks]} clicks) ➔ #{Color.c(p_short, Color::CYAN)}"
1901
+ end
1902
+ end
1903
+ puts " 💡 #{Color::BOLD}Fix:#{Color::RESET} Consolidate canonical signals or point internal links to 1 primary URL to concentrate ranking authority."
1904
+ end
1905
+ puts
1906
+
1907
+ puts "#{Color::BOLD}📱 5. DEVICE EXPERIENCE & SERP PARITY#{Color::RESET}"
1908
+ puts "Device | Clicks | Impressions | CTR | Position | Clicks Share"
1909
+ puts "-------------------------------------------------------------------------"
1910
+ d[:devices].each do |r|
1911
+ d_name = Base.format_device(r[:device]).ljust(12)
1912
+ c_str = Base.format_number(r[:clicks]).ljust(8)
1913
+ i_str = Base.format_number(r[:impressions]).ljust(11)
1914
+ ctr_str = "#{r[:ctr]}%".ljust(6)
1915
+ pos_str = r[:position].to_s.ljust(8)
1916
+ share_str = r[:share].to_s
1917
+ puts "#{d_name} | #{c_str} | #{i_str} | #{ctr_str} | #{pos_str} | #{share_str}"
1918
+ end
1919
+ if d[:mobileDeficit]
1920
+ puts " #{Color.c('⚠️ Mobile CTR Alert: Mobile CTR is significantly lower than Desktop. Audit mobile LCP speed and small-screen snippet readability.', Color::YELLOW)}"
1921
+ end
1922
+ puts
1923
+
1924
+ if d[:ga4] && d[:ga4][:linked]
1925
+ puts "#{Color::BOLD}📈 6. ON-SITE RETENTION & HIGH-BOUNCE ALERTS (via GA4)#{Color::RESET}"
1926
+ puts " • 30-Day GA4 Sessions: #{Base.format_number(d[:ga4][:sessions])}"
1927
+ puts " • Overall Bounce Rate: #{d[:ga4][:bounce_rate]}%"
1928
+ puts " • Average Session Duration: #{d[:ga4][:avg_duration]}"
1929
+ if d[:ga4][:high_bounce_pages].empty?
1930
+ puts Color.c(" ✅ No critical high-bounce landing pages (>80% with >= 10 sessions) detected.", Color::GREEN)
1931
+ else
1932
+ puts " • Highest-Bounce Landing Pages (>= 10 sessions, >= 80% bounce):"
1933
+ d[:ga4][:high_bounce_pages].each_with_index do |hb, idx|
1934
+ puts " #{idx + 1}. #{Color.c(hb[:path].ljust(30), Color::CYAN)} ➔ #{Color.c("#{hb[:sessions]} sess", Color::BOLD)} | #{Color.c("#{hb[:bounce_rate]}% bounce", Color::RED)} | #{hb[:duration]} avg"
1935
+ end
1936
+ end
1937
+ puts
1938
+ end
1939
+
1940
+ puts "#{Color::BOLD}📋 7. PRIORITIZED ACTION PLAN (THE REAL VALUE ROADMAP)#{Color::RESET}"
1941
+ high_p = d[:actionPlan].select { |a| a[:priority] == 'high' }
1942
+ med_p = d[:actionPlan].select { |a| a[:priority] == 'medium' }
1943
+ low_p = d[:actionPlan].select { |a| a[:priority] == 'low' }
1944
+
1945
+ idx = 1
1946
+ if high_p.any?
1947
+ puts " #{Color.c('[🔴 HIGH PRIORITY - IMMEDIATE VALUE WINS]', Color::RED, Color::BOLD)}"
1948
+ high_p.each do |item|
1949
+ puts " #{idx}. #{Color::BOLD}#{item[:title]}#{Color::RESET}"
1950
+ puts " #{item[:detail]}"
1951
+ idx += 1
1952
+ end
1953
+ end
1954
+
1955
+ if med_p.any?
1956
+ puts " #{Color.c('[🟡 MEDIUM PRIORITY - HIGH-LEVERAGE GROWTH]', Color::YELLOW, Color::BOLD)}"
1957
+ med_p.each do |item|
1958
+ puts " #{idx}. #{Color::BOLD}#{item[:title]}#{Color::RESET}"
1959
+ puts " #{item[:detail]}"
1960
+ idx += 1
1961
+ end
1962
+ end
1963
+
1964
+ if low_p.any?
1965
+ puts " #{Color.c('[🟢 QUICK WIN / TECHNICAL MAINTENANCE]', Color::GREEN, Color::BOLD)}"
1966
+ low_p.each do |item|
1967
+ puts " #{idx}. #{Color::BOLD}#{item[:title]}#{Color::RESET}"
1968
+ puts " #{item[:detail]}"
1969
+ idx += 1
1970
+ end
1971
+ end
1972
+ puts
1973
+ end
1974
+ end
1975
+ end
1976
+ end