gsc-cli 2.1.0 â 2.2.2
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 +4 -1
- data/README.md +448 -408
- data/bin/gsc +28067 -5661
- data/dist/gsc +29121 -5046
- 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/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 +707 -5265
- data/lib/gsc/cli_advanced.rb +987 -44
- 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 +2 -2
- 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_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 +153 -36
- data/lib/gsc/keyword_value.rb +191 -0
- data/lib/gsc/landing_roi.rb +195 -0
- data/lib/gsc/llms_generator.rb +343 -22
- data/lib/gsc/low_ctr_rewriter.rb +408 -0
- data/lib/gsc/mobile_parity.rb +222 -0
- data/lib/gsc/network_tracer.rb +8 -1
- data/lib/gsc/page_analyzer.rb +47 -7
- 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 +46 -15
- data/lib/gsc/schema_generator.rb +788 -0
- data/lib/gsc/schema_validator.rb +36 -38
- 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 +108 -22
- 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 +118 -0
- metadata +75 -1
|
@@ -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
|