gsc-cli 2.0.2 → 2.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (108) hide show
  1. checksums.yaml +4 -4
  2. data/AUTH.md +205 -0
  3. data/FUNDING.md +120 -0
  4. data/README.md +463 -299
  5. data/bin/gsc +29158 -4921
  6. data/dist/gsc +29158 -4921
  7. data/lib/gsc/aio_hunter.rb +343 -0
  8. data/lib/gsc/answer_synthesizer.rb +157 -0
  9. data/lib/gsc/api.rb +53 -1
  10. data/lib/gsc/auth.rb +26 -0
  11. data/lib/gsc/backlinks_manager.rb +96 -0
  12. data/lib/gsc/brand_segmenter.rb +140 -0
  13. data/lib/gsc/cache_manager.rb +806 -0
  14. data/lib/gsc/cannibalization_analyzer.rb +141 -0
  15. data/lib/gsc/canonical_chains.rb +367 -0
  16. data/lib/gsc/citation_simulator.rb +339 -0
  17. data/lib/gsc/cli/aio_hunter.rb +154 -0
  18. data/lib/gsc/cli/analytics.rb +788 -0
  19. data/lib/gsc/cli/audit.rb +1976 -0
  20. data/lib/gsc/cli/base.rb +384 -0
  21. data/lib/gsc/cli/cache.rb +266 -0
  22. data/lib/gsc/cli/canonical.rb +223 -0
  23. data/lib/gsc/cli/citation_simulator.rb +152 -0
  24. data/lib/gsc/cli/dashboard.rb +354 -0
  25. data/lib/gsc/cli/doctor.rb +129 -0
  26. data/lib/gsc/cli/eeat.rb +125 -0
  27. data/lib/gsc/cli/ga4.rb +852 -0
  28. data/lib/gsc/cli/growth.rb +650 -0
  29. data/lib/gsc/cli/hreflang.rb +164 -0
  30. data/lib/gsc/cli/image_seo.rb +162 -0
  31. data/lib/gsc/cli/indexing.rb +458 -0
  32. data/lib/gsc/cli/intent_shift.rb +125 -0
  33. data/lib/gsc/cli/keyword_value.rb +134 -0
  34. data/lib/gsc/cli/keywords.rb +795 -0
  35. data/lib/gsc/cli/landing_roi.rb +308 -0
  36. data/lib/gsc/cli/low_ctr.rb +213 -0
  37. data/lib/gsc/cli/mobile_parity.rb +150 -0
  38. data/lib/gsc/cli/report.rb +100 -0
  39. data/lib/gsc/cli/rich_results.rb +172 -0
  40. data/lib/gsc/cli/schema_generate.rb +149 -0
  41. data/lib/gsc/cli/seasonal.rb +232 -0
  42. data/lib/gsc/cli/security.rb +153 -0
  43. data/lib/gsc/cli/setup.rb +1291 -0
  44. data/lib/gsc/cli/sitemap_tree.rb +143 -0
  45. data/lib/gsc/cli/skill_pack.rb +62 -0
  46. data/lib/gsc/cli/soft_404.rb +199 -0
  47. data/lib/gsc/cli/sparkline.rb +227 -0
  48. data/lib/gsc/cli/watchdog.rb +150 -0
  49. data/lib/gsc/cli/zombie_purger.rb +208 -0
  50. data/lib/gsc/cli.rb +706 -5111
  51. data/lib/gsc/cli_advanced.rb +1513 -0
  52. data/lib/gsc/client.rb +17 -2
  53. data/lib/gsc/color.rb +16 -1
  54. data/lib/gsc/command_registry.rb +47 -9
  55. data/lib/gsc/config.rb +11 -2
  56. data/lib/gsc/content_gap.rb +112 -0
  57. data/lib/gsc/ctr_curve.rb +115 -0
  58. data/lib/gsc/decay_predictor.rb +322 -0
  59. data/lib/gsc/doctor.rb +434 -0
  60. data/lib/gsc/eeat_auditor.rb +428 -0
  61. data/lib/gsc/entity_auditor.rb +229 -0
  62. data/lib/gsc/firewall_scanner.rb +733 -0
  63. data/lib/gsc/geo_auditor.rb +368 -0
  64. data/lib/gsc/google_suggest.rb +109 -0
  65. data/lib/gsc/google_trends.rb +8 -1
  66. data/lib/gsc/heading_validator.rb +283 -0
  67. data/lib/gsc/hreflang_validator.rb +412 -0
  68. data/lib/gsc/image_seo.rb +286 -0
  69. data/lib/gsc/indexing_queue.rb +179 -0
  70. data/lib/gsc/indexnow.rb +93 -0
  71. data/lib/gsc/intent_shift.rb +188 -0
  72. data/lib/gsc/internal_links.rb +249 -0
  73. data/lib/gsc/keyword_value.rb +191 -0
  74. data/lib/gsc/landing_roi.rb +195 -0
  75. data/lib/gsc/llms_generator.rb +425 -0
  76. data/lib/gsc/low_ctr_rewriter.rb +408 -0
  77. data/lib/gsc/mobile_parity.rb +222 -0
  78. data/lib/gsc/network_tracer.rb +93 -0
  79. data/lib/gsc/open_page_rank.rb +72 -0
  80. data/lib/gsc/page_analyzer.rb +47 -7
  81. data/lib/gsc/page_comparator.rb +108 -0
  82. data/lib/gsc/page_speed.rb +110 -0
  83. data/lib/gsc/prompts.rb +38 -29
  84. data/lib/gsc/questions_harvester.rb +178 -0
  85. data/lib/gsc/report_generator.rb +461 -0
  86. data/lib/gsc/rich_results.rb +388 -0
  87. data/lib/gsc/robots_checker.rb +114 -0
  88. data/lib/gsc/schema_generator.rb +788 -0
  89. data/lib/gsc/schema_validator.rb +120 -0
  90. data/lib/gsc/seasonal_predictor.rb +381 -0
  91. data/lib/gsc/security_scanner.rb +496 -0
  92. data/lib/gsc/serp_feature_detector.rb +359 -0
  93. data/lib/gsc/serp_preview.rb +152 -0
  94. data/lib/gsc/site_crawler.rb +113 -21
  95. data/lib/gsc/sitemap_loader.rb +15 -4
  96. data/lib/gsc/sitemap_tree.rb +301 -0
  97. data/lib/gsc/skill_pack.rb +195 -0
  98. data/lib/gsc/soft_404_analyzer.rb +385 -0
  99. data/lib/gsc/sparkline.rb +171 -0
  100. data/lib/gsc/speed_correlator.rb +416 -0
  101. data/lib/gsc/striking_playbook.rb +190 -0
  102. data/lib/gsc/title_optimizer.rb +420 -0
  103. data/lib/gsc/vault.rb +260 -0
  104. data/lib/gsc/version.rb +1 -1
  105. data/lib/gsc/watchdog.rb +235 -0
  106. data/lib/gsc/zombie_purger.rb +366 -0
  107. data/lib/gsc.rb +144 -0
  108. metadata +91 -2
