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.
- checksums.yaml +4 -4
- data/AUTH.md +205 -0
- data/FUNDING.md +120 -0
- data/README.md +463 -299
- data/bin/gsc +29158 -4921
- data/dist/gsc +29158 -4921
- 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/backlinks_manager.rb +96 -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 +706 -5111
- data/lib/gsc/cli_advanced.rb +1513 -0
- 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 +11 -2
- data/lib/gsc/content_gap.rb +112 -0
- 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_suggest.rb +109 -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 +249 -0
- data/lib/gsc/keyword_value.rb +191 -0
- data/lib/gsc/landing_roi.rb +195 -0
- data/lib/gsc/llms_generator.rb +425 -0
- data/lib/gsc/low_ctr_rewriter.rb +408 -0
- data/lib/gsc/mobile_parity.rb +222 -0
- data/lib/gsc/network_tracer.rb +93 -0
- data/lib/gsc/open_page_rank.rb +72 -0
- data/lib/gsc/page_analyzer.rb +47 -7
- data/lib/gsc/page_comparator.rb +108 -0
- data/lib/gsc/page_speed.rb +110 -0
- 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 +114 -0
- data/lib/gsc/schema_generator.rb +788 -0
- data/lib/gsc/schema_validator.rb +120 -0
- 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 +152 -0
- 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 +144 -0
- metadata +91 -2
|
@@ -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
|