gsc-cli 2.1.0 → 2.2.2
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/AUTH.md +4 -1
- data/README.md +448 -408
- 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
|
@@ -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('&', '&')
|
|
412
|
+
.gsub('"', '"')
|
|
413
|
+
.gsub(''', "'")
|
|
414
|
+
.gsub(''', "'")
|
|
415
|
+
.gsub('<', '<')
|
|
416
|
+
.gsub('>', '>')
|
|
417
|
+
.gsub(' ', ' ')
|
|
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