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,408 @@
1
+ # encoding: utf-8
2
+ # frozen_string_literal: true
3
+
4
+ require 'net/http'
5
+ require 'uri'
6
+ require 'json'
7
+ require 'zlib'
8
+ require 'stringio'
9
+ require 'time'
10
+ require_relative 'ctr_curve' if File.exist?(File.expand_path('ctr_curve.rb', __dir__))
11
+ require_relative 'title_optimizer' if File.exist?(File.expand_path('title_optimizer.rb', __dir__))
12
+
13
+ module GSC
14
+ class LowCtrRewriter
15
+ DEFAULT_CPC_ESTIMATE = 1.50 # $1.50 baseline CPC equivalent
16
+ MAX_SERP_PX = 560.0 # Google SERP width target (< 580px limit)
17
+ MIN_CHARS = 35
18
+ MAX_CHARS = 60
19
+
20
+ attr_reader :rows, :options, :results
21
+
22
+ def self.analyze(rows, options = {})
23
+ new(rows, options).analyze
24
+ end
25
+
26
+ def initialize(rows, options = {})
27
+ @rows = rows || []
28
+ @options = options
29
+ @cpc_estimate = (options[:cpc] || options[:cpc_estimate] || DEFAULT_CPC_ESTIMATE).to_f
30
+ @min_imp = (options[:min_imp] || options[:min_impressions] || 50).to_i
31
+ @max_pos = (options[:max_pos] || 15.0).to_f
32
+ @brand = (options[:brand_name] || (options[:brand].is_a?(String) ? options[:brand] : nil) || '').to_s.strip
33
+ @results = []
34
+ end
35
+
36
+ def analyze
37
+ # 1. Normalize rows into standard query-page records
38
+ records = normalize_rows(@rows)
39
+
40
+ # 2. Filter for keywords ranking on page 1-2 (pos <= max_pos) with sufficient impressions
41
+ qualified = records.select do |r|
42
+ r[:position] <= @max_pos && r[:impressions] >= [(@min_imp / 2), 10].max
43
+ end
44
+
45
+ # 3. Group by page URL to build page-level clusters
46
+ pages_map = Hash.new { |h, k| h[k] = [] }
47
+ qualified.each { |r| pages_map[r[:page]] << r }
48
+
49
+ analyzed_pages = []
50
+ total_portfolio_lost_clicks = 0
51
+
52
+ pages_map.each do |page_url, query_records|
53
+ page_analysis = analyze_page_cluster(page_url, query_records)
54
+ next unless page_analysis[:is_leaking]
55
+
56
+ analyzed_pages << page_analysis
57
+ total_portfolio_lost_clicks += page_analysis[:lost_clicks]
58
+ end
59
+
60
+ # Sort by highest lost clicks (maximum traffic hemorrhage first)
61
+ analyzed_pages.sort_by! { |p| -p[:lost_clicks] }
62
+
63
+ limit = (@options[:limit] || 15).to_i
64
+ limited_pages = limit > 0 ? analyzed_pages.first(limit) : analyzed_pages
65
+
66
+ total_lost_revenue = (total_portfolio_lost_clicks * @cpc_estimate).round(2)
67
+
68
+ {
69
+ total_leaking_pages: analyzed_pages.size,
70
+ total_monthly_lost_clicks: total_portfolio_lost_clicks,
71
+ estimated_monthly_value_lost: total_lost_revenue,
72
+ cpc_used: @cpc_estimate,
73
+ projected_recovery: {
74
+ conservative_25pct: (total_portfolio_lost_clicks * 0.25).round,
75
+ realistic_50pct: (total_portfolio_lost_clicks * 0.50).round,
76
+ full_parity_100pct: total_portfolio_lost_clicks
77
+ },
78
+ pages: limited_pages
79
+ }
80
+ end
81
+
82
+ # Public helper to synthesize 3 title hooks for a given query and brand
83
+ def self.generate_title_hooks(primary_query, brand = '', page_url = '')
84
+ new([], brand_name: brand).synthesize_three_hooks(primary_query, brand, page_url)
85
+ end
86
+
87
+ # Public helper to synthesize high-converting meta description
88
+ def self.generate_meta_description(primary_query, brand = '')
89
+ new([], brand_name: brand).synthesize_meta_description(primary_query, brand)
90
+ end
91
+
92
+ def synthesize_three_hooks(primary_query, brand = '', page_url = '', _current_title = '')
93
+ clean_query = title_case(primary_query.to_s.strip)
94
+ clean_brand = brand.to_s.strip.capitalize
95
+ brand_suffix = clean_brand.empty? ? '' : " | #{clean_brand}"
96
+
97
+ year = Time.now.year.to_s
98
+
99
+ rewrites = []
100
+
101
+ # Hook 1: Authority / Power-Number Hook
102
+ # e.g., "Best [Query] (2026 Tested Guide) | Brand"
103
+ h1_candidate = "#{clean_query} (#{year} Tested Guide)#{brand_suffix}"
104
+ h1_final = enforce_serp_pixel_limit(h1_candidate, clean_query, brand_suffix, "#{clean_query} (#{year})#{brand_suffix}")
105
+ rewrites << build_hook_record('Authority & Power-Number Hook', h1_final, 'Adds proof year and tested authority to capture trust.')
106
+
107
+ # Hook 2: Benefit & Outcome Velocity Hook
108
+ # e.g., "How to [Query] Fast: Complete Blueprint | Brand"
109
+ verb_prefix = clean_query.downcase.start_with?('how to') ? '' : 'How to '
110
+ h2_candidate = "#{verb_prefix}#{clean_query} Fast: The Proven Blueprint#{brand_suffix}"
111
+ h2_fallback = "#{verb_prefix}#{clean_query} (Step-by-Step)#{brand_suffix}"
112
+ h2_final = enforce_serp_pixel_limit(h2_candidate, clean_query, brand_suffix, h2_fallback)
113
+ rewrites << build_hook_record('Benefit & Velocity Hook', h2_final, 'Focuses on speed and clear outcome to induce clicks.')
114
+
115
+ # Hook 3: Curiosity / Information-Gain Hook
116
+ # e.g., "The Truth About [Query]: What Works Now | Brand"
117
+ h3_candidate = "The Truth About #{clean_query} (#{year} Update)#{brand_suffix}"
118
+ h3_fallback = "#{clean_query} Explained: 5 Proven Secrets#{brand_suffix}"
119
+ h3_final = enforce_serp_pixel_limit(h3_candidate, clean_query, brand_suffix, h3_fallback)
120
+ rewrites << build_hook_record('Curiosity & Information-Gain Hook', h3_final, 'Leverages high curiosity and informational advantage.')
121
+
122
+ rewrites
123
+ end
124
+
125
+ def synthesize_meta_description(primary_query, brand = '')
126
+ clean_query = title_case(primary_query.to_s.strip)
127
+ brand_name = brand.to_s.strip.empty? ? 'our team' : brand.to_s.strip
128
+ year = Time.now.year.to_s
129
+
130
+ # 140 - 155 chars optimal target
131
+ base = "Looking for #{clean_query.downcase}? Discover the #{year} verified guide by #{brand_name}. Proven strategies, exact benchmarks & practical examples inside."
132
+ if base.length > 155
133
+ base = "Discover the #{year} guide to #{clean_query.downcase} by #{brand_name}. Proven strategies, benchmarks and actionable tips. Read now!"
134
+ end
135
+ base
136
+ end
137
+
138
+ private
139
+
140
+ def normalize_rows(raw_rows)
141
+ raw_rows.map do |r|
142
+ if r.is_a?(Hash) && r.key?('keys') && r['keys'].is_a?(Array)
143
+ q = r['keys'][0].to_s
144
+ p = r['keys'][1].to_s
145
+ imp = (r['impressions'] || 0).to_i
146
+ clk = (r['clicks'] || 0).to_i
147
+ pos = (r['position'] || 100.0).to_f.round(1)
148
+ ctr = (r['ctr'] ? (r['ctr'] * 100.0).round(2) : (imp > 0 ? (clk.to_f / imp * 100.0).round(2) : 0.0))
149
+ { query: q, page: p, impressions: imp, clicks: clk, position: pos, ctr: ctr }
150
+ elsif r.is_a?(Hash)
151
+ q = (r[:query] || r['query']).to_s
152
+ p = (r[:page] || r['page'] || r[:url] || r['url']).to_s
153
+ imp = (r[:impressions] || r['impressions'] || 0).to_i
154
+ clk = (r[:clicks] || r['clicks'] || 0).to_i
155
+ pos = (r[:position] || r['position'] || 100.0).to_f.round(1)
156
+ raw_ctr = r[:ctr] || r['ctr']
157
+ ctr = if raw_ctr && raw_ctr <= 1.0 && raw_ctr > 0.0
158
+ (raw_ctr * 100.0).round(2)
159
+ elsif raw_ctr
160
+ raw_ctr.to_f.round(2)
161
+ else
162
+ imp > 0 ? (clk.to_f / imp * 100.0).round(2) : 0.0
163
+ end
164
+ { query: q, page: p, impressions: imp, clicks: clk, position: pos, ctr: ctr }
165
+ end
166
+ end.compact
167
+ end
168
+
169
+ def analyze_page_cluster(page_url, queries)
170
+ total_imp = queries.sum { |q| q[:impressions] }
171
+ total_clicks = queries.sum { |q| q[:clicks] }
172
+ actual_ctr = total_imp > 0 ? ((total_clicks.to_f / total_imp) * 100.0).round(2) : 0.0
173
+
174
+ # Weighted average position by impressions
175
+ weighted_pos = if total_imp > 0
176
+ (queries.sum { |q| q[:position] * q[:impressions] }.to_f / total_imp).round(1)
177
+ else
178
+ queries.map { |q| q[:position] }.sum / [queries.size, 1].max
179
+ end
180
+
181
+ # Benchmark expected CTR based on weighted position
182
+ expected_ctr = benchmark_ctr_for(weighted_pos)
183
+
184
+ # Sort queries by highest impressions to pinpoint primary search intent
185
+ sorted_queries = queries.sort_by { |q| -q[:impressions] }
186
+ primary_query = sorted_queries.first ? sorted_queries.first[:query] : extract_slug_topic(page_url)
187
+ secondary_queries = sorted_queries.drop(1).first(3).map { |q| q[:query] }
188
+
189
+ # Lost Clicks Calculation (Query-by-query sum for precision)
190
+ page_lost_clicks = 0
191
+ queries.each do |q|
192
+ q_exp = benchmark_ctr_for(q[:position])
193
+ if q[:ctr] < (q_exp * 0.70) && (q_exp - q[:ctr]) >= 1.0
194
+ q_lost = [((q[:impressions] * ((q_exp - q[:ctr]) / 100.0))).round, 0].max
195
+ page_lost_clicks += q_lost
196
+ end
197
+ end
198
+
199
+ # Fallback to aggregate formula if individual sum was 0 but aggregate is leaking
200
+ ctr_gap = (expected_ctr - actual_ctr).round(2)
201
+ if page_lost_clicks == 0 && actual_ctr < (expected_ctr * 0.65) && ctr_gap >= 1.5 && total_imp >= @min_imp
202
+ page_lost_clicks = [((total_imp * (ctr_gap / 100.0))).round, 1].max
203
+ end
204
+
205
+ # A page is leaking if it has lost clicks and meets threshold
206
+ is_leaking = page_lost_clicks >= 5 && total_imp >= @min_imp
207
+
208
+ # Determine brand token
209
+ brand = @brand.empty? ? extract_brand_from_url(page_url) : @brand
210
+
211
+ # Inspect current live title tag & pixel width if enabled
212
+ current_title_info = fetch_page_title_info(page_url)
213
+
214
+ # Synthesize 3 high-converting hook title options
215
+ rewrites = synthesize_three_hooks(primary_query, brand, page_url, current_title_info[:title])
216
+
217
+ # Synthesize high-converting meta description
218
+ meta_desc = synthesize_meta_description(primary_query, brand)
219
+
220
+ # Revenue hemorrhage
221
+ lost_revenue = (page_lost_clicks * @cpc_estimate).round(2)
222
+
223
+ # Severity classification
224
+ severity = if page_lost_clicks >= 80 || (actual_ctr < expected_ctr * 0.35 && total_imp >= 200)
225
+ :critical
226
+ elsif page_lost_clicks >= 30 || (actual_ctr < expected_ctr * 0.55)
227
+ :high
228
+ else
229
+ :moderate
230
+ end
231
+
232
+ {
233
+ url: page_url,
234
+ primary_query: primary_query,
235
+ secondary_queries: secondary_queries,
236
+ impressions: total_imp,
237
+ clicks: total_clicks,
238
+ position: weighted_pos,
239
+ actual_ctr: actual_ctr,
240
+ expected_ctr: expected_ctr,
241
+ ctr_gap: ctr_gap,
242
+ lost_clicks: page_lost_clicks,
243
+ lost_revenue: lost_revenue,
244
+ severity: severity,
245
+ is_leaking: is_leaking,
246
+ current_title: current_title_info[:title],
247
+ current_pixel_width: current_title_info[:pixel_width],
248
+ current_char_count: current_title_info[:char_count],
249
+ current_truncated: current_title_info[:truncated],
250
+ current_meta_desc: current_title_info[:meta_desc],
251
+ suggested_rewrites: rewrites,
252
+ suggested_meta: meta_desc,
253
+ code_snippets: {
254
+ html_title: "<title>#{rewrites.first[:title]}</title>",
255
+ html_meta: "<meta name=\"description\" content=\"#{meta_desc}\">",
256
+ nextjs: "export const metadata = {\n title: \"#{rewrites.first[:title]}\",\n description: \"#{meta_desc}\"\n};"
257
+ }
258
+ }
259
+ end
260
+
261
+ def benchmark_ctr_for(position)
262
+ if defined?(CtrCurve) && CtrCurve.respond_to?(:benchmark_for)
263
+ CtrCurve.benchmark_for(position)
264
+ else
265
+ pos = position.to_f.round
266
+ if pos <= 1
267
+ 28.0
268
+ else
269
+ case pos
270
+ when 2 then 15.5
271
+ when 3 then 11.0
272
+ when 4 then 8.0
273
+ when 5 then 6.0
274
+ when 6 then 4.5
275
+ when 7 then 3.5
276
+ when 8 then 2.8
277
+ when 9 then 2.2
278
+ when 10 then 1.8
279
+ when 11..15 then 1.0
280
+ else 0.5
281
+ end
282
+ end
283
+ end
284
+ end
285
+
286
+
287
+ def build_hook_record(type, title_str, rationale)
288
+ px = pixel_width_of(title_str)
289
+ {
290
+ type: type,
291
+ title: title_str,
292
+ char_count: title_str.size,
293
+ pixel_width: px,
294
+ fits_serp: px <= MAX_SERP_PX,
295
+ rationale: rationale
296
+ }
297
+ end
298
+
299
+ def enforce_serp_pixel_limit(candidate, primary_query, brand_suffix, fallback)
300
+ if pixel_width_of(candidate) <= MAX_SERP_PX && candidate.size <= MAX_CHARS
301
+ return candidate
302
+ end
303
+
304
+ if pixel_width_of(fallback) <= MAX_SERP_PX && fallback.size <= MAX_CHARS
305
+ return fallback
306
+ end
307
+
308
+ # Truncate / compress query gracefully while retaining brand
309
+ condensed_q = primary_query.split.first(4).join(' ')
310
+ condensed = "#{condensed_q} Guide#{brand_suffix}"
311
+ return condensed if pixel_width_of(condensed) <= MAX_SERP_PX
312
+
313
+ "#{condensed_q}#{brand_suffix}"
314
+ end
315
+
316
+ def pixel_width_of(str)
317
+ if defined?(TitleOptimizer) && TitleOptimizer.respond_to?(:estimate_pixel_width)
318
+ TitleOptimizer.estimate_pixel_width(str)
319
+ else
320
+ (str.to_s.size * 8.5).round(1)
321
+ end
322
+ end
323
+
324
+ def title_case(str)
325
+ non_cap = %w[a an the and but or for nor on in at to from by of]
326
+ words = str.to_s.split
327
+ return '' if words.empty?
328
+
329
+ words.each_with_index.map do |word, idx|
330
+ if idx == 0 || !non_cap.include?(word.downcase)
331
+ word.capitalize
332
+ else
333
+ word.downcase
334
+ end
335
+ end.join(' ')
336
+ end
337
+
338
+ def extract_slug_topic(url)
339
+ uri = URI.parse(url) rescue nil
340
+ return 'Target Page' unless uri
341
+
342
+ slug = uri.path.to_s.split('/').last.to_s.sub(/\.[^.]+$/, '').tr('-_', ' ').strip
343
+ slug.empty? ? 'Home Page' : title_case(slug)
344
+ end
345
+
346
+ def extract_brand_from_url(url)
347
+ uri = URI.parse(url) rescue nil
348
+ return '' unless uri && uri.host
349
+
350
+ parts = uri.host.split('.')
351
+ parts.size >= 2 ? parts[-2].capitalize : parts.first.capitalize
352
+ end
353
+
354
+ def fetch_page_title_info(url)
355
+ # In testing or offline environments, provide clean defaults
356
+ return default_title_info(url) unless @options[:fetch_live_titles] && url =~ %r{^https?://}
357
+
358
+ uri = URI.parse(url) rescue nil
359
+ return default_title_info(url) unless uri
360
+
361
+ http = Net::HTTP.new(uri.host, uri.port)
362
+ http.use_ssl = (uri.scheme == 'https')
363
+ http.open_timeout = 3
364
+ http.read_timeout = 4
365
+
366
+ req = Net::HTTP::Get.new(uri.request_uri.empty? ? '/' : uri.request_uri)
367
+ req['User-Agent'] = "Mozilla/5.0 (compatible; GSC-LowCtrRewriter/#{GSC::VERSION}; +https://apolloswave.com)"
368
+
369
+ res = http.request(req)
370
+ return default_title_info(url) unless res.code.to_i >= 200 && res.code.to_i < 400
371
+
372
+ body = res.body.to_s.dup.force_encoding('UTF-8').scrub
373
+ title_match = body.match(/<title[^>]*>(.*?)<\/title>/im)
374
+ raw_title = title_match ? title_match[1].to_s.gsub(/\s+/, ' ').strip : ''
375
+
376
+ meta_match = body.match(/<meta\s+[^>]*name=["']description["'][^>]*content=["']([^"']*)["']/im) ||
377
+ body.match(/<meta\s+[^>]*content=["']([^"']*)["'][^>]*name=["']description["']/im)
378
+ meta_desc = meta_match ? meta_match[1].to_s.gsub(/\s+/, ' ').strip : ''
379
+
380
+ chars = raw_title.size
381
+ px = pixel_width_of(raw_title)
382
+
383
+ {
384
+ title: raw_title.empty? ? extract_slug_topic(url) : raw_title,
385
+ char_count: chars,
386
+ pixel_width: px,
387
+ truncated: px > MAX_SERP_PX,
388
+ meta_desc: meta_desc
389
+ }
390
+ rescue StandardError
391
+ default_title_info(url)
392
+ end
393
+
394
+ def default_title_info(url)
395
+ topic = extract_slug_topic(url)
396
+ brand = extract_brand_from_url(url)
397
+ synth = "#{topic} | #{brand}"
398
+ px = pixel_width_of(synth)
399
+ {
400
+ title: synth,
401
+ char_count: synth.size,
402
+ pixel_width: px,
403
+ truncated: px > MAX_SERP_PX,
404
+ meta_desc: ''
405
+ }
406
+ end
407
+ end
408
+ end
@@ -0,0 +1,222 @@
1
+ # encoding: utf-8
2
+ # frozen_string_literal: true
3
+
4
+ require 'json'
5
+ require 'date'
6
+
7
+ module GSC
8
+ class MobileParity
9
+ DEFAULT_GAP_THRESHOLD = 2.0 # 2+ position gap
10
+ DEFAULT_MIN_IMP = 5
11
+
12
+ attr_reader :options, :api, :domain
13
+
14
+ def initialize(options = {}, api = nil, domain = nil)
15
+ @options = options
16
+ @api = api
17
+ @domain = domain.to_s.strip
18
+ end
19
+
20
+ def self.audit(options = {}, api = nil, domain = nil)
21
+ new(options, api, domain).audit
22
+ end
23
+
24
+ def audit
25
+ paired_data = fetch_paired_data
26
+ analyzed = analyze_pairs(paired_data)
27
+ synthesize_report(analyzed)
28
+ end
29
+
30
+ private
31
+
32
+ def analyze_pairs(pairs)
33
+ gap_thresh = (@options[:gap_threshold] || DEFAULT_GAP_THRESHOLD).to_f
34
+ min_imp = (@options[:min_imp] || DEFAULT_MIN_IMP).to_i
35
+
36
+ results = []
37
+
38
+ pairs.each do |p|
39
+ d = p[:desktop] || { clicks: 0, impressions: 0, ctr: 0.0, position: 100.0 }
40
+ m = p[:mobile] || { clicks: 0, impressions: 0, ctr: 0.0, position: 100.0 }
41
+
42
+ next if (d[:impressions] + m[:impressions]) < min_imp
43
+
44
+ pos_gap = (m[:position] - d[:position]).round(1) # positive = desktop ranks better
45
+ ctr_gap = (m[:ctr] - d[:ctr]).round(2)
46
+
47
+ # Estimate lost mobile clicks if mobile matched desktop CTR
48
+ expected_mob_clicks = ((d[:ctr] / 100.0) * m[:impressions]).round
49
+ lost_clicks = [expected_mob_clicks - m[:clicks], 0].max
50
+
51
+ status = if pos_gap >= 5.0
52
+ :critical_suppression
53
+ elsif pos_gap >= gap_thresh
54
+ :moderate_suppression
55
+ elsif pos_gap <= -2.0
56
+ :mobile_advantaged
57
+ else
58
+ :parity
59
+ end
60
+
61
+ diagnosis, remediation = diagnose(p[:query], pos_gap, ctr_gap, d, m)
62
+
63
+ results << {
64
+ query: p[:query],
65
+ status: status,
66
+ pos_gap: pos_gap,
67
+ ctr_gap: ctr_gap,
68
+ lost_clicks: lost_clicks,
69
+ desktop: d,
70
+ mobile: m,
71
+ diagnosis: diagnosis,
72
+ remediation: remediation
73
+ }
74
+ end
75
+
76
+ # Sort by worst mobile suppression first (highest positive pos_gap)
77
+ results.sort_by { |r| -r[:pos_gap] }
78
+ end
79
+
80
+ def diagnose(query, pos_gap, ctr_gap, d, m)
81
+ if pos_gap >= 5.0
82
+ [
83
+ "Severe Mobile Demotion: Ranks Pos #{d[:position]} on Desktop but crashes to Pos #{m[:position]} on Mobile.",
84
+ "Audit mobile Core Web Vitals (CLS/INP), inspect touch target sizes (<48px), and ensure above-the-fold content isn't hidden in accordions on mobile."
85
+ ]
86
+ elsif pos_gap >= 2.0
87
+ [
88
+ "Moderate Mobile Position Lag: Mobile is trailing Desktop by #{pos_gap} positions.",
89
+ "Check mobile viewport viewport meta tag, eliminate intrusive interstitials, and compress mobile hero LCP image."
90
+ ]
91
+ elsif ctr_gap <= -3.0 && pos_gap.abs < 2.0
92
+ [
93
+ "CTR Parity Mismatch: Position is stable, but Mobile CTR (#{m[:ctr]}%) is significantly lower than Desktop (#{d[:ctr]}%).",
94
+ "Test title tag truncation on mobile screens (keep < 55 characters) and test rich snippets/favicons."
95
+ ]
96
+ elsif pos_gap <= -2.0
97
+ [
98
+ "Mobile Favored: Mobile ranks #{pos_gap.abs} positions higher than Desktop.",
99
+ "Maintain mobile experience; review desktop page speed and responsiveness."
100
+ ]
101
+ else
102
+ [
103
+ "SERP Parity Aligned: Mobile and Desktop performance are in healthy equilibrium.",
104
+ "Continue monitoring across core algorithm updates."
105
+ ]
106
+ end
107
+ end
108
+
109
+ def synthesize_report(analyzed)
110
+ total = analyzed.size
111
+ critical = analyzed.select { |r| r[:status] == :critical_suppression }
112
+ moderate = analyzed.select { |r| r[:status] == :moderate_suppression }
113
+ parity = analyzed.select { |r| r[:status] == :parity }
114
+ favored = analyzed.select { |r| r[:status] == :mobile_advantaged }
115
+
116
+ total_lost_clicks = analyzed.sum { |r| r[:lost_clicks] }
117
+
118
+ total_desktop_clicks = analyzed.sum { |r| r[:desktop][:clicks] }
119
+ total_mobile_clicks = analyzed.sum { |r| r[:mobile][:clicks] }
120
+ combined_clicks = total_desktop_clicks + total_mobile_clicks
121
+
122
+ mob_share_pct = combined_clicks > 0 ? ((total_mobile_clicks.to_f / combined_clicks) * 100.0).round(1) : 0.0
123
+
124
+ # Parity Health Score: 100 max, penalized heavily by critical and moderate suppression
125
+ score = if total.zero?
126
+ 100
127
+ else
128
+ penalty = (critical.size * 18) + (moderate.size * 6)
129
+ [[100 - penalty, 10].max, 100].min
130
+ end
131
+
132
+ grade = if total.zero?
133
+ 'A'
134
+ else
135
+ case score
136
+ when 90..100 then 'A'
137
+ when 75..89 then 'B'
138
+ when 55..74 then 'C'
139
+ else 'F'
140
+ end
141
+ end
142
+
143
+ remediations = (critical + moderate).map { |r| r[:remediation] }.compact.uniq
144
+
145
+ {
146
+ domain: @domain,
147
+ total_queries_analyzed: total,
148
+ traffic_distribution: {
149
+ mobile_share_pct: mob_share_pct,
150
+ desktop_share_pct: combined_clicks > 0 ? (100.0 - mob_share_pct).round(1) : 0.0,
151
+ total_mobile_clicks: total_mobile_clicks,
152
+ total_desktop_clicks: total_desktop_clicks
153
+ },
154
+ health_score: score,
155
+ grade: grade,
156
+ critical_suppression_count: critical.size,
157
+ moderate_suppression_count: moderate.size,
158
+ parity_count: parity.size,
159
+ mobile_favored_count: favored.size,
160
+ total_estimated_lost_mobile_clicks: total_lost_clicks,
161
+ priority_remediations: remediations,
162
+ disparities: analyzed
163
+ }
164
+ end
165
+
166
+ def fetch_paired_data
167
+ if @api
168
+ begin
169
+ days = (@options[:days] || 28).to_i
170
+ end_date = (Date.today - 2).strftime('%Y-%m-%d')
171
+ start_date = (Date.today - 2 - days).strftime('%Y-%m-%d')
172
+
173
+ raw_rows = if @api.respond_to?(:query_analytics)
174
+ target_site = @options[:site_url] || (@domain.start_with?('sc-domain:', 'http') ? @domain : "sc-domain:#{@domain}")
175
+ res = @api.query_analytics(target_site, days: days, dimensions: %w[query device], row_limit: 5000)
176
+ if (!res[:ok] || (res.dig(:data, 'rows') || []).empty?) && target_site.start_with?('sc-domain:')
177
+ # Fallback to URL-prefix
178
+ fallback_res = @api.query_analytics("https://#{@domain}/", days: days, dimensions: %w[query device], row_limit: 5000)
179
+ res = fallback_res if fallback_res[:ok] && (fallback_res.dig(:data, 'rows') || []).any?
180
+ end
181
+ res[:ok] ? (res.dig(:data, 'rows') || []) : []
182
+ elsif @api.respond_to?(:search_analytics)
183
+ res = @api.search_analytics(@domain, start_date: start_date, end_date: end_date, dimensions: %w[query device])
184
+ res['rows'] || []
185
+ else
186
+ []
187
+ end
188
+
189
+ if raw_rows.any?
190
+ grouped = {}
191
+ raw_rows.each do |r|
192
+ keys = r['keys'] || []
193
+ q = keys[0]
194
+ dev = keys[1].to_s.upcase
195
+ next unless q && dev
196
+
197
+ grouped[q] ||= {}
198
+ grouped[q][dev] = {
199
+ clicks: (r['clicks'] || 0).to_i,
200
+ impressions: (r['impressions'] || 0).to_i,
201
+ ctr: ((r['ctr'] || 0.0) * 100.0).round(2),
202
+ position: (r['position'] || 100.0).to_f.round(1)
203
+ }
204
+ end
205
+
206
+ return grouped.map do |q, devs|
207
+ {
208
+ query: q,
209
+ desktop: devs['DESKTOP'],
210
+ mobile: devs['MOBILE']
211
+ }
212
+ end
213
+ end
214
+ rescue StandardError
215
+ # GSC query failed
216
+ end
217
+ end
218
+
219
+ []
220
+ end
221
+ end
222
+ end