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.
- checksums.yaml +4 -4
- data/AUTH.md +205 -0
- data/FUNDING.md +120 -0
- data/README.md +463 -299
- data/bin/gsc +29158 -4921
- data/dist/gsc +29158 -4921
- data/lib/gsc/aio_hunter.rb +343 -0
- data/lib/gsc/answer_synthesizer.rb +157 -0
- data/lib/gsc/api.rb +53 -1
- data/lib/gsc/auth.rb +26 -0
- data/lib/gsc/backlinks_manager.rb +96 -0
- data/lib/gsc/brand_segmenter.rb +140 -0
- data/lib/gsc/cache_manager.rb +806 -0
- data/lib/gsc/cannibalization_analyzer.rb +141 -0
- data/lib/gsc/canonical_chains.rb +367 -0
- data/lib/gsc/citation_simulator.rb +339 -0
- data/lib/gsc/cli/aio_hunter.rb +154 -0
- data/lib/gsc/cli/analytics.rb +788 -0
- data/lib/gsc/cli/audit.rb +1976 -0
- data/lib/gsc/cli/base.rb +384 -0
- data/lib/gsc/cli/cache.rb +266 -0
- data/lib/gsc/cli/canonical.rb +223 -0
- data/lib/gsc/cli/citation_simulator.rb +152 -0
- data/lib/gsc/cli/dashboard.rb +354 -0
- data/lib/gsc/cli/doctor.rb +129 -0
- data/lib/gsc/cli/eeat.rb +125 -0
- data/lib/gsc/cli/ga4.rb +852 -0
- data/lib/gsc/cli/growth.rb +650 -0
- data/lib/gsc/cli/hreflang.rb +164 -0
- data/lib/gsc/cli/image_seo.rb +162 -0
- data/lib/gsc/cli/indexing.rb +458 -0
- data/lib/gsc/cli/intent_shift.rb +125 -0
- data/lib/gsc/cli/keyword_value.rb +134 -0
- data/lib/gsc/cli/keywords.rb +795 -0
- data/lib/gsc/cli/landing_roi.rb +308 -0
- data/lib/gsc/cli/low_ctr.rb +213 -0
- data/lib/gsc/cli/mobile_parity.rb +150 -0
- data/lib/gsc/cli/report.rb +100 -0
- data/lib/gsc/cli/rich_results.rb +172 -0
- data/lib/gsc/cli/schema_generate.rb +149 -0
- data/lib/gsc/cli/seasonal.rb +232 -0
- data/lib/gsc/cli/security.rb +153 -0
- data/lib/gsc/cli/setup.rb +1291 -0
- data/lib/gsc/cli/sitemap_tree.rb +143 -0
- data/lib/gsc/cli/skill_pack.rb +62 -0
- data/lib/gsc/cli/soft_404.rb +199 -0
- data/lib/gsc/cli/sparkline.rb +227 -0
- data/lib/gsc/cli/watchdog.rb +150 -0
- data/lib/gsc/cli/zombie_purger.rb +208 -0
- data/lib/gsc/cli.rb +706 -5111
- data/lib/gsc/cli_advanced.rb +1513 -0
- data/lib/gsc/client.rb +17 -2
- data/lib/gsc/color.rb +16 -1
- data/lib/gsc/command_registry.rb +47 -9
- data/lib/gsc/config.rb +11 -2
- data/lib/gsc/content_gap.rb +112 -0
- data/lib/gsc/ctr_curve.rb +115 -0
- data/lib/gsc/decay_predictor.rb +322 -0
- data/lib/gsc/doctor.rb +434 -0
- data/lib/gsc/eeat_auditor.rb +428 -0
- data/lib/gsc/entity_auditor.rb +229 -0
- data/lib/gsc/firewall_scanner.rb +733 -0
- data/lib/gsc/geo_auditor.rb +368 -0
- data/lib/gsc/google_suggest.rb +109 -0
- data/lib/gsc/google_trends.rb +8 -1
- data/lib/gsc/heading_validator.rb +283 -0
- data/lib/gsc/hreflang_validator.rb +412 -0
- data/lib/gsc/image_seo.rb +286 -0
- data/lib/gsc/indexing_queue.rb +179 -0
- data/lib/gsc/indexnow.rb +93 -0
- data/lib/gsc/intent_shift.rb +188 -0
- data/lib/gsc/internal_links.rb +249 -0
- data/lib/gsc/keyword_value.rb +191 -0
- data/lib/gsc/landing_roi.rb +195 -0
- data/lib/gsc/llms_generator.rb +425 -0
- data/lib/gsc/low_ctr_rewriter.rb +408 -0
- data/lib/gsc/mobile_parity.rb +222 -0
- data/lib/gsc/network_tracer.rb +93 -0
- data/lib/gsc/open_page_rank.rb +72 -0
- data/lib/gsc/page_analyzer.rb +47 -7
- data/lib/gsc/page_comparator.rb +108 -0
- data/lib/gsc/page_speed.rb +110 -0
- data/lib/gsc/prompts.rb +38 -29
- data/lib/gsc/questions_harvester.rb +178 -0
- data/lib/gsc/report_generator.rb +461 -0
- data/lib/gsc/rich_results.rb +388 -0
- data/lib/gsc/robots_checker.rb +114 -0
- data/lib/gsc/schema_generator.rb +788 -0
- data/lib/gsc/schema_validator.rb +120 -0
- data/lib/gsc/seasonal_predictor.rb +381 -0
- data/lib/gsc/security_scanner.rb +496 -0
- data/lib/gsc/serp_feature_detector.rb +359 -0
- data/lib/gsc/serp_preview.rb +152 -0
- data/lib/gsc/site_crawler.rb +113 -21
- data/lib/gsc/sitemap_loader.rb +15 -4
- data/lib/gsc/sitemap_tree.rb +301 -0
- data/lib/gsc/skill_pack.rb +195 -0
- data/lib/gsc/soft_404_analyzer.rb +385 -0
- data/lib/gsc/sparkline.rb +171 -0
- data/lib/gsc/speed_correlator.rb +416 -0
- data/lib/gsc/striking_playbook.rb +190 -0
- data/lib/gsc/title_optimizer.rb +420 -0
- data/lib/gsc/vault.rb +260 -0
- data/lib/gsc/version.rb +1 -1
- data/lib/gsc/watchdog.rb +235 -0
- data/lib/gsc/zombie_purger.rb +366 -0
- data/lib/gsc.rb +144 -0
- 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
|