gsc-cli 2.0.2 ā 2.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/AUTH.md +205 -0
- data/FUNDING.md +120 -0
- data/README.md +463 -299
- data/bin/gsc +29158 -4921
- data/dist/gsc +29158 -4921
- data/lib/gsc/aio_hunter.rb +343 -0
- data/lib/gsc/answer_synthesizer.rb +157 -0
- data/lib/gsc/api.rb +53 -1
- data/lib/gsc/auth.rb +26 -0
- data/lib/gsc/backlinks_manager.rb +96 -0
- data/lib/gsc/brand_segmenter.rb +140 -0
- data/lib/gsc/cache_manager.rb +806 -0
- data/lib/gsc/cannibalization_analyzer.rb +141 -0
- data/lib/gsc/canonical_chains.rb +367 -0
- data/lib/gsc/citation_simulator.rb +339 -0
- data/lib/gsc/cli/aio_hunter.rb +154 -0
- data/lib/gsc/cli/analytics.rb +788 -0
- data/lib/gsc/cli/audit.rb +1976 -0
- data/lib/gsc/cli/base.rb +384 -0
- data/lib/gsc/cli/cache.rb +266 -0
- data/lib/gsc/cli/canonical.rb +223 -0
- data/lib/gsc/cli/citation_simulator.rb +152 -0
- data/lib/gsc/cli/dashboard.rb +354 -0
- data/lib/gsc/cli/doctor.rb +129 -0
- data/lib/gsc/cli/eeat.rb +125 -0
- data/lib/gsc/cli/ga4.rb +852 -0
- data/lib/gsc/cli/growth.rb +650 -0
- data/lib/gsc/cli/hreflang.rb +164 -0
- data/lib/gsc/cli/image_seo.rb +162 -0
- data/lib/gsc/cli/indexing.rb +458 -0
- data/lib/gsc/cli/intent_shift.rb +125 -0
- data/lib/gsc/cli/keyword_value.rb +134 -0
- data/lib/gsc/cli/keywords.rb +795 -0
- data/lib/gsc/cli/landing_roi.rb +308 -0
- data/lib/gsc/cli/low_ctr.rb +213 -0
- data/lib/gsc/cli/mobile_parity.rb +150 -0
- data/lib/gsc/cli/report.rb +100 -0
- data/lib/gsc/cli/rich_results.rb +172 -0
- data/lib/gsc/cli/schema_generate.rb +149 -0
- data/lib/gsc/cli/seasonal.rb +232 -0
- data/lib/gsc/cli/security.rb +153 -0
- data/lib/gsc/cli/setup.rb +1291 -0
- data/lib/gsc/cli/sitemap_tree.rb +143 -0
- data/lib/gsc/cli/skill_pack.rb +62 -0
- data/lib/gsc/cli/soft_404.rb +199 -0
- data/lib/gsc/cli/sparkline.rb +227 -0
- data/lib/gsc/cli/watchdog.rb +150 -0
- data/lib/gsc/cli/zombie_purger.rb +208 -0
- data/lib/gsc/cli.rb +706 -5111
- data/lib/gsc/cli_advanced.rb +1513 -0
- data/lib/gsc/client.rb +17 -2
- data/lib/gsc/color.rb +16 -1
- data/lib/gsc/command_registry.rb +47 -9
- data/lib/gsc/config.rb +11 -2
- data/lib/gsc/content_gap.rb +112 -0
- data/lib/gsc/ctr_curve.rb +115 -0
- data/lib/gsc/decay_predictor.rb +322 -0
- data/lib/gsc/doctor.rb +434 -0
- data/lib/gsc/eeat_auditor.rb +428 -0
- data/lib/gsc/entity_auditor.rb +229 -0
- data/lib/gsc/firewall_scanner.rb +733 -0
- data/lib/gsc/geo_auditor.rb +368 -0
- data/lib/gsc/google_suggest.rb +109 -0
- data/lib/gsc/google_trends.rb +8 -1
- data/lib/gsc/heading_validator.rb +283 -0
- data/lib/gsc/hreflang_validator.rb +412 -0
- data/lib/gsc/image_seo.rb +286 -0
- data/lib/gsc/indexing_queue.rb +179 -0
- data/lib/gsc/indexnow.rb +93 -0
- data/lib/gsc/intent_shift.rb +188 -0
- data/lib/gsc/internal_links.rb +249 -0
- data/lib/gsc/keyword_value.rb +191 -0
- data/lib/gsc/landing_roi.rb +195 -0
- data/lib/gsc/llms_generator.rb +425 -0
- data/lib/gsc/low_ctr_rewriter.rb +408 -0
- data/lib/gsc/mobile_parity.rb +222 -0
- data/lib/gsc/network_tracer.rb +93 -0
- data/lib/gsc/open_page_rank.rb +72 -0
- data/lib/gsc/page_analyzer.rb +47 -7
- data/lib/gsc/page_comparator.rb +108 -0
- data/lib/gsc/page_speed.rb +110 -0
- data/lib/gsc/prompts.rb +38 -29
- data/lib/gsc/questions_harvester.rb +178 -0
- data/lib/gsc/report_generator.rb +461 -0
- data/lib/gsc/rich_results.rb +388 -0
- data/lib/gsc/robots_checker.rb +114 -0
- data/lib/gsc/schema_generator.rb +788 -0
- data/lib/gsc/schema_validator.rb +120 -0
- data/lib/gsc/seasonal_predictor.rb +381 -0
- data/lib/gsc/security_scanner.rb +496 -0
- data/lib/gsc/serp_feature_detector.rb +359 -0
- data/lib/gsc/serp_preview.rb +152 -0
- data/lib/gsc/site_crawler.rb +113 -21
- data/lib/gsc/sitemap_loader.rb +15 -4
- data/lib/gsc/sitemap_tree.rb +301 -0
- data/lib/gsc/skill_pack.rb +195 -0
- data/lib/gsc/soft_404_analyzer.rb +385 -0
- data/lib/gsc/sparkline.rb +171 -0
- data/lib/gsc/speed_correlator.rb +416 -0
- data/lib/gsc/striking_playbook.rb +190 -0
- data/lib/gsc/title_optimizer.rb +420 -0
- data/lib/gsc/vault.rb +260 -0
- data/lib/gsc/version.rb +1 -1
- data/lib/gsc/watchdog.rb +235 -0
- data/lib/gsc/zombie_purger.rb +366 -0
- data/lib/gsc.rb +144 -0
- metadata +91 -2
|
@@ -0,0 +1,1513 @@
|
|
|
1
|
+
# encoding: utf-8
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require 'json'
|
|
5
|
+
require 'uri'
|
|
6
|
+
|
|
7
|
+
module GSC
|
|
8
|
+
class CLI
|
|
9
|
+
# 1. Google Suggest
|
|
10
|
+
def self.handle_suggest_command(target, options)
|
|
11
|
+
query = target.to_s.strip
|
|
12
|
+
if query.empty?
|
|
13
|
+
puts Color.c("ā Error: Query required. Example: gsc suggest \"seo audit\"", Color::RED)
|
|
14
|
+
return
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
alphabet = options[:alphabet] || false
|
|
18
|
+
suggest = GSC::GoogleSuggest.new(query, options)
|
|
19
|
+
results = suggest.fetch(alphabet: alphabet, questions: false)
|
|
20
|
+
|
|
21
|
+
if options[:json]
|
|
22
|
+
puts JSON.pretty_generate(results)
|
|
23
|
+
return
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
puts BANNER unless options[:in_dashboard]
|
|
27
|
+
puts "š” #{Color::BOLD}GOOGLE SEARCH SUGGESTIONS:#{Color::RESET} #{Color.c(query, Color::CYAN)}"
|
|
28
|
+
puts "ā" * 70
|
|
29
|
+
|
|
30
|
+
if alphabet
|
|
31
|
+
results.each do |key, list|
|
|
32
|
+
next if list.empty?
|
|
33
|
+
prefix = (key == 'root') ? "Root" : "+ #{key.upcase}"
|
|
34
|
+
puts "\n#{Color.c(prefix, Color::BOLD, Color::YELLOW)}:"
|
|
35
|
+
list.each do |item|
|
|
36
|
+
puts " ⢠#{item[:term]}"
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
else
|
|
40
|
+
if results.empty?
|
|
41
|
+
puts " (No search suggestions returned)"
|
|
42
|
+
else
|
|
43
|
+
results.each_with_index do |item, idx|
|
|
44
|
+
puts " #{Color.c((idx + 1).to_s.rjust(2), Color::DIM)}. #{Color.c(item[:term], Color::BOLD)}"
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
puts ""
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# 2. Questions / PAA
|
|
52
|
+
def self.handle_questions_command(target, options)
|
|
53
|
+
query = target.to_s.strip
|
|
54
|
+
if query.empty?
|
|
55
|
+
puts Color.c("ā Error: Query required. Example: gsc questions \"seo audit\"", Color::RED)
|
|
56
|
+
return
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
suggest = GSC::GoogleSuggest.new(query, options)
|
|
60
|
+
results = suggest.fetch(questions: true)
|
|
61
|
+
|
|
62
|
+
if options[:json]
|
|
63
|
+
puts JSON.pretty_generate(results)
|
|
64
|
+
return
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
puts BANNER unless options[:in_dashboard]
|
|
68
|
+
puts "ā #{Color::BOLD}SEARCH INTENT QUESTIONS & FAQs:#{Color::RESET} #{Color.c(query, Color::CYAN)}"
|
|
69
|
+
puts "ā" * 70
|
|
70
|
+
|
|
71
|
+
total_found = 0
|
|
72
|
+
results.each do |prefix, list|
|
|
73
|
+
next if list.empty?
|
|
74
|
+
puts "\n#{Color.c(prefix.upcase, Color::BOLD, Color::CYAN)}:"
|
|
75
|
+
list.each do |item|
|
|
76
|
+
total_found += 1
|
|
77
|
+
puts " ⢠#{item[:term]}"
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
if total_found.zero?
|
|
82
|
+
puts " (No question suggestions found for \"#{query}\")"
|
|
83
|
+
end
|
|
84
|
+
puts ""
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# 3. OpenPageRank Domain Authority
|
|
88
|
+
def self.handle_authority_command(target, extra, options)
|
|
89
|
+
domains = [target, extra].flatten.compact.reject { |d| d.to_s.strip.empty? }
|
|
90
|
+
domains << Config.default_domain if domains.empty?
|
|
91
|
+
domains = domains.compact
|
|
92
|
+
|
|
93
|
+
if domains.empty?
|
|
94
|
+
puts Color.c("ā Error: Domain required. Example: gsc authority example.com", Color::RED)
|
|
95
|
+
return
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
opr = GSC::OpenPageRank.new
|
|
99
|
+
unless opr.configured?
|
|
100
|
+
puts Color.c("ā ļø OpenPageRank API key not configured.", Color::YELLOW, Color::BOLD)
|
|
101
|
+
puts " Get a 100% free key (300,000 free queries/month) at: #{Color.c('https://openpagerank.com', Color::CYAN)}"
|
|
102
|
+
puts " Then run: #{Color.c('gsc config set opr_api_key <YOUR_KEY>', Color::GREEN)}"
|
|
103
|
+
puts " Or pass: #{Color.c('OPENPAGERANK_API_KEY=<KEY> gsc authority ...', Color::DIM)}"
|
|
104
|
+
return
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
data = opr.check_domains(domains)
|
|
108
|
+
|
|
109
|
+
if options[:json]
|
|
110
|
+
puts JSON.pretty_generate(data)
|
|
111
|
+
return
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
puts BANNER unless options[:in_dashboard]
|
|
115
|
+
puts "š #{Color::BOLD}OPEN PAGERANK & DOMAIN AUTHORITY (Common Crawl Graph):#{Color::RESET}"
|
|
116
|
+
puts "ā" * 75
|
|
117
|
+
|
|
118
|
+
if data[:status] == 'error'
|
|
119
|
+
puts Color.c("ā Error: #{data[:message]}", Color::RED)
|
|
120
|
+
return
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
puts "#{'DOMAIN'.ljust(35)} #{'PAGERANK'.ljust(12)} #{'GLOBAL RANK'.ljust(18)} #{'STATUS'}"
|
|
124
|
+
puts "ā" * 75
|
|
125
|
+
|
|
126
|
+
(data[:records] || []).each do |rec|
|
|
127
|
+
d_name = rec[:domain].to_s.ljust(35)
|
|
128
|
+
pr = sprintf("%.2f / 10", rec[:page_rank_decimal]).ljust(12)
|
|
129
|
+
gr = rec[:rank] ? "##{rec[:rank].to_s.reverse.gsub(/(\d{3})(?=\d)/, '\\1,').reverse}".ljust(18) : "N/A".ljust(18)
|
|
130
|
+
st = (rec[:status_code] == 200) ? Color.c("200 OK", Color::GREEN) : Color.c(rec[:status_code].to_s, Color::YELLOW)
|
|
131
|
+
|
|
132
|
+
puts "#{Color.c(d_name, Color::BOLD)} #{Color.c(pr, Color::CYAN)} #{Color.c(gr, Color::YELLOW)} #{st}"
|
|
133
|
+
end
|
|
134
|
+
puts ""
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# 4. PageSpeed Core Web Vitals
|
|
138
|
+
def self.handle_speed_command(target, options)
|
|
139
|
+
raw = target || (Config.default_domain ? "https://#{Config.default_domain}" : nil)
|
|
140
|
+
if raw.nil? || raw.empty?
|
|
141
|
+
puts Color.c("ā Error: Target URL or domain required. Example: gsc speed https://mybrand.com", Color::RED)
|
|
142
|
+
return
|
|
143
|
+
end
|
|
144
|
+
url = raw.to_s.start_with?('http') ? raw : "https://#{raw}"
|
|
145
|
+
strategy = options[:strategy] || 'mobile'
|
|
146
|
+
|
|
147
|
+
puts BANNER unless options[:json] || options[:in_dashboard]
|
|
148
|
+
puts "ā” Measuring Core Web Vitals via Google PageSpeed Insights (#{strategy.upcase}):" unless options[:json]
|
|
149
|
+
puts " #{Color.c(url, Color::CYAN)}\n" unless options[:json]
|
|
150
|
+
|
|
151
|
+
ps = GSC::PageSpeed.new(url, strategy: strategy)
|
|
152
|
+
data = ps.run
|
|
153
|
+
|
|
154
|
+
if options[:json]
|
|
155
|
+
puts JSON.pretty_generate(data)
|
|
156
|
+
return
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
if data[:error]
|
|
160
|
+
puts Color.c("ā PageSpeed API Error: #{data[:message]}", Color::RED)
|
|
161
|
+
return
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
perf_score = data[:performance_score]
|
|
165
|
+
score_color = perf_score >= 90 ? Color::GREEN : (perf_score >= 50 ? Color::YELLOW : Color::RED)
|
|
166
|
+
|
|
167
|
+
puts "āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā"
|
|
168
|
+
puts "ā Lighthouse Performance Score: #{Color.c(perf_score.to_s.rjust(3) + ' / 100', score_color, Color::BOLD)} ā"
|
|
169
|
+
puts "ā Lighthouse SEO Score : #{Color.c(data[:seo_score].to_s.rjust(3) + ' / 100', Color::GREEN, Color::BOLD)} ā"
|
|
170
|
+
puts "āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā"
|
|
171
|
+
|
|
172
|
+
m = data[:metrics] || {}
|
|
173
|
+
puts "\n#{Color::BOLD}š CORE WEB VITALS (Lab Metrics):#{Color::RESET}"
|
|
174
|
+
puts " ⢠LCP (Largest Contentful Paint) : #{Color.c(m[:lcp] || 'N/A', Color::BOLD)}"
|
|
175
|
+
puts " ⢠FCP (First Contentful Paint) : #{Color.c(m[:fcp] || 'N/A', Color::BOLD)}"
|
|
176
|
+
puts " ⢠CLS (Cumulative Layout Shift) : #{Color.c(m[:cls] || 'N/A', Color::BOLD)}"
|
|
177
|
+
puts " ⢠TBT (Total Blocking Time) : #{Color.c(m[:tbt] || 'N/A', Color::BOLD)}"
|
|
178
|
+
puts " ⢠Speed Index : #{Color.c(m[:speed_index] || 'N/A', Color::BOLD)}"
|
|
179
|
+
|
|
180
|
+
opps = data[:opportunities] || []
|
|
181
|
+
unless opps.empty?
|
|
182
|
+
puts "\n#{Color::BOLD}š” TOP SPEED OPPORTUNITIES:#{Color::RESET}"
|
|
183
|
+
opps.each do |opp|
|
|
184
|
+
puts " ⢠#{opp[:title]}: #{Color.c(opp[:display] || "#{opp[:savings_ms]}ms savings", Color::YELLOW)}"
|
|
185
|
+
end
|
|
186
|
+
end
|
|
187
|
+
puts ""
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
# 4b. Core Web Vitals & GSC Performance Correlator
|
|
191
|
+
def self.handle_speed_correlate_command(target, options)
|
|
192
|
+
domain = options[:domain] || Config.default_domain
|
|
193
|
+
if (domain.nil? || domain.empty?) && (target.nil? || target.empty?)
|
|
194
|
+
puts Color.c("ā Error: Target URL or domain required. Example: gsc speed-correlate https://mybrand.com", Color::RED)
|
|
195
|
+
return
|
|
196
|
+
end
|
|
197
|
+
url = target || "https://#{domain}/"
|
|
198
|
+
url = "https://#{url}" unless url =~ %r{^https?://}
|
|
199
|
+
strategy = options[:strategy] || 'mobile'
|
|
200
|
+
days = (options[:days] || 28).to_i
|
|
201
|
+
|
|
202
|
+
# Attempt to get GSC API client if authenticated
|
|
203
|
+
api = nil
|
|
204
|
+
sa = GSC::Auth.find_service_account(options[:key], domain)
|
|
205
|
+
if sa && sa[:data]
|
|
206
|
+
token = GSC::Auth.fetch_access_token(sa[:data])
|
|
207
|
+
client = GSC::Client.new(token: token)
|
|
208
|
+
api = GSC::API.new(client)
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
correlator = GSC::SpeedCorrelator.new(url, api: api, domain: domain, days: days, strategy: strategy, options: options)
|
|
212
|
+
data = correlator.correlate
|
|
213
|
+
|
|
214
|
+
if options[:json]
|
|
215
|
+
puts JSON.pretty_generate(data)
|
|
216
|
+
return
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
puts BANNER unless options[:in_dashboard]
|
|
220
|
+
puts "ā” #{Color::BOLD}CORE WEB VITALS & SEARCH CONSOLE PERFORMANCE CORRELATOR:#{Color::RESET}"
|
|
221
|
+
puts " Target URL: #{Color.c(url, Color::CYAN, Color::BOLD)} [Strategy: #{strategy.upcase}]"
|
|
222
|
+
puts "ā" * 80
|
|
223
|
+
|
|
224
|
+
cwv = data[:core_web_vitals] || {}
|
|
225
|
+
gsc = data[:gsc_performance] || {}
|
|
226
|
+
diag = data[:correlation_diagnosis] || {}
|
|
227
|
+
proj = data[:projections] || {}
|
|
228
|
+
|
|
229
|
+
perf_score = cwv[:performance_score] || 0
|
|
230
|
+
score_color = perf_score >= 90 ? Color::GREEN : (perf_score >= 50 ? Color::YELLOW : Color::RED)
|
|
231
|
+
|
|
232
|
+
puts "š± #{Color::BOLD}LIGHTHOUSE PERFORMANCE:#{Color::RESET} #{Color.c("#{perf_score}/100", score_color, Color::BOLD)} [Source: #{cwv[:source]}]"
|
|
233
|
+
puts " ⢠LCP (Largest Contentful Paint) : #{Color.c(cwv[:lcp_display].to_s, Color::BOLD)} [Status: #{diag[:lcp_status]}]"
|
|
234
|
+
puts " ⢠CLS (Cumulative Layout Shift) : #{Color.c(cwv[:cls_display].to_s, Color::BOLD)} [Status: #{diag[:cls_status]}]"
|
|
235
|
+
puts " ⢠FCP (First Contentful Paint) : #{Color.c(cwv[:fcp_display].to_s, Color::BOLD)}"
|
|
236
|
+
puts " ⢠TBT (Total Blocking Time) : #{Color.c(cwv[:tbt_display].to_s, Color::BOLD)}"
|
|
237
|
+
|
|
238
|
+
puts "\nš #{Color::BOLD}SEARCH CONSOLE TRACTION (Past #{days} Days):#{Color::RESET}"
|
|
239
|
+
puts " ⢠Impressions : #{Color.c(gsc[:impressions].to_s, Color::CYAN, Color::BOLD)}"
|
|
240
|
+
puts " ⢠Clicks : #{Color.c(gsc[:clicks].to_s, Color::GREEN, Color::BOLD)}"
|
|
241
|
+
puts " ⢠CTR : #{Color.c("#{gsc[:ctr]}%", Color::BOLD)}"
|
|
242
|
+
puts " ⢠Avg Position: #{Color.c(gsc[:position].to_s, Color::BOLD)}"
|
|
243
|
+
|
|
244
|
+
puts "\nš§ #{Color::BOLD}ALGORITHMIC CWV IMPACT DIAGNOSIS:#{Color::RESET}"
|
|
245
|
+
diag_color = diag[:overall_cwv_pass] ? Color::GREEN : Color::YELLOW
|
|
246
|
+
puts " ⢠CWV Overall Verdict : [#{Color.c(diag[:overall_cwv_pass] ? 'PASSED' : 'NEEDS OPTIMIZATION', diag_color, Color::BOLD)}]"
|
|
247
|
+
puts " ⢠Algorithmic Drag : #{Color.c(diag[:algorithmic_status], Color::BOLD)}"
|
|
248
|
+
|
|
249
|
+
puts "\nš #{Color::BOLD}PROJECTED SEARCH IMPRESSION & TRAFFIC SURGE (Upon Reaching 'Good' CWV):#{Color::RESET}"
|
|
250
|
+
puts " ⢠Potential Impression Lift : #{Color.c("+#{proj[:potential_lift_percentage]}%", Color::GREEN, Color::BOLD)} (+#{proj[:incremental_impressions_gain]} impressions)"
|
|
251
|
+
puts " ⢠Projected Impressions Target: #{Color.c(proj[:projected_impressions].to_s, Color::CYAN, Color::BOLD)}"
|
|
252
|
+
puts " ⢠Projected Rank Lift : #{Color.c(proj[:estimated_position_improvement].to_s, Color::GREEN, Color::BOLD)} (Pos #{proj[:current_position]} -> Pos #{proj[:projected_position]})"
|
|
253
|
+
puts " ⢠Projected Monthly Click Gain: #{Color.c("+#{proj[:projected_incremental_monthly_clicks]} clicks/mo", Color::GREEN, Color::BOLD)}"
|
|
254
|
+
|
|
255
|
+
action_plan = data[:action_plan] || []
|
|
256
|
+
if action_plan.any?
|
|
257
|
+
puts "\nš ļø #{Color::BOLD}PRIORITIZED ENGINEERING FIXES FOR SPEED & RANKINGS:#{Color::RESET}"
|
|
258
|
+
action_plan.each_with_index do |act, idx|
|
|
259
|
+
puts " #{idx + 1}. [#{Color.c(act[:priority], Color::BOLD)}] [#{act[:metric]}]: #{act[:recommendation]}"
|
|
260
|
+
end
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
opps = cwv[:opportunities] || []
|
|
264
|
+
if opps.any?
|
|
265
|
+
puts "\nš” #{Color::BOLD}IDENTIFIED BOTTLENECK OPPORTUNITIES:#{Color::RESET}"
|
|
266
|
+
opps.each do |o|
|
|
267
|
+
puts " ⢠#{o[:title]} (#{Color.c(o[:display] || "#{o[:savings_ms]}ms savings", Color::YELLOW)})"
|
|
268
|
+
end
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
puts "\n" + ("ā" * 80) + "\n"
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
# 5. Page Comparison
|
|
275
|
+
def self.handle_compare_command(target, extra, options)
|
|
276
|
+
url1 = target
|
|
277
|
+
url2 = extra
|
|
278
|
+
|
|
279
|
+
if url1.nil? || url2.nil?
|
|
280
|
+
puts Color.c("ā Error: Two URLs required. Example: gsc compare https://site.com/p1 https://competitor.com/p2", Color::RED)
|
|
281
|
+
return
|
|
282
|
+
end
|
|
283
|
+
|
|
284
|
+
comp = GSC::PageComparator.new(url1, url2)
|
|
285
|
+
data = comp.compare
|
|
286
|
+
|
|
287
|
+
if options[:json]
|
|
288
|
+
puts JSON.pretty_generate(data)
|
|
289
|
+
return
|
|
290
|
+
end
|
|
291
|
+
|
|
292
|
+
puts BANNER unless options[:in_dashboard]
|
|
293
|
+
puts "š„ #{Color::BOLD}HEAD-TO-HEAD SEO ON-PAGE COMPARISON:#{Color::RESET}"
|
|
294
|
+
puts " Page 1 (Target): #{Color.c(url1, Color::CYAN)}"
|
|
295
|
+
puts " Page 2 (Competitor): #{Color.c(url2, Color::YELLOW)}"
|
|
296
|
+
puts "ā" * 80
|
|
297
|
+
|
|
298
|
+
c = data[:comparison] || {}
|
|
299
|
+
|
|
300
|
+
# Meta Titles
|
|
301
|
+
t1 = c.dig(:meta, :title, :page1) || {}
|
|
302
|
+
t2 = c.dig(:meta, :title, :page2) || {}
|
|
303
|
+
puts "\n#{Color::BOLD}š TITLE TAG:#{Color::RESET}"
|
|
304
|
+
puts " P1: #{t1[:text]} (#{t1[:length]} chars) [#{t1[:optimal] ? Color.c('Optimal', Color::GREEN) : Color.c('Review', Color::YELLOW)}]"
|
|
305
|
+
puts " P2: #{t2[:text]} (#{t2[:length]} chars) [#{t2[:optimal] ? Color.c('Optimal', Color::GREEN) : Color.c('Review', Color::YELLOW)}]"
|
|
306
|
+
|
|
307
|
+
# Headings
|
|
308
|
+
h = c[:headings] || {}
|
|
309
|
+
puts "\n#{Color::BOLD}š·ļø HEADINGS H1 / H2:#{Color::RESET}"
|
|
310
|
+
puts " P1: #{h.dig(:h1_count, :page1)} H1s | #{h.dig(:h2_count, :page1)} H2s"
|
|
311
|
+
puts " P2: #{h.dig(:h1_count, :page2)} H1s | #{h.dig(:h2_count, :page2)} H2s"
|
|
312
|
+
|
|
313
|
+
# Images
|
|
314
|
+
img = c[:images] || {}
|
|
315
|
+
puts "\n#{Color::BOLD}š¼ļø IMAGES & ACCESSIBILITY:#{Color::RESET}"
|
|
316
|
+
puts " P1: #{img.dig(:total_images, :page1)} images (#{img.dig(:missing_alt, :page1)} missing alt)"
|
|
317
|
+
puts " P2: #{img.dig(:total_images, :page2)} images (#{img.dig(:missing_alt, :page2)} missing alt)"
|
|
318
|
+
|
|
319
|
+
# Links
|
|
320
|
+
l = c[:links] || {}
|
|
321
|
+
puts "\n#{Color::BOLD}š LINK COUNTS:#{Color::RESET}"
|
|
322
|
+
puts " P1: #{l.dig(:internal, :page1)} internal | #{l.dig(:external, :page1)} external"
|
|
323
|
+
puts " P2: #{l.dig(:internal, :page2)} internal | #{l.dig(:external, :page2)} external"
|
|
324
|
+
|
|
325
|
+
# Schema
|
|
326
|
+
s = c[:structured_data] || {}
|
|
327
|
+
puts "\n#{Color::BOLD}š¦ STRUCTURED DATA (JSON-LD):#{Color::RESET}"
|
|
328
|
+
puts " P1: #{s.dig(:schema_count, :page1)} schemas #{(s.dig(:schema_types, :page1) || []).inspect}"
|
|
329
|
+
puts " P2: #{s.dig(:schema_count, :page2)} schemas #{(s.dig(:schema_types, :page2) || []).inspect}"
|
|
330
|
+
|
|
331
|
+
# Speed
|
|
332
|
+
p_time = c[:performance] || {}
|
|
333
|
+
puts "\n#{Color::BOLD}ā” RESPONSE TIME:#{Color::RESET}"
|
|
334
|
+
puts " P1: #{p_time.dig(:response_time_ms, :page1)}ms | P2: #{p_time.dig(:response_time_ms, :page2)}ms"
|
|
335
|
+
puts ""
|
|
336
|
+
end
|
|
337
|
+
|
|
338
|
+
# 6. Content Gap
|
|
339
|
+
def self.handle_content_gap_command(target, extra, options)
|
|
340
|
+
url1 = target
|
|
341
|
+
url2 = extra
|
|
342
|
+
|
|
343
|
+
if url1.nil? || url2.nil?
|
|
344
|
+
puts Color.c("ā Error: Two URLs required. Example: gsc content-gap https://mysite.com https://competitor.com", Color::RED)
|
|
345
|
+
return
|
|
346
|
+
end
|
|
347
|
+
|
|
348
|
+
gap = GSC::ContentGap.new(url1, url2)
|
|
349
|
+
data = gap.analyze
|
|
350
|
+
|
|
351
|
+
if options[:json]
|
|
352
|
+
puts JSON.pretty_generate(data)
|
|
353
|
+
return
|
|
354
|
+
end
|
|
355
|
+
|
|
356
|
+
puts BANNER unless options[:in_dashboard]
|
|
357
|
+
puts "š #{Color::BOLD}CONTENT & TOPICAL KEYWORD GAP (SurferSEO Style):#{Color::RESET}"
|
|
358
|
+
puts " My URL: #{Color.c(url1, Color::CYAN)} (#{data[:page1][:word_count]} words)"
|
|
359
|
+
puts " Competitor URL: #{Color.c(url2, Color::YELLOW)} (#{data[:page2][:word_count]} words)"
|
|
360
|
+
puts "ā" * 80
|
|
361
|
+
|
|
362
|
+
puts "\n#{Color::BOLD}šÆ HIGH-FREQUENCY PHRASES IN COMPETITOR MISSING IN YOUR CONTENT:#{Color::RESET}"
|
|
363
|
+
unigrams = data[:missing_unigrams] || []
|
|
364
|
+
bigrams = data[:missing_bigrams] || []
|
|
365
|
+
|
|
366
|
+
if unigrams.empty? && bigrams.empty?
|
|
367
|
+
puts " (No major content gap detected! Your page covers competitor terminology well.)"
|
|
368
|
+
else
|
|
369
|
+
puts "\n #{Color.c('Top Missing 2-Word Keyphrases:', Color::BOLD, Color::YELLOW)}"
|
|
370
|
+
bigrams.first(8).each do |b|
|
|
371
|
+
puts " ⢠\"#{Color.c(b[:term], Color::BOLD)}\" (Competitor uses #{b[:competitor_count]}x, You: #{b[:your_count]}x)"
|
|
372
|
+
end
|
|
373
|
+
|
|
374
|
+
puts "\n #{Color.c('Top Missing Keywords:', Color::BOLD, Color::CYAN)}"
|
|
375
|
+
unigrams.first(8).each do |u|
|
|
376
|
+
puts " ⢠\"#{Color.c(u[:term], Color::BOLD)}\" (Competitor uses #{u[:competitor_count]}x, You: #{u[:your_count]}x)"
|
|
377
|
+
end
|
|
378
|
+
end
|
|
379
|
+
|
|
380
|
+
headings = data[:missing_headings] || []
|
|
381
|
+
unless headings.empty?
|
|
382
|
+
puts "\n#{Color::BOLD}š COMPETITOR HEADINGS / TOPICS YOU OMITTED:#{Color::RESET}"
|
|
383
|
+
headings.each do |h|
|
|
384
|
+
puts " ⢠#{h}"
|
|
385
|
+
end
|
|
386
|
+
end
|
|
387
|
+
puts ""
|
|
388
|
+
end
|
|
389
|
+
|
|
390
|
+
# 7. Internal Links Audit & Orphan Page Rescue Engine
|
|
391
|
+
def self.handle_internal_links_command(target, options)
|
|
392
|
+
raw = target || (Config.default_domain ? "https://#{Config.default_domain}" : nil)
|
|
393
|
+
if raw.nil? || raw.empty?
|
|
394
|
+
puts Color.c("ā Error: Target URL or domain required. Example: gsc internal-links https://mybrand.com", Color::RED)
|
|
395
|
+
return
|
|
396
|
+
end
|
|
397
|
+
base_url = raw.to_s.start_with?('http') ? raw : "https://#{raw}"
|
|
398
|
+
conc = options[:concurrency] || 5
|
|
399
|
+
il = GSC::InternalLinks.new(base_url, limit: options[:limit] || 50, concurrency: conc)
|
|
400
|
+
|
|
401
|
+
puts BANNER unless options[:json] || options[:in_dashboard]
|
|
402
|
+
puts "šøļø Auditing Internal Links & Orphan Pages for: #{Color.c(base_url, Color::CYAN)} (#{conc} worker threads)...\n" unless options[:json]
|
|
403
|
+
|
|
404
|
+
data = il.audit
|
|
405
|
+
|
|
406
|
+
if options[:json]
|
|
407
|
+
puts JSON.pretty_generate(data)
|
|
408
|
+
return
|
|
409
|
+
end
|
|
410
|
+
|
|
411
|
+
score_color = case data[:health_score]
|
|
412
|
+
when 85..100 then Color::GREEN
|
|
413
|
+
when 70..84 then Color::YELLOW
|
|
414
|
+
else Color::RED
|
|
415
|
+
end
|
|
416
|
+
|
|
417
|
+
puts "š Domain: #{Color.c(data[:base_url], Color::CYAN, Color::BOLD)}"
|
|
418
|
+
puts "ā” Crawl Performance: #{data[:total_pages]} pages mapped in #{data[:crawl_duration_s]}s"
|
|
419
|
+
puts "š Link Equity Score: #{Color.c("#{data[:health_score]}/100 (Grade #{data[:health_grade]})", score_color, Color::BOLD)}"
|
|
420
|
+
puts "šØ True Orphans: #{Color.c(data[:orphans].length.to_s, data[:orphans].empty? ? Color::GREEN : Color::RED, Color::BOLD)} (0 incoming internal links)"
|
|
421
|
+
puts "ā ļø Weakly Linked: #{Color.c(data[:weak_pages].length.to_s, Color::YELLOW, Color::BOLD)} (Only 1 incoming internal link)"
|
|
422
|
+
puts "š§ Buried Pages: #{Color.c(data[:deep_pages].length.to_s, data[:deep_pages].empty? ? Color::GREEN : Color::YELLOW, Color::BOLD)} (Click depth >= 4)"
|
|
423
|
+
puts "ā" * 80
|
|
424
|
+
|
|
425
|
+
orphan_details = data[:orphan_details] || []
|
|
426
|
+
unless orphan_details.empty?
|
|
427
|
+
puts "\n#{Color::BOLD}šØ ORPHAN PAGE RESCUE PLAYBOOK (#{orphan_details.size} crawl dead-ends):#{Color::RESET}"
|
|
428
|
+
orphan_details.each_with_index do |orp, idx|
|
|
429
|
+
puts "\n #{Color::BOLD}#{idx + 1}. #{Color.c(orp[:url], Color::RED, Color::BOLD)}#{Color::RESET} [Depth: #{orp[:depth]}]"
|
|
430
|
+
puts " #{Color::BOLD}Rescue Prescriptions:#{Color::RESET}"
|
|
431
|
+
orp[:suggested_rescues].each do |rec|
|
|
432
|
+
puts " ⢠#{Color.c(rec[:action], Color::CYAN)}"
|
|
433
|
+
end
|
|
434
|
+
end
|
|
435
|
+
else
|
|
436
|
+
puts "\n#{Color.c('ā
EXCELLENT! No orphan pages detected on site.', Color::GREEN, Color::BOLD)}"
|
|
437
|
+
end
|
|
438
|
+
|
|
439
|
+
weak = data[:weak_pages] || []
|
|
440
|
+
unless weak.empty?
|
|
441
|
+
puts "\n#{Color::BOLD}ā ļø WEAKLY LINKED PAGES (Vulnerable to De-indexation):#{Color::RESET}"
|
|
442
|
+
weak.first(5).each do |w|
|
|
443
|
+
puts " ⢠#{w[:url]} (Linked only from: #{Color.c(w[:source].to_s, Color::GRAY)})"
|
|
444
|
+
end
|
|
445
|
+
puts " ... and #{weak.size - 5} more weak pages" if weak.size > 5
|
|
446
|
+
end
|
|
447
|
+
|
|
448
|
+
deep = data[:deep_pages] || []
|
|
449
|
+
unless deep.empty?
|
|
450
|
+
puts "\n#{Color::BOLD}š§ BURIED PAGES (Crawl Depth >= 4 clicks):#{Color::RESET}"
|
|
451
|
+
deep.first(5).each do |dp|
|
|
452
|
+
puts " ⢠#{dp[:url]} (#{Color.c("#{dp[:depth]} clicks deep", Color::YELLOW)})"
|
|
453
|
+
end
|
|
454
|
+
end
|
|
455
|
+
|
|
456
|
+
puts "\n#{Color::BOLD}š TOP LINK EQUITY HUBS (High-Authority Internal Sources):#{Color::RESET}"
|
|
457
|
+
(data[:top_linked] || []).first(6).each do |top|
|
|
458
|
+
title_str = top[:title].to_s.empty? ? '' : " ā \"#{top[:title][0..35]}...\""
|
|
459
|
+
puts " ⢠#{Color.c(top[:url], Color::BOLD)}#{title_str} (#{Color.c(top[:incoming_count].to_s, Color::CYAN)} in / #{top[:outgoing_count]} out)"
|
|
460
|
+
end
|
|
461
|
+
|
|
462
|
+
if options[:csv]
|
|
463
|
+
csv_data = orphan_details.map do |o|
|
|
464
|
+
[o[:url], o[:depth], o[:suggested_rescues].map { |r| r[:source_url] }.join('; ')]
|
|
465
|
+
end
|
|
466
|
+
write_csv(options[:csv], %w[OrphanURL Depth RecommendedRescueSources], csv_data)
|
|
467
|
+
puts Color.c("\nš Exported orphan rescue playbook to #{options[:csv]}", Color::CYAN)
|
|
468
|
+
end
|
|
469
|
+
|
|
470
|
+
puts "\n" + ("ā" * 80) + "\n"
|
|
471
|
+
end
|
|
472
|
+
|
|
473
|
+
# 8. Schema Validator & Generator
|
|
474
|
+
def self.handle_schema_command(target, extra, options)
|
|
475
|
+
if target == 'generate' || target == 'gen'
|
|
476
|
+
schema_type = extra || 'faq'
|
|
477
|
+
tpl = GSC::SchemaValidator.generate_template(schema_type)
|
|
478
|
+
if options[:json]
|
|
479
|
+
puts JSON.pretty_generate(tpl)
|
|
480
|
+
else
|
|
481
|
+
puts Color.c("š Generated JSON-LD Schema (#{schema_type}):", Color::GREEN, Color::BOLD)
|
|
482
|
+
puts "<script type=\"application/ld+json\">"
|
|
483
|
+
puts JSON.pretty_generate(tpl)
|
|
484
|
+
puts "</script>"
|
|
485
|
+
end
|
|
486
|
+
return
|
|
487
|
+
end
|
|
488
|
+
|
|
489
|
+
raw = target || (Config.default_domain ? "https://#{Config.default_domain}" : nil)
|
|
490
|
+
if raw.nil? || raw.empty?
|
|
491
|
+
puts Color.c("ā Error: Target URL or domain required. Example: gsc schema https://mybrand.com", Color::RED)
|
|
492
|
+
return
|
|
493
|
+
end
|
|
494
|
+
url = raw.to_s.start_with?('http') ? raw : "https://#{raw}"
|
|
495
|
+
sv = GSC::SchemaValidator.new(url)
|
|
496
|
+
data = sv.audit
|
|
497
|
+
|
|
498
|
+
if options[:json]
|
|
499
|
+
puts JSON.pretty_generate(data)
|
|
500
|
+
return
|
|
501
|
+
end
|
|
502
|
+
|
|
503
|
+
puts BANNER unless options[:in_dashboard]
|
|
504
|
+
puts "š¦ #{Color::BOLD}STRUCTURED DATA & RICH SNIPPET VALIDATION:#{Color::RESET} #{Color.c(url, Color::CYAN)}"
|
|
505
|
+
puts "ā" * 75
|
|
506
|
+
|
|
507
|
+
schemas = data[:schemas] || []
|
|
508
|
+
if schemas.empty?
|
|
509
|
+
puts " (No JSON-LD structured data schemas found on this page)"
|
|
510
|
+
puts " Tip: Run `gsc schema generate faq` to create valid JSON-LD schema."
|
|
511
|
+
else
|
|
512
|
+
schemas.each do |sc|
|
|
513
|
+
valid_badge = sc[:valid] ? Color.c("VALID", Color::GREEN, Color::BOLD) : Color.c("INVALID", Color::RED, Color::BOLD)
|
|
514
|
+
puts "\nSchema ##{sc[:index] + 1}: #{Color.c(sc[:type], Color::BOLD)} [#{valid_badge}]"
|
|
515
|
+
(sc[:errors] || []).each { |e| puts " ā Error: #{Color.c(e, Color::RED)}" }
|
|
516
|
+
(sc[:warnings] || []).each { |w| puts " ā ļø Warning: #{Color.c(w, Color::YELLOW)}" }
|
|
517
|
+
end
|
|
518
|
+
end
|
|
519
|
+
puts ""
|
|
520
|
+
end
|
|
521
|
+
|
|
522
|
+
# 9. LLMS.txt & AI Search
|
|
523
|
+
def self.handle_llms_command(target, extra, options)
|
|
524
|
+
raw = target || (Config.default_domain ? "https://#{Config.default_domain}" : nil)
|
|
525
|
+
if raw.nil? || raw.empty?
|
|
526
|
+
puts Color.c("ā Error: Target URL or domain required. Example: gsc llms https://mybrand.com", Color::RED)
|
|
527
|
+
return
|
|
528
|
+
end
|
|
529
|
+
base_url = raw.to_s.start_with?('http') ? raw : "https://#{raw}"
|
|
530
|
+
llms = GSC::LlmsGenerator.new(base_url)
|
|
531
|
+
|
|
532
|
+
if extra == 'audit' || options[:audit]
|
|
533
|
+
data = llms.audit_ai_readability(base_url)
|
|
534
|
+
if options[:json]
|
|
535
|
+
puts JSON.pretty_generate(data)
|
|
536
|
+
else
|
|
537
|
+
puts BANNER unless options[:in_dashboard]
|
|
538
|
+
puts "š¤ #{Color::BOLD}AI SEARCH ENGINE / LLM READABILITY AUDIT:#{Color::RESET} #{Color.c(base_url, Color::CYAN)}"
|
|
539
|
+
puts "ā" * 70
|
|
540
|
+
puts "AI Citation Readiness Score: #{Color.c(data[:ai_readability_score].to_s + '/100', Color::GREEN, Color::BOLD)} [Grade: #{data[:grade]}]"
|
|
541
|
+
puts "\nFeatures Detected:"
|
|
542
|
+
puts " ⢠Single H1 Heading : #{data.dig(:features, :h1_count) == 1 ? 'ā
Yes' : 'ā No'}"
|
|
543
|
+
puts " ⢠Tables for Data : #{data.dig(:features, :has_tables) ? 'ā
Yes' : 'ā No'}"
|
|
544
|
+
puts " ⢠Bullet Lists : #{data.dig(:features, :has_lists) ? 'ā
Yes' : 'ā No'}"
|
|
545
|
+
puts " ⢠Structured Data : #{data.dig(:features, :schemas_found)} schemas"
|
|
546
|
+
unless data[:issues].empty?
|
|
547
|
+
puts "\nOptimizations for Perplexity & ChatGPT:"
|
|
548
|
+
data[:issues].each { |iss| puts " ⢠#{Color.c(iss, Color::YELLOW)}" }
|
|
549
|
+
end
|
|
550
|
+
puts ""
|
|
551
|
+
end
|
|
552
|
+
else
|
|
553
|
+
content = llms.generate_llms_txt
|
|
554
|
+
if options[:save]
|
|
555
|
+
File.write("llms.txt", content)
|
|
556
|
+
puts Color.c("ā
Successfully wrote llms.txt to current directory!", Color::GREEN)
|
|
557
|
+
else
|
|
558
|
+
puts content
|
|
559
|
+
end
|
|
560
|
+
end
|
|
561
|
+
end
|
|
562
|
+
|
|
563
|
+
# 10. SERP & Social Preview / Live SERP Feature Detector
|
|
564
|
+
def self.handle_preview_command(target, options)
|
|
565
|
+
is_url = target.to_s.start_with?('http://', 'https://')
|
|
566
|
+
has_title_or_url = is_url || options[:url] || options[:title] || options[:desc] || options[:description] || options[:preview] || (target.to_s =~ /[:|ā]/)
|
|
567
|
+
|
|
568
|
+
if options[:live] || options[:features] || (!has_title_or_url && !target.to_s.empty?)
|
|
569
|
+
return handle_serp_features_command(target, options)
|
|
570
|
+
end
|
|
571
|
+
|
|
572
|
+
url = options[:url] || (target if is_url)
|
|
573
|
+
url ||= (Config.default_domain ? "https://#{Config.default_domain}" : nil)
|
|
574
|
+
title = options[:title] || (!is_url && !target.to_s.empty? ? target.to_s : nil)
|
|
575
|
+
desc = options[:desc] || options[:description]
|
|
576
|
+
|
|
577
|
+
if url.nil? && (title.nil? || title.empty?)
|
|
578
|
+
puts Color.c("ā Error: Target URL or title required for SERP preview. Example: gsc preview https://mybrand.com/page", Color::RED)
|
|
579
|
+
return
|
|
580
|
+
end
|
|
581
|
+
url ||= "https://#{title.to_s.downcase.gsub(/[^a-z0-9]+/, '-')}.local"
|
|
582
|
+
|
|
583
|
+
sp = GSC::SerpPreview.new(url, title: title, desc: desc)
|
|
584
|
+
data = sp.generate
|
|
585
|
+
|
|
586
|
+
if options[:json]
|
|
587
|
+
puts JSON.pretty_generate(data)
|
|
588
|
+
return
|
|
589
|
+
end
|
|
590
|
+
|
|
591
|
+
puts BANNER unless options[:in_dashboard]
|
|
592
|
+
m = data[:metrics] || {}
|
|
593
|
+
|
|
594
|
+
puts "š„ļø #{Color::BOLD}GOOGLE SERP PREVIEW (Desktop Viewport):#{Color::RESET}"
|
|
595
|
+
puts "āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā"
|
|
596
|
+
puts "ā #{Color.c(data.dig(:desktop_serp, :breadcrumb).to_s.ljust(71), Color::DIM)}ā"
|
|
597
|
+
puts "ā #{Color.c(data.dig(:desktop_serp, :title).to_s.ljust(71), Color::BLUE, Color::BOLD)}ā"
|
|
598
|
+
puts "ā #{Color.c(data.dig(:desktop_serp, :snippet).to_s[0..70].ljust(71), Color::DIM)}ā"
|
|
599
|
+
puts "āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā"
|
|
600
|
+
|
|
601
|
+
t_status = if m[:title_truncated]
|
|
602
|
+
Color.c("ā ļø TRUNCATED (+#{(m[:title_pixel_est] - m[:title_desktop_limit]).round(1)}px over 580px limit)", Color::YELLOW, Color::BOLD)
|
|
603
|
+
else
|
|
604
|
+
Color.c('ā
OPTIMAL (< 580px desktop limit)', Color::GREEN, Color::BOLD)
|
|
605
|
+
end
|
|
606
|
+
|
|
607
|
+
d_status = if m[:desc_truncated]
|
|
608
|
+
Color.c("ā ļø TRUNCATED (+#{(m[:desc_pixel_est] - m[:desc_desktop_limit]).round(1)}px over 960px limit)", Color::YELLOW, Color::BOLD)
|
|
609
|
+
else
|
|
610
|
+
Color.c('ā
OPTIMAL (< 960px desktop limit)', Color::GREEN, Color::BOLD)
|
|
611
|
+
end
|
|
612
|
+
|
|
613
|
+
puts " ⢠Title Width: #{m[:title_chars]} chars / ~#{m[:title_pixel_est]}px [#{t_status}]"
|
|
614
|
+
puts " ⢠Description Width: #{m[:desc_chars]} chars / ~#{m[:desc_pixel_est]}px [#{d_status}]"
|
|
615
|
+
|
|
616
|
+
puts "\nš± #{Color::BOLD}GOOGLE SERP PREVIEW (Mobile Viewport):#{Color::RESET}"
|
|
617
|
+
puts "āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā"
|
|
618
|
+
puts "ā #{Color.c(data.dig(:mobile_serp, :breadcrumb).to_s.ljust(71), Color::DIM)}ā"
|
|
619
|
+
puts "ā #{Color.c(data.dig(:mobile_serp, :title).to_s.ljust(71), Color::BLUE, Color::BOLD)}ā"
|
|
620
|
+
puts "ā #{Color.c(data.dig(:mobile_serp, :snippet).to_s[0..70].ljust(71), Color::DIM)}ā"
|
|
621
|
+
puts "āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā"
|
|
622
|
+
|
|
623
|
+
soc = data[:social] || {}
|
|
624
|
+
if soc[:og_title] || soc[:og_image]
|
|
625
|
+
puts "\nš #{Color::BOLD}OPEN GRAPH / SOCIAL CARD PREVIEW:#{Color::RESET}"
|
|
626
|
+
puts " ⢠Title : #{soc[:og_title]}"
|
|
627
|
+
puts " ⢠Description : #{soc[:og_description]}"
|
|
628
|
+
puts " ⢠Card Image : #{soc[:og_image] || '(No og:image specified)'}"
|
|
629
|
+
end
|
|
630
|
+
puts ""
|
|
631
|
+
end
|
|
632
|
+
|
|
633
|
+
# 10a. Live SERP Feature Detector & Zero-Click Threat Analysis
|
|
634
|
+
def self.handle_serp_features_command(query, options)
|
|
635
|
+
query_str = query.to_s.sub(/^(query|live):/, '').strip
|
|
636
|
+
query_str = "what is #{Config.default_domain || 'technical seo'}" if query_str.empty?
|
|
637
|
+
|
|
638
|
+
detector = GSC::SerpFeatureDetector.new(query_str, options)
|
|
639
|
+
data = detector.detect
|
|
640
|
+
|
|
641
|
+
if options[:json]
|
|
642
|
+
puts JSON.pretty_generate(data)
|
|
643
|
+
return
|
|
644
|
+
end
|
|
645
|
+
|
|
646
|
+
puts BANNER unless options[:in_dashboard]
|
|
647
|
+
puts "š #{Color::BOLD}LIVE SERP FEATURE & ZERO-CLICK THREAT DETECTOR:#{Color::RESET} #{Color.c(query_str, Color::CYAN, Color::BOLD)}"
|
|
648
|
+
puts "ā" * 80
|
|
649
|
+
|
|
650
|
+
zt = data[:zero_click_threat] || {}
|
|
651
|
+
threat_color = case zt[:level]
|
|
652
|
+
when 'LOW' then Color::GREEN
|
|
653
|
+
when 'MODERATE' then Color::YELLOW
|
|
654
|
+
else Color::RED
|
|
655
|
+
end
|
|
656
|
+
|
|
657
|
+
puts "šÆ Search Intent: #{Color.c(data.dig(:intent, :primary).to_s, Color::BOLD)} (#{data.dig(:intent, :description)})"
|
|
658
|
+
puts "šØ Zero-Click Threat: #{Color.c("#{zt[:level]} (#{zt[:score]}/100)", threat_color, Color::BOLD)} [Organic CTR Impact: #{zt[:estimated_organic_ctr_suppression]}]"
|
|
659
|
+
puts "ā" * 80
|
|
660
|
+
|
|
661
|
+
f = data[:features] || {}
|
|
662
|
+
puts "\n#{Color::BOLD}š DETECTED SERP REAL ESTATE FEATURES:#{Color::RESET}"
|
|
663
|
+
|
|
664
|
+
# AI Overview
|
|
665
|
+
aio = f[:ai_overview] || {}
|
|
666
|
+
aio_badge = aio[:detected] ? Color.c("ACTIVE (#{aio[:probability_pct]}% prob)", Color::RED, Color::BOLD) : Color.c("Unlikely (#{aio[:probability_pct]}%)", Color::GRAY)
|
|
667
|
+
puts " ⢠Google AI Overview (Gemini) : [#{aio_badge}]"
|
|
668
|
+
puts " āā #{Color.c(aio[:impact], Color::DIM)}" if aio[:detected]
|
|
669
|
+
|
|
670
|
+
# Featured Snippet
|
|
671
|
+
fs = f[:featured_snippet] || {}
|
|
672
|
+
fs_badge = fs[:detected] ? Color.c("OPPORTUNITY (#{fs[:target_format].capitalize} - #{fs[:probability_pct]}%)", Color::GREEN, Color::BOLD) : Color.c("None", Color::GRAY)
|
|
673
|
+
puts " ⢠Featured Snippet Box : [#{fs_badge}]"
|
|
674
|
+
puts " āā Target Recipe: #{Color.c(fs[:capture_prescription], Color::CYAN)}" if fs[:detected]
|
|
675
|
+
|
|
676
|
+
# People Also Ask
|
|
677
|
+
paa = f[:people_also_ask] || {}
|
|
678
|
+
paa_badge = paa[:detected] ? Color.c("#{paa[:count]} Live Questions Extracted", Color::CYAN, Color::BOLD) : Color.c("None", Color::GRAY)
|
|
679
|
+
puts " ⢠People Also Ask (PAA) : [#{paa_badge}]"
|
|
680
|
+
if paa[:detected] && (paa[:questions] || []).any?
|
|
681
|
+
paa[:questions].first(5).each do |q_text|
|
|
682
|
+
puts " ā \"#{Color.c(q_text, Color::YELLOW)}\""
|
|
683
|
+
end
|
|
684
|
+
end
|
|
685
|
+
|
|
686
|
+
# Local Pack
|
|
687
|
+
lp = f[:local_3_pack] || {}
|
|
688
|
+
lp_badge = lp[:detected] ? Color.c("DETECTED (Google Maps 3-Pack)", Color::YELLOW, Color::BOLD) : Color.c("None", Color::GRAY)
|
|
689
|
+
puts " ⢠Local Maps 3-Pack : [#{lp_badge}]"
|
|
690
|
+
|
|
691
|
+
# Video Carousel
|
|
692
|
+
vc = f[:video_carousel] || {}
|
|
693
|
+
vc_badge = vc[:detected] ? Color.c("DETECTED (Video Carousel)", Color::YELLOW, Color::BOLD) : Color.c("None", Color::GRAY)
|
|
694
|
+
puts " ⢠Video Carousel / YouTube : [#{vc_badge}]"
|
|
695
|
+
|
|
696
|
+
# Discussions & Forums
|
|
697
|
+
df = f[:discussions_and_forums] || {}
|
|
698
|
+
df_badge = df[:detected] ? Color.c("DETECTED (Reddit/Quora Modules)", Color::CYAN, Color::BOLD) : Color.c("None", Color::GRAY)
|
|
699
|
+
puts " ⢠Discussions & Forums : [#{df_badge}]"
|
|
700
|
+
|
|
701
|
+
# Shopping Pack
|
|
702
|
+
sp = f[:shopping_pack] || {}
|
|
703
|
+
sp_badge = sp[:detected] ? Color.c("DETECTED (Shopping Product Grid)", Color::YELLOW, Color::BOLD) : Color.c("None", Color::GRAY)
|
|
704
|
+
puts " ⢠Google Shopping Grid : [#{sp_badge}]"
|
|
705
|
+
|
|
706
|
+
# Playbook
|
|
707
|
+
playbook = data[:playbook] || []
|
|
708
|
+
if playbook.any?
|
|
709
|
+
puts "\n#{Color::BOLD}š RICH SNIPPET & CITATION CAPTURE PLAYBOOK:#{Color::RESET}"
|
|
710
|
+
playbook.each_with_index do |p, idx|
|
|
711
|
+
puts " #{idx + 1}. [#{Color.c(p[:priority], Color::BOLD)}] #{Color.c(p[:target], Color::CYAN)}:"
|
|
712
|
+
puts " š #{p[:action]}"
|
|
713
|
+
end
|
|
714
|
+
end
|
|
715
|
+
|
|
716
|
+
# Live SERP Sampling
|
|
717
|
+
serp = data[:serp_sampling] || []
|
|
718
|
+
if serp.any?
|
|
719
|
+
puts "\n#{Color::BOLD}š LIVE SERP SAMPLING (Top Competitor Results):#{Color::RESET}"
|
|
720
|
+
serp.each do |r|
|
|
721
|
+
puts " #{r[:position]}. #{Color.c(r[:title], Color::BOLD)} [#{Color.c(r[:domain], Color::GRAY)}]"
|
|
722
|
+
puts " āā #{Color.c(r[:url], Color::BLUE)}"
|
|
723
|
+
end
|
|
724
|
+
end
|
|
725
|
+
|
|
726
|
+
puts "\n" + ("ā" * 80) + "\n"
|
|
727
|
+
end
|
|
728
|
+
|
|
729
|
+
# 10b. IndexNow Multi-Engine Instant Indexing (Bing, Yandex, Seznam, Naver)
|
|
730
|
+
def self.handle_indexnow_command(target, extra, options)
|
|
731
|
+
if target == 'key' || target == 'connect'
|
|
732
|
+
if extra && !extra.strip.empty?
|
|
733
|
+
key = IndexNow.set_key(extra)
|
|
734
|
+
puts Color.c("ā
Successfully set IndexNow API key to: #{key}", Color::GREEN, Color::BOLD)
|
|
735
|
+
else
|
|
736
|
+
key = IndexNow.get_or_create_key
|
|
737
|
+
host = Config.default_domain || 'yourdomain.com'
|
|
738
|
+
puts "š #{Color::BOLD}INDEXNOW API KEY & HOST SETUP:#{Color::RESET}"
|
|
739
|
+
puts " ⢠Active Key: #{Color.c(key, Color::CYAN, Color::BOLD)}"
|
|
740
|
+
puts " ⢠Host Verification: #{Color.c("https://#{host}/#{key}.txt", Color::BLUE)}"
|
|
741
|
+
puts " ⢠Required Content: #{Color.c(key, Color::BOLD)}"
|
|
742
|
+
puts "\nš” #{Color.c('Verification Tip:', Color::YELLOW)} Create a plain text file at the root of your web server named '#{key}.txt' with '#{key}' as the content."
|
|
743
|
+
end
|
|
744
|
+
return
|
|
745
|
+
end
|
|
746
|
+
|
|
747
|
+
if target == 'sitemap' || target.to_s.end_with?('.xml')
|
|
748
|
+
sitemap_target = (target == 'sitemap') ? extra : target
|
|
749
|
+
handle_indexnow_sitemap_command(sitemap_target, options)
|
|
750
|
+
return
|
|
751
|
+
end
|
|
752
|
+
|
|
753
|
+
target_url = target || options[:url]
|
|
754
|
+
unless target_url
|
|
755
|
+
if Config.default_domain
|
|
756
|
+
target_url = "https://#{Config.default_domain}"
|
|
757
|
+
else
|
|
758
|
+
raise 'Please provide a URL to index (e.g. gsc indexnow https://example.com/new-page) or connect a default domain.'
|
|
759
|
+
end
|
|
760
|
+
end
|
|
761
|
+
|
|
762
|
+
urls = [target_url]
|
|
763
|
+
urls << extra if extra && extra.start_with?('http')
|
|
764
|
+
|
|
765
|
+
key = options[:key] || IndexNow.get_or_create_key
|
|
766
|
+
res = IndexNow.submit(urls, key: key)
|
|
767
|
+
|
|
768
|
+
if options[:json]
|
|
769
|
+
puts JSON.pretty_generate(res)
|
|
770
|
+
return
|
|
771
|
+
end
|
|
772
|
+
|
|
773
|
+
puts BANNER unless options[:in_dashboard]
|
|
774
|
+
puts "ā” #{Color::BOLD}INDEXNOW MULTI-SEARCH ENGINE SUBMISSION:#{Color::RESET}"
|
|
775
|
+
puts "ā" * 75
|
|
776
|
+
status_color = res[:success] ? Color::GREEN : Color::RED
|
|
777
|
+
puts "Status : #{Color.c(res[:message], status_color, Color::BOLD)}"
|
|
778
|
+
puts "Submitted URLs : #{Color.c(res[:submitted_urls].to_s, Color::CYAN, Color::BOLD)}"
|
|
779
|
+
puts "Host : #{res[:host]}"
|
|
780
|
+
puts "Key Location : #{res[:key_location]}"
|
|
781
|
+
puts "Engines Notified: #{Color.c('Microsoft Bing, Yandex, Seznam, Naver', Color::BOLD)}"
|
|
782
|
+
puts "ā" * 75
|
|
783
|
+
|
|
784
|
+
urls.each_with_index do |u, idx|
|
|
785
|
+
puts " #{idx + 1}. #{Color.c(u, Color::CYAN)}"
|
|
786
|
+
end
|
|
787
|
+
|
|
788
|
+
unless res[:success]
|
|
789
|
+
puts "\n#{Color.c('ā ļø Note:', Color::YELLOW)} Ensure you have created #{res[:key_location]} with content: #{res[:key]}"
|
|
790
|
+
end
|
|
791
|
+
puts ""
|
|
792
|
+
end
|
|
793
|
+
|
|
794
|
+
def self.handle_indexnow_sitemap_command(target, options)
|
|
795
|
+
sitemap = target || options[:sitemap] || 'sitemap.xml'
|
|
796
|
+
key = options[:key] || IndexNow.get_or_create_key
|
|
797
|
+
limit = options[:limit] ? options[:limit].to_i : nil
|
|
798
|
+
|
|
799
|
+
puts BANNER unless options[:in_dashboard]
|
|
800
|
+
puts "š #{Color::BOLD}BATCH INDEXNOW SITEMAP SUBMISSION:#{Color::RESET} #{Color.c(sitemap, Color::CYAN)}"
|
|
801
|
+
puts "ā" * 75
|
|
802
|
+
|
|
803
|
+
res = IndexNow.submit_sitemap(sitemap, key: key, limit: limit)
|
|
804
|
+
|
|
805
|
+
if options[:json]
|
|
806
|
+
puts JSON.pretty_generate(res)
|
|
807
|
+
return
|
|
808
|
+
end
|
|
809
|
+
|
|
810
|
+
status_color = res[:success] ? Color::GREEN : Color::RED
|
|
811
|
+
puts "Status : #{Color.c(res[:message], status_color, Color::BOLD)}"
|
|
812
|
+
puts "URLs Submitted : #{Color.c(res[:submitted_urls].to_s, Color::GREEN, Color::BOLD)}"
|
|
813
|
+
puts "Host : #{res[:host]}"
|
|
814
|
+
puts "Engines Notified: #{Color.c('Microsoft Bing, Yandex, Seznam, Naver', Color::BOLD)}"
|
|
815
|
+
puts "ā" * 75
|
|
816
|
+
puts "⨠All URLs pushed to IndexNow instant crawl queue."
|
|
817
|
+
puts ""
|
|
818
|
+
end
|
|
819
|
+
|
|
820
|
+
# 11. Network & Redirect Tracer
|
|
821
|
+
def self.handle_trace_command(target, options)
|
|
822
|
+
url = target || (Config.default_domain ? "https://#{Config.default_domain}" : nil)
|
|
823
|
+
if url.nil? || url.empty?
|
|
824
|
+
puts Color.c("ā Error: Target URL or domain required. Example: gsc trace https://mybrand.com", Color::RED)
|
|
825
|
+
return
|
|
826
|
+
end
|
|
827
|
+
nt = GSC::NetworkTracer.new(url)
|
|
828
|
+
data = nt.trace
|
|
829
|
+
|
|
830
|
+
if options[:json]
|
|
831
|
+
puts JSON.pretty_generate(data)
|
|
832
|
+
return
|
|
833
|
+
end
|
|
834
|
+
|
|
835
|
+
puts BANNER unless options[:in_dashboard]
|
|
836
|
+
puts "š¤ļø #{Color::BOLD}REDIRECT CHAIN & HTTP HEADER TRACE:#{Color::RESET} #{Color.c(url, Color::CYAN)}"
|
|
837
|
+
puts "ā" * 75
|
|
838
|
+
puts "Total Hops: #{data[:total_hops]} | Duration: #{data[:total_duration_ms]}ms"
|
|
839
|
+
|
|
840
|
+
(data[:hops] || []).each do |hop|
|
|
841
|
+
status_c = (hop[:status_code] == 200) ? Color::GREEN : Color::YELLOW
|
|
842
|
+
puts "\nHop ##{hop[:hop]}: #{Color.c(hop[:status_code].to_s, status_c, Color::BOLD)} (#{hop[:duration_ms]}ms)"
|
|
843
|
+
puts " URL: #{hop[:url]}"
|
|
844
|
+
puts " X-Robots-Tag: #{Color.c(hop[:x_robots_tag], Color::RED)}" if hop[:x_robots_tag]
|
|
845
|
+
puts " Canonical: #{hop[:canonical_header]}" if hop[:canonical_header]
|
|
846
|
+
puts " HSTS: #{hop[:hsts] ? 'Enabled' : 'Disabled'}"
|
|
847
|
+
end
|
|
848
|
+
puts ""
|
|
849
|
+
end
|
|
850
|
+
|
|
851
|
+
# 12. Robots.txt Checker
|
|
852
|
+
def self.handle_robots_command(target, extra, options)
|
|
853
|
+
raw = target || (Config.default_domain ? "https://#{Config.default_domain}" : nil)
|
|
854
|
+
if raw.nil? || raw.empty?
|
|
855
|
+
puts Color.c("ā Error: Target URL or domain required. Example: gsc robots https://mybrand.com", Color::RED)
|
|
856
|
+
return
|
|
857
|
+
end
|
|
858
|
+
url = raw.to_s.start_with?('http') ? raw : "https://#{raw}"
|
|
859
|
+
path = extra || '/'
|
|
860
|
+
bot = options[:bot] || 'googlebot'
|
|
861
|
+
|
|
862
|
+
rc = GSC::RobotsChecker.new(url)
|
|
863
|
+
data = rc.check(path, bot)
|
|
864
|
+
|
|
865
|
+
if options[:json]
|
|
866
|
+
puts JSON.pretty_generate(data)
|
|
867
|
+
return
|
|
868
|
+
end
|
|
869
|
+
|
|
870
|
+
puts BANNER unless options[:in_dashboard]
|
|
871
|
+
puts "š¤ #{Color::BOLD}ROBOTS.TXT CRAWLER SIMULATOR:#{Color::RESET}"
|
|
872
|
+
puts " Robots URL : #{data[:robots_url]}"
|
|
873
|
+
puts " User Agent : #{Color.c(bot, Color::CYAN)}"
|
|
874
|
+
puts " Test Path : #{Color.c(path, Color::BOLD)}"
|
|
875
|
+
puts "ā" * 70
|
|
876
|
+
|
|
877
|
+
status_badge = data[:allowed] ? Color.c("ā
ALLOWED", Color::GREEN, Color::BOLD) : Color.c("ā BLOCKED (DISALLOW)", Color::RED, Color::BOLD)
|
|
878
|
+
puts "Crawl Verdict : #{status_badge}"
|
|
879
|
+
if data[:matched_rule]
|
|
880
|
+
puts "Matched Rule : #{data[:matched_rule][:type].to_s.upcase}: #{data[:matched_rule][:path]}"
|
|
881
|
+
end
|
|
882
|
+
puts ""
|
|
883
|
+
end
|
|
884
|
+
|
|
885
|
+
# 13. Backlinks & GSC Links Ingestion
|
|
886
|
+
def self.handle_backlinks_command(target, extra, options)
|
|
887
|
+
domain = (target unless target == 'import') || Config.default_domain
|
|
888
|
+
if domain.nil? || domain.empty?
|
|
889
|
+
puts Color.c("ā Error: Target domain required. Example: gsc backlinks mybrand.com", Color::RED)
|
|
890
|
+
return
|
|
891
|
+
end
|
|
892
|
+
bm = GSC::BacklinksManager.new(domain)
|
|
893
|
+
|
|
894
|
+
if target == 'import' || extra == 'import'
|
|
895
|
+
source = (target == 'import') ? extra : target
|
|
896
|
+
content = if source == 'clip' || source == 'clipboard'
|
|
897
|
+
`pbpaste 2>/dev/null`
|
|
898
|
+
elsif source && File.exist?(source)
|
|
899
|
+
File.read(source)
|
|
900
|
+
else
|
|
901
|
+
nil
|
|
902
|
+
end
|
|
903
|
+
|
|
904
|
+
if content.nil? || content.strip.empty?
|
|
905
|
+
puts Color.c("ā Error: No content provided. Usage: gsc backlinks import [file.csv|clip]", Color::RED)
|
|
906
|
+
return
|
|
907
|
+
end
|
|
908
|
+
|
|
909
|
+
res = bm.import_csv(content)
|
|
910
|
+
if options[:json]
|
|
911
|
+
puts JSON.pretty_generate(res)
|
|
912
|
+
else
|
|
913
|
+
puts Color.c("ā
Successfully imported GSC backlink export!", Color::GREEN, Color::BOLD)
|
|
914
|
+
puts " Referring Domains : #{res[:sources_count]}"
|
|
915
|
+
puts " Target Pages : #{res[:targets_count]}"
|
|
916
|
+
end
|
|
917
|
+
return
|
|
918
|
+
end
|
|
919
|
+
|
|
920
|
+
data = bm.summary
|
|
921
|
+
|
|
922
|
+
if options[:json]
|
|
923
|
+
puts JSON.pretty_generate(data)
|
|
924
|
+
return
|
|
925
|
+
end
|
|
926
|
+
|
|
927
|
+
puts BANNER unless options[:in_dashboard]
|
|
928
|
+
puts "š #{Color::BOLD}GSC BACKLINK & REFERRING DOMAIN INTELLIGENCE:#{Color::RESET} #{Color.c(domain, Color::CYAN)}"
|
|
929
|
+
puts "ā" * 75
|
|
930
|
+
puts "Total Referring Domains : #{Color.c(data[:total_referring_domains].to_s, Color::BOLD)}"
|
|
931
|
+
puts "Total External Links : #{Color.c(data[:total_external_links].to_s, Color::BOLD)}"
|
|
932
|
+
puts "Last Updated : #{data[:updated_at] || 'Never (Run `gsc backlinks import` to ingest GSC export)'}"
|
|
933
|
+
puts "ā" * 75
|
|
934
|
+
|
|
935
|
+
sources = data[:top_referring_domains] || []
|
|
936
|
+
unless sources.empty?
|
|
937
|
+
puts "\n#{Color::BOLD}š TOP REFERRING SITES:#{Color::RESET}"
|
|
938
|
+
sources.each do |s|
|
|
939
|
+
puts " ⢠#{s['domain'].to_s.ljust(45)} #{Color.c(s['links_count'].to_s.rjust(6) + ' links', Color::CYAN)}"
|
|
940
|
+
end
|
|
941
|
+
end
|
|
942
|
+
|
|
943
|
+
targets = data[:top_target_pages] || []
|
|
944
|
+
unless targets.empty?
|
|
945
|
+
puts "\n#{Color::BOLD}šÆ TOP LINKED LANDING PAGES:#{Color::RESET}"
|
|
946
|
+
targets.each do |t|
|
|
947
|
+
puts " ⢠#{t['target_url'].to_s.ljust(45)} #{Color.c(t['incoming_count'].to_s.rjust(6) + ' links', Color::GREEN)}"
|
|
948
|
+
end
|
|
949
|
+
end
|
|
950
|
+
puts ""
|
|
951
|
+
end
|
|
952
|
+
|
|
953
|
+
# 14. Generative Engine Optimization (GEO / AEO) Citability Auditor
|
|
954
|
+
def self.handle_geo_command(target, options)
|
|
955
|
+
raw = target || (Config.default_domain ? "https://#{Config.default_domain}" : nil)
|
|
956
|
+
if raw.nil? || raw.empty?
|
|
957
|
+
puts Color.c("ā Error: Target URL or domain required. Example: gsc geo https://mybrand.com", Color::RED)
|
|
958
|
+
return
|
|
959
|
+
end
|
|
960
|
+
url = raw.to_s.start_with?('http') ? raw : "https://#{raw}"
|
|
961
|
+
auditor = GSC::GeoAuditor.new(url)
|
|
962
|
+
|
|
963
|
+
puts "š¤ Analyzing Generative Engine Optimization (GEO) & Citability for #{Color.c(url, Color::CYAN)}...\n" unless options[:json]
|
|
964
|
+
|
|
965
|
+
data = auditor.audit
|
|
966
|
+
if data[:error]
|
|
967
|
+
if options[:json]
|
|
968
|
+
puts JSON.pretty_generate(data)
|
|
969
|
+
else
|
|
970
|
+
puts Color.c("ā Error: #{data[:message]}", Color::RED, Color::BOLD)
|
|
971
|
+
end
|
|
972
|
+
return
|
|
973
|
+
end
|
|
974
|
+
|
|
975
|
+
if options[:json]
|
|
976
|
+
puts JSON.pretty_generate(data)
|
|
977
|
+
return
|
|
978
|
+
end
|
|
979
|
+
|
|
980
|
+
puts BANNER unless options[:in_dashboard]
|
|
981
|
+
puts "š¤ #{Color::BOLD}GENERATIVE ENGINE OPTIMIZATION (GEO / AEO) AUDIT#{Color::RESET}"
|
|
982
|
+
puts "šÆ Target URL : #{Color.c(data[:url], Color::CYAN)}"
|
|
983
|
+
puts "ā" * 75
|
|
984
|
+
|
|
985
|
+
score_color = case data[:score]
|
|
986
|
+
when 85..100 then Color::GREEN
|
|
987
|
+
when 70..84 then Color::CYAN
|
|
988
|
+
when 50..69 then Color::YELLOW
|
|
989
|
+
else Color::RED
|
|
990
|
+
end
|
|
991
|
+
|
|
992
|
+
puts "š #{Color::BOLD}AI CITABILITY SCORE:#{Color::RESET} #{Color.c("#{data[:score]}/100", score_color, Color::BOLD)} ā #{data[:grade]}"
|
|
993
|
+
puts " ⢠AI Bot Access (Robots.txt) : #{data[:category_scores][:ai_bot_access][:score]}/25"
|
|
994
|
+
puts " ⢠Direct Question Answers : #{data[:category_scores][:direct_answers][:score]}/25"
|
|
995
|
+
puts " ⢠Factual & Data Citability : #{data[:category_scores][:facts_and_data][:score]}/25"
|
|
996
|
+
puts " ⢠Entity Schema & Authority : #{data[:category_scores][:entity_schema][:score]}/25"
|
|
997
|
+
puts "ā" * 75
|
|
998
|
+
|
|
999
|
+
puts "\nš¤ #{Color::BOLD}AI SEARCH CRAWLER ACCESS (Robots.txt):#{Color::RESET}"
|
|
1000
|
+
data[:ai_crawlers][:bots].each do |bot, info|
|
|
1001
|
+
badge = info[:allowed] ? Color.c("ā
ALLOWED", Color::GREEN) : Color.c("ā BLOCKED", Color::RED, Color::BOLD)
|
|
1002
|
+
puts " ⢠#{bot.ljust(20)} [#{info[:provider]}]: #{badge} (#{info[:rule]})"
|
|
1003
|
+
end
|
|
1004
|
+
|
|
1005
|
+
puts "\nš #{Color::BOLD}DIRECT ANSWER & CITABILITY DENSITY:#{Color::RESET}"
|
|
1006
|
+
puts " ⢠Question Headings Detected : #{data[:direct_answers][:total_question_headings]}"
|
|
1007
|
+
puts " ⢠Direct Answer Paragraphs : #{data[:direct_answers][:direct_answer_blocks_found]}"
|
|
1008
|
+
puts " ⢠Optimal Length Answers (30-80w): #{data[:direct_answers][:optimal_answers_count]}"
|
|
1009
|
+
|
|
1010
|
+
samples = data[:direct_answers][:samples] || []
|
|
1011
|
+
if samples.any?
|
|
1012
|
+
puts " #{Color::BOLD}Sample Answer Pairs:#{Color::RESET}"
|
|
1013
|
+
samples.each do |s|
|
|
1014
|
+
puts " Q: #{Color.c(s[:question], Color::CYAN)}"
|
|
1015
|
+
puts " A: #{s[:answer_preview]} (#{s[:word_count]} words)"
|
|
1016
|
+
end
|
|
1017
|
+
end
|
|
1018
|
+
|
|
1019
|
+
puts "\nš #{Color::BOLD}FACTUAL & INFORMATION GAIN SIGNALS:#{Color::RESET}"
|
|
1020
|
+
puts " ⢠Statistics & Percentages : #{data[:facts_and_citability][:percentage_mentions]}"
|
|
1021
|
+
puts " ⢠Currency / Pricing Signals : #{data[:facts_and_citability][:currency_mentions]}"
|
|
1022
|
+
puts " ⢠Structured Bullet Items (<li>): #{data[:facts_and_citability][:bullet_list_items]}"
|
|
1023
|
+
puts " ⢠Comparison Table Rows (<tr>) : #{data[:facts_and_citability][:comparison_table_rows]}"
|
|
1024
|
+
puts " ⢠Recent Year Signals (2024-26): #{data[:facts_and_citability][:recent_year_mentions]}"
|
|
1025
|
+
|
|
1026
|
+
puts "\nšļø #{Color::BOLD}ENTITY KNOWLEDGE GRAPH & SCHEMA:#{Color::RESET}"
|
|
1027
|
+
puts " ⢠Schemas Found : #{data[:entity_knowledge_graph][:schema_count]} (#{data[:entity_knowledge_graph][:schema_types].join(', ')})"
|
|
1028
|
+
puts " ⢠Organization / Brand Schema : #{data[:entity_knowledge_graph][:has_organization_or_brand] ? Color.c("Yes", Color::GREEN) : Color.c("Missing", Color::YELLOW)}"
|
|
1029
|
+
puts " ⢠FAQ / HowTo Rich Schema : #{data[:entity_knowledge_graph][:has_faq_or_howto] ? Color.c("Yes", Color::GREEN) : Color.c("None", Color::YELLOW)}"
|
|
1030
|
+
puts " ⢠Wikidata / Wikipedia Linked : #{data[:entity_knowledge_graph][:wikidata_or_wikipedia_linked] ? Color.c("Yes", Color::GREEN) : Color.c("No", Color::YELLOW)}"
|
|
1031
|
+
if data[:entity_knowledge_graph][:same_as_domains].any?
|
|
1032
|
+
puts " ⢠Social Authority Entity Links: #{data[:entity_knowledge_graph][:same_as_domains].join(', ')}"
|
|
1033
|
+
end
|
|
1034
|
+
|
|
1035
|
+
recs = data[:recommendations] || []
|
|
1036
|
+
if recs.any?
|
|
1037
|
+
puts "\nš” #{Color::BOLD}AI CITABILITY OPTIMIZATION RECOMMENDATIONS:#{Color::RESET}"
|
|
1038
|
+
recs.each_with_index do |r, i|
|
|
1039
|
+
puts " #{i + 1}. #{Color.c(r, Color::YELLOW)}"
|
|
1040
|
+
end
|
|
1041
|
+
end
|
|
1042
|
+
puts ""
|
|
1043
|
+
end
|
|
1044
|
+
|
|
1045
|
+
# 15. Knowledge Graph Entity & sameAs Disambiguation Auditor
|
|
1046
|
+
def self.handle_entity_command(target, options)
|
|
1047
|
+
raw = target || (Config.default_domain ? "https://#{Config.default_domain}" : nil)
|
|
1048
|
+
if raw.nil? || raw.empty?
|
|
1049
|
+
puts Color.c("ā Error: Target URL or domain required. Example: gsc entity https://mybrand.com", Color::RED)
|
|
1050
|
+
return
|
|
1051
|
+
end
|
|
1052
|
+
url = raw.to_s.start_with?('http') ? raw : "https://#{raw}"
|
|
1053
|
+
|
|
1054
|
+
puts "šļø Auditing Knowledge Graph Entity & sameAs Identity for #{Color.c(url, Color::CYAN)}...\n" unless options[:json]
|
|
1055
|
+
|
|
1056
|
+
data = GSC::EntityAuditor.audit(url)
|
|
1057
|
+
if data[:error]
|
|
1058
|
+
if options[:json]
|
|
1059
|
+
puts JSON.pretty_generate(data)
|
|
1060
|
+
else
|
|
1061
|
+
puts Color.c("ā Error: #{data[:error]}", Color::RED, Color::BOLD)
|
|
1062
|
+
end
|
|
1063
|
+
return
|
|
1064
|
+
end
|
|
1065
|
+
|
|
1066
|
+
if options[:json]
|
|
1067
|
+
puts JSON.pretty_generate(data)
|
|
1068
|
+
return
|
|
1069
|
+
end
|
|
1070
|
+
|
|
1071
|
+
puts BANNER unless options[:in_dashboard]
|
|
1072
|
+
puts "šļø #{Color::BOLD}KNOWLEDGE GRAPH ENTITY & sameAs DISAMBIGUATION AUDIT#{Color::RESET}"
|
|
1073
|
+
puts "šÆ Target URL : #{Color.c(data[:url], Color::CYAN)}"
|
|
1074
|
+
puts "ā" * 75
|
|
1075
|
+
|
|
1076
|
+
score_color = case data[:score]
|
|
1077
|
+
when 85..100 then Color::GREEN
|
|
1078
|
+
when 70..84 then Color::CYAN
|
|
1079
|
+
when 50..69 then Color::YELLOW
|
|
1080
|
+
else Color::RED
|
|
1081
|
+
end
|
|
1082
|
+
|
|
1083
|
+
puts "š #{Color::BOLD}ENTITY AUTHORITY SCORE:#{Color::RESET} #{Color.c("#{data[:score]}/100", score_color, Color::BOLD)} ā Grade #{data[:grade]}"
|
|
1084
|
+
puts "š¦ Detected Entities : #{data[:entities_found]}"
|
|
1085
|
+
puts "ā" * 75
|
|
1086
|
+
|
|
1087
|
+
if data[:findings].any?
|
|
1088
|
+
puts "\nš #{Color::BOLD}ENTITY SIGNALS DETECTED:#{Color::RESET}"
|
|
1089
|
+
data[:findings].each do |f|
|
|
1090
|
+
puts " ⢠#{Color.c(f, Color::GREEN)}"
|
|
1091
|
+
end
|
|
1092
|
+
end
|
|
1093
|
+
|
|
1094
|
+
if data[:authoritative_links].any?
|
|
1095
|
+
puts "\nš #{Color::BOLD}HIGH-AUTHORITY SAMEAS IDENTITY LINKS:#{Color::RESET}"
|
|
1096
|
+
data[:authoritative_links].each do |source, link|
|
|
1097
|
+
puts " ⢠#{source.to_s.capitalize.ljust(15)}: #{Color.c(link, Color::CYAN)}"
|
|
1098
|
+
end
|
|
1099
|
+
end
|
|
1100
|
+
|
|
1101
|
+
if data[:recommendations].any?
|
|
1102
|
+
puts "\nš” #{Color::BOLD}OPTIMIZATION DIRECTIVES:#{Color::RESET}"
|
|
1103
|
+
data[:recommendations].each_with_index do |r, i|
|
|
1104
|
+
puts " #{i + 1}. #{Color.c(r, Color::YELLOW)}"
|
|
1105
|
+
end
|
|
1106
|
+
end
|
|
1107
|
+
|
|
1108
|
+
if options[:generate] || data[:score] < 70
|
|
1109
|
+
puts "\nš #{Color::BOLD}RECOMMENDED ENHANCED JSON-LD SCHEMA SNIPPET:#{Color::RESET}"
|
|
1110
|
+
puts "<!-- Paste inside <head> on your homepage/about page -->"
|
|
1111
|
+
puts "<script type=\"application/ld+json\">"
|
|
1112
|
+
puts JSON.pretty_generate(data[:recommended_json_ld])
|
|
1113
|
+
puts "</script>"
|
|
1114
|
+
end
|
|
1115
|
+
puts ""
|
|
1116
|
+
end
|
|
1117
|
+
|
|
1118
|
+
# 16. Multi-Domain Agency Vault & Key Manager
|
|
1119
|
+
def self.handle_vault_command(subcmd, target, extra, options)
|
|
1120
|
+
action = subcmd.to_s.downcase
|
|
1121
|
+
action = 'list' if action.empty? || action == 'vault'
|
|
1122
|
+
|
|
1123
|
+
case action
|
|
1124
|
+
when 'status'
|
|
1125
|
+
stat = GSC::Vault.status
|
|
1126
|
+
if options[:json]
|
|
1127
|
+
puts JSON.pretty_generate(stat)
|
|
1128
|
+
else
|
|
1129
|
+
puts BANNER unless options[:in_dashboard]
|
|
1130
|
+
puts "š #{Color::BOLD}AGENCY CREDENTIAL VAULT SECURITY STATUS#{Color::RESET}"
|
|
1131
|
+
puts "ā" * 70
|
|
1132
|
+
puts " ⢠Vault Directory: #{stat[:vault_dir]}"
|
|
1133
|
+
puts " ⢠Encryption Algorithm: #{Color.c(stat[:encryption_algorithm], Color::GREEN, Color::BOLD)}"
|
|
1134
|
+
puts " ⢠Master Key Present: #{stat[:master_key_present] ? Color.c('Yes (Stored locally)', Color::GREEN) : Color.c('Generated on demand', Color::YELLOW)}"
|
|
1135
|
+
puts " ⢠Stored Client Keys: #{Color.c(stat[:keys_stored].to_s, Color::CYAN, Color::BOLD)}"
|
|
1136
|
+
puts " ⢠Domains Mapped: #{Color.c(stat[:domains_mapped].to_s, Color::CYAN)}"
|
|
1137
|
+
puts " ⢠POSIX 0700 Security: #{stat[:permissions_secure] ? Color.c('ENFORCED', Color::GREEN) : Color.c('PERMISSIVE', Color::YELLOW)}"
|
|
1138
|
+
puts " ⢠Active Target Domain: #{Color.c(stat[:active_domain] || 'None', Color::GREEN, Color::BOLD)}"
|
|
1139
|
+
puts "ā" * 70
|
|
1140
|
+
puts "š” Switch active client anytime: #{Color.c('gsc switch <domain|alias|#>', Color::CYAN)}"
|
|
1141
|
+
puts ""
|
|
1142
|
+
end
|
|
1143
|
+
|
|
1144
|
+
when 'add', 'import'
|
|
1145
|
+
key_path = target
|
|
1146
|
+
unless key_path && File.file?(File.expand_path(key_path))
|
|
1147
|
+
puts Color.c("ā Error: Valid service account JSON path required. Example: gsc vault add ./client-key.json --domain client.com", Color::RED)
|
|
1148
|
+
return
|
|
1149
|
+
end
|
|
1150
|
+
|
|
1151
|
+
domain = options[:domain] || extra
|
|
1152
|
+
alias_name = options[:alias]
|
|
1153
|
+
ga4_id = options[:property]
|
|
1154
|
+
|
|
1155
|
+
begin
|
|
1156
|
+
entry = GSC::Vault.add_key(key_path, domain: domain, alias_name: alias_name, ga4_id: ga4_id)
|
|
1157
|
+
if options[:json]
|
|
1158
|
+
puts JSON.pretty_generate({ status: 'ok', entry: entry })
|
|
1159
|
+
else
|
|
1160
|
+
puts Color.c("ā
Successfully encrypted and added key to Agency Vault!", Color::GREEN, Color::BOLD)
|
|
1161
|
+
puts " ⢠Client Email: #{Color.c(entry['client_email'], Color::CYAN)}"
|
|
1162
|
+
puts " ⢠Domain: #{Color.c(entry['domain'] || 'Not set', Color::GREEN)}"
|
|
1163
|
+
puts " ⢠Alias: #{Color.c(entry['alias'] || 'Not set', Color::YELLOW)}"
|
|
1164
|
+
puts " ⢠Key Vault: #{entry['key_file']} (AES-256-GCM Encrypted)"
|
|
1165
|
+
end
|
|
1166
|
+
rescue StandardError => e
|
|
1167
|
+
puts Color.c("ā Vault Error: #{e.message}", Color::RED)
|
|
1168
|
+
end
|
|
1169
|
+
|
|
1170
|
+
when 'remove', 'rm', 'delete'
|
|
1171
|
+
target_query = target
|
|
1172
|
+
unless target_query
|
|
1173
|
+
puts Color.c("ā Error: Specify domain or alias to remove. Example: gsc vault remove client.com", Color::RED)
|
|
1174
|
+
return
|
|
1175
|
+
end
|
|
1176
|
+
|
|
1177
|
+
ok = GSC::Vault.remove_entry(target_query)
|
|
1178
|
+
if ok
|
|
1179
|
+
puts Color.c("ā
Removed #{target_query} from Agency Vault.", Color::GREEN)
|
|
1180
|
+
else
|
|
1181
|
+
puts Color.c("ā ļø Entry not found in Agency Vault: #{target_query}", Color::YELLOW)
|
|
1182
|
+
end
|
|
1183
|
+
|
|
1184
|
+
when 'list', 'ls'
|
|
1185
|
+
entries = GSC::Vault.list_entries
|
|
1186
|
+
if options[:json]
|
|
1187
|
+
puts JSON.pretty_generate({ vault: GSC::Vault.status, entries: entries })
|
|
1188
|
+
return
|
|
1189
|
+
end
|
|
1190
|
+
|
|
1191
|
+
puts BANNER unless options[:in_dashboard]
|
|
1192
|
+
puts "š #{Color::BOLD}AGENCY CREDENTIAL VAULT (AES-256-GCM Secure Store)#{Color::RESET}"
|
|
1193
|
+
puts "ā" * 75
|
|
1194
|
+
|
|
1195
|
+
if entries.empty?
|
|
1196
|
+
puts Color.c("No client keys registered in Agency Vault yet.", Color::YELLOW)
|
|
1197
|
+
puts "To add a key: #{Color.c('gsc vault add /path/to/key.json --domain client.com --alias client', Color::CYAN)}"
|
|
1198
|
+
puts "ā" * 75
|
|
1199
|
+
return
|
|
1200
|
+
end
|
|
1201
|
+
|
|
1202
|
+
puts "#{Color::BOLD} # | Domain | Alias | Client Account / Email#{Color::RESET}"
|
|
1203
|
+
puts "ā" * 75
|
|
1204
|
+
entries.each_with_index do |e, i|
|
|
1205
|
+
num_str = "[#{i + 1}]".ljust(4)
|
|
1206
|
+
dom_str = (e['domain'] || 'unassigned').ljust(28)
|
|
1207
|
+
alias_str = (e['alias'] || '-').ljust(10)
|
|
1208
|
+
email_str = e['client_email'].to_s
|
|
1209
|
+
marker = e['active'] ? " #{Color.c('š [ACTIVE]', Color::GREEN, Color::BOLD)}" : ""
|
|
1210
|
+
|
|
1211
|
+
puts "#{Color.c(num_str, Color::CYAN)}| #{dom_str} | #{Color.c(alias_str, Color::YELLOW)} | #{email_str}#{marker}"
|
|
1212
|
+
end
|
|
1213
|
+
puts "ā" * 75
|
|
1214
|
+
puts "š” Switch active client anytime: #{Color.c('gsc switch <domain|alias|#>', Color::CYAN)}"
|
|
1215
|
+
puts ""
|
|
1216
|
+
else
|
|
1217
|
+
puts Color.c("Unknown vault action: #{action}. Valid actions: list, add, remove, status", Color::YELLOW)
|
|
1218
|
+
end
|
|
1219
|
+
end
|
|
1220
|
+
|
|
1221
|
+
# 18. Direct Answer & Information Gain Snippet Synthesizer
|
|
1222
|
+
def self.handle_answer_command(query, options = {})
|
|
1223
|
+
if query.nil? || query.to_s.strip.empty?
|
|
1224
|
+
puts Color.c("ā Error: Query required. Usage: gsc answer <query> [--brand <name>] [--type <definition|steps|comparison|faq>] [--generate] [--json]", Color::RED, Color::BOLD)
|
|
1225
|
+
return
|
|
1226
|
+
end
|
|
1227
|
+
|
|
1228
|
+
data = GSC::AnswerSynthesizer.synthesize(query, options)
|
|
1229
|
+
|
|
1230
|
+
if options[:json]
|
|
1231
|
+
puts JSON.pretty_generate(data)
|
|
1232
|
+
return
|
|
1233
|
+
end
|
|
1234
|
+
|
|
1235
|
+
puts BANNER unless options[:in_dashboard]
|
|
1236
|
+
puts "š£ļø #{Color::BOLD}DIRECT ANSWER & INFORMATION GAIN SNIPPET (GEO / AEO)#{Color::RESET}"
|
|
1237
|
+
puts "šÆ Target Query : #{Color.c("\"#{data[:query]}\"", Color::YELLOW, Color::BOLD)}"
|
|
1238
|
+
puts "š·ļø Intent Type : [#{Color.c(data[:intent_type].upcase, Color::MAGENTA, Color::BOLD)}]"
|
|
1239
|
+
puts "š Word Count : #{data[:word_count]} words (#{Color.c("Optimal: #{data[:target_optimal_range]}", Color::GREEN)})"
|
|
1240
|
+
puts "š Citability : #{Color.c("#{data[:citability_score]}/100", Color::GREEN, Color::BOLD)} (Optimized for Featured Snippets, ChatGPT & Perplexity)"
|
|
1241
|
+
puts "ā" * 75
|
|
1242
|
+
|
|
1243
|
+
puts "\n#{Color::BOLD}š¬ OPTIMIZED DIRECT ANSWER PARAGRAPH:#{Color::RESET}"
|
|
1244
|
+
puts " #{Color.c(data[:direct_answer], Color::BOLD)}"
|
|
1245
|
+
|
|
1246
|
+
puts "\n#{Color::BOLD}š STRUCTURED ATTRIBUTES & KEY POINTS:#{Color::RESET}"
|
|
1247
|
+
data[:key_points].each do |kp|
|
|
1248
|
+
clean_kp = kp.gsub(/\*\*(.*?)\*\*/, "#{Color::BOLD}\\1#{Color::RESET}")
|
|
1249
|
+
puts " ⢠#{clean_kp}"
|
|
1250
|
+
end
|
|
1251
|
+
|
|
1252
|
+
puts "\n#{Color::BOLD}š INFORMATION GAIN FACTOID:#{Color::RESET}"
|
|
1253
|
+
puts " š” #{Color.c(data[:information_gain_stat], Color::CYAN)}"
|
|
1254
|
+
|
|
1255
|
+
if options[:generate]
|
|
1256
|
+
puts "\n#{Color::BOLD}š» READY-TO-PASTE HTML SNIPPET:#{Color::RESET}"
|
|
1257
|
+
puts Color.c(data[:html_markup], Color::DIM)
|
|
1258
|
+
|
|
1259
|
+
puts "\n#{Color::BOLD}š SCHEMA.ORG JSON-LD SCRIPT BLOCK:#{Color::RESET}"
|
|
1260
|
+
puts Color.c("<script type=\"application/ld+json\">\n#{JSON.pretty_generate(data[:schema_jsonld])}\n</script>", Color::CYAN)
|
|
1261
|
+
else
|
|
1262
|
+
puts "\nš” #{Color.c('Tip: Pass --generate to print copy-paste HTML and Schema.org JSON-LD blocks.', Color::GRAY)}"
|
|
1263
|
+
end
|
|
1264
|
+
puts "ā" * 75 + "\n"
|
|
1265
|
+
end
|
|
1266
|
+
|
|
1267
|
+
# 19. AI Bot Robots.txt & Cloudflare Firewall Scanner
|
|
1268
|
+
def self.handle_firewall_command(target, options = {})
|
|
1269
|
+
scanner = GSC::FirewallScanner.new(target, options)
|
|
1270
|
+
data = scanner.scan
|
|
1271
|
+
|
|
1272
|
+
if options[:json]
|
|
1273
|
+
puts JSON.pretty_generate(data)
|
|
1274
|
+
return
|
|
1275
|
+
end
|
|
1276
|
+
|
|
1277
|
+
puts BANNER unless options[:in_dashboard]
|
|
1278
|
+
puts "š”ļø #{Color::BOLD}AI BOT ACCESSIBILITY & CLOUDFLARE/WAF FIREWALL AUDITOR#{Color::RESET}"
|
|
1279
|
+
puts "šÆ Target URL : #{Color.c(data[:url], Color::YELLOW, Color::BOLD)}"
|
|
1280
|
+
puts "š Hostname : #{data[:host]}"
|
|
1281
|
+
|
|
1282
|
+
# Edge Infrastructure
|
|
1283
|
+
edge_names = data[:edge_infrastructure].map { |i| "#{i[:name]} (#{i[:role]})" }.join(' | ')
|
|
1284
|
+
puts "āļø Edge CDN/WAF : #{Color.c(edge_names, Color::CYAN)}"
|
|
1285
|
+
|
|
1286
|
+
# Score & Grade
|
|
1287
|
+
grade_color = case data[:grade]
|
|
1288
|
+
when 'A' then Color::GREEN
|
|
1289
|
+
when 'B' then Color::CYAN
|
|
1290
|
+
when 'C' then Color::YELLOW
|
|
1291
|
+
when 'D' then Color::RED
|
|
1292
|
+
else Color::RED
|
|
1293
|
+
end
|
|
1294
|
+
|
|
1295
|
+
score_str = "#{data[:score]}/100 [Grade #{data[:grade]}]"
|
|
1296
|
+
puts "š AI Access : #{Color.c(score_str, Color::BOLD, grade_color)} - #{data[:verdict]}"
|
|
1297
|
+
puts "ā" * 80
|
|
1298
|
+
|
|
1299
|
+
# Section 1: Live HTTP Probes
|
|
1300
|
+
puts "\n#{Color::BOLD}š” LIVE EDGE WAF MULTI-USER-AGENT PROBES (LIVE PASS-THROUGH):#{Color::RESET}"
|
|
1301
|
+
puts "#{Color::BOLD}%-18s %-24s %-12s %-10s %-14s %s#{Color::RESET}" % ['Persona', 'Client Name', 'HTTP Status', 'Latency', 'WAF Verdict', 'Challenge / Block']
|
|
1302
|
+
puts "ā" * 80
|
|
1303
|
+
|
|
1304
|
+
data[:live_probes].each do |probe|
|
|
1305
|
+
status_col = case probe[:status]
|
|
1306
|
+
when 200..299 then Color.c("#{probe[:status]} OK", Color::GREEN)
|
|
1307
|
+
when 300..399 then Color.c("#{probe[:status]} REDIR", Color::YELLOW)
|
|
1308
|
+
when 403 then Color.c("403 FORBID", Color::RED, Color::BOLD)
|
|
1309
|
+
when 503 then Color.c("503 UNAVAIL", Color::RED, Color::BOLD)
|
|
1310
|
+
else Color.c(probe[:status].to_s, Color::RED)
|
|
1311
|
+
end
|
|
1312
|
+
|
|
1313
|
+
verdict_col = case probe[:verdict]
|
|
1314
|
+
when :pass then Color.c("PASS", Color::GREEN)
|
|
1315
|
+
when :redirect then Color.c("REDIRECT", Color::YELLOW)
|
|
1316
|
+
when :challenge then Color.c("CHALLENGE", Color::RED, Color::BOLD)
|
|
1317
|
+
when :blocked then Color.c("BLOCKED", Color::RED, Color::BOLD)
|
|
1318
|
+
else Color.c("ERROR", Color::RED)
|
|
1319
|
+
end
|
|
1320
|
+
|
|
1321
|
+
challenges_str = probe[:challenges].empty? ? '-' : probe[:challenges].join(', ')
|
|
1322
|
+
challenges_col = probe[:challenges].empty? ? Color.c(challenges_str, Color::GRAY) : Color.c(challenges_str, Color::RED, Color::BOLD)
|
|
1323
|
+
|
|
1324
|
+
puts "%-18s %-24s %-21s %-10s %-23s %s" % [
|
|
1325
|
+
probe[:persona],
|
|
1326
|
+
probe[:name],
|
|
1327
|
+
status_col,
|
|
1328
|
+
"#{probe[:duration_ms]}ms",
|
|
1329
|
+
verdict_col,
|
|
1330
|
+
challenges_col
|
|
1331
|
+
]
|
|
1332
|
+
end
|
|
1333
|
+
|
|
1334
|
+
# Section 2: Robots.txt Crawler Governance
|
|
1335
|
+
puts "\n#{Color::BOLD}š¤ ROBOTS.TXT AI SEARCH & TRAINING CRAWLER DIRECTIVES:#{Color::RESET}"
|
|
1336
|
+
puts "#{Color::BOLD}%-20s %-15s %-12s %-12s %s#{Color::RESET}" % ['Bot User-Agent', 'Provider', 'Role', 'Status', 'Matched Rule / Reason']
|
|
1337
|
+
puts "ā" * 80
|
|
1338
|
+
|
|
1339
|
+
data[:robots_txt][:bots].each do |bot_name, bot_info|
|
|
1340
|
+
status_col = case bot_info[:status]
|
|
1341
|
+
when :allowed then Color.c("ALLOWED", Color::GREEN, Color::BOLD)
|
|
1342
|
+
when :restricted then Color.c("RESTRICTED", Color::YELLOW)
|
|
1343
|
+
when :blocked then Color.c("BLOCKED", Color::RED, Color::BOLD)
|
|
1344
|
+
else Color.c("UNKNOWN", Color::GRAY)
|
|
1345
|
+
end
|
|
1346
|
+
|
|
1347
|
+
category_label = bot_info[:category].to_s.sub('_', ' ').capitalize
|
|
1348
|
+
|
|
1349
|
+
puts "%-20s %-15s %-12s %-21s %s" % [
|
|
1350
|
+
bot_name,
|
|
1351
|
+
bot_info[:provider],
|
|
1352
|
+
category_label,
|
|
1353
|
+
status_col,
|
|
1354
|
+
Color.c(bot_info[:reason].to_s, Color::GRAY)
|
|
1355
|
+
]
|
|
1356
|
+
end
|
|
1357
|
+
|
|
1358
|
+
# Section 3: Blockade Diagnosis
|
|
1359
|
+
puts "\n#{Color::BOLD}š SILENT BOT BLOCKADE DIAGNOSIS:#{Color::RESET}"
|
|
1360
|
+
diag = data[:blockade_diagnosis]
|
|
1361
|
+
if diag[:silent_blockade_detected]
|
|
1362
|
+
puts Color.c(" šØ #{diag[:summary]}", Color::RED, Color::BOLD)
|
|
1363
|
+
puts " ā ļø Browser pass: #{diag[:browser_accessible]} | Googlebot pass: #{diag[:googlebot_accessible]}"
|
|
1364
|
+
puts " ā Blocked AI crawlers: #{diag[:blocked_probes].map { |p| "#{p[:name]} (#{p[:status]})" }.join(', ')}"
|
|
1365
|
+
else
|
|
1366
|
+
puts Color.c(" #{diag[:summary]}", Color::GREEN)
|
|
1367
|
+
end
|
|
1368
|
+
|
|
1369
|
+
# Section 4: Prescriptive WAF & Robots.txt Remediation Recipes
|
|
1370
|
+
recipes = data[:remediation_recipes]
|
|
1371
|
+
if recipes[:cloudflare_waf_rule]
|
|
1372
|
+
puts "\n#{Color::BOLD}š ļø RECOMMENDED CLOUDFLARE CUSTOM WAF BYPASS RULE:#{Color::RESET}"
|
|
1373
|
+
puts " Rule Name: #{Color.c(recipes[:cloudflare_waf_rule][:name], Color::CYAN, Color::BOLD)}"
|
|
1374
|
+
puts " Expression:\n #{Color.c(recipes[:cloudflare_waf_rule][:filter_expression], Color::YELLOW)}"
|
|
1375
|
+
puts " Action: #{Color.c('Skip', Color::GREEN, Color::BOLD)} (Skip WAF Managed Rules, Super Bot Fight Mode, Rate Limiting)"
|
|
1376
|
+
puts "\n #{Color::BOLD}Dashboard Deployment Steps:#{Color::RESET}"
|
|
1377
|
+
recipes[:cloudflare_waf_rule][:dashboard_instructions].each do |step|
|
|
1378
|
+
puts " #{Color.c(step, Color::GRAY)}"
|
|
1379
|
+
end
|
|
1380
|
+
end
|
|
1381
|
+
|
|
1382
|
+
if options[:generate] || diag[:silent_blockade_detected] || diag[:robots_critical_blocked].any?
|
|
1383
|
+
puts "\n#{Color::BOLD}š RECOMMENDED ROBOTS.TXT CONFIGURATION FOR AI SEARCH:#{Color::RESET}"
|
|
1384
|
+
puts Color.c(recipes[:recommended_robots_txt], Color::DIM)
|
|
1385
|
+
else
|
|
1386
|
+
puts "\nš” #{Color.c('Tip: Pass --generate to display copy-paste robots.txt configuration.', Color::GRAY)}"
|
|
1387
|
+
end
|
|
1388
|
+
|
|
1389
|
+
puts "ā" * 80 + "\n"
|
|
1390
|
+
end
|
|
1391
|
+
|
|
1392
|
+
# 20. Title Tag Length & Pixel Width Batch Optimizer
|
|
1393
|
+
def self.handle_titles_command(target, options = {})
|
|
1394
|
+
target_site = target.to_s.strip
|
|
1395
|
+
target_site = Config.default_domain || '' if target_site.empty?
|
|
1396
|
+
if target_site.empty?
|
|
1397
|
+
puts Color.c("ā Error: Target domain or sitemap required. Example: gsc titles mybrand.com", Color::RED)
|
|
1398
|
+
return
|
|
1399
|
+
end
|
|
1400
|
+
|
|
1401
|
+
optimizer = GSC::TitleOptimizer.new(target_site, options)
|
|
1402
|
+
|
|
1403
|
+
unless options[:json] || options[:csv]
|
|
1404
|
+
puts BANNER unless options[:in_dashboard]
|
|
1405
|
+
puts "š #{Color::BOLD}GOOGLE SERP TITLE LENGTH & PIXEL OVERFLOW AUDITOR#{Color::RESET}"
|
|
1406
|
+
puts "šÆ Target: #{Color.c(target_site, Color::YELLOW, Color::BOLD)}"
|
|
1407
|
+
print "š Crawling and calculating pixel metrics across pages... "
|
|
1408
|
+
$stdout.flush
|
|
1409
|
+
end
|
|
1410
|
+
|
|
1411
|
+
data = optimizer.audit do |_url, current, total|
|
|
1412
|
+
unless options[:json] || options[:csv]
|
|
1413
|
+
print "\rš Crawling and calculating pixel metrics across pages... [#{current}/#{total}]"
|
|
1414
|
+
$stdout.flush
|
|
1415
|
+
end
|
|
1416
|
+
end
|
|
1417
|
+
|
|
1418
|
+
unless options[:json] || options[:csv]
|
|
1419
|
+
puts " Done!\n"
|
|
1420
|
+
end
|
|
1421
|
+
|
|
1422
|
+
if options[:json]
|
|
1423
|
+
puts JSON.pretty_generate(data)
|
|
1424
|
+
return
|
|
1425
|
+
end
|
|
1426
|
+
|
|
1427
|
+
if options[:csv]
|
|
1428
|
+
require 'csv'
|
|
1429
|
+
puts "URL,Characters,Pixel_Width,Status,Current_Title,Desktop_Preview,Suggested_Rewrite_1,Suggested_Rewrite_2"
|
|
1430
|
+
data[:pages].each do |p|
|
|
1431
|
+
r1 = p[:suggested_rewrites][0] ? p[:suggested_rewrites][0][:title].gsub('"', '""') : ''
|
|
1432
|
+
r2 = p[:suggested_rewrites][1] ? p[:suggested_rewrites][1][:title].gsub('"', '""') : ''
|
|
1433
|
+
title_clean = p[:title].to_s.gsub('"', '""')
|
|
1434
|
+
prev_clean = p[:desktop_preview].to_s.gsub('"', '""')
|
|
1435
|
+
puts "\"#{p[:url]}\",#{p[:char_count]},#{p[:pixel_width]},#{p[:status]},\"#{title_clean}\",\"#{prev_clean}\",\"#{r1}\",\"#{r2}\""
|
|
1436
|
+
end
|
|
1437
|
+
return
|
|
1438
|
+
end
|
|
1439
|
+
|
|
1440
|
+
# Health Grade & Color
|
|
1441
|
+
grade_color = case data[:grade]
|
|
1442
|
+
when 'A' then Color::GREEN
|
|
1443
|
+
when 'B' then Color::CYAN
|
|
1444
|
+
when 'C' then Color::YELLOW
|
|
1445
|
+
when 'D' then Color::RED
|
|
1446
|
+
else Color::RED
|
|
1447
|
+
end
|
|
1448
|
+
|
|
1449
|
+
puts "š Title Health : #{Color.c("#{data[:health_score]}/100 [Grade #{data[:grade]}]", Color::BOLD, grade_color)} - #{data[:verdict]}"
|
|
1450
|
+
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)}"
|
|
1451
|
+
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"
|
|
1452
|
+
puts "ā" * 80
|
|
1453
|
+
|
|
1454
|
+
pages_to_display = options[:overflow_only] ? data[:pages].select { |p| [:desktop_overflow, :critical_overflow].include?(p[:status]) } : data[:pages]
|
|
1455
|
+
|
|
1456
|
+
if pages_to_display.empty?
|
|
1457
|
+
puts "\nš #{Color.c('All audited pages have optimal title tags with zero pixel overflow!', Color::GREEN, Color::BOLD)}"
|
|
1458
|
+
else
|
|
1459
|
+
puts "\n#{Color::BOLD}%-16s %-7s %-10s %s#{Color::RESET}" % ['Status', 'Chars', 'Width', 'URL & Title Tag']
|
|
1460
|
+
puts "ā" * 80
|
|
1461
|
+
|
|
1462
|
+
pages_to_display.each do |page|
|
|
1463
|
+
status_col = case page[:status]
|
|
1464
|
+
when :optimal then Color.c("OPTIMAL", Color::GREEN)
|
|
1465
|
+
when :desktop_overflow then Color.c("DESK OVERFLOW", Color::YELLOW, Color::BOLD)
|
|
1466
|
+
when :critical_overflow then Color.c("CRIT OVERFLOW", Color::RED, Color::BOLD)
|
|
1467
|
+
when :too_short then Color.c("TOO SHORT", Color::CYAN)
|
|
1468
|
+
when :missing then Color.c("MISSING", Color::RED, Color::BOLD)
|
|
1469
|
+
end
|
|
1470
|
+
|
|
1471
|
+
px_str = "#{page[:pixel_width]}px"
|
|
1472
|
+
px_col = page[:pixel_width] > 580.0 ? Color.c(px_str, Color::RED, Color::BOLD) : Color.c(px_str, Color::GREEN)
|
|
1473
|
+
|
|
1474
|
+
puts "%-23s %-7s %-18s %s" % [
|
|
1475
|
+
status_col,
|
|
1476
|
+
"#{page[:char_count]}c",
|
|
1477
|
+
px_col,
|
|
1478
|
+
Color.c(page[:url], Color::BOLD)
|
|
1479
|
+
]
|
|
1480
|
+
puts " ā³ #{Color.c(page[:title].empty? ? '(No Title Tag)' : page[:title], Color::DIM)}"
|
|
1481
|
+
end
|
|
1482
|
+
end
|
|
1483
|
+
|
|
1484
|
+
# Section: Title Rewrite Optimization Playbook
|
|
1485
|
+
overflowing_pages = data[:pages].reject { |p| p[:suggested_rewrites].empty? }
|
|
1486
|
+
|
|
1487
|
+
if overflowing_pages.any?
|
|
1488
|
+
puts "\n\n#{Color::BOLD}š TITLE REWRITE & CTR OPTIMIZATION PLAYBOOK (#{overflowing_pages.size} Pages Flagged):#{Color::RESET}"
|
|
1489
|
+
puts "ā" * 80
|
|
1490
|
+
|
|
1491
|
+
overflowing_pages.first(8).each_with_index do |p, idx|
|
|
1492
|
+
puts "\n#{Color.c("[#{idx + 1}]", Color::CYAN, Color::BOLD)} #{Color.c(p[:url], Color::BOLD)}"
|
|
1493
|
+
puts " ā Current Title : #{Color.c("\"#{p[:title]}\"", Color::RED)} (#{p[:pixel_width]}px, #{p[:char_count]} chars)"
|
|
1494
|
+
puts " š Desktop SERP : #{Color.c(p[:desktop_preview], Color::GRAY)}"
|
|
1495
|
+
|
|
1496
|
+
puts " š” #{Color::BOLD}Synthesized Non-Truncating Title Rewrites (<560px):#{Color::RESET}"
|
|
1497
|
+
p[:suggested_rewrites].each do |rewrite|
|
|
1498
|
+
puts " ⢠[#{Color.c(rewrite[:type], Color::YELLOW)}] #{Color.c(rewrite[:title], Color::GREEN, Color::BOLD)}"
|
|
1499
|
+
puts " ā³ #{rewrite[:pixel_width]}px | #{rewrite[:char_count]} chars | Desktop SERP: #{Color.c('FITS CLEANLY', Color::GREEN)}"
|
|
1500
|
+
end
|
|
1501
|
+
end
|
|
1502
|
+
|
|
1503
|
+
if overflowing_pages.size > 8
|
|
1504
|
+
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."
|
|
1505
|
+
end
|
|
1506
|
+
end
|
|
1507
|
+
|
|
1508
|
+
puts "\n" + ("ā" * 80) + "\n"
|
|
1509
|
+
end
|
|
1510
|
+
end
|
|
1511
|
+
end
|
|
1512
|
+
|
|
1513
|
+
|