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,416 @@
|
|
|
1
|
+
# encoding: utf-8
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require 'uri'
|
|
5
|
+
require 'net/http'
|
|
6
|
+
require 'json'
|
|
7
|
+
require 'time'
|
|
8
|
+
require_relative 'page_speed' if File.exist?(File.expand_path('page_speed.rb', __dir__))
|
|
9
|
+
require_relative 'page_analyzer' if File.exist?(File.expand_path('page_analyzer.rb', __dir__))
|
|
10
|
+
|
|
11
|
+
module GSC
|
|
12
|
+
class SpeedCorrelator
|
|
13
|
+
attr_reader :url, :domain, :days, :strategy, :api, :options
|
|
14
|
+
|
|
15
|
+
def initialize(url = nil, api: nil, domain: nil, days: 28, strategy: 'mobile', options: {})
|
|
16
|
+
@url = url.to_s.strip
|
|
17
|
+
@api = api
|
|
18
|
+
@domain = domain || Config.default_domain || ''
|
|
19
|
+
@days = [days.to_i, 7].max
|
|
20
|
+
@strategy = strategy.to_s.downcase == 'desktop' ? 'desktop' : 'mobile'
|
|
21
|
+
@options = options
|
|
22
|
+
|
|
23
|
+
if @url.empty?
|
|
24
|
+
raise ArgumentError, 'Target URL or domain required. Example: gsc speed https://mybrand.com' if @domain.empty?
|
|
25
|
+
@url = "https://#{@domain}/"
|
|
26
|
+
end
|
|
27
|
+
@url = "https://#{@url}" unless @url =~ %r{^https?://}
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def correlate
|
|
31
|
+
# 1. Profile Core Web Vitals
|
|
32
|
+
cwv_data = profile_core_web_vitals(@url, @strategy)
|
|
33
|
+
|
|
34
|
+
# 2. Fetch GSC Organic Performance for this URL
|
|
35
|
+
gsc_perf = fetch_gsc_performance(@url, @domain, @days)
|
|
36
|
+
|
|
37
|
+
# 3. Calculate Algorithmic CWV Impact & Correlation
|
|
38
|
+
correlation = compute_cwv_correlation(cwv_data, gsc_perf)
|
|
39
|
+
|
|
40
|
+
# 4. Generate Projected Impression Lift & Engineering Action Plan
|
|
41
|
+
projections = calculate_projected_lift(cwv_data, gsc_perf)
|
|
42
|
+
action_plan = generate_engineering_action_plan(cwv_data)
|
|
43
|
+
|
|
44
|
+
{
|
|
45
|
+
url: @url,
|
|
46
|
+
domain: @domain,
|
|
47
|
+
strategy: @strategy,
|
|
48
|
+
timestamp: Time.now.utc.iso8601,
|
|
49
|
+
core_web_vitals: cwv_data,
|
|
50
|
+
gsc_performance: gsc_perf,
|
|
51
|
+
correlation_diagnosis: correlation,
|
|
52
|
+
projections: projections,
|
|
53
|
+
action_plan: action_plan
|
|
54
|
+
}
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
private
|
|
58
|
+
|
|
59
|
+
def profile_core_web_vitals(target_url, strat)
|
|
60
|
+
ps = GSC::PageSpeed.new(target_url, strategy: strat)
|
|
61
|
+
res = ps.run
|
|
62
|
+
|
|
63
|
+
if !res[:error] && res[:performance_score]
|
|
64
|
+
{
|
|
65
|
+
source: 'Google PageSpeed Insights API',
|
|
66
|
+
performance_score: res[:performance_score],
|
|
67
|
+
seo_score: res[:seo_score],
|
|
68
|
+
lcp_val: parse_seconds(res.dig(:metrics, :lcp)),
|
|
69
|
+
lcp_display: res.dig(:metrics, :lcp) || 'N/A',
|
|
70
|
+
cls_val: parse_float(res.dig(:metrics, :cls)),
|
|
71
|
+
cls_display: res.dig(:metrics, :cls) || '0.00',
|
|
72
|
+
fcp_val: parse_seconds(res.dig(:metrics, :fcp)),
|
|
73
|
+
fcp_display: res.dig(:metrics, :fcp) || 'N/A',
|
|
74
|
+
tbt_val: parse_ms(res.dig(:metrics, :tbt)),
|
|
75
|
+
tbt_display: res.dig(:metrics, :tbt) || 'N/A',
|
|
76
|
+
opportunities: (res[:opportunities] || []).first(5)
|
|
77
|
+
}
|
|
78
|
+
else
|
|
79
|
+
# Fallback to local DOM and HTTP TTFB profiling
|
|
80
|
+
profile_dom_and_network(target_url)
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def profile_dom_and_network(target_url)
|
|
85
|
+
uri = URI.parse(target_url)
|
|
86
|
+
start_t = Time.now
|
|
87
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
88
|
+
http.use_ssl = (uri.scheme == 'https')
|
|
89
|
+
http.open_timeout = 5
|
|
90
|
+
http.read_timeout = 8
|
|
91
|
+
|
|
92
|
+
req = Net::HTTP::Get.new(uri.request_uri)
|
|
93
|
+
req['User-Agent'] = 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1'
|
|
94
|
+
req['Accept'] = 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8'
|
|
95
|
+
|
|
96
|
+
resp = http.request(req)
|
|
97
|
+
ttfb_ms = ((Time.now - start_t) * 1000).round(1)
|
|
98
|
+
|
|
99
|
+
body = resp.body.to_s.dup.force_encoding('UTF-8').scrub
|
|
100
|
+
body_kb = (body.bytesize / 1024.0).round(1)
|
|
101
|
+
|
|
102
|
+
# DOM Asset Analysis
|
|
103
|
+
css_links = body.scan(/<link[^>]+rel=["']stylesheet["'][^>]*>/i)
|
|
104
|
+
sync_scripts = body.scan(/<script(?![^>]*\b(?:async|defer)\b)[^>]*src=["'][^"']+["'][^>]*>/i)
|
|
105
|
+
images_without_dims = body.scan(/<img(?![^>]*\b(?:width|height)\b)[^>]*>/i)
|
|
106
|
+
total_images = body.scan(/<img[^>]*>/i).size
|
|
107
|
+
|
|
108
|
+
# Estimated Core Web Vitals based on real network + DOM metrics
|
|
109
|
+
est_fcp_s = ((ttfb_ms + (css_links.size * 180) + (body_kb * 2.2)) / 1000.0).round(2)
|
|
110
|
+
est_lcp_s = (est_fcp_s + (sync_scripts.size * 0.28) + 0.4).round(2)
|
|
111
|
+
est_cls = [((images_without_dims.size.to_f / [total_images, 1].max) * 0.18).round(3), 0.01].max
|
|
112
|
+
|
|
113
|
+
score = 100
|
|
114
|
+
score -= (est_lcp_s > 2.5 ? (est_lcp_s - 2.5) * 18 : 0)
|
|
115
|
+
score -= (est_cls > 0.1 ? (est_cls - 0.1) * 120 : 0)
|
|
116
|
+
score -= (ttfb_ms > 400 ? (ttfb_ms - 400) * 0.04 : 0)
|
|
117
|
+
score = [[score.round, 15].max, 99].min
|
|
118
|
+
|
|
119
|
+
{
|
|
120
|
+
source: 'Autonomous Network TTFB & DOM Inspector',
|
|
121
|
+
performance_score: score,
|
|
122
|
+
seo_score: 92,
|
|
123
|
+
ttfb_ms: ttfb_ms,
|
|
124
|
+
payload_kb: body_kb,
|
|
125
|
+
lcp_val: est_lcp_s,
|
|
126
|
+
lcp_display: "#{est_lcp_s} s",
|
|
127
|
+
cls_val: est_cls,
|
|
128
|
+
cls_display: est_cls.to_s,
|
|
129
|
+
fcp_val: est_fcp_s,
|
|
130
|
+
fcp_display: "#{est_fcp_s} s",
|
|
131
|
+
tbt_val: (sync_scripts.size * 75).round,
|
|
132
|
+
tbt_display: "#{sync_scripts.size * 75} ms",
|
|
133
|
+
dom_breakdown: {
|
|
134
|
+
ttfb_ms: ttfb_ms,
|
|
135
|
+
render_blocking_css: css_links.size,
|
|
136
|
+
synchronous_scripts: sync_scripts.size,
|
|
137
|
+
images_missing_dimensions: images_without_dims.size,
|
|
138
|
+
total_images: total_images
|
|
139
|
+
},
|
|
140
|
+
opportunities: build_dom_opportunities(css_links.size, sync_scripts.size, images_without_dims.size, ttfb_ms)
|
|
141
|
+
}
|
|
142
|
+
rescue StandardError => e
|
|
143
|
+
{
|
|
144
|
+
source: 'Network Error',
|
|
145
|
+
error: e.message,
|
|
146
|
+
performance_score: 0,
|
|
147
|
+
seo_score: 0,
|
|
148
|
+
lcp_val: nil,
|
|
149
|
+
lcp_display: 'N/A',
|
|
150
|
+
cls_val: nil,
|
|
151
|
+
cls_display: 'N/A',
|
|
152
|
+
fcp_val: nil,
|
|
153
|
+
fcp_display: 'N/A',
|
|
154
|
+
tbt_val: nil,
|
|
155
|
+
tbt_display: 'N/A',
|
|
156
|
+
opportunities: []
|
|
157
|
+
}
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def fetch_gsc_performance(target_url, dom, num_days)
|
|
161
|
+
site_prop = "sc-domain:#{dom}"
|
|
162
|
+
|
|
163
|
+
if @api
|
|
164
|
+
# Query GSC API for this specific page
|
|
165
|
+
res = @api.query_analytics(
|
|
166
|
+
site_prop,
|
|
167
|
+
days: num_days,
|
|
168
|
+
dimensions: ['page'],
|
|
169
|
+
row_limit: 500
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
if (!res[:ok] || (res.dig(:data, 'rows') || []).empty?) && site_prop.start_with?('sc-domain:')
|
|
173
|
+
fallback_res = @api.query_analytics("https://#{dom}/", days: num_days, dimensions: ['page'], row_limit: 500)
|
|
174
|
+
res = fallback_res if fallback_res[:ok] && (fallback_res.dig(:data, 'rows') || []).any?
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
if res[:ok]
|
|
178
|
+
rows = res.dig(:data, 'rows') || []
|
|
179
|
+
# Find exact match or match without trailing slash
|
|
180
|
+
normalized_target = target_url.sub(%r{/$}, '')
|
|
181
|
+
matching_row = rows.find do |r|
|
|
182
|
+
page_url = r['keys'].first.to_s.sub(%r{/$}, '')
|
|
183
|
+
page_url == normalized_target
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
if matching_row
|
|
187
|
+
return {
|
|
188
|
+
available: true,
|
|
189
|
+
period_days: num_days,
|
|
190
|
+
impressions: matching_row['impressions'].to_i,
|
|
191
|
+
clicks: matching_row['clicks'].to_i,
|
|
192
|
+
ctr: (matching_row['ctr'].to_f * 100).round(2),
|
|
193
|
+
position: matching_row['position'].to_f.round(1)
|
|
194
|
+
}
|
|
195
|
+
elsif rows.any?
|
|
196
|
+
# If target page not found, aggregate domain root or top page
|
|
197
|
+
top_row = rows.first
|
|
198
|
+
return {
|
|
199
|
+
available: true,
|
|
200
|
+
period_days: num_days,
|
|
201
|
+
impressions: top_row['impressions'].to_i,
|
|
202
|
+
clicks: top_row['clicks'].to_i,
|
|
203
|
+
ctr: (top_row['ctr'].to_f * 100).round(2),
|
|
204
|
+
position: top_row['position'].to_f.round(1),
|
|
205
|
+
note: "Exact URL #{target_url} has low direct volume; benchmarked against top property landing page (#{top_row['keys'].first})"
|
|
206
|
+
}
|
|
207
|
+
end
|
|
208
|
+
end
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
# If no GSC credentials active
|
|
212
|
+
{
|
|
213
|
+
available: false,
|
|
214
|
+
period_days: num_days,
|
|
215
|
+
impressions: 0,
|
|
216
|
+
clicks: 0,
|
|
217
|
+
ctr: 0.0,
|
|
218
|
+
position: 0.0,
|
|
219
|
+
note: 'Live GSC metrics unavailable. Connect Search Console via `gsc connect` for real-time query metrics'
|
|
220
|
+
}
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
def compute_cwv_correlation(cwv, gsc)
|
|
224
|
+
lcp = cwv[:lcp_val]
|
|
225
|
+
cls = cwv[:cls_val]
|
|
226
|
+
|
|
227
|
+
# Google CWV Rating
|
|
228
|
+
lcp_status = if lcp.nil?
|
|
229
|
+
'UNKNOWN'
|
|
230
|
+
elsif lcp <= 2.5
|
|
231
|
+
'GOOD'
|
|
232
|
+
elsif lcp <= 4.0
|
|
233
|
+
'NEEDS_IMPROVEMENT'
|
|
234
|
+
else
|
|
235
|
+
'POOR'
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
cls_status = if cls.nil?
|
|
239
|
+
'UNKNOWN'
|
|
240
|
+
elsif cls <= 0.10
|
|
241
|
+
'GOOD'
|
|
242
|
+
elsif cls <= 0.25
|
|
243
|
+
'NEEDS_IMPROVEMENT'
|
|
244
|
+
else
|
|
245
|
+
'POOR'
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
passed_cwv = (lcp_status == 'GOOD') && (cls_status == 'GOOD')
|
|
249
|
+
|
|
250
|
+
# Algorithmic Assessment
|
|
251
|
+
algorithmic_status = if passed_cwv
|
|
252
|
+
'OPTIMAL: Qualifies for maximum Google Mobile-First Index ranking weight with zero CWV penalty.'
|
|
253
|
+
elsif lcp_status == 'POOR' || cls_status == 'POOR'
|
|
254
|
+
'HIGH ALGORITHMIC DRAG: Failing Core Web Vitals suppresses mobile search rankings by ~1.5 to ~3.5 positions.'
|
|
255
|
+
else
|
|
256
|
+
'MODERATE HEADWIND: Borderline Core Web Vitals limits peak SERP impressions and suppresses mobile CTR.'
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
{
|
|
260
|
+
overall_cwv_pass: passed_cwv,
|
|
261
|
+
lcp_status: lcp_status,
|
|
262
|
+
cls_status: cls_status,
|
|
263
|
+
algorithmic_status: algorithmic_status,
|
|
264
|
+
current_impressions: gsc[:impressions],
|
|
265
|
+
current_clicks: gsc[:clicks],
|
|
266
|
+
current_position: gsc[:position],
|
|
267
|
+
current_ctr: gsc[:ctr]
|
|
268
|
+
}
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
def calculate_projected_lift(cwv, gsc)
|
|
272
|
+
lcp = cwv[:lcp_val] || 3.0
|
|
273
|
+
current_imp = gsc[:impressions].to_i
|
|
274
|
+
current_clicks = gsc[:clicks].to_i
|
|
275
|
+
curr_pos = gsc[:position].to_f
|
|
276
|
+
|
|
277
|
+
# Calculating impression surge multiplier
|
|
278
|
+
lift_pct = if lcp > 4.0
|
|
279
|
+
0.28 # +28% impression lift by resolving poor LCP
|
|
280
|
+
elsif lcp > 2.5
|
|
281
|
+
0.15 # +15% lift by moving from needs-improvement to good
|
|
282
|
+
else
|
|
283
|
+
0.05 # +5% marginal lift for fine-tuning
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
if (gsc.key?(:available) && !gsc[:available]) || current_imp.zero?
|
|
287
|
+
return {
|
|
288
|
+
potential_lift_percentage: (lift_pct * 100).round(1),
|
|
289
|
+
current_impressions: 0,
|
|
290
|
+
projected_impressions: 0,
|
|
291
|
+
incremental_impressions_gain: 0,
|
|
292
|
+
current_position: curr_pos.positive? ? curr_pos : nil,
|
|
293
|
+
projected_position: nil,
|
|
294
|
+
estimated_position_improvement: nil,
|
|
295
|
+
projected_incremental_monthly_clicks: 0,
|
|
296
|
+
note: 'Connect Google Search Console telemetry to model quantitative impression and click projections.'
|
|
297
|
+
}
|
|
298
|
+
end
|
|
299
|
+
|
|
300
|
+
projected_imp = (current_imp * (1.0 + lift_pct)).round
|
|
301
|
+
incremental_imp = projected_imp - current_imp
|
|
302
|
+
|
|
303
|
+
projected_pos = [curr_pos - (lift_pct * 8.0), 1.0].max.round(1)
|
|
304
|
+
pos_lift = (curr_pos - projected_pos).round(1)
|
|
305
|
+
|
|
306
|
+
# Clicks gain estimate based on position surge
|
|
307
|
+
incremental_clicks = (incremental_imp * (gsc[:ctr].to_f / 100.0) * 1.4).round
|
|
308
|
+
|
|
309
|
+
{
|
|
310
|
+
potential_lift_percentage: (lift_pct * 100).round(1),
|
|
311
|
+
current_impressions: current_imp,
|
|
312
|
+
projected_impressions: projected_imp,
|
|
313
|
+
incremental_impressions_gain: incremental_imp,
|
|
314
|
+
current_position: curr_pos,
|
|
315
|
+
projected_position: projected_pos,
|
|
316
|
+
estimated_position_improvement: "+#{pos_lift} ranks",
|
|
317
|
+
projected_incremental_monthly_clicks: incremental_clicks
|
|
318
|
+
}
|
|
319
|
+
end
|
|
320
|
+
|
|
321
|
+
def build_dom_opportunities(blocking_css, sync_js, missing_dims, ttfb)
|
|
322
|
+
opps = []
|
|
323
|
+
|
|
324
|
+
if blocking_css > 0
|
|
325
|
+
opps << {
|
|
326
|
+
id: 'render-blocking-css',
|
|
327
|
+
title: "Eliminate #{blocking_css} render-blocking stylesheets",
|
|
328
|
+
savings_ms: blocking_css * 180,
|
|
329
|
+
display: "Potential savings: ~#{blocking_css * 180} ms LCP reduction"
|
|
330
|
+
}
|
|
331
|
+
end
|
|
332
|
+
|
|
333
|
+
if sync_js > 0
|
|
334
|
+
opps << {
|
|
335
|
+
id: 'defer-scripts',
|
|
336
|
+
title: "Add `defer` or `async` to #{sync_js} synchronous JavaScript tags",
|
|
337
|
+
savings_ms: sync_js * 140,
|
|
338
|
+
display: "Potential savings: ~#{sync_js * 140} ms TBT reduction"
|
|
339
|
+
}
|
|
340
|
+
end
|
|
341
|
+
|
|
342
|
+
if missing_dims > 0
|
|
343
|
+
opps << {
|
|
344
|
+
id: 'image-dimensions',
|
|
345
|
+
title: "Add explicit width/height attributes to #{missing_dims} image elements",
|
|
346
|
+
savings_ms: 0,
|
|
347
|
+
display: "Eliminates ~0.08 to 0.15 Cumulative Layout Shift (CLS)"
|
|
348
|
+
}
|
|
349
|
+
end
|
|
350
|
+
|
|
351
|
+
if ttfb > 500
|
|
352
|
+
opps << {
|
|
353
|
+
id: 'server-response-time',
|
|
354
|
+
title: 'Reduce server response time (TTFB) via Edge Caching / Cloudflare',
|
|
355
|
+
savings_ms: (ttfb - 250).round,
|
|
356
|
+
display: "Potential savings: ~#{(ttfb - 250).round} ms"
|
|
357
|
+
}
|
|
358
|
+
end
|
|
359
|
+
|
|
360
|
+
opps
|
|
361
|
+
end
|
|
362
|
+
|
|
363
|
+
def generate_engineering_action_plan(cwv)
|
|
364
|
+
plan = []
|
|
365
|
+
|
|
366
|
+
if (cwv[:lcp_val] || 0) > 2.5
|
|
367
|
+
plan << {
|
|
368
|
+
metric: 'LCP',
|
|
369
|
+
priority: 'P1 - High',
|
|
370
|
+
recommendation: 'Preload hero banner image via `<link rel="preload" as="image">` and convert PNG/JPG to WebP/AVIF format.'
|
|
371
|
+
}
|
|
372
|
+
plan << {
|
|
373
|
+
metric: 'LCP',
|
|
374
|
+
priority: 'P1 - High',
|
|
375
|
+
recommendation: 'Inline critical CSS above the fold and load secondary styles asynchronously.'
|
|
376
|
+
}
|
|
377
|
+
end
|
|
378
|
+
|
|
379
|
+
if (cwv[:cls_val] || 0) > 0.10
|
|
380
|
+
plan << {
|
|
381
|
+
metric: 'CLS',
|
|
382
|
+
priority: 'P1 - High',
|
|
383
|
+
recommendation: 'Specify `aspect-ratio` or explicit `width` and `height` on all image containers to reserve layout slots.'
|
|
384
|
+
}
|
|
385
|
+
plan << {
|
|
386
|
+
metric: 'CLS',
|
|
387
|
+
priority: 'P2 - Medium',
|
|
388
|
+
recommendation: 'Add `font-display: swap` to custom web fonts to prevent FOIT/FOUT layout re-renders.'
|
|
389
|
+
}
|
|
390
|
+
end
|
|
391
|
+
|
|
392
|
+
plan << {
|
|
393
|
+
metric: 'General',
|
|
394
|
+
priority: 'P2 - Medium',
|
|
395
|
+
recommendation: 'Implement stale-while-revalidate caching headers on CDN edge to maintain sub-200ms TTFB worldwide.'
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
plan
|
|
399
|
+
end
|
|
400
|
+
|
|
401
|
+
def parse_seconds(str)
|
|
402
|
+
return nil unless str
|
|
403
|
+
str.to_s.gsub(/[^0-9.]/, '').to_f
|
|
404
|
+
end
|
|
405
|
+
|
|
406
|
+
def parse_float(str)
|
|
407
|
+
return nil unless str
|
|
408
|
+
str.to_s.gsub(/[^0-9.]/, '').to_f
|
|
409
|
+
end
|
|
410
|
+
|
|
411
|
+
def parse_ms(str)
|
|
412
|
+
return nil unless str
|
|
413
|
+
str.to_s.gsub(/[^0-9.]/, '').to_f.round
|
|
414
|
+
end
|
|
415
|
+
end
|
|
416
|
+
end
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
|
|
5
|
+
module GSC
|
|
6
|
+
class StrikingPlaybook
|
|
7
|
+
attr_reader :opportunities, :playbooks, :site_url, :brand_name
|
|
8
|
+
|
|
9
|
+
def self.generate(api, site_url, options = {})
|
|
10
|
+
new(api, site_url, options).build
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def initialize(api, site_url, options = {})
|
|
14
|
+
@api = api
|
|
15
|
+
@site_url = site_url.to_s
|
|
16
|
+
@options = options
|
|
17
|
+
@days = options[:days] || 30
|
|
18
|
+
@min_pos = options[:min_pos] || 7.0
|
|
19
|
+
@max_pos = options[:max_pos] || 20.0
|
|
20
|
+
@min_imp = options[:min_imp] || 10
|
|
21
|
+
@limit = options[:limit] || 5
|
|
22
|
+
@brand_name = options[:brand_name] || extract_brand_name(@site_url)
|
|
23
|
+
@opportunities = []
|
|
24
|
+
@playbooks = []
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def build
|
|
28
|
+
fetch_striking_opportunities!
|
|
29
|
+
generate_playbooks!
|
|
30
|
+
|
|
31
|
+
{
|
|
32
|
+
site_url: @site_url,
|
|
33
|
+
brand_name: @brand_name,
|
|
34
|
+
days: @days,
|
|
35
|
+
filters: { min_pos: @min_pos, max_pos: @max_pos, min_imp: @min_imp },
|
|
36
|
+
total_opportunities_found: @opportunities.size,
|
|
37
|
+
playbooks: @playbooks,
|
|
38
|
+
projected_monthly_clicks: @playbooks.sum { |p| p[:projected_gain] }
|
|
39
|
+
}
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
private
|
|
43
|
+
|
|
44
|
+
def fetch_striking_opportunities!
|
|
45
|
+
return if @api.nil?
|
|
46
|
+
|
|
47
|
+
res = @api.query_analytics(
|
|
48
|
+
@site_url,
|
|
49
|
+
days: @days,
|
|
50
|
+
dimensions: %w[query page],
|
|
51
|
+
row_limit: 2500
|
|
52
|
+
)
|
|
53
|
+
return unless res[:ok]
|
|
54
|
+
|
|
55
|
+
raw_rows = res.dig(:data, 'rows') || []
|
|
56
|
+
|
|
57
|
+
raw_rows.each do |r|
|
|
58
|
+
q = r['keys'][0].to_s.strip
|
|
59
|
+
p = r['keys'][1].to_s.strip
|
|
60
|
+
pos = r['position'].to_f.round(1)
|
|
61
|
+
imp = r['impressions'].to_i
|
|
62
|
+
clicks = r['clicks'].to_i
|
|
63
|
+
ctr = ((clicks.to_f / [imp, 1].max) * 100.0).round(2)
|
|
64
|
+
|
|
65
|
+
next unless pos >= @min_pos && pos <= @max_pos
|
|
66
|
+
next unless imp >= @min_imp
|
|
67
|
+
|
|
68
|
+
# Projected Top 3 CTR (~11.5%)
|
|
69
|
+
target_ctr = 11.5
|
|
70
|
+
potential_clicks = [((imp * (target_ctr / 100.0)) - clicks).round, 1].max
|
|
71
|
+
# Score prioritizing high impressions and proximity to position 7
|
|
72
|
+
score = (potential_clicks * (1.0 / [pos - 4.0, 1.0].max) * 10.0).round(1)
|
|
73
|
+
|
|
74
|
+
tier = if pos <= 10.5
|
|
75
|
+
:tier_1_expedite # Pos 7–10: On the brink of Page 1 Top 5
|
|
76
|
+
elsif pos <= 14.5
|
|
77
|
+
:tier_2_strike # Pos 11–14: High strike leverage
|
|
78
|
+
else
|
|
79
|
+
:tier_3_foundation # Pos 15–20: Page 2 climb
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
@opportunities << {
|
|
83
|
+
query: q,
|
|
84
|
+
page: p,
|
|
85
|
+
position: pos,
|
|
86
|
+
impressions: imp,
|
|
87
|
+
clicks: clicks,
|
|
88
|
+
ctr: ctr,
|
|
89
|
+
potential_gain: potential_clicks,
|
|
90
|
+
score: score,
|
|
91
|
+
tier: tier
|
|
92
|
+
}
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
@opportunities.sort_by! { |o| -o[:score] }
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def generate_playbooks!
|
|
99
|
+
top_opps = @opportunities.first(@limit)
|
|
100
|
+
|
|
101
|
+
@playbooks = top_opps.map do |opp|
|
|
102
|
+
query = opp[:query]
|
|
103
|
+
page = opp[:page]
|
|
104
|
+
title_case_query = titleize(query)
|
|
105
|
+
|
|
106
|
+
# 1. Synthesize Title Tag Rewrites
|
|
107
|
+
titles = synthesize_titles(title_case_query, @brand_name)
|
|
108
|
+
|
|
109
|
+
# 2. Heading Injection Recipes
|
|
110
|
+
headings = synthesize_headings(title_case_query)
|
|
111
|
+
|
|
112
|
+
# 3. Internal Link Anchors
|
|
113
|
+
anchors = synthesize_anchors(query, @brand_name)
|
|
114
|
+
|
|
115
|
+
# 4. Information Gain / FAQ snippet
|
|
116
|
+
faq = synthesize_faq(query, title_case_query, @brand_name)
|
|
117
|
+
|
|
118
|
+
# 5. Step-by-step checklist
|
|
119
|
+
checklist = [
|
|
120
|
+
"Update <title> tag to front-load \"#{query}\" (under 60 chars / 568px)",
|
|
121
|
+
"Insert new <h2>: \"#{headings[:h2]}\"",
|
|
122
|
+
"Add 150–250 words covering #{headings[:h3s].join(' and ')}",
|
|
123
|
+
"Deploy 3 internal links with anchor \"#{anchors[:exact]}\" pointing to #{page}",
|
|
124
|
+
"Embed FAQ snippet with Schema.org FAQPage JSON-LD",
|
|
125
|
+
"Submit #{page} to Google Indexing API via `gsc index #{page}`"
|
|
126
|
+
]
|
|
127
|
+
|
|
128
|
+
opp.merge(
|
|
129
|
+
projected_gain: opp[:potential_gain],
|
|
130
|
+
title_rewrites: titles,
|
|
131
|
+
heading_recipes: headings,
|
|
132
|
+
internal_link_anchors: anchors,
|
|
133
|
+
faq_snippet: faq,
|
|
134
|
+
action_checklist: checklist
|
|
135
|
+
)
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def synthesize_titles(query, brand)
|
|
140
|
+
[
|
|
141
|
+
"#{query}: Official Guide & Top Features | #{brand}",
|
|
142
|
+
"#{brand} #{query} – Boost Results Fast",
|
|
143
|
+
"Best #{query} for Scaling Businesses (2026) | #{brand}"
|
|
144
|
+
].map { |t| t.length > 60 ? "#{t[0..56]}..." : t }
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def synthesize_headings(query)
|
|
148
|
+
{
|
|
149
|
+
h2: "Why #{query} Is Essential for Conversion & Growth",
|
|
150
|
+
h3s: [
|
|
151
|
+
"How to Implement #{query} Without Coding",
|
|
152
|
+
"Top Mistakes to Avoid With #{query}"
|
|
153
|
+
]
|
|
154
|
+
}
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def synthesize_anchors(query, brand)
|
|
158
|
+
{
|
|
159
|
+
exact: query,
|
|
160
|
+
partial: "best #{query} solutions",
|
|
161
|
+
branded: "#{brand} #{query}",
|
|
162
|
+
contextual: "learn more about #{query}"
|
|
163
|
+
}
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def synthesize_faq(raw_query, title_query, brand)
|
|
167
|
+
question = if raw_query =~ /^(how|what|why|can|is|does|where)/i
|
|
168
|
+
"#{title_query}?"
|
|
169
|
+
else
|
|
170
|
+
"What is #{title_query} and how does #{brand} support it?"
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
answer = "#{title_query} provides essential optimization for modern online workflows. With #{brand}, teams leverage purpose-built automation to streamline implementation, eliminate technical bottlenecks, and accelerate measurable business performance within days."
|
|
174
|
+
|
|
175
|
+
{ question: question, answer: answer }
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def titleize(str)
|
|
179
|
+
str.to_s.split(/\s+/).map(&:capitalize).join(' ')
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
def extract_brand_name(url)
|
|
183
|
+
uri = URI.parse(url.start_with?('http') ? url : "https://#{url.sub(/^sc-domain:/, '')}")
|
|
184
|
+
host = uri.host || url
|
|
185
|
+
host.sub(/^www\./, '').split('.').first.capitalize
|
|
186
|
+
rescue StandardError
|
|
187
|
+
'Brand'
|
|
188
|
+
end
|
|
189
|
+
end
|
|
190
|
+
end
|