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
@@ -10,7 +10,7 @@ module GSC
10
10
  def self.handle_suggest_command(target, options)
11
11
  query = target.to_s.strip
12
12
  if query.empty?
13
- puts Color.c("āŒ Error: Query required. Example: gsc suggest \"moving boxes\"", Color::RED)
13
+ puts Color.c("āŒ Error: Query required. Example: gsc suggest \"seo audit\"", Color::RED)
14
14
  return
15
15
  end
16
16
 
@@ -52,7 +52,7 @@ module GSC
52
52
  def self.handle_questions_command(target, options)
53
53
  query = target.to_s.strip
54
54
  if query.empty?
55
- puts Color.c("āŒ Error: Query required. Example: gsc questions \"packing dishes\"", Color::RED)
55
+ puts Color.c("āŒ Error: Query required. Example: gsc questions \"seo audit\"", Color::RED)
56
56
  return
57
57
  end
58
58
 
@@ -91,7 +91,7 @@ module GSC
91
91
  domains = domains.compact
92
92
 
93
93
  if domains.empty?
94
- puts Color.c("āŒ Error: Domain required. Example: gsc authority packinglog.com", Color::RED)
94
+ puts Color.c("āŒ Error: Domain required. Example: gsc authority example.com", Color::RED)
95
95
  return
96
96
  end
97
97
 
@@ -136,8 +136,12 @@ module GSC
136
136
 
137
137
  # 4. PageSpeed Core Web Vitals
138
138
  def self.handle_speed_command(target, options)
