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,420 @@
1
+ # encoding: utf-8
2
+ # frozen_string_literal: true
3
+
4
+ require 'net/http'
5
+ require 'uri'
6
+ require 'json'
7
+ require 'zlib'
8
+ require 'stringio'
9
+ require 'time'
10
+ require_relative 'config' if File.exist?(File.expand_path('config.rb', __dir__))
11
+ require_relative 'color' if File.exist?(File.expand_path('color.rb', __dir__))
12
+ require_relative 'sitemap_loader' if File.exist?(File.expand_path('sitemap_loader.rb', __dir__))
13
+
14
+ module GSC
15
+ class TitleOptimizer
16
+ DESKTOP_MAX_PX = 580.0
17
+ MOBILE_MAX_PX = 540.0
18
+ OPTIMAL_MIN_PX = 380.0
19
+ OPTIMAL_MAX_PX = 560.0
20
+ MIN_CHARS = 35
21
+ MAX_CHARS = 65
22
+
23
+ SEPARATORS = [' | ', ' - ', ' — ', ' – ', ' • ', ' : ', ' » '].freeze
24
+
25
+ attr_reader :target, :options, :results, :brand_token
26
+
27
+ def initialize(target = nil, options = {})
28
+ raw = target.to_s.strip
29
+ raw = Config.default_domain.to_s.strip if raw.empty?
30
+ @target = raw
31
+ @options = options
32
+ @results = []
33
+ @brand_token = extract_brand_token(@target)
34
+ end
35
+
36
+ def audit(&progress_block)
37
+ urls = discover_target_urls(@target)
38
+ limit = (@options[:limit] || 25).to_i
39
+ urls = urls.first(limit) if limit > 0
40
+
41
+ total = urls.size
42
+ concurrency = (@options[:concurrency] || 5).to_i
43
+ concurrency = 1 if concurrency < 1
44
+ concurrency = [concurrency, 20].min
45
+ concurrency = [concurrency, total].min if total > 0
46
+
47
+ if concurrency <= 1 || total <= 1
48
+ urls.each_with_index do |url, idx|
49
+ progress_block.call(url, idx + 1, total) if block_given?
50
+ @results << audit_page(url)
51
+ end
52
+ else
53
+ queue = Queue.new
54
+ urls.each_with_index { |url, idx| queue << [url, idx] }
55
+
56
+ indexed_results = []
57
+ mutex = Mutex.new
58
+ completed = 0
59
+
60
+ workers = Array.new(concurrency) do
61
+ Thread.new do
62
+ loop do
63
+ item = begin
64
+ queue.pop(true)
65
+ rescue ThreadError
66
+ nil
67
+ end
68
+ break unless item
69
+
70
+ url, original_idx = item
71
+ page_data = audit_page(url)
72
+
73
+ mutex.synchronize do
74
+ indexed_results << [original_idx, page_data]
75
+ completed += 1
76
+ progress_block.call(url, completed, total) if block_given?
77
+ end
78
+ end
79
+ end
80
+ end
81
+
82
+ workers.each(&:join)
83
+ @results = indexed_results.sort_by { |idx, _| idx }.map { |_, data| data }
84
+ end
85
+
86
+ calculate_site_summary
87
+ end
88
+
89
+ def self.estimate_pixel_width(str)
90
+ width = 0.0
91
+ str.to_s.each_char do |ch|
92
+ width += case ch
93
+ when /[WM]/ then 13.5
94
+ when /[wm]/ then 12.0
95
+ when /[ABCDEFGHKNOPQRSTUVXYZ]/ then 10.5
96
+ when /[fijlt1I\|\ \.\:\;\!\,\'\`\-\/]/ then 4.5
97
+ when /[abcdeghknopqrsuvxyz]/ then 8.5
98
+ when /[\@\&\%\©\®\#\$\*\+\=\<\>]/ then 12.0
99
+ when /[0-9]/ then 9.0
100
+ else 8.5
101
+ end
102
+ end
103
+ width.round(1)
104
+ end
105
+
106
+ def self.truncate_to_pixel_width(str, limit_px = DESKTOP_MAX_PX)
107
+ return '' if str.nil? || str.empty?
108
+ return str if estimate_pixel_width(str) <= limit_px
109
+
110
+ ellipsis = '...'
111
+ target_limit = limit_px - estimate_pixel_width(ellipsis)
112
+ current = ''
113
+
114
+ str.each_char do |ch|
115
+ break if estimate_pixel_width(current + ch) > target_limit
116
+ current += ch
117
+ end
118
+
119
+ current.strip + ellipsis
120
+ end
121
+
122
+ private
123
+
124
+ def discover_target_urls(input)
125
+ normalized = input.start_with?('http://', 'https://') ? input : "https://#{input}"
126
+ uri = URI.parse(normalized)
127
+
128
+ # If input has a specific path that is not root and not sitemap, treat as single page
129
+ if !uri.path.empty? && uri.path != '/' && !uri.path.include?('sitemap') && !input.end_with?('.xml')
130
+ return [normalized]
131
+ end
132
+
133
+ # 1. Try sitemap first
134
+ sitemap_candidates = [
135
+ input.end_with?('.xml') ? input : nil,
136
+ "#{uri.scheme}://#{uri.host}:#{uri.port}/sitemap.xml",
137
+ "#{uri.scheme}://#{uri.host}:#{uri.port}/sitemap_products_1.xml"
138
+ ].compact
139
+
140
+ sitemap_candidates.each do |candidate|
141
+ begin
142
+ urls = SitemapLoader.load_urls(candidate)
143
+ return urls if urls.any?
144
+ rescue StandardError
145
+ # continue to next candidate
146
+ end
147
+ end
148
+
149
+ # 2. Fallback: Crawl homepage and extract internal links
150
+ crawl_homepage_links(normalized)
151
+ rescue StandardError
152
+ [input.start_with?('http') ? input : "https://#{input}"]
153
+ end
154
+
155
+ def crawl_homepage_links(root_url)
156
+ uri = URI.parse(root_url)
157
+ html = fetch_html(root_url)
158
+ return [root_url] if html.empty?
159
+
160
+ found = [root_url]
161
+ html.scan(/<a\s+[^>]*href=["']([^"']+)["']/i).flatten.each do |href|
162
+ href = href.split('#').first.to_s.strip
163
+ next if href.empty? || href.start_with?('javascript:', 'mailto:', 'tel:')
164
+
165
+ resolved = begin
166
+ URI.join(root_url, href).to_s
167
+ rescue StandardError
168
+ nil
169
+ end
170
+ next unless resolved
171
+
172
+ res_uri = URI.parse(resolved) rescue nil
173
+ next unless res_uri && res_uri.host == uri.host && res_uri.scheme =~ /^https?$/
174
+
175
+ clean_url = "#{res_uri.scheme}://#{res_uri.host}#{res_uri.path}"
176
+ clean_url = clean_url.chomp('/') unless res_uri.path == '/'
177
+ found << clean_url unless found.include?(clean_url)
178
+ end
179
+
180
+ found.uniq
181
+ end
182
+
183
+ def audit_page(url)
184
+ html = fetch_html(url)
185
+ title_match = html.match(/<title[^>]*>(.*?)<\/title>/im)
186
+ raw_title = title_match ? decode_html_entities(title_match[1].to_s.strip.gsub(/\s+/, ' ')) : ''
187
+
188
+ h1_match = html.match(/<h1[^>]*>(.*?)<\/h1>/im)
189
+ h1_text = h1_match ? decode_html_entities(h1_match[1].to_s.gsub(/<[^>]+>/, '').strip.gsub(/\s+/, ' ')) : ''
190
+
191
+ meta_match = html.match(/<meta\s+[^>]*name=["']description["'][^>]*content=["']([^"']*)["']/im) ||
192
+ html.match(/<meta\s+[^>]*content=["']([^"']*)["'][^>]*name=["']description["']/im)
193
+ meta_desc = meta_match ? decode_html_entities(meta_match[1].to_s.strip.gsub(/\s+/, ' ')) : ''
194
+
195
+ chars = raw_title.size
196
+ px_width = self.class.estimate_pixel_width(raw_title)
197
+
198
+ status, hazard = evaluate_title_status(raw_title, chars, px_width)
199
+ separator = detect_separator(raw_title)
200
+ has_brand = contains_brand?(raw_title)
201
+
202
+ rewrites = (status != :optimal) ? synthesize_rewrites(url, raw_title, h1_text, px_width) : []
203
+
204
+ {
205
+ url: url,
206
+ title: raw_title,
207
+ h1: h1_text,
208
+ meta_desc: meta_desc,
209
+ char_count: chars,
210
+ pixel_width: px_width,
211
+ status: status, # :optimal, :desktop_overflow, :critical_overflow, :too_short, :missing
212
+ truncation_hazard: hazard,
213
+ desktop_preview: self.class.truncate_to_pixel_width(raw_title, DESKTOP_MAX_PX),
214
+ separator_detected: separator,
215
+ has_brand_name: has_brand,
216
+ suggested_rewrites: rewrites
217
+ }
218
+ end
219
+
220
+ def evaluate_title_status(title, chars, px)
221
+ return [:missing, 'CRITICAL: Missing Title Tag'] if title.empty?
222
+ return [:critical_overflow, 'SEVERE: Truncates on both Desktop and Mobile (>630px)'] if px > 630.0 || chars > 70
223
+ return [:desktop_overflow, 'MODERATE: Truncates on Desktop SERP (>580px)'] if px > DESKTOP_MAX_PX
224
+ return [:too_short, 'SUBOPTIMAL: Too short (<35 chars, wasting SERP real estate)'] if chars < MIN_CHARS || px < 350.0
225
+
226
+ [:optimal, 'NONE: Fits cleanly in desktop and mobile SERPs']
227
+ end
228
+
229
+ def detect_separator(title)
230
+ SEPARATORS.find { |sep| title.include?(sep) }&.strip
231
+ end
232
+
233
+ def contains_brand?(title)
234
+ return false if @brand_token.empty?
235
+ title.downcase.include?(@brand_token.downcase)
236
+ end
237
+
238
+ def synthesize_rewrites(url, original_title, h1_text, current_px)
239
+ uri = URI.parse(url) rescue nil
240
+ slug_words = uri ? uri.path.split('/').last.to_s.tr('-_', ' ').split : []
241
+ core_topic = if !h1_text.empty? && h1_text.size < 45
242
+ h1_text
243
+ elsif slug_words.any?
244
+ slug_words.map(&:capitalize).join(' ')
245
+ else
246
+ original_title.split(/[\-\|\—\•\:]/).first.to_s.strip
247
+ end
248
+
249
+ core_topic = core_topic.sub(/^(The|A|An)\s+/i, '').strip
250
+ brand = @brand_token.capitalize
251
+
252
+ rewrites = []
253
+
254
+ # Variation 1: Primary Search Keyword + Brand (Clean standard format)
255
+ v1_raw = "#{core_topic} | #{brand}"
256
+ if self.class.estimate_pixel_width(v1_raw) > DESKTOP_MAX_PX
257
+ v1_raw = "#{core_topic.split.first(4).join(' ')} | #{brand}"
258
+ end
259
+ v1_px = self.class.estimate_pixel_width(v1_raw)
260
+ rewrites << {
261
+ type: 'Primary Hook + Clean Brand',
262
+ title: v1_raw,
263
+ char_count: v1_raw.size,
264
+ pixel_width: v1_px,
265
+ fits_serp: v1_px <= DESKTOP_MAX_PX
266
+ }
267
+
268
+ # Variation 2: Action / Value-Driven Hook
269
+ verbs = ['The Official', 'Boost', 'Scale', 'Automate', 'Ultimate']
270
+ chosen_verb = verbs[(core_topic.length + brand.length) % verbs.size]
271
+ v2_raw = "#{chosen_verb} #{core_topic} - #{brand}"
272
+ if self.class.estimate_pixel_width(v2_raw) > DESKTOP_MAX_PX
273
+ v2_raw = "#{chosen_verb} #{core_topic.split.first(3).join(' ')} - #{brand}"
274
+ end
275
+ v2_px = self.class.estimate_pixel_width(v2_raw)
276
+ rewrites << {
277
+ type: 'Action / Benefit-Driven Hook',
278
+ title: v2_raw,
279
+ char_count: v2_raw.size,
280
+ pixel_width: v2_px,
281
+ fits_serp: v2_px <= DESKTOP_MAX_PX
282
+ }
283
+
284
+ # Variation 3: Compact Exact-Intent Match (Fluff stripped)
285
+ v3_clean = core_topic.gsub(/\b(official|best|new|202[0-9]|review|app)\b/i, '').strip.gsub(/\s+/, ' ')
286
+ v3_raw = brand.to_s.strip.empty? ? "#{v3_clean} – Official Overview" : "#{v3_clean} | #{brand}"
287
+ if self.class.estimate_pixel_width(v3_raw) > DESKTOP_MAX_PX
288
+ v3_raw = "#{v3_clean.split.first(4).join(' ')} | #{brand}"
289
+ end
290
+ v3_px = self.class.estimate_pixel_width(v3_raw)
291
+ rewrites << {
292
+ type: 'Compact Exact-Intent Match',
293
+ title: v3_raw,
294
+ char_count: v3_raw.size,
295
+ pixel_width: v3_px,
296
+ fits_serp: v3_px <= DESKTOP_MAX_PX
297
+ }
298
+
299
+ rewrites
300
+ end
301
+
302
+ def calculate_site_summary
303
+ total = @results.size
304
+ return empty_summary if total.zero?
305
+
306
+ optimal_count = @results.count { |r| r[:status] == :optimal }
307
+ desk_overflow = @results.count { |r| r[:status] == :desktop_overflow }
308
+ crit_overflow = @results.count { |r| r[:status] == :critical_overflow }
309
+ short_count = @results.count { |r| r[:status] == :too_short }
310
+ missing_count = @results.count { |r| r[:status] == :missing }
311
+
312
+ optimal_pct = ((optimal_count.to_f / total) * 100).round(1)
313
+
314
+ # Score calculation (0 - 100)
315
+ raw_score = 100.0
316
+ raw_score -= (desk_overflow.to_f / total) * 30.0
317
+ raw_score -= (crit_overflow.to_f / total) * 60.0
318
+ raw_score -= (short_count.to_f / total) * 15.0
319
+ raw_score -= (missing_count.to_f / total) * 100.0
320
+ score = [[0, raw_score.round].max, 100].min
321
+
322
+ grade = case score
323
+ when 90..100 then 'A'
324
+ when 75..89 then 'B'
325
+ when 60..74 then 'C'
326
+ when 40..59 then 'D'
327
+ else 'F'
328
+ end
329
+
330
+ verdict = case grade
331
+ when 'A' then 'EXCELLENT: Over 90% of titles fit Google SERP pixel constraints'
332
+ when 'B' then 'GOOD: Minor title overflow on desktop SERPs'
333
+ when 'C' then 'MODERATE: Noticeable title truncation across multiple landing pages'
334
+ when 'D' then 'POOR: Frequent SERP ellipsis (...) truncation harming click-through rates'
335
+ else 'CRITICAL: Widespread title overflow or missing title tags'
336
+ end
337
+
338
+ {
339
+ target: @target,
340
+ timestamp: Time.now.utc.iso8601,
341
+ total_pages: total,
342
+ health_score: score,
343
+ grade: grade,
344
+ verdict: verdict,
345
+ counts: {
346
+ optimal: optimal_count,
347
+ desktop_overflow: desk_overflow,
348
+ critical_overflow: crit_overflow,
349
+ too_short: short_count,
350
+ missing: missing_count
351
+ },
352
+ percentages: {
353
+ optimal_pct: optimal_pct,
354
+ overflow_pct: (((desk_overflow + crit_overflow).to_f / total) * 100).round(1)
355
+ },
356
+ pages: @results
357
+ }
358
+ end
359
+
360
+ def empty_summary
361
+ {
362
+ target: @target,
363
+ timestamp: Time.now.utc.iso8601,
364
+ total_pages: 0,
365
+ health_score: 0,
366
+ grade: 'F',
367
+ verdict: 'No pages found or audited',
368
+ counts: { optimal: 0, desktop_overflow: 0, critical_overflow: 0, too_short: 0, missing: 0 },
369
+ percentages: { optimal_pct: 0.0, overflow_pct: 0.0 },
370
+ pages: []
371
+ }
372
+ end
373
+
374
+ def extract_brand_token(url_or_domain)
375
+ clean = url_or_domain.sub(%r{^https?://}, '').split('/').first.to_s.downcase
376
+ clean.sub(/\.(com|co|io|net|org|app|dev|ai|store)$/, '').split('.').last.to_s
377
+ end
378
+
379
+ def fetch_html(url)
380
+ uri = URI.parse(url)
381
+ http = Net::HTTP.new(uri.host, uri.port)
382
+ http.use_ssl = (uri.scheme == 'https')
383
+ http.open_timeout = 5
384
+ http.read_timeout = 8
385
+
386
+ path = uri.request_uri.empty? ? '/' : uri.request_uri
387
+ req = Net::HTTP::Get.new(path)
388
+ req['User-Agent'] = "Mozilla/5.0 (compatible; GSC-TitleOptimizer/#{GSC::VERSION}; +https://apolloswave.com)"
389
+ req['Accept-Encoding'] = 'gzip'
390
+
391
+ res = http.request(req)
392
+ return '' unless res.code.to_i >= 200 && res.code.to_i < 400
393
+
394
+ raw = res.body || ''
395
+ body_str = if res['content-encoding'] =~ /gzip/i && !raw.empty?
396
+ begin
397
+ Zlib::GzipReader.new(StringIO.new(raw)).read
398
+ rescue StandardError
399
+ raw
400
+ end
401
+ else
402
+ raw
403
+ end
404
+ body_str.to_s.dup.force_encoding('UTF-8').scrub
405
+ rescue StandardError
406
+ ''
407
+ end
408
+
409
+ def decode_html_entities(str)
410
+ str.to_s
411
+ .gsub('&amp;', '&')
412
+ .gsub('&quot;', '"')
413
+ .gsub('&#39;', "'")
414
+ .gsub('&apos;', "'")
415
+ .gsub('&lt;', '<')
416
+ .gsub('&gt;', '>')
417
+ .gsub('&nbsp;', ' ')
418
+ end
419
+ end
420
+ end
data/lib/gsc/vault.rb ADDED
@@ -0,0 +1,260 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'fileutils'
4
+ require 'json'
5
+ require 'openssl'
6
+ require 'base64'
7
+ require 'time'
8
+
9
+ module GSC
10
+ class Vault
11
+ VAULT_DIR = File.join(Config::CONFIG_DIR, 'vault')
12
+ KEYS_DIR = File.join(VAULT_DIR, 'keys')
13
+ INDEX_FILE = File.join(VAULT_DIR, 'vault.json')
14
+ MASTER_FILE = File.join(VAULT_DIR, '.master.key')
15
+ AUTH_DATA = 'gsc-vault-v1'
16
+
17
+ def self.ensure_directories!
18
+ FileUtils.mkdir_p(KEYS_DIR)
19
+ File.chmod(0700, VAULT_DIR) rescue nil
20
+ File.chmod(0700, KEYS_DIR) rescue nil
21
+ end
22
+
23
+ def self.master_key
24
+ ensure_directories!
25
+ if File.exist?(MASTER_FILE)
26
+ File.chmod(0600, MASTER_FILE) rescue nil
27
+ raw = File.binread(MASTER_FILE)
28
+ return raw if raw.bytesize == 32
29
+ end
30
+
31
+ # Generate new 256-bit AES master key
32
+ new_key = OpenSSL::Random.random_bytes(32)
33
+ File.binwrite(MASTER_FILE, new_key)
34
+ File.chmod(0600, MASTER_FILE) rescue nil
35
+ new_key
36
+ end
37
+
38
+ def self.encrypt(plaintext)
39
+ cipher = OpenSSL::Cipher.new('aes-256-gcm').encrypt
40
+ cipher.key = master_key
41
+ iv = cipher.random_iv
42
+ cipher.auth_data = AUTH_DATA
43
+ encrypted = cipher.update(plaintext) + cipher.final
44
+ tag = cipher.auth_tag
45
+
46
+ JSON.generate({
47
+ 'alg' => 'AES-256-GCM',
48
+ 'iv' => Base64.strict_encode64(iv),
49
+ 'tag' => Base64.strict_encode64(tag),
50
+ 'data' => Base64.strict_encode64(encrypted)
51
+ })
52
+ end
53
+
54
+ def self.decrypt(envelope_json)
55
+ payload = JSON.parse(envelope_json)
56
+ decipher = OpenSSL::Cipher.new('aes-256-gcm').decrypt
57
+ decipher.key = master_key
58
+ decipher.iv = Base64.strict_decode64(payload['iv'])
59
+ decipher.auth_tag = Base64.strict_decode64(payload['tag'])
60
+ decipher.auth_data = AUTH_DATA
61
+ decipher.update(Base64.strict_decode64(payload['data'])) + decipher.final
62
+ rescue StandardError => e
63
+ raise "Vault Decryption Failed: #{e.message}"
64
+ end
65
+
66
+ def self.load_index
67
+ ensure_directories!
68
+ return { 'domains' => {}, 'aliases' => {} } unless File.exist?(INDEX_FILE)
69
+ JSON.parse(File.read(INDEX_FILE))
70
+ rescue StandardError
71
+ { 'domains' => {}, 'aliases' => {} }
72
+ end
73
+
74
+ def self.save_index(data)
75
+ ensure_directories!
76
+ File.write(INDEX_FILE, JSON.pretty_generate(data))
77
+ File.chmod(0600, INDEX_FILE) rescue nil
78
+ data
79
+ end
80
+
81
+ def self.normalize_domain(dom)
82
+ dom.to_s.strip.downcase.sub(%r{^https?://}, '').sub(/^sc-domain:/, '').chomp('/')
83
+ end
84
+
85
+ def self.add_key(raw_path, domain: nil, alias_name: nil, ga4_id: nil)
86
+ path = File.expand_path(raw_path)
87
+ raise "File not found: #{path}" unless File.file?(path)
88
+
89
+ raw_content = File.read(path)
90
+ json = JSON.parse(raw_content)
91
+ client_email = json['client_email']
92
+ private_key = json['private_key']
93
+ project_id = json['project_id']
94
+
95
+ raise 'Invalid Google Service Account JSON: missing client_email or private_key' unless client_email && private_key
96
+
97
+ target_domain = domain ? normalize_domain(domain) : nil
98
+ clean_alias = alias_name ? alias_name.to_s.strip.downcase.gsub(/[^a-z0-9_-]/, '') : nil
99
+
100
+ # If no domain given, default to project_id or sanitize client_email
101
+ safe_stem = target_domain || clean_alias || (project_id ? "#{project_id}.vault" : "key_#{Time.now.to_i}")
102
+ sanitized_stem = safe_stem.gsub(/[^a-zA-Z0-9.-]/, '_').gsub(/\.{2,}/, '_')
103
+ safe_filename = File.basename("#{sanitized_stem}.enc")
104
+ key_store_path = File.join(KEYS_DIR, safe_filename)
105
+
106
+ encrypted_blob = encrypt(raw_content)
107
+ File.write(key_store_path, encrypted_blob)
108
+ File.chmod(0600, key_store_path) rescue nil
109
+
110
+ idx = load_index
111
+ entry = {
112
+ 'domain' => target_domain,
113
+ 'alias' => clean_alias,
114
+ 'key_file' => safe_filename,
115
+ 'client_email' => client_email,
116
+ 'project_id' => project_id,
117
+ 'ga4_id' => ga4_id,
118
+ 'created_at' => Time.now.utc.iso8601,
119
+ 'last_used_at' => nil
120
+ }
121
+
122
+ idx['domains'][target_domain] = entry if target_domain
123
+ idx['aliases'][clean_alias] = entry if clean_alias
124
+ idx['keys'] ||= {}
125
+ idx['keys'][safe_filename] = entry
126
+
127
+ save_index(idx)
128
+ entry
129
+ end
130
+
131
+ def self.find_entry(query)
132
+ return nil if query.nil? || query.to_s.strip.empty?
133
+ clean = normalize_domain(query)
134
+ idx = load_index
135
+
136
+ # 1. Exact domain match
137
+ return idx['domains'][clean] if idx['domains'] && idx['domains'][clean]
138
+
139
+ # 2. Exact alias match
140
+ return idx['aliases'][clean] if idx['aliases'] && idx['aliases'][clean]
141
+
142
+ # 3. Partial domain match
143
+ match = (idx['domains'] || {}).find { |d, _| d.include?(clean) }
144
+ return match[1] if match
145
+
146
+ # 4. Partial alias or key match
147
+ alias_match = (idx['aliases'] || {}).find { |a, _| a.include?(clean) }
148
+ return alias_match[1] if alias_match
149
+
150
+ # 5. Check by number (1-indexed based on sorted domains)
151
+ if clean =~ /^\d+$/
152
+ num = clean.to_i
153
+ domains_list = (idx['domains'] || {}).keys.sort
154
+ if num >= 1 && num <= domains_list.size
155
+ return idx['domains'][domains_list[num - 1]]
156
+ end
157
+ end
158
+
159
+ nil
160
+ end
161
+
162
+ def self.get_decrypted_key(entry)
163
+ return nil unless entry && entry['key_file']
164
+ clean_filename = File.basename(entry['key_file'].to_s)
165
+ key_file = File.join(KEYS_DIR, clean_filename)
166
+ return nil unless File.file?(key_file)
167
+ return nil unless File.expand_path(key_file).start_with?(File.expand_path(KEYS_DIR))
168
+
169
+ raw = File.read(key_file)
170
+ decrypted = decrypt(raw)
171
+
172
+ # Update last used
173
+ idx = load_index
174
+ if idx['keys'] && idx['keys'][entry['key_file']]
175
+ idx['keys'][entry['key_file']]['last_used_at'] = Time.now.utc.iso8601
176
+ save_index(idx)
177
+ end
178
+
179
+ JSON.parse(decrypted)
180
+ rescue StandardError => e
181
+ warn "Vault Decryption Warning: #{e.message}"
182
+ nil
183
+ end
184
+
185
+ def self.key_for_domain(domain)
186
+ entry = find_entry(domain)
187
+ return nil unless entry
188
+ get_decrypted_key(entry)
189
+ end
190
+
191
+ def self.remove_entry(query)
192
+ entry = find_entry(query)
193
+ return false unless entry
194
+
195
+ idx = load_index
196
+ idx['domains'].delete(entry['domain']) if entry['domain']
197
+ idx['aliases'].delete(entry['alias']) if entry['alias']
198
+ idx['keys'].delete(entry['key_file']) if entry['key_file']
199
+
200
+ if entry['key_file']
201
+ file_path = File.join(KEYS_DIR, entry['key_file'])
202
+ FileUtils.rm_f(file_path)
203
+ end
204
+
205
+ save_index(idx)
206
+ true
207
+ end
208
+
209
+ def self.switch_to(target_query)
210
+ entry = find_entry(target_query)
211
+ target_domain = entry ? (entry['domain'] || target_query) : normalize_domain(target_query)
212
+
213
+ # Set default domain in config
214
+ Config.set_default_domain(target_domain)
215
+
216
+ # If GA4 ID is mapped in entry, set it
217
+ if entry && entry['ga4_id']
218
+ Config.set_ga4_property_id(entry['ga4_id'], target_domain)
219
+ end
220
+
221
+ {
222
+ domain: target_domain,
223
+ entry: entry,
224
+ has_vault_key: !entry.nil?
225
+ }
226
+ end
227
+
228
+ def self.list_entries
229
+ idx = load_index
230
+ active = Config.default_domain
231
+
232
+ entries = []
233
+ (idx['keys'] || {}).each do |_k, entry|
234
+ is_active = (entry['domain'] == active)
235
+ entries << entry.merge('active' => is_active)
236
+ end
237
+
238
+ entries.sort_by { |e| [e['active'] ? 0 : 1, e['domain'].to_s] }
239
+ end
240
+
241
+ def self.status
242
+ ensure_directories!
243
+ idx = load_index
244
+ keys_count = (idx['keys'] || {}).size
245
+ domains_count = (idx['domains'] || {}).size
246
+ has_master = File.exist?(MASTER_FILE)
247
+ perm_ok = (File.stat(VAULT_DIR).mode & 0777 == 0700) rescue false
248
+
249
+ {
250
+ vault_dir: VAULT_DIR,
251
+ master_key_present: has_master,
252
+ encryption_algorithm: 'AES-256-GCM',
253
+ keys_stored: keys_count,
254
+ domains_mapped: domains_count,
255
+ permissions_secure: perm_ok,
256
+ active_domain: Config.default_domain
257
+ }
258
+ end
259
+ end
260
+ end
data/lib/gsc/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module GSC
4
- VERSION = '2.0.2'
4
+ VERSION = '2.2.0'
5
5
  end