gsc-cli 2.1.0 → 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/README.md +410 -414
- 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,1976 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
require 'cgi'
|
|
5
|
+
require 'uri'
|
|
6
|
+
require_relative 'base'
|
|
7
|
+
require_relative '../open_pagerank' if File.exist?(File.expand_path('../open_pagerank.rb', __dir__))
|
|
8
|
+
require_relative '../pagespeed' if File.exist?(File.expand_path('../pagespeed.rb', __dir__))
|
|
9
|
+
require_relative '../speed_correlator' if File.exist?(File.expand_path('../speed_correlator.rb', __dir__))
|
|
10
|
+
require_relative '../page_comparator' if File.exist?(File.expand_path('../page_comparator.rb', __dir__))
|
|
11
|
+
require_relative '../content_gap' if File.exist?(File.expand_path('../content_gap.rb', __dir__))
|
|
12
|
+
require_relative '../internal_links' if File.exist?(File.expand_path('../internal_links.rb', __dir__))
|
|
13
|
+
require_relative '../schema_validator' if File.exist?(File.expand_path('../schema_validator.rb', __dir__))
|
|
14
|
+
require_relative '../llms_generator' if File.exist?(File.expand_path('../llms_generator.rb', __dir__))
|
|
15
|
+
require_relative '../network_tracer' if File.exist?(File.expand_path('../network_tracer.rb', __dir__))
|
|
16
|
+
require_relative '../robots_checker' if File.exist?(File.expand_path('../robots_checker.rb', __dir__))
|
|
17
|
+
require_relative '../backlinks_manager' if File.exist?(File.expand_path('../backlinks_manager.rb', __dir__))
|
|
18
|
+
require_relative '../geo_auditor' if File.exist?(File.expand_path('../geo_auditor.rb', __dir__))
|
|
19
|
+
require_relative '../entity_auditor' if File.exist?(File.expand_path('../entity_auditor.rb', __dir__))
|
|
20
|
+
require_relative '../firewall_scanner' if File.exist?(File.expand_path('../firewall_scanner.rb', __dir__))
|
|
21
|
+
require_relative '../title_optimizer' if File.exist?(File.expand_path('../title_optimizer.rb', __dir__))
|
|
22
|
+
require_relative '../page_analyzer' if File.exist?(File.expand_path('../page_analyzer.rb', __dir__))
|
|
23
|
+
require_relative '../site_crawler' if File.exist?(File.expand_path('../site_crawler.rb', __dir__))
|
|
24
|
+
|
|
25
|
+
module GSC
|
|
26
|
+
class CLI
|
|
27
|
+
module Audit
|
|
28
|
+
module_function
|
|
29
|
+
|
|
30
|
+
def run(command, target, extra, options, api = nil, site_url = nil, hostname = nil, https_origin = nil)
|
|
31
|
+
case command
|
|
32
|
+
when 'authority', 'opr', 'da', 'domain-authority'
|
|
33
|
+
handle_authority(target, extra, options)
|
|
34
|
+
when 'speed', 'vitals', 'pagespeed', 'psi'
|
|
35
|
+
handle_speed(target, options)
|
|
36
|
+
when 'speed-correlate', 'cwv-correlate', 'sc-perf'
|
|
37
|
+
handle_speed_correlate(target, options)
|
|
38
|
+
when 'compare', 'diff-seo', 'vs'
|
|
39
|
+
handle_compare(target, extra, options)
|
|
40
|
+
when 'content-gap', 'gap'
|
|
41
|
+
handle_content_gap(target, extra, options)
|
|
42
|
+
when 'internal-links', 'orphans', 'links-audit'
|
|
43
|
+
handle_internal_links(target, options)
|
|
44
|
+
when 'schema', 'rich-snippets', 'ld-json'
|
|
45
|
+
handle_schema(target, extra, options)
|
|
46
|
+
when 'llms', 'ai-ready'
|
|
47
|
+
handle_llms(target, extra, options, api, site_url)
|
|
48
|
+
when 'trace', 'redirects', 'hops'
|
|
49
|
+
handle_trace(target, options)
|
|
50
|
+
when 'robots', 'robots-txt'
|
|
51
|
+
handle_robots(target, extra, options)
|
|
52
|
+
when 'backlinks', 'links'
|
|
53
|
+
handle_backlinks(target, extra, options)
|
|
54
|
+
when 'geo', 'aeo', 'citability'
|
|
55
|
+
handle_geo(target, options)
|
|
56
|
+
when 'entity', 'kg', 'knowledge-graph'
|
|
57
|
+
handle_entity(target, options)
|
|
58
|
+
when 'firewall', 'ai-bots', 'waf-scan', 'bot-firewall'
|
|
59
|
+
handle_firewall(target, options)
|
|
60
|
+
when 'titles', 'title-opt', 'pixel-titles', 'title-tags'
|
|
61
|
+
handle_titles(target, options)
|
|
62
|
+
when 'headings', 'h1', 'heading-structure'
|
|
63
|
+
handle_headings(target, options)
|
|
64
|
+
when 'page', 'page-audit'
|
|
65
|
+
handle_page(target, site_url, api, options)
|
|
66
|
+
when 'site-audit', 'site-crawl', 'crawl'
|
|
67
|
+
handle_site_crawl(target, site_url, api, options)
|
|
68
|
+
when 'audit', 'a'
|
|
69
|
+
run_comprehensive_audit(api, site_url, hostname, https_origin, options)
|
|
70
|
+
else
|
|
71
|
+
raise "Unknown audit command: #{command}"
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def handle_authority(target, extra, options)
|
|
76
|
+
domains = [target, extra].flatten.compact.reject { |d| d.to_s.strip.empty? }
|
|
77
|
+
domains << Config.default_domain if domains.empty?
|
|
78
|
+
domains = domains.compact
|
|
79
|
+
|
|
80
|
+
if domains.empty?
|
|
81
|
+
puts Color.c("❌ Error: Domain required. Example: gsc authority example.com", Color::RED)
|
|
82
|
+
return
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
opr = GSC::OpenPageRank.new
|
|
86
|
+
unless opr.configured?
|
|
87
|
+
puts Color.c("⚠️ OpenPageRank API key not configured.", Color::YELLOW, Color::BOLD)
|
|
88
|
+
puts " Get a 100% free key (300,000 free queries/month) at: #{Color.c('https://openpagerank.com', Color::CYAN)}"
|
|
89
|
+
puts " Then run: #{Color.c('gsc config set opr_api_key <YOUR_KEY>', Color::GREEN)}"
|
|
90
|
+
puts " Or pass: #{Color.c('OPENPAGERANK_API_KEY=<KEY> gsc authority ...', Color::DIM)}"
|
|
91
|
+
return
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
data = opr.check_domains(domains)
|
|
95
|
+
|
|
96
|
+
if options[:json]
|
|
97
|
+
puts JSON.pretty_generate(data)
|
|
98
|
+
return
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
puts Base::BANNER unless options[:in_dashboard]
|
|
102
|
+
puts "🌐 #{Color::BOLD}OPEN PAGERANK & DOMAIN AUTHORITY (Common Crawl Graph):#{Color::RESET}"
|
|
103
|
+
puts "─" * 75
|
|
104
|
+
|
|
105
|
+
if data[:status] == 'error'
|
|
106
|
+
puts Color.c("❌ Error: #{data[:message]}", Color::RED)
|
|
107
|
+
return
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
puts "#{'DOMAIN'.ljust(35)} #{'PAGERANK'.ljust(12)} #{'GLOBAL RANK'.ljust(18)} #{'STATUS'}"
|
|
111
|
+
puts "─" * 75
|
|
112
|
+
|
|
113
|
+
(data[:records] || []).each do |rec|
|
|
114
|
+
d_name = rec[:domain].to_s.ljust(35)
|
|
115
|
+
pr = sprintf("%.2f / 10", rec[:page_rank_decimal]).ljust(12)
|
|
116
|
+
gr = rec[:rank] ? "##{rec[:rank].to_s.reverse.gsub(/(\d{3})(?=\d)/, '\\1,').reverse}".ljust(18) : "N/A".ljust(18)
|
|
117
|
+
st = (rec[:status_code] == 200) ? Color.c("200 OK", Color::GREEN) : Color.c(rec[:status_code].to_s, Color::YELLOW)
|
|
118
|
+
|
|
119
|
+
puts "#{Color.c(d_name, Color::BOLD)} #{Color.c(pr, Color::CYAN)} #{Color.c(gr, Color::YELLOW)} #{st}"
|
|
120
|
+
end
|
|
121
|
+
puts ""
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def handle_speed(target, options)
|
|
125
|
+
url = target || (Config.default_domain ? "https://#{Config.default_domain}" : nil)
|
|
126
|
+
unless url
|
|
127
|
+
if options[:json]
|
|
128
|
+
puts JSON.pretty_generate({ error: 'Target URL or domain required. Example: gsc speed https://mysite.com' })
|
|
129
|
+
else
|
|
130
|
+
puts Color.c("❌ Error: Target URL or domain required. Example: gsc speed https://mysite.com", Color::RED, Color::BOLD)
|
|
131
|
+
end
|
|
132
|
+
return
|
|
133
|
+
end
|
|
134
|
+
url = "https://#{url}" unless url =~ %r{^https?://}
|
|
135
|
+
strategy = options[:strategy] || 'mobile'
|
|
136
|
+
|
|
137
|
+
puts Base::BANNER unless options[:json] || options[:in_dashboard]
|
|
138
|
+
puts "⚡ Measuring Core Web Vitals via Google PageSpeed Insights (#{strategy.upcase}):" unless options[:json]
|
|
139
|
+
puts " #{Color.c(url, Color::CYAN)}\n" unless options[:json]
|
|
140
|
+
|
|
141
|
+
ps = GSC::PageSpeed.new(url, strategy: strategy)
|
|
142
|
+
data = ps.run
|
|
143
|
+
|
|
144
|
+
if options[:json]
|
|
145
|
+
puts JSON.pretty_generate(data)
|
|
146
|
+
return
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
if data[:error]
|
|
150
|
+
puts Color.c("❌ PageSpeed API Error: #{data[:message]}", Color::RED)
|
|
151
|
+
return
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
perf_score = data[:performance_score]
|
|
155
|
+
score_color = perf_score >= 90 ? Color::GREEN : (perf_score >= 50 ? Color::YELLOW : Color::RED)
|
|
156
|
+
|
|
157
|
+
puts "╔══════════════════════════════════════════════════════════════╗"
|
|
158
|
+
puts "║ Lighthouse Performance Score: #{Color.c(perf_score.to_s.rjust(3) + ' / 100', score_color, Color::BOLD)} ║"
|
|
159
|
+
puts "║ Lighthouse SEO Score : #{Color.c(data[:seo_score].to_s.rjust(3) + ' / 100', Color::GREEN, Color::BOLD)} ║"
|
|
160
|
+
puts "╚══════════════════════════════════════════════════════════════╝"
|
|
161
|
+
|
|
162
|
+
m = data[:metrics] || {}
|
|
163
|
+
puts "\n#{Color::BOLD}📊 CORE WEB VITALS (Lab Metrics):#{Color::RESET}"
|
|
164
|
+
puts " • LCP (Largest Contentful Paint) : #{Color.c(m[:lcp] || 'N/A', Color::BOLD)}"
|
|
165
|
+
puts " • FCP (First Contentful Paint) : #{Color.c(m[:fcp] || 'N/A', Color::BOLD)}"
|
|
166
|
+
puts " • CLS (Cumulative Layout Shift) : #{Color.c(m[:cls] || 'N/A', Color::BOLD)}"
|
|
167
|
+
puts " • TBT (Total Blocking Time) : #{Color.c(m[:tbt] || 'N/A', Color::BOLD)}"
|
|
168
|
+
puts " • Speed Index : #{Color.c(m[:speed_index] || 'N/A', Color::BOLD)}"
|
|
169
|
+
|
|
170
|
+
opps = data[:opportunities] || []
|
|
171
|
+
unless opps.empty?
|
|
172
|
+
puts "\n#{Color::BOLD}💡 TOP SPEED OPPORTUNITIES:#{Color::RESET}"
|
|
173
|
+
opps.each do |opp|
|
|
174
|
+
puts " • #{opp[:title]}: #{Color.c(opp[:display] || "#{opp[:savings_ms]}ms savings", Color::YELLOW)}"
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
puts ""
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
def handle_speed_correlate(target, options)
|
|
181
|
+
domain = options[:domain] || (target =~ /\.(com|org|net|io|app|co|dev|store)$/i ? target : nil) || Config.default_domain
|
|
182
|
+
url = target || (domain ? "https://#{domain}/" : nil)
|
|
183
|
+
unless url
|
|
184
|
+
if options[:json]
|
|
185
|
+
puts JSON.pretty_generate({ error: 'Target URL or domain required. Example: gsc speed-correlate https://mysite.com' })
|
|
186
|
+
else
|
|
187
|
+
puts Color.c("❌ Error: Target URL or domain required. Example: gsc speed-correlate https://mysite.com", Color::RED, Color::BOLD)
|
|
188
|
+
end
|
|
189
|
+
return
|
|
190
|
+
end
|
|
191
|
+
domain ||= URI.parse(url).host.to_s.sub(/^www\./, '') rescue 'site'
|
|
192
|
+
url = "https://#{url}" unless url =~ %r{^https?://}
|
|
193
|
+
strategy = options[:strategy] || 'mobile'
|
|
194
|
+
days = (options[:days] || 28).to_i
|
|
195
|
+
|
|
196
|
+
api = nil
|
|
197
|
+
sa = GSC::Auth.find_service_account(options[:key], domain)
|
|
198
|
+
if sa && sa[:data]
|
|
199
|
+
token = GSC::Auth.fetch_access_token(sa[:data])
|
|
200
|
+
client = GSC::Client.new(token: token)
|
|
201
|
+
api = GSC::API.new(client)
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
correlator = GSC::SpeedCorrelator.new(url, api: api, domain: domain, days: days, strategy: strategy, options: options)
|
|
205
|
+
data = correlator.correlate
|
|
206
|
+
|
|
207
|
+
if options[:json]
|
|
208
|
+
puts JSON.pretty_generate(data)
|
|
209
|
+
return
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
puts Base::BANNER unless options[:in_dashboard]
|
|
213
|
+
puts "⚡ #{Color::BOLD}CORE WEB VITALS & SEARCH CONSOLE PERFORMANCE CORRELATOR:#{Color::RESET}"
|
|
214
|
+
puts " Target URL: #{Color.c(url, Color::CYAN, Color::BOLD)} [Strategy: #{strategy.upcase}]"
|
|
215
|
+
puts "─" * 80
|
|
216
|
+
|
|
217
|
+
cwv = data[:core_web_vitals] || {}
|
|
218
|
+
gsc = data[:gsc_performance] || {}
|
|
219
|
+
diag = data[:correlation_diagnosis] || {}
|
|
220
|
+
proj = data[:projections] || {}
|
|
221
|
+
|
|
222
|
+
perf_score = cwv[:performance_score] || 0
|
|
223
|
+
score_color = perf_score >= 90 ? Color::GREEN : (perf_score >= 50 ? Color::YELLOW : Color::RED)
|
|
224
|
+
|
|
225
|
+
puts "📱 #{Color::BOLD}LIGHTHOUSE PERFORMANCE:#{Color::RESET} #{Color.c("#{perf_score}/100", score_color, Color::BOLD)} [Source: #{cwv[:source]}]"
|
|
226
|
+
puts " • LCP (Largest Contentful Paint) : #{Color.c(cwv[:lcp_display].to_s, Color::BOLD)} [Status: #{diag[:lcp_status]}]"
|
|
227
|
+
puts " • CLS (Cumulative Layout Shift) : #{Color.c(cwv[:cls_display].to_s, Color::BOLD)} [Status: #{diag[:cls_status]}]"
|
|
228
|
+
puts " • FCP (First Contentful Paint) : #{Color.c(cwv[:fcp_display].to_s, Color::BOLD)}"
|
|
229
|
+
puts " • TBT (Total Blocking Time) : #{Color.c(cwv[:tbt_display].to_s, Color::BOLD)}"
|
|
230
|
+
|
|
231
|
+
puts "\n📈 #{Color::BOLD}SEARCH CONSOLE TRACTION (Past #{days} Days):#{Color::RESET}"
|
|
232
|
+
puts " • Impressions : #{Color.c(gsc[:impressions].to_s, Color::CYAN, Color::BOLD)}"
|
|
233
|
+
puts " • Clicks : #{Color.c(gsc[:clicks].to_s, Color::GREEN, Color::BOLD)}"
|
|
234
|
+
puts " • CTR : #{Color.c("#{gsc[:ctr]}%", Color::BOLD)}"
|
|
235
|
+
puts " • Avg Position: #{Color.c(gsc[:position].to_s, Color::BOLD)}"
|
|
236
|
+
|
|
237
|
+
puts "\n🧠 #{Color::BOLD}ALGORITHMIC CWV IMPACT DIAGNOSIS:#{Color::RESET}"
|
|
238
|
+
diag_color = diag[:overall_cwv_pass] ? Color::GREEN : Color::YELLOW
|
|
239
|
+
puts " • CWV Overall Verdict : [#{Color.c(diag[:overall_cwv_pass] ? 'PASSED' : 'NEEDS OPTIMIZATION', diag_color, Color::BOLD)}]"
|
|
240
|
+
puts " • Algorithmic Drag : #{Color.c(diag[:algorithmic_status], Color::BOLD)}"
|
|
241
|
+
|
|
242
|
+
puts "\n🚀 #{Color::BOLD}PROJECTED SEARCH IMPRESSION & TRAFFIC SURGE (Upon Reaching 'Good' CWV):#{Color::RESET}"
|
|
243
|
+
puts " • Potential Impression Lift : #{Color.c("+#{proj[:potential_lift_percentage]}%", Color::GREEN, Color::BOLD)} (+#{proj[:incremental_impressions_gain]} impressions)"
|
|
244
|
+
puts " • Projected Impressions Target: #{Color.c(proj[:projected_impressions].to_s, Color::CYAN, Color::BOLD)}"
|
|
245
|
+
puts " • Projected Rank Lift : #{Color.c(proj[:estimated_position_improvement].to_s, Color::GREEN, Color::BOLD)} (Pos #{proj[:current_position]} -> Pos #{proj[:projected_position]})"
|
|
246
|
+
puts " • Projected Monthly Click Gain: #{Color.c("+#{proj[:projected_incremental_monthly_clicks]} clicks/mo", Color::GREEN, Color::BOLD)}"
|
|
247
|
+
|
|
248
|
+
action_plan = data[:action_plan] || []
|
|
249
|
+
if action_plan.any?
|
|
250
|
+
puts "\n🛠️ #{Color::BOLD}PRIORITIZED ENGINEERING FIXES FOR SPEED & RANKINGS:#{Color::RESET}"
|
|
251
|
+
action_plan.each_with_index do |act, idx|
|
|
252
|
+
puts " #{idx + 1}. [#{Color.c(act[:priority], Color::BOLD)}] [#{act[:metric]}]: #{act[:recommendation]}"
|
|
253
|
+
end
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
opps = cwv[:opportunities] || []
|
|
257
|
+
if opps.any?
|
|
258
|
+
puts "\n💡 #{Color::BOLD}IDENTIFIED BOTTLENECK OPPORTUNITIES:#{Color::RESET}"
|
|
259
|
+
opps.each do |o|
|
|
260
|
+
puts " • #{o[:title]} (#{Color.c(o[:display] || "#{o[:savings_ms]}ms savings", Color::YELLOW)})"
|
|
261
|
+
end
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
puts "\n" + ("─" * 80) + "\n"
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
def handle_compare(target, extra, options)
|
|
268
|
+
url1 = target
|
|
269
|
+
url2 = extra
|
|
270
|
+
|
|
271
|
+
if url1.nil? || url2.nil?
|
|
272
|
+
puts Color.c("❌ Error: Two URLs required. Example: gsc compare https://site.com/p1 https://competitor.com/p2", Color::RED)
|
|
273
|
+
return
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
comp = GSC::PageComparator.new(url1, url2)
|
|
277
|
+
data = comp.compare
|
|
278
|
+
|
|
279
|
+
if options[:json]
|
|
280
|
+
puts JSON.pretty_generate(data)
|
|
281
|
+
return
|
|
282
|
+
end
|
|
283
|
+
|
|
284
|
+
puts Base::BANNER unless options[:in_dashboard]
|
|
285
|
+
puts "🥊 #{Color::BOLD}HEAD-TO-HEAD SEO ON-PAGE COMPARISON:#{Color::RESET}"
|
|
286
|
+
puts " Page 1 (Target): #{Color.c(url1, Color::CYAN)}"
|
|
287
|
+
puts " Page 2 (Competitor): #{Color.c(url2, Color::YELLOW)}"
|
|
288
|
+
puts "─" * 80
|
|
289
|
+
|
|
290
|
+
c = data[:comparison] || {}
|
|
291
|
+
|
|
292
|
+
t1 = c.dig(:meta, :title, :page1) || {}
|
|
293
|
+
t2 = c.dig(:meta, :title, :page2) || {}
|
|
294
|
+
puts "\n#{Color::BOLD}📑 TITLE TAG:#{Color::RESET}"
|
|
295
|
+
puts " P1: #{t1[:text]} (#{t1[:length]} chars) [#{t1[:optimal] ? Color.c('Optimal', Color::GREEN) : Color.c('Review', Color::YELLOW)}]"
|
|
296
|
+
puts " P2: #{t2[:text]} (#{t2[:length]} chars) [#{t2[:optimal] ? Color.c('Optimal', Color::GREEN) : Color.c('Review', Color::YELLOW)}]"
|
|
297
|
+
|
|
298
|
+
h = c[:headings] || {}
|
|
299
|
+
puts "\n#{Color::BOLD}🏷️ HEADINGS H1 / H2:#{Color::RESET}"
|
|
300
|
+
puts " P1: #{h.dig(:h1_count, :page1)} H1s | #{h.dig(:h2_count, :page1)} H2s"
|
|
301
|
+
puts " P2: #{h.dig(:h1_count, :page2)} H1s | #{h.dig(:h2_count, :page2)} H2s"
|
|
302
|
+
|
|
303
|
+
img = c[:images] || {}
|
|
304
|
+
puts "\n#{Color::BOLD}🖼️ IMAGES & ACCESSIBILITY:#{Color::RESET}"
|
|
305
|
+
puts " P1: #{img.dig(:total_images, :page1)} images (#{img.dig(:missing_alt, :page1)} missing alt)"
|
|
306
|
+
puts " P2: #{img.dig(:total_images, :page2)} images (#{img.dig(:missing_alt, :page2)} missing alt)"
|
|
307
|
+
|
|
308
|
+
l = c[:links] || {}
|
|
309
|
+
puts "\n#{Color::BOLD}🔗 LINK COUNTS:#{Color::RESET}"
|
|
310
|
+
puts " P1: #{l.dig(:internal, :page1)} internal | #{l.dig(:external, :page1)} external"
|
|
311
|
+
puts " P2: #{l.dig(:internal, :page2)} internal | #{l.dig(:external, :page2)} external"
|
|
312
|
+
|
|
313
|
+
s = c[:structured_data] || {}
|
|
314
|
+
puts "\n#{Color::BOLD}📦 STRUCTURED DATA (JSON-LD):#{Color::RESET}"
|
|
315
|
+
puts " P1: #{s.dig(:schema_count, :page1)} schemas #{(s.dig(:schema_types, :page1) || []).inspect}"
|
|
316
|
+
puts " P2: #{s.dig(:schema_count, :page2)} schemas #{(s.dig(:schema_types, :page2) || []).inspect}"
|
|
317
|
+
|
|
318
|
+
p_time = c[:performance] || {}
|
|
319
|
+
puts "\n#{Color::BOLD}⚡ RESPONSE TIME:#{Color::RESET}"
|
|
320
|
+
puts " P1: #{p_time.dig(:response_time_ms, :page1)}ms | P2: #{p_time.dig(:response_time_ms, :page2)}ms"
|
|
321
|
+
puts ""
|
|
322
|
+
end
|
|
323
|
+
|
|
324
|
+
def handle_content_gap(target, extra, options)
|
|
325
|
+
url1 = target
|
|
326
|
+
url2 = extra
|
|
327
|
+
|
|
328
|
+
if url1.nil? || url2.nil?
|
|
329
|
+
puts Color.c("❌ Error: Two URLs required. Example: gsc content-gap https://mysite.com https://competitor.com", Color::RED)
|
|
330
|
+
return
|
|
331
|
+
end
|
|
332
|
+
|
|
333
|
+
gap = GSC::ContentGap.new(url1, url2)
|
|
334
|
+
data = gap.analyze
|
|
335
|
+
|
|
336
|
+
if options[:json]
|
|
337
|
+
puts JSON.pretty_generate(data)
|
|
338
|
+
return
|
|
339
|
+
end
|
|
340
|
+
|
|
341
|
+
puts Base::BANNER unless options[:in_dashboard]
|
|
342
|
+
puts "🔍 #{Color::BOLD}CONTENT & TOPICAL KEYWORD GAP (SurferSEO Style):#{Color::RESET}"
|
|
343
|
+
puts " My URL: #{Color.c(url1, Color::CYAN)} (#{data[:page1][:word_count]} words)"
|
|
344
|
+
puts " Competitor URL: #{Color.c(url2, Color::YELLOW)} (#{data[:page2][:word_count]} words)"
|
|
345
|
+
puts "─" * 80
|
|
346
|
+
|
|
347
|
+
puts "\n#{Color::BOLD}🎯 HIGH-FREQUENCY PHRASES IN COMPETITOR MISSING IN YOUR CONTENT:#{Color::RESET}"
|
|
348
|
+
unigrams = data[:missing_unigrams] || []
|
|
349
|
+
bigrams = data[:missing_bigrams] || []
|
|
350
|
+
|
|
351
|
+
if unigrams.empty? && bigrams.empty?
|
|
352
|
+
puts " (No major content gap detected! Your page covers competitor terminology well.)"
|
|
353
|
+
else
|
|
354
|
+
puts "\n #{Color.c('Top Missing 2-Word Keyphrases:', Color::BOLD, Color::YELLOW)}"
|
|
355
|
+
bigrams.first(8).each do |b|
|
|
356
|
+
puts " • \"#{Color.c(b[:term], Color::BOLD)}\" (Competitor uses #{b[:competitor_count]}x, You: #{b[:your_count]}x)"
|
|
357
|
+
end
|
|
358
|
+
|
|
359
|
+
puts "\n #{Color.c('Top Missing Keywords:', Color::BOLD, Color::CYAN)}"
|
|
360
|
+
unigrams.first(8).each do |u|
|
|
361
|
+
puts " • \"#{Color.c(u[:term], Color::BOLD)}\" (Competitor uses #{u[:competitor_count]}x, You: #{u[:your_count]}x)"
|
|
362
|
+
end
|
|
363
|
+
end
|
|
364
|
+
|
|
365
|
+
headings = data[:missing_headings] || []
|
|
366
|
+
unless headings.empty?
|
|
367
|
+
puts "\n#{Color::BOLD}📑 COMPETITOR HEADINGS / TOPICS YOU OMITTED:#{Color::RESET}"
|
|
368
|
+
headings.each do |h|
|
|
369
|
+
puts " • #{h}"
|
|
370
|
+
end
|
|
371
|
+
end
|
|
372
|
+
puts ""
|
|
373
|
+
end
|
|
374
|
+
|
|
375
|
+
def handle_internal_links(target, options)
|
|
376
|
+
base_url = target || (Config.default_domain ? "https://#{Config.default_domain}" : nil)
|
|
377
|
+
unless base_url
|
|
378
|
+
if options[:json]
|
|
379
|
+
puts JSON.pretty_generate({ error: 'Target URL or domain required. Example: gsc orphans https://mysite.com' })
|
|
380
|
+
else
|
|
381
|
+
puts Color.c("❌ Error: Target URL or domain required. Example: gsc orphans https://mysite.com", Color::RED, Color::BOLD)
|
|
382
|
+
end
|
|
383
|
+
return
|
|
384
|
+
end
|
|
385
|
+
conc = options[:concurrency] || 5
|
|
386
|
+
il = GSC::InternalLinks.new(base_url, limit: options[:limit] || 50, concurrency: conc)
|
|
387
|
+
|
|
388
|
+
puts Base::BANNER unless options[:json] || options[:in_dashboard]
|
|
389
|
+
puts "🕸️ Auditing Internal Links & Orphan Pages for: #{Color.c(base_url, Color::CYAN)} (#{conc} worker threads)...\n" unless options[:json]
|
|
390
|
+
|
|
391
|
+
data = il.audit
|
|
392
|
+
|
|
393
|
+
if options[:json]
|
|
394
|
+
puts JSON.pretty_generate(data)
|
|
395
|
+
return
|
|
396
|
+
end
|
|
397
|
+
|
|
398
|
+
score_color = case data[:health_score]
|
|
399
|
+
when 85..100 then Color::GREEN
|
|
400
|
+
when 70..84 then Color::YELLOW
|
|
401
|
+
else Color::RED
|
|
402
|
+
end
|
|
403
|
+
|
|
404
|
+
puts "📍 Domain: #{Color.c(data[:base_url], Color::CYAN, Color::BOLD)}"
|
|
405
|
+
puts "⚡ Crawl Performance: #{data[:total_pages]} pages mapped in #{data[:crawl_duration_s]}s"
|
|
406
|
+
puts "🏆 Link Equity Score: #{Color.c("#{data[:health_score]}/100 (Grade #{data[:health_grade]})", score_color, Color::BOLD)}"
|
|
407
|
+
puts "🚨 True Orphans: #{Color.c(data[:orphans].length.to_s, data[:orphans].empty? ? Color::GREEN : Color::RED, Color::BOLD)} (0 incoming internal links)"
|
|
408
|
+
puts "⚠️ Weakly Linked: #{Color.c(data[:weak_pages].length.to_s, Color::YELLOW, Color::BOLD)} (Only 1 incoming internal link)"
|
|
409
|
+
puts "🧗 Buried Pages: #{Color.c(data[:deep_pages].length.to_s, data[:deep_pages].empty? ? Color::GREEN : Color::YELLOW, Color::BOLD)} (Click depth >= 4)"
|
|
410
|
+
puts "─" * 80
|
|
411
|
+
|
|
412
|
+
orphan_details = data[:orphan_details] || []
|
|
413
|
+
unless orphan_details.empty?
|
|
414
|
+
puts "\n#{Color::BOLD}🚨 ORPHAN PAGE RESCUE PLAYBOOK (#{orphan_details.size} crawl dead-ends):#{Color::RESET}"
|
|
415
|
+
orphan_details.each_with_index do |orp, idx|
|
|
416
|
+
puts "\n #{Color::BOLD}#{idx + 1}. #{Color.c(orp[:url], Color::RED, Color::BOLD)}#{Color::RESET} [Depth: #{orp[:depth]}]"
|
|
417
|
+
puts " #{Color::BOLD}Rescue Prescriptions:#{Color::RESET}"
|
|
418
|
+
orp[:suggested_rescues].each do |rec|
|
|
419
|
+
puts " • #{Color.c(rec[:action], Color::CYAN)}"
|
|
420
|
+
end
|
|
421
|
+
end
|
|
422
|
+
else
|
|
423
|
+
puts "\n#{Color.c('✅ EXCELLENT! No orphan pages detected on site.', Color::GREEN, Color::BOLD)}"
|
|
424
|
+
end
|
|
425
|
+
|
|
426
|
+
weak = data[:weak_pages] || []
|
|
427
|
+
unless weak.empty?
|
|
428
|
+
puts "\n#{Color::BOLD}⚠️ WEAKLY LINKED PAGES (Vulnerable to De-indexation):#{Color::RESET}"
|
|
429
|
+
weak.first(5).each do |w|
|
|
430
|
+
puts " • #{w[:url]} (Linked only from: #{Color.c(w[:source].to_s, Color::GRAY)})"
|
|
431
|
+
end
|
|
432
|
+
puts " ... and #{weak.size - 5} more weak pages" if weak.size > 5
|
|
433
|
+
end
|
|
434
|
+
|
|
435
|
+
deep = data[:deep_pages] || []
|
|
436
|
+
unless deep.empty?
|
|
437
|
+
puts "\n#{Color::BOLD}🧗 BURIED PAGES (Crawl Depth >= 4 clicks):#{Color::RESET}"
|
|
438
|
+
deep.first(5).each do |dp|
|
|
439
|
+
puts " • #{dp[:url]} (#{Color.c("#{dp[:depth]} clicks deep", Color::YELLOW)})"
|
|
440
|
+
end
|
|
441
|
+
end
|
|
442
|
+
|
|
443
|
+
puts "\n#{Color::BOLD}🔝 TOP LINK EQUITY HUBS (High-Authority Internal Sources):#{Color::RESET}"
|
|
444
|
+
(data[:top_linked] || []).first(6).each do |top|
|
|
445
|
+
title_str = top[:title].to_s.empty? ? '' : " — \"#{top[:title][0..35]}...\""
|
|
446
|
+
puts " • #{Color.c(top[:url], Color::BOLD)}#{title_str} (#{Color.c(top[:incoming_count].to_s, Color::CYAN)} in / #{top[:outgoing_count]} out)"
|
|
447
|
+
end
|
|
448
|
+
|
|
449
|
+
if options[:csv]
|
|
450
|
+
csv_data = orphan_details.map do |o|
|
|
451
|
+
[o[:url], o[:depth], o[:suggested_rescues].map { |r| r[:source_url] }.join('; ')]
|
|
452
|
+
end
|
|
453
|
+
Base.write_csv(options[:csv], %w[OrphanURL Depth RecommendedRescueSources], csv_data)
|
|
454
|
+
puts Color.c("\n📁 Exported orphan rescue playbook to #{options[:csv]}", Color::CYAN)
|
|
455
|
+
end
|
|
456
|
+
|
|
457
|
+
puts "\n" + ("─" * 80) + "\n"
|
|
458
|
+
end
|
|
459
|
+
|
|
460
|
+
def handle_schema(target, extra, options)
|
|
461
|
+
type_str = target.to_s.downcase
|
|
462
|
+
supported_types = GSC::SchemaValidator::AVAILABLE_TEMPLATES.map { |t| t[:type] }
|
|
463
|
+
|
|
464
|
+
if %w[list templates types help].include?(type_str) || (target.nil? && !Config.default_domain)
|
|
465
|
+
render_schema_templates_list(options)
|
|
466
|
+
return
|
|
467
|
+
end
|
|
468
|
+
|
|
469
|
+
if target == 'generate' || target == 'gen' || supported_types.include?(type_str)
|
|
470
|
+
schema_type = (target == 'generate' || target == 'gen') ? (extra || 'faq') : target
|
|
471
|
+
tpl = GSC::SchemaValidator.generate_template(schema_type)
|
|
472
|
+
if options[:json]
|
|
473
|
+
puts JSON.pretty_generate(tpl)
|
|
474
|
+
else
|
|
475
|
+
puts Color.c("📋 Generated JSON-LD Schema (#{schema_type}):", Color::GREEN, Color::BOLD)
|
|
476
|
+
puts "<script type=\"application/ld+json\">"
|
|
477
|
+
puts JSON.pretty_generate(tpl)
|
|
478
|
+
puts "</script>"
|
|
479
|
+
puts "\n" + Color.gray("💡 Available templates: ") + Color.cyan(supported_types.join(', '))
|
|
480
|
+
puts Color.gray(" Run `gsc schema list` to view all templates and Google Rich Result features.")
|
|
481
|
+
puts Color.gray(" Tip: Run `gsc schema-gen <url> --type #{schema_type}` to extract and auto-fill from a live page.")
|
|
482
|
+
puts Color.gray(" Test live in Google's Rich Results Tool: https://search.google.com/test/rich-results")
|
|
483
|
+
if options[:open]
|
|
484
|
+
puts Color.green("\n🚀 Opening Google Rich Results Test (Code Tab) in your browser...")
|
|
485
|
+
Base.open_in_browser("https://search.google.com/test/rich-results")
|
|
486
|
+
end
|
|
487
|
+
puts
|
|
488
|
+
end
|
|
489
|
+
return
|
|
490
|
+
end
|
|
491
|
+
|
|
492
|
+
url = target || (Config.default_domain ? "https://#{Config.default_domain}" : nil)
|
|
493
|
+
unless url
|
|
494
|
+
if options[:json]
|
|
495
|
+
puts JSON.pretty_generate({ error: 'Target URL or schema type required. Example: gsc schema https://mysite.com' })
|
|
496
|
+
else
|
|
497
|
+
puts Color.c("❌ Error: Target URL or schema type required. Example: gsc schema https://mysite.com", Color::RED, Color::BOLD)
|
|
498
|
+
puts " Run `gsc schema list` to view all available JSON-LD templates."
|
|
499
|
+
end
|
|
500
|
+
return
|
|
501
|
+
end
|
|
502
|
+
sv = GSC::SchemaValidator.new(url)
|
|
503
|
+
data = sv.audit
|
|
504
|
+
|
|
505
|
+
rich_test_url = url =~ %r{^https?://} ? "https://search.google.com/test/rich-results?url=#{CGI.escape(url)}" : "https://search.google.com/test/rich-results"
|
|
506
|
+
|
|
507
|
+
if options[:json]
|
|
508
|
+
data[:google_rich_results_test_url] = rich_test_url
|
|
509
|
+
puts JSON.pretty_generate(data)
|
|
510
|
+
return
|
|
511
|
+
end
|
|
512
|
+
|
|
513
|
+
puts Base::BANNER unless options[:in_dashboard]
|
|
514
|
+
puts "📦 #{Color::BOLD}STRUCTURED DATA & RICH SNIPPET VALIDATION:#{Color::RESET} #{Color.c(url, Color::CYAN)}"
|
|
515
|
+
puts "─" * 75
|
|
516
|
+
|
|
517
|
+
schemas = data[:schemas] || []
|
|
518
|
+
if schemas.empty?
|
|
519
|
+
puts " (No JSON-LD structured data schemas found on this page)"
|
|
520
|
+
puts " Tip: Run `gsc schema generate faq` to create valid JSON-LD schema."
|
|
521
|
+
else
|
|
522
|
+
schemas.each do |sc|
|
|
523
|
+
valid_badge = sc[:valid] ? Color.c("VALID", Color::GREEN, Color::BOLD) : Color.c("INVALID", Color::RED, Color::BOLD)
|
|
524
|
+
puts "\nSchema ##{sc[:index] + 1}: #{Color.c(sc[:type], Color::BOLD)} [#{valid_badge}]"
|
|
525
|
+
(sc[:errors] || []).each { |e| puts " ❌ Error: #{Color.c(e, Color::RED)}" }
|
|
526
|
+
(sc[:warnings] || []).each { |w| puts " ⚠️ Warning: #{Color.c(w, Color::YELLOW)}" }
|
|
527
|
+
end
|
|
528
|
+
end
|
|
529
|
+
|
|
530
|
+
puts "\n" + Color.bold("🌐 OFFICIAL GOOGLE RICH RESULTS TEST TOOL:")
|
|
531
|
+
puts " #{Color.cyan(rich_test_url)}"
|
|
532
|
+
if options[:open]
|
|
533
|
+
puts Color.green("\n🚀 Opening Google Rich Results Test in your browser...")
|
|
534
|
+
Base.open_in_browser(rich_test_url)
|
|
535
|
+
end
|
|
536
|
+
puts ""
|
|
537
|
+
end
|
|
538
|
+
|
|
539
|
+
def render_schema_templates_list(options)
|
|
540
|
+
templates = GSC::SchemaValidator::AVAILABLE_TEMPLATES
|
|
541
|
+
if options[:json]
|
|
542
|
+
puts JSON.pretty_generate(templates)
|
|
543
|
+
return
|
|
544
|
+
end
|
|
545
|
+
|
|
546
|
+
puts "\n" + Color.cyan("╔" + "═" * 78 + "╗")
|
|
547
|
+
puts Color.cyan("║") + Color.bold(" 📦 AVAILABLE GOOGLE RICH SNIPPET SCHEMA TEMPLATES ") + Color.cyan("║")
|
|
548
|
+
puts Color.cyan("╚" + "═" * 78 + "╝")
|
|
549
|
+
puts " Generate ready-to-paste JSON-LD structured data or auto-fill from any live URL."
|
|
550
|
+
puts Color.cyan("─" * 80)
|
|
551
|
+
puts sprintf(" %-16s %-45s %s", Color.bold("Schema Type"), Color.bold("Google SERP Feature"), Color.bold("Command Example"))
|
|
552
|
+
puts Color.gray(" " + "─" * 76)
|
|
553
|
+
|
|
554
|
+
templates.each do |tpl|
|
|
555
|
+
type_col = Color.cyan(tpl[:type].ljust(14))
|
|
556
|
+
feature_col = tpl[:feature].ljust(43)
|
|
557
|
+
example_col = Color.gray("gsc schema #{tpl[:type]}")
|
|
558
|
+
puts " #{type_col} #{feature_col} #{example_col}"
|
|
559
|
+
end
|
|
560
|
+
|
|
561
|
+
puts Color.cyan("─" * 80)
|
|
562
|
+
puts Color.bold("💡 USAGE & WORKFLOWS:")
|
|
563
|
+
puts " 1. Generate Clean Template: " + Color.green("gsc schema <type>") + Color.gray(" (e.g. gsc schema product)")
|
|
564
|
+
puts " 2. Auto-Extract from Live URL: " + Color.green("gsc schema-gen <url> --type <type>")
|
|
565
|
+
puts " 3. Audit Page for Rich Schema: " + Color.green("gsc schema <url>") + Color.gray(" (or `gsc rich-results <url>`)")
|
|
566
|
+
puts " 4. Live Googlebot Verification: " + Color.green("gsc inspect <url>")
|
|
567
|
+
puts Color.cyan("═" * 80) + "\n"
|
|
568
|
+
end
|
|
569
|
+
|
|
570
|
+
def handle_llms(target, extra, options, api = nil, site_url = nil)
|
|
571
|
+
base_url = target || (Config.default_domain ? "https://#{Config.default_domain}" : nil)
|
|
572
|
+
unless base_url
|
|
573
|
+
if options[:json]
|
|
574
|
+
puts JSON.pretty_generate({ error: 'Target URL or domain required. Example: gsc llms https://mysite.com' })
|
|
575
|
+
else
|
|
576
|
+
puts Color.c("❌ Error: Target URL or domain required. Example: gsc llms https://mysite.com", Color::RED, Color::BOLD)
|
|
577
|
+
end
|
|
578
|
+
return
|
|
579
|
+
end
|
|
580
|
+
base_url = "https://#{base_url}" unless base_url =~ %r{^https?://}
|
|
581
|
+
llms = GSC::LlmsGenerator.new(base_url, options)
|
|
582
|
+
|
|
583
|
+
if extra == 'audit' || options[:audit]
|
|
584
|
+
data = llms.audit_ai_readability(base_url)
|
|
585
|
+
if options[:json]
|
|
586
|
+
puts JSON.pretty_generate(data)
|
|
587
|
+
else
|
|
588
|
+
puts Base::BANNER unless options[:in_dashboard]
|
|
589
|
+
puts "🤖 #{Color::BOLD}AI SEARCH ENGINE / LLM READABILITY AUDIT:#{Color::RESET} #{Color.c(base_url, Color::CYAN)}"
|
|
590
|
+
puts "─" * 70
|
|
591
|
+
puts "AI Citation Readiness Score: #{Color.c(data[:ai_readability_score].to_s + '/100', Color::GREEN, Color::BOLD)} [Grade: #{data[:grade]}]"
|
|
592
|
+
puts "\nFeatures Detected:"
|
|
593
|
+
puts " • Single H1 Heading : #{data.dig(:features, :h1_count) == 1 ? '✅ Yes' : '❌ No'}"
|
|
594
|
+
puts " • Tables for Data : #{data.dig(:features, :has_tables) ? '✅ Yes' : '❌ No'}"
|
|
595
|
+
puts " • Bullet Lists : #{data.dig(:features, :has_lists) ? '✅ Yes' : '❌ No'}"
|
|
596
|
+
puts " • Structured Data : #{data.dig(:features, :schemas_found)} schemas"
|
|
597
|
+
unless data[:issues].empty?
|
|
598
|
+
puts "\nOptimizations for Perplexity & ChatGPT:"
|
|
599
|
+
data[:issues].each { |iss| puts " • #{Color.c(iss, Color::YELLOW)}" }
|
|
600
|
+
end
|
|
601
|
+
puts ""
|
|
602
|
+
end
|
|
603
|
+
elsif options[:package] || options[:bundle] || extra == 'bundle' || extra == 'package'
|
|
604
|
+
# Fetch GSC search queries for FAQ injection if available
|
|
605
|
+
gsc_rows = nil
|
|
606
|
+
if api && site_url
|
|
607
|
+
res = api.query_analytics(
|
|
608
|
+
site_url: site_url,
|
|
609
|
+
start_date: (Date.today - 28).to_s,
|
|
610
|
+
end_date: Date.today.to_s,
|
|
611
|
+
dimensions: ['page', 'query'],
|
|
612
|
+
row_limit: 1000
|
|
613
|
+
) rescue nil
|
|
614
|
+
gsc_rows = res.dig(:data, 'rows') || res['rows'] if res
|
|
615
|
+
end
|
|
616
|
+
|
|
617
|
+
output_dir = options[:write] || (options[:save] ? '.' : nil)
|
|
618
|
+
bundle = llms.package_agent_bundle(output_dir: output_dir, limit: (options[:limit] || 25).to_i, gsc_rows: gsc_rows)
|
|
619
|
+
|
|
620
|
+
if options[:json]
|
|
621
|
+
puts JSON.pretty_generate(bundle)
|
|
622
|
+
return
|
|
623
|
+
end
|
|
624
|
+
|
|
625
|
+
puts Base::BANNER unless options[:in_dashboard]
|
|
626
|
+
puts "📦 #{Color::BOLD}AI AGENT MULTI-PAGE KNOWLEDGE PACKAGER (Turn 23):#{Color::RESET} #{Color.c(base_url, Color::CYAN)}"
|
|
627
|
+
puts "─" * 76
|
|
628
|
+
puts " Base Domain: #{Color.c(base_url, Color::CYAN)}"
|
|
629
|
+
puts " Pages Packaged: #{Color.bold(bundle[:total_pages_packaged].to_s)}"
|
|
630
|
+
puts " /llms.txt Size: #{Color.green("#{bundle.dig(:llms_txt, :chars)} chars")} (#{bundle.dig(:llms_txt, :tokens)} tokens)"
|
|
631
|
+
puts " /llms-full.txt Size: #{Color.green("#{bundle.dig(:llms_full_txt, :chars)} chars")} (#{bundle.dig(:llms_full_txt, :tokens)} tokens)"
|
|
632
|
+
puts "\n LLM Context Window Feasibility:"
|
|
633
|
+
puts " • Claude 3.5 Sonnet (200k) : #{bundle.dig(:context_window_fit, :claude_3_5_sonnet_200k) ? '✅ Fits cleanly' : '⚠️ Exceeds context'}"
|
|
634
|
+
puts " • GPT-4o / GPT-5 (128k) : #{bundle.dig(:context_window_fit, :gpt_4o_128k) ? '✅ Fits cleanly' : '⚠️ Exceeds context'}"
|
|
635
|
+
puts " • Gemini 1.5 Pro (1M) : #{bundle.dig(:context_window_fit, :gemini_1_5_pro_1m) ? '✅ Fits cleanly' : '⚠️ Exceeds context'}"
|
|
636
|
+
|
|
637
|
+
if bundle[:saved_files].any?
|
|
638
|
+
puts "\n Saved Files:"
|
|
639
|
+
bundle[:saved_files].each do |f|
|
|
640
|
+
puts " ✓ #{f[:path]} (#{f[:size_bytes]} bytes)"
|
|
641
|
+
end
|
|
642
|
+
else
|
|
643
|
+
puts "\n Tip: Run with `--write` to export /llms.txt and /llms-full.txt directly to your project root."
|
|
644
|
+
end
|
|
645
|
+
puts "─" * 76
|
|
646
|
+
puts ""
|
|
647
|
+
elsif options[:full] || extra == 'full'
|
|
648
|
+
# Generate /llms-full.txt
|
|
649
|
+
gsc_rows = nil
|
|
650
|
+
if api && site_url
|
|
651
|
+
res = api.query_analytics(
|
|
652
|
+
site_url: site_url,
|
|
653
|
+
start_date: (Date.today - 28).to_s,
|
|
654
|
+
end_date: Date.today.to_s,
|
|
655
|
+
dimensions: ['page', 'query'],
|
|
656
|
+
row_limit: 1000
|
|
657
|
+
) rescue nil
|
|
658
|
+
gsc_rows = res.dig(:data, 'rows') || res['rows'] if res
|
|
659
|
+
end
|
|
660
|
+
|
|
661
|
+
content = llms.generate_llms_full_txt(limit: (options[:limit] || 25).to_i, gsc_rows: gsc_rows)
|
|
662
|
+
if options[:save] || options[:write]
|
|
663
|
+
out_file = options[:write] && options[:write] != '.' ? File.join(options[:write], 'llms-full.txt') : 'llms-full.txt'
|
|
664
|
+
File.write(out_file, content, encoding: 'UTF-8')
|
|
665
|
+
puts Color.c("✅ Successfully wrote #{out_file} (#{content.length} chars, ~#{(content.length/4.0).ceil} tokens)!", Color::GREEN)
|
|
666
|
+
else
|
|
667
|
+
puts content
|
|
668
|
+
end
|
|
669
|
+
else
|
|
670
|
+
content = llms.generate_llms_txt(limit: (options[:limit] || 25).to_i)
|
|
671
|
+
if options[:save] || options[:write]
|
|
672
|
+
out_file = options[:write] && options[:write] != '.' ? File.join(options[:write], 'llms.txt') : 'llms.txt'
|
|
673
|
+
File.write(out_file, content, encoding: 'UTF-8')
|
|
674
|
+
puts Color.c("✅ Successfully wrote #{out_file} to current directory!", Color::GREEN)
|
|
675
|
+
else
|
|
676
|
+
puts content
|
|
677
|
+
end
|
|
678
|
+
end
|
|
679
|
+
end
|
|
680
|
+
|
|
681
|
+
def handle_trace(target, options)
|
|
682
|
+
url = target || (Config.default_domain ? "https://#{Config.default_domain}" : nil)
|
|
683
|
+
unless url
|
|
684
|
+
if options[:json]
|
|
685
|
+
puts JSON.pretty_generate({ error: 'Target URL required. Example: gsc trace https://mysite.com' })
|
|
686
|
+
else
|
|
687
|
+
puts Color.c("❌ Error: Target URL required. Example: gsc trace https://mysite.com", Color::RED, Color::BOLD)
|
|
688
|
+
end
|
|
689
|
+
return
|
|
690
|
+
end
|
|
691
|
+
nt = GSC::NetworkTracer.new(url)
|
|
692
|
+
data = nt.trace
|
|
693
|
+
|
|
694
|
+
if options[:json]
|
|
695
|
+
puts JSON.pretty_generate(data)
|
|
696
|
+
return
|
|
697
|
+
end
|
|
698
|
+
|
|
699
|
+
puts Base::BANNER unless options[:in_dashboard]
|
|
700
|
+
puts "🛤️ #{Color::BOLD}REDIRECT CHAIN & HTTP HEADER TRACE:#{Color::RESET} #{Color.c(url, Color::CYAN)}"
|
|
701
|
+
puts "─" * 75
|
|
702
|
+
puts "Total Hops: #{data[:total_hops]} | Duration: #{data[:total_duration_ms]}ms"
|
|
703
|
+
|
|
704
|
+
(data[:hops] || []).each do |hop|
|
|
705
|
+
status_c = (hop[:status_code] == 200) ? Color::GREEN : Color::YELLOW
|
|
706
|
+
puts "\nHop ##{hop[:hop]}: #{Color.c(hop[:status_code].to_s, status_c, Color::BOLD)} (#{hop[:duration_ms]}ms)"
|
|
707
|
+
puts " URL: #{hop[:url]}"
|
|
708
|
+
puts " X-Robots-Tag: #{Color.c(hop[:x_robots_tag], Color::RED)}" if hop[:x_robots_tag]
|
|
709
|
+
puts " Canonical: #{hop[:canonical_header]}" if hop[:canonical_header]
|
|
710
|
+
puts " HSTS: #{hop[:hsts] ? 'Enabled' : 'Disabled'}"
|
|
711
|
+
end
|
|
712
|
+
puts ""
|
|
713
|
+
end
|
|
714
|
+
|
|
715
|
+
def handle_robots(target, extra, options)
|
|
716
|
+
url = target || (Config.default_domain ? "https://#{Config.default_domain}" : nil)
|
|
717
|
+
unless url
|
|
718
|
+
if options[:json]
|
|
719
|
+
puts JSON.pretty_generate({ error: 'Target URL or domain required. Example: gsc robots https://mysite.com' })
|
|
720
|
+
else
|
|
721
|
+
puts Color.c("❌ Error: Target URL or domain required. Example: gsc robots https://mysite.com", Color::RED, Color::BOLD)
|
|
722
|
+
end
|
|
723
|
+
return
|
|
724
|
+
end
|
|
725
|
+
path = extra || '/'
|
|
726
|
+
bot = options[:bot] || 'googlebot'
|
|
727
|
+
|
|
728
|
+
rc = GSC::RobotsChecker.new(url)
|
|
729
|
+
data = rc.check(path, bot)
|
|
730
|
+
|
|
731
|
+
if options[:json]
|
|
732
|
+
puts JSON.pretty_generate(data)
|
|
733
|
+
return
|
|
734
|
+
end
|
|
735
|
+
|
|
736
|
+
puts Base::BANNER unless options[:in_dashboard]
|
|
737
|
+
puts "🤖 #{Color::BOLD}ROBOTS.TXT CRAWLER SIMULATOR:#{Color::RESET}"
|
|
738
|
+
puts " Robots URL : #{data[:robots_url]}"
|
|
739
|
+
puts " User Agent : #{Color.c(bot, Color::CYAN)}"
|
|
740
|
+
puts " Test Path : #{Color.c(path, Color::BOLD)}"
|
|
741
|
+
puts "─" * 70
|
|
742
|
+
|
|
743
|
+
status_badge = data[:allowed] ? Color.c("✅ ALLOWED", Color::GREEN, Color::BOLD) : Color.c("❌ BLOCKED (DISALLOW)", Color::RED, Color::BOLD)
|
|
744
|
+
puts "Crawl Verdict : #{status_badge}"
|
|
745
|
+
if data[:matched_rule]
|
|
746
|
+
puts "Matched Rule : #{data[:matched_rule][:type].to_s.upcase}: #{data[:matched_rule][:path]}"
|
|
747
|
+
end
|
|
748
|
+
puts ""
|
|
749
|
+
end
|
|
750
|
+
|
|
751
|
+
def handle_backlinks(target, extra, options)
|
|
752
|
+
domain = target || Config.default_domain
|
|
753
|
+
unless domain
|
|
754
|
+
if options[:json]
|
|
755
|
+
puts JSON.pretty_generate({ error: 'Target domain required. Example: gsc backlinks mysite.com' })
|
|
756
|
+
else
|
|
757
|
+
puts Color.c("❌ Error: Target domain required. Example: gsc backlinks mysite.com", Color::RED, Color::BOLD)
|
|
758
|
+
end
|
|
759
|
+
return
|
|
760
|
+
end
|
|
761
|
+
bm = GSC::BacklinksManager.new(domain)
|
|
762
|
+
|
|
763
|
+
if target == 'import' || extra == 'import'
|
|
764
|
+
source = (target == 'import') ? extra : target
|
|
765
|
+
content = if source == 'clip' || source == 'clipboard'
|
|
766
|
+
`pbpaste 2>/dev/null`
|
|
767
|
+
elsif source && File.exist?(source)
|
|
768
|
+
File.read(source)
|
|
769
|
+
else
|
|
770
|
+
nil
|
|
771
|
+
end
|
|
772
|
+
|
|
773
|
+
if content.nil? || content.strip.empty?
|
|
774
|
+
puts Color.c("❌ Error: No content provided. Usage: gsc backlinks import [file.csv|clip]", Color::RED)
|
|
775
|
+
return
|
|
776
|
+
end
|
|
777
|
+
|
|
778
|
+
res = bm.import_csv(content)
|
|
779
|
+
if options[:json]
|
|
780
|
+
puts JSON.pretty_generate(res)
|
|
781
|
+
else
|
|
782
|
+
puts Color.c("✅ Successfully imported GSC backlink export!", Color::GREEN, Color::BOLD)
|
|
783
|
+
puts " Referring Domains : #{res[:sources_count]}"
|
|
784
|
+
puts " Target Pages : #{res[:targets_count]}"
|
|
785
|
+
end
|
|
786
|
+
return
|
|
787
|
+
end
|
|
788
|
+
|
|
789
|
+
data = bm.summary
|
|
790
|
+
|
|
791
|
+
if options[:json]
|
|
792
|
+
puts JSON.pretty_generate(data)
|
|
793
|
+
return
|
|
794
|
+
end
|
|
795
|
+
|
|
796
|
+
puts Base::BANNER unless options[:in_dashboard]
|
|
797
|
+
puts "🔗 #{Color::BOLD}GSC BACKLINK & REFERRING DOMAIN INTELLIGENCE:#{Color::RESET} #{Color.c(domain, Color::CYAN)}"
|
|
798
|
+
puts "─" * 75
|
|
799
|
+
puts "Total Referring Domains : #{Color.c(data[:total_referring_domains].to_s, Color::BOLD)}"
|
|
800
|
+
puts "Total External Links : #{Color.c(data[:total_external_links].to_s, Color::BOLD)}"
|
|
801
|
+
puts "Last Updated : #{data[:updated_at] || 'Never (Run `gsc backlinks import` to ingest GSC export)'}"
|
|
802
|
+
puts "─" * 75
|
|
803
|
+
|
|
804
|
+
sources = data[:top_referring_domains] || []
|
|
805
|
+
unless sources.empty?
|
|
806
|
+
puts "\n#{Color::BOLD}🌐 TOP REFERRING SITES:#{Color::RESET}"
|
|
807
|
+
sources.each do |s|
|
|
808
|
+
puts " • #{s['domain'].to_s.ljust(45)} #{Color.c(s['links_count'].to_s.rjust(6) + ' links', Color::CYAN)}"
|
|
809
|
+
end
|
|
810
|
+
end
|
|
811
|
+
|
|
812
|
+
targets = data[:top_target_pages] || []
|
|
813
|
+
unless targets.empty?
|
|
814
|
+
puts "\n#{Color::BOLD}🎯 TOP LINKED LANDING PAGES:#{Color::RESET}"
|
|
815
|
+
targets.each do |t|
|
|
816
|
+
puts " • #{t['target_url'].to_s.ljust(45)} #{Color.c(t['incoming_count'].to_s.rjust(6) + ' links', Color::GREEN)}"
|
|
817
|
+
end
|
|
818
|
+
end
|
|
819
|
+
puts ""
|
|
820
|
+
end
|
|
821
|
+
|
|
822
|
+
def handle_geo(target, options)
|
|
823
|
+
url = target || (Config.default_domain ? "https://#{Config.default_domain}" : nil)
|
|
824
|
+
unless url
|
|
825
|
+
if options[:json]
|
|
826
|
+
puts JSON.pretty_generate({ error: 'Target URL required. Example: gsc geo https://mysite.com' })
|
|
827
|
+
else
|
|
828
|
+
puts Color.c("❌ Error: Target URL required. Example: gsc geo https://mysite.com", Color::RED, Color::BOLD)
|
|
829
|
+
end
|
|
830
|
+
return
|
|
831
|
+
end
|
|
832
|
+
auditor = GSC::GeoAuditor.new(url)
|
|
833
|
+
|
|
834
|
+
puts "🤖 Analyzing Generative Engine Optimization (GEO) & Citability for #{Color.c(url, Color::CYAN)}...\n" unless options[:json]
|
|
835
|
+
|
|
836
|
+
data = auditor.audit
|
|
837
|
+
if data[:error]
|
|
838
|
+
if options[:json]
|
|
839
|
+
puts JSON.pretty_generate(data)
|
|
840
|
+
else
|
|
841
|
+
puts Color.c("❌ Error: #{data[:message]}", Color::RED, Color::BOLD)
|
|
842
|
+
end
|
|
843
|
+
return
|
|
844
|
+
end
|
|
845
|
+
|
|
846
|
+
if options[:json]
|
|
847
|
+
puts JSON.pretty_generate(data)
|
|
848
|
+
return
|
|
849
|
+
end
|
|
850
|
+
|
|
851
|
+
puts Base::BANNER unless options[:in_dashboard]
|
|
852
|
+
puts "🤖 #{Color::BOLD}GENERATIVE ENGINE OPTIMIZATION (GEO / AEO) AUDIT#{Color::RESET}"
|
|
853
|
+
puts "🎯 Target URL : #{Color.c(data[:url], Color::CYAN)}"
|
|
854
|
+
puts "─" * 75
|
|
855
|
+
|
|
856
|
+
score_color = case data[:score]
|
|
857
|
+
when 85..100 then Color::GREEN
|
|
858
|
+
when 70..84 then Color::CYAN
|
|
859
|
+
when 50..69 then Color::YELLOW
|
|
860
|
+
else Color::RED
|
|
861
|
+
end
|
|
862
|
+
|
|
863
|
+
puts "🏆 #{Color::BOLD}AI CITABILITY SCORE:#{Color::RESET} #{Color.c("#{data[:score]}/100", score_color, Color::BOLD)} — #{data[:grade]}"
|
|
864
|
+
puts " • AI Bot Access (Robots.txt) : #{data[:category_scores][:ai_bot_access][:score]}/25"
|
|
865
|
+
puts " • Direct Question Answers : #{data[:category_scores][:direct_answers][:score]}/25"
|
|
866
|
+
puts " • Factual & Data Citability : #{data[:category_scores][:facts_and_data][:score]}/25"
|
|
867
|
+
puts " • Entity Schema & Authority : #{data[:category_scores][:entity_schema][:score]}/25"
|
|
868
|
+
puts "─" * 75
|
|
869
|
+
|
|
870
|
+
puts "\n🤖 #{Color::BOLD}AI SEARCH CRAWLER ACCESS (Robots.txt):#{Color::RESET}"
|
|
871
|
+
data[:ai_crawlers][:bots].each do |bot, info|
|
|
872
|
+
badge = info[:allowed] ? Color.c("✅ ALLOWED", Color::GREEN) : Color.c("❌ BLOCKED", Color::RED, Color::BOLD)
|
|
873
|
+
puts " • #{bot.ljust(20)} [#{info[:provider]}]: #{badge} (#{info[:rule]})"
|
|
874
|
+
end
|
|
875
|
+
|
|
876
|
+
puts "\n📝 #{Color::BOLD}DIRECT ANSWER & CITABILITY DENSITY:#{Color::RESET}"
|
|
877
|
+
puts " • Question Headings Detected : #{data[:direct_answers][:total_question_headings]}"
|
|
878
|
+
puts " • Direct Answer Paragraphs : #{data[:direct_answers][:direct_answer_blocks_found]}"
|
|
879
|
+
puts " • Optimal Length Answers (30-80w): #{data[:direct_answers][:optimal_answers_count]}"
|
|
880
|
+
|
|
881
|
+
samples = data[:direct_answers][:samples] || []
|
|
882
|
+
if samples.any?
|
|
883
|
+
puts " #{Color::BOLD}Sample Answer Pairs:#{Color::RESET}"
|
|
884
|
+
samples.each do |s|
|
|
885
|
+
puts " Q: #{Color.c(s[:question], Color::CYAN)}"
|
|
886
|
+
puts " A: #{s[:answer_preview]} (#{s[:word_count]} words)"
|
|
887
|
+
end
|
|
888
|
+
end
|
|
889
|
+
|
|
890
|
+
puts "\n📊 #{Color::BOLD}FACTUAL & INFORMATION GAIN SIGNALS:#{Color::RESET}"
|
|
891
|
+
puts " • Statistics & Percentages : #{data[:facts_and_citability][:percentage_mentions]}"
|
|
892
|
+
puts " • Currency / Pricing Signals : #{data[:facts_and_citability][:currency_mentions]}"
|
|
893
|
+
puts " • Structured Bullet Items (<li>): #{data[:facts_and_citability][:bullet_list_items]}"
|
|
894
|
+
puts " • Comparison Table Rows (<tr>) : #{data[:facts_and_citability][:comparison_table_rows]}"
|
|
895
|
+
puts " • Recent Year Signals (2024-26): #{data[:facts_and_citability][:recent_year_mentions]}"
|
|
896
|
+
|
|
897
|
+
puts "\n🏛️ #{Color::BOLD}ENTITY KNOWLEDGE GRAPH & SCHEMA:#{Color::RESET}"
|
|
898
|
+
puts " • Schemas Found : #{data[:entity_knowledge_graph][:schema_count]} (#{data[:entity_knowledge_graph][:schema_types].join(', ')})"
|
|
899
|
+
puts " • Organization / Brand Schema : #{data[:entity_knowledge_graph][:has_organization_or_brand] ? Color.c("Yes", Color::GREEN) : Color.c("Missing", Color::YELLOW)}"
|
|
900
|
+
puts " • FAQ / HowTo Rich Schema : #{data[:entity_knowledge_graph][:has_faq_or_howto] ? Color.c("Yes", Color::GREEN) : Color.c("None", Color::YELLOW)}"
|
|
901
|
+
puts " • Wikidata / Wikipedia Linked : #{data[:entity_knowledge_graph][:wikidata_or_wikipedia_linked] ? Color.c("Yes", Color::GREEN) : Color.c("No", Color::YELLOW)}"
|
|
902
|
+
if data[:entity_knowledge_graph][:same_as_domains].any?
|
|
903
|
+
puts " • Social Authority Entity Links: #{data[:entity_knowledge_graph][:same_as_domains].join(', ')}"
|
|
904
|
+
end
|
|
905
|
+
|
|
906
|
+
recs = data[:recommendations] || []
|
|
907
|
+
if recs.any?
|
|
908
|
+
puts "\n💡 #{Color::BOLD}AI CITABILITY OPTIMIZATION RECOMMENDATIONS:#{Color::RESET}"
|
|
909
|
+
recs.each_with_index do |r, i|
|
|
910
|
+
puts " #{i + 1}. #{Color.c(r, Color::YELLOW)}"
|
|
911
|
+
end
|
|
912
|
+
end
|
|
913
|
+
puts ""
|
|
914
|
+
end
|
|
915
|
+
|
|
916
|
+
def handle_entity(target, options)
|
|
917
|
+
url = target || (Config.default_domain ? "https://#{Config.default_domain}" : nil)
|
|
918
|
+
unless url
|
|
919
|
+
if options[:json]
|
|
920
|
+
puts JSON.pretty_generate({ error: 'Target URL required. Example: gsc entity https://mysite.com' })
|
|
921
|
+
else
|
|
922
|
+
puts Color.c("❌ Error: Target URL required. Example: gsc entity https://mysite.com", Color::RED, Color::BOLD)
|
|
923
|
+
end
|
|
924
|
+
return
|
|
925
|
+
end
|
|
926
|
+
url = "https://#{url}" unless url =~ %r{^https?://}
|
|
927
|
+
|
|
928
|
+
puts "🏛️ Auditing Knowledge Graph Entity & sameAs Identity for #{Color.c(url, Color::CYAN)}...\n" unless options[:json]
|
|
929
|
+
|
|
930
|
+
data = GSC::EntityAuditor.audit(url)
|
|
931
|
+
if data[:error]
|
|
932
|
+
if options[:json]
|
|
933
|
+
puts JSON.pretty_generate(data)
|
|
934
|
+
else
|
|
935
|
+
puts Color.c("❌ Error: #{data[:error]}", Color::RED, Color::BOLD)
|
|
936
|
+
end
|
|
937
|
+
return
|
|
938
|
+
end
|
|
939
|
+
|
|
940
|
+
if options[:json]
|
|
941
|
+
puts JSON.pretty_generate(data)
|
|
942
|
+
return
|
|
943
|
+
end
|
|
944
|
+
|
|
945
|
+
puts Base::BANNER unless options[:in_dashboard]
|
|
946
|
+
puts "🏛️ #{Color::BOLD}KNOWLEDGE GRAPH ENTITY & sameAs DISAMBIGUATION AUDIT#{Color::RESET}"
|
|
947
|
+
puts "🎯 Target URL : #{Color.c(data[:url], Color::CYAN)}"
|
|
948
|
+
puts "─" * 75
|
|
949
|
+
|
|
950
|
+
score_color = case data[:score]
|
|
951
|
+
when 85..100 then Color::GREEN
|
|
952
|
+
when 70..84 then Color::CYAN
|
|
953
|
+
when 50..69 then Color::YELLOW
|
|
954
|
+
else Color::RED
|
|
955
|
+
end
|
|
956
|
+
|
|
957
|
+
puts "🏆 #{Color::BOLD}ENTITY AUTHORITY SCORE:#{Color::RESET} #{Color.c("#{data[:score]}/100", score_color, Color::BOLD)} — Grade #{data[:grade]}"
|
|
958
|
+
puts "📦 Detected Entities : #{data[:entities_found]}"
|
|
959
|
+
puts "─" * 75
|
|
960
|
+
|
|
961
|
+
if data[:findings].any?
|
|
962
|
+
puts "\n🔎 #{Color::BOLD}ENTITY SIGNALS DETECTED:#{Color::RESET}"
|
|
963
|
+
data[:findings].each do |f|
|
|
964
|
+
puts " • #{Color.c(f, Color::GREEN)}"
|
|
965
|
+
end
|
|
966
|
+
end
|
|
967
|
+
|
|
968
|
+
if data[:authoritative_links].any?
|
|
969
|
+
puts "\n🌐 #{Color::BOLD}HIGH-AUTHORITY SAMEAS IDENTITY LINKS:#{Color::RESET}"
|
|
970
|
+
data[:authoritative_links].each do |source, link|
|
|
971
|
+
puts " • #{source.to_s.capitalize.ljust(15)}: #{Color.c(link, Color::CYAN)}"
|
|
972
|
+
end
|
|
973
|
+
end
|
|
974
|
+
|
|
975
|
+
if data[:recommendations].any?
|
|
976
|
+
puts "\n💡 #{Color::BOLD}OPTIMIZATION DIRECTIVES:#{Color::RESET}"
|
|
977
|
+
data[:recommendations].each_with_index do |r, i|
|
|
978
|
+
puts " #{i + 1}. #{Color.c(r, Color::YELLOW)}"
|
|
979
|
+
end
|
|
980
|
+
end
|
|
981
|
+
|
|
982
|
+
if options[:generate] || data[:score] < 70
|
|
983
|
+
puts "\n📋 #{Color::BOLD}RECOMMENDED ENHANCED JSON-LD SCHEMA SNIPPET:#{Color::RESET}"
|
|
984
|
+
puts "<!-- Paste inside <head> on your homepage/about page -->"
|
|
985
|
+
puts "<script type=\"application/ld+json\">"
|
|
986
|
+
puts JSON.pretty_generate(data[:recommended_json_ld])
|
|
987
|
+
puts "</script>"
|
|
988
|
+
end
|
|
989
|
+
puts ""
|
|
990
|
+
end
|
|
991
|
+
|
|
992
|
+
def handle_firewall(target, options = {})
|
|
993
|
+
scanner = GSC::FirewallScanner.new(target, options)
|
|
994
|
+
data = scanner.scan
|
|
995
|
+
|
|
996
|
+
if options[:json]
|
|
997
|
+
puts JSON.pretty_generate(data)
|
|
998
|
+
return
|
|
999
|
+
end
|
|
1000
|
+
|
|
1001
|
+
puts Base::BANNER unless options[:in_dashboard]
|
|
1002
|
+
puts "🛡️ #{Color::BOLD}AI BOT ACCESSIBILITY & CLOUDFLARE/WAF FIREWALL AUDITOR#{Color::RESET}"
|
|
1003
|
+
puts "🎯 Target URL : #{Color.c(data[:url], Color::YELLOW, Color::BOLD)}"
|
|
1004
|
+
puts "🌐 Hostname : #{data[:host]}"
|
|
1005
|
+
|
|
1006
|
+
edge_names = data[:edge_infrastructure].map { |i| "#{i[:name]} (#{i[:role]})" }.join(' | ')
|
|
1007
|
+
puts "☁️ Edge CDN/WAF : #{Color.c(edge_names, Color::CYAN)}"
|
|
1008
|
+
|
|
1009
|
+
grade_color = case data[:grade]
|
|
1010
|
+
when 'A' then Color::GREEN
|
|
1011
|
+
when 'B' then Color::CYAN
|
|
1012
|
+
when 'C' then Color::YELLOW
|
|
1013
|
+
when 'D' then Color::RED
|
|
1014
|
+
else Color::RED
|
|
1015
|
+
end
|
|
1016
|
+
|
|
1017
|
+
score_str = "#{data[:score]}/100 [Grade #{data[:grade]}]"
|
|
1018
|
+
puts "🏆 AI Access : #{Color.c(score_str, Color::BOLD, grade_color)} - #{data[:verdict]}"
|
|
1019
|
+
puts "─" * 80
|
|
1020
|
+
|
|
1021
|
+
puts "\n#{Color::BOLD}📡 LIVE EDGE WAF MULTI-USER-AGENT PROBES (LIVE PASS-THROUGH):#{Color::RESET}"
|
|
1022
|
+
puts "#{Color::BOLD}%-18s %-24s %-12s %-10s %-14s %s#{Color::RESET}" % ['Persona', 'Client Name', 'HTTP Status', 'Latency', 'WAF Verdict', 'Challenge / Block']
|
|
1023
|
+
puts "─" * 80
|
|
1024
|
+
|
|
1025
|
+
data[:live_probes].each do |probe|
|
|
1026
|
+
status_col = case probe[:status]
|
|
1027
|
+
when 200..299 then Color.c("#{probe[:status]} OK", Color::GREEN)
|
|
1028
|
+
when 300..399 then Color.c("#{probe[:status]} REDIR", Color::YELLOW)
|
|
1029
|
+
when 403 then Color.c("403 FORBID", Color::RED, Color::BOLD)
|
|
1030
|
+
when 503 then Color.c("503 UNAVAIL", Color::RED, Color::BOLD)
|
|
1031
|
+
else Color.c(probe[:status].to_s, Color::RED)
|
|
1032
|
+
end
|
|
1033
|
+
|
|
1034
|
+
verdict_col = case probe[:verdict]
|
|
1035
|
+
when :pass then Color.c("PASS", Color::GREEN)
|
|
1036
|
+
when :redirect then Color.c("REDIRECT", Color::YELLOW)
|
|
1037
|
+
when :challenge then Color.c("CHALLENGE", Color::RED, Color::BOLD)
|
|
1038
|
+
when :blocked then Color.c("BLOCKED", Color::RED, Color::BOLD)
|
|
1039
|
+
else Color.c("ERROR", Color::RED)
|
|
1040
|
+
end
|
|
1041
|
+
|
|
1042
|
+
challenges_str = probe[:challenges].empty? ? '-' : probe[:challenges].join(', ')
|
|
1043
|
+
challenges_col = probe[:challenges].empty? ? Color.c(challenges_str, Color::GRAY) : Color.c(challenges_str, Color::RED, Color::BOLD)
|
|
1044
|
+
|
|
1045
|
+
puts "%-18s %-24s %-21s %-10s %-23s %s" % [
|
|
1046
|
+
probe[:persona],
|
|
1047
|
+
probe[:name],
|
|
1048
|
+
status_col,
|
|
1049
|
+
"#{probe[:duration_ms]}ms",
|
|
1050
|
+
verdict_col,
|
|
1051
|
+
challenges_col
|
|
1052
|
+
]
|
|
1053
|
+
end
|
|
1054
|
+
|
|
1055
|
+
puts "\n#{Color::BOLD}🤖 ROBOTS.TXT AI SEARCH & TRAINING CRAWLER DIRECTIVES:#{Color::RESET}"
|
|
1056
|
+
puts "#{Color::BOLD}%-20s %-15s %-12s %-12s %s#{Color::RESET}" % ['Bot User-Agent', 'Provider', 'Role', 'Status', 'Matched Rule / Reason']
|
|
1057
|
+
puts "─" * 80
|
|
1058
|
+
|
|
1059
|
+
data[:robots_txt][:bots].each do |bot_name, bot_info|
|
|
1060
|
+
status_col = case bot_info[:status]
|
|
1061
|
+
when :allowed then Color.c("ALLOWED", Color::GREEN, Color::BOLD)
|
|
1062
|
+
when :restricted then Color.c("RESTRICTED", Color::YELLOW)
|
|
1063
|
+
when :blocked then Color.c("BLOCKED", Color::RED, Color::BOLD)
|
|
1064
|
+
else Color.c("UNKNOWN", Color::GRAY)
|
|
1065
|
+
end
|
|
1066
|
+
|
|
1067
|
+
category_label = bot_info[:category].to_s.sub('_', ' ').capitalize
|
|
1068
|
+
|
|
1069
|
+
puts "%-20s %-15s %-12s %-21s %s" % [
|
|
1070
|
+
bot_name,
|
|
1071
|
+
bot_info[:provider],
|
|
1072
|
+
category_label,
|
|
1073
|
+
status_col,
|
|
1074
|
+
Color.c(bot_info[:reason].to_s, Color::GRAY)
|
|
1075
|
+
]
|
|
1076
|
+
end
|
|
1077
|
+
|
|
1078
|
+
puts "\n#{Color::BOLD}🔍 SILENT BOT BLOCKADE DIAGNOSIS:#{Color::RESET}"
|
|
1079
|
+
diag = data[:blockade_diagnosis]
|
|
1080
|
+
if diag[:silent_blockade_detected]
|
|
1081
|
+
puts Color.c(" 🚨 #{diag[:summary]}", Color::RED, Color::BOLD)
|
|
1082
|
+
puts " ⚠️ Browser pass: #{diag[:browser_accessible]} | Googlebot pass: #{diag[:googlebot_accessible]}"
|
|
1083
|
+
puts " ❌ Blocked AI crawlers: #{diag[:blocked_probes].map { |p| "#{p[:name]} (#{p[:status]})" }.join(', ')}"
|
|
1084
|
+
else
|
|
1085
|
+
puts Color.c(" #{diag[:summary]}", Color::GREEN)
|
|
1086
|
+
end
|
|
1087
|
+
|
|
1088
|
+
recipes = data[:remediation_recipes]
|
|
1089
|
+
if recipes[:cloudflare_waf_rule]
|
|
1090
|
+
puts "\n#{Color::BOLD}🛠️ RECOMMENDED CLOUDFLARE CUSTOM WAF BYPASS RULE:#{Color::RESET}"
|
|
1091
|
+
puts " Rule Name: #{Color.c(recipes[:cloudflare_waf_rule][:name], Color::CYAN, Color::BOLD)}"
|
|
1092
|
+
puts " Expression:\n #{Color.c(recipes[:cloudflare_waf_rule][:filter_expression], Color::YELLOW)}"
|
|
1093
|
+
puts " Action: #{Color.c('Skip', Color::GREEN, Color::BOLD)} (Skip WAF Managed Rules, Super Bot Fight Mode, Rate Limiting)"
|
|
1094
|
+
puts "\n #{Color::BOLD}Dashboard Deployment Steps:#{Color::RESET}"
|
|
1095
|
+
recipes[:cloudflare_waf_rule][:dashboard_instructions].each do |step|
|
|
1096
|
+
puts " #{Color.c(step, Color::GRAY)}"
|
|
1097
|
+
end
|
|
1098
|
+
end
|
|
1099
|
+
|
|
1100
|
+
if options[:generate] || diag[:silent_blockade_detected] || diag[:robots_critical_blocked].any?
|
|
1101
|
+
puts "\n#{Color::BOLD}📄 RECOMMENDED ROBOTS.TXT CONFIGURATION FOR AI SEARCH:#{Color::RESET}"
|
|
1102
|
+
puts Color.c(recipes[:recommended_robots_txt], Color::DIM)
|
|
1103
|
+
else
|
|
1104
|
+
puts "\n💡 #{Color.c('Tip: Pass --generate to display copy-paste robots.txt configuration.', Color::GRAY)}"
|
|
1105
|
+
end
|
|
1106
|
+
|
|
1107
|
+
puts "─" * 80 + "\n"
|
|
1108
|
+
end
|
|
1109
|
+
|
|
1110
|
+
def handle_titles(target, options = {})
|
|
1111
|
+
target_site = target.to_s.strip
|
|
1112
|
+
target_site = Config.default_domain.to_s.strip if target_site.empty?
|
|
1113
|
+
if target_site.empty?
|
|
1114
|
+
if options[:json]
|
|
1115
|
+
puts JSON.pretty_generate({ error: 'Target domain required. Example: gsc titles mysite.com' })
|
|
1116
|
+
else
|
|
1117
|
+
puts Color.c("❌ Error: Target domain required. Example: gsc titles mysite.com", Color::RED, Color::BOLD)
|
|
1118
|
+
end
|
|
1119
|
+
return
|
|
1120
|
+
end
|
|
1121
|
+
|
|
1122
|
+
optimizer = GSC::TitleOptimizer.new(target_site, options)
|
|
1123
|
+
|
|
1124
|
+
unless options[:json] || options[:csv]
|
|
1125
|
+
puts Base::BANNER unless options[:in_dashboard]
|
|
1126
|
+
puts "📏 #{Color::BOLD}GOOGLE SERP TITLE LENGTH & PIXEL OVERFLOW AUDITOR#{Color::RESET}"
|
|
1127
|
+
puts "🎯 Target: #{Color.c(target_site, Color::YELLOW, Color::BOLD)}"
|
|
1128
|
+
print "🔄 Crawling and calculating pixel metrics across pages... "
|
|
1129
|
+
$stdout.flush
|
|
1130
|
+
end
|
|
1131
|
+
|
|
1132
|
+
data = optimizer.audit do |_url, current, total|
|
|
1133
|
+
unless options[:json] || options[:csv]
|
|
1134
|
+
print "\r🔄 Crawling and calculating pixel metrics across pages... [#{current}/#{total}]"
|
|
1135
|
+
$stdout.flush
|
|
1136
|
+
end
|
|
1137
|
+
end
|
|
1138
|
+
|
|
1139
|
+
unless options[:json] || options[:csv]
|
|
1140
|
+
puts " Done!\n"
|
|
1141
|
+
end
|
|
1142
|
+
|
|
1143
|
+
if options[:json]
|
|
1144
|
+
puts JSON.pretty_generate(data)
|
|
1145
|
+
return
|
|
1146
|
+
end
|
|
1147
|
+
|
|
1148
|
+
if options[:csv]
|
|
1149
|
+
require 'csv'
|
|
1150
|
+
puts "URL,Characters,Pixel_Width,Status,Current_Title,Desktop_Preview,Suggested_Rewrite_1,Suggested_Rewrite_2"
|
|
1151
|
+
data[:pages].each do |p|
|
|
1152
|
+
r1 = p[:suggested_rewrites][0] ? p[:suggested_rewrites][0][:title].gsub('"', '""') : ''
|
|
1153
|
+
r2 = p[:suggested_rewrites][1] ? p[:suggested_rewrites][1][:title].gsub('"', '""') : ''
|
|
1154
|
+
title_clean = p[:title].to_s.gsub('"', '""')
|
|
1155
|
+
prev_clean = p[:desktop_preview].to_s.gsub('"', '""')
|
|
1156
|
+
puts "\"#{p[:url]}\",#{p[:char_count]},#{p[:pixel_width]},#{p[:status]},\"#{title_clean}\",\"#{prev_clean}\",\"#{r1}\",\"#{r2}\""
|
|
1157
|
+
end
|
|
1158
|
+
return
|
|
1159
|
+
end
|
|
1160
|
+
|
|
1161
|
+
grade_color = case data[:grade]
|
|
1162
|
+
when 'A' then Color::GREEN
|
|
1163
|
+
when 'B' then Color::CYAN
|
|
1164
|
+
when 'C' then Color::YELLOW
|
|
1165
|
+
when 'D' then Color::RED
|
|
1166
|
+
else Color::RED
|
|
1167
|
+
end
|
|
1168
|
+
|
|
1169
|
+
puts "🏆 Title Health : #{Color.c("#{data[:health_score]}/100 [Grade #{data[:grade]}]", Color::BOLD, grade_color)} - #{data[:verdict]}"
|
|
1170
|
+
puts "📊 Overview : #{data[:total_pages]} Pages Audited | #{Color.c("#{data[:percentages][:optimal_pct]}% Optimal", Color::GREEN)} | #{Color.c("#{data[:percentages][:overflow_pct]}% Overflowing", Color::YELLOW)}"
|
|
1171
|
+
puts "📈 Breakdown : ✅ #{data[:counts][:optimal]} Optimal | ⚠️ #{data[:counts][:desktop_overflow]} Desktop Overflow | 🚨 #{data[:counts][:critical_overflow]} Critical Overflow | ℹ️ #{data[:counts][:too_short]} Too Short | ❌ #{data[:counts][:missing]} Missing"
|
|
1172
|
+
puts "─" * 80
|
|
1173
|
+
|
|
1174
|
+
pages_to_display = options[:overflow_only] ? data[:pages].select { |p| [:desktop_overflow, :critical_overflow].include?(p[:status]) } : data[:pages]
|
|
1175
|
+
|
|
1176
|
+
if pages_to_display.empty?
|
|
1177
|
+
puts "\n🎉 #{Color.c('All audited pages have optimal title tags with zero pixel overflow!', Color::GREEN, Color::BOLD)}"
|
|
1178
|
+
else
|
|
1179
|
+
puts "\n#{Color::BOLD}%-16s %-7s %-10s %s#{Color::RESET}" % ['Status', 'Chars', 'Width', 'URL & Title Tag']
|
|
1180
|
+
puts "─" * 80
|
|
1181
|
+
|
|
1182
|
+
pages_to_display.each do |page|
|
|
1183
|
+
status_col = case page[:status]
|
|
1184
|
+
when :optimal then Color.c("OPTIMAL", Color::GREEN)
|
|
1185
|
+
when :desktop_overflow then Color.c("DESK OVERFLOW", Color::YELLOW, Color::BOLD)
|
|
1186
|
+
when :critical_overflow then Color.c("CRIT OVERFLOW", Color::RED, Color::BOLD)
|
|
1187
|
+
when :too_short then Color.c("TOO SHORT", Color::CYAN)
|
|
1188
|
+
when :missing then Color.c("MISSING", Color::RED, Color::BOLD)
|
|
1189
|
+
end
|
|
1190
|
+
|
|
1191
|
+
px_str = "#{page[:pixel_width]}px"
|
|
1192
|
+
px_col = page[:pixel_width] > 580.0 ? Color.c(px_str, Color::RED, Color::BOLD) : Color.c(px_str, Color::GREEN)
|
|
1193
|
+
|
|
1194
|
+
puts "%-23s %-7s %-18s %s" % [
|
|
1195
|
+
status_col,
|
|
1196
|
+
"#{page[:char_count]}c",
|
|
1197
|
+
px_col,
|
|
1198
|
+
Color.c(page[:url], Color::BOLD)
|
|
1199
|
+
]
|
|
1200
|
+
puts " ↳ #{Color.c(page[:title].empty? ? '(No Title Tag)' : page[:title], Color::DIM)}"
|
|
1201
|
+
end
|
|
1202
|
+
end
|
|
1203
|
+
|
|
1204
|
+
overflowing_pages = data[:pages].reject { |p| p[:suggested_rewrites].empty? }
|
|
1205
|
+
|
|
1206
|
+
if overflowing_pages.any?
|
|
1207
|
+
puts "\n\n#{Color::BOLD}📝 TITLE REWRITE & CTR OPTIMIZATION PLAYBOOK (#{overflowing_pages.size} Pages Flagged):#{Color::RESET}"
|
|
1208
|
+
puts "─" * 80
|
|
1209
|
+
|
|
1210
|
+
overflowing_pages.first(8).each_with_index do |p, idx|
|
|
1211
|
+
puts "\n#{Color.c("[#{idx + 1}]", Color::CYAN, Color::BOLD)} #{Color.c(p[:url], Color::BOLD)}"
|
|
1212
|
+
puts " ❌ Current Title : #{Color.c("\"#{p[:title]}\"", Color::RED)} (#{p[:pixel_width]}px, #{p[:char_count]} chars)"
|
|
1213
|
+
puts " 👀 Desktop SERP : #{Color.c(p[:desktop_preview], Color::GRAY)}"
|
|
1214
|
+
|
|
1215
|
+
puts " 💡 #{Color::BOLD}Synthesized Non-Truncating Title Rewrites (<560px):#{Color::RESET}"
|
|
1216
|
+
p[:suggested_rewrites].each do |rewrite|
|
|
1217
|
+
puts " • [#{Color.c(rewrite[:type], Color::YELLOW)}] #{Color.c(rewrite[:title], Color::GREEN, Color::BOLD)}"
|
|
1218
|
+
puts " ↳ #{rewrite[:pixel_width]}px | #{rewrite[:char_count]} chars | Desktop SERP: #{Color.c('FITS CLEANLY', Color::GREEN)}"
|
|
1219
|
+
end
|
|
1220
|
+
end
|
|
1221
|
+
|
|
1222
|
+
if overflowing_pages.size > 8
|
|
1223
|
+
puts "\n... and #{overflowing_pages.size - 8} more pages flagged. Run with #{Color.c('--csv', Color::CYAN)} or #{Color.c('--json', Color::CYAN)} to export all rewrites."
|
|
1224
|
+
end
|
|
1225
|
+
end
|
|
1226
|
+
|
|
1227
|
+
puts "\n" + ("─" * 80) + "\n"
|
|
1228
|
+
end
|
|
1229
|
+
|
|
1230
|
+
def handle_headings(target, options = {})
|
|
1231
|
+
url_or_path = target || (Config.default_domain ? "https://#{Config.default_domain}" : nil)
|
|
1232
|
+
unless url_or_path
|
|
1233
|
+
if options[:json]
|
|
1234
|
+
puts JSON.pretty_generate({ error: 'Target URL or file required. Example: gsc headings https://mysite.com' })
|
|
1235
|
+
else
|
|
1236
|
+
puts Color.c("❌ Error: Target URL or file required. Example: gsc headings https://mysite.com", Color::RED, Color::BOLD)
|
|
1237
|
+
end
|
|
1238
|
+
return
|
|
1239
|
+
end
|
|
1240
|
+
puts Base::BANNER unless options[:json] || options[:in_dashboard]
|
|
1241
|
+
|
|
1242
|
+
analyzer = PageAnalyzer.new(url_or_path)
|
|
1243
|
+
puts "🔍 Validating Heading Hierarchy (H1-H6) for: #{Color.c(url_or_path, Color::CYAN)}...\n" unless options[:json]
|
|
1244
|
+
|
|
1245
|
+
data = analyzer.fetch_and_analyze
|
|
1246
|
+
h = data[:headings]
|
|
1247
|
+
|
|
1248
|
+
if options[:json]
|
|
1249
|
+
puts JSON.pretty_generate({
|
|
1250
|
+
url: data[:url],
|
|
1251
|
+
http_status: data[:http_status],
|
|
1252
|
+
score: h[:score],
|
|
1253
|
+
grade: h[:grade],
|
|
1254
|
+
depth: h[:depth],
|
|
1255
|
+
total_headings: h[:count],
|
|
1256
|
+
h1_count: h[:h1_count],
|
|
1257
|
+
counts: h[:counts],
|
|
1258
|
+
empty_count: h[:empty_count],
|
|
1259
|
+
violations: h[:violations],
|
|
1260
|
+
keyword_analysis: h[:keyword_analysis],
|
|
1261
|
+
headings: h[:list],
|
|
1262
|
+
ascii_tree: h[:ascii_tree]
|
|
1263
|
+
})
|
|
1264
|
+
return
|
|
1265
|
+
end
|
|
1266
|
+
|
|
1267
|
+
status_color = data[:http_status] == 200 ? Color::GREEN : Color::YELLOW
|
|
1268
|
+
score_color = (h[:score] || 0) >= 80 ? Color::GREEN : ((h[:score] || 0) >= 60 ? Color::YELLOW : Color::RED)
|
|
1269
|
+
|
|
1270
|
+
puts "📍 URL: #{Color.c(data[:url], Color::CYAN, Color::BOLD)}"
|
|
1271
|
+
puts "⚡ HTTP Status: #{Color.c(data[:http_status].to_s, status_color, Color::BOLD)} (#{data[:response_time_ms]}ms)"
|
|
1272
|
+
puts "🏆 Heading Health: #{Color.c("#{h[:score]}/100 (Grade #{h[:grade]})", score_color, Color::BOLD)}"
|
|
1273
|
+
puts "📊 Total Headings: #{h[:count]} (Max Depth: H#{h[:depth]})"
|
|
1274
|
+
|
|
1275
|
+
counts_summary = (h[:counts] || {}).select { |_k, v| v > 0 }.map { |k, v| "#{v}x #{k.upcase}" }.join(' | ')
|
|
1276
|
+
puts "🔢 Tag Distribution: #{counts_summary.empty? ? 'None' : counts_summary}"
|
|
1277
|
+
puts "─" * 80
|
|
1278
|
+
|
|
1279
|
+
puts "\n#{Color::BOLD}🌲 HEADING HIERARCHY TREE:#{Color::RESET}"
|
|
1280
|
+
if h[:color_tree] && !h[:color_tree].strip.empty?
|
|
1281
|
+
puts h[:color_tree]
|
|
1282
|
+
else
|
|
1283
|
+
puts " (No headings found on page)"
|
|
1284
|
+
end
|
|
1285
|
+
|
|
1286
|
+
if h[:violations] && !h[:violations].empty?
|
|
1287
|
+
puts "\n#{Color::BOLD}⚠️ SEMANTIC HIERARCHY VIOLATIONS (#{h[:violations].size} detected):#{Color::RESET}"
|
|
1288
|
+
h[:violations].each_with_index do |v, idx|
|
|
1289
|
+
badge = case v[:severity]
|
|
1290
|
+
when :critical then Color.c("[CRITICAL]", Color::RED, Color::BOLD)
|
|
1291
|
+
when :warning then Color.c("[WARNING]", Color::YELLOW, Color::BOLD)
|
|
1292
|
+
else Color.c("[INFO]", Color::GRAY)
|
|
1293
|
+
end
|
|
1294
|
+
puts " #{idx + 1}. #{badge} #{v[:message]}"
|
|
1295
|
+
end
|
|
1296
|
+
else
|
|
1297
|
+
puts "\n#{Color.c('✅ 100% Semantic Heading Structure Compliance (No hierarchy violations found)', Color::GREEN, Color::BOLD)}"
|
|
1298
|
+
end
|
|
1299
|
+
|
|
1300
|
+
if options[:keyword] && h[:keyword_analysis]
|
|
1301
|
+
ka = h[:keyword_analysis]
|
|
1302
|
+
puts "\n#{Color::BOLD}🎯 KEYWORD HEADING RELEVANCE (#{options[:keyword]}):#{Color::RESET}"
|
|
1303
|
+
h1_status = ka[:h1_has_keyword] ? Color.c("Found in H1 ✅", Color::GREEN) : Color.c("Missing from H1 ❌", Color::YELLOW)
|
|
1304
|
+
puts " H1 Optimization: #{h1_status}"
|
|
1305
|
+
puts " H2 Occurrences: #{ka[:h2_matched_count]} H2 sections contain target keyword"
|
|
1306
|
+
end
|
|
1307
|
+
|
|
1308
|
+
puts "\n" + ("─" * 80) + "\n"
|
|
1309
|
+
end
|
|
1310
|
+
|
|
1311
|
+
def handle_page(target, site_url, api, options = {})
|
|
1312
|
+
url_or_path = target || (Config.default_domain ? "https://#{Config.default_domain}" : nil)
|
|
1313
|
+
unless url_or_path
|
|
1314
|
+
if options[:json]
|
|
1315
|
+
puts JSON.pretty_generate({ error: 'Target URL required. Example: gsc page https://mysite.com' })
|
|
1316
|
+
else
|
|
1317
|
+
puts Color.c("❌ Error: Target URL required. Example: gsc page https://mysite.com", Color::RED, Color::BOLD)
|
|
1318
|
+
end
|
|
1319
|
+
return
|
|
1320
|
+
end
|
|
1321
|
+
puts Base::BANNER unless options[:json] || options[:in_dashboard]
|
|
1322
|
+
|
|
1323
|
+
analyzer = PageAnalyzer.new(url_or_path)
|
|
1324
|
+
puts "🔍 Auditing On-Page DOM & GSC Performance for: #{Color.c(url_or_path, Color::CYAN)}...\n" unless options[:json]
|
|
1325
|
+
|
|
1326
|
+
data = analyzer.fetch_and_analyze(
|
|
1327
|
+
check_links: options[:check_links] || false,
|
|
1328
|
+
gsc_api: api,
|
|
1329
|
+
active_domain: site_url
|
|
1330
|
+
)
|
|
1331
|
+
|
|
1332
|
+
if options[:json]
|
|
1333
|
+
puts JSON.pretty_generate(data)
|
|
1334
|
+
return
|
|
1335
|
+
end
|
|
1336
|
+
|
|
1337
|
+
status_color = data[:http_status] == 200 ? Color::GREEN : Color::YELLOW
|
|
1338
|
+
puts "📍 URL: #{Color.c(data[:url], Color::CYAN, Color::BOLD)}"
|
|
1339
|
+
puts "⚡ HTTP Status: #{Color.c(data[:http_status].to_s, status_color, Color::BOLD)} (#{data[:response_time_ms]}ms) | Indexability: #{Color.c(data[:indexability][:status], data[:indexability][:status] == 'INDEXABLE' ? Color::GREEN : Color::RED, Color::BOLD)}"
|
|
1340
|
+
puts "─" * 80
|
|
1341
|
+
|
|
1342
|
+
title = data[:title]
|
|
1343
|
+
t_color = title[:ok] ? Color::GREEN : Color::YELLOW
|
|
1344
|
+
puts "\n#{Color::BOLD}📑 ON-PAGE METADATA (Detailed SEO Engine):#{Color::RESET}"
|
|
1345
|
+
puts " Title: #{Color.c(title[:text].empty? ? '(Missing)' : title[:text], Color::BOLD)} [#{Color.c("#{title[:length]} chars / ~#{title[:pixel_est]}px", t_color)}]"
|
|
1346
|
+
meta = data[:meta_description]
|
|
1347
|
+
m_color = meta[:ok] ? Color::GREEN : Color::YELLOW
|
|
1348
|
+
puts " Meta Desc: #{meta[:text].empty? ? Color.c('(Missing)', Color::YELLOW) : meta[:text]} [#{Color.c("#{meta[:length]} chars", m_color)}]"
|
|
1349
|
+
canon = data[:canonical]
|
|
1350
|
+
canon_str = canon[:url] ? (canon[:self_referencing] ? "#{canon[:url]} (Self-Referencing ✅)" : "#{canon[:url]} (Canonicalised ⚠️)") : '(None)'
|
|
1351
|
+
puts " Canonical: #{canon_str}"
|
|
1352
|
+
puts " Word Count: #{data[:stats][:word_count]} words (~#{data[:stats][:reading_time_mins]} min read)"
|
|
1353
|
+
|
|
1354
|
+
h = data[:headings]
|
|
1355
|
+
h_badge = h[:h1_count] == 1 ? Color.c("1x H1 ✅", Color::GREEN) : Color.c("#{h[:h1_count]}x H1 ⚠️", Color::RED)
|
|
1356
|
+
score_badge = h[:score] ? " | Health Score: #{Color.c("#{h[:score]}/100", (h[:score] >= 80 ? Color::GREEN : Color::YELLOW), Color::BOLD)} (Grade #{h[:grade]})" : ""
|
|
1357
|
+
puts "\n#{Color::BOLD}🏷️ HEADINGS HIERARCHY (#{h[:count]} total, #{h_badge}#{score_badge}):#{Color::RESET}"
|
|
1358
|
+
if h[:color_tree] && !h[:color_tree].strip.empty?
|
|
1359
|
+
puts h[:color_tree]
|
|
1360
|
+
else
|
|
1361
|
+
h[:list].first(8).each do |heading|
|
|
1362
|
+
indent = " " + (" " * (heading[:tag][1].to_i - 1))
|
|
1363
|
+
tag_badge = Color.c("[#{heading[:tag].upcase}]", Color::MAGENTA)
|
|
1364
|
+
puts "#{indent}#{tag_badge} #{heading[:text]}"
|
|
1365
|
+
end
|
|
1366
|
+
puts " ... and #{h[:count] - 8} more headings" if h[:count] > 8
|
|
1367
|
+
end
|
|
1368
|
+
|
|
1369
|
+
img = data[:images]
|
|
1370
|
+
img_color = img[:missing_alt_count] == 0 ? Color::GREEN : Color::YELLOW
|
|
1371
|
+
puts "\n#{Color::BOLD}🖼️ IMAGES & LINKS:#{Color::RESET}"
|
|
1372
|
+
puts " Images: #{img[:total]} total (#{Color.c("#{img[:missing_alt_count]} missing alt tags", img_color)})"
|
|
1373
|
+
if img[:missing_alt_count] > 0
|
|
1374
|
+
img[:missing_alt_images].first(3).each do |i|
|
|
1375
|
+
puts " ⚠️ Missing Alt: #{Color.c(i[:src].to_s[0..60], Color::GRAY)}"
|
|
1376
|
+
end
|
|
1377
|
+
end
|
|
1378
|
+
lnk = data[:links]
|
|
1379
|
+
puts " Links: #{lnk[:total]} total (#{lnk[:internal_count]} Internal, #{lnk[:external_count]} External, #{lnk[:nofollow_count]} Nofollow)"
|
|
1380
|
+
if data.dig(:links, :verification)
|
|
1381
|
+
broken = data[:links][:verification].reject { |l| l[:ok] }
|
|
1382
|
+
if broken.empty?
|
|
1383
|
+
puts " Link Health: #{Color.c('All verified links OK (HTTP 200) ✅', Color::GREEN)}"
|
|
1384
|
+
else
|
|
1385
|
+
puts " Broken Links: #{Color.c("#{broken.size} dead links found 🔴", Color::RED, Color::BOLD)}"
|
|
1386
|
+
broken.each do |b|
|
|
1387
|
+
puts " ❌ [HTTP #{b[:status]}] #{b[:href]} (Anchor: #{b[:anchor]})"
|
|
1388
|
+
end
|
|
1389
|
+
end
|
|
1390
|
+
elsif !options[:check_links]
|
|
1391
|
+
puts " Link Health: #{Color.c('(Pass --check-links to test HTTP status of every link)', Color::GRAY)}"
|
|
1392
|
+
end
|
|
1393
|
+
|
|
1394
|
+
s_list = data[:schema]
|
|
1395
|
+
puts "\n#{Color::BOLD}💎 STRUCTURED DATA (Schema.org JSON-LD):#{Color::RESET}"
|
|
1396
|
+
if s_list.empty?
|
|
1397
|
+
puts " #{Color.c('No JSON-LD schema found on this page.', Color::YELLOW)}"
|
|
1398
|
+
else
|
|
1399
|
+
types = s_list.flat_map { |s| s[:types] }.compact.uniq
|
|
1400
|
+
puts " Detected Schemas: #{Color.c(types.join(', '), Color::GREEN, Color::BOLD)}"
|
|
1401
|
+
end
|
|
1402
|
+
|
|
1403
|
+
if data[:gsc]
|
|
1404
|
+
g = data[:gsc]
|
|
1405
|
+
puts "\n#{Color::BOLD}📊 GOOGLE SEARCH CONSOLE PERFORMANCE (Past 90 Days):#{Color::RESET}"
|
|
1406
|
+
puts " Total Clicks: #{Color.c(g[:total_clicks].to_s, Color::CYAN, Color::BOLD)}"
|
|
1407
|
+
puts " Total Impressions: #{Color.c(g[:total_impressions].to_s, Color::CYAN, Color::BOLD)}"
|
|
1408
|
+
puts " Average CTR: #{Color.c("#{g[:ctr]}%", Color::CYAN, Color::BOLD)}"
|
|
1409
|
+
puts " Average Position: #{Color.c(g[:avg_position].to_s, Color::CYAN, Color::BOLD)}"
|
|
1410
|
+
if g[:top_queries] && !g[:top_queries].empty?
|
|
1411
|
+
puts " Top Ranking Queries:"
|
|
1412
|
+
g[:top_queries].each do |q|
|
|
1413
|
+
puts " • #{Color.c(q[:query], Color::YELLOW)} (Pos #{q[:position]}, #{q[:impressions]} imp, #{q[:clicks]} clicks)"
|
|
1414
|
+
end
|
|
1415
|
+
end
|
|
1416
|
+
end
|
|
1417
|
+
|
|
1418
|
+
puts "\n" + ("─" * 80)
|
|
1419
|
+
if data[:issues].empty?
|
|
1420
|
+
puts Color.c("✅ EXCELLENT! No SEO defects or hierarchy flaws detected.", Color::GREEN, Color::BOLD)
|
|
1421
|
+
else
|
|
1422
|
+
puts "#{Color::BOLD}⚠️ ACTIONABLE ISSUES FOUND (#{data[:issues].size}):#{Color::RESET}"
|
|
1423
|
+
data[:issues].each do |issue|
|
|
1424
|
+
badge = issue[:level] == :error || issue[:level] == :critical ? Color.c("[ERROR]", Color::RED, Color::BOLD) : Color.c("[WARN]", Color::YELLOW, Color::BOLD)
|
|
1425
|
+
puts " #{badge} #{issue[:message]}"
|
|
1426
|
+
end
|
|
1427
|
+
end
|
|
1428
|
+
puts ("─" * 80) + "\n"
|
|
1429
|
+
end
|
|
1430
|
+
|
|
1431
|
+
def handle_site_crawl(target, site_url, api, options = {})
|
|
1432
|
+
sitemap_target = target || (Config.default_domain ? "https://#{Config.default_domain}/sitemap.xml" : nil)
|
|
1433
|
+
unless sitemap_target
|
|
1434
|
+
if options[:json]
|
|
1435
|
+
puts JSON.pretty_generate({ error: 'Target sitemap XML required. Example: gsc crawl https://mysite.com/sitemap.xml' })
|
|
1436
|
+
else
|
|
1437
|
+
puts Color.c("❌ Error: Target sitemap XML required. Example: gsc crawl https://mysite.com/sitemap.xml", Color::RED, Color::BOLD)
|
|
1438
|
+
end
|
|
1439
|
+
return
|
|
1440
|
+
end
|
|
1441
|
+
puts Base::BANNER unless options[:json] || options[:in_dashboard]
|
|
1442
|
+
puts "🕷️ Starting Comprehensive Site Audit & Broken Link Crawler..." unless options[:json]
|
|
1443
|
+
puts " Target: #{Color.c(sitemap_target, Color::CYAN)}" unless options[:json]
|
|
1444
|
+
conc = options[:concurrency] || 5
|
|
1445
|
+
puts " Concurrency: #{Color.c("#{conc} worker threads", Color::GREEN)}" unless options[:json]
|
|
1446
|
+
puts " Link Verification: #{options[:check_links] ? Color.c('Enabled (testing all links)', Color::GREEN) : Color.c('Disabled (use --check-links to verify)', Color::GRAY)}" unless options[:json]
|
|
1447
|
+
puts "" unless options[:json]
|
|
1448
|
+
|
|
1449
|
+
crawler = SiteCrawler.new(sitemap_target, options.merge(gsc_api: api, active_domain: site_url))
|
|
1450
|
+
|
|
1451
|
+
summary = crawler.run do |url, current, total|
|
|
1452
|
+
unless options[:json]
|
|
1453
|
+
print "\r\e[K⏳ [#{current}/#{total}] Crawling & auditing: #{Color.c(url[0..60], Color::CYAN)}"
|
|
1454
|
+
$stdout.flush
|
|
1455
|
+
end
|
|
1456
|
+
end
|
|
1457
|
+
|
|
1458
|
+
puts "\n" unless options[:json]
|
|
1459
|
+
|
|
1460
|
+
if options[:report]
|
|
1461
|
+
report_file = crawler.generate_markdown_report(options[:report])
|
|
1462
|
+
puts Color.c("📝 Exported Comprehensive Markdown Audit Report to: #{report_file}\n", Color::GREEN, Color::BOLD) unless options[:json]
|
|
1463
|
+
end
|
|
1464
|
+
|
|
1465
|
+
if options[:json]
|
|
1466
|
+
puts JSON.pretty_generate({
|
|
1467
|
+
summary: summary,
|
|
1468
|
+
broken_links: crawler.broken_links,
|
|
1469
|
+
missing_alts: crawler.missing_alts,
|
|
1470
|
+
heading_issues: crawler.heading_issues,
|
|
1471
|
+
title_issues: crawler.title_issues,
|
|
1472
|
+
canonical_issues: crawler.canonical_issues,
|
|
1473
|
+
results: crawler.results
|
|
1474
|
+
})
|
|
1475
|
+
return
|
|
1476
|
+
end
|
|
1477
|
+
|
|
1478
|
+
puts ("═" * 80)
|
|
1479
|
+
puts "#{Color::BOLD}📊 SITE AUDIT SUMMARY REPORT#{Color::RESET}"
|
|
1480
|
+
puts ("─" * 80)
|
|
1481
|
+
puts " Pages Audited: #{Color.c(summary[:total_pages].to_s, Color::BOLD)}"
|
|
1482
|
+
puts " Total Issues Detected: #{Color.c(summary[:total_issues].to_s, summary[:total_issues] == 0 ? Color::GREEN : Color::YELLOW, Color::BOLD)}"
|
|
1483
|
+
puts " Broken Links (404/500): #{Color.c(summary[:broken_links_count].to_s, summary[:broken_links_count] == 0 ? Color::GREEN : Color::RED, Color::BOLD)}"
|
|
1484
|
+
puts " Images Missing Alt: #{Color.c(summary[:missing_alts_count].to_s, summary[:missing_alts_count] == 0 ? Color::GREEN : Color::YELLOW, Color::BOLD)}"
|
|
1485
|
+
puts " H1 Heading Flaws: #{Color.c(summary[:heading_issues_count].to_s, summary[:heading_issues_count] == 0 ? Color::GREEN : Color::YELLOW, Color::BOLD)}"
|
|
1486
|
+
puts " Title/Meta Flaws: #{Color.c(summary[:title_meta_issues_count].to_s, summary[:title_meta_issues_count] == 0 ? Color::GREEN : Color::YELLOW, Color::BOLD)}"
|
|
1487
|
+
puts ("═" * 80)
|
|
1488
|
+
|
|
1489
|
+
if summary[:broken_links_count] > 0
|
|
1490
|
+
puts "\n#{Color::BOLD}🚨 BROKEN LINKS DETECTED:#{Color::RESET}"
|
|
1491
|
+
crawler.broken_links.first(10).each do |b|
|
|
1492
|
+
puts " ❌ [HTTP #{b[:status]}] #{b[:href]}"
|
|
1493
|
+
puts " Found on: #{Color.c(b[:source_page], Color::GRAY)}"
|
|
1494
|
+
end
|
|
1495
|
+
end
|
|
1496
|
+
|
|
1497
|
+
if summary[:missing_alts_count] > 0
|
|
1498
|
+
puts "\n#{Color::BOLD}🖼️ TOP IMAGES MISSING ALT ATTRIBUTES:#{Color::RESET}"
|
|
1499
|
+
crawler.missing_alts.first(5).each do |img|
|
|
1500
|
+
puts " ⚠️ #{Color.c(img[:src], Color::YELLOW)} (on #{img[:page_url]})"
|
|
1501
|
+
end
|
|
1502
|
+
end
|
|
1503
|
+
|
|
1504
|
+
puts "\n💡 Run with #{Color.c('--report docs/seo/site_audit.md', Color::CYAN)} to generate full AI-actionable Markdown report." unless options[:report]
|
|
1505
|
+
puts ""
|
|
1506
|
+
end
|
|
1507
|
+
|
|
1508
|
+
def run_comprehensive_audit(api, site_url, hostname, https_origin, options)
|
|
1509
|
+
days = options[:days] || 30
|
|
1510
|
+
puts "🔍 Running 360° Comprehensive SEO & Growth Audit for #{Color.c(hostname, Color::CYAN)} (Past #{days} days)...\n" unless options[:json]
|
|
1511
|
+
|
|
1512
|
+
sites_res = api.list_sites
|
|
1513
|
+
access_ok = false
|
|
1514
|
+
if sites_res[:ok]
|
|
1515
|
+
entries = sites_res.dig(:data, 'siteEntry') || []
|
|
1516
|
+
access_ok = entries.any? { |s| s['siteUrl'] == site_url || s['siteUrl'].include?(hostname) }
|
|
1517
|
+
end
|
|
1518
|
+
|
|
1519
|
+
perf_res = api.query_analytics(site_url, days: days, dimensions: [])
|
|
1520
|
+
perf_row = perf_res.dig(:data, 'rows', 0) || { 'clicks' => 0, 'impressions' => 0, 'ctr' => 0, 'position' => 0 }
|
|
1521
|
+
total_clicks = perf_row['clicks'] || 0
|
|
1522
|
+
total_imp = perf_row['impressions'] || 0
|
|
1523
|
+
avg_ctr = ((perf_row['ctr'] || 0) * 100).round(2)
|
|
1524
|
+
avg_pos = (perf_row['position'] || 0).round(1)
|
|
1525
|
+
|
|
1526
|
+
home_res = api.inspect_url("#{https_origin}/", site_url)
|
|
1527
|
+
idx_res = home_res.dig(:data, 'inspectionResult', 'indexStatusResult') || {}
|
|
1528
|
+
verdict = idx_res['verdict'] || 'UNKNOWN'
|
|
1529
|
+
coverage = idx_res['coverageState'] || 'Unknown'
|
|
1530
|
+
google_canonical = idx_res['googleCanonical'] || "#{https_origin}/"
|
|
1531
|
+
user_canonical = idx_res['userCanonical'] || "#{https_origin}/"
|
|
1532
|
+
crawled_as = idx_res['crawlingUserAgent'] || idx_res['crawledAs'] || 'Googlebot Mobile'
|
|
1533
|
+
last_crawl_raw = idx_res['lastCrawlTime']
|
|
1534
|
+
last_crawl = last_crawl_raw ? last_crawl_raw.split('T').first : 'Unknown'
|
|
1535
|
+
canonical_match = google_canonical.chomp('/') == user_canonical.chomp('/') || google_canonical.include?(hostname)
|
|
1536
|
+
|
|
1537
|
+
sm_res = api.list_sitemaps(site_url)
|
|
1538
|
+
sitemaps = sm_res[:ok] ? (sm_res.dig(:data, 'sitemap') || []) : []
|
|
1539
|
+
sm_errors = sitemaps.sum { |s| (s['errors'] || 0).to_i }
|
|
1540
|
+
sm_warnings = sitemaps.sum { |s| (s['warnings'] || 0).to_i }
|
|
1541
|
+
|
|
1542
|
+
qp_res = api.query_analytics(site_url, days: days, dimensions: %w[query page], row_limit: 500)
|
|
1543
|
+
qp_rows = qp_res[:ok] ? (qp_res.dig(:data, 'rows') || []) : []
|
|
1544
|
+
|
|
1545
|
+
opportunities = qp_rows.select { |r| r['position'] >= 7.0 && r['position'] <= 20.0 && r['impressions'] >= 5 }
|
|
1546
|
+
.map do |r|
|
|
1547
|
+
c = r['clicks'] || 0
|
|
1548
|
+
i = r['impressions'] || 0
|
|
1549
|
+
pos = r['position'].round(1)
|
|
1550
|
+
potential_clicks = [((i * 0.12) - c).round, 1].max
|
|
1551
|
+
score = (potential_clicks * (1.0 / [pos - 2.0, 1.0].max) * 10.0).round(1)
|
|
1552
|
+
{ query: r['keys'][0], page: r['keys'][1], position: pos, impressions: i, clicks: c, potential_gain: potential_clicks, score: score }
|
|
1553
|
+
end.sort_by { |o| -o[:score] }.first(5)
|
|
1554
|
+
|
|
1555
|
+
underperformers = qp_rows.select { |r| r['position'] <= 10.0 && r['impressions'] >= 15 }
|
|
1556
|
+
.map do |r|
|
|
1557
|
+
pos_val = r['position'].round(1)
|
|
1558
|
+
actual = ((r['ctr'] || 0) * 100).round(2)
|
|
1559
|
+
exp = Base.expected_ctr_for(pos_val)
|
|
1560
|
+
gap = (exp - actual).round(2)
|
|
1561
|
+
lost = [((r['impressions'] * (gap / 100.0))).round, 1].max
|
|
1562
|
+
{ query: r['keys'][0], page: r['keys'][1], position: pos_val, impressions: r['impressions'], clicks: r['clicks'], actual_ctr: actual, expected_ctr: exp, gap: gap, lost_clicks: lost }
|
|
1563
|
+
end.select { |u| u[:gap] >= 2.0 && u[:actual_ctr] < (u[:expected_ctr] * 0.70) }
|
|
1564
|
+
.sort_by { |u| -u[:lost_clicks] }.first(5)
|
|
1565
|
+
|
|
1566
|
+
by_query = qp_rows.group_by { |r| r['keys'][0] }
|
|
1567
|
+
cannibalized = by_query.select { |_q, list| list.map { |r| r['keys'][1] }.uniq.size > 1 }
|
|
1568
|
+
.map do |q, list|
|
|
1569
|
+
pages = list.map do |r|
|
|
1570
|
+
{ page: r['keys'][1], impressions: r['impressions'], clicks: r['clicks'], position: r['position'].round(1) }
|
|
1571
|
+
end.sort_by { |p| -p[:impressions] }
|
|
1572
|
+
total_q_imp = pages.sum { |p| p[:impressions] }
|
|
1573
|
+
{ query: q, total_impressions: total_q_imp, pages: pages }
|
|
1574
|
+
end.sort_by { |c| -c[:total_impressions] }.first(3)
|
|
1575
|
+
|
|
1576
|
+
dev_res = api.query_analytics(site_url, days: days, dimensions: ['device'])
|
|
1577
|
+
devices_data = (dev_res[:ok] ? (dev_res.dig(:data, 'rows') || []) : []).map do |r|
|
|
1578
|
+
c = r['clicks'] || 0
|
|
1579
|
+
i = r['impressions'] || 0
|
|
1580
|
+
ctr = ((r['ctr'] || 0) * 100).round(2)
|
|
1581
|
+
pos = (r['position'] || 0).round(1)
|
|
1582
|
+
share = total_clicks > 0 ? "#{(c.to_f / total_clicks * 100).round(1)}%" : "-"
|
|
1583
|
+
{ device: r['keys'].first, clicks: c, impressions: i, ctr: ctr, position: pos, share: share }
|
|
1584
|
+
end.sort_by { |r| -r[:clicks] }
|
|
1585
|
+
|
|
1586
|
+
desk_dev = devices_data.find { |d| d[:device] == 'DESKTOP' }
|
|
1587
|
+
mob_dev = devices_data.find { |d| d[:device] == 'MOBILE' }
|
|
1588
|
+
mobile_deficit = desk_dev && mob_dev && desk_dev[:ctr] >= 1.0 && mob_dev[:ctr] < (desk_dev[:ctr] * 0.6)
|
|
1589
|
+
|
|
1590
|
+
snip_res = api.query_analytics(site_url, days: days, dimensions: ['searchAppearance'], row_limit: 10)
|
|
1591
|
+
snippets_data = (snip_res[:ok] ? (snip_res.dig(:data, 'rows') || []) : []).map do |r|
|
|
1592
|
+
{ type: r['keys'].first, clicks: r['clicks'] || 0, impressions: r['impressions'] || 0, ctr: ((r['ctr'] || 0) * 100).round(2), position: (r['position'] || 0).round(1) }
|
|
1593
|
+
end
|
|
1594
|
+
|
|
1595
|
+
ga4_prop = options[:property] || Config.ga4_property_id(hostname)
|
|
1596
|
+
ga4_data = nil
|
|
1597
|
+
high_bounce_pages = []
|
|
1598
|
+
if ga4_prop
|
|
1599
|
+
ga4_res = api.query_ga4_report(ga4_prop, days: days, limit: 50, hostname: (options[:all_hosts] ? nil : hostname), site_only: options[:site_only])
|
|
1600
|
+
if ga4_res[:ok]
|
|
1601
|
+
raw_ga4 = ga4_res.dig(:data, 'rows') || []
|
|
1602
|
+
tot_sess = raw_ga4.sum { |r| r.dig('metricValues', 0, 'value').to_i }
|
|
1603
|
+
tot_bounce = raw_ga4.sum { |r| r.dig('metricValues', 3, 'value').to_f * r.dig('metricValues', 0, 'value').to_i }
|
|
1604
|
+
overall_bounce = tot_sess.positive? ? (tot_bounce / tot_sess * 100.0).round(1) : 0.0
|
|
1605
|
+
tot_dur = raw_ga4.sum { |r| r.dig('metricValues', 4, 'value').to_f * r.dig('metricValues', 0, 'value').to_i }
|
|
1606
|
+
overall_dur = tot_sess.positive? ? (tot_dur / tot_sess).round : 0
|
|
1607
|
+
|
|
1608
|
+
high_bounce_pages = raw_ga4.map do |r|
|
|
1609
|
+
path = r.dig('dimensionValues', 0, 'value')
|
|
1610
|
+
sess = r.dig('metricValues', 0, 'value').to_i
|
|
1611
|
+
bounce = ((r.dig('metricValues', 3, 'value') || 0).to_f * 100).round(1)
|
|
1612
|
+
dur = (r.dig('metricValues', 4, 'value') || 0).to_f.round
|
|
1613
|
+
{ path: path, sessions: sess, bounce_rate: bounce, duration: Base.format_duration(dur) }
|
|
1614
|
+
end.select { |p| p[:sessions] >= 10 && p[:bounce_rate] >= 80.0 }.sort_by { |p| -p[:sessions] }.first(5)
|
|
1615
|
+
|
|
1616
|
+
ga4_data = {
|
|
1617
|
+
linked: true,
|
|
1618
|
+
property_id: ga4_prop,
|
|
1619
|
+
sessions: tot_sess,
|
|
1620
|
+
bounce_rate: overall_bounce,
|
|
1621
|
+
avg_duration_sec: overall_dur,
|
|
1622
|
+
avg_duration: Base.format_duration(overall_dur),
|
|
1623
|
+
high_bounce_pages: high_bounce_pages
|
|
1624
|
+
}
|
|
1625
|
+
end
|
|
1626
|
+
end
|
|
1627
|
+
|
|
1628
|
+
score = 100
|
|
1629
|
+
score -= 20 if verdict != 'PASS'
|
|
1630
|
+
score -= 10 if sitemaps.empty?
|
|
1631
|
+
score -= 10 if sm_errors > 0
|
|
1632
|
+
score -= 10 unless canonical_match
|
|
1633
|
+
score -= 15 if avg_ctr < 0.5
|
|
1634
|
+
score -= 10 if avg_ctr >= 0.5 && avg_ctr < 1.0
|
|
1635
|
+
score -= 10 if avg_pos > 30.0
|
|
1636
|
+
score -= [underperformers.size * 5, 10].min
|
|
1637
|
+
score -= [cannibalized.size * 5, 10].min
|
|
1638
|
+
score -= 5 if mobile_deficit
|
|
1639
|
+
if ga4_data && ga4_data[:sessions] > 0
|
|
1640
|
+
score -= 15 if ga4_data[:bounce_rate] > 85.0
|
|
1641
|
+
score -= 10 if ga4_data[:bounce_rate] > 75.0 && ga4_data[:bounce_rate] <= 85.0
|
|
1642
|
+
score += 5 if ga4_data[:bounce_rate] <= 60.0
|
|
1643
|
+
end
|
|
1644
|
+
score += 5 unless snippets_data.empty?
|
|
1645
|
+
score = [[score, 20].max, 100].min
|
|
1646
|
+
|
|
1647
|
+
grade = case score
|
|
1648
|
+
when 88..100 then 'A'
|
|
1649
|
+
when 75...88 then 'B'
|
|
1650
|
+
when 60...75 then 'C'
|
|
1651
|
+
else 'D'
|
|
1652
|
+
end
|
|
1653
|
+
|
|
1654
|
+
grade_label = case grade
|
|
1655
|
+
when 'A' then 'EXCELLENT - DOMINATING SERPS'
|
|
1656
|
+
when 'B' then 'GOOD - HIGH-LEVERAGE GROWTH WINS AVAILABLE'
|
|
1657
|
+
when 'C' then 'NEEDS ATTENTION - CLICK BLEED & CONFLICTS DETECTED'
|
|
1658
|
+
else 'CRITICAL - INDEXING, CANNIBALIZATION OR RETENTION FRICTION'
|
|
1659
|
+
end
|
|
1660
|
+
|
|
1661
|
+
action_plan = []
|
|
1662
|
+
|
|
1663
|
+
if cannibalized.any?
|
|
1664
|
+
top_c = cannibalized.first
|
|
1665
|
+
conflicting_pages = top_c[:pages].map { |p| p[:page].sub(%r{^https?://[^/]+}, '') }
|
|
1666
|
+
action_plan << {
|
|
1667
|
+
priority: 'high',
|
|
1668
|
+
title: "Consolidate Keyword Cannibalization on \"#{top_c[:query]}\"",
|
|
1669
|
+
detail: "Search intent is split across #{top_c[:pages].size} competing URLs (#{conflicting_pages.join(', ')}). Consolidate internal links and canonical signals to #{top_c[:pages].first[:page].sub(%r{^https?://[^/]+}, '')} to unify Page 1 ranking power."
|
|
1670
|
+
}
|
|
1671
|
+
end
|
|
1672
|
+
|
|
1673
|
+
if underperformers.any?
|
|
1674
|
+
top_u = underperformers.first
|
|
1675
|
+
action_plan << {
|
|
1676
|
+
priority: 'high',
|
|
1677
|
+
title: "Recover ~#{top_u[:lost_clicks]} Lost Clicks/mo on \"#{top_u[:query]}\"",
|
|
1678
|
+
detail: "Currently ranking Pos #{top_u[:position]} on #{top_u[:page].sub(%r{^https?://[^/]+}, '')} with #{top_u[:actual_ctr]}% CTR (expected benchmark ~#{top_u[:expected_ctr]}%). Rewrite `<title>` and meta description with numbers or benefit hooks to double click-through."
|
|
1679
|
+
}
|
|
1680
|
+
end
|
|
1681
|
+
|
|
1682
|
+
if sm_errors > 0
|
|
1683
|
+
err_sm = sitemaps.find { |s| (s['errors'] || 0).to_i > 0 }
|
|
1684
|
+
action_plan << {
|
|
1685
|
+
priority: 'high',
|
|
1686
|
+
title: "Resolve Sitemap Errors in Search Console",
|
|
1687
|
+
detail: "Sitemap #{err_sm['path']} has #{err_sm['errors']} errors. Clean up 404s or resubmit current sitemap-index.xml."
|
|
1688
|
+
}
|
|
1689
|
+
end
|
|
1690
|
+
|
|
1691
|
+
if verdict != 'PASS'
|
|
1692
|
+
action_plan << {
|
|
1693
|
+
priority: 'high',
|
|
1694
|
+
title: "Resolve Homepage Indexation Blocker",
|
|
1695
|
+
detail: "Homepage Googlebot verdict is #{verdict} (#{coverage}). Inspect URL in Search Console to resolve indexing eligibility."
|
|
1696
|
+
}
|
|
1697
|
+
end
|
|
1698
|
+
|
|
1699
|
+
if opportunities.any?
|
|
1700
|
+
top_o = opportunities.first
|
|
1701
|
+
action_plan << {
|
|
1702
|
+
priority: 'medium',
|
|
1703
|
+
title: "Push Striking-Distance Keyword \"#{top_o[:query]}\" to Top 3",
|
|
1704
|
+
detail: "Currently Pos #{top_o[:position]} with #{top_o[:impressions]} impressions on #{top_o[:page].sub(%r{^https?://[^/]+}, '')}. Add a dedicated H2 section and clear answering copy to unlock ~+#{top_o[:potential_gain]} clicks/mo."
|
|
1705
|
+
}
|
|
1706
|
+
end
|
|
1707
|
+
|
|
1708
|
+
if high_bounce_pages.any?
|
|
1709
|
+
top_hb = high_bounce_pages.first
|
|
1710
|
+
action_plan << {
|
|
1711
|
+
priority: 'medium',
|
|
1712
|
+
title: "Address High Bounce Rate (#{top_hb[:bounce_rate]}%) on #{top_hb[:path]}",
|
|
1713
|
+
detail: "Page received #{top_hb[:sessions]} sessions with #{top_hb[:duration]} average time. Audit above-the-fold LCP speed and ensure page content directly satisfies searcher intent."
|
|
1714
|
+
}
|
|
1715
|
+
end
|
|
1716
|
+
|
|
1717
|
+
if mobile_deficit
|
|
1718
|
+
action_plan << {
|
|
1719
|
+
priority: 'medium',
|
|
1720
|
+
title: "Audit Mobile Viewport & Responsive CTR",
|
|
1721
|
+
detail: "Mobile CTR (#{mob_dev[:ctr]}%) is significantly trailing Desktop (#{desk_dev[:ctr]}%). Test page on real devices for mobile layout shift, font readability, or button touch targets."
|
|
1722
|
+
}
|
|
1723
|
+
end
|
|
1724
|
+
|
|
1725
|
+
if snippets_data.empty?
|
|
1726
|
+
action_plan << {
|
|
1727
|
+
priority: 'low',
|
|
1728
|
+
title: "Implement Structured Data (Schema.org)",
|
|
1729
|
+
detail: "No rich snippet enhancements active. Add Product, FAQ, or Organization JSON-LD markup to earn rich SERP real estate."
|
|
1730
|
+
}
|
|
1731
|
+
else
|
|
1732
|
+
snippet_labels = snippets_data.map { |s| Base.format_appearance(s[:type]) }.join(', ')
|
|
1733
|
+
action_plan << {
|
|
1734
|
+
priority: 'low',
|
|
1735
|
+
title: "Maintain Rich Snippets",
|
|
1736
|
+
detail: "#{snippet_labels} currently active. Monitor Google Rich Results Test to maintain snippet coverage."
|
|
1737
|
+
}
|
|
1738
|
+
end
|
|
1739
|
+
|
|
1740
|
+
audit_payload = {
|
|
1741
|
+
domain: hostname,
|
|
1742
|
+
siteUrl: site_url,
|
|
1743
|
+
healthScore: score,
|
|
1744
|
+
grade: grade,
|
|
1745
|
+
gradeLabel: grade_label,
|
|
1746
|
+
days: days,
|
|
1747
|
+
totals: {
|
|
1748
|
+
clicks: total_clicks,
|
|
1749
|
+
impressions: total_imp,
|
|
1750
|
+
ctr: avg_ctr,
|
|
1751
|
+
position: avg_pos
|
|
1752
|
+
},
|
|
1753
|
+
technical: {
|
|
1754
|
+
homepageVerdict: verdict,
|
|
1755
|
+
coverageState: coverage,
|
|
1756
|
+
googleCanonical: google_canonical,
|
|
1757
|
+
userCanonical: user_canonical,
|
|
1758
|
+
canonicalMatch: canonical_match,
|
|
1759
|
+
crawledAs: crawled_as,
|
|
1760
|
+
lastCrawl: last_crawl,
|
|
1761
|
+
sitemapsCount: sitemaps.size,
|
|
1762
|
+
sitemapsErrors: sm_errors,
|
|
1763
|
+
sitemapsWarnings: sm_warnings,
|
|
1764
|
+
sitemaps: sitemaps
|
|
1765
|
+
},
|
|
1766
|
+
opportunities: opportunities,
|
|
1767
|
+
underperformers: underperformers,
|
|
1768
|
+
cannibalization: cannibalized,
|
|
1769
|
+
devices: devices_data,
|
|
1770
|
+
mobileDeficit: mobile_deficit,
|
|
1771
|
+
snippets: snippets_data,
|
|
1772
|
+
ga4: ga4_data,
|
|
1773
|
+
actionPlan: action_plan
|
|
1774
|
+
}
|
|
1775
|
+
|
|
1776
|
+
if options[:json]
|
|
1777
|
+
puts JSON.pretty_generate(audit_payload)
|
|
1778
|
+
else
|
|
1779
|
+
print_comprehensive_audit(audit_payload)
|
|
1780
|
+
|
|
1781
|
+
if options[:csv]
|
|
1782
|
+
csv_rows = action_plan.map { |a| [a[:priority], a[:title], a[:detail]] }
|
|
1783
|
+
Base.write_csv(options[:csv], %w[Priority Title Recommendation], csv_rows)
|
|
1784
|
+
puts Color.c("📁 Exported prioritized action plan to #{options[:csv]}\n", Color::CYAN)
|
|
1785
|
+
end
|
|
1786
|
+
end
|
|
1787
|
+
end
|
|
1788
|
+
|
|
1789
|
+
def print_comprehensive_audit(d)
|
|
1790
|
+
score = d[:healthScore]
|
|
1791
|
+
score_colored = if score >= 80
|
|
1792
|
+
Color.c("#{score} / 100", Color::GREEN, Color::BOLD)
|
|
1793
|
+
elsif score >= 65
|
|
1794
|
+
Color.c("#{score} / 100", Color::YELLOW, Color::BOLD)
|
|
1795
|
+
else
|
|
1796
|
+
Color.c("#{score} / 100", Color::RED, Color::BOLD)
|
|
1797
|
+
end
|
|
1798
|
+
|
|
1799
|
+
puts "#{Color::BOLD}══════════════════════════════════════════════════════════════════════════════#{Color::RESET}"
|
|
1800
|
+
puts "#{Color::BOLD}🎯 OVERALL SEO HEALTH SCORE: #{score_colored} #{Color::BOLD}[GRADE #{d[:grade]} - #{d[:gradeLabel]}]#{Color::RESET}"
|
|
1801
|
+
puts "#{Color::BOLD}══════════════════════════════════════════════════════════════════════════════#{Color::RESET}"
|
|
1802
|
+
|
|
1803
|
+
idx_status = d[:technical][:homepageVerdict] == 'PASS' ? Color.c('✅ PASS (100% Eligible)', Color::GREEN) : Color.c("⚠️ #{d[:technical][:homepageVerdict]}", Color::YELLOW)
|
|
1804
|
+
ctr_status = d[:totals][:ctr] >= 1.5 ? Color.c("✅ #{d[:totals][:ctr]}% CTR (Healthy)", Color::GREEN) : Color.c("⚠️ #{d[:totals][:ctr]}% CTR (Below Benchmark)", Color::YELLOW)
|
|
1805
|
+
cann_status = d[:cannibalization].empty? ? Color.c('✅ 0 Conflicts', Color::GREEN) : Color.c("🔴 #{d[:cannibalization].size} Query Conflicts Detected", Color::RED, Color::BOLD)
|
|
1806
|
+
|
|
1807
|
+
bounce_status = if d[:ga4] && d[:ga4][:linked] && d[:ga4][:sessions] > 0
|
|
1808
|
+
b = d[:ga4][:bounce_rate]
|
|
1809
|
+
b <= 65.0 ? Color.c("✅ #{b}% Bounce Rate (Strong)", Color::GREEN) : (b <= 80.0 ? Color.c("🟡 #{b}% Bounce Rate (Moderate)", Color::YELLOW) : Color.c("🔴 #{b}% Bounce Rate (High)", Color::RED, Color::BOLD))
|
|
1810
|
+
else
|
|
1811
|
+
Color.c('ℹ️ GA4 Not Linked', Color::GRAY)
|
|
1812
|
+
end
|
|
1813
|
+
|
|
1814
|
+
puts " • Technical Indexing: #{idx_status}"
|
|
1815
|
+
puts " • SERP Click Efficiency: #{ctr_status}"
|
|
1816
|
+
puts " • SERP Cannibalization: #{cann_status}"
|
|
1817
|
+
puts " • On-Site Retention: #{bounce_status}"
|
|
1818
|
+
puts "#{Color::BOLD}══════════════════════════════════════════════════════════════════════════════#{Color::RESET}\n"
|
|
1819
|
+
|
|
1820
|
+
puts "#{Color::BOLD}📊 30-DAY PERFORMANCE OVERVIEW#{Color::RESET}"
|
|
1821
|
+
puts " • Organic Search Clicks: #{Color.c(Base.format_number(d[:totals][:clicks]), Color::GREEN, Color::BOLD)}"
|
|
1822
|
+
puts " • Total SERP Impressions: #{Color.c(Base.format_number(d[:totals][:impressions]), Color::MAGENTA, Color::BOLD)}"
|
|
1823
|
+
puts " • Average Search Position: #{Color.c(d[:totals][:position].to_s, Color::YELLOW, Color::BOLD)}"
|
|
1824
|
+
puts " • Average SERP CTR: #{Color.c("#{d[:totals][:ctr]}%", Color::CYAN, Color::BOLD)}"
|
|
1825
|
+
if d[:ga4] && d[:ga4][:linked] && d[:ga4][:sessions] > 0
|
|
1826
|
+
puts " • GA4 Total Sessions: #{Color.c(Base.format_number(d[:ga4][:sessions]), Color::MAGENTA, Color::BOLD)} [Property #{d[:ga4][:property_id]}]"
|
|
1827
|
+
puts " • GA4 Average Bounce Rate: #{Color.c("#{d[:ga4][:bounce_rate]}%", Color::YELLOW, Color::BOLD)}"
|
|
1828
|
+
puts " • GA4 Avg Session Duration: #{Color.c(d[:ga4][:avg_duration].to_s, Color::CYAN, Color::BOLD)}"
|
|
1829
|
+
end
|
|
1830
|
+
puts
|
|
1831
|
+
|
|
1832
|
+
tech = d[:technical]
|
|
1833
|
+
puts "#{Color::BOLD}🛠️ 1. TECHNICAL CRAWL & INDEXATION HEALTH#{Color::RESET}"
|
|
1834
|
+
v_color = tech[:homepageVerdict] == 'PASS' ? Color.c('✅ PASS (Indexed & Eligible for SERPs)', Color::GREEN, Color::BOLD) : Color.c("⚠️ #{tech[:homepageVerdict]}", Color::RED, Color::BOLD)
|
|
1835
|
+
puts " • Homepage Googlebot Verdict: #{v_color}"
|
|
1836
|
+
puts " • Google Index Coverage State: #{tech[:coverageState]}"
|
|
1837
|
+
c_match = tech[:canonicalMatch] ? Color.c("Matches declared canonical", Color::GREEN) : Color.c("⚠️ Canonical Mismatch Detected", Color::YELLOW)
|
|
1838
|
+
puts " • Google-Selected Canonical: #{tech[:googleCanonical]} (#{c_match})"
|
|
1839
|
+
puts " • Crawl User-Agent: Googlebot #{tech[:crawledAs]} (Mobile-First Indexing)"
|
|
1840
|
+
puts " • Last Googlebot Crawl: #{tech[:lastCrawl]}"
|
|
1841
|
+
sm_text = tech[:sitemapsCount] > 0 ? Color.c("#{tech[:sitemapsCount]} registered in Search Console", Color::GREEN) : Color.c("⚠️ No sitemaps found", Color::YELLOW)
|
|
1842
|
+
puts " • XML Sitemaps: #{sm_text}"
|
|
1843
|
+
if tech[:sitemapsErrors] > 0
|
|
1844
|
+
err_sms = tech[:sitemaps].select { |s| (s['errors'] || 0).to_i > 0 }
|
|
1845
|
+
err_sms.each do |sm|
|
|
1846
|
+
last_dl = sm['lastDownloaded'] ? sm['lastDownloaded'].split('T').first : 'Unknown'
|
|
1847
|
+
puts " #{Color.c("⚠️ Notice: #{sm['path']} has #{sm['errors']} error (Last downloaded: #{last_dl}).", Color::YELLOW)}"
|
|
1848
|
+
end
|
|
1849
|
+
end
|
|
1850
|
+
puts
|
|
1851
|
+
|
|
1852
|
+
puts "#{Color::BOLD}🎯 2. HIGH-IMPACT STRIKING-DISTANCE KEYWORDS (Page 2 ➔ Page 1 Quick Wins)#{Color::RESET}"
|
|
1853
|
+
if d[:opportunities].empty?
|
|
1854
|
+
puts Color.c(" ℹ️ No striking-distance queries found (pos 7–20 with >= 5 impressions).", Color::GRAY)
|
|
1855
|
+
else
|
|
1856
|
+
puts "Pos | Impressions | Clicks | Est. Unlock | Target Query & Landing Page"
|
|
1857
|
+
puts "--------------------------------------------------------------------------------"
|
|
1858
|
+
d[:opportunities].each do |o|
|
|
1859
|
+
pos_str = o[:position].to_s.ljust(5)
|
|
1860
|
+
imp_str = Base.format_number(o[:impressions]).ljust(11)
|
|
1861
|
+
c_str = Base.format_number(o[:clicks]).ljust(6)
|
|
1862
|
+
gain_str = "+#{Base.format_number(o[:potential_gain])}/mo".ljust(12)
|
|
1863
|
+
page_short = o[:page].sub(%r{^https?://[^/]+}, '')
|
|
1864
|
+
page_short = '/' if page_short.empty?
|
|
1865
|
+
puts "#{Color.c(pos_str, Color::YELLOW)} | #{imp_str} | #{c_str} | #{Color.c(gain_str, Color::GREEN, Color::BOLD)} | #{Color.c(o[:query], Color::BOLD)} ➔ #{Color.c(page_short, Color::CYAN)}"
|
|
1866
|
+
end
|
|
1867
|
+
puts "💡 #{Color::BOLD}Action:#{Color::RESET} Add an H2 subheading and dedicated answering copy on these pages to push into the Top 3."
|
|
1868
|
+
end
|
|
1869
|
+
puts
|
|
1870
|
+
|
|
1871
|
+
puts "#{Color::BOLD}⚡ 3. CTR CLICK-BLEED & TITLE HOOK DEFICITS (Top 10 Rankings)#{Color::RESET}"
|
|
1872
|
+
if d[:underperformers].empty?
|
|
1873
|
+
puts Color.c(" ✅ No critical Top 10 CTR click-bleed detected. Existing Page 1 rankings are converting at or above expected benchmarks.", Color::GREEN)
|
|
1874
|
+
else
|
|
1875
|
+
puts "Pos | Impressions | Actual CTR | Expected | Lost Clicks | Target Query & Landing Page"
|
|
1876
|
+
puts "----------------------------------------------------------------------------------------"
|
|
1877
|
+
d[:underperformers].each do |u|
|
|
1878
|
+
pos_str = u[:position].to_s.ljust(5)
|
|
1879
|
+
imp_str = Base.format_number(u[:impressions]).ljust(11)
|
|
1880
|
+
act_str = "#{u[:actual_ctr]}%".ljust(10)
|
|
1881
|
+
exp_str = "~#{u[:expected_ctr]}%".ljust(8)
|
|
1882
|
+
lost_str = "~#{Base.format_number(u[:lost_clicks])}/mo".ljust(12)
|
|
1883
|
+
page_short = u[:page].sub(%r{^https?://[^/]+}, '')
|
|
1884
|
+
page_short = '/' if page_short.empty?
|
|
1885
|
+
puts "#{Color.c(pos_str, Color::GREEN)} | #{imp_str} | #{Color.c(act_str, Color::RED)} | #{exp_str} | #{Color.c(lost_str, Color::YELLOW, Color::BOLD)} | #{Color.c(u[:query], Color::BOLD)} ➔ #{Color.c(page_short, Color::CYAN)}"
|
|
1886
|
+
end
|
|
1887
|
+
puts "💡 #{Color::BOLD}Action:#{Color::RESET} Rewrite the <title> tag and meta description with numbers, brackets, or clear benefit hooks to recover lost clicks immediately."
|
|
1888
|
+
end
|
|
1889
|
+
puts
|
|
1890
|
+
|
|
1891
|
+
puts "#{Color::BOLD}🔀 4. KEYWORD CANNIBALIZATION (Multiple URLs Splitting SERP Equity)#{Color::RESET}"
|
|
1892
|
+
if d[:cannibalization].empty?
|
|
1893
|
+
puts Color.c(" ✅ Clean URL architecture! No internal keyword cannibalization detected.", Color::GREEN)
|
|
1894
|
+
else
|
|
1895
|
+
d[:cannibalization].each do |c|
|
|
1896
|
+
puts " #{Color.c('⚠️ Conflict:', Color::RED, Color::BOLD)} Query #{Color.c("\"#{c[:query]}\"", Color::BOLD)} (#{Base.format_number(c[:total_impressions])} total imp) split across #{c[:pages].size} competing URLs:"
|
|
1897
|
+
c[:pages].each do |p|
|
|
1898
|
+
p_short = p[:page].sub(%r{^https?://[^/]+}, '')
|
|
1899
|
+
p_short = '/' if p_short.empty?
|
|
1900
|
+
puts " ↳ Pos #{p[:position].to_s.ljust(4)} (#{Base.format_number(p[:impressions]).rjust(4)} imp, #{p[:clicks]} clicks) ➔ #{Color.c(p_short, Color::CYAN)}"
|
|
1901
|
+
end
|
|
1902
|
+
end
|
|
1903
|
+
puts " 💡 #{Color::BOLD}Fix:#{Color::RESET} Consolidate canonical signals or point internal links to 1 primary URL to concentrate ranking authority."
|
|
1904
|
+
end
|
|
1905
|
+
puts
|
|
1906
|
+
|
|
1907
|
+
puts "#{Color::BOLD}📱 5. DEVICE EXPERIENCE & SERP PARITY#{Color::RESET}"
|
|
1908
|
+
puts "Device | Clicks | Impressions | CTR | Position | Clicks Share"
|
|
1909
|
+
puts "-------------------------------------------------------------------------"
|
|
1910
|
+
d[:devices].each do |r|
|
|
1911
|
+
d_name = Base.format_device(r[:device]).ljust(12)
|
|
1912
|
+
c_str = Base.format_number(r[:clicks]).ljust(8)
|
|
1913
|
+
i_str = Base.format_number(r[:impressions]).ljust(11)
|
|
1914
|
+
ctr_str = "#{r[:ctr]}%".ljust(6)
|
|
1915
|
+
pos_str = r[:position].to_s.ljust(8)
|
|
1916
|
+
share_str = r[:share].to_s
|
|
1917
|
+
puts "#{d_name} | #{c_str} | #{i_str} | #{ctr_str} | #{pos_str} | #{share_str}"
|
|
1918
|
+
end
|
|
1919
|
+
if d[:mobileDeficit]
|
|
1920
|
+
puts " #{Color.c('⚠️ Mobile CTR Alert: Mobile CTR is significantly lower than Desktop. Audit mobile LCP speed and small-screen snippet readability.', Color::YELLOW)}"
|
|
1921
|
+
end
|
|
1922
|
+
puts
|
|
1923
|
+
|
|
1924
|
+
if d[:ga4] && d[:ga4][:linked]
|
|
1925
|
+
puts "#{Color::BOLD}📈 6. ON-SITE RETENTION & HIGH-BOUNCE ALERTS (via GA4)#{Color::RESET}"
|
|
1926
|
+
puts " • 30-Day GA4 Sessions: #{Base.format_number(d[:ga4][:sessions])}"
|
|
1927
|
+
puts " • Overall Bounce Rate: #{d[:ga4][:bounce_rate]}%"
|
|
1928
|
+
puts " • Average Session Duration: #{d[:ga4][:avg_duration]}"
|
|
1929
|
+
if d[:ga4][:high_bounce_pages].empty?
|
|
1930
|
+
puts Color.c(" ✅ No critical high-bounce landing pages (>80% with >= 10 sessions) detected.", Color::GREEN)
|
|
1931
|
+
else
|
|
1932
|
+
puts " • Highest-Bounce Landing Pages (>= 10 sessions, >= 80% bounce):"
|
|
1933
|
+
d[:ga4][:high_bounce_pages].each_with_index do |hb, idx|
|
|
1934
|
+
puts " #{idx + 1}. #{Color.c(hb[:path].ljust(30), Color::CYAN)} ➔ #{Color.c("#{hb[:sessions]} sess", Color::BOLD)} | #{Color.c("#{hb[:bounce_rate]}% bounce", Color::RED)} | #{hb[:duration]} avg"
|
|
1935
|
+
end
|
|
1936
|
+
end
|
|
1937
|
+
puts
|
|
1938
|
+
end
|
|
1939
|
+
|
|
1940
|
+
puts "#{Color::BOLD}📋 7. PRIORITIZED ACTION PLAN (THE REAL VALUE ROADMAP)#{Color::RESET}"
|
|
1941
|
+
high_p = d[:actionPlan].select { |a| a[:priority] == 'high' }
|
|
1942
|
+
med_p = d[:actionPlan].select { |a| a[:priority] == 'medium' }
|
|
1943
|
+
low_p = d[:actionPlan].select { |a| a[:priority] == 'low' }
|
|
1944
|
+
|
|
1945
|
+
idx = 1
|
|
1946
|
+
if high_p.any?
|
|
1947
|
+
puts " #{Color.c('[🔴 HIGH PRIORITY - IMMEDIATE VALUE WINS]', Color::RED, Color::BOLD)}"
|
|
1948
|
+
high_p.each do |item|
|
|
1949
|
+
puts " #{idx}. #{Color::BOLD}#{item[:title]}#{Color::RESET}"
|
|
1950
|
+
puts " #{item[:detail]}"
|
|
1951
|
+
idx += 1
|
|
1952
|
+
end
|
|
1953
|
+
end
|
|
1954
|
+
|
|
1955
|
+
if med_p.any?
|
|
1956
|
+
puts " #{Color.c('[🟡 MEDIUM PRIORITY - HIGH-LEVERAGE GROWTH]', Color::YELLOW, Color::BOLD)}"
|
|
1957
|
+
med_p.each do |item|
|
|
1958
|
+
puts " #{idx}. #{Color::BOLD}#{item[:title]}#{Color::RESET}"
|
|
1959
|
+
puts " #{item[:detail]}"
|
|
1960
|
+
idx += 1
|
|
1961
|
+
end
|
|
1962
|
+
end
|
|
1963
|
+
|
|
1964
|
+
if low_p.any?
|
|
1965
|
+
puts " #{Color.c('[🟢 QUICK WIN / TECHNICAL MAINTENANCE]', Color::GREEN, Color::BOLD)}"
|
|
1966
|
+
low_p.each do |item|
|
|
1967
|
+
puts " #{idx}. #{Color::BOLD}#{item[:title]}#{Color::RESET}"
|
|
1968
|
+
puts " #{item[:detail]}"
|
|
1969
|
+
idx += 1
|
|
1970
|
+
end
|
|
1971
|
+
end
|
|
1972
|
+
puts
|
|
1973
|
+
end
|
|
1974
|
+
end
|
|
1975
|
+
end
|
|
1976
|
+
end
|