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
data/lib/gsc/cli/ga4.rb
ADDED
|
@@ -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
|