139
- url = target || "https://#{Config.default_domain || 'example.com'}"
140
- url = "https://#{url}" unless url =~ %r{^https?://}
139
+ raw = target || (Config.default_domain ? "https://#{Config.default_domain}" : nil)
140
+ if raw.nil? || raw.empty?
141
+ puts Color.c("āŒ Error: Target URL or domain required. Example: gsc speed https://mybrand.com", Color::RED)
142
+ return
143
+ end
144
+ url = raw.to_s.start_with?('http') ? raw : "https://#{raw}"
141
145
  strategy = options[:strategy] || 'mobile'
142
146
 
143
147
  puts BANNER unless options[:json] || options[:in_dashboard]
@@ -183,6 +187,90 @@ module GSC
183
187
  puts ""
184
188
  end
185
189
 
190
+ # 4b. Core Web Vitals & GSC Performance Correlator
191
+ def self.handle_speed_correlate_command(target, options)
192
+ domain = options[:domain] || Config.default_domain
193
+ if (domain.nil? || domain.empty?) && (target.nil? || target.empty?)
194
+ puts Color.c("āŒ Error: Target URL or domain required. Example: gsc speed-correlate https://mybrand.com", Color::RED)
195
+ return
196
+ end
197
+ url = target || "https://#{domain}/"
198
+ url = "https://#{url}" unless url =~ %r{^https?://}
199
+ strategy = options[:strategy] || 'mobile'
200
+ days = (options[:days] || 28).to_i
201
+
202
+ # Attempt to get GSC API client if authenticated
203
+ api = nil
204
+ sa = GSC::Auth.find_service_account(options[:key], domain)
205
+ if sa && sa[:data]
206
+ token = GSC::Auth.fetch_access_token(sa[:data])
207
+ client = GSC::Client.new(token: token)
208
+ api = GSC::API.new(client)
209
+ end
210
+
211
+ correlator = GSC::SpeedCorrelator.new(url, api: api, domain: domain, days: days, strategy: strategy, options: options)
212
+ data = correlator.correlate
213
+
214
+ if options[:json]
215
+ puts JSON.pretty_generate(data)
216
+ return
217
+ end
218
+
219
+ puts BANNER unless options[:in_dashboard]
220
+ puts "⚔ #{Color::BOLD}CORE WEB VITALS & SEARCH CONSOLE PERFORMANCE CORRELATOR:#{Color::RESET}"
221
+ puts " Target URL: #{Color.c(url, Color::CYAN, Color::BOLD)} [Strategy: #{strategy.upcase}]"
222
+ puts "─" * 80
223
+
224
+ cwv = data[:core_web_vitals] || {}
225
+ gsc = data[:gsc_performance] || {}
226
+ diag = data[:correlation_diagnosis] || {}
227
+ proj = data[:projections] || {}
228
+
229
+ perf_score = cwv[:performance_score] || 0
230
+ score_color = perf_score >= 90 ? Color::GREEN : (perf_score >= 50 ? Color::YELLOW : Color::RED)
231
+
232
+ puts "šŸ“± #{Color::BOLD}LIGHTHOUSE PERFORMANCE:#{Color::RESET} #{Color.c("#{perf_score}/100", score_color, Color::BOLD)} [Source: #{cwv[:source]}]"
233
+ puts " • LCP (Largest Contentful Paint) : #{Color.c(cwv[:lcp_display].to_s, Color::BOLD)} [Status: #{diag[:lcp_status]}]"
234
+ puts " • CLS (Cumulative Layout Shift) : #{Color.c(cwv[:cls_display].to_s, Color::BOLD)} [Status: #{diag[:cls_status]}]"
235
+ puts " • FCP (First Contentful Paint) : #{Color.c(cwv[:fcp_display].to_s, Color::BOLD)}"
236
+ puts " • TBT (Total Blocking Time) : #{Color.c(cwv[:tbt_display].to_s, Color::BOLD)}"
237
+
238
+ puts "\nšŸ“ˆ #{Color::BOLD}SEARCH CONSOLE TRACTION (Past #{days} Days):#{Color::RESET}"
239
+ puts " • Impressions : #{Color.c(gsc[:impressions].to_s, Color::CYAN, Color::BOLD)}"
240
+ puts " • Clicks : #{Color.c(gsc[:clicks].to_s, Color::GREEN, Color::BOLD)}"
241
+ puts " • CTR : #{Color.c("#{gsc[:ctr]}%", Color::BOLD)}"
242
+ puts " • Avg Position: #{Color.c(gsc[:position].to_s, Color::BOLD)}"
243
+
244
+ puts "\n🧠 #{Color::BOLD}ALGORITHMIC CWV IMPACT DIAGNOSIS:#{Color::RESET}"
245
+ diag_color = diag[:overall_cwv_pass] ? Color::GREEN : Color::YELLOW
246
+ puts " • CWV Overall Verdict : [#{Color.c(diag[:overall_cwv_pass] ? 'PASSED' : 'NEEDS OPTIMIZATION', diag_color, Color::BOLD)}]"
247
+ puts " • Algorithmic Drag : #{Color.c(diag[:algorithmic_status], Color::BOLD)}"
248
+
249
+ puts "\nšŸš€ #{Color::BOLD}PROJECTED SEARCH IMPRESSION & TRAFFIC SURGE (Upon Reaching 'Good' CWV):#{Color::RESET}"
250
+ puts " • Potential Impression Lift : #{Color.c("+#{proj[:potential_lift_percentage]}%", Color::GREEN, Color::BOLD)} (+#{proj[:incremental_impressions_gain]} impressions)"
251
+ puts " • Projected Impressions Target: #{Color.c(proj[:projected_impressions].to_s, Color::CYAN, Color::BOLD)}"
252
+ puts " • Projected Rank Lift : #{Color.c(proj[:estimated_position_improvement].to_s, Color::GREEN, Color::BOLD)} (Pos #{proj[:current_position]} -> Pos #{proj[:projected_position]})"
253
+ puts " • Projected Monthly Click Gain: #{Color.c("+#{proj[:projected_incremental_monthly_clicks]} clicks/mo", Color::GREEN, Color::BOLD)}"
254
+
255
+ action_plan = data[:action_plan] || []
256
+ if action_plan.any?
257
+ puts "\nšŸ› ļø #{Color::BOLD}PRIORITIZED ENGINEERING FIXES FOR SPEED & RANKINGS:#{Color::RESET}"
258
+ action_plan.each_with_index do |act, idx|
259
+ puts " #{idx + 1}. [#{Color.c(act[:priority], Color::BOLD)}] [#{act[:metric]}]: #{act[:recommendation]}"
260
+ end
261
+ end
262
+
263
+ opps = cwv[:opportunities] || []
264
+ if opps.any?
265
+ puts "\nšŸ’” #{Color::BOLD}IDENTIFIED BOTTLENECK OPPORTUNITIES:#{Color::RESET}"
266
+ opps.each do |o|
267
+ puts " • #{o[:title]} (#{Color.c(o[:display] || "#{o[:savings_ms]}ms savings", Color::YELLOW)})"
268
+ end
269
+ end
270
+
271
+ puts "\n" + ("─" * 80) + "\n"
272
+ end
273
+
186
274
  # 5. Page Comparison
187
275
  def self.handle_compare_command(target, extra, options)
188
276
  url1 = target
@@ -299,13 +387,19 @@ module GSC
299
387
  puts ""
300
388
  end
301
389
 
302
- # 7. Internal Links Audit
390
+ # 7. Internal Links Audit & Orphan Page Rescue Engine
303
391
  def self.handle_internal_links_command(target, options)
304
- base_url = target || "https://#{Config.default_domain || 'example.com'}"
305
- il = GSC::InternalLinks.new(base_url, limit: options[:limit] || 50)
392
+ raw = target || (Config.default_domain ? "https://#{Config.default_domain}" : nil)
393
+ if raw.nil? || raw.empty?
394
+ puts Color.c("āŒ Error: Target URL or domain required. Example: gsc internal-links https://mybrand.com", Color::RED)
395
+ return
396
+ end
397
+ base_url = raw.to_s.start_with?('http') ? raw : "https://#{raw}"
398
+ conc = options[:concurrency] || 5
399
+ il = GSC::InternalLinks.new(base_url, limit: options[:limit] || 50, concurrency: conc)
306
400
 
307
401
  puts BANNER unless options[:json] || options[:in_dashboard]
308
- puts "šŸ•øļø Auditing Internal Links & Orphan Pages for: #{Color.c(base_url, Color::CYAN)}...\n" unless options[:json]
402
+ puts "šŸ•øļø Auditing Internal Links & Orphan Pages for: #{Color.c(base_url, Color::CYAN)} (#{conc} worker threads)...\n" unless options[:json]
309
403
 
310
404
  data = il.audit
311
405
 
@@ -314,25 +408,66 @@ module GSC
314
408
  return
315
409
  end
316
410
 
317
- puts "─" * 80
318
- puts "Pages Discovered: #{Color.c(data[:total_pages].to_s, Color::BOLD)}"
319
- puts "Orphan Pages: #{Color.c(data[:orphans].length.to_s, data[:orphans].empty? ? Color::GREEN : Color::RED, Color::BOLD)}"
320
- puts "Weakly Linked: #{Color.c(data[:weak_pages].length.to_s, Color::YELLOW, Color::BOLD)} (Only 1 incoming internal link)"
411
+ score_color = case data[:health_score]
412
+ when 85..100 then Color::GREEN
413
+ when 70..84 then Color::YELLOW
414
+ else Color::RED
415
+ end
416
+
417
+ puts "šŸ“ Domain: #{Color.c(data[:base_url], Color::CYAN, Color::BOLD)}"
418
+ puts "⚔ Crawl Performance: #{data[:total_pages]} pages mapped in #{data[:crawl_duration_s]}s"
419
+ puts "šŸ† Link Equity Score: #{Color.c("#{data[:health_score]}/100 (Grade #{data[:health_grade]})", score_color, Color::BOLD)}"
420
+ puts "🚨 True Orphans: #{Color.c(data[:orphans].length.to_s, data[:orphans].empty? ? Color::GREEN : Color::RED, Color::BOLD)} (0 incoming internal links)"
421
+ puts "āš ļø Weakly Linked: #{Color.c(data[:weak_pages].length.to_s, Color::YELLOW, Color::BOLD)} (Only 1 incoming internal link)"
422
+ puts "šŸ§— Buried Pages: #{Color.c(data[:deep_pages].length.to_s, data[:deep_pages].empty? ? Color::GREEN : Color::YELLOW, Color::BOLD)} (Click depth >= 4)"
321
423
  puts "─" * 80
322
424
 
323
- orphans = data[:orphans] || []
324
- unless orphans.empty?
325
- puts "\n#{Color::BOLD}🚨 ORPHAN PAGES (0 incoming internal links - Crawl Dead Ends):#{Color::RESET}"
326
- orphans.each do |orp|
327
- puts " • #{Color.c(orp, Color::RED)}"
425
+ orphan_details = data[:orphan_details] || []
426
+ unless orphan_details.empty?
427
+ puts "\n#{Color::BOLD}🚨 ORPHAN PAGE RESCUE PLAYBOOK (#{orphan_details.size} crawl dead-ends):#{Color::RESET}"
428
+ orphan_details.each_with_index do |orp, idx|
429
+ puts "\n #{Color::BOLD}#{idx + 1}. #{Color.c(orp[:url], Color::RED, Color::BOLD)}#{Color::RESET} [Depth: #{orp[:depth]}]"
430
+ puts " #{Color::BOLD}Rescue Prescriptions:#{Color::RESET}"
431
+ orp[:suggested_rescues].each do |rec|
432
+ puts " • #{Color.c(rec[:action], Color::CYAN)}"
433
+ end
328
434
  end
435
+ else
436
+ puts "\n#{Color.c('āœ… EXCELLENT! No orphan pages detected on site.', Color::GREEN, Color::BOLD)}"
329
437
  end
330
438
 
331
- puts "\n#{Color::BOLD}šŸ” MOST LINKED INTERNAL PAGES:#{Color::RESET}"
332
- (data[:top_linked] || []).each do |top|
333
- puts " • #{top[:url]} (#{Color.c(top[:incoming_count].to_s, Color::CYAN)} incoming links)"
439
+ weak = data[:weak_pages] || []
440
+ unless weak.empty?
441
+ puts "\n#{Color::BOLD}āš ļø WEAKLY LINKED PAGES (Vulnerable to De-indexation):#{Color::RESET}"
442
+ weak.first(5).each do |w|
443
+ puts " • #{w[:url]} (Linked only from: #{Color.c(w[:source].to_s, Color::GRAY)})"
444
+ end
445
+ puts " ... and #{weak.size - 5} more weak pages" if weak.size > 5
334
446
  end
335
- puts ""
447
+
448
+ deep = data[:deep_pages] || []
449
+ unless deep.empty?
450
+ puts "\n#{Color::BOLD}šŸ§— BURIED PAGES (Crawl Depth >= 4 clicks):#{Color::RESET}"
451
+ deep.first(5).each do |dp|
452
+ puts " • #{dp[:url]} (#{Color.c("#{dp[:depth]} clicks deep", Color::YELLOW)})"
453
+ end
454
+ end
455
+
456
+ puts "\n#{Color::BOLD}šŸ” TOP LINK EQUITY HUBS (High-Authority Internal Sources):#{Color::RESET}"
457
+ (data[:top_linked] || []).first(6).each do |top|
458
+ title_str = top[:title].to_s.empty? ? '' : " — \"#{top[:title][0..35]}...\""
459
+ puts " • #{Color.c(top[:url], Color::BOLD)}#{title_str} (#{Color.c(top[:incoming_count].to_s, Color::CYAN)} in / #{top[:outgoing_count]} out)"
460
+ end
461
+
462
+ if options[:csv]
463
+ csv_data = orphan_details.map do |o|
464
+ [o[:url], o[:depth], o[:suggested_rescues].map { |r| r[:source_url] }.join('; ')]
465
+ end
466
+ write_csv(options[:csv], %w[OrphanURL Depth RecommendedRescueSources], csv_data)
467
+ puts Color.c("\nšŸ“ Exported orphan rescue playbook to #{options[:csv]}", Color::CYAN)
468
+ end
469
+
470
+ puts "\n" + ("─" * 80) + "\n"
336
471
  end
337
472
 
338
473
  # 8. Schema Validator & Generator
@@ -351,7 +486,12 @@ module GSC
351
486
  return
352
487
  end
353
488
 
354
- url = target || "https://#{Config.default_domain || 'example.com'}"
489
+ raw = target || (Config.default_domain ? "https://#{Config.default_domain}" : nil)
490
+ if raw.nil? || raw.empty?
491
+ puts Color.c("āŒ Error: Target URL or domain required. Example: gsc schema https://mybrand.com", Color::RED)
492
+ return
493
+ end
494
+ url = raw.to_s.start_with?('http') ? raw : "https://#{raw}"
355
495
  sv = GSC::SchemaValidator.new(url)
356
496
  data = sv.audit
357
497
 
@@ -381,7 +521,12 @@ module GSC
381
521
 
382
522
  # 9. LLMS.txt & AI Search
383
523
  def self.handle_llms_command(target, extra, options)
384
- base_url = target || "https://#{Config.default_domain || 'example.com'}"
524
+ raw = target || (Config.default_domain ? "https://#{Config.default_domain}" : nil)
525
+ if raw.nil? || raw.empty?
526
+ puts Color.c("āŒ Error: Target URL or domain required. Example: gsc llms https://mybrand.com", Color::RED)
527
+ return
528
+ end
529
+ base_url = raw.to_s.start_with?('http') ? raw : "https://#{raw}"
385
530
  llms = GSC::LlmsGenerator.new(base_url)
386
531
 
387
532
  if extra == 'audit' || options[:audit]
@@ -415,10 +560,27 @@ module GSC
415
560
  end
416
561
  end
417
562
 
418
- # 10. SERP & Social Preview
563
+ # 10. SERP & Social Preview / Live SERP Feature Detector
419
564
  def self.handle_preview_command(target, options)
420
- url = target || "https://#{Config.default_domain || 'example.com'}"
421
- sp = GSC::SerpPreview.new(url)
565
+ is_url = target.to_s.start_with?('http://', 'https://')
566
+ has_title_or_url = is_url || options[:url] || options[:title] || options[:desc] || options[:description] || options[:preview] || (target.to_s =~ /[:|—]/)
567
+
568
+ if options[:live] || options[:features] || (!has_title_or_url && !target.to_s.empty?)
569
+ return handle_serp_features_command(target, options)
570
+ end
571
+
572
+ url = options[:url] || (target if is_url)
573
+ url ||= (Config.default_domain ? "https://#{Config.default_domain}" : nil)
574
+ title = options[:title] || (!is_url && !target.to_s.empty? ? target.to_s : nil)
575
+ desc = options[:desc] || options[:description]
576
+
577
+ if url.nil? && (title.nil? || title.empty?)
578
+ puts Color.c("āŒ Error: Target URL or title required for SERP preview. Example: gsc preview https://mybrand.com/page", Color::RED)
579
+ return
580
+ end
581
+ url ||= "https://#{title.to_s.downcase.gsub(/[^a-z0-9]+/, '-')}.local"
582
+
583
+ sp = GSC::SerpPreview.new(url, title: title, desc: desc)
422
584
  data = sp.generate
423
585
 
424
586
  if options[:json]
@@ -427,29 +589,241 @@ module GSC
427
589
  end
428
590
 
429
591
  puts BANNER unless options[:in_dashboard]
592
+ m = data[:metrics] || {}
593
+
430
594
  puts "šŸ–„ļø #{Color::BOLD}GOOGLE SERP PREVIEW (Desktop Viewport):#{Color::RESET}"
431
- puts "ā”Œā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”"
432
- puts "│ #{Color.c(data.dig(:desktop_serp, :breadcrumb), Color::DIM)}│"
433
- puts "│ #{Color.c(data.dig(:desktop_serp, :title).ljust(59), Color::BLUE, Color::BOLD)}│"
434
- puts "│ #{Color.c(data.dig(:desktop_serp, :snippet)[0..58].ljust(59), Color::DIM)}│"
435
- puts "ā””ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”˜"
436
- if data[:truncation_risk]
437
- puts Color.c("āš ļø Warning: Title exceeds 60 characters and may truncate with '...' on Google SERPs.", Color::YELLOW)
438
- else
439
- puts Color.c("āœ… Title length is optimal (< 60 chars / ~580px).", Color::GREEN)
440
- end
595
+ puts "ā”Œā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”"
596
+ puts "│ #{Color.c(data.dig(:desktop_serp, :breadcrumb).to_s.ljust(71), Color::DIM)}│"
597
+ puts "│ #{Color.c(data.dig(:desktop_serp, :title).to_s.ljust(71), Color::BLUE, Color::BOLD)}│"
598
+ puts "│ #{Color.c(data.dig(:desktop_serp, :snippet).to_s[0..70].ljust(71), Color::DIM)}│"
599
+ puts "ā””ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”˜"
600
+
601
+ t_status = if m[:title_truncated]
602
+ Color.c("āš ļø TRUNCATED (+#{(m[:title_pixel_est] - m[:title_desktop_limit]).round(1)}px over 580px limit)", Color::YELLOW, Color::BOLD)
603
+ else
604
+ Color.c('āœ… OPTIMAL (< 580px desktop limit)', Color::GREEN, Color::BOLD)
605
+ end
606
+
607
+ d_status = if m[:desc_truncated]
608
+ Color.c("āš ļø TRUNCATED (+#{(m[:desc_pixel_est] - m[:desc_desktop_limit]).round(1)}px over 960px limit)", Color::YELLOW, Color::BOLD)
609
+ else
610
+ Color.c('āœ… OPTIMAL (< 960px desktop limit)', Color::GREEN, Color::BOLD)
611
+ end
612
+
613
+ puts " • Title Width: #{m[:title_chars]} chars / ~#{m[:title_pixel_est]}px [#{t_status}]"
614
+ puts " • Description Width: #{m[:desc_chars]} chars / ~#{m[:desc_pixel_est]}px [#{d_status}]"
615
+
616
+ puts "\nšŸ“± #{Color::BOLD}GOOGLE SERP PREVIEW (Mobile Viewport):#{Color::RESET}"
617
+ puts "ā”Œā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”"
618
+ puts "│ #{Color.c(data.dig(:mobile_serp, :breadcrumb).to_s.ljust(71), Color::DIM)}│"
619
+ puts "│ #{Color.c(data.dig(:mobile_serp, :title).to_s.ljust(71), Color::BLUE, Color::BOLD)}│"
620
+ puts "│ #{Color.c(data.dig(:mobile_serp, :snippet).to_s[0..70].ljust(71), Color::DIM)}│"
621
+ puts "ā””ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”˜"
441
622
 
442
- puts "\nšŸ“± #{Color::BOLD}OPEN GRAPH / SOCIAL CARD PREVIEW:#{Color::RESET}"
443
623
  soc = data[:social] || {}
444
- puts " • Title : #{soc[:og_title]}"
445
- puts " • Description : #{soc[:og_description]}"
446
- puts " • Card Image : #{soc[:og_image] || '(No og:image specified)'}"
624
+ if soc[:og_title] || soc[:og_image]
625
+ puts "\n🌐 #{Color::BOLD}OPEN GRAPH / SOCIAL CARD PREVIEW:#{Color::RESET}"
626
+ puts " • Title : #{soc[:og_title]}"
627
+ puts " • Description : #{soc[:og_description]}"
628
+ puts " • Card Image : #{soc[:og_image] || '(No og:image specified)'}"
629
+ end
630
+ puts ""
631
+ end
632
+
633
+ # 10a. Live SERP Feature Detector & Zero-Click Threat Analysis
634
+ def self.handle_serp_features_command(query, options)
635
+ query_str = query.to_s.sub(/^(query|live):/, '').strip
636
+ query_str = "what is #{Config.default_domain || 'technical seo'}" if query_str.empty?
637
+
638
+ detector = GSC::SerpFeatureDetector.new(query_str, options)
639
+ data = detector.detect
640
+
641
+ if options[:json]
642
+ puts JSON.pretty_generate(data)
643
+ return
644
+ end
645
+
646
+ puts BANNER unless options[:in_dashboard]
647
+ puts "šŸ” #{Color::BOLD}LIVE SERP FEATURE & ZERO-CLICK THREAT DETECTOR:#{Color::RESET} #{Color.c(query_str, Color::CYAN, Color::BOLD)}"
648
+ puts "─" * 80
649
+
650
+ zt = data[:zero_click_threat] || {}
651
+ threat_color = case zt[:level]
652
+ when 'LOW' then Color::GREEN
653
+ when 'MODERATE' then Color::YELLOW
654
+ else Color::RED
655
+ end
656
+
657
+ puts "šŸŽÆ Search Intent: #{Color.c(data.dig(:intent, :primary).to_s, Color::BOLD)} (#{data.dig(:intent, :description)})"
658
+ puts "🚨 Zero-Click Threat: #{Color.c("#{zt[:level]} (#{zt[:score]}/100)", threat_color, Color::BOLD)} [Organic CTR Impact: #{zt[:estimated_organic_ctr_suppression]}]"
659
+ puts "─" * 80
660
+
661
+ f = data[:features] || {}
662
+ puts "\n#{Color::BOLD}šŸ“Š DETECTED SERP REAL ESTATE FEATURES:#{Color::RESET}"
663
+
664
+ # AI Overview
665
+ aio = f[:ai_overview] || {}
666
+ aio_badge = aio[:detected] ? Color.c("ACTIVE (#{aio[:probability_pct]}% prob)", Color::RED, Color::BOLD) : Color.c("Unlikely (#{aio[:probability_pct]}%)", Color::GRAY)
667
+ puts " • Google AI Overview (Gemini) : [#{aio_badge}]"
668
+ puts " └─ #{Color.c(aio[:impact], Color::DIM)}" if aio[:detected]
669
+
670
+ # Featured Snippet
671
+ fs = f[:featured_snippet] || {}
672
+ fs_badge = fs[:detected] ? Color.c("OPPORTUNITY (#{fs[:target_format].capitalize} - #{fs[:probability_pct]}%)", Color::GREEN, Color::BOLD) : Color.c("None", Color::GRAY)
673
+ puts " • Featured Snippet Box : [#{fs_badge}]"
674
+ puts " └─ Target Recipe: #{Color.c(fs[:capture_prescription], Color::CYAN)}" if fs[:detected]
675
+
676
+ # People Also Ask
677
+ paa = f[:people_also_ask] || {}
678
+ paa_badge = paa[:detected] ? Color.c("#{paa[:count]} Live Questions Extracted", Color::CYAN, Color::BOLD) : Color.c("None", Color::GRAY)
679
+ puts " • People Also Ask (PAA) : [#{paa_badge}]"
680
+ if paa[:detected] && (paa[:questions] || []).any?
681
+ paa[:questions].first(5).each do |q_text|
682
+ puts " ā“ \"#{Color.c(q_text, Color::YELLOW)}\""
683
+ end
684
+ end
685
+
686
+ # Local Pack
687
+ lp = f[:local_3_pack] || {}
688
+ lp_badge = lp[:detected] ? Color.c("DETECTED (Google Maps 3-Pack)", Color::YELLOW, Color::BOLD) : Color.c("None", Color::GRAY)
689
+ puts " • Local Maps 3-Pack : [#{lp_badge}]"
690
+
691
+ # Video Carousel
692
+ vc = f[:video_carousel] || {}
693
+ vc_badge = vc[:detected] ? Color.c("DETECTED (Video Carousel)", Color::YELLOW, Color::BOLD) : Color.c("None", Color::GRAY)
694
+ puts " • Video Carousel / YouTube : [#{vc_badge}]"
695
+
696
+ # Discussions & Forums
697
+ df = f[:discussions_and_forums] || {}
698
+ df_badge = df[:detected] ? Color.c("DETECTED (Reddit/Quora Modules)", Color::CYAN, Color::BOLD) : Color.c("None", Color::GRAY)
699
+ puts " • Discussions & Forums : [#{df_badge}]"
700
+
701
+ # Shopping Pack
702
+ sp = f[:shopping_pack] || {}
703
+ sp_badge = sp[:detected] ? Color.c("DETECTED (Shopping Product Grid)", Color::YELLOW, Color::BOLD) : Color.c("None", Color::GRAY)
704
+ puts " • Google Shopping Grid : [#{sp_badge}]"
705
+
706
+ # Playbook
707
+ playbook = data[:playbook] || []
708
+ if playbook.any?
709
+ puts "\n#{Color::BOLD}šŸ† RICH SNIPPET & CITATION CAPTURE PLAYBOOK:#{Color::RESET}"
710
+ playbook.each_with_index do |p, idx|
711
+ puts " #{idx + 1}. [#{Color.c(p[:priority], Color::BOLD)}] #{Color.c(p[:target], Color::CYAN)}:"
712
+ puts " šŸ‘‰ #{p[:action]}"
713
+ end
714
+ end
715
+
716
+ # Live SERP Sampling
717
+ serp = data[:serp_sampling] || []
718
+ if serp.any?
719
+ puts "\n#{Color::BOLD}🌐 LIVE SERP SAMPLING (Top Competitor Results):#{Color::RESET}"
720
+ serp.each do |r|
721
+ puts " #{r[:position]}. #{Color.c(r[:title], Color::BOLD)} [#{Color.c(r[:domain], Color::GRAY)}]"
722
+ puts " └─ #{Color.c(r[:url], Color::BLUE)}"
723
+ end
724
+ end
725
+
726
+ puts "\n" + ("─" * 80) + "\n"
727
+ end
728
+
729
+ # 10b. IndexNow Multi-Engine Instant Indexing (Bing, Yandex, Seznam, Naver)
730
+ def self.handle_indexnow_command(target, extra, options)
731
+ if target == 'key' || target == 'connect'
732
+ if extra && !extra.strip.empty?
733
+ key = IndexNow.set_key(extra)
734
+ puts Color.c("āœ… Successfully set IndexNow API key to: #{key}", Color::GREEN, Color::BOLD)
735
+ else
736
+ key = IndexNow.get_or_create_key
737
+ host = Config.default_domain || 'yourdomain.com'
738
+ puts "šŸ”‘ #{Color::BOLD}INDEXNOW API KEY & HOST SETUP:#{Color::RESET}"
739
+ puts " • Active Key: #{Color.c(key, Color::CYAN, Color::BOLD)}"
740
+ puts " • Host Verification: #{Color.c("https://#{host}/#{key}.txt", Color::BLUE)}"
741
+ puts " • Required Content: #{Color.c(key, Color::BOLD)}"
742
+ puts "\nšŸ’” #{Color.c('Verification Tip:', Color::YELLOW)} Create a plain text file at the root of your web server named '#{key}.txt' with '#{key}' as the content."
743
+ end
744
+ return
745
+ end
746
+
747
+ if target == 'sitemap' || target.to_s.end_with?('.xml')
748
+ sitemap_target = (target == 'sitemap') ? extra : target
749
+ handle_indexnow_sitemap_command(sitemap_target, options)
750
+ return
751
+ end
752
+
753
+ target_url = target || options[:url]
754
+ unless target_url
755
+ if Config.default_domain
756
+ target_url = "https://#{Config.default_domain}"
757
+ else
758
+ raise 'Please provide a URL to index (e.g. gsc indexnow https://example.com/new-page) or connect a default domain.'
759
+ end
760
+ end
761
+
762
+ urls = [target_url]
763
+ urls << extra if extra && extra.start_with?('http')
764
+
765
+ key = options[:key] || IndexNow.get_or_create_key
766
+ res = IndexNow.submit(urls, key: key)
767
+
768
+ if options[:json]
769
+ puts JSON.pretty_generate(res)
770
+ return
771
+ end
772
+
773
+ puts BANNER unless options[:in_dashboard]
774
+ puts "⚔ #{Color::BOLD}INDEXNOW MULTI-SEARCH ENGINE SUBMISSION:#{Color::RESET}"
775
+ puts "─" * 75
776
+ status_color = res[:success] ? Color::GREEN : Color::RED
777
+ puts "Status : #{Color.c(res[:message], status_color, Color::BOLD)}"
778
+ puts "Submitted URLs : #{Color.c(res[:submitted_urls].to_s, Color::CYAN, Color::BOLD)}"
779
+ puts "Host : #{res[:host]}"
780
+ puts "Key Location : #{res[:key_location]}"
781
+ puts "Engines Notified: #{Color.c('Microsoft Bing, Yandex, Seznam, Naver', Color::BOLD)}"
782
+ puts "─" * 75
783
+
784
+ urls.each_with_index do |u, idx|
785
+ puts " #{idx + 1}. #{Color.c(u, Color::CYAN)}"
786
+ end
787
+
788
+ unless res[:success]
789
+ puts "\n#{Color.c('āš ļø Note:', Color::YELLOW)} Ensure you have created #{res[:key_location]} with content: #{res[:key]}"
790
+ end
791
+ puts ""
792
+ end
793
+
794
+ def self.handle_indexnow_sitemap_command(target, options)
795
+ sitemap = target || options[:sitemap] || 'sitemap.xml'
796
+ key = options[:key] || IndexNow.get_or_create_key
797
+ limit = options[:limit] ? options[:limit].to_i : nil
798
+
799
+ puts BANNER unless options[:in_dashboard]
800
+ puts "šŸ“„ #{Color::BOLD}BATCH INDEXNOW SITEMAP SUBMISSION:#{Color::RESET} #{Color.c(sitemap, Color::CYAN)}"
801
+ puts "─" * 75
802
+
803
+ res = IndexNow.submit_sitemap(sitemap, key: key, limit: limit)
804
+
805
+ if options[:json]
806
+ puts JSON.pretty_generate(res)
807
+ return
808
+ end
809
+
810
+ status_color = res[:success] ? Color::GREEN : Color::RED
811
+ puts "Status : #{Color.c(res[:message], status_color, Color::BOLD)}"
812
+ puts "URLs Submitted : #{Color.c(res[:submitted_urls].to_s, Color::GREEN, Color::BOLD)}"
813
+ puts "Host : #{res[:host]}"
814
+ puts "Engines Notified: #{Color.c('Microsoft Bing, Yandex, Seznam, Naver', Color::BOLD)}"
815
+ puts "─" * 75
816
+ puts "✨ All URLs pushed to IndexNow instant crawl queue."
447
817
  puts ""
448
818
  end
449
819
 
450
820
  # 11. Network & Redirect Tracer
451
821
  def self.handle_trace_command(target, options)
452
- url = target || Config.default_domain || 'example.com'
822
+ url = target || (Config.default_domain ? "https://#{Config.default_domain}" : nil)
823
+ if url.nil? || url.empty?
824
+ puts Color.c("āŒ Error: Target URL or domain required. Example: gsc trace https://mybrand.com", Color::RED)
825
+ return
826
+ end
453
827
  nt = GSC::NetworkTracer.new(url)
454
828
  data = nt.trace
455
829
 
@@ -476,7 +850,12 @@ module GSC
476
850
 
477
851
  # 12. Robots.txt Checker
478
852
  def self.handle_robots_command(target, extra, options)
479
- url = target || "https://#{Config.default_domain || 'example.com'}"
853
+ raw = target || (Config.default_domain ? "https://#{Config.default_domain}" : nil)
854
+ if raw.nil? || raw.empty?
855
+ puts Color.c("āŒ Error: Target URL or domain required. Example: gsc robots https://mybrand.com", Color::RED)
856
+ return
857
+ end
858
+ url = raw.to_s.start_with?('http') ? raw : "https://#{raw}"
480
859
  path = extra || '/'
481
860
  bot = options[:bot] || 'googlebot'
482
861
 
@@ -505,7 +884,11 @@ module GSC
505
884
 
506
885
  # 13. Backlinks & GSC Links Ingestion
507
886
  def self.handle_backlinks_command(target, extra, options)
508
- domain = target || Config.default_domain || 'example.com'
887
+ domain = (target unless target == 'import') || Config.default_domain
888
+ if domain.nil? || domain.empty?
889
+ puts Color.c("āŒ Error: Target domain required. Example: gsc backlinks mybrand.com", Color::RED)
890
+ return
891
+ end
509
892
  bm = GSC::BacklinksManager.new(domain)
510
893
 
511
894
  if target == 'import' || extra == 'import'
@@ -566,5 +949,565 @@ module GSC
566
949
  end
567
950
  puts ""
568
951
  end
952
+
953
+ # 14. Generative Engine Optimization (GEO / AEO) Citability Auditor
954
+ def self.handle_geo_command(target, options)
955
+ raw = target || (Config.default_domain ? "https://#{Config.default_domain}" : nil)
956
+ if raw.nil? || raw.empty?
957
+ puts Color.c("āŒ Error: Target URL or domain required. Example: gsc geo https://mybrand.com", Color::RED)
958
+ return
959
+ end
960
+ url = raw.to_s.start_with?('http') ? raw : "https://#{raw}"
961
+ auditor = GSC::GeoAuditor.new(url)
962
+
963
+ puts "šŸ¤– Analyzing Generative Engine Optimization (GEO) & Citability for #{Color.c(url, Color::CYAN)}...\n" unless options[:json]
964
+
965
+ data = auditor.audit
966
+ if data[:error]
967
+ if options[:json]
968
+ puts JSON.pretty_generate(data)
969
+ else
970
+ puts Color.c("āŒ Error: #{data[:message]}", Color::RED, Color::BOLD)
971
+ end
972
+ return
973
+ end
974
+
975
+ if options[:json]
976
+ puts JSON.pretty_generate(data)
977
+ return
978
+ end
979
+
980
+ puts BANNER unless options[:in_dashboard]
981
+ puts "šŸ¤– #{Color::BOLD}GENERATIVE ENGINE OPTIMIZATION (GEO / AEO) AUDIT#{Color::RESET}"
982
+ puts "šŸŽÆ Target URL : #{Color.c(data[:url], Color::CYAN)}"
983
+ puts "─" * 75
984
+
985
+ score_color = case data[:score]
986
+ when 85..100 then Color::GREEN
987
+ when 70..84 then Color::CYAN
988
+ when 50..69 then Color::YELLOW
989
+ else Color::RED
990
+ end
991
+
992
+ puts "šŸ† #{Color::BOLD}AI CITABILITY SCORE:#{Color::RESET} #{Color.c("#{data[:score]}/100", score_color, Color::BOLD)} — #{data[:grade]}"
993
+ puts " • AI Bot Access (Robots.txt) : #{data[:category_scores][:ai_bot_access][:score]}/25"
994
+ puts " • Direct Question Answers : #{data[:category_scores][:direct_answers][:score]}/25"
995
+ puts " • Factual & Data Citability : #{data[:category_scores][:facts_and_data][:score]}/25"
996
+ puts " • Entity Schema & Authority : #{data[:category_scores][:entity_schema][:score]}/25"
997
+ puts "─" * 75
998
+
999
+ puts "\nšŸ¤– #{Color::BOLD}AI SEARCH CRAWLER ACCESS (Robots.txt):#{Color::RESET}"
1000
+ data[:ai_crawlers][:bots].each do |bot, info|
1001
+ badge = info[:allowed] ? Color.c("āœ… ALLOWED", Color::GREEN) : Color.c("āŒ BLOCKED", Color::RED, Color::BOLD)
1002
+ puts " • #{bot.ljust(20)} [#{info[:provider]}]: #{badge} (#{info[:rule]})"
1003
+ end
1004
+
1005
+ puts "\nšŸ“ #{Color::BOLD}DIRECT ANSWER & CITABILITY DENSITY:#{Color::RESET}"
1006
+ puts " • Question Headings Detected : #{data[:direct_answers][:total_question_headings]}"
1007
+ puts " • Direct Answer Paragraphs : #{data[:direct_answers][:direct_answer_blocks_found]}"
1008
+ puts " • Optimal Length Answers (30-80w): #{data[:direct_answers][:optimal_answers_count]}"
1009
+
1010
+ samples = data[:direct_answers][:samples] || []
1011
+ if samples.any?
1012
+ puts " #{Color::BOLD}Sample Answer Pairs:#{Color::RESET}"
1013
+ samples.each do |s|
1014
+ puts " Q: #{Color.c(s[:question], Color::CYAN)}"
1015
+ puts " A: #{s[:answer_preview]} (#{s[:word_count]} words)"
1016
+ end
1017
+ end
1018
+
1019
+ puts "\nšŸ“Š #{Color::BOLD}FACTUAL & INFORMATION GAIN SIGNALS:#{Color::RESET}"
1020
+ puts " • Statistics & Percentages : #{data[:facts_and_citability][:percentage_mentions]}"
1021
+ puts " • Currency / Pricing Signals : #{data[:facts_and_citability][:currency_mentions]}"
1022
+ puts " • Structured Bullet Items (<li>): #{data[:facts_and_citability][:bullet_list_items]}"
1023
+ puts " • Comparison Table Rows (<tr>) : #{data[:facts_and_citability][:comparison_table_rows]}"
1024
+ puts " • Recent Year Signals (2024-26): #{data[:facts_and_citability][:recent_year_mentions]}"
1025
+
1026
+ puts "\nšŸ›ļø #{Color::BOLD}ENTITY KNOWLEDGE GRAPH & SCHEMA:#{Color::RESET}"
1027
+ puts " • Schemas Found : #{data[:entity_knowledge_graph][:schema_count]} (#{data[:entity_knowledge_graph][:schema_types].join(', ')})"
1028
+ puts " • Organization / Brand Schema : #{data[:entity_knowledge_graph][:has_organization_or_brand] ? Color.c("Yes", Color::GREEN) : Color.c("Missing", Color::YELLOW)}"
1029
+ puts " • FAQ / HowTo Rich Schema : #{data[:entity_knowledge_graph][:has_faq_or_howto] ? Color.c("Yes", Color::GREEN) : Color.c("None", Color::YELLOW)}"
1030
+ puts " • Wikidata / Wikipedia Linked : #{data[:entity_knowledge_graph][:wikidata_or_wikipedia_linked] ? Color.c("Yes", Color::GREEN) : Color.c("No", Color::YELLOW)}"
1031
+ if data[:entity_knowledge_graph][:same_as_domains].any?
1032
+ puts " • Social Authority Entity Links: #{data[:entity_knowledge_graph][:same_as_domains].join(', ')}"
1033
+ end
1034
+
1035
+ recs = data[:recommendations] || []
1036
+ if recs.any?
1037
+ puts "\nšŸ’” #{Color::BOLD}AI CITABILITY OPTIMIZATION RECOMMENDATIONS:#{Color::RESET}"
1038
+ recs.each_with_index do |r, i|
1039
+ puts " #{i + 1}. #{Color.c(r, Color::YELLOW)}"
1040
+ end
1041
+ end
1042
+ puts ""
1043
+ end
1044
+
1045
+ # 15. Knowledge Graph Entity & sameAs Disambiguation Auditor
1046
+ def self.handle_entity_command(target, options)
1047
+ raw = target || (Config.default_domain ? "https://#{Config.default_domain}" : nil)
1048
+ if raw.nil? || raw.empty?
1049
+ puts Color.c("āŒ Error: Target URL or domain required. Example: gsc entity https://mybrand.com", Color::RED)
1050
+ return
1051
+ end
1052
+ url = raw.to_s.start_with?('http') ? raw : "https://#{raw}"
1053
+
1054
+ puts "šŸ›ļø Auditing Knowledge Graph Entity & sameAs Identity for #{Color.c(url, Color::CYAN)}...\n" unless options[:json]
1055
+
1056
+ data = GSC::EntityAuditor.audit(url)
1057
+ if data[:error]
1058
+ if options[:json]
1059
+ puts JSON.pretty_generate(data)
1060
+ else
1061
+ puts Color.c("āŒ Error: #{data[:error]}", Color::RED, Color::BOLD)
1062
+ end
1063
+ return
1064
+ end
1065
+
1066
+ if options[:json]
1067
+ puts JSON.pretty_generate(data)
1068
+ return
1069
+ end
1070
+
1071
+ puts BANNER unless options[:in_dashboard]
1072
+ puts "šŸ›ļø #{Color::BOLD}KNOWLEDGE GRAPH ENTITY & sameAs DISAMBIGUATION AUDIT#{Color::RESET}"
1073
+ puts "šŸŽÆ Target URL : #{Color.c(data[:url], Color::CYAN)}"
1074
+ puts "─" * 75
1075
+
1076
+ score_color = case data[:score]
1077
+ when 85..100 then Color::GREEN
1078
+ when 70..84 then Color::CYAN
1079
+ when 50..69 then Color::YELLOW
1080
+ else Color::RED
1081
+ end
1082
+
1083
+ puts "šŸ† #{Color::BOLD}ENTITY AUTHORITY SCORE:#{Color::RESET} #{Color.c("#{data[:score]}/100", score_color, Color::BOLD)} — Grade #{data[:grade]}"
1084
+ puts "šŸ“¦ Detected Entities : #{data[:entities_found]}"
1085
+ puts "─" * 75
1086
+
1087
+ if data[:findings].any?
1088
+ puts "\nšŸ”Ž #{Color::BOLD}ENTITY SIGNALS DETECTED:#{Color::RESET}"
1089
+ data[:findings].each do |f|
1090
+ puts " • #{Color.c(f, Color::GREEN)}"
1091
+ end
1092
+ end
1093
+
1094
+ if data[:authoritative_links].any?
1095
+ puts "\n🌐 #{Color::BOLD}HIGH-AUTHORITY SAMEAS IDENTITY LINKS:#{Color::RESET}"
1096
+ data[:authoritative_links].each do |source, link|
1097
+ puts " • #{source.to_s.capitalize.ljust(15)}: #{Color.c(link, Color::CYAN)}"
1098
+ end
1099
+ end
1100
+
1101
+ if data[:recommendations].any?
1102
+ puts "\nšŸ’” #{Color::BOLD}OPTIMIZATION DIRECTIVES:#{Color::RESET}"
1103
+ data[:recommendations].each_with_index do |r, i|
1104
+ puts " #{i + 1}. #{Color.c(r, Color::YELLOW)}"
1105
+ end
1106
+ end
1107
+
1108
+ if options[:generate] || data[:score] < 70
1109
+ puts "\nšŸ“‹ #{Color::BOLD}RECOMMENDED ENHANCED JSON-LD SCHEMA SNIPPET:#{Color::RESET}"
1110
+ puts "<!-- Paste inside <head> on your homepage/about page -->"
1111
+ puts "<script type=\"application/ld+json\">"
1112
+ puts JSON.pretty_generate(data[:recommended_json_ld])
1113
+ puts "</script>"
1114
+ end
1115
+ puts ""
1116
+ end
1117
+
1118
+ # 16. Multi-Domain Agency Vault & Key Manager
1119
+ def self.handle_vault_command(subcmd, target, extra, options)
1120
+ action = subcmd.to_s.downcase
1121
+ action = 'list' if action.empty? || action == 'vault'
1122
+
1123
+ case action
1124
+ when 'status'
1125
+ stat = GSC::Vault.status
1126
+ if options[:json]
1127
+ puts JSON.pretty_generate(stat)
1128
+ else
1129
+ puts BANNER unless options[:in_dashboard]
1130
+ puts "šŸ” #{Color::BOLD}AGENCY CREDENTIAL VAULT SECURITY STATUS#{Color::RESET}"
1131
+ puts "─" * 70
1132
+ puts " • Vault Directory: #{stat[:vault_dir]}"
1133
+ puts " • Encryption Algorithm: #{Color.c(stat[:encryption_algorithm], Color::GREEN, Color::BOLD)}"
1134
+ puts " • Master Key Present: #{stat[:master_key_present] ? Color.c('Yes (Stored locally)', Color::GREEN) : Color.c('Generated on demand', Color::YELLOW)}"
1135
+ puts " • Stored Client Keys: #{Color.c(stat[:keys_stored].to_s, Color::CYAN, Color::BOLD)}"
1136
+ puts " • Domains Mapped: #{Color.c(stat[:domains_mapped].to_s, Color::CYAN)}"
1137
+ puts " • POSIX 0700 Security: #{stat[:permissions_secure] ? Color.c('ENFORCED', Color::GREEN) : Color.c('PERMISSIVE', Color::YELLOW)}"
1138
+ puts " • Active Target Domain: #{Color.c(stat[:active_domain] || 'None', Color::GREEN, Color::BOLD)}"
1139
+ puts "─" * 70
1140
+ puts "šŸ’” Switch active client anytime: #{Color.c('gsc switch <domain|alias|#>', Color::CYAN)}"
1141
+ puts ""
1142
+ end
1143
+
1144
+ when 'add', 'import'
1145
+ key_path = target
1146
+ unless key_path && File.file?(File.expand_path(key_path))
1147
+ puts Color.c("āŒ Error: Valid service account JSON path required. Example: gsc vault add ./client-key.json --domain client.com", Color::RED)
1148
+ return
1149
+ end
1150
+
1151
+ domain = options[:domain] || extra
1152
+ alias_name = options[:alias]
1153
+ ga4_id = options[:property]
1154
+
1155
+ begin
1156
+ entry = GSC::Vault.add_key(key_path, domain: domain, alias_name: alias_name, ga4_id: ga4_id)
1157
+ if options[:json]
1158
+ puts JSON.pretty_generate({ status: 'ok', entry: entry })
1159
+ else
1160
+ puts Color.c("āœ… Successfully encrypted and added key to Agency Vault!", Color::GREEN, Color::BOLD)
1161
+ puts " • Client Email: #{Color.c(entry['client_email'], Color::CYAN)}"
1162
+ puts " • Domain: #{Color.c(entry['domain'] || 'Not set', Color::GREEN)}"
1163
+ puts " • Alias: #{Color.c(entry['alias'] || 'Not set', Color::YELLOW)}"
1164
+ puts " • Key Vault: #{entry['key_file']} (AES-256-GCM Encrypted)"
1165
+ end
1166
+ rescue StandardError => e
1167
+ puts Color.c("āŒ Vault Error: #{e.message}", Color::RED)
1168
+ end
1169
+
1170
+ when 'remove', 'rm', 'delete'
1171
+ target_query = target
1172
+ unless target_query
1173
+ puts Color.c("āŒ Error: Specify domain or alias to remove. Example: gsc vault remove client.com", Color::RED)
1174
+ return
1175
+ end
1176
+
1177
+ ok = GSC::Vault.remove_entry(target_query)
1178
+ if ok
1179
+ puts Color.c("āœ… Removed #{target_query} from Agency Vault.", Color::GREEN)
1180
+ else
1181
+ puts Color.c("āš ļø Entry not found in Agency Vault: #{target_query}", Color::YELLOW)
1182
+ end
1183
+
1184
+ when 'list', 'ls'
1185
+ entries = GSC::Vault.list_entries
1186
+ if options[:json]
1187
+ puts JSON.pretty_generate({ vault: GSC::Vault.status, entries: entries })
1188
+ return
1189
+ end
1190
+
1191
+ puts BANNER unless options[:in_dashboard]
1192
+ puts "šŸ” #{Color::BOLD}AGENCY CREDENTIAL VAULT (AES-256-GCM Secure Store)#{Color::RESET}"
1193
+ puts "─" * 75
1194
+
1195
+ if entries.empty?
1196
+ puts Color.c("No client keys registered in Agency Vault yet.", Color::YELLOW)
1197
+ puts "To add a key: #{Color.c('gsc vault add /path/to/key.json --domain client.com --alias client', Color::CYAN)}"
1198
+ puts "─" * 75
1199
+ return
1200
+ end
1201
+
1202
+ puts "#{Color::BOLD} # | Domain | Alias | Client Account / Email#{Color::RESET}"
1203
+ puts "─" * 75
1204
+ entries.each_with_index do |e, i|
1205
+ num_str = "[#{i + 1}]".ljust(4)
1206
+ dom_str = (e['domain'] || 'unassigned').ljust(28)
1207
+ alias_str = (e['alias'] || '-').ljust(10)
1208
+ email_str = e['client_email'].to_s
1209
+ marker = e['active'] ? " #{Color.c('šŸ‘ˆ [ACTIVE]', Color::GREEN, Color::BOLD)}" : ""
1210
+
1211
+ puts "#{Color.c(num_str, Color::CYAN)}| #{dom_str} | #{Color.c(alias_str, Color::YELLOW)} | #{email_str}#{marker}"
1212
+ end
1213
+ puts "─" * 75
1214
+ puts "šŸ’” Switch active client anytime: #{Color.c('gsc switch <domain|alias|#>', Color::CYAN)}"
1215
+ puts ""
1216
+ else
1217
+ puts Color.c("Unknown vault action: #{action}. Valid actions: list, add, remove, status", Color::YELLOW)
1218
+ end
1219
+ end
1220
+
1221
+ # 18. Direct Answer & Information Gain Snippet Synthesizer
1222
+ def self.handle_answer_command(query, options = {})
1223
+ if query.nil? || query.to_s.strip.empty?
1224
+ puts Color.c("āŒ Error: Query required. Usage: gsc answer <query> [--brand <name>] [--type <definition|steps|comparison|faq>] [--generate] [--json]", Color::RED, Color::BOLD)
1225
+ return
1226
+ end
1227
+
1228
+ data = GSC::AnswerSynthesizer.synthesize(query, options)
1229
+
1230
+ if options[:json]
1231
+ puts JSON.pretty_generate(data)
1232
+ return
1233
+ end
1234
+
1235
+ puts BANNER unless options[:in_dashboard]
1236
+ puts "šŸ—£ļø #{Color::BOLD}DIRECT ANSWER & INFORMATION GAIN SNIPPET (GEO / AEO)#{Color::RESET}"
1237
+ puts "šŸŽÆ Target Query : #{Color.c("\"#{data[:query]}\"", Color::YELLOW, Color::BOLD)}"
1238
+ puts "šŸ·ļø Intent Type : [#{Color.c(data[:intent_type].upcase, Color::MAGENTA, Color::BOLD)}]"
1239
+ puts "šŸ“Š Word Count : #{data[:word_count]} words (#{Color.c("Optimal: #{data[:target_optimal_range]}", Color::GREEN)})"
1240
+ puts "šŸ† Citability : #{Color.c("#{data[:citability_score]}/100", Color::GREEN, Color::BOLD)} (Optimized for Featured Snippets, ChatGPT & Perplexity)"
1241
+ puts "─" * 75
1242
+
1243
+ puts "\n#{Color::BOLD}šŸ’¬ OPTIMIZED DIRECT ANSWER PARAGRAPH:#{Color::RESET}"
1244
+ puts " #{Color.c(data[:direct_answer], Color::BOLD)}"
1245
+
1246
+ puts "\n#{Color::BOLD}šŸ“Œ STRUCTURED ATTRIBUTES & KEY POINTS:#{Color::RESET}"
1247
+ data[:key_points].each do |kp|
1248
+ clean_kp = kp.gsub(/\*\*(.*?)\*\*/, "#{Color::BOLD}\\1#{Color::RESET}")
1249
+ puts " • #{clean_kp}"
1250
+ end
1251
+
1252
+ puts "\n#{Color::BOLD}šŸ“ˆ INFORMATION GAIN FACTOID:#{Color::RESET}"
1253
+ puts " šŸ’” #{Color.c(data[:information_gain_stat], Color::CYAN)}"
1254
+
1255
+ if options[:generate]
1256
+ puts "\n#{Color::BOLD}šŸ’» READY-TO-PASTE HTML SNIPPET:#{Color::RESET}"
1257
+ puts Color.c(data[:html_markup], Color::DIM)
1258
+
1259
+ puts "\n#{Color::BOLD}šŸ’Ž SCHEMA.ORG JSON-LD SCRIPT BLOCK:#{Color::RESET}"
1260
+ puts Color.c("<script type=\"application/ld+json\">\n#{JSON.pretty_generate(data[:schema_jsonld])}\n</script>", Color::CYAN)
1261
+ else
1262
+ puts "\nšŸ’” #{Color.c('Tip: Pass --generate to print copy-paste HTML and Schema.org JSON-LD blocks.', Color::GRAY)}"
1263
+ end
1264
+ puts "─" * 75 + "\n"
1265
+ end
1266
+
1267
+ # 19. AI Bot Robots.txt & Cloudflare Firewall Scanner
1268
+ def self.handle_firewall_command(target, options = {})
1269
+ scanner = GSC::FirewallScanner.new(target, options)
1270
+ data = scanner.scan
1271
+
1272
+ if options[:json]
1273
+ puts JSON.pretty_generate(data)
1274
+ return
1275
+ end
1276
+
1277
+ puts BANNER unless options[:in_dashboard]
1278
+ puts "šŸ›”ļø #{Color::BOLD}AI BOT ACCESSIBILITY & CLOUDFLARE/WAF FIREWALL AUDITOR#{Color::RESET}"
1279
+ puts "šŸŽÆ Target URL : #{Color.c(data[:url], Color::YELLOW, Color::BOLD)}"
1280
+ puts "🌐 Hostname : #{data[:host]}"
1281
+
1282
+ # Edge Infrastructure
1283
+ edge_names = data[:edge_infrastructure].map { |i| "#{i[:name]} (#{i[:role]})" }.join(' | ')
1284
+ puts "ā˜ļø Edge CDN/WAF : #{Color.c(edge_names, Color::CYAN)}"
1285
+
1286
+ # Score & Grade
1287
+ grade_color = case data[:grade]
1288
+ when 'A' then Color::GREEN
1289
+ when 'B' then Color::CYAN
1290
+ when 'C' then Color::YELLOW
1291
+ when 'D' then Color::RED
1292
+ else Color::RED
1293
+ end
1294
+
1295
+ score_str = "#{data[:score]}/100 [Grade #{data[:grade]}]"
1296
+ puts "šŸ† AI Access : #{Color.c(score_str, Color::BOLD, grade_color)} - #{data[:verdict]}"
1297
+ puts "─" * 80
1298
+
1299
+ # Section 1: Live HTTP Probes
1300
+ puts "\n#{Color::BOLD}šŸ“” LIVE EDGE WAF MULTI-USER-AGENT PROBES (LIVE PASS-THROUGH):#{Color::RESET}"
1301
+ puts "#{Color::BOLD}%-18s %-24s %-12s %-10s %-14s %s#{Color::RESET}" % ['Persona', 'Client Name', 'HTTP Status', 'Latency', 'WAF Verdict', 'Challenge / Block']
1302
+ puts "─" * 80
1303
+
1304
+ data[:live_probes].each do |probe|
1305
+ status_col = case probe[:status]
1306
+ when 200..299 then Color.c("#{probe[:status]} OK", Color::GREEN)
1307
+ when 300..399 then Color.c("#{probe[:status]} REDIR", Color::YELLOW)
1308
+ when 403 then Color.c("403 FORBID", Color::RED, Color::BOLD)
1309
+ when 503 then Color.c("503 UNAVAIL", Color::RED, Color::BOLD)
1310
+ else Color.c(probe[:status].to_s, Color::RED)
1311
+ end
1312
+
1313
+ verdict_col = case probe[:verdict]
1314
+ when :pass then Color.c("PASS", Color::GREEN)
1315
+ when :redirect then Color.c("REDIRECT", Color::YELLOW)
1316
+ when :challenge then Color.c("CHALLENGE", Color::RED, Color::BOLD)
1317
+ when :blocked then Color.c("BLOCKED", Color::RED, Color::BOLD)
1318
+ else Color.c("ERROR", Color::RED)
1319
+ end
1320
+
1321
+ challenges_str = probe[:challenges].empty? ? '-' : probe[:challenges].join(', ')
1322
+ challenges_col = probe[:challenges].empty? ? Color.c(challenges_str, Color::GRAY) : Color.c(challenges_str, Color::RED, Color::BOLD)
1323
+
1324
+ puts "%-18s %-24s %-21s %-10s %-23s %s" % [
1325
+ probe[:persona],
1326
+ probe[:name],
1327
+ status_col,
1328
+ "#{probe[:duration_ms]}ms",
1329
+ verdict_col,
1330
+ challenges_col
1331
+ ]
1332
+ end
1333
+
1334
+ # Section 2: Robots.txt Crawler Governance
1335
+ puts "\n#{Color::BOLD}šŸ¤– ROBOTS.TXT AI SEARCH & TRAINING CRAWLER DIRECTIVES:#{Color::RESET}"
1336
+ puts "#{Color::BOLD}%-20s %-15s %-12s %-12s %s#{Color::RESET}" % ['Bot User-Agent', 'Provider', 'Role', 'Status', 'Matched Rule / Reason']
1337
+ puts "─" * 80
1338
+
1339
+ data[:robots_txt][:bots].each do |bot_name, bot_info|
1340
+ status_col = case bot_info[:status]
1341
+ when :allowed then Color.c("ALLOWED", Color::GREEN, Color::BOLD)
1342
+ when :restricted then Color.c("RESTRICTED", Color::YELLOW)
1343
+ when :blocked then Color.c("BLOCKED", Color::RED, Color::BOLD)
1344
+ else Color.c("UNKNOWN", Color::GRAY)
1345
+ end
1346
+
1347
+ category_label = bot_info[:category].to_s.sub('_', ' ').capitalize
1348
+
1349
+ puts "%-20s %-15s %-12s %-21s %s" % [
1350
+ bot_name,
1351
+ bot_info[:provider],
1352
+ category_label,
1353
+ status_col,
1354
+ Color.c(bot_info[:reason].to_s, Color::GRAY)
1355
+ ]
1356
+ end
1357
+
1358
+ # Section 3: Blockade Diagnosis
1359
+ puts "\n#{Color::BOLD}šŸ” SILENT BOT BLOCKADE DIAGNOSIS:#{Color::RESET}"
1360
+ diag = data[:blockade_diagnosis]
1361
+ if diag[:silent_blockade_detected]
1362
+ puts Color.c(" 🚨 #{diag[:summary]}", Color::RED, Color::BOLD)
1363
+ puts " āš ļø Browser pass: #{diag[:browser_accessible]} | Googlebot pass: #{diag[:googlebot_accessible]}"
1364
+ puts " āŒ Blocked AI crawlers: #{diag[:blocked_probes].map { |p| "#{p[:name]} (#{p[:status]})" }.join(', ')}"
1365
+ else
1366
+ puts Color.c(" #{diag[:summary]}", Color::GREEN)
1367
+ end
1368
+
1369
+ # Section 4: Prescriptive WAF & Robots.txt Remediation Recipes
1370
+ recipes = data[:remediation_recipes]
1371
+ if recipes[:cloudflare_waf_rule]
1372
+ puts "\n#{Color::BOLD}šŸ› ļø RECOMMENDED CLOUDFLARE CUSTOM WAF BYPASS RULE:#{Color::RESET}"
1373
+ puts " Rule Name: #{Color.c(recipes[:cloudflare_waf_rule][:name], Color::CYAN, Color::BOLD)}"
1374
+ puts " Expression:\n #{Color.c(recipes[:cloudflare_waf_rule][:filter_expression], Color::YELLOW)}"
1375
+ puts " Action: #{Color.c('Skip', Color::GREEN, Color::BOLD)} (Skip WAF Managed Rules, Super Bot Fight Mode, Rate Limiting)"
1376
+ puts "\n #{Color::BOLD}Dashboard Deployment Steps:#{Color::RESET}"
1377
+ recipes[:cloudflare_waf_rule][:dashboard_instructions].each do |step|
1378
+ puts " #{Color.c(step, Color::GRAY)}"
1379
+ end
1380
+ end
1381
+
1382
+ if options[:generate] || diag[:silent_blockade_detected] || diag[:robots_critical_blocked].any?
1383
+ puts "\n#{Color::BOLD}šŸ“„ RECOMMENDED ROBOTS.TXT CONFIGURATION FOR AI SEARCH:#{Color::RESET}"
1384
+ puts Color.c(recipes[:recommended_robots_txt], Color::DIM)
1385
+ else
1386
+ puts "\nšŸ’” #{Color.c('Tip: Pass --generate to display copy-paste robots.txt configuration.', Color::GRAY)}"
1387
+ end
1388
+
1389
+ puts "─" * 80 + "\n"
1390
+ end
1391
+
1392
+ # 20. Title Tag Length & Pixel Width Batch Optimizer
1393
+ def self.handle_titles_command(target, options = {})
1394
+ target_site = target.to_s.strip
1395
+ target_site = Config.default_domain || '' if target_site.empty?
1396
+ if target_site.empty?
1397
+ puts Color.c("āŒ Error: Target domain or sitemap required. Example: gsc titles mybrand.com", Color::RED)
1398
+ return
1399
+ end
1400
+
1401
+ optimizer = GSC::TitleOptimizer.new(target_site, options)
1402
+
1403
+ unless options[:json] || options[:csv]
1404
+ puts BANNER unless options[:in_dashboard]
1405
+ puts "šŸ“ #{Color::BOLD}GOOGLE SERP TITLE LENGTH & PIXEL OVERFLOW AUDITOR#{Color::RESET}"
1406
+ puts "šŸŽÆ Target: #{Color.c(target_site, Color::YELLOW, Color::BOLD)}"
1407
+ print "šŸ”„ Crawling and calculating pixel metrics across pages... "
1408
+ $stdout.flush
1409
+ end
1410
+
1411
+ data = optimizer.audit do |_url, current, total|
1412
+ unless options[:json] || options[:csv]
1413
+ print "\ršŸ”„ Crawling and calculating pixel metrics across pages... [#{current}/#{total}]"
1414
+ $stdout.flush
1415
+ end
1416
+ end
1417
+
1418
+ unless options[:json] || options[:csv]
1419
+ puts " Done!\n"
1420
+ end
1421
+
1422
+ if options[:json]
1423
+ puts JSON.pretty_generate(data)
1424
+ return
1425
+ end
1426
+
1427
+ if options[:csv]
1428
+ require 'csv'
1429
+ puts "URL,Characters,Pixel_Width,Status,Current_Title,Desktop_Preview,Suggested_Rewrite_1,Suggested_Rewrite_2"
1430
+ data[:pages].each do |p|
1431
+ r1 = p[:suggested_rewrites][0] ? p[:suggested_rewrites][0][:title].gsub('"', '""') : ''
1432
+ r2 = p[:suggested_rewrites][1] ? p[:suggested_rewrites][1][:title].gsub('"', '""') : ''
1433
+ title_clean = p[:title].to_s.gsub('"', '""')
1434
+ prev_clean = p[:desktop_preview].to_s.gsub('"', '""')
1435
+ puts "\"#{p[:url]}\",#{p[:char_count]},#{p[:pixel_width]},#{p[:status]},\"#{title_clean}\",\"#{prev_clean}\",\"#{r1}\",\"#{r2}\""
1436
+ end
1437
+ return
1438
+ end
1439
+
1440
+ # Health Grade & Color
1441
+ grade_color = case data[:grade]
1442
+ when 'A' then Color::GREEN
1443
+ when 'B' then Color::CYAN
1444
+ when 'C' then Color::YELLOW
1445
+ when 'D' then Color::RED
1446
+ else Color::RED
1447
+ end
1448
+
1449
+ puts "šŸ† Title Health : #{Color.c("#{data[:health_score]}/100 [Grade #{data[:grade]}]", Color::BOLD, grade_color)} - #{data[:verdict]}"
1450
+ puts "šŸ“Š Overview : #{data[:total_pages]} Pages Audited | #{Color.c("#{data[:percentages][:optimal_pct]}% Optimal", Color::GREEN)} | #{Color.c("#{data[:percentages][:overflow_pct]}% Overflowing", Color::YELLOW)}"
1451
+ puts "šŸ“ˆ Breakdown : āœ… #{data[:counts][:optimal]} Optimal | āš ļø #{data[:counts][:desktop_overflow]} Desktop Overflow | 🚨 #{data[:counts][:critical_overflow]} Critical Overflow | ā„¹ļø #{data[:counts][:too_short]} Too Short | āŒ #{data[:counts][:missing]} Missing"
1452
+ puts "─" * 80
1453
+
1454
+ pages_to_display = options[:overflow_only] ? data[:pages].select { |p| [:desktop_overflow, :critical_overflow].include?(p[:status]) } : data[:pages]
1455
+
1456
+ if pages_to_display.empty?
1457
+ puts "\nšŸŽ‰ #{Color.c('All audited pages have optimal title tags with zero pixel overflow!', Color::GREEN, Color::BOLD)}"
1458
+ else
1459
+ puts "\n#{Color::BOLD}%-16s %-7s %-10s %s#{Color::RESET}" % ['Status', 'Chars', 'Width', 'URL & Title Tag']
1460
+ puts "─" * 80
1461
+
1462
+ pages_to_display.each do |page|
1463
+ status_col = case page[:status]
1464
+ when :optimal then Color.c("OPTIMAL", Color::GREEN)
1465
+ when :desktop_overflow then Color.c("DESK OVERFLOW", Color::YELLOW, Color::BOLD)
1466
+ when :critical_overflow then Color.c("CRIT OVERFLOW", Color::RED, Color::BOLD)
1467
+ when :too_short then Color.c("TOO SHORT", Color::CYAN)
1468
+ when :missing then Color.c("MISSING", Color::RED, Color::BOLD)
1469
+ end
1470
+
1471
+ px_str = "#{page[:pixel_width]}px"
1472
+ px_col = page[:pixel_width] > 580.0 ? Color.c(px_str, Color::RED, Color::BOLD) : Color.c(px_str, Color::GREEN)
1473
+
1474
+ puts "%-23s %-7s %-18s %s" % [
1475
+ status_col,
1476
+ "#{page[:char_count]}c",
1477
+ px_col,
1478
+ Color.c(page[:url], Color::BOLD)
1479
+ ]
1480
+ puts " ↳ #{Color.c(page[:title].empty? ? '(No Title Tag)' : page[:title], Color::DIM)}"
1481
+ end
1482
+ end
1483
+
1484
+ # Section: Title Rewrite Optimization Playbook
1485
+ overflowing_pages = data[:pages].reject { |p| p[:suggested_rewrites].empty? }
1486
+
1487
+ if overflowing_pages.any?
1488
+ puts "\n\n#{Color::BOLD}šŸ“ TITLE REWRITE & CTR OPTIMIZATION PLAYBOOK (#{overflowing_pages.size} Pages Flagged):#{Color::RESET}"
1489
+ puts "─" * 80
1490
+
1491
+ overflowing_pages.first(8).each_with_index do |p, idx|
1492
+ puts "\n#{Color.c("[#{idx + 1}]", Color::CYAN, Color::BOLD)} #{Color.c(p[:url], Color::BOLD)}"
1493
+ puts " āŒ Current Title : #{Color.c("\"#{p[:title]}\"", Color::RED)} (#{p[:pixel_width]}px, #{p[:char_count]} chars)"
1494
+ puts " šŸ‘€ Desktop SERP : #{Color.c(p[:desktop_preview], Color::GRAY)}"
1495
+
1496
+ puts " šŸ’” #{Color::BOLD}Synthesized Non-Truncating Title Rewrites (<560px):#{Color::RESET}"
1497
+ p[:suggested_rewrites].each do |rewrite|
1498
+ puts " • [#{Color.c(rewrite[:type], Color::YELLOW)}] #{Color.c(rewrite[:title], Color::GREEN, Color::BOLD)}"
1499
+ puts " ↳ #{rewrite[:pixel_width]}px | #{rewrite[:char_count]} chars | Desktop SERP: #{Color.c('FITS CLEANLY', Color::GREEN)}"
1500
+ end
1501
+ end
1502
+
1503
+ if overflowing_pages.size > 8
1504
+ puts "\n... and #{overflowing_pages.size - 8} more pages flagged. Run with #{Color.c('--csv', Color::CYAN)} or #{Color.c('--json', Color::CYAN)} to export all rewrites."
1505
+ end
1506
+ end
1507
+
1508
+ puts "\n" + ("─" * 80) + "\n"
1509
+ end
569
1510
  end
570
1511
  end
1512
+
1513
+