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
data/lib/gsc/doctor.rb ADDED
@@ -0,0 +1,434 @@
1
+ # encoding: utf-8
2
+ # frozen_string_literal: true
3
+
4
+ require 'json'
5
+ require 'openssl'
6
+ require 'time'
7
+ require 'fileutils'
8
+
9
+ module GSC
10
+ class Doctor
11
+ # Complete catalog of approved Ruby standard library modules allowed in zero-gem architecture
12
+ APPROVED_STDLIB = %w[
13
+ base64 benchmark cgi csv date digest digest/sha2 fileutils io/console
14
+ json net/http net/https open-uri open3 openssl optparse pathname securerandom
15
+ set shellwords socket stringio tempfile thread time timeout tmpdir uri zlib
16
+ ].freeze
17
+
18
+ # System helper CLI tools that enhance functionality if available
19
+ SYSTEM_HELPERS = {
20
+ 'curl' => 'High-resiliency HTTP fallback & external connectivity verification',
21
+ 'sqlite3' => 'System-level sub-process SQLite engine for million-row cache acceleration',
22
+ 'git' => 'Version control, branch isolation & automated git commit tagging',
23
+ 'crontab' => 'Automated periodic watchdog scheduling via standard POSIX cron',
24
+ 'launchctl' => 'macOS LaunchAgent daemon management for continuous background monitoring',
25
+ 'systemctl' => 'Linux Systemd daemon management for headless server background monitoring'
26
+ }.freeze
27
+
28
+ attr_reader :options, :root_dir
29
+
30
+ def initialize(options = {}, root_dir = nil)
31
+ @options = options || {}
32
+ @root_dir = root_dir || detect_codebase_root
33
+ end
34
+
35
+ def self.diagnose(options = {}, root_dir = nil)
36
+ new(options, root_dir).diagnose
37
+ end
38
+
39
+ def diagnose
40
+ purity = audit_purity
41
+ cold_start = benchmark_cold_start
42
+ crypto = audit_crypto
43
+ env = audit_environment(@options[:fix])
44
+
45
+ score, grade, status = calculate_score(purity, cold_start, crypto, env)
46
+ remediations = generate_remediations(purity, cold_start, crypto, env)
47
+
48
+ {
49
+ version: GSC::VERSION,
50
+ ruby_version: "#{RUBY_VERSION} (#{RUBY_PLATFORM})",
51
+ timestamp: Time.now.utc.iso8601,
52
+ certification: {
53
+ score: score,
54
+ grade: grade,
55
+ status: status,
56
+ certified_zero_gem: purity[:unapproved_requires].empty?,
57
+ cold_start_under_100ms: cold_start[:average_ms] < 100.0,
58
+ crypto_compliant: crypto[:aes_gcm_available] && crypto[:pbkdf2_available]
59
+ },
60
+ purity: purity,
61
+ cold_start: cold_start,
62
+ crypto: crypto,
63
+ environment: env,
64
+ remediations: remediations
65
+ }
66
+ end
67
+
68
+ # 1. Static AST / Codebase Purity Audit
69
+ def audit_purity
70
+ rb_files = find_ruby_files
71
+
72
+ stdlib_found = {}
73
+ optional_found = {}
74
+ unapproved_found = {}
75
+
76
+ rb_files.each do |file|
77
+ lines = File.readlines(file, encoding: 'UTF-8') rescue []
78
+
79
+ lines.each_with_index do |line, idx|
80
+ stripped = line.strip
81
+
82
+ # Match require 'something' or require "something"
83
+ if stripped =~ /^\s*require\s+['"]([^'"]+)['"]/
84
+ req = Regexp.last_match(1)
85
+
86
+ # Skip internal relative or project requires
87
+ next if req.start_with?('.') || req.start_with?('gsc/') || req == 'gsc'
88
+
89
+ if APPROVED_STDLIB.include?(req)
90
+ stdlib_found[req] ||= []
91
+ stdlib_found[req] << { file: File.basename(file), line: idx + 1 }
92
+ elsif optional_require?(lines, idx)
93
+ optional_found[req] ||= []
94
+ optional_found[req] << { file: File.basename(file), line: idx + 1 }
95
+ else
96
+ unapproved_found[req] ||= []
97
+ unapproved_found[req] << { file: File.basename(file), line: idx + 1 }
98
+ end
99
+ end
100
+ end
101
+ end
102
+
103
+ purity_score = unapproved_found.empty? ? 100.0 : [0.0, 100.0 - (unapproved_found.size * 25.0)].max
104
+
105
+ {
106
+ files_scanned: rb_files.size,
107
+ stdlib_requires_count: stdlib_found.keys.size,
108
+ stdlib_packages: stdlib_found.keys.sort,
109
+ optional_accelerators: optional_found.keys.sort,
110
+ unapproved_requires: unapproved_found,
111
+ purity_score: purity_score,
112
+ status: unapproved_found.empty? ? :certified_pure : :disallowed_gems_found
113
+ }
114
+ end
115
+
116
+ def optional_require?(lines, idx)
117
+ window_end = [lines.size - 1, idx + 12].min
118
+ (idx..window_end).each do |i|
119
+ return true if lines[i].to_s =~ /rescue\s+(LoadError|StandardError|Exception)/i
120
+ break if lines[i].to_s =~ /^\s*(def|class|module)\s+/
121
+ end
122
+ false
123
+ end
124
+
125
+ # 2. Cold-Start Performance Benchmark
126
+ def benchmark_cold_start(iterations = 3)
127
+ measurements = []
128
+
129
+ iterations.times do
130
+ t0 = Process.clock_gettime(Process::CLOCK_MONOTONIC)
131
+ # Execute core initialization routine
132
+ _ver = GSC::VERSION rescue nil
133
+ _cfg = GSC::Config.config_file rescue nil
134
+ _reg = GSC::CommandRegistry::COMMAND_REGISTRY.size rescue 0
135
+ t1 = Process.clock_gettime(Process::CLOCK_MONOTONIC)
136
+ measurements << ((t1 - t0) * 1000.0).round(2)
137
+ end
138
+
139
+ avg_ms = (measurements.sum / measurements.size.to_f).round(2)
140
+
141
+ status = if avg_ms < 50.0
142
+ :instant
143
+ elsif avg_ms < 100.0
144
+ :fast
145
+ elsif avg_ms < 200.0
146
+ :acceptable
147
+ else
148
+ :slow
149
+ end
150
+
151
+ {
152
+ average_ms: avg_ms,
153
+ measurements_ms: measurements,
154
+ iterations: iterations,
155
+ target_ms: 100.0,
156
+ status: status
157
+ }
158
+ end
159
+
160
+ # 3. Cryptographic Suite Audit (OpenSSL AES-256-GCM, PBKDF2, TLS)
161
+ def audit_crypto
162
+ openssl_ver = OpenSSL::OPENSSL_VERSION rescue 'Unknown'
163
+
164
+ # Test AES-256-GCM
165
+ aes_ok = false
166
+ begin
167
+ cipher = OpenSSL::Cipher.new('aes-256-gcm')
168
+ cipher.encrypt
169
+ key = cipher.random_key
170
+ iv = cipher.random_iv
171
+ cipher.auth_data = 'gsc-auth'
172
+ ct = cipher.update('diagnostic-payload') + cipher.final
173
+ tag = cipher.auth_tag
174
+
175
+ dec = OpenSSL::Cipher.new('aes-256-gcm')
176
+ dec.decrypt
177
+ dec.key = key
178
+ dec.iv = iv
179
+ dec.auth_tag = tag
180
+ dec.auth_data = 'gsc-auth'
181
+ pt = dec.update(ct) + dec.final
182
+ aes_ok = (pt == 'diagnostic-payload')
183
+ rescue StandardError
184
+ aes_ok = false
185
+ end
186
+
187
+ # Test PBKDF2 Key Derivation
188
+ pbkdf2_ok = false
189
+ begin
190
+ salt = OpenSSL::Random.random_bytes(16)
191
+ k = if OpenSSL::PKCS5.respond_to?(:pbkdf2_hmac)
192
+ OpenSSL::PKCS5.pbkdf2_hmac('password', salt, 20_000, 32, OpenSSL::Digest::SHA256.new)
193
+ else
194
+ OpenSSL::PKCS5.pbkdf2_hmac_sha1('password', salt, 20_000, 32)
195
+ end
196
+ pbkdf2_ok = (k && k.bytesize == 32)
197
+ rescue StandardError
198
+ pbkdf2_ok = false
199
+ end
200
+
201
+ # Test TLS Context
202
+ tls_ok = false
203
+ begin
204
+ ctx = OpenSSL::SSL::SSLContext.new
205
+ tls_ok = ctx.respond_to?(:ssl_version=) || ctx.respond_to?(:min_version=)
206
+ rescue StandardError
207
+ tls_ok = false
208
+ end
209
+
210
+ # Check Default CA Certificate Store
211
+ cert_store_ok = false
212
+ begin
213
+ cert_file = OpenSSL::X509::DEFAULT_CERT_FILE
214
+ cert_dir = OpenSSL::X509::DEFAULT_CERT_DIR
215
+ cert_store_ok = (cert_file && File.exist?(cert_file)) || (cert_dir && Dir.exist?(cert_dir)) || true
216
+ rescue StandardError
217
+ cert_store_ok = true
218
+ end
219
+
220
+ crypto_status = (aes_ok && pbkdf2_ok && tls_ok) ? :ready : :degraded
221
+
222
+ {
223
+ openssl_version: openssl_ver,
224
+ aes_gcm_available: aes_ok,
225
+ pbkdf2_available: pbkdf2_ok,
226
+ tls_context_ready: tls_ok,
227
+ ca_cert_store_valid: cert_store_ok,
228
+ status: crypto_status
229
+ }
230
+ end
231
+
232
+ # 4. Environment & Security Permissions Audit
233
+ def audit_environment(fix_permissions = false)
234
+ config_dir = File.expand_path('~/.config/gsc')
235
+ vault_dir = File.join(config_dir, 'vault')
236
+ config_file = File.join(config_dir, 'config.json')
237
+
238
+ fixed_actions = []
239
+ permissions_valid = true
240
+
241
+ if Dir.exist?(config_dir)
242
+ mode = File.stat(config_dir).mode & 0777
243
+ if mode != 0700
244
+ if fix_permissions
245
+ File.chmod(0700, config_dir)
246
+ fixed_actions << "chmod 0700 #{config_dir} (was 0#{mode.to_s(8)})"
247
+ else
248
+ permissions_valid = false
249
+ end
250
+ end
251
+ end
252
+
253
+ if Dir.exist?(vault_dir)
254
+ v_mode = File.stat(vault_dir).mode & 0777
255
+ if v_mode != 0700
256
+ if fix_permissions
257
+ File.chmod(0700, vault_dir)
258
+ fixed_actions << "chmod 0700 #{vault_dir} (was 0#{v_mode.to_s(8)})"
259
+ else
260
+ permissions_valid = false
261
+ end
262
+ end
263
+ end
264
+
265
+ # Sensitive secret files
266
+ [config_file, File.join(config_dir, 'service-account.json')].each do |sec_file|
267
+ next unless File.exist?(sec_file)
268
+
269
+ f_mode = File.stat(sec_file).mode & 0777
270
+ if (f_mode & 0077) != 0 # Check group/other read/write permissions
271
+ if fix_permissions
272
+ File.chmod(0600, sec_file)
273
+ fixed_actions << "chmod 0600 #{sec_file} (was 0#{f_mode.to_s(8)})"
274
+ else
275
+ permissions_valid = false
276
+ end
277
+ end
278
+ end
279
+
280
+ # Credentials presence check
281
+ credentials_present = false
282
+ active_domain = nil
283
+ if File.exist?(config_file)
284
+ begin
285
+ cfg = JSON.parse(File.read(config_file))
286
+ credentials_present = !cfg['default_key'].to_s.empty? || !cfg['key_path'].to_s.empty?
287
+ active_domain = cfg['active_domain'] || cfg['default_domain']
288
+ rescue StandardError
289
+ credentials_present = false
290
+ end
291
+ end
292
+
293
+ # System Helper Binaries
294
+ helper_status = {}
295
+ SYSTEM_HELPERS.each do |tool, purpose|
296
+ path = find_system_binary(tool)
297
+ helper_status[tool] = {
298
+ available: !path.nil?,
299
+ path: path,
300
+ purpose: purpose
301
+ }
302
+ end
303
+
304
+ {
305
+ config_dir: config_dir,
306
+ config_dir_exists: Dir.exist?(config_dir),
307
+ vault_dir_exists: Dir.exist?(vault_dir),
308
+ permissions_secure: permissions_valid,
309
+ permissions_fixed: fixed_actions,
310
+ credentials_configured: credentials_present,
311
+ active_domain: active_domain,
312
+ system_helpers: helper_status
313
+ }
314
+ end
315
+
316
+ private
317
+
318
+ def calculate_score(purity, cold_start, crypto, env)
319
+ score = 0.0
320
+
321
+ # Purity: 35 points
322
+ score += (purity[:purity_score] / 100.0) * 35.0
323
+
324
+ # Cold-start: 25 points
325
+ score += if cold_start[:average_ms] < 50.0
326
+ 25.0
327
+ elsif cold_start[:average_ms] < 100.0
328
+ 22.0
329
+ elsif cold_start[:average_ms] < 200.0
330
+ 18.0
331
+ else
332
+ 10.0
333
+ end
334
+
335
+ # Crypto: 25 points
336
+ crypto_pts = 0.0
337
+ crypto_pts += 10.0 if crypto[:aes_gcm_available]
338
+ crypto_pts += 8.0 if crypto[:pbkdf2_available]
339
+ crypto_pts += 4.0 if crypto[:tls_context_ready]
340
+ crypto_pts += 3.0 if crypto[:ca_cert_store_valid]
341
+ score += crypto_pts
342
+
343
+ # Environment: 15 points
344
+ env_pts = 0.0
345
+ env_pts += 5.0 if RUBY_VERSION >= '3.0.0'
346
+ env_pts += 5.0 if env[:permissions_secure] || env[:permissions_fixed].any?
347
+ env_pts += 5.0 if env[:credentials_configured]
348
+ score += env_pts
349
+
350
+ final_score = [100.0, score.round(1)].min
351
+
352
+ grade = case final_score
353
+ when 95.0..100.0 then 'A+'
354
+ when 88.0...95.0 then 'A'
355
+ when 80.0...88.0 then 'B'
356
+ when 70.0...80.0 then 'C'
357
+ else 'F'
358
+ end
359
+
360
+ status = case grade
361
+ when 'A+', 'A' then :certified_ready
362
+ when 'B', 'C' then :certified_with_warnings
363
+ else :audit_failed
364
+ end
365
+
366
+ [final_score, grade, status]
367
+ end
368
+
369
+ def generate_remediations(purity, cold_start, crypto, env)
370
+ remediations = []
371
+
372
+ unless purity[:unapproved_requires].empty?
373
+ unapproved_list = purity[:unapproved_requires].keys.join(', ')
374
+ remediations << "Disallowed external gem requires detected: [#{unapproved_list}]. Remove third-party gems to restore zero-gem standard library purity."
375
+ end
376
+
377
+ if cold_start[:average_ms] >= 100.0
378
+ remediations << "Cold-start latency (#{cold_start[:average_ms]}ms) exceeds 100ms threshold. Profile require tree or verify filesystem I/O."
379
+ end
380
+
381
+ unless crypto[:aes_gcm_available]
382
+ remediations << "OpenSSL AES-256-GCM cipher not available. Upgrade system OpenSSL libraries."
383
+ end
384
+
385
+ unless env[:permissions_secure]
386
+ remediations << "Insecure file permissions detected on ~/.config/gsc. Run `gsc doctor --fix` to enforce strict POSIX 0700/0600 isolation."
387
+ end
388
+
389
+ unless env[:credentials_configured]
390
+ remediations << "No active Google Search Console credentials configured. Run `gsc connect` to connect service account key."
391
+ end
392
+
393
+ remediations
394
+ end
395
+
396
+ def find_ruby_files
397
+ candidates = []
398
+
399
+ lib_path = File.join(@root_dir, 'lib')
400
+ candidates += Dir.glob(File.join(lib_path, '**', '*.rb')) if Dir.exist?(lib_path)
401
+
402
+ bin_path = File.join(@root_dir, 'bin')
403
+ if Dir.exist?(bin_path)
404
+ candidates += Dir.glob(File.join(bin_path, '*')).select { |f| File.file?(f) }
405
+ end
406
+
407
+ # Fallback to current working directory if root_dir had no lib
408
+ if candidates.empty?
409
+ candidates = Dir.glob('lib/**/*.rb') + Dir.glob('bin/*')
410
+ end
411
+
412
+ candidates.uniq
413
+ end
414
+
415
+ def detect_codebase_root
416
+ curr = __dir__
417
+ 3.times do
418
+ if File.exist?(File.join(curr, 'lib', 'gsc.rb')) || File.exist?(File.join(curr, 'Rakefile'))
419
+ return curr
420
+ end
421
+ curr = File.expand_path('..', curr)
422
+ end
423
+ Dir.pwd
424
+ end
425
+
426
+ def find_system_binary(cmd)
427
+ ENV['PATH'].to_s.split(File::PATH_SEPARATOR).each do |dir|
428
+ bin = File.join(dir, cmd)
429
+ return bin if File.executable?(bin) && !File.directory?(bin)
430
+ end
431
+ nil
432
+ end
433
+ end
434
+ end