gsc-cli 2.1.0 → 2.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (100) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +410 -414
  3. data/bin/gsc +28067 -5661
  4. data/dist/gsc +29121 -5046
  5. data/lib/gsc/aio_hunter.rb +343 -0
  6. data/lib/gsc/answer_synthesizer.rb +157 -0
  7. data/lib/gsc/api.rb +53 -1
  8. data/lib/gsc/auth.rb +26 -0
  9. data/lib/gsc/brand_segmenter.rb +140 -0
  10. data/lib/gsc/cache_manager.rb +806 -0
  11. data/lib/gsc/cannibalization_analyzer.rb +141 -0
  12. data/lib/gsc/canonical_chains.rb +367 -0
  13. data/lib/gsc/citation_simulator.rb +339 -0
  14. data/lib/gsc/cli/aio_hunter.rb +154 -0
  15. data/lib/gsc/cli/analytics.rb +788 -0
  16. data/lib/gsc/cli/audit.rb +1976 -0
  17. data/lib/gsc/cli/base.rb +384 -0
  18. data/lib/gsc/cli/cache.rb +266 -0
  19. data/lib/gsc/cli/canonical.rb +223 -0
  20. data/lib/gsc/cli/citation_simulator.rb +152 -0
  21. data/lib/gsc/cli/dashboard.rb +354 -0
  22. data/lib/gsc/cli/doctor.rb +129 -0
  23. data/lib/gsc/cli/eeat.rb +125 -0
  24. data/lib/gsc/cli/ga4.rb +852 -0
  25. data/lib/gsc/cli/growth.rb +650 -0
  26. data/lib/gsc/cli/hreflang.rb +164 -0
  27. data/lib/gsc/cli/image_seo.rb +162 -0
  28. data/lib/gsc/cli/indexing.rb +458 -0
  29. data/lib/gsc/cli/intent_shift.rb +125 -0
  30. data/lib/gsc/cli/keyword_value.rb +134 -0
  31. data/lib/gsc/cli/keywords.rb +795 -0
  32. data/lib/gsc/cli/landing_roi.rb +308 -0
  33. data/lib/gsc/cli/low_ctr.rb +213 -0
  34. data/lib/gsc/cli/mobile_parity.rb +150 -0
  35. data/lib/gsc/cli/report.rb +100 -0
  36. data/lib/gsc/cli/rich_results.rb +172 -0
  37. data/lib/gsc/cli/schema_generate.rb +149 -0
  38. data/lib/gsc/cli/seasonal.rb +232 -0
  39. data/lib/gsc/cli/security.rb +153 -0
  40. data/lib/gsc/cli/setup.rb +1291 -0
  41. data/lib/gsc/cli/sitemap_tree.rb +143 -0
  42. data/lib/gsc/cli/skill_pack.rb +62 -0
  43. data/lib/gsc/cli/soft_404.rb +199 -0
  44. data/lib/gsc/cli/sparkline.rb +227 -0
  45. data/lib/gsc/cli/watchdog.rb +150 -0
  46. data/lib/gsc/cli/zombie_purger.rb +208 -0
  47. data/lib/gsc/cli.rb +707 -5265
  48. data/lib/gsc/cli_advanced.rb +987 -44
  49. data/lib/gsc/client.rb +17 -2
  50. data/lib/gsc/color.rb +16 -1
  51. data/lib/gsc/command_registry.rb +47 -9
  52. data/lib/gsc/config.rb +2 -2
  53. data/lib/gsc/ctr_curve.rb +115 -0
  54. data/lib/gsc/decay_predictor.rb +322 -0
  55. data/lib/gsc/doctor.rb +434 -0
  56. data/lib/gsc/eeat_auditor.rb +428 -0
  57. data/lib/gsc/entity_auditor.rb +229 -0
  58. data/lib/gsc/firewall_scanner.rb +733 -0
  59. data/lib/gsc/geo_auditor.rb +368 -0
  60. data/lib/gsc/google_trends.rb +8 -1
  61. data/lib/gsc/heading_validator.rb +283 -0
  62. data/lib/gsc/hreflang_validator.rb +412 -0
  63. data/lib/gsc/image_seo.rb +286 -0
  64. data/lib/gsc/indexing_queue.rb +179 -0
  65. data/lib/gsc/indexnow.rb +93 -0
  66. data/lib/gsc/intent_shift.rb +188 -0
  67. data/lib/gsc/internal_links.rb +153 -36
  68. data/lib/gsc/keyword_value.rb +191 -0
  69. data/lib/gsc/landing_roi.rb +195 -0
  70. data/lib/gsc/llms_generator.rb +343 -22
  71. data/lib/gsc/low_ctr_rewriter.rb +408 -0
  72. data/lib/gsc/mobile_parity.rb +222 -0
  73. data/lib/gsc/network_tracer.rb +8 -1
  74. data/lib/gsc/page_analyzer.rb +47 -7
  75. data/lib/gsc/prompts.rb +38 -29
  76. data/lib/gsc/questions_harvester.rb +178 -0
  77. data/lib/gsc/report_generator.rb +461 -0
  78. data/lib/gsc/rich_results.rb +388 -0
  79. data/lib/gsc/robots_checker.rb +46 -15
  80. data/lib/gsc/schema_generator.rb +788 -0
  81. data/lib/gsc/schema_validator.rb +36 -38
  82. data/lib/gsc/seasonal_predictor.rb +381 -0
  83. data/lib/gsc/security_scanner.rb +496 -0
  84. data/lib/gsc/serp_feature_detector.rb +359 -0
  85. data/lib/gsc/serp_preview.rb +108 -22
  86. data/lib/gsc/site_crawler.rb +113 -21
  87. data/lib/gsc/sitemap_loader.rb +15 -4
  88. data/lib/gsc/sitemap_tree.rb +301 -0
  89. data/lib/gsc/skill_pack.rb +195 -0
  90. data/lib/gsc/soft_404_analyzer.rb +385 -0
  91. data/lib/gsc/sparkline.rb +171 -0
  92. data/lib/gsc/speed_correlator.rb +416 -0
  93. data/lib/gsc/striking_playbook.rb +190 -0
  94. data/lib/gsc/title_optimizer.rb +420 -0
  95. data/lib/gsc/vault.rb +260 -0
  96. data/lib/gsc/version.rb +1 -1
  97. data/lib/gsc/watchdog.rb +235 -0
  98. data/lib/gsc/zombie_purger.rb +366 -0
  99. data/lib/gsc.rb +118 -0
  100. 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