gsc-cli 2.1.0 → 2.2.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/AUTH.md +4 -1
- data/README.md +448 -408
- data/bin/gsc +28067 -5661
- data/dist/gsc +29121 -5046
- data/lib/gsc/aio_hunter.rb +343 -0
- data/lib/gsc/answer_synthesizer.rb +157 -0
- data/lib/gsc/api.rb +53 -1
- data/lib/gsc/auth.rb +26 -0
- data/lib/gsc/brand_segmenter.rb +140 -0
- data/lib/gsc/cache_manager.rb +806 -0
- data/lib/gsc/cannibalization_analyzer.rb +141 -0
- data/lib/gsc/canonical_chains.rb +367 -0
- data/lib/gsc/citation_simulator.rb +339 -0
- data/lib/gsc/cli/aio_hunter.rb +154 -0
- data/lib/gsc/cli/analytics.rb +788 -0
- data/lib/gsc/cli/audit.rb +1976 -0
- data/lib/gsc/cli/base.rb +384 -0
- data/lib/gsc/cli/cache.rb +266 -0
- data/lib/gsc/cli/canonical.rb +223 -0
- data/lib/gsc/cli/citation_simulator.rb +152 -0
- data/lib/gsc/cli/dashboard.rb +354 -0
- data/lib/gsc/cli/doctor.rb +129 -0
- data/lib/gsc/cli/eeat.rb +125 -0
- data/lib/gsc/cli/ga4.rb +852 -0
- data/lib/gsc/cli/growth.rb +650 -0
- data/lib/gsc/cli/hreflang.rb +164 -0
- data/lib/gsc/cli/image_seo.rb +162 -0
- data/lib/gsc/cli/indexing.rb +458 -0
- data/lib/gsc/cli/intent_shift.rb +125 -0
- data/lib/gsc/cli/keyword_value.rb +134 -0
- data/lib/gsc/cli/keywords.rb +795 -0
- data/lib/gsc/cli/landing_roi.rb +308 -0
- data/lib/gsc/cli/low_ctr.rb +213 -0
- data/lib/gsc/cli/mobile_parity.rb +150 -0
- data/lib/gsc/cli/report.rb +100 -0
- data/lib/gsc/cli/rich_results.rb +172 -0
- data/lib/gsc/cli/schema_generate.rb +149 -0
- data/lib/gsc/cli/seasonal.rb +232 -0
- data/lib/gsc/cli/security.rb +153 -0
- data/lib/gsc/cli/setup.rb +1291 -0
- data/lib/gsc/cli/sitemap_tree.rb +143 -0
- data/lib/gsc/cli/skill_pack.rb +62 -0
- data/lib/gsc/cli/soft_404.rb +199 -0
- data/lib/gsc/cli/sparkline.rb +227 -0
- data/lib/gsc/cli/watchdog.rb +150 -0
- data/lib/gsc/cli/zombie_purger.rb +208 -0
- data/lib/gsc/cli.rb +707 -5265
- data/lib/gsc/cli_advanced.rb +987 -44
- data/lib/gsc/client.rb +17 -2
- data/lib/gsc/color.rb +16 -1
- data/lib/gsc/command_registry.rb +47 -9
- data/lib/gsc/config.rb +2 -2
- data/lib/gsc/ctr_curve.rb +115 -0
- data/lib/gsc/decay_predictor.rb +322 -0
- data/lib/gsc/doctor.rb +434 -0
- data/lib/gsc/eeat_auditor.rb +428 -0
- data/lib/gsc/entity_auditor.rb +229 -0
- data/lib/gsc/firewall_scanner.rb +733 -0
- data/lib/gsc/geo_auditor.rb +368 -0
- data/lib/gsc/google_trends.rb +8 -1
- data/lib/gsc/heading_validator.rb +283 -0
- data/lib/gsc/hreflang_validator.rb +412 -0
- data/lib/gsc/image_seo.rb +286 -0
- data/lib/gsc/indexing_queue.rb +179 -0
- data/lib/gsc/indexnow.rb +93 -0
- data/lib/gsc/intent_shift.rb +188 -0
- data/lib/gsc/internal_links.rb +153 -36
- data/lib/gsc/keyword_value.rb +191 -0
- data/lib/gsc/landing_roi.rb +195 -0
- data/lib/gsc/llms_generator.rb +343 -22
- data/lib/gsc/low_ctr_rewriter.rb +408 -0
- data/lib/gsc/mobile_parity.rb +222 -0
- data/lib/gsc/network_tracer.rb +8 -1
- data/lib/gsc/page_analyzer.rb +47 -7
- data/lib/gsc/prompts.rb +38 -29
- data/lib/gsc/questions_harvester.rb +178 -0
- data/lib/gsc/report_generator.rb +461 -0
- data/lib/gsc/rich_results.rb +388 -0
- data/lib/gsc/robots_checker.rb +46 -15
- data/lib/gsc/schema_generator.rb +788 -0
- data/lib/gsc/schema_validator.rb +36 -38
- data/lib/gsc/seasonal_predictor.rb +381 -0
- data/lib/gsc/security_scanner.rb +496 -0
- data/lib/gsc/serp_feature_detector.rb +359 -0
- data/lib/gsc/serp_preview.rb +108 -22
- data/lib/gsc/site_crawler.rb +113 -21
- data/lib/gsc/sitemap_loader.rb +15 -4
- data/lib/gsc/sitemap_tree.rb +301 -0
- data/lib/gsc/skill_pack.rb +195 -0
- data/lib/gsc/soft_404_analyzer.rb +385 -0
- data/lib/gsc/sparkline.rb +171 -0
- data/lib/gsc/speed_correlator.rb +416 -0
- data/lib/gsc/striking_playbook.rb +190 -0
- data/lib/gsc/title_optimizer.rb +420 -0
- data/lib/gsc/vault.rb +260 -0
- data/lib/gsc/version.rb +1 -1
- data/lib/gsc/watchdog.rb +235 -0
- data/lib/gsc/zombie_purger.rb +366 -0
- data/lib/gsc.rb +118 -0
- metadata +75 -1
|
@@ -0,0 +1,788 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
require_relative 'base'
|
|
5
|
+
require_relative '../brand_segmenter' if File.exist?(File.expand_path('../brand_segmenter.rb', __dir__))
|
|
6
|
+
require_relative '../ctr_curve' if File.exist?(File.expand_path('../ctr_curve.rb', __dir__))
|
|
7
|
+
require_relative '../decay_predictor' if File.exist?(File.expand_path('../decay_predictor.rb', __dir__))
|
|
8
|
+
|
|
9
|
+
module GSC
|
|
10
|
+
class CLI
|
|
11
|
+
module Analytics
|
|
12
|
+
module_function
|
|
13
|
+
|
|
14
|
+
def run(command, target, extra, options, api, site_url, hostname, https_origin)
|
|
15
|
+
case command
|
|
16
|
+
when 'performance', 'perf', 'p'
|
|
17
|
+
handle_performance(api, site_url, hostname, options)
|
|
18
|
+
when 'top-queries', 'queries', 'query', 'tq'
|
|
19
|
+
handle_top_queries(api, site_url, options)
|
|
20
|
+
when 'brand', 'brand-split', 'brand-segmentation'
|
|
21
|
+
handle_brand(api, site_url, options)
|
|
22
|
+
when 'top-pages', 'pages'
|
|
23
|
+
handle_top_pages(api, site_url, options)
|
|
24
|
+
when 'ctr-curve', 'ctr-simulator', 'traffic-gain'
|
|
25
|
+
handle_ctr_curve(api, site_url, options)
|
|
26
|
+
when 'decay', 'trends-decay', 'd'
|
|
27
|
+
handle_decay(api, site_url, options)
|
|
28
|
+
when 'devices'
|
|
29
|
+
handle_devices(api, site_url, options)
|
|
30
|
+
when 'countries'
|
|
31
|
+
handle_countries(api, site_url, options)
|
|
32
|
+
when 'snippets', 'appearance', 'search-appearance'
|
|
33
|
+
handle_snippets(api, site_url, options)
|
|
34
|
+
when 'cities'
|
|
35
|
+
handle_cities(api, hostname, options)
|
|
36
|
+
else
|
|
37
|
+
raise "Unknown analytics command: #{command}"
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def handle_performance(api, site_url, hostname, options)
|
|
42
|
+
puts "📈 Calculating Search Console & Behavioral Performance for #{Color.c(site_url, Color::CYAN)} (Past #{options[:days]} days)...\n" unless options[:json]
|
|
43
|
+
|
|
44
|
+
# 1. Overall Totals
|
|
45
|
+
res = api.query_analytics(site_url, days: options[:days], dimensions: [])
|
|
46
|
+
unless res[:ok]
|
|
47
|
+
if options[:json]
|
|
48
|
+
puts JSON.pretty_generate({ error: res[:data] })
|
|
49
|
+
else
|
|
50
|
+
puts Color.c("❌ Performance API Error (#{res[:status]}): #{res[:data]}", Color::RED)
|
|
51
|
+
end
|
|
52
|
+
return
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
row = res.dig(:data, 'rows', 0) || { 'clicks' => 0, 'impressions' => 0, 'ctr' => 0, 'position' => 0 }
|
|
56
|
+
total_clicks = row['clicks'] || 0
|
|
57
|
+
total_imp = row['impressions'] || 0
|
|
58
|
+
avg_ctr = ((row['ctr'] || 0) * 100).round(2)
|
|
59
|
+
avg_pos = (row['position'] || 0).round(1)
|
|
60
|
+
|
|
61
|
+
# 2. Device Breakdown
|
|
62
|
+
dev_res = api.query_analytics(site_url, days: options[:days], dimensions: ['device'])
|
|
63
|
+
devices_data = (dev_res[:ok] ? (dev_res.dig(:data, 'rows') || []) : []).map do |r|
|
|
64
|
+
c = r['clicks'] || 0
|
|
65
|
+
i = r['impressions'] || 0
|
|
66
|
+
ctr = ((r['ctr'] || 0) * 100).round(2)
|
|
67
|
+
pos = (r['position'] || 0).round(1)
|
|
68
|
+
share = total_clicks > 0 ? "#{(c.to_f / total_clicks * 100).round(1)}%" : "-"
|
|
69
|
+
{ device: r['keys'].first, clicks: c, impressions: i, ctr: ctr, position: pos, share: share }
|
|
70
|
+
end.sort_by { |r| -r[:clicks] }
|
|
71
|
+
|
|
72
|
+
# 3. Top Countries
|
|
73
|
+
cntry_limit = (options[:limit] == 50 ? 10 : options[:limit])
|
|
74
|
+
cntry_res = api.query_analytics(site_url, days: options[:days], dimensions: ['country'], row_limit: [cntry_limit * 3, 50].max)
|
|
75
|
+
countries_data = (cntry_res[:ok] ? (cntry_res.dig(:data, 'rows') || []) : []).map do |r|
|
|
76
|
+
{
|
|
77
|
+
country: r['keys'].first,
|
|
78
|
+
clicks: r['clicks'] || 0,
|
|
79
|
+
impressions: r['impressions'] || 0,
|
|
80
|
+
ctr: ((r['ctr'] || 0) * 100).round(2),
|
|
81
|
+
position: (r['position'] || 0).round(1)
|
|
82
|
+
}
|
|
83
|
+
end.sort_by { |r| [-r[:clicks], -r[:impressions]] }.first(cntry_limit)
|
|
84
|
+
|
|
85
|
+
# 4. Search Appearance / Rich Snippets
|
|
86
|
+
snip_res = api.query_analytics(site_url, days: options[:days], dimensions: ['searchAppearance'], row_limit: 10)
|
|
87
|
+
snippets_data = (snip_res[:ok] ? (snip_res.dig(:data, 'rows') || []) : []).map do |r|
|
|
88
|
+
{
|
|
89
|
+
type: r['keys'].first,
|
|
90
|
+
clicks: r['clicks'] || 0,
|
|
91
|
+
impressions: r['impressions'] || 0,
|
|
92
|
+
ctr: ((r['ctr'] || 0) * 100).round(2),
|
|
93
|
+
position: (r['position'] || 0).round(1)
|
|
94
|
+
}
|
|
95
|
+
end.sort_by { |r| -r[:clicks] }
|
|
96
|
+
|
|
97
|
+
# 5. Top Search Queries (Top 5 for dashboard summary)
|
|
98
|
+
q_res = api.query_analytics(site_url, days: options[:days], dimensions: ['query'], row_limit: 5)
|
|
99
|
+
queries_data = (q_res[:ok] ? (q_res.dig(:data, 'rows') || []) : []).map do |r|
|
|
100
|
+
{
|
|
101
|
+
query: r['keys'].first,
|
|
102
|
+
clicks: r['clicks'] || 0,
|
|
103
|
+
impressions: r['impressions'] || 0,
|
|
104
|
+
ctr: ((r['ctr'] || 0) * 100).round(2),
|
|
105
|
+
position: (r['position'] || 0).round(1)
|
|
106
|
+
}
|
|
107
|
+
end.sort_by { |r| -r[:clicks] }
|
|
108
|
+
|
|
109
|
+
# 6. Top Landing Pages (Top 5 for dashboard summary)
|
|
110
|
+
p_res = api.query_analytics(site_url, days: options[:days], dimensions: ['page'], row_limit: 5)
|
|
111
|
+
pages_data = (p_res[:ok] ? (p_res.dig(:data, 'rows') || []) : []).map do |r|
|
|
112
|
+
path = r['keys'].first.sub(%r{^https?://[^/]+}, '')
|
|
113
|
+
path = '/' if path.empty?
|
|
114
|
+
{
|
|
115
|
+
path: path,
|
|
116
|
+
clicks: r['clicks'] || 0,
|
|
117
|
+
impressions: r['impressions'] || 0,
|
|
118
|
+
ctr: ((r['ctr'] || 0) * 100).round(2),
|
|
119
|
+
position: (r['position'] || 0).round(1)
|
|
120
|
+
}
|
|
121
|
+
end.sort_by { |r| -r[:clicks] }
|
|
122
|
+
|
|
123
|
+
# 7. GA4 Top Cities (if linked)
|
|
124
|
+
ga4_property_id = options[:property] || Config.ga4_property_id(hostname)
|
|
125
|
+
cities_data = []
|
|
126
|
+
if ga4_property_id
|
|
127
|
+
ga4_res = api.query_ga4_cities(ga4_property_id, days: options[:days], limit: 8, hostname: (options[:all_hosts] ? nil : hostname), site_only: options[:site_only])
|
|
128
|
+
if ga4_res[:ok]
|
|
129
|
+
cities_data = (ga4_res.dig(:data, 'rows') || []).map do |cr|
|
|
130
|
+
city = cr.dig('dimensionValues', 0, 'value') || '(unknown)'
|
|
131
|
+
country = cr.dig('dimensionValues', 1, 'value') || '(unknown)'
|
|
132
|
+
sess = (cr.dig('metricValues', 0, 'value') || 0).to_i
|
|
133
|
+
bounce = ((cr.dig('metricValues', 1, 'value') || 0).to_f * 100).round(1)
|
|
134
|
+
dur = (cr.dig('metricValues', 2, 'value') || 0).to_f.round
|
|
135
|
+
{ city: city, country: country, sessions: sess, bounce_rate: "#{bounce}%", duration: Base.format_duration(dur) }
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
if options[:json]
|
|
141
|
+
payload = {
|
|
142
|
+
domain: hostname,
|
|
143
|
+
property: site_url,
|
|
144
|
+
ga4PropertyId: ga4_property_id,
|
|
145
|
+
startDate: res[:start_date],
|
|
146
|
+
endDate: res[:end_date],
|
|
147
|
+
totals: {
|
|
148
|
+
clicks: total_clicks,
|
|
149
|
+
impressions: total_imp,
|
|
150
|
+
ctr: avg_ctr,
|
|
151
|
+
position: avg_pos
|
|
152
|
+
},
|
|
153
|
+
devices: devices_data,
|
|
154
|
+
countries: countries_data,
|
|
155
|
+
snippets: snippets_data,
|
|
156
|
+
topQueries: queries_data,
|
|
157
|
+
topPages: pages_data,
|
|
158
|
+
cities: cities_data
|
|
159
|
+
}
|
|
160
|
+
puts JSON.pretty_generate(payload)
|
|
161
|
+
else
|
|
162
|
+
puts "#{Color::BOLD}══════════════════════════════════════════════════════════════#{Color::RESET}"
|
|
163
|
+
puts "#{Color::BOLD}📊 SEARCH PERFORMANCE TOTALS (#{res[:start_date]} to #{res[:end_date]})#{Color::RESET}"
|
|
164
|
+
puts "#{Color::BOLD}══════════════════════════════════════════════════════════════#{Color::RESET}"
|
|
165
|
+
puts " 🔵 Total Clicks: #{Color.c(Base.format_number(total_clicks), Color::GREEN, Color::BOLD)}"
|
|
166
|
+
puts " 🟣 Total Impressions: #{Color.c(Base.format_number(total_imp), Color::MAGENTA, Color::BOLD)}"
|
|
167
|
+
puts " 🟢 Average CTR: #{Color.c("#{avg_ctr}%", Color::CYAN, Color::BOLD)}"
|
|
168
|
+
puts " 🟠 Average Position: #{Color.c(avg_pos.to_s, Color::YELLOW, Color::BOLD)}"
|
|
169
|
+
puts "#{Color::BOLD}══════════════════════════════════════════════════════════════#{Color::RESET}"
|
|
170
|
+
|
|
171
|
+
print_devices_table(devices_data) unless devices_data.empty?
|
|
172
|
+
print_countries_table(countries_data) unless countries_data.empty?
|
|
173
|
+
print_snippets_table(snippets_data)
|
|
174
|
+
print_mini_queries_table(queries_data) unless queries_data.empty?
|
|
175
|
+
print_mini_pages_table(pages_data) unless pages_data.empty?
|
|
176
|
+
|
|
177
|
+
if ga4_property_id && !cities_data.empty?
|
|
178
|
+
print_cities_table(cities_data, ga4_property_id)
|
|
179
|
+
elsif !ga4_property_id
|
|
180
|
+
puts "\n#{Color.c("💡 Link GA4 with `gsc connect-ga4` or `gsc config set-ga4 <id>` to see city-level behavioral data & retention.", Color::GRAY)}"
|
|
181
|
+
end
|
|
182
|
+
puts
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
def handle_top_queries(api, site_url, options)
|
|
187
|
+
sort_field, sort_order = Base.resolve_sort_params(options[:sort], options[:order])
|
|
188
|
+
sort_desc = sort_field ? "sorted by #{sort_field} #{sort_order}" : "sorted by clicks & impressions"
|
|
189
|
+
filter_tag = if options[:brand]
|
|
190
|
+
" #{Color.c('[BRAND ONLY]', Color::GREEN)}"
|
|
191
|
+
elsif options[:non_brand]
|
|
192
|
+
" #{Color.c('[NON-BRAND ONLY]', Color::MAGENTA)}"
|
|
193
|
+
else
|
|
194
|
+
""
|
|
195
|
+
end
|
|
196
|
+
puts "📊 Fetching top search queries for #{Color.c(site_url, Color::CYAN)}#{filter_tag} (Past #{options[:days]} days, #{sort_desc})...\n" unless options[:json]
|
|
197
|
+
|
|
198
|
+
res = if options[:all]
|
|
199
|
+
puts "🔄 Paginating across all query rows via startRow..." unless options[:json]
|
|
200
|
+
api.query_all_analytics(site_url, days: options[:days], dimensions: ['query'])
|
|
201
|
+
else
|
|
202
|
+
fetch_limit = [options[:limit] * 10, 1000].max
|
|
203
|
+
api.query_analytics(site_url, days: options[:days], dimensions: ['query'], row_limit: fetch_limit)
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
if res[:ok]
|
|
207
|
+
raw_rows = (res.dig(:data, 'rows') || []).map do |r|
|
|
208
|
+
{
|
|
209
|
+
query: r['keys'].first,
|
|
210
|
+
clicks: r['clicks'],
|
|
211
|
+
impressions: r['impressions'],
|
|
212
|
+
ctr: (r['ctr'] * 100).round(2),
|
|
213
|
+
position: r['position'].round(1)
|
|
214
|
+
}
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
if options[:brand] || options[:non_brand]
|
|
218
|
+
segmenter = GSC::BrandSegmenter.new(domain: site_url, custom_brand: options[:brand_name])
|
|
219
|
+
raw_rows = if options[:brand]
|
|
220
|
+
raw_rows.select { |r| segmenter.brand?(r[:query]) }
|
|
221
|
+
else
|
|
222
|
+
raw_rows.reject { |r| segmenter.brand?(r[:query]) }
|
|
223
|
+
end
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
sorted_rows = Base.sort_analytics_rows(raw_rows, options[:sort], options[:order])
|
|
227
|
+
rows = options[:all] ? sorted_rows : sorted_rows.first(options[:limit])
|
|
228
|
+
|
|
229
|
+
if options[:json]
|
|
230
|
+
puts JSON.pretty_generate(rows)
|
|
231
|
+
elsif rows.empty?
|
|
232
|
+
puts Color.c("ℹ️ No search query data recorded matching criteria in the last #{options[:days]} days.", Color::YELLOW)
|
|
233
|
+
else
|
|
234
|
+
puts "#{Color::BOLD}Clicks | Impressions | CTR | Position | Query#{Color::RESET}"
|
|
235
|
+
puts "--------------------------------------------------------------------------------"
|
|
236
|
+
rows.each do |row|
|
|
237
|
+
clicks = row[:clicks].to_s.ljust(6)
|
|
238
|
+
imp = row[:impressions].to_s.ljust(11)
|
|
239
|
+
ctr = "#{row[:ctr]}%".ljust(6)
|
|
240
|
+
pos = row[:position].to_s.ljust(8)
|
|
241
|
+
puts "#{clicks} | #{imp} | #{ctr} | #{pos} | #{Color.c(row[:query], Color::CYAN)}"
|
|
242
|
+
end
|
|
243
|
+
puts "\n#{Color.c("Total Queries Displayed: #{rows.size}", Color::GRAY)}\n"
|
|
244
|
+
|
|
245
|
+
if options[:csv]
|
|
246
|
+
csv_data = rows.map { |r| [r[:query], r[:clicks], r[:impressions], r[:ctr], r[:position]] }
|
|
247
|
+
Base.write_csv(options[:csv], %w[Query Clicks Impressions CTR Position], csv_data)
|
|
248
|
+
puts Color.c("📁 Exported queries to #{options[:csv]}", Color::CYAN)
|
|
249
|
+
end
|
|
250
|
+
end
|
|
251
|
+
else
|
|
252
|
+
if options[:json]
|
|
253
|
+
puts JSON.pretty_generate({ error: res[:data] })
|
|
254
|
+
else
|
|
255
|
+
puts Color.c("❌ Search Analytics Error (#{res[:status]}): #{res[:data]}", Color::RED)
|
|
256
|
+
end
|
|
257
|
+
end
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
def handle_brand(api, site_url, options)
|
|
261
|
+
puts "🏷️ Analyzing Brand vs. Non-Brand Search Query Segmentation for #{Color.c(site_url, Color::CYAN)} (Past #{options[:days]} days)...\n" unless options[:json]
|
|
262
|
+
|
|
263
|
+
res = api.query_all_analytics(site_url, days: options[:days], dimensions: ['query'])
|
|
264
|
+
if res[:ok]
|
|
265
|
+
raw_rows = (res.dig(:data, 'rows') || []).map do |r|
|
|
266
|
+
{
|
|
267
|
+
query: r['keys'].first,
|
|
268
|
+
clicks: r['clicks'],
|
|
269
|
+
impressions: r['impressions'],
|
|
270
|
+
ctr: (r['ctr'] * 100).round(2),
|
|
271
|
+
position: r['position'].round(1)
|
|
272
|
+
}
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
segmenter = GSC::BrandSegmenter.new(domain: site_url, custom_brand: options[:brand_name])
|
|
276
|
+
segmented = segmenter.segment(raw_rows)
|
|
277
|
+
summary = segmented[:summary]
|
|
278
|
+
|
|
279
|
+
if options[:json]
|
|
280
|
+
puts JSON.pretty_generate({
|
|
281
|
+
domain: site_url,
|
|
282
|
+
brand_tokens: segmenter.brand_tokens,
|
|
283
|
+
summary: summary,
|
|
284
|
+
top_brand: segmented[:brand].first(options[:limit] || 10),
|
|
285
|
+
top_non_brand: segmented[:non_brand].first(options[:limit] || 10)
|
|
286
|
+
})
|
|
287
|
+
else
|
|
288
|
+
puts "Identified Brand Tokens: #{Color.c(segmenter.brand_tokens.join(', '), Color::YELLOW)}"
|
|
289
|
+
puts "Total Search Queries: #{summary[:total_queries]}\n\n"
|
|
290
|
+
|
|
291
|
+
puts "#{Color::BOLD}Segment | Queries | Clicks (% Share) | Imp (% Share) | CTR | Avg Pos#{Color::RESET}"
|
|
292
|
+
puts "----------------------------------------------------------------------------------"
|
|
293
|
+
b = summary[:brand]
|
|
294
|
+
nb = summary[:non_brand]
|
|
295
|
+
puts "#{Color.c('Brand', Color::GREEN).ljust(20)} | #{b[:queries_count].to_s.ljust(7)} | #{b[:clicks].to_s.ljust(4)} (#{b[:click_share]}%) | #{b[:impressions].to_s.ljust(5)} (#{b[:impression_share]}%) | #{b[:ctr]}%".ljust(64) + "| #{b[:avg_position]}"
|
|
296
|
+
puts "#{Color.c('Non-Brand', Color::MAGENTA).ljust(20)} | #{nb[:queries_count].to_s.ljust(7)} | #{nb[:clicks].to_s.ljust(4)} (#{nb[:click_share]}%) | #{nb[:impressions].to_s.ljust(5)} (#{nb[:impression_share]}%) | #{nb[:ctr]}%".ljust(64) + "| #{nb[:avg_position]}"
|
|
297
|
+
puts "----------------------------------------------------------------------------------"
|
|
298
|
+
|
|
299
|
+
puts "\n#{Color::BOLD}Top Brand Queries:#{Color::RESET}"
|
|
300
|
+
if segmented[:brand].empty?
|
|
301
|
+
puts Color.c(" (None detected)", Color::GRAY)
|
|
302
|
+
else
|
|
303
|
+
segmented[:brand].first(options[:limit] || 5).each do |q|
|
|
304
|
+
puts " • #{Color.c(q[:query], Color::GREEN)}: #{q[:clicks]} clicks, #{q[:impressions]} imp, pos #{q[:position]}"
|
|
305
|
+
end
|
|
306
|
+
end
|
|
307
|
+
|
|
308
|
+
puts "\n#{Color::BOLD}Top Non-Brand (Discovery) Queries:#{Color::RESET}"
|
|
309
|
+
if segmented[:non_brand].empty?
|
|
310
|
+
puts Color.c(" (None detected)", Color::GRAY)
|
|
311
|
+
else
|
|
312
|
+
segmented[:non_brand].first(options[:limit] || 5).each do |q|
|
|
313
|
+
puts " • #{Color.c(q[:query], Color::MAGENTA)}: #{q[:clicks]} clicks, #{q[:impressions]} imp, pos #{q[:position]}"
|
|
314
|
+
end
|
|
315
|
+
end
|
|
316
|
+
puts
|
|
317
|
+
end
|
|
318
|
+
else
|
|
319
|
+
if options[:json]
|
|
320
|
+
puts JSON.pretty_generate({ error: res[:data] })
|
|
321
|
+
else
|
|
322
|
+
puts Color.c("❌ Search Analytics Error (#{res[:status]}): #{res[:data]}", Color::RED)
|
|
323
|
+
end
|
|
324
|
+
end
|
|
325
|
+
end
|
|
326
|
+
|
|
327
|
+
def handle_top_pages(api, site_url, options)
|
|
328
|
+
sort_field, sort_order = Base.resolve_sort_params(options[:sort], options[:order])
|
|
329
|
+
sort_desc = sort_field ? "sorted by #{sort_field} #{sort_order}" : "sorted by clicks & impressions"
|
|
330
|
+
puts "📄 Fetching top landing pages for #{Color.c(site_url, Color::CYAN)} (Past #{options[:days]} days, #{sort_desc})...\n" unless options[:json]
|
|
331
|
+
|
|
332
|
+
res = if options[:all]
|
|
333
|
+
puts "🔄 Paginating across all page rows via startRow..." unless options[:json]
|
|
334
|
+
api.query_all_analytics(site_url, days: options[:days], dimensions: ['page'])
|
|
335
|
+
else
|
|
336
|
+
fetch_limit = [options[:limit] * 10, 1000].max
|
|
337
|
+
api.query_analytics(site_url, days: options[:days], dimensions: ['page'], row_limit: fetch_limit)
|
|
338
|
+
end
|
|
339
|
+
|
|
340
|
+
if res[:ok]
|
|
341
|
+
raw_rows = (res.dig(:data, 'rows') || []).map do |r|
|
|
342
|
+
{
|
|
343
|
+
page: r['keys'].first,
|
|
344
|
+
clicks: r['clicks'],
|
|
345
|
+
impressions: r['impressions'],
|
|
346
|
+
ctr: (r['ctr'] * 100).round(2),
|
|
347
|
+
position: r['position'].round(1)
|
|
348
|
+
}
|
|
349
|
+
end
|
|
350
|
+
|
|
351
|
+
sorted_rows = Base.sort_analytics_rows(raw_rows, options[:sort], options[:order])
|
|
352
|
+
rows = options[:all] ? sorted_rows : sorted_rows.first(options[:limit])
|
|
353
|
+
|
|
354
|
+
if options[:json]
|
|
355
|
+
puts JSON.pretty_generate(rows)
|
|
356
|
+
elsif rows.empty?
|
|
357
|
+
puts Color.c("ℹ️ No page performance data recorded in the last #{options[:days]} days.", Color::YELLOW)
|
|
358
|
+
else
|
|
359
|
+
puts "#{Color::BOLD}Clicks | Impressions | CTR | Position | Page URL#{Color::RESET}"
|
|
360
|
+
puts "--------------------------------------------------------------------------------"
|
|
361
|
+
rows.each do |row|
|
|
362
|
+
clicks = row[:clicks].to_s.ljust(6)
|
|
363
|
+
imp = row[:impressions].to_s.ljust(11)
|
|
364
|
+
ctr = "#{row[:ctr]}%".ljust(6)
|
|
365
|
+
pos = row[:position].to_s.ljust(8)
|
|
366
|
+
puts "#{clicks} | #{imp} | #{ctr} | #{pos} | #{Color.c(row[:page], Color::CYAN)}"
|
|
367
|
+
end
|
|
368
|
+
puts "\n#{Color.c("Total Pages Displayed: #{rows.size}", Color::GRAY)}\n"
|
|
369
|
+
|
|
370
|
+
if options[:csv]
|
|
371
|
+
csv_data = rows.map { |r| [r[:page], r[:clicks], r[:impressions], r[:ctr], r[:position]] }
|
|
372
|
+
Base.write_csv(options[:csv], %w[Page Clicks Impressions CTR Position], csv_data)
|
|
373
|
+
puts Color.c("📁 Exported pages to #{options[:csv]}", Color::CYAN)
|
|
374
|
+
end
|
|
375
|
+
end
|
|
376
|
+
else
|
|
377
|
+
if options[:json]
|
|
378
|
+
puts JSON.pretty_generate({ error: res[:data] })
|
|
379
|
+
else
|
|
380
|
+
puts Color.c("❌ Search Analytics Error (#{res[:status]}): #{res[:data]}", Color::RED)
|
|
381
|
+
end
|
|
382
|
+
end
|
|
383
|
+
end
|
|
384
|
+
|
|
385
|
+
def handle_ctr_curve(api, site_url, options)
|
|
386
|
+
target_pos = options[:target_pos] || 3
|
|
387
|
+
min_imp = options[:min_imp] || 10
|
|
388
|
+
limit = options[:limit] || 20
|
|
389
|
+
|
|
390
|
+
puts "📈 Modeling SERP CTR Curve & Traffic Gains for #{Color.c(site_url, Color::CYAN)} (Target: Pos #{target_pos}, Min #{min_imp} imp, Past #{options[:days]} days)...\n" unless options[:json]
|
|
391
|
+
|
|
392
|
+
res = api.query_all_analytics(site_url, days: options[:days], dimensions: ['query'])
|
|
393
|
+
if res[:ok]
|
|
394
|
+
raw_rows = (res.dig(:data, 'rows') || []).map do |r|
|
|
395
|
+
{
|
|
396
|
+
query: r['keys'].first,
|
|
397
|
+
clicks: r['clicks'],
|
|
398
|
+
impressions: r['impressions'],
|
|
399
|
+
ctr: (r['ctr'] * 100).round(2),
|
|
400
|
+
position: r['position'].round(1)
|
|
401
|
+
}
|
|
402
|
+
end
|
|
403
|
+
|
|
404
|
+
sim = GSC::CtrCurve.simulate(raw_rows, target_pos: target_pos, min_imp: min_imp)
|
|
405
|
+
opportunities = sim[:opportunities].first(limit)
|
|
406
|
+
|
|
407
|
+
if options[:json]
|
|
408
|
+
puts JSON.pretty_generate(sim)
|
|
409
|
+
else
|
|
410
|
+
puts "Target SERP Benchmark: Position #{target_pos} (#{sim[:target_ctr]}% Expected CTR)"
|
|
411
|
+
puts "Total Search Queries Modeled: #{sim[:total_queries_analyzed]}"
|
|
412
|
+
puts Color.c("\n🚀 Incremental Organic Potential: +#{sim[:total_incremental_clicks]} clicks/month if ranked at Top #{target_pos}!\n", Color::GREEN, Color::BOLD)
|
|
413
|
+
|
|
414
|
+
if opportunities.empty?
|
|
415
|
+
puts Color.c("ℹ️ No ranking queries met the threshold of >= #{min_imp} impressions.", Color::YELLOW)
|
|
416
|
+
else
|
|
417
|
+
puts "#{Color::BOLD}Clicks | +Gain | Imp | Pos | Act CTR | Exp CTR | Query#{Color::RESET}"
|
|
418
|
+
puts "----------------------------------------------------------------------------------"
|
|
419
|
+
opportunities.each do |o|
|
|
420
|
+
clicks = o[:current_clicks].to_s.ljust(6)
|
|
421
|
+
gain = "+#{o[:incremental_clicks]}".ljust(6)
|
|
422
|
+
imp = o[:impressions].to_s.ljust(9)
|
|
423
|
+
pos = o[:current_position].to_s.ljust(5)
|
|
424
|
+
act_ctr = "#{o[:current_ctr]}%".ljust(7)
|
|
425
|
+
exp_ctr = "#{o[:expected_ctr]}%".ljust(7)
|
|
426
|
+
gain_col = o[:incremental_clicks] > 0 ? Color.c(gain, Color::GREEN) : Color.c(gain, Color::GRAY)
|
|
427
|
+
puts "#{clicks} | #{gain_col} | #{imp} | #{pos} | #{act_ctr} | #{exp_ctr} | #{Color.c(o[:query], Color::CYAN)}"
|
|
428
|
+
end
|
|
429
|
+
puts "\n#{Color.c("Showing top #{opportunities.size} highest-leverage ranking opportunities", Color::GRAY)}\n"
|
|
430
|
+
|
|
431
|
+
if sim[:underperformers].any?
|
|
432
|
+
puts "\n#{Color::BOLD}🎯 Immediate Snippet CTR Fixes (Page 1 Rankings Underperforming CTR Benchmark):#{Color::RESET}"
|
|
433
|
+
sim[:underperformers].first(5).each do |u|
|
|
434
|
+
loss = [((u[:expected_ctr] / 100.0) * u[:impressions]).round - u[:current_clicks], 0].max
|
|
435
|
+
puts " • #{Color.c(u[:query], Color::YELLOW)}: Pos #{u[:current_position]} (Actual: #{u[:current_ctr]}% vs Exp: #{u[:expected_ctr]}%) ➔ ~#{loss} clicks lost"
|
|
436
|
+
end
|
|
437
|
+
end
|
|
438
|
+
end
|
|
439
|
+
puts
|
|
440
|
+
end
|
|
441
|
+
else
|
|
442
|
+
if options[:json]
|
|
443
|
+
puts JSON.pretty_generate({ error: res[:data] })
|
|
444
|
+
else
|
|
445
|
+
puts Color.c("❌ Search Analytics Error (#{res[:status]}): #{res[:data]}", Color::RED)
|
|
446
|
+
end
|
|
447
|
+
end
|
|
448
|
+
end
|
|
449
|
+
|
|
450
|
+
def handle_decay(api, site_url, options)
|
|
451
|
+
window_days = options[:compare] || 28
|
|
452
|
+
dim = options[:dimension] || 'page'
|
|
453
|
+
dim_label = (dim == 'page') ? 'Landing Pages' : 'Search Queries'
|
|
454
|
+
|
|
455
|
+
puts "📉 Analyzing ranking & traffic decay for #{Color.c(site_url, Color::CYAN)} (#{dim_label}, Past #{window_days}d)...\n" unless options[:json]
|
|
456
|
+
|
|
457
|
+
end_current = Date.today - 2
|
|
458
|
+
start_current = end_current - window_days
|
|
459
|
+
|
|
460
|
+
ts_res = api.query_analytics(
|
|
461
|
+
site_url,
|
|
462
|
+
start_date: start_current.iso8601,
|
|
463
|
+
end_date: end_current.iso8601,
|
|
464
|
+
dimensions: [dim, 'date'],
|
|
465
|
+
row_limit: 5000
|
|
466
|
+
)
|
|
467
|
+
|
|
468
|
+
if ts_res[:ok]
|
|
469
|
+
raw_rows = ts_res.dig(:data, 'rows') || []
|
|
470
|
+
analysis = GSC::DecayPredictor.analyze_timeseries(raw_rows, dimension: dim, min_imp: options[:min_imp] || 10)
|
|
471
|
+
|
|
472
|
+
if options[:json]
|
|
473
|
+
puts JSON.pretty_generate(analysis)
|
|
474
|
+
else
|
|
475
|
+
score_color = analysis[:health_score] >= 80 ? Color::GREEN : (analysis[:health_score] >= 65 ? Color::YELLOW : Color::RED)
|
|
476
|
+
puts "#{Color::BOLD}📊 SEARCH CONSOLE CONTENT DECAY & VELOCITY AUDIT#{Color::RESET}"
|
|
477
|
+
puts "#{Color::BOLD}══════════════════════════════════════════════════════════════#{Color::RESET}"
|
|
478
|
+
puts " Health Score: #{Color.c("#{analysis[:health_score]}/100 [Grade #{analysis[:health_grade]}]", score_color, Color::BOLD)}"
|
|
479
|
+
puts " Evaluated #{dim_label}: #{analysis[:total_evaluated]}"
|
|
480
|
+
puts " 🔻 Decaying Assets: #{Color.c(analysis[:decaying_count].to_s, Color::RED, Color::BOLD)}"
|
|
481
|
+
puts " 🚀 Surging Assets: #{Color.c(analysis[:surging_count].to_s, Color::GREEN, Color::BOLD)}"
|
|
482
|
+
puts " ⚖️ Stable Assets: #{Color.c(analysis[:stable_count].to_s, Color::CYAN)}"
|
|
483
|
+
puts "#{Color::BOLD}══════════════════════════════════════════════════════════════#{Color::RESET}\n"
|
|
484
|
+
|
|
485
|
+
limit = options[:limit] || 15
|
|
486
|
+
if analysis[:decaying].any?
|
|
487
|
+
puts "#{Color::BOLD}🔻 TOP DECAYING #{dim_label.upcase} (Slope & Velocity Loss):#{Color::RESET}"
|
|
488
|
+
analysis[:decaying].first(limit).each_with_index do |d, i|
|
|
489
|
+
sev_color = case d[:severity]
|
|
490
|
+
when 'CRITICAL' then Color::RED
|
|
491
|
+
when 'HIGH' then Color::YELLOW
|
|
492
|
+
else Color::CYAN
|
|
493
|
+
end
|
|
494
|
+
badge = Color.c("[#{d[:classification]}]", sev_color, Color::BOLD)
|
|
495
|
+
w_str = d[:weekly_impressions].join(' → ')
|
|
496
|
+
slope_str = Color.c("#{d[:impression_slope]} imp/wk", Color::RED)
|
|
497
|
+
drift_str = d[:position_drift] > 0 ? Color.c("Rank Drift: +#{d[:position_drift]}", Color::YELLOW) : "Rank Drift: #{d[:position_drift]}"
|
|
498
|
+
|
|
499
|
+
entity_display = (dim == 'page') ? d[:entity].sub(%r{^https?://[^/]+}, '') : "\"#{d[:entity]}\""
|
|
500
|
+
entity_display = '/' if entity_display.empty?
|
|
501
|
+
|
|
502
|
+
puts "#{Color::BOLD}#{i + 1}. #{badge} #{entity_display}#{Color::RESET}"
|
|
503
|
+
puts " • Weekly Imp: #{w_str} (Slope: #{slope_str})"
|
|
504
|
+
puts " • Velocity: 7d: #{d[:vel_7d_imp_pct]}% | 14d: #{d[:vel_14d_imp_pct]}% | 28d: #{d[:vel_28d_imp_pct]}%"
|
|
505
|
+
puts " • SERP Position: Prior: #{d[:prior_position] || '-'} → Curr: #{d[:current_position] || '-'} (#{drift_str})"
|
|
506
|
+
if d[:projected_monthly_clicks_lost] > 0
|
|
507
|
+
puts " • Traffic Risk: #{Color.c("~#{d[:projected_monthly_clicks_lost]} clicks/mo lost", Color::RED)} if slope continues"
|
|
508
|
+
end
|
|
509
|
+
puts " 👉 #{Color.c('Prescription:', Color::BOLD)} #{d[:prescription]}\n\n"
|
|
510
|
+
end
|
|
511
|
+
else
|
|
512
|
+
puts Color.c("✅ Zero content decay detected! All #{dim_label.downcase} maintain positive or stable velocity slopes.\n", Color::GREEN)
|
|
513
|
+
end
|
|
514
|
+
|
|
515
|
+
if analysis[:surging].any?
|
|
516
|
+
puts "#{Color::BOLD}🚀 TOP SURGING #{dim_label.upcase} (Positive Velocity):#{Color::RESET}"
|
|
517
|
+
analysis[:surging].first(limit).each do |s|
|
|
518
|
+
entity_display = (dim == 'page') ? s[:entity].sub(%r{^https?://[^/]+}, '') : "\"#{s[:entity]}\""
|
|
519
|
+
entity_display = '/' if entity_display.empty?
|
|
520
|
+
gain_str = Color.c("+#{s[:vel_14d_imp_pct]}%", Color::GREEN, Color::BOLD)
|
|
521
|
+
puts " • #{entity_display.ljust(45)} | 14d Gain: #{gain_str} | Slope: +#{s[:impression_slope]} imp/wk"
|
|
522
|
+
end
|
|
523
|
+
puts
|
|
524
|
+
end
|
|
525
|
+
|
|
526
|
+
if options[:csv]
|
|
527
|
+
csv_rows = analysis[:decaying].map do |d|
|
|
528
|
+
[d[:entity], d[:classification], d[:severity], d[:total_impressions], d[:total_clicks], d[:vel_7d_imp_pct], d[:vel_14d_imp_pct], d[:vel_28d_imp_pct], d[:impression_slope], d[:click_slope], d[:prior_position], d[:current_position], d[:position_drift], d[:projected_monthly_clicks_lost], d[:prescription]]
|
|
529
|
+
end
|
|
530
|
+
Base.write_csv(options[:csv], %w[Entity Classification Severity TotalImpressions TotalClicks Vel7dImpPct Vel14dImpPct Vel28dImpPct ImpSlope ClickSlope PriorPosition CurrentPosition PositionDrift ProjectedClicksLost Prescription], csv_rows)
|
|
531
|
+
puts Color.c("📁 Exported decay & velocity analysis to #{options[:csv]}", Color::CYAN)
|
|
532
|
+
end
|
|
533
|
+
end
|
|
534
|
+
else
|
|
535
|
+
err = ts_res[:data]
|
|
536
|
+
if options[:json]
|
|
537
|
+
puts JSON.pretty_generate({ error: err })
|
|
538
|
+
else
|
|
539
|
+
puts Color.c("❌ Search Analytics Error: #{Base.format_api_error(err)}", Color::RED)
|
|
540
|
+
end
|
|
541
|
+
end
|
|
542
|
+
end
|
|
543
|
+
|
|
544
|
+
def handle_devices(api, site_url, options)
|
|
545
|
+
puts "📱 Fetching device breakdown for #{Color.c(site_url, Color::CYAN)} (Past #{options[:days]} days)...\n" unless options[:json]
|
|
546
|
+
res = api.query_analytics(site_url, days: options[:days], dimensions: ['device'])
|
|
547
|
+
if res[:ok]
|
|
548
|
+
tot_res = api.query_analytics(site_url, days: options[:days], dimensions: [])
|
|
549
|
+
tot_clicks = tot_res.dig(:data, 'rows', 0, 'clicks') || 0
|
|
550
|
+
rows = (res.dig(:data, 'rows') || []).map do |r|
|
|
551
|
+
c = r['clicks'] || 0
|
|
552
|
+
i = r['impressions'] || 0
|
|
553
|
+
ctr = ((r['ctr'] || 0) * 100).round(2)
|
|
554
|
+
pos = (r['position'] || 0).round(1)
|
|
555
|
+
share = tot_clicks > 0 ? "#{(c.to_f / tot_clicks * 100).round(1)}%" : "-"
|
|
556
|
+
{ device: r['keys'].first, clicks: c, impressions: i, ctr: ctr, position: pos, share: share }
|
|
557
|
+
end.sort_by { |r| -r[:clicks] }
|
|
558
|
+
|
|
559
|
+
if options[:json]
|
|
560
|
+
puts JSON.pretty_generate(rows)
|
|
561
|
+
elsif rows.empty?
|
|
562
|
+
puts Color.c("ℹ️ No device data recorded in the last #{options[:days]} days.", Color::YELLOW)
|
|
563
|
+
else
|
|
564
|
+
print_devices_table(rows)
|
|
565
|
+
puts
|
|
566
|
+
if options[:csv]
|
|
567
|
+
Base.write_csv(options[:csv], %w[Device Clicks Impressions CTR Position Share], rows.map { |r| [r[:device], r[:clicks], r[:impressions], r[:ctr], r[:position], r[:share]] })
|
|
568
|
+
puts Color.c("📁 Exported device breakdown to #{options[:csv]}", Color::CYAN)
|
|
569
|
+
end
|
|
570
|
+
end
|
|
571
|
+
else
|
|
572
|
+
if options[:json]
|
|
573
|
+
puts JSON.pretty_generate({ error: res[:data] })
|
|
574
|
+
else
|
|
575
|
+
puts Color.c("❌ Device API Error (#{res[:status]}): #{res[:data]}", Color::RED)
|
|
576
|
+
end
|
|
577
|
+
end
|
|
578
|
+
end
|
|
579
|
+
|
|
580
|
+
def handle_countries(api, site_url, options)
|
|
581
|
+
limit = options[:limit] || 20
|
|
582
|
+
puts "🌍 Fetching top #{limit} countries for #{Color.c(site_url, Color::CYAN)} (Past #{options[:days]} days)...\n" unless options[:json]
|
|
583
|
+
res = api.query_analytics(site_url, days: options[:days], dimensions: ['country'], row_limit: limit)
|
|
584
|
+
if res[:ok]
|
|
585
|
+
rows = (res.dig(:data, 'rows') || []).map do |r|
|
|
586
|
+
{
|
|
587
|
+
country: r['keys'].first,
|
|
588
|
+
clicks: r['clicks'] || 0,
|
|
589
|
+
impressions: r['impressions'] || 0,
|
|
590
|
+
ctr: ((r['ctr'] || 0) * 100).round(2),
|
|
591
|
+
position: (r['position'] || 0).round(1)
|
|
592
|
+
}
|
|
593
|
+
end.sort_by { |r| -r[:clicks] }
|
|
594
|
+
|
|
595
|
+
if options[:json]
|
|
596
|
+
puts JSON.pretty_generate(rows)
|
|
597
|
+
elsif rows.empty?
|
|
598
|
+
puts Color.c("ℹ️ No country data recorded in the last #{options[:days]} days.", Color::YELLOW)
|
|
599
|
+
else
|
|
600
|
+
print_countries_table(rows)
|
|
601
|
+
puts
|
|
602
|
+
if options[:csv]
|
|
603
|
+
Base.write_csv(options[:csv], %w[Country Clicks Impressions CTR Position], rows.map { |r| [r[:country], r[:clicks], r[:impressions], r[:ctr], r[:position]] })
|
|
604
|
+
puts Color.c("📁 Exported country breakdown to #{options[:csv]}", Color::CYAN)
|
|
605
|
+
end
|
|
606
|
+
end
|
|
607
|
+
else
|
|
608
|
+
if options[:json]
|
|
609
|
+
puts JSON.pretty_generate({ error: res[:data] })
|
|
610
|
+
else
|
|
611
|
+
puts Color.c("❌ Country API Error (#{res[:status]}): #{res[:data]}", Color::RED)
|
|
612
|
+
end
|
|
613
|
+
end
|
|
614
|
+
end
|
|
615
|
+
|
|
616
|
+
def handle_snippets(api, site_url, options)
|
|
617
|
+
puts "✨ Fetching search appearance & rich snippets for #{Color.c(site_url, Color::CYAN)} (Past #{options[:days]} days)...\n" unless options[:json]
|
|
618
|
+
res = api.query_analytics(site_url, days: options[:days], dimensions: ['searchAppearance'], row_limit: 20)
|
|
619
|
+
if res[:ok]
|
|
620
|
+
rows = (res.dig(:data, 'rows') || []).map do |r|
|
|
621
|
+
{
|
|
622
|
+
type: r['keys'].first,
|
|
623
|
+
clicks: r['clicks'] || 0,
|
|
624
|
+
impressions: r['impressions'] || 0,
|
|
625
|
+
ctr: ((r['ctr'] || 0) * 100).round(2),
|
|
626
|
+
position: (r['position'] || 0).round(1)
|
|
627
|
+
}
|
|
628
|
+
end.sort_by { |r| -r[:clicks] }
|
|
629
|
+
|
|
630
|
+
if options[:json]
|
|
631
|
+
puts JSON.pretty_generate(rows)
|
|
632
|
+
else
|
|
633
|
+
print_snippets_table(rows)
|
|
634
|
+
puts
|
|
635
|
+
if options[:csv]
|
|
636
|
+
Base.write_csv(options[:csv], %w[AppearanceType Clicks Impressions CTR Position], rows.map { |r| [r[:type], r[:clicks], r[:impressions], r[:ctr], r[:position]] })
|
|
637
|
+
puts Color.c("📁 Exported rich snippets to #{options[:csv]}", Color::CYAN)
|
|
638
|
+
end
|
|
639
|
+
end
|
|
640
|
+
else
|
|
641
|
+
if options[:json]
|
|
642
|
+
puts JSON.pretty_generate({ error: res[:data] })
|
|
643
|
+
else
|
|
644
|
+
puts Color.c("❌ Search Appearance API Error (#{res[:status]}): #{res[:data]}", Color::RED)
|
|
645
|
+
end
|
|
646
|
+
end
|
|
647
|
+
end
|
|
648
|
+
|
|
649
|
+
def handle_cities(api, hostname, options)
|
|
650
|
+
property_id = options[:property] || Config.ga4_property_id(hostname)
|
|
651
|
+
unless property_id
|
|
652
|
+
if options[:json]
|
|
653
|
+
puts JSON.pretty_generate({ error: "No GA4 Property ID linked for #{hostname}. Link with `gsc config set-ga4 <id>` or `gsc connect-ga4`." })
|
|
654
|
+
else
|
|
655
|
+
puts Color.c("\n❌ No GA4 Property ID linked for #{hostname}!", Color::RED, Color::BOLD)
|
|
656
|
+
puts "Link your GA4 property ID using:\n #{Color.c("gsc connect-ga4", Color::GREEN, Color::BOLD)} (interactive wizard)"
|
|
657
|
+
puts " #{Color.c("gsc config set-ga4 <property_id>", Color::CYAN)} (direct link)\n"
|
|
658
|
+
end
|
|
659
|
+
return
|
|
660
|
+
end
|
|
661
|
+
|
|
662
|
+
limit = options[:limit] || 20
|
|
663
|
+
puts "🏙️ Fetching top #{limit} cities for #{Color.c(hostname, Color::CYAN)} [Property #{property_id}] (Past #{options[:days]} days)...\n" unless options[:json]
|
|
664
|
+
res = api.query_ga4_cities(property_id, days: options[:days], limit: limit, hostname: (options[:all_hosts] ? nil : hostname), site_only: options[:site_only])
|
|
665
|
+
if res[:ok]
|
|
666
|
+
rows = (res.dig(:data, 'rows') || []).map do |cr|
|
|
667
|
+
city = cr.dig('dimensionValues', 0, 'value') || '(unknown)'
|
|
668
|
+
country = cr.dig('dimensionValues', 1, 'value') || '(unknown)'
|
|
669
|
+
sess = (cr.dig('metricValues', 0, 'value') || 0).to_i
|
|
670
|
+
bounce = ((cr.dig('metricValues', 1, 'value') || 0).to_f * 100).round(1)
|
|
671
|
+
dur = (cr.dig('metricValues', 2, 'value') || 0).to_f.round
|
|
672
|
+
{ city: city, country: country, sessions: sess, bounce_rate: "#{bounce}%", duration: Base.format_duration(dur) }
|
|
673
|
+
end
|
|
674
|
+
|
|
675
|
+
if options[:json]
|
|
676
|
+
puts JSON.pretty_generate(rows)
|
|
677
|
+
elsif rows.empty?
|
|
678
|
+
puts Color.c("ℹ️ No city data recorded in the last #{options[:days]} days.", Color::YELLOW)
|
|
679
|
+
else
|
|
680
|
+
print_cities_table(rows, property_id)
|
|
681
|
+
puts
|
|
682
|
+
if options[:csv]
|
|
683
|
+
Base.write_csv(options[:csv], %w[City Country Sessions BounceRate AvgDuration], rows.map { |r| [r[:city], r[:country], r[:sessions], r[:bounce_rate], r[:duration]] })
|
|
684
|
+
puts Color.c("📁 Exported city breakdown to #{options[:csv]}", Color::CYAN)
|
|
685
|
+
end
|
|
686
|
+
end
|
|
687
|
+
else
|
|
688
|
+
puts Color.c("❌ GA4 API Error (#{res[:status]}): #{res[:data]}", Color::RED)
|
|
689
|
+
end
|
|
690
|
+
end
|
|
691
|
+
|
|
692
|
+
# Table formatters
|
|
693
|
+
def print_devices_table(rows)
|
|
694
|
+
puts "\n#{Color::BOLD}📱 DEVICE BREAKDOWN (Search Traffic)#{Color::RESET}"
|
|
695
|
+
puts "Device | Clicks | Impressions | CTR | Position | Clicks Share"
|
|
696
|
+
puts "-------------------------------------------------------------------------"
|
|
697
|
+
rows.each do |r|
|
|
698
|
+
d_name = Base.format_device(r[:device]).ljust(12)
|
|
699
|
+
c_str = Base.format_number(r[:clicks]).ljust(8)
|
|
700
|
+
i_str = Base.format_number(r[:impressions]).ljust(11)
|
|
701
|
+
ctr_str = "#{r[:ctr]}%".ljust(6)
|
|
702
|
+
pos_str = r[:position].to_s.ljust(8)
|
|
703
|
+
share_str = r[:share].to_s
|
|
704
|
+
puts "#{d_name} | #{c_str} | #{i_str} | #{ctr_str} | #{pos_str} | #{share_str}"
|
|
705
|
+
end
|
|
706
|
+
end
|
|
707
|
+
|
|
708
|
+
def print_countries_table(rows)
|
|
709
|
+
puts "\n#{Color::BOLD}🌍 TOP COUNTRIES (Search Demand)#{Color::RESET}"
|
|
710
|
+
puts "Country | Clicks | Impressions | CTR | Position"
|
|
711
|
+
puts "-------------------------------------------------------------------------"
|
|
712
|
+
rows.each do |r|
|
|
713
|
+
c_name = Base.format_country(r[:country])[0..21].ljust(22)
|
|
714
|
+
c_str = Base.format_number(r[:clicks]).ljust(8)
|
|
715
|
+
i_str = Base.format_number(r[:impressions]).ljust(11)
|
|
716
|
+
ctr_str = "#{r[:ctr]}%".ljust(6)
|
|
717
|
+
pos_str = r[:position].to_s
|
|
718
|
+
puts "#{c_name} | #{c_str} | #{i_str} | #{ctr_str} | #{pos_str}"
|
|
719
|
+
end
|
|
720
|
+
end
|
|
721
|
+
|
|
722
|
+
def print_snippets_table(rows)
|
|
723
|
+
puts "\n#{Color::BOLD}✨ SEARCH APPEARANCE & RICH SNIPPETS#{Color::RESET}"
|
|
724
|
+
if rows.empty?
|
|
725
|
+
puts Color.c(" (No rich snippet enhancements detected in this date range)\n", Color::GRAY)
|
|
726
|
+
else
|
|
727
|
+
puts "Appearance Type | Clicks | Impressions | CTR | Position"
|
|
728
|
+
puts "-------------------------------------------------------------------------"
|
|
729
|
+
rows.each do |r|
|
|
730
|
+
t_name = Base.format_appearance(r[:type])[0..21].ljust(22)
|
|
731
|
+
c_str = Base.format_number(r[:clicks]).ljust(8)
|
|
732
|
+
i_str = Base.format_number(r[:impressions]).ljust(11)
|
|
733
|
+
ctr_str = "#{r[:ctr]}%".ljust(6)
|
|
734
|
+
pos_str = r[:position].to_s
|
|
735
|
+
puts "#{t_name} | #{c_str} | #{i_str} | #{ctr_str} | #{pos_str}"
|
|
736
|
+
end
|
|
737
|
+
end
|
|
738
|
+
end
|
|
739
|
+
|
|
740
|
+
def print_cities_table(rows, property_id = nil)
|
|
741
|
+
prop_label = property_id ? " [Property #{property_id}]" : ""
|
|
742
|
+
puts "\n#{Color::BOLD}🏙️ TOP CITIES & ON-SITE ENGAGEMENT#{prop_label}#{Color::RESET}"
|
|
743
|
+
if rows.empty?
|
|
744
|
+
puts Color.c(" (No city-level behavioral data available in GA4)\n", Color::GRAY)
|
|
745
|
+
else
|
|
746
|
+
puts "City | Country | Sessions | Bounce | Avg Time"
|
|
747
|
+
puts "-------------------------------------------------------------------------"
|
|
748
|
+
rows.each do |r|
|
|
749
|
+
city_str = r[:city][0..19].ljust(20)
|
|
750
|
+
cntry_str = r[:country][0..17].ljust(18)
|
|
751
|
+
sess_str = Base.format_number(r[:sessions]).ljust(8)
|
|
752
|
+
bounce_str = "#{r[:bounce_rate]}".ljust(6)
|
|
753
|
+
dur_str = r[:duration].to_s
|
|
754
|
+
puts "#{city_str} | #{cntry_str} | #{sess_str} | #{bounce_str} | #{dur_str}"
|
|
755
|
+
end
|
|
756
|
+
end
|
|
757
|
+
end
|
|
758
|
+
|
|
759
|
+
def print_mini_queries_table(rows)
|
|
760
|
+
puts "\n#{Color::BOLD}🔍 TOP SEARCH QUERIES#{Color::RESET}"
|
|
761
|
+
puts "Clicks | Impressions | CTR | Position | Query"
|
|
762
|
+
puts "-------------------------------------------------------------------------"
|
|
763
|
+
rows.each do |r|
|
|
764
|
+
c_str = Base.format_number(r[:clicks]).ljust(6)
|
|
765
|
+
i_str = Base.format_number(r[:impressions]).ljust(11)
|
|
766
|
+
ctr_str = "#{r[:ctr]}%".ljust(6)
|
|
767
|
+
pos_str = r[:position].to_s.ljust(8)
|
|
768
|
+
q_str = Color.c(r[:query], Color::YELLOW)
|
|
769
|
+
puts "#{c_str} | #{i_str} | #{ctr_str} | #{pos_str} | #{q_str}"
|
|
770
|
+
end
|
|
771
|
+
end
|
|
772
|
+
|
|
773
|
+
def print_mini_pages_table(rows)
|
|
774
|
+
puts "\n#{Color::BOLD}📄 TOP LANDING PAGES#{Color::RESET}"
|
|
775
|
+
puts "Clicks | Impressions | CTR | Position | Page Path"
|
|
776
|
+
puts "-------------------------------------------------------------------------"
|
|
777
|
+
rows.each do |r|
|
|
778
|
+
c_str = Base.format_number(r[:clicks]).ljust(6)
|
|
779
|
+
i_str = Base.format_number(r[:impressions]).ljust(11)
|
|
780
|
+
ctr_str = "#{r[:ctr]}%".ljust(6)
|
|
781
|
+
pos_str = r[:position].to_s.ljust(8)
|
|
782
|
+
p_str = Color.c(r[:path], Color::CYAN)
|
|
783
|
+
puts "#{c_str} | #{i_str} | #{ctr_str} | #{pos_str} | #{p_str}"
|
|
784
|
+
end
|
|
785
|
+
end
|
|
786
|
+
end
|
|
787
|
+
end
|
|
788
|
+
end
|