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,385 @@
|
|
|
1
|
+
# encoding: utf-8
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require 'uri'
|
|
5
|
+
require 'net/http'
|
|
6
|
+
require 'json'
|
|
7
|
+
require 'time'
|
|
8
|
+
require 'thread'
|
|
9
|
+
|
|
10
|
+
module GSC
|
|
11
|
+
class Soft404Analyzer
|
|
12
|
+
ERROR_TITLE_PATTERNS = [
|
|
13
|
+
/404/i,
|
|
14
|
+
/not\s+found/i,
|
|
15
|
+
/page\s+not\s+found/i,
|
|
16
|
+
/error/i,
|
|
17
|
+
/oops/i,
|
|
18
|
+
/unavailable/i,
|
|
19
|
+
/doesn't\s+exist/i,
|
|
20
|
+
/does\s+not\s+exist/i,
|
|
21
|
+
/empty/i,
|
|
22
|
+
/out\s+of\s+stock/i
|
|
23
|
+
].freeze
|
|
24
|
+
|
|
25
|
+
ERROR_BODY_PATTERNS = [
|
|
26
|
+
/page\s+you\s+are\s+looking\s+for\s+(cannot|can't|could\s+not)\s+be\s+found/i,
|
|
27
|
+
/the\s+page\s+you\s+requested\s+does\s+not\s+exist/i,
|
|
28
|
+
/we\s+couldn't\s+find\s+that\s+page/i,
|
|
29
|
+
/no\s+products?\s+found/i,
|
|
30
|
+
/there\s+are\s+no\s+products/i,
|
|
31
|
+
/this\s+product\s+is\s+unavailable/i,
|
|
32
|
+
/sorry,\s+we\s+couldn't\s+find/i,
|
|
33
|
+
/404\s+-\s+file\s+or\s+directory\s+not\s+found/i,
|
|
34
|
+
/item\s+not\s+available/i
|
|
35
|
+
].freeze
|
|
36
|
+
|
|
37
|
+
attr_reader :target, :options
|
|
38
|
+
|
|
39
|
+
def initialize(target = nil, options = {})
|
|
40
|
+
if target.is_a?(Array)
|
|
41
|
+
@urls = target.map(&:to_s).reject(&:empty?)
|
|
42
|
+
@target = ''
|
|
43
|
+
else
|
|
44
|
+
@urls = []
|
|
45
|
+
@target = target.to_s.dup.force_encoding('UTF-8').scrub.strip
|
|
46
|
+
end
|
|
47
|
+
@options = options || {}
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def self.analyze(target = nil, options = {})
|
|
51
|
+
new(target, options).analyze
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def analyze
|
|
55
|
+
urls = resolve_urls_to_audit
|
|
56
|
+
return empty_target_result if urls.empty?
|
|
57
|
+
|
|
58
|
+
concurrency = (@options[:concurrency] || 5).to_i
|
|
59
|
+
concurrency = [[concurrency, 1].max, 15].min
|
|
60
|
+
|
|
61
|
+
results = audit_urls_concurrently(urls, concurrency)
|
|
62
|
+
|
|
63
|
+
diagnose_overall(results)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
private
|
|
67
|
+
|
|
68
|
+
def resolve_urls_to_audit
|
|
69
|
+
return @urls if @urls.any?
|
|
70
|
+
return @options[:urls] if @options[:urls].is_a?(Array) && @options[:urls].any?
|
|
71
|
+
|
|
72
|
+
if @target.empty?
|
|
73
|
+
[]
|
|
74
|
+
elsif File.exist?(@target)
|
|
75
|
+
File.readlines(@target, encoding: 'UTF-8').map(&:strip).reject { |l| l.empty? || l.start_with?('#') }
|
|
76
|
+
elsif @target =~ %r{^https?://}i
|
|
77
|
+
[@target]
|
|
78
|
+
elsif @target =~ /\.(com|org|net|io|app|co|dev|store|edu|gov|xyz|info|biz|me)$/i
|
|
79
|
+
clean_dom = @target.sub(%r{^https?://}i, '').chomp('/')
|
|
80
|
+
["https://#{clean_dom}/", "https://#{clean_dom}/gsc-verify-nonexistent-404-probe"]
|
|
81
|
+
else
|
|
82
|
+
[]
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def empty_target_result
|
|
87
|
+
{
|
|
88
|
+
total_urls_audited: 0,
|
|
89
|
+
health_score: 100.0,
|
|
90
|
+
grade: 'A',
|
|
91
|
+
summary: {
|
|
92
|
+
healthy_count: 0,
|
|
93
|
+
soft_404_count: 0,
|
|
94
|
+
hard_404_count: 0,
|
|
95
|
+
thin_content_count: 0,
|
|
96
|
+
crawl_budget_waste_percent: 0.0
|
|
97
|
+
},
|
|
98
|
+
all_results: [],
|
|
99
|
+
prescriptions: ['Provide a target URL, file of URLs, or domain property to audit for soft-404 errors.']
|
|
100
|
+
}
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def audit_urls_concurrently(urls, concurrency)
|
|
104
|
+
results = []
|
|
105
|
+
mutex = Mutex.new
|
|
106
|
+
queue = Queue.new
|
|
107
|
+
urls.each { |u| queue << u }
|
|
108
|
+
|
|
109
|
+
threads = Array.new(concurrency) do
|
|
110
|
+
Thread.new do
|
|
111
|
+
loop do
|
|
112
|
+
url = begin
|
|
113
|
+
queue.pop(true)
|
|
114
|
+
rescue ThreadError
|
|
115
|
+
nil
|
|
116
|
+
end
|
|
117
|
+
break unless url
|
|
118
|
+
|
|
119
|
+
res = audit_single_url(url)
|
|
120
|
+
mutex.synchronize { results << res }
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
threads.each(&:join)
|
|
126
|
+
results.sort_by { |r| r[:url] }
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def audit_single_url(url)
|
|
130
|
+
uri = URI.parse(url) rescue nil
|
|
131
|
+
return { url: url, error: 'Invalid URL format', status: :invalid_url } unless uri
|
|
132
|
+
|
|
133
|
+
# If offline / mock check for tests or local files
|
|
134
|
+
if @options[:mock_results] && @options[:mock_results][url]
|
|
135
|
+
return @options[:mock_results][url]
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
begin
|
|
139
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
140
|
+
http.use_ssl = (uri.scheme == 'https')
|
|
141
|
+
http.open_timeout = 5
|
|
142
|
+
http.read_timeout = 5
|
|
143
|
+
|
|
144
|
+
req = Net::HTTP::Get.new(uri.request_uri, {
|
|
145
|
+
'User-Agent' => 'Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)'
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
t0 = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
149
|
+
response = http.request(req)
|
|
150
|
+
t1 = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
151
|
+
|
|
152
|
+
status_code = response.code.to_i
|
|
153
|
+
body = response.body.to_s.dup.force_encoding('UTF-8').scrub
|
|
154
|
+
headers = response.each_header.to_h
|
|
155
|
+
|
|
156
|
+
evaluate_response(url, status_code, body, headers, ((t1 - t0) * 1000).round(1))
|
|
157
|
+
rescue StandardError => e
|
|
158
|
+
# Real-time network error
|
|
159
|
+
network_error_result(url, e.message)
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def evaluate_response(url, status_code, body, headers, latency_ms)
|
|
164
|
+
body_size = body.bytesize
|
|
165
|
+
text = strip_tags(body)
|
|
166
|
+
word_count = text.split.size
|
|
167
|
+
|
|
168
|
+
title = extract_title(body)
|
|
169
|
+
h1 = extract_h1(body)
|
|
170
|
+
meta_robots = headers['x-robots-tag'].to_s
|
|
171
|
+
|
|
172
|
+
# Soft-404 heuristics
|
|
173
|
+
is_hard_404 = (status_code == 404 || status_code == 410)
|
|
174
|
+
title_has_error = ERROR_TITLE_PATTERNS.any? { |pat| title =~ pat }
|
|
175
|
+
body_has_error_pattern = ERROR_BODY_PATTERNS.any? { |pat| body =~ pat }
|
|
176
|
+
is_thin_content = word_count < (@options[:threshold] || 100).to_i
|
|
177
|
+
|
|
178
|
+
# Google treats 200 OK with error title or error body as soft-404
|
|
179
|
+
is_soft_404 = (status_code == 200) && (title_has_error || body_has_error_pattern || (is_thin_content && word_count < 35))
|
|
180
|
+
|
|
181
|
+
# 302 redirecting to homepage is often soft-404
|
|
182
|
+
is_homepage_redirect = (status_code == 301 || status_code == 302) && (headers['location'] == '/' || headers['location'] == url.sub(%r{/[^/]+/?$}, '/'))
|
|
183
|
+
|
|
184
|
+
classification = if is_hard_404
|
|
185
|
+
:hard_404
|
|
186
|
+
elsif is_soft_404
|
|
187
|
+
:soft_404
|
|
188
|
+
elsif is_homepage_redirect
|
|
189
|
+
:soft_404_redirect
|
|
190
|
+
elsif is_thin_content
|
|
191
|
+
:thin_content_warning
|
|
192
|
+
else
|
|
193
|
+
:healthy_200
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
suggested_action = case classification
|
|
197
|
+
when :soft_404
|
|
198
|
+
"Return HTTP 404/410 or 301 redirect to relevant parent hub"
|
|
199
|
+
when :hard_404
|
|
200
|
+
"301 redirect to closely matched category or purge from sitemaps"
|
|
201
|
+
when :soft_404_redirect
|
|
202
|
+
"Redirect to exact topic match instead of generic homepage"
|
|
203
|
+
when :thin_content_warning
|
|
204
|
+
"Enrich content to >250 words or consolidate into topic hub"
|
|
205
|
+
else
|
|
206
|
+
"Optimal — verified healthy"
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
suggested_redirect = suggest_redirect_target(url)
|
|
210
|
+
|
|
211
|
+
stack = detect_tech_stack(headers, body)
|
|
212
|
+
|
|
213
|
+
{
|
|
214
|
+
url: url,
|
|
215
|
+
status_code: status_code,
|
|
216
|
+
latency_ms: latency_ms,
|
|
217
|
+
body_size_bytes: body_size,
|
|
218
|
+
word_count: word_count,
|
|
219
|
+
title: title,
|
|
220
|
+
h1: h1,
|
|
221
|
+
meta_robots: meta_robots,
|
|
222
|
+
classification: classification,
|
|
223
|
+
is_soft_404: (classification == :soft_404 || classification == :soft_404_redirect),
|
|
224
|
+
suggested_action: suggested_action,
|
|
225
|
+
suggested_redirect: suggested_redirect,
|
|
226
|
+
detected_stack: stack,
|
|
227
|
+
remediation_rules: generate_server_rules(url, suggested_redirect)
|
|
228
|
+
}
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
def network_error_result(url, err_msg)
|
|
232
|
+
suggested_redirect = suggest_redirect_target(url)
|
|
233
|
+
|
|
234
|
+
{
|
|
235
|
+
url: url,
|
|
236
|
+
status_code: 0,
|
|
237
|
+
latency_ms: 0.0,
|
|
238
|
+
body_size_bytes: 0,
|
|
239
|
+
word_count: 0,
|
|
240
|
+
title: "Connection Failed",
|
|
241
|
+
h1: "Network Error",
|
|
242
|
+
meta_robots: "",
|
|
243
|
+
classification: :unreachable,
|
|
244
|
+
is_soft_404: false,
|
|
245
|
+
suggested_action: "Verify DNS and server network accessibility",
|
|
246
|
+
suggested_redirect: suggested_redirect,
|
|
247
|
+
detected_stack: { framework: 'Universal Web', server: 'Unreachable', default_format: 'nginx' },
|
|
248
|
+
remediation_rules: generate_server_rules(url, suggested_redirect),
|
|
249
|
+
note: "Unreachable: #{err_msg}"
|
|
250
|
+
}
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
def diagnose_overall(results)
|
|
254
|
+
total = results.size
|
|
255
|
+
soft_404s = results.select { |r| r[:is_soft_404] }
|
|
256
|
+
hard_404s = results.select { |r| r[:classification] == :hard_404 }
|
|
257
|
+
thin_warnings = results.select { |r| r[:classification] == :thin_content_warning }
|
|
258
|
+
healthy = results.select { |r| r[:classification] == :healthy_200 }
|
|
259
|
+
detected_stack = results.map { |r| r[:detected_stack] }.compact.first
|
|
260
|
+
|
|
261
|
+
# Health score calculation (100 minus penalties for soft-404 and broken pages)
|
|
262
|
+
penalties = (soft_404s.size * 25.0) + (hard_404s.size * 15.0) + (thin_warnings.size * 5.0)
|
|
263
|
+
score = [100.0, [0.0, 100.0 - (penalties / [total, 1].max)].max].min.round(1)
|
|
264
|
+
|
|
265
|
+
grade = case score
|
|
266
|
+
when 90.0..100.0 then 'A'
|
|
267
|
+
when 80.0...90.0 then 'B'
|
|
268
|
+
when 70.0...80.0 then 'C'
|
|
269
|
+
when 60.0...70.0 then 'D'
|
|
270
|
+
else 'F'
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
{
|
|
274
|
+
target: @target,
|
|
275
|
+
timestamp: Time.now.utc.iso8601,
|
|
276
|
+
total_urls_audited: total,
|
|
277
|
+
health_score: score,
|
|
278
|
+
grade: grade,
|
|
279
|
+
detected_stack: detected_stack,
|
|
280
|
+
summary: {
|
|
281
|
+
healthy_count: healthy.size,
|
|
282
|
+
soft_404_count: soft_404s.size,
|
|
283
|
+
hard_404_count: hard_404s.size,
|
|
284
|
+
thin_content_count: thin_warnings.size,
|
|
285
|
+
crawl_budget_waste_percent: total > 0 ? ((soft_404s.size + hard_404s.size) * 100.0 / total).round(1) : 0.0
|
|
286
|
+
},
|
|
287
|
+
soft_404_urls: soft_404s,
|
|
288
|
+
all_results: results
|
|
289
|
+
}
|
|
290
|
+
end
|
|
291
|
+
|
|
292
|
+
def suggest_redirect_target(url)
|
|
293
|
+
uri = URI.parse(url) rescue nil
|
|
294
|
+
return '/' unless uri && uri.path
|
|
295
|
+
|
|
296
|
+
parts = uri.path.split('/').reject(&:empty?)
|
|
297
|
+
if parts.size > 1
|
|
298
|
+
"/#{parts[0...-1].join('/')}"
|
|
299
|
+
else
|
|
300
|
+
'/'
|
|
301
|
+
end
|
|
302
|
+
end
|
|
303
|
+
|
|
304
|
+
def detect_tech_stack(headers = {}, body = '')
|
|
305
|
+
server = (headers['server'] || '').downcase
|
|
306
|
+
powered_by = (headers['x-powered-by'] || '').downcase
|
|
307
|
+
body_str = body.to_s
|
|
308
|
+
|
|
309
|
+
if headers['x-sveltekit-page'] || body_str.include?('__sveltekit') || body_str.include?('_app/immutable')
|
|
310
|
+
{ framework: 'SvelteKit', server: server.empty? ? 'Node/Edge' : server.capitalize, default_format: 'sveltekit' }
|
|
311
|
+
elsif body_str =~ /class=["'][^"']*astro-[^"']*["']/ || body_str.include?('data-astro-cid') || body_str =~ /<meta[^>]+content=["']Astro/i
|
|
312
|
+
{ framework: 'Astro', server: server.empty? ? 'Static/SSR' : server.capitalize, default_format: 'astro' }
|
|
313
|
+
elsif body_str.include?('___gatsby') || body_str.include?('gatsby-script') || body_str.include?('id="___gatsby"')
|
|
314
|
+
{ framework: 'Gatsby', server: server.empty? ? 'Static/SSR' : server.capitalize, default_format: 'gatsby' }
|
|
315
|
+
elsif powered_by.include?('next.js') || body_str.include?('__NEXT_DATA__') || body_str.include?('/_next/')
|
|
316
|
+
{ framework: 'Next.js', server: server.empty? ? 'Vercel/Node' : server.capitalize, default_format: 'nextjs' }
|
|
317
|
+
elsif body_str.include?('assets.website-files.com') || body_str =~ /<meta[^>]+content=["']Webflow/i || body_str.include?('w-mod-')
|
|
318
|
+
{ framework: 'Webflow', server: 'Webflow Hosting', default_format: 'webflow' }
|
|
319
|
+
elsif body_str.include?('cdn.shopify.com') || body_str.include?('Shopify.shop') || headers['x-shopify-stage']
|
|
320
|
+
{ framework: 'Shopify', server: 'Shopify Cloud', default_format: 'shopify' }
|
|
321
|
+
elsif body_str.include?('wp-content') || body_str =~ /<meta[^>]+content=["']WordPress/i
|
|
322
|
+
{ framework: 'WordPress', server: server.empty? ? 'PHP/Apache' : server.capitalize, default_format: 'htaccess' }
|
|
323
|
+
elsif server.include?('github.com')
|
|
324
|
+
{ framework: 'GitHub Pages', server: 'GitHub Pages', default_format: 'github' }
|
|
325
|
+
elsif server.include?('caddy')
|
|
326
|
+
{ framework: 'Caddy', server: 'Caddy Server', default_format: 'caddy' }
|
|
327
|
+
elsif server.include?('cloudflare')
|
|
328
|
+
{ framework: 'Cloudflare Pages / Workers', server: 'Cloudflare', default_format: 'cloudflare' }
|
|
329
|
+
elsif server.include?('nginx')
|
|
330
|
+
{ framework: 'Nginx', server: 'Nginx', default_format: 'nginx' }
|
|
331
|
+
elsif server.include?('apache')
|
|
332
|
+
{ framework: 'Apache', server: 'Apache', default_format: 'htaccess' }
|
|
333
|
+
else
|
|
334
|
+
{ framework: 'Universal Web', server: server.empty? ? 'HTTP' : server.capitalize, default_format: 'nginx' }
|
|
335
|
+
end
|
|
336
|
+
end
|
|
337
|
+
|
|
338
|
+
def generate_server_rules(url, destination)
|
|
339
|
+
uri = URI.parse(url) rescue nil
|
|
340
|
+
path = uri ? uri.path : url
|
|
341
|
+
path = "/#{path}" unless path.start_with?('/')
|
|
342
|
+
|
|
343
|
+
escaped_path = Regexp.escape(path).gsub('\-', '-')
|
|
344
|
+
|
|
345
|
+
{
|
|
346
|
+
nginx: "rewrite ^#{escaped_path}/?$ #{destination} permanent;",
|
|
347
|
+
caddy: "redir #{path} #{destination} 301",
|
|
348
|
+
htaccess: "Redirect 301 #{path} #{destination}",
|
|
349
|
+
cloudflare: "#{path} #{destination} 301",
|
|
350
|
+
cloudflare_workers: "if (url.pathname === '#{path}') return Response.redirect(new URL('#{destination}', request.url), 301);",
|
|
351
|
+
github_pages: "<meta http-equiv=\"refresh\" content=\"0; url=#{destination}\">\n<link rel=\"canonical\" href=\"#{destination}\">",
|
|
352
|
+
netlify: "#{path} #{destination} 301",
|
|
353
|
+
sveltekit: "if (event.url.pathname === '#{path}') redirect(301, '#{destination}');",
|
|
354
|
+
astro: "'#{path}': '#{destination}',",
|
|
355
|
+
gatsby: "createRedirect({ fromPath: '#{path}', toPath: '#{destination}', isPermanent: true });",
|
|
356
|
+
nextjs: "{ source: '#{path}', destination: '#{destination}', permanent: true },",
|
|
357
|
+
webflow: "Old Path: #{path} -> Redirect to Page: #{destination}",
|
|
358
|
+
shopify: "#{path},#{destination}",
|
|
359
|
+
vercel: { "source" => path, "destination" => destination, "permanent" => true }
|
|
360
|
+
}
|
|
361
|
+
end
|
|
362
|
+
|
|
363
|
+
def extract_title(html)
|
|
364
|
+
if html =~ %r{<title[^>]*>(.*?)</title>}im
|
|
365
|
+
$1.strip.gsub(/\s+/, ' ')
|
|
366
|
+
else
|
|
367
|
+
''
|
|
368
|
+
end
|
|
369
|
+
end
|
|
370
|
+
|
|
371
|
+
def extract_h1(html)
|
|
372
|
+
if html =~ %r{<h1[^>]*>(.*?)</h1>}im
|
|
373
|
+
$1.strip.gsub(/<[^>]+>/, '').gsub(/\s+/, ' ')
|
|
374
|
+
else
|
|
375
|
+
''
|
|
376
|
+
end
|
|
377
|
+
end
|
|
378
|
+
|
|
379
|
+
def strip_tags(html)
|
|
380
|
+
# Remove script, style tags
|
|
381
|
+
clean = html.gsub(%r{<(script|style)[^>]*>.*?</\1>}im, ' ')
|
|
382
|
+
clean.gsub(/<[^>]+>/, ' ').gsub(/\s+/, ' ').strip
|
|
383
|
+
end
|
|
384
|
+
end
|
|
385
|
+
end
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
# encoding: utf-8
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require 'json'
|
|
5
|
+
require_relative 'color'
|
|
6
|
+
|
|
7
|
+
module GSC
|
|
8
|
+
class Sparkline
|
|
9
|
+
TICKS = ["\u2581", "\u2582", "\u2583", "\u2584", "\u2585", "\u2586", "\u2587", "\u2588"].freeze
|
|
10
|
+
|
|
11
|
+
# Renders a single-line Unicode sparkline
|
|
12
|
+
# options:
|
|
13
|
+
# colored: true (gradient from red/trough to green/peak)
|
|
14
|
+
# invert: true (lower values get higher ticks, e.g. SERP position)
|
|
15
|
+
# max_points: limit number of values (resamples if larger)
|
|
16
|
+
def self.render(values, options = {})
|
|
17
|
+
vals = normalize_values(values)
|
|
18
|
+
return '' if vals.empty?
|
|
19
|
+
|
|
20
|
+
vals = resample(vals, options[:max_points]) if options[:max_points] && vals.size > options[:max_points]
|
|
21
|
+
|
|
22
|
+
min_val = vals.min
|
|
23
|
+
max_val = vals.max
|
|
24
|
+
range = max_val - min_val
|
|
25
|
+
|
|
26
|
+
chars = vals.map do |v|
|
|
27
|
+
idx = if range.zero?
|
|
28
|
+
TICKS.size / 2
|
|
29
|
+
else
|
|
30
|
+
pos = (v - min_val) / range.to_f
|
|
31
|
+
pos = 1.0 - pos if options[:invert]
|
|
32
|
+
[((pos * (TICKS.size - 1)).round), TICKS.size - 1].min
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
tick = TICKS[idx]
|
|
36
|
+
if options[:colored]
|
|
37
|
+
color_tick(tick, idx, TICKS.size)
|
|
38
|
+
else
|
|
39
|
+
tick
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
chars.join
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Renders a multi-line ASCII terminal chart with Y-axis labels and X-axis
|
|
47
|
+
def self.chart(values, options = {})
|
|
48
|
+
vals = normalize_values(values)
|
|
49
|
+
return '' if vals.empty?
|
|
50
|
+
|
|
51
|
+
height = [options[:height] || 5, 2].max
|
|
52
|
+
dates = options[:dates] || []
|
|
53
|
+
|
|
54
|
+
min_val = vals.min
|
|
55
|
+
max_val = vals.max
|
|
56
|
+
range = [max_val - min_val, 1.0].max
|
|
57
|
+
|
|
58
|
+
y_label_width = [max_val.round.to_s.length, min_val.round.to_s.length, 3].max
|
|
59
|
+
|
|
60
|
+
rows = []
|
|
61
|
+
|
|
62
|
+
# Render each chart line from top to bottom
|
|
63
|
+
(height - 1).downto(0) do |row_idx|
|
|
64
|
+
threshold = min_val + (range * (row_idx.to_f / (height - 1)))
|
|
65
|
+
label = row_idx == height - 1 ? max_val.round.to_s : (row_idx.zero? ? min_val.round.to_s : '')
|
|
66
|
+
y_axis = label.rjust(y_label_width) + ' ┤ '
|
|
67
|
+
|
|
68
|
+
line_chars = vals.map do |v|
|
|
69
|
+
normalized = (v - min_val) / range.to_f
|
|
70
|
+
char_level = (normalized * (height - 1)).round
|
|
71
|
+
|
|
72
|
+
if char_level == row_idx
|
|
73
|
+
Color.bold(Color.cyan('•'))
|
|
74
|
+
elsif char_level > row_idx
|
|
75
|
+
Color.cyan('│')
|
|
76
|
+
else
|
|
77
|
+
' '
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
rows << "#{y_axis}#{line_chars.join}"
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# X-axis line
|
|
85
|
+
axis_pad = ' '.rjust(y_label_width)
|
|
86
|
+
rows << "#{axis_pad} ┼─#{'─' * vals.size}"
|
|
87
|
+
|
|
88
|
+
# X-axis date milestones if available
|
|
89
|
+
if dates.any? && dates.size == vals.size
|
|
90
|
+
step = [vals.size / 4, 1].max
|
|
91
|
+
label_line = ' ' * vals.size
|
|
92
|
+
0.step(vals.size - 1, step) do |i|
|
|
93
|
+
d_str = dates[i].to_s.sub(/^\d{4}-/, '') # MM-DD
|
|
94
|
+
label_line[i, d_str.length] = d_str if i + d_str.length <= label_line.length
|
|
95
|
+
end
|
|
96
|
+
rows << "#{axis_pad} #{label_line}"
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
rows.join("\n")
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# Computes trend velocity and momentum
|
|
103
|
+
def self.momentum(values)
|
|
104
|
+
vals = normalize_values(values)
|
|
105
|
+
return { change_pct: 0.0, trend: :stable, velocity: 'FLAT' } if vals.size < 2
|
|
106
|
+
|
|
107
|
+
mid = vals.size / 2
|
|
108
|
+
first_half = vals[0...mid]
|
|
109
|
+
second_half = vals[mid..-1]
|
|
110
|
+
|
|
111
|
+
avg_first = first_half.sum.to_f / [first_half.size, 1].max
|
|
112
|
+
avg_second = second_half.sum.to_f / [second_half.size, 1].max
|
|
113
|
+
|
|
114
|
+
change_pct = avg_first.zero? ? (avg_second > 0 ? 100.0 : 0.0) : (((avg_second - avg_first) / avg_first) * 100.0).round(1)
|
|
115
|
+
|
|
116
|
+
trend = if change_pct >= 25.0
|
|
117
|
+
:surging
|
|
118
|
+
elsif change_pct >= 5.0
|
|
119
|
+
:rising
|
|
120
|
+
elsif change_pct <= -25.0
|
|
121
|
+
:collapsing
|
|
122
|
+
elsif change_pct <= -5.0
|
|
123
|
+
:decaying
|
|
124
|
+
else
|
|
125
|
+
:stable
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
velocity_badge = case trend
|
|
129
|
+
when :surging then '🚀 SURGING (+%+.1f%%)' % change_pct
|
|
130
|
+
when :rising then '📈 RISING (+%+.1f%%)' % change_pct
|
|
131
|
+
when :collapsing then '💥 COLLAPSING (%+.1f%%)' % change_pct
|
|
132
|
+
when :decaying then '📉 DECAYING (%+.1f%%)' % change_pct
|
|
133
|
+
else '➡️ STABLE (%+.1f%%)' % change_pct
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
{
|
|
137
|
+
change_pct: change_pct,
|
|
138
|
+
trend: trend,
|
|
139
|
+
velocity: velocity_badge,
|
|
140
|
+
start_avg: avg_first.round(1),
|
|
141
|
+
end_avg: avg_second.round(1)
|
|
142
|
+
}
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
private
|
|
146
|
+
|
|
147
|
+
def self.normalize_values(values)
|
|
148
|
+
Array(values).map { |v| v.is_a?(Hash) ? (v[:value] || v['value'] || 0) : v }.map(&:to_f)
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def self.resample(vals, max_pts)
|
|
152
|
+
return vals if vals.size <= max_pts
|
|
153
|
+
step = vals.size.to_f / max_pts
|
|
154
|
+
(0...max_pts).map do |i|
|
|
155
|
+
idx = (i * step).round
|
|
156
|
+
vals[[idx, vals.size - 1].min]
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def self.color_tick(tick, idx, total)
|
|
161
|
+
pct = idx.to_f / (total - 1)
|
|
162
|
+
if pct >= 0.75
|
|
163
|
+
Color.green(tick)
|
|
164
|
+
elsif pct >= 0.4
|
|
165
|
+
Color.yellow(tick)
|
|
166
|
+
else
|
|
167
|
+
Color.red(tick)
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
end
|