@@ -0,0 +1,308 @@
1
+ # encoding: utf-8
2
+ # frozen_string_literal: true
3
+
4
+ require 'json'
5
+ require_relative 'base'
6
+ require_relative '../landing_roi'
7
+ require_relative '../color'
8
+
9
+ module GSC
10
+ class CLI
11
+ module LandingRoi
12
+ module_function
13
+
14
+ def run(command, target, extra, options, api = nil, site_url = nil, hostname = nil)
15
+ analyzer = GSC::LandingRoi.new(
16
+ aov: options[:aov] || 75.0,
17
+ conv_rate: options[:conv_rate] ? (options[:conv_rate].to_f / (options[:conv_rate].to_f > 1.0 ? 100.0 : 1.0)) : 0.025,
18
+ benchmark_bounce: options[:benchmark] || 45.0,
19
+ cpc: options[:cpc] || 1.50
20
+ )
21
+
22
+ # Mode A: Standalone URL parameter inspection / modeling
23
+ if target && target =~ %r{^https?://}
24
+ clicks = (options[:clicks] || 0).to_i
25
+ impressions = (options[:impressions] || 0).to_i
26
+ position = (options[:position] || 0.0).to_f
27
+ ctr = (options[:ctr] || (impressions.positive? ? ((clicks.to_f / impressions) * 100.0).round(2) : 0.0)).to_f
28
+
29
+ # If live GSC API is connected and no manual CLI overrides given, fetch live page metrics
30
+ if api && site_url && clicks.zero? && impressions.zero?
31
+ begin
32
+ page_res = api.query_analytics(site_url, days: options[:days] || 28, dimensions: ['page'], page_filter: target, row_limit: 5)
33
+ if page_res[:ok] && (row = (page_res.dig(:data, 'rows') || []).first)
34
+ clicks = row['clicks'] || 0
35
+ impressions = row['impressions'] || 0
36
+ ctr = ((row['ctr'] || 0) * 100.0).round(2)
37
+ position = (row['position'] || 0).round(1)
38
+ end
39
+ rescue StandardError
40
+ end
41
+ end
42
+
43
+ bounce_rate = (options[:bounce] || options[:bounce_rate])&.to_f
44
+ duration = options[:duration]&.to_f
45
+ ga4_found = false
46
+
47
+ # If live GA4 API is connected and no manual bounce override given, fetch live page engagement
48
+ if api && (bounce_rate.nil? || duration.nil?)
49
+ host_to_use = hostname || (URI.parse(target).host rescue nil)
50
+ property_id = options[:property] || (Config.ga4_property_id(host_to_use) rescue nil)
51
+ if property_id
52
+ norm_path = Base.normalize_path(target)
53
+ begin
54
+ ga4_res = api.query_ga4_report(property_id, days: options[:days] || 28, limit: 150, organic_only: options[:organic], hostname: host_to_use)
55
+ if ga4_res[:ok]
56
+ (ga4_res.dig(:data, 'rows') || []).each do |r|
57
+ path_val = r.dig('dimensionValues', 0, 'value') || ''
58
+ if Base.normalize_path(path_val) == norm_path
59
+ bounce_rate ||= r.dig('metricValues', 3, 'value').to_f
60
+ duration ||= r.dig('metricValues', 4, 'value').to_f
61
+ ga4_found = true
62
+ break
63
+ end
64
+ end
65
+ end
66
+ rescue StandardError
67
+ end
68
+ end
69
+ end
70
+
71
+ bounce_rate ||= 50.0
72
+ duration ||= 45.0
73
+
74
+ page = {
75
+ url: target,
76
+ clicks: clicks,
77
+ impressions: impressions,
78
+ position: position,
79
+ ctr: ctr,
80
+ sessions: (options[:sessions] || clicks).to_i,
81
+ bounce_rate: bounce_rate.round(1),
82
+ duration: duration.round(1)
83
+ }
84
+ report = analyzer.analyze_pages([page])
85
+ if options[:json]
86
+ puts JSON.pretty_generate(report)
87
+ else
88
+ render_roi_report(report, options, hostname || target, has_ga4: ga4_found)
89
+ end
90
+ return
91
+ end
92
+
93
+ # Mode B: Domain-level Search Console & GA4 correlation
94
+ unless api && site_url
95
+ if options[:json]
96
+ puts JSON.pretty_generate({ error: 'Search Console credentials required for domain-level landing ROI. Run gsc setup or gsc connect.' })
97
+ else
98
+ puts Color.red("âš ī¸ Authentication required: Connect Google Search Console credentials with `gsc setup` or `gsc connect` to analyze domain landing page ROI.")
99
+ puts " Or model a specific landing page directly: `gsc landing-roi https://example.com/page --clicks 1000 --bounce 75`"
100
+ end
101
+ return
102
+ end
103
+
104
+ # Live GSC Data Acquisition
105
+ gsc_res = api.query_analytics(site_url, days: options[:days] || 28, dimensions: ['page'], row_limit: 100)
106
+ gsc_rows = gsc_res[:ok] ? (gsc_res.dig(:data, 'rows') || []) : []
107
+
108
+ if gsc_rows.empty?
109
+ if options[:json]
110
+ puts JSON.pretty_generate({ error: 'No GSC landing page data returned' })
111
+ else
112
+ puts Color.red("âš ī¸ No search performance data found for #{site_url}")
113
+ end
114
+ return
115
+ end
116
+
117
+ # Check GA4 Data
118
+ property_id = options[:property] || (Config.ga4_property_id(hostname) rescue nil)
119
+ ga4_index = {}
120
+
121
+ if property_id
122
+ ga4_res = api.query_ga4_report(property_id, days: options[:days] || 28, limit: 150, organic_only: options[:organic], hostname: (options[:all_hosts] ? nil : hostname), site_only: options[:site_only]) rescue { ok: false }
123
+ if ga4_res[:ok]
124
+ (ga4_res.dig(:data, 'rows') || []).each do |r|
125
+ path_val = r.dig('dimensionValues', 0, 'value') || '/'
126
+ norm = Base.normalize_path(path_val)
127
+ sessions = r.dig('metricValues', 0, 'value').to_i
128
+ bounce = r.dig('metricValues', 3, 'value').to_f
129
+ dur = r.dig('metricValues', 4, 'value').to_f
130
+ ga4_index[norm] = { sessions: sessions, bounce_rate: bounce, duration: dur }
131
+ end
132
+ end
133
+ end
134
+
135
+ # Merge Pages
136
+ merged_pages = gsc_rows.map do |row|
137
+ full_url = row['keys'][0]
138
+ norm = Base.normalize_path(full_url)
139
+ clicks = row['clicks'].to_i
140
+ imp = row['impressions'].to_i
141
+ pos = row['position'].to_f.round(1)
142
+ ctr = (row['ctr'].to_f * 100.0).round(2)
143
+
144
+ ga4_data = ga4_index[norm]
145
+ if ga4_data
146
+ sessions = ga4_data[:sessions]
147
+ bounce = ga4_data[:bounce_rate]
148
+ duration = ga4_data[:duration]
149
+ else
150
+ sessions = clicks
151
+ bounce = nil # Will use heuristic
152
+ duration = 60.0
153
+ end
154
+
155
+ {
156
+ url: full_url,
157
+ clicks: clicks,
158
+ impressions: imp,
159
+ position: pos,
160
+ ctr: ctr,
161
+ sessions: sessions,
162
+ bounce_rate: bounce,
163
+ duration: duration
164
+ }
165
+ end
166
+
167
+ report = analyzer.analyze_pages(merged_pages)
168
+
169
+ if options[:json]
170
+ puts JSON.pretty_generate(report)
171
+ return
172
+ end
173
+
174
+ render_roi_report(report, options, hostname || site_url, has_ga4: !property_id.nil?)
175
+
176
+ if options[:csv]
177
+ csv_rows = report[:pages].map do |p|
178
+ [
179
+ p[:url],
180
+ p[:clicks],
181
+ "#{p[:bounce_rate]}%",
182
+ p[:duration_seconds],
183
+ p[:pehi_score],
184
+ p[:quadrant].to_s.upcase,
185
+ p[:lost_visitors],
186
+ "$#{p[:monthly_revenue_leak]}",
187
+ "$#{p[:annual_revenue_leak]}",
188
+ "$#{p[:actual_revenue_est]}",
189
+ p[:prescriptions].first
190
+ ]
191
+ end
192
+ Base.write_csv(
193
+ options[:csv],
194
+ %w[URL Clicks BounceRate DurationSeconds PEHIScore Quadrant LostVisitors MonthlyLeak AnnualLeak ActualRevenueEst TopFix],
195
+ csv_rows
196
+ )
197
+ puts Color.cyan("📁 Exported Landing Page ROI Report to #{options[:csv]}")
198
+ end
199
+ end
200
+
201
+ def render_roi_report(report, options, target_name, has_ga4: true)
202
+ puts "\n" + Color.cyan("╔" + "═" * 78 + "╗")
203
+ puts Color.cyan("║") + Color.bold(" 💰 LANDING PAGE ECONOMIC EFFICIENCY & REVENUE LEAKAGE AUDIT ") + Color.cyan("║")
204
+ customized = options[:aov] || options[:conv_rate] || options[:benchmark]
205
+ param_label = customized ? Color.green("(Custom Model)") : Color.gray("(Defaults: override with --aov, --conv-rate, --benchmark)")
206
+ puts Color.gray(" Economic Model: ") + Color.dim("AOV: $#{report[:assumptions][:aov]} | Conv: #{report[:assumptions][:conv_rate_pct]}% | Target Bounce: #{report[:assumptions][:benchmark_bounce_pct]}% ") + param_label
207
+ puts Color.gray(" Leakage Formula: ") + Color.dim("Excess Bounce (Actual % - Target %) × Clicks × Conv Rate × AOV")
208
+ unless has_ga4
209
+ puts Color.yellow(" âš ī¸ GA4 Notice: No GA4 property linked; using position-correlated bounce rate heuristics.")
210
+ puts Color.gray(" Link GA4 with ") + Color.cyan("gsc config set-ga4 <id>") + Color.gray(" for exact analytics.")
211
+ end
212
+ puts Color.cyan("─" * 80)
213
+
214
+ # Financial Summary Cards
215
+ leak_color = report[:total_monthly_leak] > 1000.0 ? Color::RED : Color::YELLOW
216
+ puts "\n" + Color.bold("đŸ’ĩ MACRO REVENUE IMPACT:")
217
+ puts " â€ĸ Monthly Revenue Hemorrhage: " + Color.c("$#{report[:total_monthly_leak].to_s.reverse.gsub(/(\d{3})(?=\d)/, '\\1,').reverse}", leak_color, Color::BOLD)
218
+ puts " â€ĸ Annualized Revenue at Risk: " + Color.c("$#{report[:total_annual_leak].to_s.reverse.gsub(/(\d{3})(?=\d)/, '\\1,').reverse}", leak_color, Color::BOLD)
219
+ puts " â€ĸ Total Search Visitors Lost: " + Color.red(report[:total_lost_visitors].to_s) + Color.gray(" (Excess bounce over benchmark)")
220
+ puts " â€ĸ Total Clicks Analyzed: #{report[:total_clicks]} across #{report[:total_pages]} landing pages"
221
+
222
+ if report[:total_clicks].zero?
223
+ puts "\n" + Color.yellow(" â„šī¸ Why is Revenue Leakage $0.0?")
224
+ puts " â€ĸ Google Search Console recorded 0 organic search clicks for this URL in this period."
225
+ puts " â€ĸ Revenue leakage calculates money lost from active visitors who landed and bounced."
226
+ puts " â€ĸ Since 0 visitors arrived, 0 visitors bounced (0 clicks × Conv Rate × AOV = $0.0)."
227
+ puts "\n 💡 " + Color.bold("Want to forecast potential revenue or model traffic scenarios?")
228
+ puts " Pass #{Color.cyan('--clicks <N>')} to simulate traffic (and optional #{Color.cyan('--bounce <%>')}):"
229
+ sample_clicks = 1000
230
+ sample_aov = report[:assumptions][:aov]
231
+ sample_cvr = (report[:assumptions][:conv_rate_pct] / 100.0).round(3)
232
+ puts " Example: #{Color.cyan("gsc landing-roi #{target_name} --clicks #{sample_clicks} --bounce 50 --aov #{sample_aov} --conv-rate #{sample_cvr}")}"
233
+ elsif report[:total_monthly_leak].zero?
234
+ puts "\n đŸ›Ąī¸ " + Color.green("HEALTHY BOUNCE RATE: Audited landing pages are currently performing at or better than your #{report[:assumptions][:benchmark_bounce_pct]}% target benchmark.")
235
+ puts Color.gray(" Your top traffic pages (like Homepage at 0.9% bounce) retain visitors efficiently, so zero revenue is leaking over benchmark.")
236
+ end
237
+
238
+ # Strategic Quadrant Breakdown
239
+ q = report[:quadrants] || {}
240
+ puts "\n" + Color.bold("📊 STRATEGIC QUADRANT MATRIX:")
241
+ puts " â€ĸ 💎 Cash Cow Pages (High Clicks, Low Bounce): #{Color.green(q[:cash_cows].to_s)} pages"
242
+ puts " â€ĸ 🚨 Revenue Leakers (High Clicks, High Bounce): #{Color.red(q[:revenue_leakers].to_s)} pages (Top Fix Targets)"
243
+ puts " â€ĸ ⭐ Hidden Gems (Page 2, High Conversion/Time): #{Color.cyan(q[:hidden_gems].to_s)} pages (Push to Top 3)"
244
+ puts " â€ĸ 💤 Dormant / Low-Traffic Pages: #{Color.gray(q[:zombies].to_s)} pages (Needs organic search clicks first)"
245
+
246
+ # Top Pages Table
247
+ limit = options[:limit] || 15
248
+ display_pages = report[:pages].first(limit)
249
+
250
+ puts "\n" + Color.bold("🔍 TOP LANDING PAGES BY REVENUE LEAKAGE:")
251
+ puts Color.dim(" " + "Landing Page Path".ljust(35) + "Clicks".rjust(7) + "Bounce".rjust(8) + "PEHI".rjust(7) + "Mo. Leak".rjust(11) + " Quadrant Status")
252
+ puts Color.cyan("─" * 80)
253
+
254
+ display_pages.each do |p|
255
+ clean_path = p[:url].sub(%r{^https?://[^/]+}, '')
256
+ clean_path = '/' if clean_path.empty?
257
+ url_str = clean_path.length > 33 ? (clean_path[0..30] + '..') : clean_path
258
+ clicks_str = p[:clicks].to_s.rjust(7)
259
+
260
+ b_str = "#{p[:bounce_rate]}%".rjust(8)
261
+ b_colored = if p[:bounce_rate] >= 65.0
262
+ Color.red(b_str)
263
+ elsif p[:bounce_rate] <= 40.0
264
+ Color.green(b_str)
265
+ else
266
+ Color.yellow(b_str)
267
+ end
268
+
269
+ pehi_str = "#{p[:pehi_score]}/100".rjust(7)
270
+ pehi_colored = if p[:pehi_score] >= 70
271
+ Color.green(pehi_str)
272
+ elsif p[:pehi_score] <= 40
273
+ Color.red(pehi_str)
274
+ else
275
+ Color.yellow(pehi_str)
276
+ end
277
+
278
+ leak_str = "$#{p[:monthly_revenue_leak]}".rjust(11)
279
+ leak_colored = p[:monthly_revenue_leak] > 0 ? Color.red(leak_str) : Color.green(leak_str)
280
+
281
+ quad_badge = case p[:quadrant]
282
+ when :cash_cow then Color.green("[CASH COW]")
283
+ when :revenue_leaker then Color.red("[LEAKER]")
284
+ when :hidden_gem then Color.cyan("[HIDDEN GEM]")
285
+ else
286
+ p[:clicks].zero? ? Color.gray("[DORMANT: 0 CLICKS]") : Color.gray("[LOW TRAFFIC]")
287
+ end
288
+
289
+ puts " #{url_str.ljust(35)}#{clicks_str}#{b_colored}#{pehi_colored}#{leak_colored} #{quad_badge}"
290
+
291
+ if p[:prescriptions] && p[:prescriptions].any? && p[:monthly_revenue_leak] > 0
292
+ puts " â†ŗ " + Color.yellow(p[:prescriptions].first.to_s)
293
+ end
294
+ end
295
+
296
+ puts "\n" + Color.bold("📖 METRIC DEFINITIONS & QUICK GUIDE:")
297
+ puts Color.gray(" â€ĸ PEHI (Page Economic Health Index 0–100): Composite score of visitor engagement and traffic potential.")
298
+ puts Color.gray(" â€ĸ 💎 [CASH COW]: High traffic, low bounce rate (protect and maintain current organic rankings).")
299
+ puts Color.gray(" â€ĸ 🚨 [LEAKER]: High traffic, above-benchmark bounce rate (highest priority CRO targets).")
300
+ puts Color.gray(" â€ĸ ⭐ [HIDDEN GEM]: High engagement, ranking on page 2 (add internal links to push into Top 3).")
301
+ puts Color.gray(" â€ĸ 💤 [DORMANT]: 0–24 search clicks in GSC. Focus on rankings before CRO, or test with --clicks <N>.")
302
+
303
+
304
+ puts Color.cyan("═" * 80) + "\n"
305
+ end
306
+ end
307
+ end
308
+ end
@@ -0,0 +1,213 @@
1
+ # encoding: utf-8
2
+ # frozen_string_literal: true
3
+
4
+ require 'json'
5
+ require_relative 'base'
6
+ require_relative '../low_ctr_rewriter'
7
+ require_relative '../color'
8
+
9
+ module GSC
10
+ class CLI
11
+ module LowCtr
12
+ module_function
13
+
14
+ def run(command, target, extra, options, api = nil, site_url = nil, hostname = nil)
15
+ # Mode A: Single query or specific target URL provided without needing GSC API
16
+ if target && !target.strip.empty? && (target =~ %r{^https?://} || (target !~ /\.(com|org|net|io|app|co|dev|store)$/ && !api))
17
+ handle_standalone_target(target, options)
18
+ else
19
+ # Mode B: Domain-wide Search Console audit
20
+ handle_domain_audit(api, site_url, hostname, target, options)
21
+ end
22
+ end
23
+
24
+ def handle_standalone_target(target, options)
25
+ brand = (options[:brand_name] || (options[:brand].is_a?(String) ? options[:brand] : nil) || '').to_s.strip
26
+ is_url = target =~ %r{^https?://}
27
+
28
+ if is_url
29
+ # Specific URL: fetch title & synthesize
30
+ rewriter = GSC::LowCtrRewriter.new([], options.merge(brand_name: brand, fetch_live_titles: true))
31
+ title_info = rewriter.send(:fetch_page_title_info, target)
32
+ primary_query = rewriter.send(:extract_slug_topic, target)
33
+ brand = rewriter.send(:extract_brand_from_url, target) if brand.empty?
34
+ hooks = rewriter.synthesize_three_hooks(primary_query, brand, target, title_info[:title])
35
+ meta = rewriter.synthesize_meta_description(primary_query, brand)
36
+
37
+ data = {
38
+ url: target,
39
+ primary_query: primary_query,
40
+ current_title: title_info[:title],
41
+ current_pixel_width: title_info[:pixel_width],
42
+ current_char_count: title_info[:char_count],
43
+ truncated: title_info[:truncated],
44
+ suggested_rewrites: hooks,
45
+ suggested_meta: meta,
46
+ html_title: "<title>#{hooks.first[:title]}</title>",
47
+ html_meta: "<meta name=\"description\" content=\"#{meta}\">"
48
+ }
49
+ else
50
+ # Keyword phrase
51
+ brand = 'Brand' if brand.empty?
52
+ hooks = GSC::LowCtrRewriter.generate_title_hooks(target, brand)
53
+ meta = GSC::LowCtrRewriter.generate_meta_description(target, brand)
54
+
55
+ data = {
56
+ query: target,
57
+ brand: brand,
58
+ suggested_rewrites: hooks,
59
+ suggested_meta: meta,
60
+ html_title: "<title>#{hooks.first[:title]}</title>",
61
+ html_meta: "<meta name=\"description\" content=\"#{meta}\">"
62
+ }
63
+ end
64
+
65
+ if options[:json]
66
+ puts JSON.pretty_generate(data)
67
+ return
68
+ end
69
+
70
+ puts "\n" + Color.cyan("=" * 80)
71
+ puts Color.bold(" LOW-CTR HIGH-IMPRESSION TITLE REWRITER")
72
+ puts Color.cyan("=" * 80)
73
+ if is_url
74
+ puts Color.gray(" Target URL: ") + Color.bold(target)
75
+ puts Color.gray(" Detected Query:") + Color.yellow(" \"#{data[:primary_query]}\"")
76
+ puts Color.gray(" Current Title: ") + Color.dim("#{data[:current_title]} (#{data[:current_char_count]} chars / #{data[:current_pixel_width]}px)")
77
+ if data[:truncated]
78
+ puts Color.red(" âš ī¸ Current title exceeds 560px and is TRUNCATED on Google SERP!")
79
+ end
80
+ else
81
+ puts Color.gray(" Target Query: ") + Color.bold("\"#{target}\"")
82
+ puts Color.gray(" Brand Token: ") + Color.yellow(brand)
83
+ end
84
+ puts Color.cyan("-" * 80)
85
+
86
+ puts "\n" + Color.bold("đŸˇī¸ 3 HIGH-CONVERTING HOOK TITLE REWRITES (Guaranteed < 560px SERP Safe):")
87
+ data[:suggested_rewrites].each_with_index do |hook, idx|
88
+ fit_badge = hook[:fits_serp] ? Color.green("✓ SERP Safe (#{hook[:pixel_width]}px)") : Color.red("Over limit")
89
+ puts "\n #{idx + 1}. #{Color.bold(hook[:type])}:"
90
+ puts " #{Color.c(hook[:title], Color::YELLOW, Color::BOLD)}"
91
+ puts " #{Color.gray("Width: #{hook[:char_count]} chars / #{hook[:pixel_width]}px | [#{fit_badge}]")}"
92
+ puts " #{Color.dim("â†ŗ #{hook[:rationale]}")}"
93
+ end
94
+
95
+ puts "\n" + Color.bold("📝 HIGH-CTR META DESCRIPTION (140–155 chars):")
96
+ puts " #{Color.white(data[:suggested_meta])}"
97
+
98
+ puts "\n" + Color.bold("đŸ’ģ 1-CLICK HTML SNIPPET:")
99
+ puts " #{Color.dim(data[:html_title])}"
100
+ puts " #{Color.dim(data[:html_meta])}"
101
+ puts "\n" + Color.cyan("=" * 80) + "\n"
102
+ end
103
+
104
+ def handle_domain_audit(api, site_url, hostname, target, options)
105
+ domain = target || hostname || (site_url ? site_url.sub(%r{^https?://}, '').sub(%r{/$}, '') : 'Current Domain')
106
+ days = (options[:days] || 28).to_i
107
+ min_imp = (options[:min_imp] || options[:min_impressions] || 50).to_i
108
+ cpc = (options[:cpc] || 1.50).to_f
109
+
110
+ puts Color.cyan("⚡ Scanning GSC Performance data for #{Color.bold(domain)} (Past #{days} days, Min #{min_imp} imp)...") unless options[:json]
111
+
112
+ rows = []
113
+ if api && site_url
114
+ begin
115
+ res = api.query_analytics(
116
+ site_url,
117
+ days: days,
118
+ dimensions: %w[query page],
119
+ row_limit: 5000
120
+ )
121
+ rows = res.dig(:data, 'rows') || [] if res[:ok]
122
+ rescue StandardError => e
123
+ puts Color.yellow("âš ī¸ Note: Live Search Console query returned an error: #{e.message}") unless options[:json]
124
+ end
125
+ end
126
+
127
+ analysis = GSC::LowCtrRewriter.analyze(rows, options.merge(min_imp: min_imp, cpc: cpc))
128
+
129
+ if options[:json]
130
+ puts JSON.pretty_generate(analysis)
131
+ return
132
+ end
133
+
134
+ if analysis[:pages].empty?
135
+ puts "\n" + Color.green("✅ No severe CTR underperformers found! Your ranking pages meet or exceed SERP benchmarks.")
136
+ puts Color.gray("💡 Tip: Lower --min-imp (e.g. gsc low-ctr --min-imp 20) or adjust --days 90 to scan deeper.")
137
+ return
138
+ end
139
+
140
+ puts "\n" + Color.cyan("╔" + "═" * 78 + "╗")
141
+ puts Color.cyan("║") + Color.bold(" 📉 LOW-CTR HIGH-IMPRESSION TITLE REWRITER (TRAFFIC LEAK RECOVERY) ") + Color.cyan("║")
142
+ puts Color.cyan("╚" + "═" * 78 + "╝")
143
+ puts Color.gray(" Domain: ") + Color.bold(domain)
144
+ puts Color.gray(" Leaking Pages: ") + Color.c(analysis[:total_leaking_pages].to_s, Color::YELLOW, Color::BOLD) + Color.gray(" pages ranking on Page 1-2 with low CTR")
145
+ puts Color.gray(" Monthly Lost Clicks:") + Color.c("-#{analysis[:total_monthly_lost_clicks]} clicks/month", Color::RED, Color::BOLD) + Color.gray(" leaking due to unclickable titles")
146
+ puts Color.gray(" Monthly Value Lost: ") + Color.c("-$#{analysis[:estimated_monthly_value_lost]} / month", Color::RED, Color::BOLD) + Color.gray(" (@ $#{cpc} CPC estimate)")
147
+ rec = analysis[:projected_recovery]
148
+ puts Color.gray(" Projected Recovery: ") + Color.c("+#{rec[:realistic_50pct]} clicks/mo (50% fix)", Color::GREEN, Color::BOLD) + Color.gray(" | ") + Color.c("+#{rec[:full_parity_100pct]} clicks/mo (100% parity)", Color::GREEN, Color::BOLD)
149
+ puts Color.cyan("─" * 80)
150
+
151
+ analysis[:pages].each_with_index do |p, idx|
152
+ sev_badge = case p[:severity]
153
+ when :critical then Color.c(" [CRITICAL LEAK] ", Color::RED, Color::BOLD)
154
+ when :high then Color.c(" [HIGH LEAK] ", Color::YELLOW, Color::BOLD)
155
+ else Color.cyan(" [MODERATE] ")
156
+ end
157
+
158
+ path = p[:url].sub(%r{^https?://[^/]+}, '')
159
+ path = '/' if path.empty?
160
+
161
+ puts "\n#{Color.bold("#{idx + 1}. #{path}")} #{sev_badge}"
162
+ puts " #{Color.gray("Full URL:")} #{p[:url]}"
163
+ puts " #{Color.gray("Primary Intent:")} #{Color.c("\"#{p[:primary_query]}\"", Color::YELLOW, Color::BOLD)}"
164
+ if p[:secondary_queries].any?
165
+ puts " #{Color.gray("Also Ranking for:")} #{Color.dim(p[:secondary_queries].join(', '))}"
166
+ end
167
+ puts " #{Color.gray("Metrics:")} Pos #{Color.bold(p[:position].to_s)} | #{p[:impressions]} imp | #{p[:clicks]} clicks | #{Color.red("Actual CTR: #{p[:actual_ctr]}%")} vs #{Color.green("Expected: #{p[:expected_ctr]}%")}"
168
+ puts " #{Color.gray("Hemorrhage:")} #{Color.c("-#{p[:lost_clicks]} lost clicks/mo", Color::RED, Color::BOLD)} (~$#{p[:lost_revenue]}/mo lost)"
169
+
170
+ curr_trunc = p[:current_truncated] ? Color.red(" (âš ī¸ Truncated in Google SERP >560px)") : Color.green(" (Fits SERP)")
171
+ puts " #{Color.gray("Current Title:")} #{Color.dim(p[:current_title].to_s)}#{curr_trunc}"
172
+
173
+ puts "\n #{Color.bold("đŸˇī¸ Suggested 3 Hook Title Rewrites (SERP Safe < 560px):")}"
174
+ p[:suggested_rewrites].each do |h|
175
+ puts " â€ĸ [#{h[:type]}]: #{Color.c(h[:title], Color::YELLOW, Color::BOLD)} #{Color.gray("(#{h[:pixel_width]}px)")}"
176
+ end
177
+
178
+ puts " #{Color.bold("📝 Suggested Meta Hook:")}"
179
+ puts " #{Color.white(p[:suggested_meta])}"
180
+
181
+ puts " #{Color.bold("đŸ’ģ HTML Snippet:")}"
182
+ puts " #{Color.dim(p[:code_snippets][:html_title])}"
183
+ puts Color.cyan(" " + "¡" * 74)
184
+ end
185
+
186
+ if options[:csv]
187
+ csv_rows = analysis[:pages].map do |p|
188
+ [
189
+ p[:url],
190
+ p[:primary_query],
191
+ p[:position],
192
+ p[:impressions],
193
+ p[:clicks],
194
+ p[:actual_ctr],
195
+ p[:expected_ctr],
196
+ p[:lost_clicks],
197
+ p[:lost_revenue],
198
+ p[:suggested_rewrites][0]&.dig(:title),
199
+ p[:suggested_rewrites][1]&.dig(:title),
200
+ p[:suggested_rewrites][2]&.dig(:title),
201
+ p[:suggested_meta]
202
+ ]
203
+ end
204
+ headers = %w[URL PrimaryQuery Position Impressions Clicks ActualCTR ExpectedCTR LostClicks LostRevenueUSD HookTitle1 HookTitle2 HookTitle3 MetaDescription]
205
+ Base.write_csv(options[:csv], headers, csv_rows)
206
+ puts Color.cyan("\n📁 Exported #{analysis[:pages].size} title rewrites to #{options[:csv]}")
207
+ end
208
+
209
+ puts "\n💡 #{Color.bold("Execution Tip:")} Update title tags for the top 3 critical leaking pages first. Google typically re-indexes and reflects new CTR within 48 to 72 hours!\n\n"
210
+ end
211
+ end
212
+ end
213
+ end
@@ -0,0 +1,150 @@
1
+ # encoding: utf-8
2
+ # frozen_string_literal: true
3
+
4
+ require 'json'
5
+ require_relative 'base'
6
+ require_relative '../mobile_parity'
7
+ require_relative '../color'
8
+
9
+ module GSC
10
+ class CLI
11
+ module MobileParity
12
+ module_function
13
+
14
+ def run(command, target, extra, options, api = nil, site_url = nil, hostname = nil)
15
+ target_domain = target || hostname || (Config.default_domain rescue nil)
16
+ if target_domain.nil? || target_domain.to_s.strip.empty?
17
+ if options[:json]
18
+ puts JSON.pretty_generate({ error: 'Target domain required. Example: gsc mobile-parity mysite.com or gsc use mysite.com' })
19
+ else
20
+ puts Color.c("❌ Error: Target domain required. Example: gsc mobile-parity mysite.com or gsc use mysite.com", Color::RED, Color::BOLD)
21
+ end
22
+ return
23
+ end
24
+ target_domain = target_domain.sub(%r{^https?://}, '').sub(%r{/.*$}, '')
25
+
26
+ # Resolve verified GSC property URL if authenticated
27
+ resolved_site = site_url
28
+ if api && (!resolved_site || (target && target != hostname))
29
+ resolved_site = "sc-domain:#{target_domain}"
30
+ end
31
+
32
+ puts "📱 Auditing Mobile vs Desktop SERP parity and responsive penalties for #{Color.cyan(target_domain)}..." unless options[:json]
33
+
34
+ res = GSC::MobileParity.audit(options.merge(site_url: resolved_site), api, target_domain)
35
+
36
+ if options[:json]
37
+ puts JSON.pretty_generate(res)
38
+ return
39
+ end
40
+
41
+ render_terminal(res)
42
+
43
+ if options[:csv]
44
+ export_csv(res, options[:csv])
45
+ end
46
+ end
47
+
48
+ def render_terminal(res)
49
+ t = res[:traffic_distribution]
50
+
51
+ puts "\n#{Color.bold("═══════════════════════════════════════════════════════════════════════════")}"
52
+ puts "#{Color.bold("📱 MOBILE VS DESKTOP SERP PARITY & RESPONSIVE AUDITOR")}"
53
+ puts "#{Color.bold("═══════════════════════════════════════════════════════════════════════════")}"
54
+
55
+ if res[:total_queries_analyzed].zero?
56
+ puts " â€ĸ Mobile Parity Health: [ #{Color.gray('N/A')} ] Insufficient Data (0 Queries Analyzed)"
57
+ puts " â€ĸ Device Traffic Split: 0.0% Mobile | 0.0% Desktop (No click data)"
58
+ puts " â€ĸ Total Analyzed Queries: 0 keywords"
59
+ puts " â€ĸ Critical Mobile Demotions: 0"
60
+ puts " â€ĸ Moderate Mobile Lags: 0"
61
+ puts " â€ĸ Estimated Lost Mobile Clicks: 0"
62
+ puts "#{Color.bold("───────────────────────────────────────────────────────────────────────────")}\n"
63
+ puts " â„šī¸ No cross-device query disparity data found for #{res[:domain]}."
64
+ puts " Search Console has not recorded mobile vs desktop impressions meeting the threshold (min 5 impressions)."
65
+ puts " Try expanding the date window: #{Color.cyan("gsc mobile-parity #{res[:domain]} --days 90")}"
66
+ puts "\n#{Color.bold("═══════════════════════════════════════════════════════════════════════════")}\n"
67
+ return
68
+ end
69
+
70
+ grade_color = case res[:grade]
71
+ when 'A' then Color.green(res[:grade])
72
+ when 'B' then Color.cyan(res[:grade])
73
+ when 'C' then Color.yellow(res[:grade])
74
+ else Color.red(res[:grade])
75
+ end
76
+
77
+ puts " â€ĸ Mobile Parity Health: [ #{Color.bold(grade_color)} ] #{res[:health_score]}/100 Health Score"
78
+ puts " â€ĸ Device Traffic Split: #{Color.bold(t[:mobile_share_pct].to_s + "% Mobile")} | #{t[:desktop_share_pct]}% Desktop"
79
+ puts " â€ĸ Total Analyzed Queries: #{Color.bold(res[:total_queries_analyzed].to_s)} keywords"
80
+ puts " â€ĸ Critical Mobile Demotions: #{res[:critical_suppression_count] > 0 ? Color.red(res[:critical_suppression_count].to_s) : Color.green("0")}"
81
+ puts " â€ĸ Moderate Mobile Lags: #{res[:moderate_suppression_count] > 0 ? Color.yellow(res[:moderate_suppression_count].to_s) : Color.green("0")}"
82
+ puts " â€ĸ Estimated Lost Mobile Clicks: #{res[:total_estimated_lost_mobile_clicks] > 0 ? Color.red("~" + res[:total_estimated_lost_mobile_clicks].to_s + " clicks/mo") : Color.green("0")}"
83
+ puts "#{Color.bold("───────────────────────────────────────────────────────────────────────────")}\n"
84
+
85
+ puts "#{Color.bold("CROSS-DEVICE SERP RANKINGS & DISPARITY MATRIX:")}\n\n"
86
+ printf " %-30s %-10s %-10s %-8s %-12s %s\n", "Search Query", "Desktop", "Mobile", "Gap", "Lost Clicks", "Status"
87
+ puts " -----------------------------------------------------------------------------------------"
88
+
89
+ res[:disparities].each do |d|
90
+ q_str = d[:query].length > 28 ? "#{d[:query][0..25]}..." : d[:query]
91
+ status_str = case d[:status]
92
+ when :critical_suppression then Color.red("🚨 CRITICAL")
93
+ when :moderate_suppression then Color.yellow("âš ī¸ LAG")
94
+ when :mobile_advantaged then Color.cyan("📱 FAVORED")
95
+ else Color.green("✅ PARITY")
96
+ end
97
+
98
+ gap_sign = d[:pos_gap] > 0 ? "+#{d[:pos_gap]}" : d[:pos_gap].to_s
99
+
100
+ printf " %-30s Pos %-6s Pos %-6s %-8s %-12s %s\n",
101
+ q_str,
102
+ d[:desktop][:position],
103
+ d[:mobile][:position],
104
+ gap_sign,
105
+ d[:lost_clicks] > 0 ? "~#{d[:lost_clicks]} clicks" : "-",
106
+ status_str
107
+
108
+ if d[:diagnosis] && d[:status] != :parity
109
+ puts " #{Color.yellow("â†ŗ")} #{d[:diagnosis]}"
110
+ end
111
+ end
112
+
113
+ puts "\n#{Color.bold("───────────────────────────────────────────────────────────────────────────")}"
114
+
115
+ if res[:priority_remediations].any?
116
+ puts "#{Color.bold("PRIORITY TECHNICAL RESPONSIVE REMEDIATIONS:")}"
117
+ res[:priority_remediations].each_with_index do |rem, idx|
118
+ puts " #{idx + 1}. đŸ› ī¸ #{rem}"
119
+ end
120
+ puts
121
+ end
122
+
123
+ puts "#{Color.bold("═══════════════════════════════════════════════════════════════════════════")}\n"
124
+ end
125
+
126
+ def export_csv(res, file_path)
127
+ headers = %w[Query Status PositionGap CTRGap LostClicks DesktopPos DesktopClicks DesktopCTR MobilePos MobileClicks MobileCTR Diagnosis Remediation]
128
+ rows = res[:disparities].map do |d|
129
+ [
130
+ d[:query],
131
+ d[:status].to_s,
132
+ d[:pos_gap],
133
+ d[:ctr_gap],
134
+ d[:lost_clicks],
135
+ d[:desktop][:position],
136
+ d[:desktop][:clicks],
137
+ d[:desktop][:ctr],
138
+ d[:mobile][:position],
139
+ d[:mobile][:clicks],
140
+ d[:mobile][:ctr],
141
+ d[:diagnosis],
142
+ d[:remediation]
143
+ ]
144
+ end
145
+ Base.write_csv(file_path, headers, rows)
146
+ puts Color.cyan("📁 Successfully exported Mobile Parity audit to #{file_path}")
147
+ end
148
+ end
149
+ end
150
+ end