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.
Files changed (108) hide show
  1. checksums.yaml +4 -4
  2. data/AUTH.md +205 -0
  3. data/FUNDING.md +120 -0
  4. data/README.md +463 -299
  5. data/bin/gsc +29158 -4921
  6. data/dist/gsc +29158 -4921
  7. data/lib/gsc/aio_hunter.rb +343 -0
  8. data/lib/gsc/answer_synthesizer.rb +157 -0
  9. data/lib/gsc/api.rb +53 -1
  10. data/lib/gsc/auth.rb +26 -0
  11. data/lib/gsc/backlinks_manager.rb +96 -0
  12. data/lib/gsc/brand_segmenter.rb +140 -0
  13. data/lib/gsc/cache_manager.rb +806 -0
  14. data/lib/gsc/cannibalization_analyzer.rb +141 -0
  15. data/lib/gsc/canonical_chains.rb +367 -0
  16. data/lib/gsc/citation_simulator.rb +339 -0
  17. data/lib/gsc/cli/aio_hunter.rb +154 -0
  18. data/lib/gsc/cli/analytics.rb +788 -0
  19. data/lib/gsc/cli/audit.rb +1976 -0
  20. data/lib/gsc/cli/base.rb +384 -0
  21. data/lib/gsc/cli/cache.rb +266 -0
  22. data/lib/gsc/cli/canonical.rb +223 -0
  23. data/lib/gsc/cli/citation_simulator.rb +152 -0
  24. data/lib/gsc/cli/dashboard.rb +354 -0
  25. data/lib/gsc/cli/doctor.rb +129 -0
  26. data/lib/gsc/cli/eeat.rb +125 -0
  27. data/lib/gsc/cli/ga4.rb +852 -0
  28. data/lib/gsc/cli/growth.rb +650 -0
  29. data/lib/gsc/cli/hreflang.rb +164 -0
  30. data/lib/gsc/cli/image_seo.rb +162 -0
  31. data/lib/gsc/cli/indexing.rb +458 -0
  32. data/lib/gsc/cli/intent_shift.rb +125 -0
  33. data/lib/gsc/cli/keyword_value.rb +134 -0
  34. data/lib/gsc/cli/keywords.rb +795 -0
  35. data/lib/gsc/cli/landing_roi.rb +308 -0
  36. data/lib/gsc/cli/low_ctr.rb +213 -0
  37. data/lib/gsc/cli/mobile_parity.rb +150 -0
  38. data/lib/gsc/cli/report.rb +100 -0
  39. data/lib/gsc/cli/rich_results.rb +172 -0
  40. data/lib/gsc/cli/schema_generate.rb +149 -0
  41. data/lib/gsc/cli/seasonal.rb +232 -0
  42. data/lib/gsc/cli/security.rb +153 -0
  43. data/lib/gsc/cli/setup.rb +1291 -0
  44. data/lib/gsc/cli/sitemap_tree.rb +143 -0
  45. data/lib/gsc/cli/skill_pack.rb +62 -0
  46. data/lib/gsc/cli/soft_404.rb +199 -0
  47. data/lib/gsc/cli/sparkline.rb +227 -0
  48. data/lib/gsc/cli/watchdog.rb +150 -0
  49. data/lib/gsc/cli/zombie_purger.rb +208 -0
  50. data/lib/gsc/cli.rb +706 -5111
  51. data/lib/gsc/cli_advanced.rb +1513 -0
  52. data/lib/gsc/client.rb +17 -2
  53. data/lib/gsc/color.rb +16 -1
  54. data/lib/gsc/command_registry.rb +47 -9
  55. data/lib/gsc/config.rb +11 -2
  56. data/lib/gsc/content_gap.rb +112 -0
  57. data/lib/gsc/ctr_curve.rb +115 -0
  58. data/lib/gsc/decay_predictor.rb +322 -0
  59. data/lib/gsc/doctor.rb +434 -0
  60. data/lib/gsc/eeat_auditor.rb +428 -0
  61. data/lib/gsc/entity_auditor.rb +229 -0
  62. data/lib/gsc/firewall_scanner.rb +733 -0
  63. data/lib/gsc/geo_auditor.rb +368 -0
  64. data/lib/gsc/google_suggest.rb +109 -0
  65. data/lib/gsc/google_trends.rb +8 -1
  66. data/lib/gsc/heading_validator.rb +283 -0
  67. data/lib/gsc/hreflang_validator.rb +412 -0
  68. data/lib/gsc/image_seo.rb +286 -0
  69. data/lib/gsc/indexing_queue.rb +179 -0
  70. data/lib/gsc/indexnow.rb +93 -0
  71. data/lib/gsc/intent_shift.rb +188 -0
  72. data/lib/gsc/internal_links.rb +249 -0
  73. data/lib/gsc/keyword_value.rb +191 -0
  74. data/lib/gsc/landing_roi.rb +195 -0
  75. data/lib/gsc/llms_generator.rb +425 -0
  76. data/lib/gsc/low_ctr_rewriter.rb +408 -0
  77. data/lib/gsc/mobile_parity.rb +222 -0
  78. data/lib/gsc/network_tracer.rb +93 -0
  79. data/lib/gsc/open_page_rank.rb +72 -0
  80. data/lib/gsc/page_analyzer.rb +47 -7
  81. data/lib/gsc/page_comparator.rb +108 -0
  82. data/lib/gsc/page_speed.rb +110 -0
  83. data/lib/gsc/prompts.rb +38 -29
  84. data/lib/gsc/questions_harvester.rb +178 -0
  85. data/lib/gsc/report_generator.rb +461 -0
  86. data/lib/gsc/rich_results.rb +388 -0
  87. data/lib/gsc/robots_checker.rb +114 -0
  88. data/lib/gsc/schema_generator.rb +788 -0
  89. data/lib/gsc/schema_validator.rb +120 -0
  90. data/lib/gsc/seasonal_predictor.rb +381 -0
  91. data/lib/gsc/security_scanner.rb +496 -0
  92. data/lib/gsc/serp_feature_detector.rb +359 -0
  93. data/lib/gsc/serp_preview.rb +152 -0
  94. data/lib/gsc/site_crawler.rb +113 -21
  95. data/lib/gsc/sitemap_loader.rb +15 -4
  96. data/lib/gsc/sitemap_tree.rb +301 -0
  97. data/lib/gsc/skill_pack.rb +195 -0
  98. data/lib/gsc/soft_404_analyzer.rb +385 -0
  99. data/lib/gsc/sparkline.rb +171 -0
  100. data/lib/gsc/speed_correlator.rb +416 -0
  101. data/lib/gsc/striking_playbook.rb +190 -0
  102. data/lib/gsc/title_optimizer.rb +420 -0
  103. data/lib/gsc/vault.rb +260 -0
  104. data/lib/gsc/version.rb +1 -1
  105. data/lib/gsc/watchdog.rb +235 -0
  106. data/lib/gsc/zombie_purger.rb +366 -0
  107. data/lib/gsc.rb +144 -0
  108. metadata +91 -2
