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.
- checksums.yaml +7 -0
- data/LICENSE +21 -0
- data/README.md +500 -0
- data/bin/gsc +8128 -0
- data/dist/gsc +8128 -0
- data/lib/gsc/api.rb +300 -0
- data/lib/gsc/auth.rb +92 -0
- data/lib/gsc/cli.rb +5435 -0
- data/lib/gsc/client.rb +79 -0
- data/lib/gsc/color.rb +22 -0
- data/lib/gsc/command_registry.rb +335 -0
- data/lib/gsc/config.rb +178 -0
- data/lib/gsc/google_trends.rb +187 -0
- data/lib/gsc/keyword_planner.rb +256 -0
- data/lib/gsc/keywords_everywhere.rb +144 -0
- data/lib/gsc/page_analyzer.rb +431 -0
- data/lib/gsc/prompts.rb +285 -0
- data/lib/gsc/site_crawler.rb +260 -0
- data/lib/gsc/sitemap_loader.rb +91 -0
- data/lib/gsc/version.rb +5 -0
- data/lib/gsc.rb +38 -0
- metadata +67 -0
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
|