gsc-cli 2.1.0 → 2.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/README.md +410 -414
- 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/base.rb
ADDED
|
@@ -0,0 +1,384 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'uri'
|
|
4
|
+
require 'json'
|
|
5
|
+
|
|
6
|
+
module GSC
|
|
7
|
+
class CLI
|
|
8
|
+
module Base
|
|
9
|
+
BANNER = <<~BANNER
|
|
10
|
+
#{Color::BOLD}#{Color::CYAN}╔══════════════════════════════════════════════════════════════╗
|
|
11
|
+
║ 🚀 GOOGLE SEARCH CONSOLE & INDEXING API (RUBY CLI) ║
|
|
12
|
+
╚══════════════════════════════════════════════════════════════╝#{Color::RESET}
|
|
13
|
+
BANNER
|
|
14
|
+
|
|
15
|
+
COUNTRY_NAMES = {
|
|
16
|
+
'usa' => 'United States 🇺🇸',
|
|
17
|
+
'gbr' => 'United Kingdom 🇬🇧',
|
|
18
|
+
'can' => 'Canada 🇨🇦',
|
|
19
|
+
'aus' => 'Australia 🇦🇺',
|
|
20
|
+
'ind' => 'India 🇮🇳',
|
|
21
|
+
'bra' => 'Brazil 🇧🇷',
|
|
22
|
+
'deu' => 'Germany 🇩🇪',
|
|
23
|
+
'fra' => 'France 🇫🇷',
|
|
24
|
+
'esp' => 'Spain 🇪🇸',
|
|
25
|
+
'ita' => 'Italy 🇮🇹',
|
|
26
|
+
'mex' => 'Mexico 🇲🇽',
|
|
27
|
+
'jpn' => 'Japan 🇯🇵',
|
|
28
|
+
'kor' => 'South Korea 🇰🇷',
|
|
29
|
+
'nld' => 'Netherlands 🇳🇱',
|
|
30
|
+
'swe' => 'Sweden 🇸🇪',
|
|
31
|
+
'nor' => 'Norway 🇳🇴',
|
|
32
|
+
'dnk' => 'Denmark 🇩🇰',
|
|
33
|
+
'fin' => 'Finland 🇫🇮',
|
|
34
|
+
'che' => 'Switzerland 🇨🇭',
|
|
35
|
+
'aut' => 'Austria 🇦🇹',
|
|
36
|
+
'bel' => 'Belgium 🇧🇪',
|
|
37
|
+
'irl' => 'Ireland 🇮🇪',
|
|
38
|
+
'nzl' => 'New Zealand 🇳🇿',
|
|
39
|
+
'sgp' => 'Singapore 🇸🇬',
|
|
40
|
+
'hkg' => 'Hong Kong 🇭🇰',
|
|
41
|
+
'are' => 'UAE 🇦🇪',
|
|
42
|
+
'sau' => 'Saudi Arabia 🇸🇦',
|
|
43
|
+
'kwt' => 'Kuwait 🇰🇼',
|
|
44
|
+
'isr' => 'Israel 🇮🇱',
|
|
45
|
+
'tur' => 'Turkey 🇹🇷',
|
|
46
|
+
'pol' => 'Poland 🇵🇱',
|
|
47
|
+
'ukr' => 'Ukraine 🇺🇦',
|
|
48
|
+
'zaf' => 'South Africa 🇿🇦',
|
|
49
|
+
'col' => 'Colombia 🇨🇴',
|
|
50
|
+
'arg' => 'Argentina 🇦🇷',
|
|
51
|
+
'chl' => 'Chile 🇨🇱',
|
|
52
|
+
'per' => 'Peru 🇵🇪',
|
|
53
|
+
'pak' => 'Pakistan 🇵🇰',
|
|
54
|
+
'bgd' => 'Bangladesh 🇧🇩',
|
|
55
|
+
'phl' => 'Philippines 🇵🇭',
|
|
56
|
+
'vnm' => 'Vietnam 🇻🇳',
|
|
57
|
+
'tha' => 'Thailand 🇹🇭',
|
|
58
|
+
'mys' => 'Malaysia 🇲🇾',
|
|
59
|
+
'idn' => 'Indonesia 🇮🇩',
|
|
60
|
+
'chn' => 'China 🇨🇳',
|
|
61
|
+
'twn' => 'Taiwan 🇹🇼',
|
|
62
|
+
'prt' => 'Portugal 🇵🇹',
|
|
63
|
+
'grc' => 'Greece 🇬🇷',
|
|
64
|
+
'cze' => 'Czech Republic 🇨🇿',
|
|
65
|
+
'rou' => 'Romania 🇷🇴',
|
|
66
|
+
'hun' => 'Hungary 🇭🇺',
|
|
67
|
+
'egy' => 'Egypt 🇪🇬',
|
|
68
|
+
'nga' => 'Nigeria 🇳🇬',
|
|
69
|
+
'ken' => 'Kenya 🇰🇪',
|
|
70
|
+
'mar' => 'Morocco 🇲🇦',
|
|
71
|
+
'arm' => 'Armenia 🇦🇲',
|
|
72
|
+
'aze' => 'Azerbaijan 🇦🇿',
|
|
73
|
+
'bgr' => 'Bulgaria 🇧🇬',
|
|
74
|
+
'bhr' => 'Bahrain 🇧🇭',
|
|
75
|
+
'brn' => 'Brunei 🇧🇳',
|
|
76
|
+
'civ' => 'Ivory Coast 🇨🇮',
|
|
77
|
+
'cyp' => 'Cyprus 🇨🇾',
|
|
78
|
+
'dom' => 'Dominican Rep. 🇩🇴',
|
|
79
|
+
'dza' => 'Algeria 🇩🇿',
|
|
80
|
+
'gtm' => 'Guatemala 🇬🇹',
|
|
81
|
+
'hrv' => 'Croatia 🇭🇷',
|
|
82
|
+
'irn' => 'Iran 🇮🇷',
|
|
83
|
+
'irq' => 'Iraq 🇮🇶',
|
|
84
|
+
'jam' => 'Jamaica 🇯🇲',
|
|
85
|
+
'jor' => 'Jordan 🇯🇴',
|
|
86
|
+
'lby' => 'Libya 🇱🇾',
|
|
87
|
+
'ltu' => 'Lithuania 🇱🇹',
|
|
88
|
+
'lva' => 'Latvia 🇱🇻',
|
|
89
|
+
'est' => 'Estonia 🇪🇪',
|
|
90
|
+
'qat' => 'Qatar 🇶🇦',
|
|
91
|
+
'omn' => 'Oman 🇴🇲',
|
|
92
|
+
'srb' => 'Serbia 🇷🇸',
|
|
93
|
+
'svk' => 'Slovakia 🇸🇰',
|
|
94
|
+
'svn' => 'Slovenia 🇸🇮',
|
|
95
|
+
'isl' => 'Iceland 🇮🇸',
|
|
96
|
+
'lux' => 'Luxembourg 🇱🇺',
|
|
97
|
+
'lka' => 'Sri Lanka 🇱🇰',
|
|
98
|
+
'npl' => 'Nepal 🇳🇵',
|
|
99
|
+
'tun' => 'Tunisia 🇹🇳',
|
|
100
|
+
'gha' => 'Ghana 🇬🇭'
|
|
101
|
+
}.freeze
|
|
102
|
+
|
|
103
|
+
APPEARANCE_NAMES = {
|
|
104
|
+
'REVIEW_SNIPPET' => '⭐ Review Snippets',
|
|
105
|
+
'PRODUCT_SNIPPETS' => '🏷️ Product Snippets',
|
|
106
|
+
'MERCHANT_LISTINGS' => '🏪 Merchant Listings',
|
|
107
|
+
'PAGE_EXPERIENCE' => '⚡ Page Experience',
|
|
108
|
+
'GOOD_PAGE_EXPERIENCE' => '✅ Good Page Experience',
|
|
109
|
+
'FAQ' => '❓ FAQ Rich Results',
|
|
110
|
+
'VIDEO' => '🎥 Video Snippets',
|
|
111
|
+
'RECIPE' => '🍲 Recipe Rich Results',
|
|
112
|
+
'EVENT' => '📅 Event Listings',
|
|
113
|
+
'HOW_TO' => '📋 How-To Rich Results',
|
|
114
|
+
'SUBMIT_ACTION' => '📝 Sitelinks Searchbox',
|
|
115
|
+
'AMP_ARTICLE' => '⚡ AMP Article',
|
|
116
|
+
'ORGANIC_SHOPPING' => '🛍️ Organic Shopping',
|
|
117
|
+
'TRANSLATED_RESULT' => '🌐 Translated Result'
|
|
118
|
+
}.freeze
|
|
119
|
+
|
|
120
|
+
module_function
|
|
121
|
+
|
|
122
|
+
def resolve_domain(domain_arg, target_arg, options = {})
|
|
123
|
+
raw = domain_arg
|
|
124
|
+
if raw.nil? || raw.empty?
|
|
125
|
+
if target_arg && target_arg.start_with?('http://', 'https://')
|
|
126
|
+
raw = URI(target_arg).hostname
|
|
127
|
+
else
|
|
128
|
+
raw = ENV['GSC_DOMAIN'] || Config.default_domain
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
if raw.nil? || raw.empty?
|
|
133
|
+
if options[:json]
|
|
134
|
+
puts JSON.pretty_generate({ error: 'No target domain specified. Run gsc use <domain> or pass -d <domain>.' })
|
|
135
|
+
else
|
|
136
|
+
puts Color.c("\n⚠️ No target domain specified!", Color::YELLOW, Color::BOLD)
|
|
137
|
+
puts "Please specify a domain using one of the following:"
|
|
138
|
+
puts " 1. Set a global active domain: #{Color.c('gsc use <domain>', Color::CYAN)}"
|
|
139
|
+
puts " 2. Pass domain flag: #{Color.c('gsc <command> -d <domain>', Color::CYAN)}"
|
|
140
|
+
puts " 3. Set environment variable: #{Color.c('export GSC_DOMAIN=<domain>', Color::CYAN)}"
|
|
141
|
+
puts
|
|
142
|
+
end
|
|
143
|
+
exit 1
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
if raw.start_with?('sc-domain:')
|
|
147
|
+
hostname = raw.sub('sc-domain:', '')
|
|
148
|
+
site_url = raw
|
|
149
|
+
elsif raw.start_with?('http://', 'https://')
|
|
150
|
+
uri = URI(raw)
|
|
151
|
+
hostname = uri.hostname
|
|
152
|
+
site_url = raw.end_with?('/') ? raw : "#{raw}/"
|
|
153
|
+
else
|
|
154
|
+
hostname = raw.chomp('/')
|
|
155
|
+
site_url = "sc-domain:#{hostname}"
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
https_origin = "https://#{hostname}"
|
|
159
|
+
[hostname, site_url, https_origin]
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def require_target!(target, example, options = {})
|
|
163
|
+
return if target && !target.empty?
|
|
164
|
+
|
|
165
|
+
if options[:json]
|
|
166
|
+
puts JSON.pretty_generate({ error: 'Target URL is required', example: "gsc #{example}" })
|
|
167
|
+
else
|
|
168
|
+
puts Color.c("❌ Error: Please provide a URL target.", Color::RED)
|
|
169
|
+
puts "Example: gsc #{example}"
|
|
170
|
+
end
|
|
171
|
+
exit 1
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
def dry_run?(options)
|
|
175
|
+
if options[:dry_run]
|
|
176
|
+
puts Color.c('⚠️ [DRY-RUN] Preview mode active. No live API call made.', Color::YELLOW) unless options[:json]
|
|
177
|
+
true
|
|
178
|
+
else
|
|
179
|
+
false
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
def simulate_dry_run?(options); dry_run?(options); end
|
|
183
|
+
|
|
184
|
+
def print_batch_summary(success, fail, total)
|
|
185
|
+
puts "\n#{Color::BOLD}══════════════════════════════════════════════════════════════#{Color::RESET}"
|
|
186
|
+
puts "#{Color::BOLD}🎉 BATCH INDEXATION COMPLETE#{Color::RESET}"
|
|
187
|
+
puts " ✅ Successfully Submitted: #{Color.c(success.to_s, Color::GREEN, Color::BOLD)} / #{total}"
|
|
188
|
+
puts " ❌ Failed Requests: #{Color.c(fail.to_s, Color::RED, Color::BOLD)}" if fail > 0
|
|
189
|
+
puts "#{Color::BOLD}══════════════════════════════════════════════════════════════#{Color::RESET}\n"
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
def write_csv(path, headers, rows)
|
|
193
|
+
File.open(path, 'w', encoding: 'UTF-8') do |f|
|
|
194
|
+
escaped_headers = headers.map { |h| "\"#{h.to_s.gsub('"', '""')}\"" }
|
|
195
|
+
f.puts escaped_headers.join(',')
|
|
196
|
+
rows.each do |row|
|
|
197
|
+
escaped = row.map do |v|
|
|
198
|
+
str = v.to_s
|
|
199
|
+
str = "'#{str}" if str =~ /\A[=+\-@\t\r]/
|
|
200
|
+
"\"#{str.gsub('"', '""')}\""
|
|
201
|
+
end
|
|
202
|
+
f.puts escaped.join(',')
|
|
203
|
+
end
|
|
204
|
+
end
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
def format_api_error(data)
|
|
208
|
+
if data.is_a?(Hash)
|
|
209
|
+
data.dig('error', 'message') || data['error'] || data['message'] || data.to_json
|
|
210
|
+
else
|
|
211
|
+
data.to_s
|
|
212
|
+
end
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
def format_currency(val)
|
|
216
|
+
parts = sprintf('%.2f', val.to_f).split('.')
|
|
217
|
+
parts[0] = parts[0].reverse.gsub(/(\d{3})(?=\d)/, '\\1,').reverse
|
|
218
|
+
parts.join('.')
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
def normalize_path(url_or_path)
|
|
222
|
+
return '/' if url_or_path.nil? || url_or_path.empty?
|
|
223
|
+
p = url_or_path.to_s.sub(%r{^https?://[^/]+}, '').split('?').first.split('#').first.downcase.chomp('/')
|
|
224
|
+
p.empty? ? '/' : p
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
def format_duration(seconds)
|
|
228
|
+
sec = seconds.to_i
|
|
229
|
+
return "0s" if sec <= 0
|
|
230
|
+
m = sec / 60
|
|
231
|
+
s = sec % 60
|
|
232
|
+
m > 0 ? "#{m}m #{s}s" : "#{s}s"
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
def expected_ctr_for(pos)
|
|
236
|
+
case pos
|
|
237
|
+
when 0.0...1.5 then 28.0
|
|
238
|
+
when 1.5...2.5 then 15.0
|
|
239
|
+
when 2.5...3.5 then 11.0
|
|
240
|
+
when 3.5...4.5 then 8.0
|
|
241
|
+
when 4.5...6.0 then 6.0
|
|
242
|
+
when 6.0...8.0 then 4.5
|
|
243
|
+
else 3.0
|
|
244
|
+
end
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
def format_number(n)
|
|
248
|
+
n.to_s.reverse.gsub(/(\d{3})(?=\d)/, '\\1,').reverse
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
def format_country(code)
|
|
252
|
+
COUNTRY_NAMES[code.to_s.downcase] || code.to_s.upcase
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
def format_device(dev)
|
|
256
|
+
case dev.to_s.upcase
|
|
257
|
+
when 'DESKTOP' then '💻 Desktop'
|
|
258
|
+
when 'MOBILE' then '📱 Mobile'
|
|
259
|
+
when 'TABLET' then '📟 Tablet'
|
|
260
|
+
else dev.to_s.capitalize
|
|
261
|
+
end
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
def format_appearance(type)
|
|
265
|
+
APPEARANCE_NAMES[type.to_s.upcase] || type.to_s.tr('_', ' ').capitalize
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
def resolve_sort_params(field, order)
|
|
269
|
+
canonical_field = case field&.to_s&.downcase&.strip
|
|
270
|
+
when 'imp', 'impr', 'impressions'
|
|
271
|
+
:impressions
|
|
272
|
+
when 'pos', 'rank', 'position'
|
|
273
|
+
:position
|
|
274
|
+
when 'ctr'
|
|
275
|
+
:ctr
|
|
276
|
+
when 'clicks', 'click'
|
|
277
|
+
:clicks
|
|
278
|
+
when nil, ''
|
|
279
|
+
nil
|
|
280
|
+
else
|
|
281
|
+
field.to_s.downcase.strip.to_sym
|
|
282
|
+
end
|
|
283
|
+
|
|
284
|
+
canonical_order = if order && !order.to_s.strip.empty?
|
|
285
|
+
order.to_s.downcase.strip.start_with?('asc') ? :asc : :desc
|
|
286
|
+
elsif canonical_field == :position
|
|
287
|
+
:asc
|
|
288
|
+
else
|
|
289
|
+
:desc
|
|
290
|
+
end
|
|
291
|
+
|
|
292
|
+
[canonical_field, canonical_order]
|
|
293
|
+
end
|
|
294
|
+
|
|
295
|
+
def sort_analytics_rows(rows, sort_field, order)
|
|
296
|
+
field, dir = resolve_sort_params(sort_field, order)
|
|
297
|
+
|
|
298
|
+
if field
|
|
299
|
+
rows.sort do |a, b|
|
|
300
|
+
val_a = a[field] || 0
|
|
301
|
+
val_b = b[field] || 0
|
|
302
|
+
cmp = val_a <=> val_b
|
|
303
|
+
cmp = (cmp.nil? ? 0 : cmp)
|
|
304
|
+
dir == :asc ? cmp : -cmp
|
|
305
|
+
end
|
|
306
|
+
else
|
|
307
|
+
rows.sort do |a, b|
|
|
308
|
+
clicks_cmp = (b[:clicks] || 0) <=> (a[:clicks] || 0)
|
|
309
|
+
if clicks_cmp.zero?
|
|
310
|
+
imp_cmp = (b[:impressions] || 0) <=> (a[:impressions] || 0)
|
|
311
|
+
if imp_cmp.zero?
|
|
312
|
+
(a[:position] || 999.0) <=> (b[:position] || 999.0)
|
|
313
|
+
else
|
|
314
|
+
imp_cmp
|
|
315
|
+
end
|
|
316
|
+
else
|
|
317
|
+
clicks_cmp
|
|
318
|
+
end
|
|
319
|
+
end
|
|
320
|
+
end
|
|
321
|
+
end
|
|
322
|
+
|
|
323
|
+
def fetch_available_domains
|
|
324
|
+
domains = []
|
|
325
|
+
key_path = Auth.find_key
|
|
326
|
+
if key_path && File.exist?(key_path)
|
|
327
|
+
begin
|
|
328
|
+
sa = JSON.parse(File.read(key_path))
|
|
329
|
+
token = Auth.fetch_access_token(sa)
|
|
330
|
+
client = Client.new(token: token)
|
|
331
|
+
api = API.new(client)
|
|
332
|
+
res = api.list_sites
|
|
333
|
+
if res[:ok]
|
|
334
|
+
(res.dig(:data, 'siteEntry') || []).each do |s|
|
|
335
|
+
clean = s['siteUrl'].sub(%r{^https?://}, '').sub(/^sc-domain:/, '').chomp('/')
|
|
336
|
+
domains << clean unless domains.include?(clean)
|
|
337
|
+
end
|
|
338
|
+
end
|
|
339
|
+
rescue StandardError
|
|
340
|
+
# fallback to config
|
|
341
|
+
end
|
|
342
|
+
end
|
|
343
|
+
|
|
344
|
+
Config.ga4_properties.keys.each do |d|
|
|
345
|
+
domains << d unless domains.include?(d)
|
|
346
|
+
end
|
|
347
|
+
|
|
348
|
+
domains.sort
|
|
349
|
+
end
|
|
350
|
+
|
|
351
|
+
def copy_to_clipboard(text)
|
|
352
|
+
if RUBY_PLATFORM =~ /darwin/
|
|
353
|
+
IO.popen('pbcopy', 'w') { |f| f << text }
|
|
354
|
+
true
|
|
355
|
+
elsif system('which xclip > /dev/null 2>&1')
|
|
356
|
+
IO.popen('xclip -selection clipboard', 'w') { |f| f << text }
|
|
357
|
+
true
|
|
358
|
+
else
|
|
359
|
+
false
|
|
360
|
+
end
|
|
361
|
+
rescue StandardError
|
|
362
|
+
false
|
|
363
|
+
end
|
|
364
|
+
|
|
365
|
+
def open_in_browser(url)
|
|
366
|
+
return false if url.nil? || url.to_s.strip.empty?
|
|
367
|
+
return false if ENV['CI'] || ENV['HEADLESS']
|
|
368
|
+
|
|
369
|
+
if RUBY_PLATFORM =~ /darwin/
|
|
370
|
+
system('open', url.to_s)
|
|
371
|
+
elsif RUBY_PLATFORM =~ /linux/
|
|
372
|
+
return false unless ENV['DISPLAY'] || ENV['WAYLAND_DISPLAY']
|
|
373
|
+
system('xdg-open', url.to_s)
|
|
374
|
+
elsif RUBY_PLATFORM =~ /mswin|mingw|cygwin/
|
|
375
|
+
system('start', '', url.to_s)
|
|
376
|
+
else
|
|
377
|
+
false
|
|
378
|
+
end
|
|
379
|
+
rescue StandardError
|
|
380
|
+
false
|
|
381
|
+
end
|
|
382
|
+
end
|
|
383
|
+
end
|
|
384
|
+
end
|
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
require_relative 'base'
|
|
5
|
+
require_relative '../cache_manager' if File.exist?(File.expand_path('../cache_manager.rb', __dir__))
|
|
6
|
+
|
|
7
|
+
module GSC
|
|
8
|
+
class CLI
|
|
9
|
+
module Cache
|
|
10
|
+
module_function
|
|
11
|
+
|
|
12
|
+
def run(action, target, extra, options, api = nil)
|
|
13
|
+
mgr = GSC::CacheManager.new
|
|
14
|
+
|
|
15
|
+
subcmd = action.to_s.downcase.strip
|
|
16
|
+
subcmd = 'status' if subcmd.empty? || subcmd == 'cache'
|
|
17
|
+
|
|
18
|
+
case subcmd
|
|
19
|
+
when 'status', 'info', 'stats'
|
|
20
|
+
handle_status(mgr, options)
|
|
21
|
+
when 'warm', 'sync', 'fetch'
|
|
22
|
+
handle_warm(mgr, target || options[:domain], options, api)
|
|
23
|
+
when 'query', 'queries', 'search', 'q'
|
|
24
|
+
handle_query(mgr, target, options)
|
|
25
|
+
when 'pages', 'page', 'p'
|
|
26
|
+
handle_pages(mgr, target, options)
|
|
27
|
+
when 'diff', 'compare'
|
|
28
|
+
handle_diff(mgr, target || options[:domain], extra, options)
|
|
29
|
+
when 'clear', 'reset', 'drop'
|
|
30
|
+
handle_clear(mgr, target || options[:domain], options)
|
|
31
|
+
else
|
|
32
|
+
# If target was provided as a search term directly: `gsc cache "seo audit"`
|
|
33
|
+
handle_query(mgr, action, options)
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def handle_status(mgr, options)
|
|
38
|
+
stats = mgr.status
|
|
39
|
+
if options[:json]
|
|
40
|
+
puts JSON.pretty_generate(stats)
|
|
41
|
+
return
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
puts Base::BANNER unless options[:in_dashboard]
|
|
45
|
+
puts "💾 #{Color::BOLD}LOCAL QUERY & ANALYTICS CACHE STATUS#{Color::RESET}"
|
|
46
|
+
puts "─" * 70
|
|
47
|
+
puts " ⚙️ Storage Engine : #{Color.c(stats[:engine].to_s.upcase, Color::GREEN, Color::BOLD)}"
|
|
48
|
+
puts " 📁 Database Path : #{stats[:path]}"
|
|
49
|
+
puts " 📊 Database Size : #{Color.c(stats[:formatted_size], Color::CYAN, Color::BOLD)}"
|
|
50
|
+
puts " 🔎 Cached Queries : #{Color.c(Base.format_number(stats[:total_queries]), Color::YELLOW, Color::BOLD)}"
|
|
51
|
+
puts " 📄 Cached Pages : #{Color.c(Base.format_number(stats[:total_pages]), Color::YELLOW, Color::BOLD)}"
|
|
52
|
+
puts " 🌐 Cached Domains : #{stats[:domains].empty? ? '(none)' : stats[:domains].join(', ')}"
|
|
53
|
+
puts " 🕒 Last Updated : #{stats[:last_updated] || 'Never'}"
|
|
54
|
+
puts " 📸 Snapshots Count : #{stats[:snapshots].size}"
|
|
55
|
+
puts "─" * 70
|
|
56
|
+
puts "💡 #{Color::DIM}Run 'gsc cache warm' to sync Search Console data into local offline storage.#{Color::RESET}\n"
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def handle_warm(mgr, domain_arg, options, api)
|
|
60
|
+
hostname, site_url, _origin = Base.resolve_domain(domain_arg, nil, options)
|
|
61
|
+
days = options[:days] || 30
|
|
62
|
+
limit = options[:limit] || 5000
|
|
63
|
+
|
|
64
|
+
unless api
|
|
65
|
+
key_path = Auth.find_key(options[:key])
|
|
66
|
+
Auth.validate_key_file!(key_path, options)
|
|
67
|
+
sa = JSON.parse(File.read(key_path))
|
|
68
|
+
token = Auth.fetch_access_token(sa)
|
|
69
|
+
client = Client.new(token: token)
|
|
70
|
+
api = API.new(client)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
unless options[:json]
|
|
74
|
+
puts Base::BANNER unless options[:in_dashboard]
|
|
75
|
+
puts "🔥 #{Color::BOLD}WARMING LOCAL QUERY CACHE#{Color::RESET} for #{Color.c(hostname, Color::CYAN, Color::BOLD)} (Past #{days} days)..."
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
res = mgr.warm(site_url, api, days: days, limit: limit)
|
|
79
|
+
|
|
80
|
+
if options[:json]
|
|
81
|
+
puts JSON.pretty_generate(res)
|
|
82
|
+
return
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
puts "✅ Successfully warmed local cache in #{Color.c("#{res[:elapsed_ms]}ms", Color::GREEN, Color::BOLD)}!"
|
|
86
|
+
puts " • Saved Queries : #{Color.c(Base.format_number(res[:queries_saved]), Color::YELLOW, Color::BOLD)}"
|
|
87
|
+
puts " • Saved Pages : #{Color.c(Base.format_number(res[:pages_saved]), Color::YELLOW, Color::BOLD)}"
|
|
88
|
+
puts " • Snapshot Date : #{res[:snapshot_date]}"
|
|
89
|
+
puts "\n💡 #{Color::DIM}You can now query instantly offline: 'gsc cache query [term]'#{Color::RESET}\n"
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def handle_query(mgr, search_term, options)
|
|
93
|
+
start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
94
|
+
domain = options[:domain]
|
|
95
|
+
sort = options[:sort] || 'clicks'
|
|
96
|
+
order = options[:order] || 'desc'
|
|
97
|
+
limit = options[:limit] || 50
|
|
98
|
+
min_clicks = options[:min_clicks] || 0
|
|
99
|
+
min_imp = options[:min_imp] || 0
|
|
100
|
+
|
|
101
|
+
rows = mgr.query(
|
|
102
|
+
domain: domain,
|
|
103
|
+
search: search_term,
|
|
104
|
+
min_clicks: min_clicks,
|
|
105
|
+
min_impressions: min_imp,
|
|
106
|
+
sort: sort,
|
|
107
|
+
order: order,
|
|
108
|
+
limit: limit
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
elapsed_ms = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time) * 1000).round(2)
|
|
112
|
+
|
|
113
|
+
if options[:json]
|
|
114
|
+
puts JSON.pretty_generate({ total: rows.size, elapsed_ms: elapsed_ms, rows: rows })
|
|
115
|
+
return
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
if options[:csv]
|
|
119
|
+
Base.write_csv(options[:csv], %w[Domain Query Page Clicks Impressions CTR Position Date], rows.map { |r| [r[:domain], r[:query], r[:page], r[:clicks], r[:impressions], r[:ctr], r[:position], r[:snapshot_date]] })
|
|
120
|
+
puts Color.c("📁 Exported #{rows.size} cached query rows to #{options[:csv]} (#{elapsed_ms}ms)\n", Color::CYAN)
|
|
121
|
+
return
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
puts Base::BANNER unless options[:in_dashboard]
|
|
125
|
+
search_label = search_term && !search_term.strip.empty? ? " matching #{Color.c("\"#{search_term}\"", Color::CYAN)}" : ""
|
|
126
|
+
puts "⚡ #{Color::BOLD}OFFLINE CACHED SEARCH QUERIES#{Color::RESET}#{search_label} (#{Color.c("#{elapsed_ms}ms response", Color::GREEN)})"
|
|
127
|
+
puts "─" * 90
|
|
128
|
+
|
|
129
|
+
if rows.empty?
|
|
130
|
+
puts " (No cached queries found. Run 'gsc cache warm' to populate local database)"
|
|
131
|
+
puts "─" * 90
|
|
132
|
+
return
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
puts "%-42s %8s %10s %8s %8s" % ['Query', 'Clicks', 'Impressions', 'CTR', 'Position']
|
|
136
|
+
puts "─" * 90
|
|
137
|
+
rows.each do |r|
|
|
138
|
+
q_str = r[:query].length > 40 ? "#{r[:query][0..37]}..." : r[:query]
|
|
139
|
+
ctr_str = "#{r[:ctr]}%"
|
|
140
|
+
puts "%-42s %8s %10s %8s %8s" % [
|
|
141
|
+
Color.c(q_str, Color::BOLD),
|
|
142
|
+
Color.c(Base.format_number(r[:clicks]), Color::GREEN),
|
|
143
|
+
Base.format_number(r[:impressions]),
|
|
144
|
+
ctr_str,
|
|
145
|
+
Color.c(r[:position].to_s, Color::CYAN)
|
|
146
|
+
]
|
|
147
|
+
end
|
|
148
|
+
puts "─" * 90
|
|
149
|
+
puts "Showing #{rows.size} cached queries | Instant sub-10ms offline response (0 Google API quota used)\n"
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def handle_pages(mgr, search_term, options)
|
|
153
|
+
start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
154
|
+
domain = options[:domain]
|
|
155
|
+
sort = options[:sort] || 'clicks'
|
|
156
|
+
order = options[:order] || 'desc'
|
|
157
|
+
limit = options[:limit] || 50
|
|
158
|
+
min_clicks = options[:min_clicks] || 0
|
|
159
|
+
|
|
160
|
+
rows = mgr.pages(
|
|
161
|
+
domain: domain,
|
|
162
|
+
search: search_term,
|
|
163
|
+
min_clicks: min_clicks,
|
|
164
|
+
sort: sort,
|
|
165
|
+
order: order,
|
|
166
|
+
limit: limit
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
elapsed_ms = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time) * 1000).round(2)
|
|
170
|
+
|
|
171
|
+
if options[:json]
|
|
172
|
+
puts JSON.pretty_generate({ total: rows.size, elapsed_ms: elapsed_ms, rows: rows })
|
|
173
|
+
return
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
if options[:csv]
|
|
177
|
+
Base.write_csv(options[:csv], %w[Domain Page Clicks Impressions CTR Position Date], rows.map { |r| [r[:domain], r[:page], r[:clicks], r[:impressions], r[:ctr], r[:position], r[:snapshot_date]] })
|
|
178
|
+
puts Color.c("📁 Exported #{rows.size} cached pages to #{options[:csv]} (#{elapsed_ms}ms)\n", Color::CYAN)
|
|
179
|
+
return
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
puts Base::BANNER unless options[:in_dashboard]
|
|
183
|
+
puts "⚡ #{Color::BOLD}OFFLINE CACHED LANDING PAGES#{Color::RESET} (#{Color.c("#{elapsed_ms}ms response", Color::GREEN)})"
|
|
184
|
+
puts "─" * 90
|
|
185
|
+
|
|
186
|
+
if rows.empty?
|
|
187
|
+
puts " (No cached pages found. Run 'gsc cache warm' to populate local database)"
|
|
188
|
+
puts "─" * 90
|
|
189
|
+
return
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
puts "%-48s %8s %10s %8s %8s" % ['Page Path', 'Clicks', 'Impressions', 'CTR', 'Position']
|
|
193
|
+
puts "─" * 90
|
|
194
|
+
rows.each do |r|
|
|
195
|
+
p_str = Base.normalize_path(r[:page])
|
|
196
|
+
p_str = p_str.length > 46 ? "#{p_str[0..43]}..." : p_str
|
|
197
|
+
puts "%-48s %8s %10s %8s %8s" % [
|
|
198
|
+
Color.c(p_str, Color::CYAN),
|
|
199
|
+
Color.c(Base.format_number(r[:clicks]), Color::GREEN),
|
|
200
|
+
Base.format_number(r[:impressions]),
|
|
201
|
+
"#{r[:ctr]}%",
|
|
202
|
+
r[:position]
|
|
203
|
+
]
|
|
204
|
+
end
|
|
205
|
+
puts "─" * 90
|
|
206
|
+
puts "Showing #{rows.size} cached pages | Instant sub-10ms offline response\n"
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
def handle_diff(mgr, domain_arg, second_arg, options)
|
|
210
|
+
hostname, _site_url, _origin = Base.resolve_domain(domain_arg, nil, options)
|
|
211
|
+
old_date = options[:old_date] || second_arg
|
|
212
|
+
new_date = options[:new_date]
|
|
213
|
+
|
|
214
|
+
res = mgr.diff(hostname, old_date, new_date)
|
|
215
|
+
|
|
216
|
+
if options[:json]
|
|
217
|
+
puts JSON.pretty_generate(res)
|
|
218
|
+
return
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
puts Base::BANNER unless options[:in_dashboard]
|
|
222
|
+
puts "📊 #{Color::BOLD}HISTORIC OFFLINE QUERY DIFF:#{Color::RESET} #{Color.c(hostname, Color::CYAN, Color::BOLD)}"
|
|
223
|
+
puts " Comparing: #{Color.c(res[:old_date] || 'Previous', Color::YELLOW)} ➔ #{Color.c(res[:new_date] || 'Current', Color::GREEN)}"
|
|
224
|
+
puts "─" * 80
|
|
225
|
+
puts " 📈 Surging Winners : #{Color.c(res[:summary][:winners_count].to_s, Color::GREEN, Color::BOLD)}"
|
|
226
|
+
puts " 📉 Decaying Losers : #{Color.c(res[:summary][:losers_count].to_s, Color::RED, Color::BOLD)}"
|
|
227
|
+
puts " ✨ New Discovered : #{Color.c(res[:summary][:new_queries_count].to_s, Color::CYAN, Color::BOLD)}"
|
|
228
|
+
puts " 🗑️ Dropped Queries : #{Color.c(res[:summary][:dropped_queries_count].to_s, Color::MAGENTA, Color::BOLD)}"
|
|
229
|
+
puts "─" * 80
|
|
230
|
+
|
|
231
|
+
if res[:winners].any?
|
|
232
|
+
puts "\n#{Color::BOLD}🚀 Top Surging Queries (Clicks Gained):#{Color::RESET}"
|
|
233
|
+
res[:winners].first(5).each do |w|
|
|
234
|
+
puts " • #{Color.c(w[:query], Color::BOLD)}: +#{w[:click_delta]} clicks (#{w[:old_clicks]} ➔ #{w[:new_clicks]}), rank delta: #{w[:pos_delta] > 0 ? "+#{w[:pos_delta]}" : w[:pos_delta]}"
|
|
235
|
+
end
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
if res[:losers].any?
|
|
239
|
+
puts "\n#{Color::BOLD}⚠️ Top Decaying Queries (Clicks Lost):#{Color::RESET}"
|
|
240
|
+
res[:losers].first(5).each do |l|
|
|
241
|
+
puts " • #{Color.c(l[:query], Color::BOLD)}: #{l[:click_delta]} clicks (#{l[:old_clicks]} ➔ #{l[:new_clicks]}), rank delta: #{l[:pos_delta]}"
|
|
242
|
+
end
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
puts ""
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
def handle_clear(mgr, domain_arg, options)
|
|
249
|
+
all = options[:all] || domain_arg == 'all'
|
|
250
|
+
domain = all ? nil : domain_arg
|
|
251
|
+
|
|
252
|
+
res = mgr.clear(domain: domain, all: all)
|
|
253
|
+
if options[:json]
|
|
254
|
+
puts JSON.pretty_generate(res)
|
|
255
|
+
return
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
if all
|
|
259
|
+
puts "🗑️ #{Color.c('Cleared all cached data from local database.', Color::GREEN, Color::BOLD)}"
|
|
260
|
+
else
|
|
261
|
+
puts "🗑️ #{Color.c("Cleared cached data for domain: #{res[:domain]}", Color::GREEN, Color::BOLD)}"
|
|
262
|
+
end
|
|
263
|
+
end
|
|
264
|
+
end
|
|
265
|
+
end
|
|
266
|
+
end
|