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,650 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require_relative 'base'
5
+ require_relative '../striking_playbook' if File.exist?(File.expand_path('../striking_playbook.rb', __dir__))
6
+ require_relative '../answer_synthesizer' if File.exist?(File.expand_path('../answer_synthesizer.rb', __dir__))
7
+ require_relative '../serp_feature_detector' if File.exist?(File.expand_path('../serp_feature_detector.rb', __dir__))
8
+ require_relative '../serp_preview' if File.exist?(File.expand_path('../serp_preview.rb', __dir__))
9
+ require_relative '../cannibalization_analyzer' if File.exist?(File.expand_path('../cannibalization_analyzer.rb', __dir__))
10
+ require_relative '../questions_harvester' if File.exist?(File.expand_path('../questions_harvester.rb', __dir__))
11
+ require_relative '../sitemap_loader' if File.exist?(File.expand_path('../sitemap_loader.rb', __dir__))
12
+ require_relative 'low_ctr' if File.exist?(File.expand_path('low_ctr.rb', __dir__))
13
+
14
+ module GSC
15
+ class CLI
16
+ module Growth
17
+ module_function
18
+
19
+ def run(command, target, extra, options, api, site_url, hostname, https_origin)
20
+ case command
21
+ when 'strike', 'striker', 'striking-playbook'
22
+ handle_strike(api, site_url, options)
23
+ when 'answer', 'direct-answer', 'aeo-snippet', 'info-gain', 'ans'
24
+ handle_answer(target, options)
25
+ when 'serp-features', 'sf', 'serp-live', 'features'
26
+ handle_serp_features(target, options)
27
+ when 'preview', 'serp', 'serp-preview', 'social-preview'
28
+ handle_preview(target, options)
29
+ when 'opportunities', 'striking-distance', 'quick-wins', 'o', 'opp'
30
+ handle_opportunities(api, site_url, options)
31
+ when 'underperformers', 'ctr-underperformers', 'ctr-gaps', 'u'
32
+ handle_underperformers(api, site_url, options)
33
+ when 'low-ctr', 'ctr-rewrite', 'lost-clicks', 'rewrite-titles', 'ctr-fix', 'lowctr', 'lc'
34
+ LowCtr.run(command, target, extra, options, api, site_url, hostname)
35
+ when 'cannibalization', 'conflicts', 'c'
36
+ handle_cannibalization(api, site_url, options)
37
+ when 'questions-harvest', 'harvest-questions', 'qh', 'faq-harvest'
38
+ handle_questions_harvest(target, api, site_url, options)
39
+ when 'zombies', 'bloat', 'z'
40
+ handle_zombies(target, api, site_url, https_origin, options)
41
+ else
42
+ raise "Unknown growth command: #{command}"
43
+ end
44
+ end
45
+
46
+ def handle_strike(api, site_url, options)
47
+ puts Base::BANNER unless options[:json] || options[:in_dashboard]
48
+ puts "🎯 Generating Striking-Distance Playbook for: #{Color.c(site_url, Color::CYAN)}...\n" unless options[:json]
49
+
50
+ data = StrikingPlaybook.generate(api, site_url, options)
51
+
52
+ if options[:json]
53
+ puts JSON.pretty_generate(data)
54
+ return
55
+ end
56
+
57
+ if data[:playbooks].empty?
58
+ puts Color.c("ℹ️ No striking-distance opportunities found (Pos #{data[:filters][:min_pos]}–#{data[:filters][:max_pos]}, Min #{data[:filters][:min_imp]} imp).", Color::YELLOW)
59
+ puts "💡 Tip: Lower --min-imp (e.g. gsc strike --min-imp 5) or increase --days 90."
60
+ return
61
+ end
62
+
63
+ puts "╔══════════════════════════════════════════════════════════════════════════════╗"
64
+ puts "║ 🚀 STRIKING-DISTANCE PLAYBOOK (POSITIONS 7–20 TO TOP 3) ║"
65
+ puts "╚══════════════════════════════════════════════════════════════════════════════╝"
66
+ puts "📍 Property: #{Color.c(data[:site_url], Color::CYAN, Color::BOLD)}"
67
+ puts "📈 Projected Gain: #{Color.c("+#{data[:projected_monthly_clicks]} clicks/mo", Color::GREEN, Color::BOLD)} (Across top #{data[:playbooks].size} targets)"
68
+ puts "🔍 Opportunities: #{data[:total_opportunities_found]} keywords detected in striking range"
69
+ puts "─" * 80
70
+
71
+ data[:playbooks].each_with_index do |p, idx|
72
+ tier_badge = case p[:tier]
73
+ when :tier_1_expedite
74
+ Color.c("⚡ [TIER 1: EXPEDITE - BRINK OF TOP 5]", Color::GREEN, Color::BOLD)
75
+ when :tier_2_strike
76
+ Color.c("🎯 [TIER 2: HIGH LEVERAGE STRIKE]", Color::CYAN, Color::BOLD)
77
+ else
78
+ Color.c("🧗 [TIER 3: FOUNDATION CLIMB]", Color::YELLOW)
79
+ end
80
+
81
+ page_short = p[:page].sub(%r{^https?://[^/]+}, '')
82
+ page_short = '/' if page_short.empty?
83
+
84
+ puts "\n#{Color::BOLD}#{idx + 1}. KEYWORD: #{Color.c("\"#{p[:query]}\"", Color::YELLOW, Color::BOLD)} #{tier_badge}#{Color::RESET}"
85
+ puts " Target Page: #{Color.c(page_short, Color::CYAN)} (#{p[:page]})"
86
+ puts " Current Metrics: Pos #{Color.c(p[:position].to_s, Color::YELLOW, Color::BOLD)} | #{p[:impressions]} imp | #{p[:clicks]} clicks (#{p[:ctr]}% CTR)"
87
+ puts " Projected Surge: #{Color.c("+#{p[:projected_gain]} clicks/month", Color::GREEN, Color::BOLD)} upon ranking Top 3"
88
+
89
+ puts "\n #{Color::BOLD}🏷️ Title Tag Rewrites (Pick one):#{Color::RESET}"
90
+ p[:title_rewrites].each do |t|
91
+ puts " • #{Color.c(t, Color::BOLD)} (#{t.length} chars)"
92
+ end
93
+
94
+ puts "\n #{Color::BOLD}🌲 Heading Injection Recipe:#{Color::RESET}"
95
+ puts " H2: #{Color.c(p[:heading_recipes][:h2], Color::MAGENTA)}"
96
+ p[:heading_recipes][:h3s].each do |h3|
97
+ puts " └── H3: #{h3}"
98
+ end
99
+
100
+ puts "\n #{Color::BOLD}🔗 Internal Link Anchors (Deploy 3 links across site):#{Color::RESET}"
101
+ puts " • Exact Match: \"#{Color.c(p[:internal_link_anchors][:exact], Color::CYAN)}\""
102
+ puts " • Partial Match: \"#{p[:internal_link_anchors][:partial]}\""
103
+ puts " • Branded: \"#{p[:internal_link_anchors][:branded]}\""
104
+
105
+ puts "\n #{Color::BOLD}❓ High-Information Gain FAQ Snippet (Paste into page):#{Color::RESET}"
106
+ puts " Q: #{Color.c(p[:faq_snippet][:question], Color::BOLD)}"
107
+ puts " A: #{p[:faq_snippet][:answer]}"
108
+
109
+ puts "\n #{Color::BOLD}📋 Action Checklist:#{Color::RESET}"
110
+ p[:action_checklist].each do |task|
111
+ puts " [ ] #{task}"
112
+ end
113
+ puts "\n" + ("─" * 80)
114
+ end
115
+
116
+ if options[:csv]
117
+ csv_data = data[:playbooks].map do |p|
118
+ [p[:query], p[:page], p[:position], p[:impressions], p[:clicks], p[:ctr], p[:projected_gain], p[:tier], p[:title_rewrites].first]
119
+ end
120
+ Base.write_csv(options[:csv], %w[Query Page Position Impressions Clicks CTR ProjectedGain Tier RecommendedTitle], csv_data)
121
+ puts Color.c("📁 Exported striking playbook to #{options[:csv]}", Color::CYAN)
122
+ end
123
+ end
124
+
125
+ def handle_answer(query, options = {})
126
+ if query.nil? || query.to_s.strip.empty?
127
+ if options[:json]
128
+ puts JSON.pretty_generate({ error: 'Query required. Usage: gsc answer <query> [--brand <name>] [--type <definition|steps|comparison|faq>] [--generate] [--json]' })
129
+ else
130
+ puts Color.c("❌ Error: Query required. Usage: gsc answer <query> [--brand <name>] [--type <definition|steps|comparison|faq>] [--generate] [--json]", Color::RED, Color::BOLD)
131
+ end
132
+ return
133
+ end
134
+
135
+ data = GSC::AnswerSynthesizer.synthesize(query, options)
136
+
137
+ if options[:json]
138
+ puts JSON.pretty_generate(data)
139
+ return
140
+ end
141
+
142
+ puts Base::BANNER unless options[:in_dashboard]
143
+ puts "🗣️ #{Color::BOLD}DIRECT ANSWER & INFORMATION GAIN SNIPPET (GEO / AEO)#{Color::RESET}"
144
+ puts "🎯 Target Query : #{Color.c("\"#{data[:query]}\"", Color::YELLOW, Color::BOLD)}"
145
+ puts "🏷️ Intent Type : [#{Color.c(data[:intent_type].upcase, Color::MAGENTA, Color::BOLD)}]"
146
+ puts "📊 Word Count : #{data[:word_count]} words (#{Color.c("Optimal: #{data[:target_optimal_range]}", Color::GREEN)})"
147
+ puts "🏆 Citability : #{Color.c("#{data[:citability_score]}/100", Color::GREEN, Color::BOLD)} (Optimized for Featured Snippets, ChatGPT & Perplexity)"
148
+ puts "─" * 75
149
+
150
+ puts "\n#{Color::BOLD}💬 OPTIMIZED DIRECT ANSWER PARAGRAPH:#{Color::RESET}"
151
+ puts " #{Color.c(data[:direct_answer], Color::BOLD)}"
152
+
153
+ puts "\n#{Color::BOLD}📌 STRUCTURED ATTRIBUTES & KEY POINTS:#{Color::RESET}"
154
+ data[:key_points].each do |kp|
155
+ clean_kp = kp.gsub(/\*\*(.*?)\*\*/, "#{Color::BOLD}\\1#{Color::RESET}")
156
+ puts " • #{clean_kp}"
157
+ end
158
+
159
+ puts "\n#{Color::BOLD}📈 INFORMATION GAIN FACTOID:#{Color::RESET}"
160
+ puts " 💡 #{Color.c(data[:information_gain_stat], Color::CYAN)}"
161
+
162
+ if options[:generate]
163
+ puts "\n#{Color::BOLD}💻 READY-TO-PASTE HTML SNIPPET:#{Color::RESET}"
164
+ puts Color.c(data[:html_markup], Color::DIM)
165
+
166
+ puts "\n#{Color::BOLD}💎 SCHEMA.ORG JSON-LD SCRIPT BLOCK:#{Color::RESET}"
167
+ puts Color.c("<script type=\"application/ld+json\">\n#{JSON.pretty_generate(data[:schema_jsonld])}\n</script>", Color::CYAN)
168
+ else
169
+ puts "\n💡 #{Color.c('Tip: Pass --generate to print copy-paste HTML and Schema.org JSON-LD blocks.', Color::GRAY)}"
170
+ end
171
+ puts "─" * 75 + "\n"
172
+ end
173
+
174
+ def handle_serp_features(query, options)
175
+ query_str = query.to_s.sub(/^(query|live):/, '').strip
176
+ if query_str.empty?
177
+ if options[:json]
178
+ puts JSON.pretty_generate({ error: 'Search query required. Example: gsc serp-features "how to speed up rails"' })
179
+ else
180
+ puts Color.c("❌ Error: Search query required. Example: gsc serp-features \"how to speed up rails\"", Color::RED, Color::BOLD)
181
+ end
182
+ return
183
+ end
184
+
185
+ detector = GSC::SerpFeatureDetector.new(query_str, options)
186
+ data = detector.detect
187
+
188
+ if options[:json]
189
+ puts JSON.pretty_generate(data)
190
+ return
191
+ end
192
+
193
+ puts Base::BANNER unless options[:in_dashboard]
194
+ puts "🔍 #{Color::BOLD}LIVE SERP FEATURE & ZERO-CLICK THREAT DETECTOR:#{Color::RESET} #{Color.c(query_str, Color::CYAN, Color::BOLD)}"
195
+ puts "─" * 80
196
+
197
+ zt = data[:zero_click_threat] || {}
198
+ threat_color = case zt[:level]
199
+ when 'LOW' then Color::GREEN
200
+ when 'MODERATE' then Color::YELLOW
201
+ else Color::RED
202
+ end
203
+
204
+ puts "🎯 Search Intent: #{Color.c(data.dig(:intent, :primary).to_s, Color::BOLD)} (#{data.dig(:intent, :description)})"
205
+ puts "🚨 Zero-Click Threat: #{Color.c("#{zt[:level]} (#{zt[:score]}/100)", threat_color, Color::BOLD)} [Organic CTR Impact: #{zt[:estimated_organic_ctr_suppression]}]"
206
+ puts "─" * 80
207
+
208
+ f = data[:features] || {}
209
+ puts "\n#{Color::BOLD}📊 DETECTED SERP REAL ESTATE FEATURES:#{Color::RESET}"
210
+
211
+ aio = f[:ai_overview] || {}
212
+ aio_badge = aio[:detected] ? Color.c("ACTIVE (#{aio[:probability_pct]}% prob)", Color::RED, Color::BOLD) : Color.c("Unlikely (#{aio[:probability_pct]}%)", Color::GRAY)
213
+ puts " • Google AI Overview (Gemini) : [#{aio_badge}]"
214
+ puts " └─ #{Color.c(aio[:impact], Color::DIM)}" if aio[:detected]
215
+
216
+ fs = f[:featured_snippet] || {}
217
+ fs_badge = fs[:detected] ? Color.c("OPPORTUNITY (#{fs[:target_format].capitalize} - #{fs[:probability_pct]}%)", Color::GREEN, Color::BOLD) : Color.c("None", Color::GRAY)
218
+ puts " • Featured Snippet Box : [#{fs_badge}]"
219
+ puts " └─ Target Recipe: #{Color.c(fs[:capture_prescription], Color::CYAN)}" if fs[:detected]
220
+
221
+ paa = f[:people_also_ask] || {}
222
+ paa_badge = paa[:detected] ? Color.c("#{paa[:count]} Live Questions Extracted", Color::CYAN, Color::BOLD) : Color.c("None", Color::GRAY)
223
+ puts " • People Also Ask (PAA) : [#{paa_badge}]"
224
+ if paa[:detected] && (paa[:questions] || []).any?
225
+ paa[:questions].first(5).each do |q_text|
226
+ puts " ❓ \"#{Color.c(q_text, Color::YELLOW)}\""
227
+ end
228
+ end
229
+
230
+ lp = f[:local_3_pack] || {}
231
+ lp_badge = lp[:detected] ? Color.c("DETECTED (Google Maps 3-Pack)", Color::YELLOW, Color::BOLD) : Color.c("None", Color::GRAY)
232
+ puts " • Local Maps 3-Pack : [#{lp_badge}]"
233
+
234
+ vc = f[:video_carousel] || {}
235
+ vc_badge = vc[:detected] ? Color.c("DETECTED (Video Carousel)", Color::YELLOW, Color::BOLD) : Color.c("None", Color::GRAY)
236
+ puts " • Video Carousel / YouTube : [#{vc_badge}]"
237
+
238
+ df = f[:discussions_and_forums] || {}
239
+ df_badge = df[:detected] ? Color.c("DETECTED (Reddit/Quora Modules)", Color::CYAN, Color::BOLD) : Color.c("None", Color::GRAY)
240
+ puts " • Discussions & Forums : [#{df_badge}]"
241
+
242
+ sp = f[:shopping_pack] || {}
243
+ sp_badge = sp[:detected] ? Color.c("DETECTED (Shopping Product Grid)", Color::YELLOW, Color::BOLD) : Color.c("None", Color::GRAY)
244
+ puts " • Google Shopping Grid : [#{sp_badge}]"
245
+
246
+ playbook = data[:playbook] || []
247
+ if playbook.any?
248
+ puts "\n#{Color::BOLD}🏆 RICH SNIPPET & CITATION CAPTURE PLAYBOOK:#{Color::RESET}"
249
+ playbook.each_with_index do |p, idx|
250
+ puts " #{idx + 1}. [#{Color.c(p[:priority], Color::BOLD)}] #{Color.c(p[:target], Color::CYAN)}:"
251
+ puts " 👉 #{p[:action]}"
252
+ end
253
+ end
254
+
255
+ serp = data[:serp_sampling] || []
256
+ if serp.any?
257
+ puts "\n#{Color::BOLD}🌐 LIVE SERP SAMPLING (Top Competitor Results):#{Color::RESET}"
258
+ serp.each do |r|
259
+ puts " #{r[:position]}. #{Color.c(r[:title], Color::BOLD)} [#{Color.c(r[:domain], Color::GRAY)}]"
260
+ puts " └─ #{Color.c(r[:url], Color::BLUE)}"
261
+ end
262
+ end
263
+
264
+ puts "\n" + ("─" * 80) + "\n"
265
+ end
266
+
267
+ def handle_preview(target, options)
268
+ is_url = target.to_s.start_with?('http://', 'https://')
269
+ has_title_or_url = is_url || options[:url] || options[:title] || options[:desc] || options[:description] || options[:preview] || (target.to_s =~ /[:|—]/)
270
+
271
+ if options[:live] || options[:features] || (!has_title_or_url && !target.to_s.empty?)
272
+ return handle_serp_features(target, options)
273
+ end
274
+
275
+ url = options[:url] || (target if is_url)
276
+ title = options[:title] || (target unless is_url)
277
+ desc = options[:desc] || options[:description]
278
+ url ||= "https://#{Config.default_domain}" if Config.default_domain
279
+
280
+ if (url.nil? || url.empty?) && (title.nil? || title.to_s.strip.empty?)
281
+ if options[:json]
282
+ puts JSON.pretty_generate({ error: 'Target URL or custom title required. Example: gsc serp-preview https://mysite.com/page' })
283
+ else
284
+ puts Color.c("❌ Error: Target URL or custom title required.", Color::RED, Color::BOLD)
285
+ puts "Example: gsc serp-preview https://mysite.com/page"
286
+ puts " gsc serp-preview --title \"Page Title\" --desc \"Meta description\""
287
+ end
288
+ return
289
+ end
290
+
291
+ sp = GSC::SerpPreview.new(url, title: title, desc: desc)
292
+ data = sp.generate
293
+
294
+ if options[:json]
295
+ puts JSON.pretty_generate(data)
296
+ return
297
+ end
298
+
299
+ puts Base::BANNER unless options[:in_dashboard]
300
+ m = data[:metrics] || {}
301
+
302
+ puts "🖥️ #{Color::BOLD}GOOGLE SERP PREVIEW (Desktop Viewport):#{Color::RESET}"
303
+ puts "┌─────────────────────────────────────────────────────────────────────────┐"
304
+ puts "│ #{Color.c(data.dig(:desktop_serp, :breadcrumb).to_s.ljust(71), Color::DIM)}│"
305
+ puts "│ #{Color.c(data.dig(:desktop_serp, :title).to_s.ljust(71), Color::BLUE, Color::BOLD)}│"
306
+ puts "│ #{Color.c(data.dig(:desktop_serp, :snippet).to_s[0..70].ljust(71), Color::DIM)}│"
307
+ puts "└─────────────────────────────────────────────────────────────────────────┘"
308
+
309
+ t_status = if m[:title_truncated]
310
+ Color.c("⚠️ TRUNCATED (+#{(m[:title_pixel_est] - m[:title_desktop_limit]).round(1)}px over 580px limit)", Color::YELLOW, Color::BOLD)
311
+ else
312
+ Color.c('✅ OPTIMAL (< 580px desktop limit)', Color::GREEN, Color::BOLD)
313
+ end
314
+
315
+ d_status = if m[:desc_truncated]
316
+ Color.c("⚠️ TRUNCATED (+#{(m[:desc_pixel_est] - m[:desc_desktop_limit]).round(1)}px over 960px limit)", Color::YELLOW, Color::BOLD)
317
+ else
318
+ Color.c('✅ OPTIMAL (< 960px desktop limit)', Color::GREEN, Color::BOLD)
319
+ end
320
+
321
+ puts " • Title Width: #{m[:title_chars]} chars / ~#{m[:title_pixel_est]}px [#{t_status}]"
322
+ puts " • Description Width: #{m[:desc_chars]} chars / ~#{m[:desc_pixel_est]}px [#{d_status}]"
323
+
324
+ puts "\n📱 #{Color::BOLD}GOOGLE SERP PREVIEW (Mobile Viewport):#{Color::RESET}"
325
+ puts "┌─────────────────────────────────────────────────────────────────────────┐"
326
+ puts "│ #{Color.c(data.dig(:mobile_serp, :breadcrumb).to_s.ljust(71), Color::DIM)}│"
327
+ puts "│ #{Color.c(data.dig(:mobile_serp, :title).to_s.ljust(71), Color::BLUE, Color::BOLD)}│"
328
+ puts "│ #{Color.c(data.dig(:mobile_serp, :snippet).to_s[0..70].ljust(71), Color::DIM)}│"
329
+ puts "└─────────────────────────────────────────────────────────────────────────┘"
330
+
331
+ soc = data[:social] || {}
332
+ if soc[:og_title] || soc[:og_image]
333
+ puts "\n🌐 #{Color::BOLD}OPEN GRAPH / SOCIAL CARD PREVIEW:#{Color::RESET}"
334
+ puts " • Title : #{soc[:og_title]}"
335
+ puts " • Description : #{soc[:og_description]}"
336
+ puts " • Card Image : #{soc[:og_image] || '(No og:image specified)'}"
337
+ end
338
+ puts ""
339
+ end
340
+
341
+ def handle_opportunities(api, site_url, options)
342
+ min_pos = options[:min_pos] || 7.0
343
+ max_pos = options[:max_pos] || 20.0
344
+ min_imp = options[:min_imp] || 10
345
+ puts "🎯 Finding striking-distance keyword opportunities for #{Color.c(site_url, Color::CYAN)} (Positions #{min_pos}–#{max_pos}, Min #{min_imp} Imp)...\n" unless options[:json]
346
+
347
+ res = api.query_analytics(site_url, days: options[:days], dimensions: %w[query page], row_limit: 2500)
348
+ if res[:ok]
349
+ raw_rows = res.dig(:data, 'rows') || []
350
+ opportunities = []
351
+
352
+ raw_rows.each do |r|
353
+ q = r['keys'][0]
354
+ p = r['keys'][1]
355
+ pos = r['position'].round(1)
356
+ imp = r['impressions']
357
+ clicks = r['clicks']
358
+ ctr = (r['ctr'] * 100).round(2)
359
+
360
+ next unless pos >= min_pos && pos <= max_pos
361
+ next unless imp >= min_imp
362
+
363
+ target_ctr = 12.0
364
+ potential_clicks = [((imp * (target_ctr / 100.0)) - clicks).round, 1].max
365
+ score = (potential_clicks * (1.0 / [pos - 2.0, 1.0].max) * 10.0).round(1)
366
+
367
+ opportunities << {
368
+ query: q,
369
+ page: p,
370
+ position: pos,
371
+ impressions: imp,
372
+ clicks: clicks,
373
+ ctr: ctr,
374
+ potentialGain: potential_clicks,
375
+ opportunityScore: score
376
+ }
377
+ end
378
+
379
+ opportunities.sort_by! { |o| -o[:opportunityScore] }
380
+ opportunities = opportunities.first(options[:limit])
381
+
382
+ if options[:json]
383
+ puts JSON.pretty_generate(opportunities)
384
+ elsif opportunities.empty?
385
+ puts Color.c("ℹ️ No striking-distance opportunities found with current filters (Min #{min_imp} imp, Pos #{min_pos}–#{max_pos}).", Color::YELLOW)
386
+ puts "💡 Tip: Try lowering --min-imp (e.g. gsc opportunities --min-imp 3) or increasing --days 90."
387
+ else
388
+ puts "#{Color::BOLD}Pos | Imp | Clicks | Est. +Clicks | Target Page & Query#{Color::RESET}"
389
+ puts "--------------------------------------------------------------------------------"
390
+ opportunities.each do |opp|
391
+ pos_str = opp[:position].to_s.ljust(5)
392
+ imp_str = opp[:impressions].to_s.ljust(6)
393
+ clicks_str = opp[:clicks].to_s.ljust(6)
394
+ gain_str = "+#{opp[:potentialGain]}/mo".ljust(12)
395
+ page_short = opp[:page].sub(%r{^https?://[^/]+}, '')
396
+ page_short = '/' if page_short.empty?
397
+
398
+ puts "#{Color.c(pos_str, Color::YELLOW)} | #{imp_str} | #{clicks_str} | #{Color.c(gain_str, Color::GREEN, Color::BOLD)} | #{Color.c(page_short, Color::CYAN)}"
399
+ puts " | | | | ↳ #{Color::BOLD}\"#{opp[:query]}\"#{Color::RESET}"
400
+ end
401
+ puts "\n#{Color.c("Total Opportunities: #{opportunities.size}", Color::GRAY)}"
402
+ puts "💡 #{Color::BOLD}Optimization Playbook:#{Color::RESET} Add an H2 subheading and 1-2 paragraphs directly targeting these queries on their respective pages to push them onto Page 1.\n\n"
403
+
404
+ if options[:csv]
405
+ csv_data = opportunities.map { |o| [o[:query], o[:page], o[:position], o[:impressions], o[:clicks], o[:ctr], o[:potentialGain], o[:opportunityScore]] }
406
+ Base.write_csv(options[:csv], %w[Query Page Position Impressions Clicks CTR PotentialGain Score], csv_data)
407
+ puts Color.c("📁 Exported opportunities to #{options[:csv]}", Color::CYAN)
408
+ end
409
+ end
410
+ else
411
+ if options[:json]
412
+ puts JSON.pretty_generate({ error: res[:data] })
413
+ else
414
+ puts Color.c("❌ Search Analytics Error (#{res[:status]}): #{res[:data]}", Color::RED)
415
+ end
416
+ end
417
+ end
418
+
419
+ def handle_underperformers(api, site_url, options)
420
+ min_imp = options[:min_imp] || 15
421
+ puts "⚡ Scanning for CTR Underperformers in Top 10 for #{Color.c(site_url, Color::CYAN)} (Min #{min_imp} Imp)...\n" unless options[:json]
422
+
423
+ res = api.query_analytics(site_url, days: options[:days], dimensions: %w[query page], row_limit: 2500)
424
+ if res[:ok]
425
+ raw_rows = res.dig(:data, 'rows') || []
426
+ underperformers = []
427
+
428
+ raw_rows.each do |r|
429
+ q = r['keys'][0]
430
+ p = r['keys'][1]
431
+ pos = r['position'].round(1)
432
+ imp = r['impressions']
433
+ clicks = r['clicks']
434
+ ctr = (r['ctr'] * 100).round(2)
435
+
436
+ next unless pos <= 10.0
437
+ next unless imp >= min_imp
438
+
439
+ expected = Base.expected_ctr_for(pos)
440
+ gap = expected - ctr
441
+ next unless gap >= 2.0 && ctr < (expected * 0.70)
442
+
443
+ lost_clicks = [((imp * (gap / 100.0))).round, 1].max
444
+
445
+ underperformers << {
446
+ query: q,
447
+ page: p,
448
+ position: pos,
449
+ impressions: imp,
450
+ clicks: clicks,
451
+ actualCtr: ctr,
452
+ expectedCtr: expected,
453
+ lostClicks: lost_clicks
454
+ }
455
+ end
456
+
457
+ underperformers.sort_by! { |u| -u[:lostClicks] }
458
+ underperformers = underperformers.first(options[:limit])
459
+
460
+ if options[:json]
461
+ puts JSON.pretty_generate(underperformers)
462
+ elsif underperformers.empty?
463
+ puts Color.c("✅ No significant CTR underperformers detected in Top 10! Your titles are converting well.", Color::GREEN)
464
+ else
465
+ puts "#{Color::BOLD}Pos | Imp | CTR | Expected | Lost Clicks | Query & Page#{Color::RESET}"
466
+ puts "--------------------------------------------------------------------------------"
467
+ underperformers.each do |item|
468
+ pos_str = item[:position].to_s.ljust(5)
469
+ imp_str = item[:impressions].to_s.ljust(6)
470
+ ctr_str = "#{item[:actualCtr]}%".ljust(6)
471
+ exp_str = "~#{item[:expectedCtr]}%".ljust(8)
472
+ lost_str = "~#{item[:lostClicks]} clicks".ljust(11)
473
+ page_short = item[:page].sub(%r{^https?://[^/]+}, '')
474
+ page_short = '/' if page_short.empty?
475
+
476
+ puts "#{Color.c(pos_str, Color::GREEN)} | #{imp_str} | #{Color.c(ctr_str, Color::RED)} | #{exp_str} | #{Color.c(lost_str, Color::YELLOW, Color::BOLD)} | #{Color.c(page_short, Color::CYAN)}"
477
+ puts " | | | | | ↳ #{Color::BOLD}\"#{item[:query]}\"#{Color::RESET}"
478
+ end
479
+ puts "\n#{Color.c("Total Underperformers: #{underperformers.size}", Color::GRAY)}"
480
+ puts "💡 #{Color::BOLD}Quick Win Playbook:#{Color::RESET} You already have top rankings! Rewrite the <title> tag and meta description with numbers, brackets [2026], or clear benefit hooks to recover lost clicks immediately.\n\n"
481
+
482
+ if options[:csv]
483
+ csv_data = underperformers.map { |u| [u[:query], u[:page], u[:position], u[:impressions], u[:clicks], u[:actualCtr], u[:expectedCtr], u[:lostClicks]] }
484
+ Base.write_csv(options[:csv], %w[Query Page Position Impressions Clicks ActualCTR ExpectedCTR LostClicks], csv_data)
485
+ puts Color.c("📁 Exported CTR underperformers to #{options[:csv]}", Color::CYAN)
486
+ end
487
+ end
488
+ else
489
+ if options[:json]
490
+ puts JSON.pretty_generate({ error: res[:data] })
491
+ else
492
+ puts Color.c("❌ Search Analytics Error (#{res[:status]}): #{res[:data]}", Color::RED)
493
+ end
494
+ end
495
+ end
496
+
497
+ def handle_cannibalization(api, site_url, options)
498
+ puts "⚔️ Auditing Keyword Cannibalization & URL Conflicts for #{Color.c(site_url, Color::CYAN)} (Past #{options[:days]} days)...\n" unless options[:json]
499
+
500
+ res = api.query_analytics(site_url, days: options[:days], dimensions: %w[query page], row_limit: 5000)
501
+ if res[:ok]
502
+ raw_rows = res.dig(:data, 'rows') || []
503
+ analysis = GSC::CannibalizationAnalyzer.analyze(raw_rows, min_imp: options[:min_imp] || 10)
504
+ conflicts = analysis[:conflicts].first(options[:limit] || 25)
505
+
506
+ if options[:json]
507
+ puts JSON.pretty_generate(analysis)
508
+ elsif conflicts.empty?
509
+ puts Color.c("✅ Zero keyword cannibalization detected! Each query has a distinct ranking page.", Color::GREEN)
510
+ puts " Evaluated #{analysis[:total_queries_evaluated]} distinct queries across #{site_url}."
511
+ else
512
+ puts "Cannibalization Health Score: #{Color.c("#{analysis[:health_score]}/100 [Grade #{analysis[:health_grade]}]", analysis[:health_score] >= 80 ? Color::GREEN : Color::RED, Color::BOLD)}"
513
+ puts "Total Conflicts Detected: #{Color.c(analysis[:conflicts_count].to_s, Color::YELLOW, Color::BOLD)} (#{analysis[:critical_count]} Critical)"
514
+ puts "Total Diluted Impressions: #{Color.c(analysis[:total_diluted_impressions].to_s, Color::RED)} search impressions split across competing URLs\n\n"
515
+
516
+ conflicts.each_with_index do |conf, i|
517
+ sev_color = case conf[:severity]
518
+ when 'CRITICAL' then Color::RED
519
+ when 'HIGH' then Color::YELLOW
520
+ else Color::CYAN
521
+ end
522
+ sev_badge = Color.c("[#{conf[:severity]}]", sev_color, Color::BOLD)
523
+ puts "#{Color::BOLD}#{i + 1}. #{sev_badge} \"#{conf[:query]}\"#{Color::RESET} (Total Imp: #{Color.c(conf[:total_impressions].to_s, Color::CYAN)}, Clicks: #{conf[:total_clicks]})"
524
+ conf[:pages].each do |p|
525
+ share_str = p[:impression_share_str].ljust(6)
526
+ imp_str = "#{p[:impressions]} imp".ljust(9)
527
+ pos_str = "Pos #{p[:position]}".ljust(9)
528
+ page_short = p[:page].sub(%r{^https?://[^/]+}, '')
529
+ page_short = '/' if page_short.empty?
530
+ puts " • #{Color.c(share_str, Color::YELLOW)} | #{imp_str} | #{pos_str} | #{page_short}"
531
+ end
532
+ puts " 👉 #{Color.c("Remedy [#{conf[:remedy][:action]}]:", Color::BOLD)} #{conf[:remedy][:recommendation]}\n\n"
533
+ end
534
+
535
+ if options[:csv]
536
+ csv_rows = []
537
+ analysis[:conflicts].each do |c|
538
+ c[:pages].each do |p|
539
+ csv_rows << [c[:query], c[:severity], c[:total_impressions], c[:remedy][:action], c[:remedy][:recommendation], p[:page], p[:impression_share_str], p[:impressions], p[:clicks], p[:position]]
540
+ end
541
+ end
542
+ Base.write_csv(options[:csv], %w[Query Severity TotalImpressions RemedyAction Recommendation Page ImpressionShare PageImpressions PageClicks Position], csv_rows)
543
+ puts Color.c("📁 Exported cannibalization conflict report to #{options[:csv]}", Color::CYAN)
544
+ end
545
+ end
546
+ else
547
+ if options[:json]
548
+ puts JSON.pretty_generate({ error: res[:data] })
549
+ else
550
+ puts Color.c("❌ Search Analytics Error (#{res[:status]}): #{res[:data]}", Color::RED)
551
+ end
552
+ end
553
+ end
554
+
555
+ def handle_questions_harvest(target, api, site_url, options)
556
+ days = options[:days] || 30
557
+ min_imp = options[:min_imp] || 1
558
+ page_filter = options[:page] || target
559
+
560
+ puts "❓ Harvesting Question & FAQ Queries for #{Color.c(site_url, Color::CYAN)} (Past #{days} days)...\n" unless options[:json]
561
+
562
+ res = api.query_analytics(site_url, days: days, dimensions: %w[query page], row_limit: 5000)
563
+ if res[:ok]
564
+ raw_rows = res.dig(:data, 'rows') || []
565
+ harvest_data = GSC::QuestionsHarvester.harvest(
566
+ raw_rows,
567
+ min_imp: min_imp,
568
+ filter_page: page_filter,
569
+ brand: options[:brand] || site_url.sub(/^sc-domain:/, '').sub(/\.[a-z]+$/, '').capitalize,
570
+ synthesize: options[:synthesize] || options[:answer]
571
+ )
572
+
573
+ if options[:json]
574
+ puts JSON.pretty_generate(harvest_data)
575
+ else
576
+ puts "#{Color::BOLD}❓ SEARCH CONSOLE QUESTION & FAQ HARVEST#{Color::RESET}"
577
+ puts "#{Color::BOLD}══════════════════════════════════════════════════════════════#{Color::RESET}"
578
+ puts " Total Questions Harvested: #{Color.c(harvest_data[:total_questions_harvested].to_s, Color::CYAN, Color::BOLD)}"
579
+ puts " 🎯 High-Opportunity (Pos 4-20): #{Color.c(harvest_data[:high_opportunity_count].to_s, Color::YELLOW, Color::BOLD)} (Prime for FAQ Rich Snippets)"
580
+ puts " 🛡️ SERP Defense (Pos 1-3): #{Color.c(harvest_data[:defense_count].to_s, Color::GREEN, Color::BOLD)} (Featured Snippet Candidates)"
581
+ puts " 📄 Landing Pages Covered: #{harvest_data[:pages_covered]}"
582
+ puts "#{Color::BOLD}══════════════════════════════════════════════════════════════#{Color::RESET}\n"
583
+
584
+ limit = options[:limit] || 15
585
+ top_questions = harvest_data[:questions].first(limit)
586
+
587
+ if top_questions.empty?
588
+ puts Color.c("No question queries found meeting criteria for this period.\n", Color::YELLOW)
589
+ else
590
+ puts "#{Color::BOLD}Top Harvested Questions:#{Color::RESET}"
591
+ puts " # | Imp | Clicks | Pos | Type | Question"
592
+ puts "─────────────────────────────────────────────────────────────────────────────"
593
+ top_questions.each_with_index do |q, i|
594
+ num_str = "[#{i + 1}]".ljust(4)
595
+ imp_str = q[:impressions].to_s.ljust(5)
596
+ clk_str = q[:clicks].to_s.ljust(7)
597
+ pos_str = q[:position].to_s.ljust(5)
598
+ type_str = Color.c((q[:type] || 'OTHER').ljust(7), Color::CYAN)
599
+
600
+ puts "#{num_str}| #{imp_str}| #{clk_str}| #{pos_str}| #{type_str}| #{Color::BOLD}#{q[:question]}#{Color::RESET}"
601
+ page_short = q[:page].sub(%r{^https?://[^/]+}, '')
602
+ page_short = '/' if page_short.empty?
603
+ puts " 🔗 Target Page: #{Color.c(page_short, Color::GRAY)} (Tier: #{q[:tier]})"
604
+ end
605
+ puts "─────────────────────────────────────────────────────────────────────────────\n"
606
+
607
+ if options[:generate] || harvest_data[:recommended_faq_schemas].any?
608
+ first_page, sample_schema = harvest_data[:recommended_faq_schemas].first
609
+ if sample_schema
610
+ page_short = first_page.sub(%r{^https?://[^/]+}, '')
611
+ puts "📋 #{Color::BOLD}READY-TO-PASTE FAQPage JSON-LD SCHEMA FOR #{Color.c(page_short, Color::CYAN)}:#{Color::RESET}"
612
+ puts "<script type=\"application/ld+json\">"
613
+ puts JSON.pretty_generate(sample_schema)
614
+ puts "</script>\n"
615
+ end
616
+ end
617
+ end
618
+
619
+ puts "💡 #{Color.bold('Want to do more? Supercharge your question & content strategy:')}"
620
+ clean_dom = site_url.sub('sc-domain:', '')
621
+ sample_keyword = top_questions.any? ? top_questions.first[:question].gsub(/[?]/, '').split(/\s+/).last(2).join(' ') : 'search analytics'
622
+ puts " • 📅 Expand date range (up to 16 months): #{Color.cyan("gsc questions-harvest -d #{clean_dom} --days 480")}"
623
+ puts " • 🌐 Mine live People Also Ask questions: #{Color.cyan("gsc questions \"#{sample_keyword}\"")}"
624
+ puts " • ✍️ Synthesize high-ranking answers: #{Color.cyan("gsc questions-harvest -d #{clean_dom} --days #{days} --synthesize")}"
625
+ puts " • 🎯 AI Overview Citation Radar: #{Color.cyan("gsc aio-hunter \"#{sample_keyword}\"")}"
626
+ puts " • 📄 Filter by specific landing page: #{Color.cyan("gsc questions-harvest /features/ -d #{clean_dom}")}\n\n"
627
+
628
+ if options[:csv]
629
+ csv_rows = harvest_data[:questions].map do |q|
630
+ [q[:question], q[:type], q[:tier], q[:impressions], q[:clicks], q[:ctr], q[:position], q[:opportunity_score], q[:page]]
631
+ end
632
+ Base.write_csv(options[:csv], %w[Question Type Tier Impressions Clicks CTR Position OpportunityScore TargetPage], csv_rows)
633
+ puts Color.c("📁 Exported question queries to #{options[:csv]}", Color::CYAN)
634
+ end
635
+ end
636
+ else
637
+ if options[:json]
638
+ puts JSON.pretty_generate({ error: res[:data] })
639
+ else
640
+ puts Color.c("❌ Search Analytics Error (#{res[:status]}): #{res[:data]}", Color::RED)
641
+ end
642
+ end
643
+ end
644
+
645
+ def handle_zombies(target, api, site_url, https_origin, options)
646
+ ZombiePurger.run('zombies', target, nil, options, api, site_url, nil, https_origin)
647
+ end
648
+ end
649
+ end
650
+ end