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,650 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
require_relative 'base'
|
|
5
|
+
require_relative '../striking_playbook' if File.exist?(File.expand_path('../striking_playbook.rb', __dir__))
|
|
6
|
+
require_relative '../answer_synthesizer' if File.exist?(File.expand_path('../answer_synthesizer.rb', __dir__))
|
|
7
|
+
require_relative '../serp_feature_detector' if File.exist?(File.expand_path('../serp_feature_detector.rb', __dir__))
|
|
8
|
+
require_relative '../serp_preview' if File.exist?(File.expand_path('../serp_preview.rb', __dir__))
|
|
9
|
+
require_relative '../cannibalization_analyzer' if File.exist?(File.expand_path('../cannibalization_analyzer.rb', __dir__))
|
|
10
|
+
require_relative '../questions_harvester' if File.exist?(File.expand_path('../questions_harvester.rb', __dir__))
|
|
11
|
+
require_relative '../sitemap_loader' if File.exist?(File.expand_path('../sitemap_loader.rb', __dir__))
|
|
12
|
+
require_relative 'low_ctr' if File.exist?(File.expand_path('low_ctr.rb', __dir__))
|
|
13
|
+
|
|
14
|
+
module GSC
|
|
15
|
+
class CLI
|
|
16
|
+
module Growth
|
|
17
|
+
module_function
|
|
18
|
+
|
|
19
|
+
def run(command, target, extra, options, api, site_url, hostname, https_origin)
|
|
20
|
+
case command
|
|
21
|
+
when 'strike', 'striker', 'striking-playbook'
|
|
22
|
+
handle_strike(api, site_url, options)
|
|
23
|
+
when 'answer', 'direct-answer', 'aeo-snippet', 'info-gain', 'ans'
|
|
24
|
+
handle_answer(target, options)
|
|
25
|
+
when 'serp-features', 'sf', 'serp-live', 'features'
|
|
26
|
+
handle_serp_features(target, options)
|
|
27
|
+
when 'preview', 'serp', 'serp-preview', 'social-preview'
|
|
28
|
+
handle_preview(target, options)
|
|
29
|
+
when 'opportunities', 'striking-distance', 'quick-wins', 'o', 'opp'
|
|
30
|
+
handle_opportunities(api, site_url, options)
|
|
31
|
+
when 'underperformers', 'ctr-underperformers', 'ctr-gaps', 'u'
|
|
32
|
+
handle_underperformers(api, site_url, options)
|
|
33
|
+
when 'low-ctr', 'ctr-rewrite', 'lost-clicks', 'rewrite-titles', 'ctr-fix', 'lowctr', 'lc'
|
|
34
|
+
LowCtr.run(command, target, extra, options, api, site_url, hostname)
|
|
35
|
+
when 'cannibalization', 'conflicts', 'c'
|
|
36
|
+
handle_cannibalization(api, site_url, options)
|
|
37
|
+
when 'questions-harvest', 'harvest-questions', 'qh', 'faq-harvest'
|
|
38
|
+
handle_questions_harvest(target, api, site_url, options)
|
|
39
|
+
when 'zombies', 'bloat', 'z'
|
|
40
|
+
handle_zombies(target, api, site_url, https_origin, options)
|
|
41
|
+
else
|
|
42
|
+
raise "Unknown growth command: #{command}"
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def handle_strike(api, site_url, options)
|
|
47
|
+
puts Base::BANNER unless options[:json] || options[:in_dashboard]
|
|
48
|
+
puts "🎯 Generating Striking-Distance Playbook for: #{Color.c(site_url, Color::CYAN)}...\n" unless options[:json]
|
|
49
|
+
|
|
50
|
+
data = StrikingPlaybook.generate(api, site_url, options)
|
|
51
|
+
|
|
52
|
+
if options[:json]
|
|
53
|
+
puts JSON.pretty_generate(data)
|
|
54
|
+
return
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
if data[:playbooks].empty?
|
|
58
|
+
puts Color.c("ℹ️ No striking-distance opportunities found (Pos #{data[:filters][:min_pos]}–#{data[:filters][:max_pos]}, Min #{data[:filters][:min_imp]} imp).", Color::YELLOW)
|
|
59
|
+
puts "💡 Tip: Lower --min-imp (e.g. gsc strike --min-imp 5) or increase --days 90."
|
|
60
|
+
return
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
puts "╔══════════════════════════════════════════════════════════════════════════════╗"
|
|
64
|
+
puts "║ 🚀 STRIKING-DISTANCE PLAYBOOK (POSITIONS 7–20 TO TOP 3) ║"
|
|
65
|
+
puts "╚══════════════════════════════════════════════════════════════════════════════╝"
|
|
66
|
+
puts "📍 Property: #{Color.c(data[:site_url], Color::CYAN, Color::BOLD)}"
|
|
67
|
+
puts "📈 Projected Gain: #{Color.c("+#{data[:projected_monthly_clicks]} clicks/mo", Color::GREEN, Color::BOLD)} (Across top #{data[:playbooks].size} targets)"
|
|
68
|
+
puts "🔍 Opportunities: #{data[:total_opportunities_found]} keywords detected in striking range"
|
|
69
|
+
puts "─" * 80
|
|
70
|
+
|
|
71
|
+
data[:playbooks].each_with_index do |p, idx|
|
|
72
|
+
tier_badge = case p[:tier]
|
|
73
|
+
when :tier_1_expedite
|
|
74
|
+
Color.c("⚡ [TIER 1: EXPEDITE - BRINK OF TOP 5]", Color::GREEN, Color::BOLD)
|
|
75
|
+
when :tier_2_strike
|
|
76
|
+
Color.c("🎯 [TIER 2: HIGH LEVERAGE STRIKE]", Color::CYAN, Color::BOLD)
|
|
77
|
+
else
|
|
78
|
+
Color.c("🧗 [TIER 3: FOUNDATION CLIMB]", Color::YELLOW)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
page_short = p[:page].sub(%r{^https?://[^/]+}, '')
|
|
82
|
+
page_short = '/' if page_short.empty?
|
|
83
|
+
|
|
84
|
+
puts "\n#{Color::BOLD}#{idx + 1}. KEYWORD: #{Color.c("\"#{p[:query]}\"", Color::YELLOW, Color::BOLD)} #{tier_badge}#{Color::RESET}"
|
|
85
|
+
puts " Target Page: #{Color.c(page_short, Color::CYAN)} (#{p[:page]})"
|
|
86
|
+
puts " Current Metrics: Pos #{Color.c(p[:position].to_s, Color::YELLOW, Color::BOLD)} | #{p[:impressions]} imp | #{p[:clicks]} clicks (#{p[:ctr]}% CTR)"
|
|
87
|
+
puts " Projected Surge: #{Color.c("+#{p[:projected_gain]} clicks/month", Color::GREEN, Color::BOLD)} upon ranking Top 3"
|
|
88
|
+
|
|
89
|
+
puts "\n #{Color::BOLD}🏷️ Title Tag Rewrites (Pick one):#{Color::RESET}"
|
|
90
|
+
p[:title_rewrites].each do |t|
|
|
91
|
+
puts " • #{Color.c(t, Color::BOLD)} (#{t.length} chars)"
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
puts "\n #{Color::BOLD}🌲 Heading Injection Recipe:#{Color::RESET}"
|
|
95
|
+
puts " H2: #{Color.c(p[:heading_recipes][:h2], Color::MAGENTA)}"
|
|
96
|
+
p[:heading_recipes][:h3s].each do |h3|
|
|
97
|
+
puts " └── H3: #{h3}"
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
puts "\n #{Color::BOLD}🔗 Internal Link Anchors (Deploy 3 links across site):#{Color::RESET}"
|
|
101
|
+
puts " • Exact Match: \"#{Color.c(p[:internal_link_anchors][:exact], Color::CYAN)}\""
|
|
102
|
+
puts " • Partial Match: \"#{p[:internal_link_anchors][:partial]}\""
|
|
103
|
+
puts " • Branded: \"#{p[:internal_link_anchors][:branded]}\""
|
|
104
|
+
|
|
105
|
+
puts "\n #{Color::BOLD}❓ High-Information Gain FAQ Snippet (Paste into page):#{Color::RESET}"
|
|
106
|
+
puts " Q: #{Color.c(p[:faq_snippet][:question], Color::BOLD)}"
|
|
107
|
+
puts " A: #{p[:faq_snippet][:answer]}"
|
|
108
|
+
|
|
109
|
+
puts "\n #{Color::BOLD}📋 Action Checklist:#{Color::RESET}"
|
|
110
|
+
p[:action_checklist].each do |task|
|
|
111
|
+
puts " [ ] #{task}"
|
|
112
|
+
end
|
|
113
|
+
puts "\n" + ("─" * 80)
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
if options[:csv]
|
|
117
|
+
csv_data = data[:playbooks].map do |p|
|
|
118
|
+
[p[:query], p[:page], p[:position], p[:impressions], p[:clicks], p[:ctr], p[:projected_gain], p[:tier], p[:title_rewrites].first]
|
|
119
|
+
end
|
|
120
|
+
Base.write_csv(options[:csv], %w[Query Page Position Impressions Clicks CTR ProjectedGain Tier RecommendedTitle], csv_data)
|
|
121
|
+
puts Color.c("📁 Exported striking playbook to #{options[:csv]}", Color::CYAN)
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def handle_answer(query, options = {})
|
|
126
|
+
if query.nil? || query.to_s.strip.empty?
|
|
127
|
+
if options[:json]
|
|
128
|
+
puts JSON.pretty_generate({ error: 'Query required. Usage: gsc answer <query> [--brand <name>] [--type <definition|steps|comparison|faq>] [--generate] [--json]' })
|
|
129
|
+
else
|
|
130
|
+
puts Color.c("❌ Error: Query required. Usage: gsc answer <query> [--brand <name>] [--type <definition|steps|comparison|faq>] [--generate] [--json]", Color::RED, Color::BOLD)
|
|
131
|
+
end
|
|
132
|
+
return
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
data = GSC::AnswerSynthesizer.synthesize(query, options)
|
|
136
|
+
|
|
137
|
+
if options[:json]
|
|
138
|
+
puts JSON.pretty_generate(data)
|
|
139
|
+
return
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
puts Base::BANNER unless options[:in_dashboard]
|
|
143
|
+
puts "🗣️ #{Color::BOLD}DIRECT ANSWER & INFORMATION GAIN SNIPPET (GEO / AEO)#{Color::RESET}"
|
|
144
|
+
puts "🎯 Target Query : #{Color.c("\"#{data[:query]}\"", Color::YELLOW, Color::BOLD)}"
|
|
145
|
+
puts "🏷️ Intent Type : [#{Color.c(data[:intent_type].upcase, Color::MAGENTA, Color::BOLD)}]"
|
|
146
|
+
puts "📊 Word Count : #{data[:word_count]} words (#{Color.c("Optimal: #{data[:target_optimal_range]}", Color::GREEN)})"
|
|
147
|
+
puts "🏆 Citability : #{Color.c("#{data[:citability_score]}/100", Color::GREEN, Color::BOLD)} (Optimized for Featured Snippets, ChatGPT & Perplexity)"
|
|
148
|
+
puts "─" * 75
|
|
149
|
+
|
|
150
|
+
puts "\n#{Color::BOLD}💬 OPTIMIZED DIRECT ANSWER PARAGRAPH:#{Color::RESET}"
|
|
151
|
+
puts " #{Color.c(data[:direct_answer], Color::BOLD)}"
|
|
152
|
+
|
|
153
|
+
puts "\n#{Color::BOLD}📌 STRUCTURED ATTRIBUTES & KEY POINTS:#{Color::RESET}"
|
|
154
|
+
data[:key_points].each do |kp|
|
|
155
|
+
clean_kp = kp.gsub(/\*\*(.*?)\*\*/, "#{Color::BOLD}\\1#{Color::RESET}")
|
|
156
|
+
puts " • #{clean_kp}"
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
puts "\n#{Color::BOLD}📈 INFORMATION GAIN FACTOID:#{Color::RESET}"
|
|
160
|
+
puts " 💡 #{Color.c(data[:information_gain_stat], Color::CYAN)}"
|
|
161
|
+
|
|
162
|
+
if options[:generate]
|
|
163
|
+
puts "\n#{Color::BOLD}💻 READY-TO-PASTE HTML SNIPPET:#{Color::RESET}"
|
|
164
|
+
puts Color.c(data[:html_markup], Color::DIM)
|
|
165
|
+
|
|
166
|
+
puts "\n#{Color::BOLD}💎 SCHEMA.ORG JSON-LD SCRIPT BLOCK:#{Color::RESET}"
|
|
167
|
+
puts Color.c("<script type=\"application/ld+json\">\n#{JSON.pretty_generate(data[:schema_jsonld])}\n</script>", Color::CYAN)
|
|
168
|
+
else
|
|
169
|
+
puts "\n💡 #{Color.c('Tip: Pass --generate to print copy-paste HTML and Schema.org JSON-LD blocks.', Color::GRAY)}"
|
|
170
|
+
end
|
|
171
|
+
puts "─" * 75 + "\n"
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
def handle_serp_features(query, options)
|
|
175
|
+
query_str = query.to_s.sub(/^(query|live):/, '').strip
|
|
176
|
+
if query_str.empty?
|
|
177
|
+
if options[:json]
|
|
178
|
+
puts JSON.pretty_generate({ error: 'Search query required. Example: gsc serp-features "how to speed up rails"' })
|
|
179
|
+
else
|
|
180
|
+
puts Color.c("❌ Error: Search query required. Example: gsc serp-features \"how to speed up rails\"", Color::RED, Color::BOLD)
|
|
181
|
+
end
|
|
182
|
+
return
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
detector = GSC::SerpFeatureDetector.new(query_str, options)
|
|
186
|
+
data = detector.detect
|
|
187
|
+
|
|
188
|
+
if options[:json]
|
|
189
|
+
puts JSON.pretty_generate(data)
|
|
190
|
+
return
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
puts Base::BANNER unless options[:in_dashboard]
|
|
194
|
+
puts "🔍 #{Color::BOLD}LIVE SERP FEATURE & ZERO-CLICK THREAT DETECTOR:#{Color::RESET} #{Color.c(query_str, Color::CYAN, Color::BOLD)}"
|
|
195
|
+
puts "─" * 80
|
|
196
|
+
|
|
197
|
+
zt = data[:zero_click_threat] || {}
|
|
198
|
+
threat_color = case zt[:level]
|
|
199
|
+
when 'LOW' then Color::GREEN
|
|
200
|
+
when 'MODERATE' then Color::YELLOW
|
|
201
|
+
else Color::RED
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
puts "🎯 Search Intent: #{Color.c(data.dig(:intent, :primary).to_s, Color::BOLD)} (#{data.dig(:intent, :description)})"
|
|
205
|
+
puts "🚨 Zero-Click Threat: #{Color.c("#{zt[:level]} (#{zt[:score]}/100)", threat_color, Color::BOLD)} [Organic CTR Impact: #{zt[:estimated_organic_ctr_suppression]}]"
|
|
206
|
+
puts "─" * 80
|
|
207
|
+
|
|
208
|
+
f = data[:features] || {}
|
|
209
|
+
puts "\n#{Color::BOLD}📊 DETECTED SERP REAL ESTATE FEATURES:#{Color::RESET}"
|
|
210
|
+
|
|
211
|
+
aio = f[:ai_overview] || {}
|
|
212
|
+
aio_badge = aio[:detected] ? Color.c("ACTIVE (#{aio[:probability_pct]}% prob)", Color::RED, Color::BOLD) : Color.c("Unlikely (#{aio[:probability_pct]}%)", Color::GRAY)
|
|
213
|
+
puts " • Google AI Overview (Gemini) : [#{aio_badge}]"
|
|
214
|
+
puts " └─ #{Color.c(aio[:impact], Color::DIM)}" if aio[:detected]
|
|
215
|
+
|
|
216
|
+
fs = f[:featured_snippet] || {}
|
|
217
|
+
fs_badge = fs[:detected] ? Color.c("OPPORTUNITY (#{fs[:target_format].capitalize} - #{fs[:probability_pct]}%)", Color::GREEN, Color::BOLD) : Color.c("None", Color::GRAY)
|
|
218
|
+
puts " • Featured Snippet Box : [#{fs_badge}]"
|
|
219
|
+
puts " └─ Target Recipe: #{Color.c(fs[:capture_prescription], Color::CYAN)}" if fs[:detected]
|
|
220
|
+
|
|
221
|
+
paa = f[:people_also_ask] || {}
|
|
222
|
+
paa_badge = paa[:detected] ? Color.c("#{paa[:count]} Live Questions Extracted", Color::CYAN, Color::BOLD) : Color.c("None", Color::GRAY)
|
|
223
|
+
puts " • People Also Ask (PAA) : [#{paa_badge}]"
|
|
224
|
+
if paa[:detected] && (paa[:questions] || []).any?
|
|
225
|
+
paa[:questions].first(5).each do |q_text|
|
|
226
|
+
puts " ❓ \"#{Color.c(q_text, Color::YELLOW)}\""
|
|
227
|
+
end
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
lp = f[:local_3_pack] || {}
|
|
231
|
+
lp_badge = lp[:detected] ? Color.c("DETECTED (Google Maps 3-Pack)", Color::YELLOW, Color::BOLD) : Color.c("None", Color::GRAY)
|
|
232
|
+
puts " • Local Maps 3-Pack : [#{lp_badge}]"
|
|
233
|
+
|
|
234
|
+
vc = f[:video_carousel] || {}
|
|
235
|
+
vc_badge = vc[:detected] ? Color.c("DETECTED (Video Carousel)", Color::YELLOW, Color::BOLD) : Color.c("None", Color::GRAY)
|
|
236
|
+
puts " • Video Carousel / YouTube : [#{vc_badge}]"
|
|
237
|
+
|
|
238
|
+
df = f[:discussions_and_forums] || {}
|
|
239
|
+
df_badge = df[:detected] ? Color.c("DETECTED (Reddit/Quora Modules)", Color::CYAN, Color::BOLD) : Color.c("None", Color::GRAY)
|
|
240
|
+
puts " • Discussions & Forums : [#{df_badge}]"
|
|
241
|
+
|
|
242
|
+
sp = f[:shopping_pack] || {}
|
|
243
|
+
sp_badge = sp[:detected] ? Color.c("DETECTED (Shopping Product Grid)", Color::YELLOW, Color::BOLD) : Color.c("None", Color::GRAY)
|
|
244
|
+
puts " • Google Shopping Grid : [#{sp_badge}]"
|
|
245
|
+
|
|
246
|
+
playbook = data[:playbook] || []
|
|
247
|
+
if playbook.any?
|
|
248
|
+
puts "\n#{Color::BOLD}🏆 RICH SNIPPET & CITATION CAPTURE PLAYBOOK:#{Color::RESET}"
|
|
249
|
+
playbook.each_with_index do |p, idx|
|
|
250
|
+
puts " #{idx + 1}. [#{Color.c(p[:priority], Color::BOLD)}] #{Color.c(p[:target], Color::CYAN)}:"
|
|
251
|
+
puts " 👉 #{p[:action]}"
|
|
252
|
+
end
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
serp = data[:serp_sampling] || []
|
|
256
|
+
if serp.any?
|
|
257
|
+
puts "\n#{Color::BOLD}🌐 LIVE SERP SAMPLING (Top Competitor Results):#{Color::RESET}"
|
|
258
|
+
serp.each do |r|
|
|
259
|
+
puts " #{r[:position]}. #{Color.c(r[:title], Color::BOLD)} [#{Color.c(r[:domain], Color::GRAY)}]"
|
|
260
|
+
puts " └─ #{Color.c(r[:url], Color::BLUE)}"
|
|
261
|
+
end
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
puts "\n" + ("─" * 80) + "\n"
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
def handle_preview(target, options)
|
|
268
|
+
is_url = target.to_s.start_with?('http://', 'https://')
|
|
269
|
+
has_title_or_url = is_url || options[:url] || options[:title] || options[:desc] || options[:description] || options[:preview] || (target.to_s =~ /[:|—]/)
|
|
270
|
+
|
|
271
|
+
if options[:live] || options[:features] || (!has_title_or_url && !target.to_s.empty?)
|
|
272
|
+
return handle_serp_features(target, options)
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
url = options[:url] || (target if is_url)
|
|
276
|
+
title = options[:title] || (target unless is_url)
|
|
277
|
+
desc = options[:desc] || options[:description]
|
|
278
|
+
url ||= "https://#{Config.default_domain}" if Config.default_domain
|
|
279
|
+
|
|
280
|
+
if (url.nil? || url.empty?) && (title.nil? || title.to_s.strip.empty?)
|
|
281
|
+
if options[:json]
|
|
282
|
+
puts JSON.pretty_generate({ error: 'Target URL or custom title required. Example: gsc serp-preview https://mysite.com/page' })
|
|
283
|
+
else
|
|
284
|
+
puts Color.c("❌ Error: Target URL or custom title required.", Color::RED, Color::BOLD)
|
|
285
|
+
puts "Example: gsc serp-preview https://mysite.com/page"
|
|
286
|
+
puts " gsc serp-preview --title \"Page Title\" --desc \"Meta description\""
|
|
287
|
+
end
|
|
288
|
+
return
|
|
289
|
+
end
|
|
290
|
+
|
|
291
|
+
sp = GSC::SerpPreview.new(url, title: title, desc: desc)
|
|
292
|
+
data = sp.generate
|
|
293
|
+
|
|
294
|
+
if options[:json]
|
|
295
|
+
puts JSON.pretty_generate(data)
|
|
296
|
+
return
|
|
297
|
+
end
|
|
298
|
+
|
|
299
|
+
puts Base::BANNER unless options[:in_dashboard]
|
|
300
|
+
m = data[:metrics] || {}
|
|
301
|
+
|
|
302
|
+
puts "🖥️ #{Color::BOLD}GOOGLE SERP PREVIEW (Desktop Viewport):#{Color::RESET}"
|
|
303
|
+
puts "┌─────────────────────────────────────────────────────────────────────────┐"
|
|
304
|
+
puts "│ #{Color.c(data.dig(:desktop_serp, :breadcrumb).to_s.ljust(71), Color::DIM)}│"
|
|
305
|
+
puts "│ #{Color.c(data.dig(:desktop_serp, :title).to_s.ljust(71), Color::BLUE, Color::BOLD)}│"
|
|
306
|
+
puts "│ #{Color.c(data.dig(:desktop_serp, :snippet).to_s[0..70].ljust(71), Color::DIM)}│"
|
|
307
|
+
puts "└─────────────────────────────────────────────────────────────────────────┘"
|
|
308
|
+
|
|
309
|
+
t_status = if m[:title_truncated]
|
|
310
|
+
Color.c("⚠️ TRUNCATED (+#{(m[:title_pixel_est] - m[:title_desktop_limit]).round(1)}px over 580px limit)", Color::YELLOW, Color::BOLD)
|
|
311
|
+
else
|
|
312
|
+
Color.c('✅ OPTIMAL (< 580px desktop limit)', Color::GREEN, Color::BOLD)
|
|
313
|
+
end
|
|
314
|
+
|
|
315
|
+
d_status = if m[:desc_truncated]
|
|
316
|
+
Color.c("⚠️ TRUNCATED (+#{(m[:desc_pixel_est] - m[:desc_desktop_limit]).round(1)}px over 960px limit)", Color::YELLOW, Color::BOLD)
|
|
317
|
+
else
|
|
318
|
+
Color.c('✅ OPTIMAL (< 960px desktop limit)', Color::GREEN, Color::BOLD)
|
|
319
|
+
end
|
|
320
|
+
|
|
321
|
+
puts " • Title Width: #{m[:title_chars]} chars / ~#{m[:title_pixel_est]}px [#{t_status}]"
|
|
322
|
+
puts " • Description Width: #{m[:desc_chars]} chars / ~#{m[:desc_pixel_est]}px [#{d_status}]"
|
|
323
|
+
|
|
324
|
+
puts "\n📱 #{Color::BOLD}GOOGLE SERP PREVIEW (Mobile Viewport):#{Color::RESET}"
|
|
325
|
+
puts "┌─────────────────────────────────────────────────────────────────────────┐"
|
|
326
|
+
puts "│ #{Color.c(data.dig(:mobile_serp, :breadcrumb).to_s.ljust(71), Color::DIM)}│"
|
|
327
|
+
puts "│ #{Color.c(data.dig(:mobile_serp, :title).to_s.ljust(71), Color::BLUE, Color::BOLD)}│"
|
|
328
|
+
puts "│ #{Color.c(data.dig(:mobile_serp, :snippet).to_s[0..70].ljust(71), Color::DIM)}│"
|
|
329
|
+
puts "└─────────────────────────────────────────────────────────────────────────┘"
|
|
330
|
+
|
|
331
|
+
soc = data[:social] || {}
|
|
332
|
+
if soc[:og_title] || soc[:og_image]
|
|
333
|
+
puts "\n🌐 #{Color::BOLD}OPEN GRAPH / SOCIAL CARD PREVIEW:#{Color::RESET}"
|
|
334
|
+
puts " • Title : #{soc[:og_title]}"
|
|
335
|
+
puts " • Description : #{soc[:og_description]}"
|
|
336
|
+
puts " • Card Image : #{soc[:og_image] || '(No og:image specified)'}"
|
|
337
|
+
end
|
|
338
|
+
puts ""
|
|
339
|
+
end
|
|
340
|
+
|
|
341
|
+
def handle_opportunities(api, site_url, options)
|
|
342
|
+
min_pos = options[:min_pos] || 7.0
|
|
343
|
+
max_pos = options[:max_pos] || 20.0
|
|
344
|
+
min_imp = options[:min_imp] || 10
|
|
345
|
+
puts "🎯 Finding striking-distance keyword opportunities for #{Color.c(site_url, Color::CYAN)} (Positions #{min_pos}–#{max_pos}, Min #{min_imp} Imp)...\n" unless options[:json]
|
|
346
|
+
|
|
347
|
+
res = api.query_analytics(site_url, days: options[:days], dimensions: %w[query page], row_limit: 2500)
|
|
348
|
+
if res[:ok]
|
|
349
|
+
raw_rows = res.dig(:data, 'rows') || []
|
|
350
|
+
opportunities = []
|
|
351
|
+
|
|
352
|
+
raw_rows.each do |r|
|
|
353
|
+
q = r['keys'][0]
|
|
354
|
+
p = r['keys'][1]
|
|
355
|
+
pos = r['position'].round(1)
|
|
356
|
+
imp = r['impressions']
|
|
357
|
+
clicks = r['clicks']
|
|
358
|
+
ctr = (r['ctr'] * 100).round(2)
|
|
359
|
+
|
|
360
|
+
next unless pos >= min_pos && pos <= max_pos
|
|
361
|
+
next unless imp >= min_imp
|
|
362
|
+
|
|
363
|
+
target_ctr = 12.0
|
|
364
|
+
potential_clicks = [((imp * (target_ctr / 100.0)) - clicks).round, 1].max
|
|
365
|
+
score = (potential_clicks * (1.0 / [pos - 2.0, 1.0].max) * 10.0).round(1)
|
|
366
|
+
|
|
367
|
+
opportunities << {
|
|
368
|
+
query: q,
|
|
369
|
+
page: p,
|
|
370
|
+
position: pos,
|
|
371
|
+
impressions: imp,
|
|
372
|
+
clicks: clicks,
|
|
373
|
+
ctr: ctr,
|
|
374
|
+
potentialGain: potential_clicks,
|
|
375
|
+
opportunityScore: score
|
|
376
|
+
}
|
|
377
|
+
end
|
|
378
|
+
|
|
379
|
+
opportunities.sort_by! { |o| -o[:opportunityScore] }
|
|
380
|
+
opportunities = opportunities.first(options[:limit])
|
|
381
|
+
|
|
382
|
+
if options[:json]
|
|
383
|
+
puts JSON.pretty_generate(opportunities)
|
|
384
|
+
elsif opportunities.empty?
|
|
385
|
+
puts Color.c("ℹ️ No striking-distance opportunities found with current filters (Min #{min_imp} imp, Pos #{min_pos}–#{max_pos}).", Color::YELLOW)
|
|
386
|
+
puts "💡 Tip: Try lowering --min-imp (e.g. gsc opportunities --min-imp 3) or increasing --days 90."
|
|
387
|
+
else
|
|
388
|
+
puts "#{Color::BOLD}Pos | Imp | Clicks | Est. +Clicks | Target Page & Query#{Color::RESET}"
|
|
389
|
+
puts "--------------------------------------------------------------------------------"
|
|
390
|
+
opportunities.each do |opp|
|
|
391
|
+
pos_str = opp[:position].to_s.ljust(5)
|
|
392
|
+
imp_str = opp[:impressions].to_s.ljust(6)
|
|
393
|
+
clicks_str = opp[:clicks].to_s.ljust(6)
|
|
394
|
+
gain_str = "+#{opp[:potentialGain]}/mo".ljust(12)
|
|
395
|
+
page_short = opp[:page].sub(%r{^https?://[^/]+}, '')
|
|
396
|
+
page_short = '/' if page_short.empty?
|
|
397
|
+
|
|
398
|
+
puts "#{Color.c(pos_str, Color::YELLOW)} | #{imp_str} | #{clicks_str} | #{Color.c(gain_str, Color::GREEN, Color::BOLD)} | #{Color.c(page_short, Color::CYAN)}"
|
|
399
|
+
puts " | | | | ↳ #{Color::BOLD}\"#{opp[:query]}\"#{Color::RESET}"
|
|
400
|
+
end
|
|
401
|
+
puts "\n#{Color.c("Total Opportunities: #{opportunities.size}", Color::GRAY)}"
|
|
402
|
+
puts "💡 #{Color::BOLD}Optimization Playbook:#{Color::RESET} Add an H2 subheading and 1-2 paragraphs directly targeting these queries on their respective pages to push them onto Page 1.\n\n"
|
|
403
|
+
|
|
404
|
+
if options[:csv]
|
|
405
|
+
csv_data = opportunities.map { |o| [o[:query], o[:page], o[:position], o[:impressions], o[:clicks], o[:ctr], o[:potentialGain], o[:opportunityScore]] }
|
|
406
|
+
Base.write_csv(options[:csv], %w[Query Page Position Impressions Clicks CTR PotentialGain Score], csv_data)
|
|
407
|
+
puts Color.c("📁 Exported opportunities to #{options[:csv]}", Color::CYAN)
|
|
408
|
+
end
|
|
409
|
+
end
|
|
410
|
+
else
|
|
411
|
+
if options[:json]
|
|
412
|
+
puts JSON.pretty_generate({ error: res[:data] })
|
|
413
|
+
else
|
|
414
|
+
puts Color.c("❌ Search Analytics Error (#{res[:status]}): #{res[:data]}", Color::RED)
|
|
415
|
+
end
|
|
416
|
+
end
|
|
417
|
+
end
|
|
418
|
+
|
|
419
|
+
def handle_underperformers(api, site_url, options)
|
|
420
|
+
min_imp = options[:min_imp] || 15
|
|
421
|
+
puts "⚡ Scanning for CTR Underperformers in Top 10 for #{Color.c(site_url, Color::CYAN)} (Min #{min_imp} Imp)...\n" unless options[:json]
|
|
422
|
+
|
|
423
|
+
res = api.query_analytics(site_url, days: options[:days], dimensions: %w[query page], row_limit: 2500)
|
|
424
|
+
if res[:ok]
|
|
425
|
+
raw_rows = res.dig(:data, 'rows') || []
|
|
426
|
+
underperformers = []
|
|
427
|
+
|
|
428
|
+
raw_rows.each do |r|
|
|
429
|
+
q = r['keys'][0]
|
|
430
|
+
p = r['keys'][1]
|
|
431
|
+
pos = r['position'].round(1)
|
|
432
|
+
imp = r['impressions']
|
|
433
|
+
clicks = r['clicks']
|
|
434
|
+
ctr = (r['ctr'] * 100).round(2)
|
|
435
|
+
|
|
436
|
+
next unless pos <= 10.0
|
|
437
|
+
next unless imp >= min_imp
|
|
438
|
+
|
|
439
|
+
expected = Base.expected_ctr_for(pos)
|
|
440
|
+
gap = expected - ctr
|
|
441
|
+
next unless gap >= 2.0 && ctr < (expected * 0.70)
|
|
442
|
+
|
|
443
|
+
lost_clicks = [((imp * (gap / 100.0))).round, 1].max
|
|
444
|
+
|
|
445
|
+
underperformers << {
|
|
446
|
+
query: q,
|
|
447
|
+
page: p,
|
|
448
|
+
position: pos,
|
|
449
|
+
impressions: imp,
|
|
450
|
+
clicks: clicks,
|
|
451
|
+
actualCtr: ctr,
|
|
452
|
+
expectedCtr: expected,
|
|
453
|
+
lostClicks: lost_clicks
|
|
454
|
+
}
|
|
455
|
+
end
|
|
456
|
+
|
|
457
|
+
underperformers.sort_by! { |u| -u[:lostClicks] }
|
|
458
|
+
underperformers = underperformers.first(options[:limit])
|
|
459
|
+
|
|
460
|
+
if options[:json]
|
|
461
|
+
puts JSON.pretty_generate(underperformers)
|
|
462
|
+
elsif underperformers.empty?
|
|
463
|
+
puts Color.c("✅ No significant CTR underperformers detected in Top 10! Your titles are converting well.", Color::GREEN)
|
|
464
|
+
else
|
|
465
|
+
puts "#{Color::BOLD}Pos | Imp | CTR | Expected | Lost Clicks | Query & Page#{Color::RESET}"
|
|
466
|
+
puts "--------------------------------------------------------------------------------"
|
|
467
|
+
underperformers.each do |item|
|
|
468
|
+
pos_str = item[:position].to_s.ljust(5)
|
|
469
|
+
imp_str = item[:impressions].to_s.ljust(6)
|
|
470
|
+
ctr_str = "#{item[:actualCtr]}%".ljust(6)
|
|
471
|
+
exp_str = "~#{item[:expectedCtr]}%".ljust(8)
|
|
472
|
+
lost_str = "~#{item[:lostClicks]} clicks".ljust(11)
|
|
473
|
+
page_short = item[:page].sub(%r{^https?://[^/]+}, '')
|
|
474
|
+
page_short = '/' if page_short.empty?
|
|
475
|
+
|
|
476
|
+
puts "#{Color.c(pos_str, Color::GREEN)} | #{imp_str} | #{Color.c(ctr_str, Color::RED)} | #{exp_str} | #{Color.c(lost_str, Color::YELLOW, Color::BOLD)} | #{Color.c(page_short, Color::CYAN)}"
|
|
477
|
+
puts " | | | | | ↳ #{Color::BOLD}\"#{item[:query]}\"#{Color::RESET}"
|
|
478
|
+
end
|
|
479
|
+
puts "\n#{Color.c("Total Underperformers: #{underperformers.size}", Color::GRAY)}"
|
|
480
|
+
puts "💡 #{Color::BOLD}Quick Win Playbook:#{Color::RESET} You already have top rankings! Rewrite the <title> tag and meta description with numbers, brackets [2026], or clear benefit hooks to recover lost clicks immediately.\n\n"
|
|
481
|
+
|
|
482
|
+
if options[:csv]
|
|
483
|
+
csv_data = underperformers.map { |u| [u[:query], u[:page], u[:position], u[:impressions], u[:clicks], u[:actualCtr], u[:expectedCtr], u[:lostClicks]] }
|
|
484
|
+
Base.write_csv(options[:csv], %w[Query Page Position Impressions Clicks ActualCTR ExpectedCTR LostClicks], csv_data)
|
|
485
|
+
puts Color.c("📁 Exported CTR underperformers to #{options[:csv]}", Color::CYAN)
|
|
486
|
+
end
|
|
487
|
+
end
|
|
488
|
+
else
|
|
489
|
+
if options[:json]
|
|
490
|
+
puts JSON.pretty_generate({ error: res[:data] })
|
|
491
|
+
else
|
|
492
|
+
puts Color.c("❌ Search Analytics Error (#{res[:status]}): #{res[:data]}", Color::RED)
|
|
493
|
+
end
|
|
494
|
+
end
|
|
495
|
+
end
|
|
496
|
+
|
|
497
|
+
def handle_cannibalization(api, site_url, options)
|
|
498
|
+
puts "⚔️ Auditing Keyword Cannibalization & URL Conflicts for #{Color.c(site_url, Color::CYAN)} (Past #{options[:days]} days)...\n" unless options[:json]
|
|
499
|
+
|
|
500
|
+
res = api.query_analytics(site_url, days: options[:days], dimensions: %w[query page], row_limit: 5000)
|
|
501
|
+
if res[:ok]
|
|
502
|
+
raw_rows = res.dig(:data, 'rows') || []
|
|
503
|
+
analysis = GSC::CannibalizationAnalyzer.analyze(raw_rows, min_imp: options[:min_imp] || 10)
|
|
504
|
+
conflicts = analysis[:conflicts].first(options[:limit] || 25)
|
|
505
|
+
|
|
506
|
+
if options[:json]
|
|
507
|
+
puts JSON.pretty_generate(analysis)
|
|
508
|
+
elsif conflicts.empty?
|
|
509
|
+
puts Color.c("✅ Zero keyword cannibalization detected! Each query has a distinct ranking page.", Color::GREEN)
|
|
510
|
+
puts " Evaluated #{analysis[:total_queries_evaluated]} distinct queries across #{site_url}."
|
|
511
|
+
else
|
|
512
|
+
puts "Cannibalization Health Score: #{Color.c("#{analysis[:health_score]}/100 [Grade #{analysis[:health_grade]}]", analysis[:health_score] >= 80 ? Color::GREEN : Color::RED, Color::BOLD)}"
|
|
513
|
+
puts "Total Conflicts Detected: #{Color.c(analysis[:conflicts_count].to_s, Color::YELLOW, Color::BOLD)} (#{analysis[:critical_count]} Critical)"
|
|
514
|
+
puts "Total Diluted Impressions: #{Color.c(analysis[:total_diluted_impressions].to_s, Color::RED)} search impressions split across competing URLs\n\n"
|
|
515
|
+
|
|
516
|
+
conflicts.each_with_index do |conf, i|
|
|
517
|
+
sev_color = case conf[:severity]
|
|
518
|
+
when 'CRITICAL' then Color::RED
|
|
519
|
+
when 'HIGH' then Color::YELLOW
|
|
520
|
+
else Color::CYAN
|
|
521
|
+
end
|
|
522
|
+
sev_badge = Color.c("[#{conf[:severity]}]", sev_color, Color::BOLD)
|
|
523
|
+
puts "#{Color::BOLD}#{i + 1}. #{sev_badge} \"#{conf[:query]}\"#{Color::RESET} (Total Imp: #{Color.c(conf[:total_impressions].to_s, Color::CYAN)}, Clicks: #{conf[:total_clicks]})"
|
|
524
|
+
conf[:pages].each do |p|
|
|
525
|
+
share_str = p[:impression_share_str].ljust(6)
|
|
526
|
+
imp_str = "#{p[:impressions]} imp".ljust(9)
|
|
527
|
+
pos_str = "Pos #{p[:position]}".ljust(9)
|
|
528
|
+
page_short = p[:page].sub(%r{^https?://[^/]+}, '')
|
|
529
|
+
page_short = '/' if page_short.empty?
|
|
530
|
+
puts " • #{Color.c(share_str, Color::YELLOW)} | #{imp_str} | #{pos_str} | #{page_short}"
|
|
531
|
+
end
|
|
532
|
+
puts " 👉 #{Color.c("Remedy [#{conf[:remedy][:action]}]:", Color::BOLD)} #{conf[:remedy][:recommendation]}\n\n"
|
|
533
|
+
end
|
|
534
|
+
|
|
535
|
+
if options[:csv]
|
|
536
|
+
csv_rows = []
|
|
537
|
+
analysis[:conflicts].each do |c|
|
|
538
|
+
c[:pages].each do |p|
|
|
539
|
+
csv_rows << [c[:query], c[:severity], c[:total_impressions], c[:remedy][:action], c[:remedy][:recommendation], p[:page], p[:impression_share_str], p[:impressions], p[:clicks], p[:position]]
|
|
540
|
+
end
|
|
541
|
+
end
|
|
542
|
+
Base.write_csv(options[:csv], %w[Query Severity TotalImpressions RemedyAction Recommendation Page ImpressionShare PageImpressions PageClicks Position], csv_rows)
|
|
543
|
+
puts Color.c("📁 Exported cannibalization conflict report to #{options[:csv]}", Color::CYAN)
|
|
544
|
+
end
|
|
545
|
+
end
|
|
546
|
+
else
|
|
547
|
+
if options[:json]
|
|
548
|
+
puts JSON.pretty_generate({ error: res[:data] })
|
|
549
|
+
else
|
|
550
|
+
puts Color.c("❌ Search Analytics Error (#{res[:status]}): #{res[:data]}", Color::RED)
|
|
551
|
+
end
|
|
552
|
+
end
|
|
553
|
+
end
|
|
554
|
+
|
|
555
|
+
def handle_questions_harvest(target, api, site_url, options)
|
|
556
|
+
days = options[:days] || 30
|
|
557
|
+
min_imp = options[:min_imp] || 1
|
|
558
|
+
page_filter = options[:page] || target
|
|
559
|
+
|
|
560
|
+
puts "❓ Harvesting Question & FAQ Queries for #{Color.c(site_url, Color::CYAN)} (Past #{days} days)...\n" unless options[:json]
|
|
561
|
+
|
|
562
|
+
res = api.query_analytics(site_url, days: days, dimensions: %w[query page], row_limit: 5000)
|
|
563
|
+
if res[:ok]
|
|
564
|
+
raw_rows = res.dig(:data, 'rows') || []
|
|
565
|
+
harvest_data = GSC::QuestionsHarvester.harvest(
|
|
566
|
+
raw_rows,
|
|
567
|
+
min_imp: min_imp,
|
|
568
|
+
filter_page: page_filter,
|
|
569
|
+
brand: options[:brand] || site_url.sub(/^sc-domain:/, '').sub(/\.[a-z]+$/, '').capitalize,
|
|
570
|
+
synthesize: options[:synthesize] || options[:answer]
|
|
571
|
+
)
|
|
572
|
+
|
|
573
|
+
if options[:json]
|
|
574
|
+
puts JSON.pretty_generate(harvest_data)
|
|
575
|
+
else
|
|
576
|
+
puts "#{Color::BOLD}❓ SEARCH CONSOLE QUESTION & FAQ HARVEST#{Color::RESET}"
|
|
577
|
+
puts "#{Color::BOLD}══════════════════════════════════════════════════════════════#{Color::RESET}"
|
|
578
|
+
puts " Total Questions Harvested: #{Color.c(harvest_data[:total_questions_harvested].to_s, Color::CYAN, Color::BOLD)}"
|
|
579
|
+
puts " 🎯 High-Opportunity (Pos 4-20): #{Color.c(harvest_data[:high_opportunity_count].to_s, Color::YELLOW, Color::BOLD)} (Prime for FAQ Rich Snippets)"
|
|
580
|
+
puts " 🛡️ SERP Defense (Pos 1-3): #{Color.c(harvest_data[:defense_count].to_s, Color::GREEN, Color::BOLD)} (Featured Snippet Candidates)"
|
|
581
|
+
puts " 📄 Landing Pages Covered: #{harvest_data[:pages_covered]}"
|
|
582
|
+
puts "#{Color::BOLD}══════════════════════════════════════════════════════════════#{Color::RESET}\n"
|
|
583
|
+
|
|
584
|
+
limit = options[:limit] || 15
|
|
585
|
+
top_questions = harvest_data[:questions].first(limit)
|
|
586
|
+
|
|
587
|
+
if top_questions.empty?
|
|
588
|
+
puts Color.c("No question queries found meeting criteria for this period.\n", Color::YELLOW)
|
|
589
|
+
else
|
|
590
|
+
puts "#{Color::BOLD}Top Harvested Questions:#{Color::RESET}"
|
|
591
|
+
puts " # | Imp | Clicks | Pos | Type | Question"
|
|
592
|
+
puts "─────────────────────────────────────────────────────────────────────────────"
|
|
593
|
+
top_questions.each_with_index do |q, i|
|
|
594
|
+
num_str = "[#{i + 1}]".ljust(4)
|
|
595
|
+
imp_str = q[:impressions].to_s.ljust(5)
|
|
596
|
+
clk_str = q[:clicks].to_s.ljust(7)
|
|
597
|
+
pos_str = q[:position].to_s.ljust(5)
|
|
598
|
+
type_str = Color.c((q[:type] || 'OTHER').ljust(7), Color::CYAN)
|
|
599
|
+
|
|
600
|
+
puts "#{num_str}| #{imp_str}| #{clk_str}| #{pos_str}| #{type_str}| #{Color::BOLD}#{q[:question]}#{Color::RESET}"
|
|
601
|
+
page_short = q[:page].sub(%r{^https?://[^/]+}, '')
|
|
602
|
+
page_short = '/' if page_short.empty?
|
|
603
|
+
puts " 🔗 Target Page: #{Color.c(page_short, Color::GRAY)} (Tier: #{q[:tier]})"
|
|
604
|
+
end
|
|
605
|
+
puts "─────────────────────────────────────────────────────────────────────────────\n"
|
|
606
|
+
|
|
607
|
+
if options[:generate] || harvest_data[:recommended_faq_schemas].any?
|
|
608
|
+
first_page, sample_schema = harvest_data[:recommended_faq_schemas].first
|
|
609
|
+
if sample_schema
|
|
610
|
+
page_short = first_page.sub(%r{^https?://[^/]+}, '')
|
|
611
|
+
puts "📋 #{Color::BOLD}READY-TO-PASTE FAQPage JSON-LD SCHEMA FOR #{Color.c(page_short, Color::CYAN)}:#{Color::RESET}"
|
|
612
|
+
puts "<script type=\"application/ld+json\">"
|
|
613
|
+
puts JSON.pretty_generate(sample_schema)
|
|
614
|
+
puts "</script>\n"
|
|
615
|
+
end
|
|
616
|
+
end
|
|
617
|
+
end
|
|
618
|
+
|
|
619
|
+
puts "💡 #{Color.bold('Want to do more? Supercharge your question & content strategy:')}"
|
|
620
|
+
clean_dom = site_url.sub('sc-domain:', '')
|
|
621
|
+
sample_keyword = top_questions.any? ? top_questions.first[:question].gsub(/[?]/, '').split(/\s+/).last(2).join(' ') : 'search analytics'
|
|
622
|
+
puts " • 📅 Expand date range (up to 16 months): #{Color.cyan("gsc questions-harvest -d #{clean_dom} --days 480")}"
|
|
623
|
+
puts " • 🌐 Mine live People Also Ask questions: #{Color.cyan("gsc questions \"#{sample_keyword}\"")}"
|
|
624
|
+
puts " • ✍️ Synthesize high-ranking answers: #{Color.cyan("gsc questions-harvest -d #{clean_dom} --days #{days} --synthesize")}"
|
|
625
|
+
puts " • 🎯 AI Overview Citation Radar: #{Color.cyan("gsc aio-hunter \"#{sample_keyword}\"")}"
|
|
626
|
+
puts " • 📄 Filter by specific landing page: #{Color.cyan("gsc questions-harvest /features/ -d #{clean_dom}")}\n\n"
|
|
627
|
+
|
|
628
|
+
if options[:csv]
|
|
629
|
+
csv_rows = harvest_data[:questions].map do |q|
|
|
630
|
+
[q[:question], q[:type], q[:tier], q[:impressions], q[:clicks], q[:ctr], q[:position], q[:opportunity_score], q[:page]]
|
|
631
|
+
end
|
|
632
|
+
Base.write_csv(options[:csv], %w[Question Type Tier Impressions Clicks CTR Position OpportunityScore TargetPage], csv_rows)
|
|
633
|
+
puts Color.c("📁 Exported question queries to #{options[:csv]}", Color::CYAN)
|
|
634
|
+
end
|
|
635
|
+
end
|
|
636
|
+
else
|
|
637
|
+
if options[:json]
|
|
638
|
+
puts JSON.pretty_generate({ error: res[:data] })
|
|
639
|
+
else
|
|
640
|
+
puts Color.c("❌ Search Analytics Error (#{res[:status]}): #{res[:data]}", Color::RED)
|
|
641
|
+
end
|
|
642
|
+
end
|
|
643
|
+
end
|
|
644
|
+
|
|
645
|
+
def handle_zombies(target, api, site_url, https_origin, options)
|
|
646
|
+
ZombiePurger.run('zombies', target, nil, options, api, site_url, nil, https_origin)
|
|
647
|
+
end
|
|
648
|
+
end
|
|
649
|
+
end
|
|
650
|
+
end
|