gsc-cli 2.0.2 → 2.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (108) hide show
  1. checksums.yaml +4 -4
  2. data/AUTH.md +205 -0
  3. data/FUNDING.md +120 -0
  4. data/README.md +463 -299
  5. data/bin/gsc +29158 -4921
  6. data/dist/gsc +29158 -4921
  7. data/lib/gsc/aio_hunter.rb +343 -0
  8. data/lib/gsc/answer_synthesizer.rb +157 -0
  9. data/lib/gsc/api.rb +53 -1
  10. data/lib/gsc/auth.rb +26 -0
  11. data/lib/gsc/backlinks_manager.rb +96 -0
  12. data/lib/gsc/brand_segmenter.rb +140 -0
  13. data/lib/gsc/cache_manager.rb +806 -0
  14. data/lib/gsc/cannibalization_analyzer.rb +141 -0
  15. data/lib/gsc/canonical_chains.rb +367 -0
  16. data/lib/gsc/citation_simulator.rb +339 -0
  17. data/lib/gsc/cli/aio_hunter.rb +154 -0
  18. data/lib/gsc/cli/analytics.rb +788 -0
  19. data/lib/gsc/cli/audit.rb +1976 -0
  20. data/lib/gsc/cli/base.rb +384 -0
  21. data/lib/gsc/cli/cache.rb +266 -0
  22. data/lib/gsc/cli/canonical.rb +223 -0
  23. data/lib/gsc/cli/citation_simulator.rb +152 -0
  24. data/lib/gsc/cli/dashboard.rb +354 -0
  25. data/lib/gsc/cli/doctor.rb +129 -0
  26. data/lib/gsc/cli/eeat.rb +125 -0
  27. data/lib/gsc/cli/ga4.rb +852 -0
  28. data/lib/gsc/cli/growth.rb +650 -0
  29. data/lib/gsc/cli/hreflang.rb +164 -0
  30. data/lib/gsc/cli/image_seo.rb +162 -0
  31. data/lib/gsc/cli/indexing.rb +458 -0
  32. data/lib/gsc/cli/intent_shift.rb +125 -0
  33. data/lib/gsc/cli/keyword_value.rb +134 -0
  34. data/lib/gsc/cli/keywords.rb +795 -0
  35. data/lib/gsc/cli/landing_roi.rb +308 -0
  36. data/lib/gsc/cli/low_ctr.rb +213 -0
  37. data/lib/gsc/cli/mobile_parity.rb +150 -0
  38. data/lib/gsc/cli/report.rb +100 -0
  39. data/lib/gsc/cli/rich_results.rb +172 -0
  40. data/lib/gsc/cli/schema_generate.rb +149 -0
  41. data/lib/gsc/cli/seasonal.rb +232 -0
  42. data/lib/gsc/cli/security.rb +153 -0
  43. data/lib/gsc/cli/setup.rb +1291 -0
  44. data/lib/gsc/cli/sitemap_tree.rb +143 -0
  45. data/lib/gsc/cli/skill_pack.rb +62 -0
  46. data/lib/gsc/cli/soft_404.rb +199 -0
  47. data/lib/gsc/cli/sparkline.rb +227 -0
  48. data/lib/gsc/cli/watchdog.rb +150 -0
  49. data/lib/gsc/cli/zombie_purger.rb +208 -0
  50. data/lib/gsc/cli.rb +706 -5111
  51. data/lib/gsc/cli_advanced.rb +1513 -0
  52. data/lib/gsc/client.rb +17 -2
  53. data/lib/gsc/color.rb +16 -1
  54. data/lib/gsc/command_registry.rb +47 -9
  55. data/lib/gsc/config.rb +11 -2
  56. data/lib/gsc/content_gap.rb +112 -0
  57. data/lib/gsc/ctr_curve.rb +115 -0
  58. data/lib/gsc/decay_predictor.rb +322 -0
  59. data/lib/gsc/doctor.rb +434 -0
  60. data/lib/gsc/eeat_auditor.rb +428 -0
  61. data/lib/gsc/entity_auditor.rb +229 -0
  62. data/lib/gsc/firewall_scanner.rb +733 -0
  63. data/lib/gsc/geo_auditor.rb +368 -0
  64. data/lib/gsc/google_suggest.rb +109 -0
  65. data/lib/gsc/google_trends.rb +8 -1
  66. data/lib/gsc/heading_validator.rb +283 -0
  67. data/lib/gsc/hreflang_validator.rb +412 -0
  68. data/lib/gsc/image_seo.rb +286 -0
  69. data/lib/gsc/indexing_queue.rb +179 -0
  70. data/lib/gsc/indexnow.rb +93 -0
  71. data/lib/gsc/intent_shift.rb +188 -0
  72. data/lib/gsc/internal_links.rb +249 -0
  73. data/lib/gsc/keyword_value.rb +191 -0
  74. data/lib/gsc/landing_roi.rb +195 -0
  75. data/lib/gsc/llms_generator.rb +425 -0
  76. data/lib/gsc/low_ctr_rewriter.rb +408 -0
  77. data/lib/gsc/mobile_parity.rb +222 -0
  78. data/lib/gsc/network_tracer.rb +93 -0
  79. data/lib/gsc/open_page_rank.rb +72 -0
  80. data/lib/gsc/page_analyzer.rb +47 -7
  81. data/lib/gsc/page_comparator.rb +108 -0
  82. data/lib/gsc/page_speed.rb +110 -0
  83. data/lib/gsc/prompts.rb +38 -29
  84. data/lib/gsc/questions_harvester.rb +178 -0
  85. data/lib/gsc/report_generator.rb +461 -0
  86. data/lib/gsc/rich_results.rb +388 -0
  87. data/lib/gsc/robots_checker.rb +114 -0
  88. data/lib/gsc/schema_generator.rb +788 -0
  89. data/lib/gsc/schema_validator.rb +120 -0
  90. data/lib/gsc/seasonal_predictor.rb +381 -0
  91. data/lib/gsc/security_scanner.rb +496 -0
  92. data/lib/gsc/serp_feature_detector.rb +359 -0
  93. data/lib/gsc/serp_preview.rb +152 -0
  94. data/lib/gsc/site_crawler.rb +113 -21
  95. data/lib/gsc/sitemap_loader.rb +15 -4
  96. data/lib/gsc/sitemap_tree.rb +301 -0
  97. data/lib/gsc/skill_pack.rb +195 -0
  98. data/lib/gsc/soft_404_analyzer.rb +385 -0
  99. data/lib/gsc/sparkline.rb +171 -0
  100. data/lib/gsc/speed_correlator.rb +416 -0
  101. data/lib/gsc/striking_playbook.rb +190 -0
  102. data/lib/gsc/title_optimizer.rb +420 -0
  103. data/lib/gsc/vault.rb +260 -0
  104. data/lib/gsc/version.rb +1 -1
  105. data/lib/gsc/watchdog.rb +235 -0
  106. data/lib/gsc/zombie_purger.rb +366 -0
  107. data/lib/gsc.rb +144 -0
  108. metadata +91 -2
@@ -0,0 +1,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