gsc-cli 2.0.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.
data/lib/gsc/api.rb ADDED
@@ -0,0 +1,300 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GSC
4
+ class API
5
+ def initialize(client)
6
+ @client = client
7
+ end
8
+
9
+ # Indexing API: Publish URL (URL_UPDATED / URL_DELETED)
10
+ def publish_url(url, type = 'URL_UPDATED')
11
+ @client.post('https://indexing.googleapis.com/v3/urlNotifications:publish', { url: url, type: type })
12
+ end
13
+
14
+ # Indexing API: Metadata status
15
+ def get_url_status(url)
16
+ @client.get("https://indexing.googleapis.com/v3/urlNotifications/metadata?url=#{URI.encode_www_form_component(url)}")
17
+ end
18
+
19
+ # URL Inspection API
20
+ def inspect_url(inspection_url, site_url)
21
+ @client.post('https://searchconsole.googleapis.com/v1/urlInspection/index:inspect', {
22
+ inspectionUrl: inspection_url,
23
+ siteUrl: site_url
24
+ })
25
+ end
26
+
27
+ # Search Analytics API
28
+ def query_analytics(site_url, days: 30, dimensions: ['query'], row_limit: 50, start_row: 0, start_date: nil, end_date: nil, filters: nil)
29
+ end_d = end_date || Date.today.iso8601
30
+ start_d = start_date || (Date.today - days).iso8601
31
+
32
+ body = {
33
+ startDate: start_d,
34
+ endDate: end_d
35
+ }
36
+ body[:rowLimit] = row_limit if row_limit
37
+ body[:startRow] = start_row if start_row && start_row > 0
38
+ body[:dimensions] = dimensions if dimensions && !dimensions.empty?
39
+ body[:dimensionFilterGroups] = [{ filters: filters }] if filters && !filters.empty?
40
+
41
+ endpoint = "https://searchconsole.googleapis.com/webmasters/v3/sites/#{URI.encode_www_form_component(site_url)}/searchAnalytics/query"
42
+ res = @client.post(endpoint, body)
43
+ res.merge(start_date: start_d, end_date: end_d)
44
+ end
45
+
46
+ # Fetch all analytics rows across pagination (handles >25,000 queries via startRow)
47
+ def query_all_analytics(site_url, days: 30, dimensions: ['query'], start_date: nil, end_date: nil, max_total: nil)
48
+ all_rows = []
49
+ start_row = 0
50
+ chunk_size = 5000
51
+ last_res = nil
52
+
53
+ loop do
54
+ res = query_analytics(
55
+ site_url,
56
+ days: days,
57
+ dimensions: dimensions,
58
+ row_limit: chunk_size,
59
+ start_row: start_row,
60
+ start_date: start_date,
61
+ end_date: end_date
62
+ )
63
+ last_res = res
64
+ return res unless res[:ok]
65
+
66
+ rows = res.dig(:data, 'rows') || []
67
+ all_rows.concat(rows)
68
+
69
+ break if rows.size < chunk_size
70
+ break if max_total && all_rows.size >= max_total
71
+
72
+ start_row += chunk_size
73
+ end
74
+
75
+ {
76
+ ok: true,
77
+ status: 200,
78
+ start_date: last_res ? last_res[:start_date] : nil,
79
+ end_date: last_res ? last_res[:end_date] : nil,
80
+ data: {
81
+ 'rows' => all_rows,
82
+ 'responseAggregationType' => last_res ? last_res.dig(:data, 'responseAggregationType') : 'byPage'
83
+ }
84
+ }
85
+ end
86
+
87
+ # Verified Sites List
88
+ def list_sites
89
+ @client.get('https://searchconsole.googleapis.com/webmasters/v3/sites')
90
+ end
91
+
92
+ # Sitemaps List
93
+ def list_sitemaps(site_url)
94
+ @client.get("https://searchconsole.googleapis.com/webmasters/v3/sites/#{URI.encode_www_form_component(site_url)}/sitemaps")
95
+ end
96
+
97
+ # Submit Sitemap
98
+ def submit_sitemap(site_url, feedpath)
99
+ @client.put("https://searchconsole.googleapis.com/webmasters/v3/sites/#{URI.encode_www_form_component(site_url)}/sitemaps/#{URI.encode_www_form_component(feedpath)}")
100
+ end
101
+
102
+ # Helper to construct GA4 dimension filters (e.g. hostName isolation, organic traffic)
103
+ def build_ga4_filter(hostname: nil, organic_only: false, site_only: false)
104
+ filters = []
105
+ if hostname && !hostname.empty?
106
+ clean_host = hostname.to_s.sub(%r{^https?://}, '').sub(/^sc-domain:/, '').sub(/^www\./, '').chomp('/')
107
+ escaped_host = Regexp.escape(clean_host)
108
+
109
+ match_val = if site_only
110
+ "^(www\\.)?#{escaped_host}$"
111
+ else
112
+ "(^|.*\\.)#{escaped_host}$"
113
+ end
114
+
115
+ filters << {
116
+ filter: {
117
+ fieldName: 'hostName',
118
+ stringFilter: {
119
+ matchType: 'FULL_REGEXP',
120
+ value: match_val
121
+ }
122
+ }
123
+ }
124
+ end
125
+
126
+ if organic_only
127
+ filters << {
128
+ filter: {
129
+ fieldName: 'sessionMedium',
130
+ stringFilter: {
131
+ matchType: 'EXACT',
132
+ value: 'organic'
133
+ }
134
+ }
135
+ }
136
+ end
137
+
138
+ if filters.size == 1
139
+ filters.first
140
+ elsif filters.size > 1
141
+ { andGroup: { expressions: filters } }
142
+ end
143
+ end
144
+
145
+ # Google Analytics 4 Data API (runReport)
146
+ def query_ga4_report(property_id, days: 30, limit: 100, organic_only: false, hostname: nil, site_only: false)
147
+ clean_id = property_id.to_s.sub(%r{^properties/}, '')
148
+ endpoint = "https://analyticsdata.googleapis.com/v1beta/properties/#{clean_id}:runReport"
149
+
150
+ body = {
151
+ dateRanges: [{ startDate: "#{days}daysAgo", endDate: 'yesterday' }],
152
+ dimensions: [{ name: 'pagePath' }],
153
+ metrics: [
154
+ { name: 'sessions' },
155
+ { name: 'activeUsers' },
156
+ { name: 'engagementRate' },
157
+ { name: 'bounceRate' },
158
+ { name: 'averageSessionDuration' },
159
+ { name: 'screenPageViews' }
160
+ ],
161
+ limit: limit
162
+ }
163
+
164
+ filter = build_ga4_filter(hostname: hostname, organic_only: organic_only, site_only: site_only)
165
+ body[:dimensionFilter] = filter if filter
166
+
167
+ @client.post(endpoint, body)
168
+ end
169
+
170
+ # Google Analytics Admin API (accountSummaries)
171
+ def list_ga4_summaries
172
+ @client.get('https://analyticsadmin.googleapis.com/v1beta/accountSummaries')
173
+ end
174
+
175
+ # Google Analytics 4 Realtime API (runRealtimeReport)
176
+ def query_ga4_realtime(property_id, limit: 30)
177
+ clean_id = property_id.to_s.sub(%r{^properties/}, '')
178
+ endpoint = "https://analyticsdata.googleapis.com/v1beta/properties/#{clean_id}:runRealtimeReport"
179
+
180
+ body = {
181
+ dimensions: [
182
+ { name: 'unifiedScreenName' },
183
+ { name: 'country' }
184
+ ],
185
+ metrics: [
186
+ { name: 'activeUsers' }
187
+ ],
188
+ metricAggregations: ['TOTAL'],
189
+ limit: limit
190
+ }
191
+
192
+ @client.post(endpoint, body)
193
+ end
194
+
195
+ # Google Analytics 4 Page Title to URL Path resolution map
196
+ def query_ga4_title_map(property_id, days: 30, hostname: nil, site_only: false)
197
+ clean_id = property_id.to_s.sub(%r{^properties/}, '')
198
+ endpoint = "https://analyticsdata.googleapis.com/v1beta/properties/#{clean_id}:runReport"
199
+
200
+ body = {
201
+ dateRanges: [{ startDate: "#{days}daysAgo", endDate: 'today' }],
202
+ dimensions: [{ name: 'pageTitle' }, { name: 'pagePath' }],
203
+ metrics: [{ name: 'screenPageViews' }],
204
+ limit: 250
205
+ }
206
+
207
+ filter = build_ga4_filter(hostname: hostname, site_only: site_only)
208
+ body[:dimensionFilter] = filter if filter
209
+
210
+ res = @client.post(endpoint, body)
211
+ map = {}
212
+ if res[:ok]
213
+ (res[:data]['rows'] || []).each do |r|
214
+ title = r.dig('dimensionValues', 0, 'value')
215
+ path = r.dig('dimensionValues', 1, 'value')
216
+ map[title] ||= path if title && path
217
+ end
218
+ end
219
+ map
220
+ rescue StandardError
221
+ {}
222
+ end
223
+
224
+ # Google Analytics 4 Google Ads Report
225
+ def query_ga4_ads(property_id, days: 30, limit: 50)
226
+ clean_id = property_id.to_s.sub(%r{^properties/}, '')
227
+ endpoint = "https://analyticsdata.googleapis.com/v1beta/properties/#{clean_id}:runReport"
228
+
229
+ body = {
230
+ dateRanges: [{ startDate: "#{days}daysAgo", endDate: 'yesterday' }],
231
+ dimensions: [
232
+ { name: 'sessionGoogleAdsCampaignName' },
233
+ { name: 'sessionGoogleAdsAdGroupName' }
234
+ ],
235
+ metrics: [
236
+ { name: 'advertiserAdClicks' },
237
+ { name: 'advertiserAdCost' },
238
+ { name: 'advertiserAdCostPerClick' },
239
+ { name: 'sessions' },
240
+ { name: 'conversions' },
241
+ { name: 'bounceRate' }
242
+ ],
243
+ limit: limit
244
+ }
245
+
246
+ @client.post(endpoint, body)
247
+ end
248
+
249
+ # Google Analytics 4 Omnichannel Traffic Acquisition Report
250
+ def query_ga4_channels(property_id, days: 30, limit: 25, hostname: nil, site_only: false)
251
+ clean_id = property_id.to_s.sub(%r{^properties/}, '')
252
+ endpoint = "https://analyticsdata.googleapis.com/v1beta/properties/#{clean_id}:runReport"
253
+
254
+ body = {
255
+ dateRanges: [{ startDate: "#{days}daysAgo", endDate: 'yesterday' }],
256
+ dimensions: [
257
+ { name: 'sessionDefaultChannelGroup' },
258
+ { name: 'sessionSourceMedium' }
259
+ ],
260
+ metrics: [
261
+ { name: 'sessions' },
262
+ { name: 'totalUsers' },
263
+ { name: 'engagementRate' },
264
+ { name: 'bounceRate' },
265
+ { name: 'averageSessionDuration' },
266
+ { name: 'conversions' }
267
+ ],
268
+ limit: limit
269
+ }
270
+
271
+ filter = build_ga4_filter(hostname: hostname, site_only: site_only)
272
+ body[:dimensionFilter] = filter if filter
273
+
274
+ @client.post(endpoint, body)
275
+ end
276
+
277
+ # Google Analytics 4 Geographic Cities Report
278
+ def query_ga4_cities(property_id, days: 30, limit: 10, hostname: nil, site_only: false)
279
+ clean_id = property_id.to_s.sub(%r{^properties/}, '')
280
+ endpoint = "https://analyticsdata.googleapis.com/v1beta/properties/#{clean_id}:runReport"
281
+
282
+ body = {
283
+ dateRanges: [{ startDate: "#{days}daysAgo", endDate: 'yesterday' }],
284
+ dimensions: [{ name: 'city' }, { name: 'country' }],
285
+ metrics: [
286
+ { name: 'sessions' },
287
+ { name: 'bounceRate' },
288
+ { name: 'averageSessionDuration' }
289
+ ],
290
+ orderBys: [{ metric: { metricName: 'sessions' }, desc: true }],
291
+ limit: limit
292
+ }
293
+
294
+ filter = build_ga4_filter(hostname: hostname, site_only: site_only)
295
+ body[:dimensionFilter] = filter if filter
296
+
297
+ @client.post(endpoint, body)
298
+ end
299
+ end
300
+ end
data/lib/gsc/auth.rb ADDED
@@ -0,0 +1,92 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GSC
4
+ class Auth
5
+ OAUTH_TOKEN_URI = URI('https://oauth2.googleapis.com/token')
6
+
7
+ SCOPES = [
8
+ 'https://www.googleapis.com/auth/indexing',
9
+ 'https://www.googleapis.com/auth/webmasters',
10
+ 'https://www.googleapis.com/auth/webmasters.readonly',
11
+ 'https://www.googleapis.com/auth/analytics.readonly'
12
+ ].freeze
13
+
14
+ def self.find_key(custom_path = nil)
15
+ candidates = [
16
+ custom_path,
17
+ Config.key_path,
18
+ ENV['GSC_KEY_PATH'],
19
+ ENV['GOOGLE_APPLICATION_CREDENTIALS'],
20
+ File.expand_path('gsc-service-account.json', Dir.pwd),
21
+ File.expand_path('service-account.json', Dir.pwd),
22
+ File.expand_path('config/gsc-service-account.json', Dir.pwd),
23
+ File.expand_path('config/service-account.json', Dir.pwd),
24
+ File.expand_path('../gsc-service-account.json', Dir.pwd),
25
+ File.expand_path('../service-account.json', Dir.pwd),
26
+ File.expand_path('~/.config/gsc/service-account.json'),
27
+ File.expand_path('~/.gsc-service-account.json')
28
+ ].compact
29
+
30
+ found = candidates.find do |path|
31
+ next false unless File.file?(path)
32
+
33
+ begin
34
+ json = JSON.parse(File.read(path))
35
+ json['client_email'] && json['private_key']
36
+ rescue StandardError
37
+ false
38
+ end
39
+ end
40
+
41
+ if found
42
+ dir = File.dirname(File.expand_path(found))
43
+ is_git = File.exist?(File.join(dir, '.git')) || File.exist?(File.join(File.dirname(dir), '.git'))
44
+ if is_git && !found.start_with?(Config::CONFIG_DIR)
45
+ warn Color.c("⚠️ Security Warning: Service account key is stored in a Git repository (#{found}).", Color::YELLOW)
46
+ warn Color.c(" Run 'gsc connect' or move key to ~/.config/gsc/ to avoid accidental credential commits.\n", Color::YELLOW)
47
+ end
48
+ end
49
+
50
+ found
51
+ end
52
+
53
+ def self.fetch_access_token(service_account)
54
+ now = Time.now.to_i
55
+ header = { alg: 'RS256', typ: 'JWT' }
56
+ claims = {
57
+ iss: service_account['client_email'],
58
+ scope: SCOPES.join(' '),
59
+ aud: OAUTH_TOKEN_URI.to_s,
60
+ exp: now + 3600,
61
+ iat: now
62
+ }
63
+
64
+ encoded_header = base64_url_encode(JSON.generate(header))
65
+ encoded_claims = base64_url_encode(JSON.generate(claims))
66
+ unsigned_jwt = "#{encoded_header}.#{encoded_claims}"
67
+
68
+ private_key = OpenSSL::PKey::RSA.new(service_account['private_key'])
69
+ signature = private_key.sign(OpenSSL::Digest::SHA256.new, unsigned_jwt)
70
+ encoded_sig = base64_url_encode(signature)
71
+ signed_jwt = "#{unsigned_jwt}.#{encoded_sig}"
72
+
73
+ res = Net::HTTP.post_form(
74
+ OAUTH_TOKEN_URI,
75
+ 'grant_type' => 'urn:ietf:params:oauth:grant-type:jwt-bearer',
76
+ 'assertion' => signed_jwt
77
+ )
78
+
79
+ data = JSON.parse(res.body)
80
+ unless res.is_a?(Net::HTTPSuccess)
81
+ error_msg = data['error_description'] || data['error'] || res.body
82
+ raise "OAuth token request failed: #{error_msg}"
83
+ end
84
+
85
+ data['access_token']
86
+ end
87
+
88
+ def self.base64_url_encode(str)
89
+ Base64.urlsafe_encode64(str).delete('=')
90
+ end
91
+ end
92
+ end