@@ -0,0 +1,852 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require_relative 'base'
5
+
6
+ module GSC
7
+ class CLI
8
+ module GA4
9
+ module_function
10
+
11
+ def run(command, target, extra, options, api, hostname, site_url)
12
+ case command
13
+ when 'ga4', 'bounce'
14
+ handle_ga4(api, hostname, options)
15
+ when 'correlation', 'engagement'
16
+ handle_correlation(api, hostname, site_url, options)
17
+ when 'ga4-properties', 'ga4-list'
18
+ handle_ga4_properties(api, hostname, options)
19
+ when 'realtime', 'live', 'r'
20
+ handle_realtime(api, hostname, options)
21
+ when 'ads', 'campaigns'
22
+ handle_ads(api, hostname, options)
23
+ when 'channels', 'traffic'
24
+ handle_channels(api, hostname, options)
25
+ else
26
+ raise "Unknown GA4 command: #{command}"
27
+ end
28
+ end
29
+
30
+ def handle_ga4(api, hostname, options)
31
+ property_id = options[:property] || Config.ga4_property_id(hostname)
32
+ unless property_id
33
+ if options[:json]
34
+ puts JSON.pretty_generate({
35
+ error: 'no_ga4_property_linked',
36
+ domain: hostname,
37
+ hint: "No GA4 property linked for domain '#{hostname}'. Run 'gsc config set-ga4 <property_id> -d #{hostname}' or pass '--property <id>'."
38
+ })
39
+ else
40
+ puts "\n#{Color.c("āš ļø No GA4 Property ID linked for domain: #{hostname}", Color::YELLOW, Color::BOLD)}"
41
+ puts "\nGoogle Analytics 4 is not yet linked for #{Color.c(hostname, Color::CYAN)}."
42
+ puts "To link your GA4 Property ID to this domain, run:"
43
+ puts " #{Color.c("gsc config set-ga4 <property_id> -d #{hostname}", Color::GREEN, Color::BOLD)}"
44
+ puts "\nOr specify a property ID directly:"
45
+ puts " #{Color.c("gsc ga4 --property <property_id>", Color::CYAN)}"
46
+ puts " #{Color.c("gsc ga4-properties", Color::CYAN)} (to discover accessible GA4 properties)"
47
+ puts
48
+ end
49
+ return
50
+ end
51
+
52
+ traffic_label = options[:organic] ? "Organic Search Only" : "All Traffic"
53
+ puts "šŸ“ˆ Fetching GA4 landing page behavioral metrics for #{Color.c(hostname, Color::CYAN)} [Property #{property_id}] (#{traffic_label}, Past #{options[:days]} days)..." unless options[:json]
54
+
55
+ limit = (options[:limit] || 25) * 3
56
+ res = api.query_ga4_report(property_id, days: options[:days], limit: limit, organic_only: options[:organic], hostname: (options[:all_hosts] ? nil : hostname), site_only: options[:site_only])
57
+ if res[:ok]
58
+ raw_rows = res.dig(:data, 'rows') || []
59
+ if raw_rows.empty?
60
+ if options[:json]
61
+ puts JSON.pretty_generate([])
62
+ else
63
+ puts Color.c("āš ļø No landing page behavioral data found in GA4 for this date range.", Color::YELLOW)
64
+ end
65
+ return
66
+ end
67
+
68
+ pages_map = {}
69
+ raw_rows.each do |row|
70
+ path_val = row.dig('dimensionValues', 0, 'value') || '/'
71
+ norm = Base.normalize_path(path_val)
72
+
73
+ sessions = row.dig('metricValues', 0, 'value').to_i
74
+ users = row.dig('metricValues', 1, 'value').to_i
75
+ eng_rate = row.dig('metricValues', 2, 'value').to_f
76
+ bounce = row.dig('metricValues', 3, 'value').to_f
77
+ duration = row.dig('metricValues', 4, 'value').to_f
78
+ views = row.dig('metricValues', 5, 'value').to_i
79
+
80
+ pages_map[norm] ||= { path: norm, sessions: 0, users: 0, views: 0, total_duration_secs: 0.0, weighted_eng: 0.0, weighted_bounce: 0.0 }
81
+ entry = pages_map[norm]
82
+ entry[:sessions] += sessions
83
+ entry[:users] += users
84
+ entry[:views] += views
85
+ entry[:total_duration_secs] += (duration * sessions)
86
+ entry[:weighted_eng] += (eng_rate * sessions)
87
+ entry[:weighted_bounce] += (bounce * sessions)
88
+ end
89
+
90
+ data = pages_map.values.map do |e|
91
+ s = e[:sessions]
92
+ avg_eng = s.positive? ? (e[:weighted_eng] / s) * 100.0 : 0.0
93
+ avg_bounce = s.positive? ? (e[:weighted_bounce] / s) * 100.0 : 0.0
94
+ avg_dur = s.positive? ? (e[:total_duration_secs] / s) : 0.0
95
+
96
+ {
97
+ path: e[:path],
98
+ sessions: s,
99
+ users: e[:users],
100
+ views: e[:views],
101
+ engagement_rate: avg_eng.round(1),
102
+ bounce_rate: avg_bounce.round(1),
103
+ duration_seconds: avg_dur.round(1),
104
+ duration_formatted: Base.format_duration(avg_dur)
105
+ }
106
+ end
107
+
108
+ sort_key = case options[:sort]&.to_s&.downcase
109
+ when 'bounce' then :bounce_rate
110
+ when 'eng', 'engagement' then :engagement_rate
111
+ when 'dur', 'duration', 'time' then :duration_seconds
112
+ when 'views', 'pageviews' then :views
113
+ when 'users' then :users
114
+ else :sessions
115
+ end
116
+
117
+ is_asc = (options[:order] == 'asc')
118
+ data.sort_by! { |d| d[sort_key] || 0 }
119
+ data.reverse! unless is_asc
120
+
121
+ display_rows = data.first(options[:limit] || 25)
122
+
123
+ if options[:json]
124
+ puts JSON.pretty_generate(display_rows)
125
+ else
126
+ puts "\n#{Color::BOLD}Sessions | Bounce | Eng Rate | Avg Time | Views | Landing Page Path#{Color::RESET}"
127
+ puts "--------------------------------------------------------------------------------"
128
+ display_rows.each do |r|
129
+ s_col = r[:sessions].to_s.ljust(8)
130
+
131
+ b_pct = "#{r[:bounce_rate]}%"
132
+ b_col = if r[:bounce_rate] >= 70.0
133
+ Color.c(b_pct.ljust(6), Color::RED, Color::BOLD)
134
+ elsif r[:bounce_rate] <= 40.0
135
+ Color.c(b_pct.ljust(6), Color::GREEN)
136
+ else
137
+ b_pct.ljust(6)
138
+ end
139
+
140
+ e_col = "#{r[:engagement_rate]}%".ljust(8)
141
+ d_col = r[:duration_formatted].ljust(8)
142
+ v_col = r[:views].to_s.ljust(5)
143
+ p_col = Color.c(r[:path], Color::CYAN)
144
+
145
+ puts "#{s_col} | #{b_col} | #{e_col} | #{d_col} | #{v_col} | #{p_col}"
146
+ end
147
+
148
+ puts "\nTotal Landing Pages: #{data.size} (Showing #{display_rows.size})\n"
149
+ end
150
+
151
+ if options[:csv]
152
+ csv_rows = display_rows.map do |r|
153
+ [r[:path], r[:sessions], r[:users], "#{r[:bounce_rate]}%", "#{r[:engagement_rate]}%", r[:duration_seconds], r[:views]]
154
+ end
155
+ Base.write_csv(options[:csv], %w[Path Sessions ActiveUsers BounceRate EngagementRate DurationSeconds PageViews], csv_rows)
156
+ puts Color.c("šŸ“ Exported GA4 report to #{options[:csv]}", Color::CYAN) unless options[:json]
157
+ end
158
+ else
159
+ handle_ga4_api_error(res, property_id, hostname, options)
160
+ end
161
+ end
162
+
163
+ def handle_correlation(api, hostname, site_url, options)
164
+ property_id = options[:property] || Config.ga4_property_id(hostname)
165
+ unless property_id
166
+ if options[:json]
167
+ puts JSON.pretty_generate({
168
+ error: 'no_ga4_property_linked',
169
+ domain: hostname,
170
+ hint: "Correlation requires a linked GA4 Property ID. Run 'gsc config set-ga4 <property_id> -d #{hostname}'."
171
+ })
172
+ else
173
+ puts "\n#{Color.c("āš ļø Correlation requires a linked GA4 Property for: #{hostname}", Color::YELLOW, Color::BOLD)}"
174
+ puts "\nSearch Console measures #{Color::BOLD}Pre-Click SERP rankings#{Color::RESET}, while GA4 measures #{Color::BOLD}Post-Click on-site behavior#{Color::RESET}."
175
+ puts "To correlate them, link your GA4 Property ID to this domain:"
176
+ puts " #{Color.c("gsc config set-ga4 <property_id> -d #{hostname}", Color::GREEN, Color::BOLD)}"
177
+ puts "\nOr specify directly:"
178
+ puts " #{Color.c("gsc correlation --property <property_id>", Color::CYAN)}"
179
+ puts " #{Color.c("gsc ga4-properties", Color::CYAN)} (to discover accessible GA4 properties)"
180
+ puts
181
+ end
182
+ return
183
+ end
184
+
185
+ traffic_mode = options[:organic] ? "Organic Search Only" : "All Traffic"
186
+ puts "šŸ”— Correlating Search Console Rankings with GA4 On-Site Behavior for #{Color.c(hostname, Color::CYAN)}..." unless options[:json]
187
+ puts " GSC: #{Color.c(site_url, Color::CYAN)} | GA4 Property: #{Color.c(property_id, Color::MAGENTA)} (#{traffic_mode}, #{options[:days]}d)\n" unless options[:json]
188
+
189
+ gsc_res = api.query_analytics(site_url, days: options[:days], dimensions: ['page'], row_limit: 250)
190
+ unless gsc_res[:ok]
191
+ if options[:json]
192
+ puts JSON.pretty_generate({ error: "GSC Error: #{gsc_res[:data]}" })
193
+ else
194
+ puts Color.c("āŒ Search Console Error (#{gsc_res[:status]}): #{gsc_res[:data]}", Color::RED)
195
+ end
196
+ return
197
+ end
198
+
199
+ ga4_res = api.query_ga4_report(property_id, days: options[:days], limit: 250, organic_only: options[:organic], hostname: (options[:all_hosts] ? nil : hostname), site_only: options[:site_only])
200
+ unless ga4_res[:ok]
201
+ handle_ga4_api_error(ga4_res, property_id, hostname, options)
202
+ return
203
+ end
204
+
205
+ gsc_rows = gsc_res.dig(:data, 'rows') || []
206
+ ga4_rows = ga4_res.dig(:data, 'rows') || []
207
+
208
+ ga4_index = {}
209
+ ga4_rows.each do |r|
210
+ path_val = r.dig('dimensionValues', 0, 'value') || '/'
211
+ norm = Base.normalize_path(path_val)
212
+
213
+ sessions = r.dig('metricValues', 0, 'value').to_i
214
+ eng_rate = r.dig('metricValues', 2, 'value').to_f
215
+ bounce = r.dig('metricValues', 3, 'value').to_f
216
+ dur = r.dig('metricValues', 4, 'value').to_f
217
+ views = r.dig('metricValues', 5, 'value').to_i
218
+
219
+ ga4_index[norm] ||= { sessions: 0, views: 0, total_dur: 0.0, weighted_eng: 0.0, weighted_bounce: 0.0 }
220
+ e = ga4_index[norm]
221
+ e[:sessions] += sessions
222
+ e[:views] += views
223
+ e[:total_dur] += (dur * sessions)
224
+ e[:weighted_eng] += (eng_rate * sessions)
225
+ e[:weighted_bounce] += (bounce * sessions)
226
+ end
227
+
228
+ merged = []
229
+ gsc_seen = {}
230
+
231
+ gsc_rows.each do |row|
232
+ full_url = row['keys'][0]
233
+ norm = Base.normalize_path(full_url)
234
+ gsc_seen[norm] = true
235
+
236
+ clicks = row['clicks'].to_i
237
+ imp = row['impressions'].to_i
238
+ pos = row['position'].to_f.round(1)
239
+ ctr = (row['ctr'].to_f * 100.0).round(1)
240
+
241
+ ga4_data = ga4_index[norm]
242
+ if ga4_data
243
+ s = ga4_data[:sessions]
244
+ b_rate = s.positive? ? (ga4_data[:weighted_bounce] / s) * 100.0 : 0.0
245
+ e_rate = s.positive? ? (ga4_data[:weighted_eng] / s) * 100.0 : 0.0
246
+ dur = s.positive? ? (ga4_data[:total_dur] / s) : 0.0
247
+ else
248
+ s = 0
249
+ b_rate = 0.0
250
+ e_rate = 0.0
251
+ dur = 0.0
252
+ end
253
+
254
+ diagnosis = if clicks >= 10 && dur >= 90.0
255
+ 'šŸ› ļø ENGAGED TOOL'
256
+ elsif clicks >= 10 && b_rate >= 70.0 && dur < 45.0
257
+ '🚨 HIGH BOUNCE'
258
+ elsif pos >= 7.0 && (dur >= 60.0 || (s >= 10 && b_rate <= 35.0))
259
+ '⭐ HIDDEN GEM'
260
+ elsif clicks >= 15 && s < 5
261
+ 'āš ļø TRACKING GAP'
262
+ elsif pos <= 5.0 && ctr >= 5.0 && b_rate <= 50.0 && s >= 10
263
+ 'šŸ’Ž WINNER'
264
+ elsif s.zero? && clicks.zero?
265
+ 'šŸ’¤ DORMANT'
266
+ else
267
+ 'HEALTHY'
268
+ end
269
+
270
+ merged << {
271
+ path: norm,
272
+ url: full_url,
273
+ clicks: clicks,
274
+ impressions: imp,
275
+ position: pos,
276
+ ctr: ctr,
277
+ sessions: s,
278
+ bounce_rate: b_rate.round(1),
279
+ engagement_rate: e_rate.round(1),
280
+ duration_seconds: dur.round(1),
281
+ duration_formatted: Base.format_duration(dur),
282
+ diagnosis: diagnosis
283
+ }
284
+ end
285
+
286
+ ga4_index.each do |norm, e|
287
+ next if gsc_seen[norm]
288
+ s = e[:sessions]
289
+ b_rate = s.positive? ? (e[:weighted_bounce] / s) * 100.0 : 0.0
290
+ e_rate = s.positive? ? (e[:weighted_eng] / s) * 100.0 : 0.0
291
+ dur = s.positive? ? (e[:total_dur] / s) : 0.0
292
+
293
+ merged << {
294
+ path: norm,
295
+ url: "https://#{hostname}#{norm}",
296
+ clicks: 0,
297
+ impressions: 0,
298
+ position: 99.0,
299
+ ctr: 0.0,
300
+ sessions: s,
301
+ bounce_rate: b_rate.round(1),
302
+ engagement_rate: e_rate.round(1),
303
+ duration_seconds: dur.round(1),
304
+ duration_formatted: Base.format_duration(dur),
305
+ diagnosis: 'DIRECT/REFERRAL'
306
+ }
307
+ end
308
+
309
+ merged.sort_by! { |m| [m[:diagnosis] == '🚨 HIGH BOUNCE' ? 0 : 1, -m[:clicks], -m[:sessions]] }
310
+ display_rows = merged.first(options[:limit] || 25)
311
+
312
+ if options[:json]
313
+ puts JSON.pretty_generate(display_rows)
314
+ else
315
+ puts "#{Color::BOLD}GSC Clicks | GSC Pos | GA4 Sess | Bounce | Avg Time | Insight | Page Path#{Color::RESET}"
316
+ puts "---------------------------------------------------------------------------------------------------------"
317
+ display_rows.each do |r|
318
+ c_col = r[:clicks].to_s.ljust(10)
319
+ p_col = r[:position].to_s.ljust(7)
320
+ s_col = r[:sessions].to_s.ljust(8)
321
+
322
+ b_pct = "#{r[:bounce_rate]}%"
323
+ b_col = if r[:bounce_rate] >= 70.0 && r[:sessions] > 0
324
+ Color.c(b_pct.ljust(6), Color::RED, Color::BOLD)
325
+ elsif r[:bounce_rate] <= 40.0 && r[:sessions] > 0
326
+ Color.c(b_pct.ljust(6), Color::GREEN)
327
+ else
328
+ b_pct.ljust(6)
329
+ end
330
+
331
+ t_col = r[:duration_formatted].ljust(8)
332
+
333
+ diag_str = case r[:diagnosis]
334
+ when '🚨 HIGH BOUNCE' then Color.c('🚨 HIGH BOUNCE'.ljust(21), Color::RED, Color::BOLD)
335
+ when 'šŸ› ļø ENGAGED TOOL' then Color.c('šŸ› ļø ENGAGED TOOL'.ljust(21), Color::CYAN, Color::BOLD)
336
+ when '⭐ HIDDEN GEM' then Color.c('⭐ HIDDEN GEM'.ljust(21), Color::YELLOW, Color::BOLD)
337
+ when 'āš ļø TRACKING GAP' then Color.c('āš ļø TRACKING GAP'.ljust(21), Color::YELLOW)
338
+ when 'šŸ’Ž WINNER' then Color.c('šŸ’Ž WINNER'.ljust(21), Color::GREEN, Color::BOLD)
339
+ else Color.c(r[:diagnosis].ljust(21), Color::GRAY)
340
+ end
341
+
342
+ path_col = Color.c(r[:path], Color::CYAN)
343
+
344
+ puts "#{c_col} | #{p_col} | #{s_col} | #{b_col} | #{t_col} | #{diag_str} | #{path_col}"
345
+ end
346
+
347
+ high_bounce = merged.select { |m| m[:diagnosis] == '🚨 HIGH BOUNCE' }
348
+ gems = merged.select { |m| m[:diagnosis] == '⭐ HIDDEN GEM' }
349
+ tools = merged.select { |m| m[:diagnosis] == 'šŸ› ļø ENGAGED TOOL' }
350
+
351
+ if tools.any?
352
+ puts "\nšŸ’” #{Color::BOLD}Engaged Tool / Utility Action Plan:#{Color::RESET} #{tools.size} interactive pages show exceptional time on page (>90s). Since users get their answer and leave, add embedded CTAs, related tools, or app trials to convert these active users."
353
+ end
354
+ if high_bounce.any?
355
+ puts "šŸ’” #{Color::BOLD}High Bounce Action Plan:#{Color::RESET} #{high_bounce.size} pages receive search clicks but abandon quickly (<45s with >70% bounce). Check for search intent mismatch (e.g. consumer searches landing on B2B features) or optimize above-the-fold content."
356
+ end
357
+ if gems.any?
358
+ puts "šŸ’” #{Color::BOLD}Hidden Gem Action Plan:#{Color::RESET} #{gems.size} pages show exceptional visitor stickiness despite lower rankings. Build internal links from your homepage to push them into the Top 3 on Google."
359
+ end
360
+ puts
361
+ end
362
+
363
+ if options[:csv]
364
+ csv_rows = display_rows.map do |r|
365
+ [r[:path], r[:url], r[:clicks], r[:impressions], r[:position], "#{r[:ctr]}%", r[:sessions], "#{r[:bounce_rate]}%", "#{r[:engagement_rate]}%", r[:duration_seconds], r[:diagnosis]]
366
+ end
367
+ Base.write_csv(options[:csv], %w[Path URL GSCClicks GSCImpressions GSCPosition GSCCTR GA4Sessions GA4BounceRate GA4EngagementRate GA4Duration Diagnosis], csv_rows)
368
+ puts Color.c("šŸ“ Exported correlation report to #{options[:csv]}", Color::CYAN) unless options[:json]
369
+ end
370
+ end
371
+
372
+ def handle_ga4_properties(api, hostname, options)
373
+ puts "šŸ” Discovering Google Analytics 4 properties accessible by service account...\n" unless options[:json]
374
+ res = api.list_ga4_summaries
375
+ if res[:ok]
376
+ summaries = res.dig(:data, 'accountSummaries') || []
377
+ properties = []
378
+ summaries.each do |acc|
379
+ acc_name = acc['displayName'] || acc['account']
380
+ (acc['propertySummaries'] || []).each do |prop|
381
+ prop_id = prop['property'].sub(%r{^properties/}, '')
382
+ prop_name = prop['displayName']
383
+ properties << {
384
+ propertyId: prop_id,
385
+ displayName: prop_name,
386
+ accountName: acc_name,
387
+ fullResource: prop['property']
388
+ }
389
+ end
390
+ end
391
+
392
+ linked_prop = Config.ga4_property_id(hostname)
393
+
394
+ if options[:json]
395
+ puts JSON.pretty_generate(properties)
396
+ elsif properties.empty?
397
+ puts Color.c("āš ļø No GA4 properties found accessible by this service account.", Color::YELLOW)
398
+ puts "\n#{Color::BOLD}To grant access:#{Color::RESET}"
399
+ puts "1. Go to: https://analytics.google.com/"
400
+ puts "2. In Admin > Property Access Management, add your service account email with Viewer role."
401
+ else
402
+ puts "#{Color::BOLD}Property ID | GA4 Property Name | Account Name#{Color::RESET}"
403
+ puts "--------------------------------------------------------------------------------"
404
+ properties.each do |p|
405
+ id_str = Color.c(p[:propertyId].ljust(12), Color::MAGENTA, Color::BOLD)
406
+ name_str = p[:displayName].ljust(27)
407
+ acc_str = Color.c(p[:accountName], Color::GRAY)
408
+
409
+ is_linked = (p[:propertyId] == linked_prop)
410
+ matches_dom = p[:displayName].downcase.include?(hostname.downcase.sub(/\..*$/, ''))
411
+
412
+ tag = if is_linked
413
+ " #{Color.c('šŸ‘ˆ [LINKED to ' + hostname + ']', Color::GREEN, Color::BOLD)}"
414
+ elsif matches_dom
415
+ " #{Color.c('šŸ‘ˆ [Recommended for ' + hostname + ']', Color::CYAN)}"
416
+ else
417
+ ""
418
+ end
419
+
420
+ puts "#{id_str} | #{name_str} | #{acc_str}#{tag}"
421
+ end
422
+
423
+ puts "\nTo link a property to #{Color.c(hostname, Color::CYAN)}:"
424
+ puts " #{Color.c("gsc config set-ga4 <property_id> -d #{hostname}", Color::GREEN, Color::BOLD)}\n\n"
425
+ end
426
+ else
427
+ handle_ga4_api_error(res, nil, hostname, options)
428
+ end
429
+ end
430
+
431
+ def handle_realtime(api, hostname, options)
432
+ property_id = options[:property] || Config.ga4_property_id(hostname)
433
+ unless property_id
434
+ if options[:json]
435
+ puts JSON.pretty_generate({
436
+ error: 'no_ga4_property_linked',
437
+ domain: hostname,
438
+ hint: "Realtime monitoring requires a linked GA4 Property ID. Run 'gsc config set-ga4 <property_id> -d #{hostname}'."
439
+ })
440
+ else
441
+ puts "\n#{Color.c("āš ļø Realtime monitoring requires a linked GA4 Property for: #{hostname}", Color::YELLOW, Color::BOLD)}"
442
+ puts "To link your GA4 Property ID to this domain, run:"
443
+ puts " #{Color.c("gsc config set-ga4 <property_id> -d #{hostname}", Color::GREEN, Color::BOLD)}"
444
+ puts "\nOr specify directly:"
445
+ puts " #{Color.c("gsc realtime --property <property_id>", Color::CYAN)}"
446
+ puts " #{Color.c("gsc ga4-properties", Color::CYAN)} (to discover accessible GA4 properties)"
447
+ puts
448
+ end
449
+ return
450
+ end
451
+
452
+ interval = options[:watch] ? [options[:watch], 3].max : nil
453
+ title_map = api.query_ga4_title_map(property_id, hostname: (options[:all_hosts] ? nil : hostname), site_only: options[:site_only])
454
+
455
+ begin
456
+ loop do
457
+ print "\e[H\e[2J" if interval && !options[:json]
458
+
459
+ res = api.query_ga4_realtime(property_id, limit: options[:limit] || 25)
460
+ if res[:ok]
461
+ data = res[:data] || {}
462
+ reported_total = data.dig('totals', 0, 'metricValues', 0, 'value')&.to_i
463
+ raw_rows = data['rows'] || []
464
+
465
+ page_counts = Hash.new(0)
466
+ country_counts = Hash.new(0)
467
+ breakdown = []
468
+
469
+ raw_rows.each do |r|
470
+ page = r.dig('dimensionValues', 0, 'value')
471
+ page = '/' if page.nil? || page.strip.empty?
472
+ country = r.dig('dimensionValues', 1, 'value')
473
+ country = 'Unknown' if country.nil? || country.strip.empty?
474
+ active = r.dig('metricValues', 0, 'value').to_i
475
+
476
+ path = if page.start_with?('/')
477
+ page
478
+ elsif title_map[page]
479
+ title_map[page]
480
+ elsif page =~ %r{/(?:[a-zA-Z0-9_\-\./]+)}
481
+ page[%r{/(?:[a-zA-Z0-9_\-\./]+)}]
482
+ else
483
+ match = title_map.find { |t, _| t.downcase.include?(page.downcase) || page.downcase.include?(t.downcase) }
484
+ match ? match[1] : '-'
485
+ end
486
+
487
+ display_key = path.start_with?('/') ? path : page
488
+ page_counts[display_key] += active
489
+ country_counts[country] += active
490
+ breakdown << { path: path, page: page, country: country, active_users: active }
491
+ end
492
+
493
+ row_max = raw_rows.map { |r| r.dig('metricValues', 0, 'value').to_i }.max || 0
494
+ page_max = page_counts.values.max || 0
495
+ total_active = reported_total || [row_max, page_max].max || 0
496
+ if total_active.zero? && raw_rows.any?
497
+ total_active = [row_max, page_max, 1].max
498
+ end
499
+
500
+ payload = {
501
+ domain: hostname,
502
+ property_id: property_id,
503
+ total_active_users: total_active,
504
+ timestamp: Time.now.iso8601,
505
+ top_pages: page_counts.sort_by { |_k, v| -v }.first(options[:limit] || 15).map { |k, v| { page: k, active_users: v } },
506
+ top_countries: country_counts.sort_by { |_k, v| -v }.first(10).map { |k, v| { country: k, active_users: v } },
507
+ active_streams: breakdown
508
+ }
509
+
510
+ if options[:json]
511
+ puts JSON.pretty_generate(payload)
512
+ else
513
+ header_text = "⚔ GA4 REALTIME VISITOR MONITOR: #{hostname} [Property #{property_id}]"
514
+ time_str = Time.now.strftime("%Y-%m-%d %H:%M:%S")
515
+ puts "#{Color::BOLD}#{Color.c(header_text, Color::CYAN)} (#{time_str})#{Color::RESET}"
516
+ puts "#{Color::BOLD}══════════════════════════════════════════════════════════════════════════════#{Color::RESET}"
517
+
518
+ count_color = total_active > 0 ? Color::GREEN : Color::YELLOW
519
+ puts " šŸ‘„ Active Users (Last 30 Mins): #{Color.c(total_active.to_s, count_color, Color::BOLD)}"
520
+ puts "#{Color::BOLD}══════════════════════════════════════════════════════════════════════════════#{Color::RESET}\n"
521
+
522
+ if total_active.zero? && raw_rows.empty?
523
+ puts Color.c(" (No active visitors on site in the last 30 minutes)\n", Color::GRAY)
524
+ else
525
+ puts "#{Color::BOLD}Active Users | Country | URL Path | Page / Screen Title#{Color::RESET}"
526
+ puts "------------------------------------------------------------------------------------------------------------------------"
527
+ breakdown.first(options[:limit] || 25).each do |b|
528
+ u_col = Color.c(b[:active_users].to_s.ljust(12), Color::GREEN, Color::BOLD)
529
+ c_col = b[:country].to_s[0..14].ljust(15)
530
+ path_str = b[:path].to_s[0..29].ljust(30)
531
+ path_col = Color.c(path_str, Color::MAGENTA, Color::BOLD)
532
+ p_col = Color.c(b[:page], Color::CYAN)
533
+ puts "#{u_col} | #{c_col} | #{path_col} | #{p_col}"
534
+ end
535
+ puts
536
+
537
+ if country_counts.any?
538
+ top_c = country_counts.sort_by { |_k, v| -v }.first(5).map { |c, count| "#{c}: #{count}" }.join(" | ")
539
+ puts "šŸŒ Top Countries: #{top_c}\n\n"
540
+ end
541
+ end
542
+
543
+ if interval
544
+ puts Color.c("ā³ Refreshing every #{interval}s (Press Ctrl+C to exit)...", Color::GRAY)
545
+ end
546
+ end
547
+ else
548
+ handle_ga4_api_error(res, property_id, hostname, options)
549
+ break
550
+ end
551
+
552
+ break unless interval
553
+ sleep interval
554
+ end
555
+ rescue Interrupt
556
+ puts "\nšŸ‘‹ Exited live monitor." unless options[:json]
557
+ end
558
+ end
559
+
560
+ def handle_ads(api, hostname, options)
561
+ property_id = options[:property] || Config.ga4_property_id(hostname)
562
+ unless property_id
563
+ if options[:json]
564
+ puts JSON.pretty_generate({
565
+ error: 'no_ga4_property_linked',
566
+ domain: hostname,
567
+ hint: "Google Ads reporting requires a linked GA4 Property ID. Run 'gsc config set-ga4 <property_id> -d #{hostname}'."
568
+ })
569
+ else
570
+ puts "\n#{Color.c("āš ļø Google Ads reporting requires a linked GA4 Property for: #{hostname}", Color::YELLOW, Color::BOLD)}"
571
+ puts "To link your GA4 Property ID to this domain, run:"
572
+ puts " #{Color.c("gsc config set-ga4 <property_id> -d #{hostname}", Color::GREEN, Color::BOLD)}"
573
+ puts "\nOr specify directly:"
574
+ puts " #{Color.c("gsc ads --property <property_id>", Color::CYAN)}"
575
+ puts " #{Color.c("gsc ga4-properties", Color::CYAN)} (to discover accessible GA4 properties)"
576
+ puts
577
+ end
578
+ return
579
+ end
580
+
581
+ puts "šŸ“¢ Fetching Google Ads campaign performance for #{Color.c(hostname, Color::CYAN)} [Property #{property_id}] (Past #{options[:days]} days)..." unless options[:json]
582
+
583
+ res = api.query_ga4_ads(property_id, days: options[:days], limit: options[:limit] || 50)
584
+ if res[:ok]
585
+ raw_rows = res.dig(:data, 'rows') || []
586
+ if raw_rows.empty?
587
+ if options[:json]
588
+ puts JSON.pretty_generate({
589
+ domain: hostname,
590
+ property_id: property_id,
591
+ campaigns: [],
592
+ message: "No Google Ads campaign data recorded in GA4 for this date range."
593
+ })
594
+ else
595
+ puts Color.c("\nā„¹ļø No Google Ads data found in GA4 for the past #{options[:days]} days.", Color::YELLOW)
596
+ puts "• Check that Google Ads is linked in GA4 (Admin > Product Links > Google Ads Links)."
597
+ puts "• Ensure Auto-tagging is enabled in Google Ads settings."
598
+ puts "• Note: Google Ads cost/click metrics typically take 12–24 hours to populate in GA4.\n\n"
599
+ end
600
+ return
601
+ end
602
+
603
+ campaigns = []
604
+ raw_rows.each do |row|
605
+ camp_name = row.dig('dimensionValues', 0, 'value') || '(not set)'
606
+ ad_group = row.dig('dimensionValues', 1, 'value') || '(not set)'
607
+
608
+ clicks = row.dig('metricValues', 0, 'value').to_i
609
+ cost = row.dig('metricValues', 1, 'value').to_f
610
+ cpc = row.dig('metricValues', 2, 'value').to_f
611
+ sessions = row.dig('metricValues', 3, 'value').to_i
612
+ conv = row.dig('metricValues', 4, 'value').to_i
613
+ bounce = row.dig('metricValues', 5, 'value').to_f
614
+
615
+ cpa = conv.positive? ? (cost / conv) : 0.0
616
+
617
+ campaigns << {
618
+ campaign: camp_name,
619
+ ad_group: ad_group,
620
+ clicks: clicks,
621
+ cost: cost.round(2),
622
+ cpc: cpc.round(2),
623
+ sessions: sessions,
624
+ conversions: conv,
625
+ cpa: cpa.round(2),
626
+ bounce_rate: (bounce * 100.0).round(1)
627
+ }
628
+ end
629
+
630
+ campaigns.reject! { |c| c[:campaign] == '(not set)' && c[:clicks].zero? && c[:cost].zero? }
631
+
632
+ if campaigns.empty?
633
+ if options[:json]
634
+ puts JSON.pretty_generate([])
635
+ else
636
+ puts Color.c("\nā„¹ļø No Google Ads clicks or spend recorded in GA4 for the past #{options[:days]} days.", Color::YELLOW, Color::BOLD)
637
+ puts "\n#{Color::BOLD}Diagnostic Checklist:#{Color::RESET}"
638
+ puts " 1. #{Color::BOLD}Campaign Hasn't Served Yet:#{Color::RESET} If your campaign was published recently, check Google Ads for 'Your campaign hasn't served in the past week' (often due to pending ad review, account security tasks, or low bids)."
639
+ puts " 2. #{Color::BOLD}0 Impressions / Clicks:#{Color::RESET} GA4 only logs campaigns once Google Ads serves impressions and records traffic."
640
+ puts " 3. #{Color::BOLD}GA4 Product Link:#{Color::RESET} Verify Google Ads is linked in GA4 (Admin > Product Links > Google Ads Links) with auto-tagging enabled."
641
+ puts " 4. #{Color::BOLD}Sync Latency:#{Color::RESET} Ad spend & click metrics take 12–24 hours to populate in GA4 after ads begin serving.\n\n"
642
+ end
643
+ return
644
+ end
645
+
646
+ campaigns.sort_by! { |c| -c[:clicks] }
647
+
648
+ if options[:json]
649
+ puts JSON.pretty_generate(campaigns)
650
+ else
651
+ puts "\n#{Color::BOLD}Clicks | Cost ($) | Avg CPC | Sessions | Conv | CPA ($) | Bounce | Campaign Name [Ad Group]#{Color::RESET}"
652
+ puts "-------------------------------------------------------------------------------------------------------"
653
+ total_clicks = 0
654
+ total_cost = 0.0
655
+ total_sessions = 0
656
+ total_conv = 0
657
+
658
+ campaigns.first(options[:limit] || 25).each do |c|
659
+ total_clicks += c[:clicks]
660
+ total_cost += c[:cost]
661
+ total_sessions += c[:sessions]
662
+ total_conv += c[:conversions]
663
+
664
+ clk_col = c[:clicks].to_s.ljust(6)
665
+ cost_col = "$#{format('%.2f', c[:cost])}".ljust(8)
666
+ cpc_col = "$#{format('%.2f', c[:cpc])}".ljust(7)
667
+ sess_col = c[:sessions].to_s.ljust(8)
668
+ conv_col = Color.c(c[:conversions].to_s.ljust(4), c[:conversions].positive? ? Color::GREEN : Color::GRAY)
669
+ cpa_col = c[:conversions].positive? ? "$#{format('%.2f', c[:cpa])}".ljust(7) : "-".ljust(7)
670
+ bnc_col = "#{c[:bounce_rate]}%".ljust(6)
671
+ name_col = "#{Color.c(c[:campaign], Color::CYAN)} [#{Color.c(c[:ad_group], Color::GRAY)}]"
672
+
673
+ puts "#{clk_col} | #{cost_col} | #{cpc_col} | #{sess_col} | #{conv_col} | #{cpa_col} | #{bnc_col} | #{name_col}"
674
+ end
675
+
676
+ puts "#{Color::BOLD}═══════════════════════════════════════════════════════════════════════════════════════#{Color::RESET}"
677
+ total_cpa = total_conv.positive? ? "$#{format('%.2f', total_cost / total_conv)}" : "-"
678
+ puts "Totals: #{total_clicks} clicks | $#{format('%.2f', total_cost)} cost | #{total_sessions} sessions | #{total_conv} conv | CPA: #{total_cpa}\n\n"
679
+ end
680
+
681
+ if options[:csv]
682
+ csv_rows = campaigns.map do |c|
683
+ [c[:campaign], c[:ad_group], c[:clicks], c[:cost], c[:cpc], c[:sessions], c[:conversions], c[:cpa], "#{c[:bounce_rate]}%"]
684
+ end
685
+ Base.write_csv(options[:csv], %w[Campaign AdGroup Clicks Cost AvgCPC Sessions Conversions CPA BounceRate], csv_rows)
686
+ puts Color.c("šŸ“ Exported Google Ads report to #{options[:csv]}", Color::CYAN) unless options[:json]
687
+ end
688
+ else
689
+ handle_ga4_api_error(res, property_id, hostname, options)
690
+ end
691
+ end
692
+
693
+ def handle_channels(api, hostname, options)
694
+ property_id = options[:property] || Config.ga4_property_id(hostname)
695
+ unless property_id
696
+ if options[:json]
697
+ puts JSON.pretty_generate({
698
+ error: 'no_ga4_property_linked',
699
+ domain: hostname,
700
+ hint: "Traffic channel reporting requires a linked GA4 Property ID. Run 'gsc config set-ga4 <property_id> -d #{hostname}'."
701
+ })
702
+ else
703
+ puts "\n#{Color.c("āš ļø Traffic channel reporting requires a linked GA4 Property for: #{hostname}", Color::YELLOW, Color::BOLD)}"
704
+ puts "To link your GA4 Property ID to this domain, run:"
705
+ puts " #{Color.c("gsc config set-ga4 <property_id> -d #{hostname}", Color::GREEN, Color::BOLD)}"
706
+ puts "\nOr specify directly:"
707
+ puts " #{Color.c("gsc channels --property <property_id>", Color::CYAN)}"
708
+ puts " #{Color.c("gsc ga4-properties", Color::CYAN)} (to discover accessible GA4 properties)"
709
+ puts
710
+ end
711
+ return
712
+ end
713
+
714
+ puts "🚦 Fetching Omnichannel acquisition breakdown for #{Color.c(hostname, Color::CYAN)} [Property #{property_id}] (Past #{options[:days]} days)..." unless options[:json]
715
+
716
+ res = api.query_ga4_channels(property_id, days: options[:days], limit: options[:limit] || 25, hostname: (options[:all_hosts] ? nil : hostname), site_only: options[:site_only])
717
+ if res[:ok]
718
+ raw_rows = res.dig(:data, 'rows') || []
719
+ if raw_rows.empty?
720
+ if options[:json]
721
+ puts JSON.pretty_generate([])
722
+ else
723
+ puts Color.c("āš ļø No acquisition channel data found in GA4 for this date range.", Color::YELLOW)
724
+ end
725
+ return
726
+ end
727
+
728
+ channels = []
729
+ raw_rows.each do |row|
730
+ channel = row.dig('dimensionValues', 0, 'value') || '(not set)'
731
+ source = row.dig('dimensionValues', 1, 'value') || '(not set)'
732
+
733
+ sessions = row.dig('metricValues', 0, 'value').to_i
734
+ users = row.dig('metricValues', 1, 'value').to_i
735
+ eng_rate = row.dig('metricValues', 2, 'value').to_f
736
+ bounce = row.dig('metricValues', 3, 'value').to_f
737
+ duration = row.dig('metricValues', 4, 'value').to_f
738
+ conv = row.dig('metricValues', 5, 'value').to_i
739
+
740
+ channels << {
741
+ channel: channel,
742
+ source_medium: source,
743
+ sessions: sessions,
744
+ users: users,
745
+ engagement_rate: (eng_rate * 100.0).round(1),
746
+ bounce_rate: (bounce * 100.0).round(1),
747
+ duration_seconds: duration.round(1),
748
+ duration_formatted: Base.format_duration(duration),
749
+ conversions: conv
750
+ }
751
+ end
752
+
753
+ channels.sort_by! { |c| -c[:sessions] }
754
+
755
+ if options[:json]
756
+ puts JSON.pretty_generate(channels)
757
+ else
758
+ puts "\n#{Color::BOLD}Sessions | Users | Eng Rate | Bounce | Avg Time | Conv | Channel Group [Source / Medium]#{Color::RESET}"
759
+ puts "-----------------------------------------------------------------------------------------------"
760
+ channels.first(options[:limit] || 20).each do |c|
761
+ s_col = c[:sessions].to_s.ljust(8)
762
+ u_col = c[:users].to_s.ljust(5)
763
+ e_col = "#{c[:engagement_rate]}%".ljust(8)
764
+
765
+ b_pct = "#{c[:bounce_rate]}%"
766
+ b_col = if c[:bounce_rate] >= 70.0
767
+ Color.c(b_pct.ljust(6), Color::RED)
768
+ elsif c[:bounce_rate] <= 40.0
769
+ Color.c(b_pct.ljust(6), Color::GREEN)
770
+ else
771
+ b_pct.ljust(6)
772
+ end
773
+
774
+ d_col = c[:duration_formatted].ljust(8)
775
+ cv_col = Color.c(c[:conversions].to_s.ljust(4), c[:conversions].positive? ? Color::GREEN : Color::GRAY)
776
+ ch_col = "#{Color.c(c[:channel], Color::CYAN)} [#{Color.c(c[:source_medium], Color::GRAY)}]"
777
+
778
+ puts "#{s_col} | #{u_col} | #{e_col} | #{b_col} | #{d_col} | #{cv_col} | #{ch_col}"
779
+ end
780
+
781
+ total_s = channels.sum { |c| c[:sessions] }
782
+ total_u = channels.sum { |c| c[:users] }
783
+ total_cv = channels.sum { |c| c[:conversions] }
784
+ puts "\nTotal Channels: #{channels.size} | Total Sessions: #{total_s} | Total Users: #{total_u} | Conversions: #{total_cv}\n\n"
785
+ end
786
+
787
+ if options[:csv]
788
+ csv_rows = channels.map do |c|
789
+ [c[:channel], c[:source_medium], c[:sessions], c[:users], "#{c[:engagement_rate]}%", "#{c[:bounce_rate]}%", c[:duration_seconds], c[:conversions]]
790
+ end
791
+ Base.write_csv(options[:csv], %w[Channel SourceMedium Sessions Users EngagementRate BounceRate DurationSeconds Conversions], csv_rows)
792
+ puts Color.c("šŸ“ Exported Channel Acquisition report to #{options[:csv]}", Color::CYAN) unless options[:json]
793
+ end
794
+ else
795
+ handle_ga4_api_error(res, property_id, hostname, options)
796
+ end
797
+ end
798
+
799
+ def handle_ga4_api_error(res, property_id, hostname, options)
800
+ msg = res.dig(:data, 'error', 'message') || res[:data].to_s
801
+ status = res[:status]
802
+
803
+ if options[:json]
804
+ puts JSON.pretty_generate({ error: msg, status: status })
805
+ return
806
+ end
807
+
808
+ puts Color.c("\nāŒ Google Analytics API Error (#{status}): #{msg}", Color::RED, Color::BOLD)
809
+
810
+ is_disabled_api = msg =~ /has not been used in project|SERVICE_DISABLED|is disabled|enable it by visiting/i ||
811
+ (res.dig(:data, 'error', 'details')&.any? { |d| d['reason'] == 'SERVICE_DISABLED' } rescue false)
812
+
813
+ if is_disabled_api
814
+ url = msg[/https:\/\/[^\s]+/, 0] || (res.dig(:data, 'error', 'details', 0, 'links', 0, 'url') rescue nil)
815
+ puts "\n#{Color.c("āš™ļø ACTION REQUIRED: Google Cloud API Not Enabled", Color::YELLOW, Color::BOLD)}"
816
+ puts "The requested Google Analytics API is disabled in your Google Cloud Project."
817
+ if url
818
+ puts "\nšŸ‘‰ #{Color::BOLD}Click this link to enable it in 1 click:#{Color::RESET}"
819
+ puts " #{Color.c(url, Color::CYAN, Color::BOLD)}"
820
+ end
821
+ puts "\nAfter clicking #{Color::BOLD}'ENABLE'#{Color::RESET} in Google Cloud Console, wait 1–2 minutes for Google propagation and retry."
822
+ puts
823
+ return
824
+ end
825
+
826
+ if status == 403
827
+ sa_key = Auth.find_key
828
+ sa_email = (JSON.parse(File.read(sa_key))['client_email'] rescue nil) if sa_key
829
+ puts "\n#{Color::BOLD}How to Fix 403 (Permission Denied):#{Color::RESET}"
830
+ puts "1. Copy your Service Account email:"
831
+ if sa_email
832
+ puts " šŸ‘‰ #{Color.c(sa_email, Color::CYAN, Color::BOLD)}"
833
+ else
834
+ puts " šŸ‘‰ (Check your service account JSON key)"
835
+ end
836
+ puts "2. Open Google Analytics: #{Color.c('https://analytics.google.com/', Color::CYAN)}"
837
+ puts "3. Click āš™ļø #{Color::BOLD}Admin#{Color::RESET} (bottom-left corner)"
838
+ if property_id && !property_id.to_s.strip.empty?
839
+ puts "4. In Property Settings, click #{Color::BOLD}Property Access Management#{Color::RESET} (for Property #{property_id})"
840
+ else
841
+ puts "4. Click #{Color::BOLD}Account Access Management#{Color::RESET} (or Property Access Management)"
842
+ end
843
+ puts "5. Click the blue #{Color::BOLD}'+'#{Color::RESET} button (top-right) āž” #{Color::BOLD}Add users#{Color::RESET}"
844
+ puts "6. Paste your service account email and assign the #{Color::BOLD}Viewer#{Color::RESET} role."
845
+ puts
846
+ elsif status == 404
847
+ puts "\nProperty ID '#{property_id}' was not found. Please verify your numeric GA4 Property ID.\n"
848
+ end
849
+ end
850
+ end
851
+ end
852
+ end