gsc-cli 2.0.2 → 2.1.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 +171 -3
- data/bin/gsc +2028 -197
- data/dist/gsc +167 -5
- data/lib/gsc/backlinks_manager.rb +96 -0
- data/lib/gsc/cli.rb +156 -3
- data/lib/gsc/cli_advanced.rb +570 -0
- data/lib/gsc/config.rb +9 -0
- data/lib/gsc/content_gap.rb +112 -0
- data/lib/gsc/google_suggest.rb +109 -0
- data/lib/gsc/internal_links.rb +132 -0
- data/lib/gsc/llms_generator.rb +104 -0
- data/lib/gsc/network_tracer.rb +86 -0
- data/lib/gsc/open_page_rank.rb +72 -0
- data/lib/gsc/page_comparator.rb +108 -0
- data/lib/gsc/page_speed.rb +110 -0
- data/lib/gsc/robots_checker.rb +83 -0
- data/lib/gsc/schema_validator.rb +122 -0
- data/lib/gsc/serp_preview.rb +66 -0
- data/lib/gsc/version.rb +1 -1
- data/lib/gsc.rb +26 -0
- metadata +17 -2
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
# encoding: utf-8
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require 'set'
|
|
5
|
+
|
|
6
|
+
module GSC
|
|
7
|
+
class ContentGap
|
|
8
|
+
STOP_WORDS = Set.new(%w[
|
|
9
|
+
a about above after again against all am an and any are aren't as at be because been before being below
|
|
10
|
+
between both but by can't cannot could couldn't did didn't do does doesn't doing don't down during each
|
|
11
|
+
few for from further had hadn't has hasn't have haven't having he he'd he'll he's her here here's hers
|
|
12
|
+
herself him himself his how how's i i'd i'll i'm i've if in into is isn't it it's its itself let's me
|
|
13
|
+
more most mustn't my myself no nor not of off on once only or other ought our ours ourselves out over own
|
|
14
|
+
same shan't she she'd she'll she's should shouldn't so some such than that that's the their theirs them
|
|
15
|
+
themselves then there there's these they they'd they'll they're they've this those through to too under until
|
|
16
|
+
up very was wasn't we we'd we'll we're we've were weren't what what's when when's where where's which while
|
|
17
|
+
who who's whom why why's with won't would wouldn't you you'd you'll you're you've your yours yourself yourselves
|
|
18
|
+
])
|
|
19
|
+
|
|
20
|
+
attr_reader :url1, :url2
|
|
21
|
+
|
|
22
|
+
def initialize(url1, url2)
|
|
23
|
+
@url1 = url1
|
|
24
|
+
@url2 = url2
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def analyze
|
|
28
|
+
pa1 = GSC::PageAnalyzer.new(@url1)
|
|
29
|
+
pa2 = GSC::PageAnalyzer.new(@url2)
|
|
30
|
+
|
|
31
|
+
data1 = pa1.fetch_and_analyze
|
|
32
|
+
data2 = pa2.fetch_and_analyze
|
|
33
|
+
|
|
34
|
+
text1 = extract_clean_text(pa1.html || '')
|
|
35
|
+
text2 = extract_clean_text(pa2.html || '')
|
|
36
|
+
|
|
37
|
+
tokens1 = tokenize(text1)
|
|
38
|
+
tokens2 = tokenize(text2)
|
|
39
|
+
|
|
40
|
+
unigrams1 = ngrams(tokens1, 1)
|
|
41
|
+
unigrams2 = ngrams(tokens2, 1)
|
|
42
|
+
|
|
43
|
+
bigrams1 = ngrams(tokens1, 2)
|
|
44
|
+
bigrams2 = ngrams(tokens2, 2)
|
|
45
|
+
|
|
46
|
+
trigrams1 = ngrams(tokens1, 3)
|
|
47
|
+
trigrams2 = ngrams(tokens2, 3)
|
|
48
|
+
|
|
49
|
+
# Competitor terms that occur at least 2 times, but occur 0 times in page1
|
|
50
|
+
missing_unigrams = term_gap(unigrams1, unigrams2, min_count: 2)
|
|
51
|
+
missing_bigrams = term_gap(bigrams1, bigrams2, min_count: 2)
|
|
52
|
+
missing_trigrams = term_gap(trigrams1, trigrams2, min_count: 2)
|
|
53
|
+
|
|
54
|
+
# Heading gaps (H2/H3 in page2 that have no match in page1)
|
|
55
|
+
h1 = (data1.dig(:headings, :h2) || []) + (data1.dig(:headings, :h3) || [])
|
|
56
|
+
h2 = (data2.dig(:headings, :h2) || []) + (data2.dig(:headings, :h3) || [])
|
|
57
|
+
|
|
58
|
+
missing_headings = h2.reject do |heading|
|
|
59
|
+
h1.any? { |my_h| my_h.downcase.include?(heading.downcase[0..15]) }
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
{
|
|
63
|
+
page1: { url: @url1, word_count: tokens1.length },
|
|
64
|
+
page2: { url: @url2, word_count: tokens2.length },
|
|
65
|
+
missing_unigrams: missing_unigrams.first(15),
|
|
66
|
+
missing_bigrams: missing_bigrams.first(15),
|
|
67
|
+
missing_trigrams: missing_trigrams.first(10),
|
|
68
|
+
missing_headings: missing_headings.first(10)
|
|
69
|
+
}
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
private
|
|
73
|
+
|
|
74
|
+
def extract_clean_text(html)
|
|
75
|
+
text = html.dup
|
|
76
|
+
text.gsub!(/<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/i, ' ')
|
|
77
|
+
text.gsub!(/<style\b[^<]*(?:(?!<\/style>)<[^<]*)*<\/style>/i, ' ')
|
|
78
|
+
text.gsub!(/<nav\b[^<]*(?:(?!<\/nav>)<[^<]*)*<\/nav>/i, ' ')
|
|
79
|
+
text.gsub!(/<footer\b[^<]*(?:(?!<\/footer>)<[^<]*)*<\/footer>/i, ' ')
|
|
80
|
+
text.gsub!(/<[^>]+>/, ' ')
|
|
81
|
+
text.gsub!(/&[a-z]+;/i, ' ')
|
|
82
|
+
text.gsub!(/\s+/, ' ')
|
|
83
|
+
text.strip
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def tokenize(text)
|
|
87
|
+
text.downcase.scan(/[a-z0-9]+/).reject { |w| w.length < 3 || STOP_WORDS.include?(w) }
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def ngrams(tokens, n)
|
|
91
|
+
counts = Hash.new(0)
|
|
92
|
+
return counts if tokens.length < n
|
|
93
|
+
|
|
94
|
+
(0..(tokens.length - n)).each do |i|
|
|
95
|
+
gram = tokens[i, n].join(' ')
|
|
96
|
+
counts[gram] += 1
|
|
97
|
+
end
|
|
98
|
+
counts
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def term_gap(counts1, counts2, min_count: 2)
|
|
102
|
+
gap = []
|
|
103
|
+
counts2.each do |term, count2|
|
|
104
|
+
count1 = counts1[term] || 0
|
|
105
|
+
if count2 >= min_count && count1 == 0
|
|
106
|
+
gap << { term: term, competitor_count: count2, your_count: count1 }
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
gap.sort_by { |item| -item[:competitor_count] }
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
# encoding: utf-8
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require 'net/http'
|
|
5
|
+
require 'uri'
|
|
6
|
+
require 'json'
|
|
7
|
+
|
|
8
|
+
module GSC
|
|
9
|
+
class GoogleSuggest
|
|
10
|
+
SUGGEST_URL = 'https://suggestqueries.google.com/complete/search'
|
|
11
|
+
|
|
12
|
+
attr_reader :query, :options
|
|
13
|
+
|
|
14
|
+
def initialize(query, options = {})
|
|
15
|
+
@query = query.to_s.strip
|
|
16
|
+
@options = options
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def fetch(alphabet: false, questions: false)
|
|
20
|
+
if questions
|
|
21
|
+
fetch_questions
|
|
22
|
+
elsif alphabet
|
|
23
|
+
fetch_alphabet_soup
|
|
24
|
+
else
|
|
25
|
+
fetch_single(@query)
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def fetch_single(search_term)
|
|
30
|
+
uri = URI("#{SUGGEST_URL}?client=chrome&q=#{URI.encode_www_form_component(search_term)}")
|
|
31
|
+
req = Net::HTTP::Get.new(uri)
|
|
32
|
+
req['User-Agent'] = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36'
|
|
33
|
+
req['Accept'] = 'application/json, text/javascript, */*; q=0.01'
|
|
34
|
+
|
|
35
|
+
res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true, open_timeout: 5, read_timeout: 10) do |http|
|
|
36
|
+
http.request(req)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
return [] unless res.code == '200'
|
|
40
|
+
|
|
41
|
+
parsed = JSON.parse(res.body.force_encoding('UTF-8'))
|
|
42
|
+
terms = parsed[1] || []
|
|
43
|
+
types = parsed[4] ? parsed[4]['google:suggesttype'] || [] : []
|
|
44
|
+
|
|
45
|
+
terms.map.with_index do |term, idx|
|
|
46
|
+
{
|
|
47
|
+
term: term,
|
|
48
|
+
type: types[idx] || 'QUERY',
|
|
49
|
+
root: search_term
|
|
50
|
+
}
|
|
51
|
+
end
|
|
52
|
+
rescue StandardError => e
|
|
53
|
+
[]
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def fetch_alphabet_soup
|
|
57
|
+
results = {}
|
|
58
|
+
base = @query.strip
|
|
59
|
+
|
|
60
|
+
# Root query first
|
|
61
|
+
results['root'] = fetch_single(base)
|
|
62
|
+
|
|
63
|
+
# A-Z permutations
|
|
64
|
+
('a'..'z').each do |letter|
|
|
65
|
+
term = "#{base} #{letter}"
|
|
66
|
+
items = fetch_single(term)
|
|
67
|
+
results[letter] = items unless items.empty?
|
|
68
|
+
sleep(0.05) # Polite throttle
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# 0-9 permutations if requested
|
|
72
|
+
if @options[:numbers]
|
|
73
|
+
('0'..'9').each do |num|
|
|
74
|
+
term = "#{base} #{num}"
|
|
75
|
+
items = fetch_single(term)
|
|
76
|
+
results[num] = items unless items.empty?
|
|
77
|
+
sleep(0.05)
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
results
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def fetch_questions
|
|
85
|
+
prefixes = [
|
|
86
|
+
'how to',
|
|
87
|
+
'how do',
|
|
88
|
+
'why do',
|
|
89
|
+
'why does',
|
|
90
|
+
'what is',
|
|
91
|
+
'what are',
|
|
92
|
+
'can you',
|
|
93
|
+
'best',
|
|
94
|
+
'where to',
|
|
95
|
+
'which'
|
|
96
|
+
]
|
|
97
|
+
|
|
98
|
+
results = {}
|
|
99
|
+
prefixes.each do |pfx|
|
|
100
|
+
term = "#{pfx} #{@query}"
|
|
101
|
+
items = fetch_single(term)
|
|
102
|
+
results[pfx] = items unless items.empty?
|
|
103
|
+
sleep(0.05)
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
results
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
# encoding: utf-8
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require 'uri'
|
|
5
|
+
require 'set'
|
|
6
|
+
|
|
7
|
+
module GSC
|
|
8
|
+
class InternalLinks
|
|
9
|
+
attr_reader :base_url, :pages, :graph, :orphans, :depths
|
|
10
|
+
|
|
11
|
+
def initialize(base_url, limit: 50)
|
|
12
|
+
@base_url = base_url.to_s.strip
|
|
13
|
+
@base_url = "https://#{@base_url}" unless @base_url =~ %r{^https?://}
|
|
14
|
+
@base_uri = URI.parse(@base_url)
|
|
15
|
+
@limit = limit
|
|
16
|
+
@graph = Hash.new { |h, k| h[k] = Set.new } # target_url => Set of source_urls
|
|
17
|
+
@out_links = Hash.new { |h, k| h[k] = Set.new } # source_url => Set of target_urls
|
|
18
|
+
@all_discovered = Set.new
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def audit(sitemap_urls = nil)
|
|
22
|
+
urls_to_crawl = if sitemap_urls && !sitemap_urls.empty?
|
|
23
|
+
sitemap_urls.first(@limit)
|
|
24
|
+
else
|
|
25
|
+
discover_urls
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
urls_to_crawl.each do |url|
|
|
29
|
+
@all_discovered << normalize_url(url)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Crawl each URL and extract internal links
|
|
33
|
+
urls_to_crawl.each do |url|
|
|
34
|
+
pa = GSC::PageAnalyzer.new(url)
|
|
35
|
+
pa.load_content! rescue next
|
|
36
|
+
dom = pa.analyze_dom rescue next
|
|
37
|
+
|
|
38
|
+
norm_source = normalize_url(url)
|
|
39
|
+
links = dom.dig(:links, :all) || []
|
|
40
|
+
|
|
41
|
+
links.each do |link_obj|
|
|
42
|
+
href = link_obj[:href]
|
|
43
|
+
target_url = resolve_internal_url(href)
|
|
44
|
+
next unless target_url
|
|
45
|
+
|
|
46
|
+
norm_target = normalize_url(target_url)
|
|
47
|
+
next if norm_target == norm_source
|
|
48
|
+
|
|
49
|
+
@graph[norm_target] << norm_source
|
|
50
|
+
@out_links[norm_source] << norm_target
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Calculate Orphans (pages in sitemap/discovered with 0 incoming internal links)
|
|
55
|
+
orphans = []
|
|
56
|
+
weak_pages = [] # only 1 internal link
|
|
57
|
+
|
|
58
|
+
@all_discovered.each do |url|
|
|
59
|
+
in_degree = @graph[url].size
|
|
60
|
+
if in_degree == 0 && url != normalize_url(@base_url)
|
|
61
|
+
orphans << url
|
|
62
|
+
elsif in_degree == 1
|
|
63
|
+
weak_pages << { url: url, source: @graph[url].first }
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Calculate click depths via BFS from root
|
|
68
|
+
depths = calculate_depths(normalize_url(@base_url))
|
|
69
|
+
|
|
70
|
+
{
|
|
71
|
+
base_url: @base_url,
|
|
72
|
+
total_pages: @all_discovered.size,
|
|
73
|
+
orphans: orphans,
|
|
74
|
+
weak_pages: weak_pages,
|
|
75
|
+
top_linked: top_linked_pages(10),
|
|
76
|
+
depths: depths
|
|
77
|
+
}
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
private
|
|
81
|
+
|
|
82
|
+
def discover_urls
|
|
83
|
+
loader = GSC::SitemapLoader.new(@base_url)
|
|
84
|
+
urls = loader.load
|
|
85
|
+
urls.empty? ? [@base_url] : urls.first(@limit)
|
|
86
|
+
rescue StandardError
|
|
87
|
+
[@base_url]
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def resolve_internal_url(href)
|
|
91
|
+
return nil if href.nil? || href.strip.empty?
|
|
92
|
+
return nil if href =~ /^(mailto|tel|javascript|#):/i
|
|
93
|
+
|
|
94
|
+
uri = URI.join(@base_url, href) rescue nil
|
|
95
|
+
return nil unless uri && uri.scheme =~ /^https?$/i
|
|
96
|
+
return nil unless uri.host.downcase == @base_uri.host.downcase
|
|
97
|
+
|
|
98
|
+
uri.fragment = nil
|
|
99
|
+
uri.to_s
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def normalize_url(url)
|
|
103
|
+
u = url.to_s.strip.sub(%r{/$}, '')
|
|
104
|
+
u
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def calculate_depths(root_url)
|
|
108
|
+
depths = { root_url => 0 }
|
|
109
|
+
queue = [root_url]
|
|
110
|
+
|
|
111
|
+
until queue.empty?
|
|
112
|
+
curr = queue.shift
|
|
113
|
+
curr_depth = depths[curr]
|
|
114
|
+
|
|
115
|
+
(@out_links[curr] || []).each do |neighbor|
|
|
116
|
+
next if depths.key?(neighbor)
|
|
117
|
+
|
|
118
|
+
depths[neighbor] = curr_depth + 1
|
|
119
|
+
queue << neighbor
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
depths
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def top_linked_pages(limit)
|
|
127
|
+
@graph.map do |url, sources|
|
|
128
|
+
{ url: url, incoming_count: sources.size }
|
|
129
|
+
end.sort_by { |item| -item[:incoming_count] }.first(limit)
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
end
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
# encoding: utf-8
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require 'uri'
|
|
5
|
+
|
|
6
|
+
module GSC
|
|
7
|
+
class LlmsGenerator
|
|
8
|
+
attr_reader :base_url, :pages
|
|
9
|
+
|
|
10
|
+
def initialize(base_url)
|
|
11
|
+
@base_url = base_url.to_s.strip
|
|
12
|
+
@base_url = "https://#{@base_url}" unless @base_url =~ %r{^https?://}
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def generate_llms_txt(title: nil, summary: nil)
|
|
16
|
+
loader = GSC::SitemapLoader.new(@base_url)
|
|
17
|
+
urls = loader.load rescue [@base_url]
|
|
18
|
+
urls = [@base_url] if urls.empty?
|
|
19
|
+
|
|
20
|
+
site_title = title || URI.parse(@base_url).host.sub(/^www\./, '').capitalize
|
|
21
|
+
site_summary = summary || "Official documentation and product guides for #{site_title}."
|
|
22
|
+
|
|
23
|
+
out = []
|
|
24
|
+
out << "# #{site_title}"
|
|
25
|
+
out << ""
|
|
26
|
+
out << "> #{site_summary}"
|
|
27
|
+
out << ""
|
|
28
|
+
out << "## Core Documentation"
|
|
29
|
+
out << ""
|
|
30
|
+
|
|
31
|
+
# Sample first 15 key URLs and fetch metadata
|
|
32
|
+
urls.first(15).each do |url|
|
|
33
|
+
pa = GSC::PageAnalyzer.new(url)
|
|
34
|
+
pa.load_content! rescue next
|
|
35
|
+
dom = pa.analyze_dom rescue next
|
|
36
|
+
|
|
37
|
+
page_title = dom.dig(:title, :text) || url
|
|
38
|
+
page_desc = dom.dig(:meta_description, :text) || "Documentation page."
|
|
39
|
+
|
|
40
|
+
out << "- [#{page_title}](#{url}): #{page_desc}"
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
out << ""
|
|
44
|
+
out << "## Optional"
|
|
45
|
+
out << ""
|
|
46
|
+
out << "- [Full Documentation](#{@base_url}/llms-full.txt): Complete consolidated knowledge base for LLM context ingestion."
|
|
47
|
+
out << ""
|
|
48
|
+
|
|
49
|
+
out.join("\n")
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def audit_ai_readability(url)
|
|
53
|
+
pa = GSC::PageAnalyzer.new(url)
|
|
54
|
+
data = pa.fetch_and_analyze
|
|
55
|
+
|
|
56
|
+
# Check criteria:
|
|
57
|
+
# 1. Clear H1 presence
|
|
58
|
+
# 2. Table presence (LLMs love tables)
|
|
59
|
+
# 3. Schema presence (structured data)
|
|
60
|
+
# 4. Definition / Bullet presence
|
|
61
|
+
html = pa.html || ''
|
|
62
|
+
has_tables = html.include?('<table')
|
|
63
|
+
has_lists = html.include?('<ul') || html.include?('<ol')
|
|
64
|
+
schemas = data.dig(:structured_data, :schemas) || []
|
|
65
|
+
h1_count = (data.dig(:headings, :h1) || []).length
|
|
66
|
+
|
|
67
|
+
score = 100
|
|
68
|
+
issues = []
|
|
69
|
+
|
|
70
|
+
if h1_count != 1
|
|
71
|
+
score -= 20
|
|
72
|
+
issues << "H1 count is #{h1_count} (Must be exactly 1 for clean LLM hierarchy)"
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
unless has_tables
|
|
76
|
+
score -= 15
|
|
77
|
+
issues << "No <table> found (tables increase LLM citation and fact extraction by 3x)"
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
unless has_lists
|
|
81
|
+
score -= 15
|
|
82
|
+
issues << "No bullet lists (<ul> or <ol>) found for quick entity consumption"
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
if schemas.empty?
|
|
86
|
+
score -= 20
|
|
87
|
+
issues << "No JSON-LD schemas detected (structured data accelerates AI knowledge graph inclusion)"
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
{
|
|
91
|
+
url: url,
|
|
92
|
+
ai_readability_score: [score, 0].max,
|
|
93
|
+
grade: score >= 80 ? 'A (Excellent)' : (score >= 60 ? 'B (Acceptable)' : 'C (Needs Work)'),
|
|
94
|
+
issues: issues,
|
|
95
|
+
features: {
|
|
96
|
+
has_tables: has_tables,
|
|
97
|
+
has_lists: has_lists,
|
|
98
|
+
schemas_found: schemas.length,
|
|
99
|
+
h1_count: h1_count
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
# encoding: utf-8
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require 'net/http'
|
|
5
|
+
require 'uri'
|
|
6
|
+
|
|
7
|
+
module GSC
|
|
8
|
+
class NetworkTracer
|
|
9
|
+
attr_reader :start_url, :max_hops
|
|
10
|
+
|
|
11
|
+
def initialize(start_url, max_hops: 10)
|
|
12
|
+
@start_url = start_url.to_s.strip
|
|
13
|
+
@start_url = "http://#{@start_url}" unless @start_url =~ %r{^https?://}
|
|
14
|
+
@max_hops = max_hops
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def trace
|
|
18
|
+
current_url = @start_url
|
|
19
|
+
hops = []
|
|
20
|
+
visited = Set.new
|
|
21
|
+
|
|
22
|
+
@max_hops.times do |hop_idx|
|
|
23
|
+
break if visited.include?(current_url)
|
|
24
|
+
visited << current_url
|
|
25
|
+
|
|
26
|
+
uri = URI.parse(current_url) rescue nil
|
|
27
|
+
break unless uri && uri.host
|
|
28
|
+
|
|
29
|
+
start_t = Time.now
|
|
30
|
+
res = nil
|
|
31
|
+
|
|
32
|
+
begin
|
|
33
|
+
Net::HTTP.start(uri.hostname, uri.port, use_ssl: (uri.scheme == 'https'), open_timeout: 5, read_timeout: 10) do |http|
|
|
34
|
+
req = Net::HTTP::Get.new(uri.request_uri)
|
|
35
|
+
req['User-Agent'] = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) gsc-cli/2.1'
|
|
36
|
+
res = http.request(req)
|
|
37
|
+
end
|
|
38
|
+
rescue StandardError => e
|
|
39
|
+
hops << {
|
|
40
|
+
hop: hop_idx + 1,
|
|
41
|
+
url: current_url,
|
|
42
|
+
error: e.message
|
|
43
|
+
}
|
|
44
|
+
break
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
duration_ms = ((Time.now - start_t) * 1000).round(1)
|
|
48
|
+
status_code = res.code.to_i
|
|
49
|
+
location = res['location']
|
|
50
|
+
|
|
51
|
+
hop_info = {
|
|
52
|
+
hop: hop_idx + 1,
|
|
53
|
+
url: current_url,
|
|
54
|
+
status_code: status_code,
|
|
55
|
+
duration_ms: duration_ms,
|
|
56
|
+
x_robots_tag: res['x-robots-tag'],
|
|
57
|
+
canonical_header: res['link'] =~ /rel="canonical"/i ? res['link'] : nil,
|
|
58
|
+
hsts: res['strict-transport-security'],
|
|
59
|
+
content_type: res['content-type'],
|
|
60
|
+
server: res['server']
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
hops << hop_info
|
|
64
|
+
|
|
65
|
+
if [301, 302, 303, 307, 308].include?(status_code) && location
|
|
66
|
+
current_url = URI.join(current_url, location).to_s
|
|
67
|
+
else
|
|
68
|
+
break
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
total_time = hops.sum { |h| h[:duration_ms] || 0 }.round(1)
|
|
73
|
+
final_hop = hops.last || {}
|
|
74
|
+
is_redirect_chain = hops.length > 2
|
|
75
|
+
|
|
76
|
+
{
|
|
77
|
+
start_url: @start_url,
|
|
78
|
+
final_url: final_hop[:url],
|
|
79
|
+
total_hops: hops.length,
|
|
80
|
+
total_duration_ms: total_time,
|
|
81
|
+
is_redirect_chain: is_redirect_chain,
|
|
82
|
+
hops: hops
|
|
83
|
+
}
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# encoding: utf-8
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require 'net/http'
|
|
5
|
+
require 'uri'
|
|
6
|
+
require 'json'
|
|
7
|
+
|
|
8
|
+
module GSC
|
|
9
|
+
class OpenPageRank
|
|
10
|
+
API_URL = 'https://openpagerank.com/api/v1.0/getPageRank'
|
|
11
|
+
|
|
12
|
+
attr_reader :api_key
|
|
13
|
+
|
|
14
|
+
def initialize(api_key = nil)
|
|
15
|
+
@api_key = api_key || ENV['OPENPAGERANK_API_KEY'] || GSC::Config.get('opr_api_key')
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def configured?
|
|
19
|
+
!@api_key.nil? && !@api_key.strip.empty?
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def check_domains(domains)
|
|
23
|
+
domains = Array(domains).map { |d| clean_domain(d) }.reject(&:empty?).uniq
|
|
24
|
+
return { error: 'No valid domains provided' } if domains.empty?
|
|
25
|
+
return { error: 'OpenPageRank API key not configured. Set via `gsc config set opr_api_key <key>` or OPENPAGERANK_API_KEY env (Get free 300k calls/mo at openpagerank.com)' } unless configured?
|
|
26
|
+
|
|
27
|
+
# Construct query params: domains[0]=a.com&domains[1]=b.com
|
|
28
|
+
params = domains.map.with_index { |d, idx| "domains%5B#{idx}%5D=#{URI.encode_www_form_component(d)}" }.join('&')
|
|
29
|
+
uri = URI("#{API_URL}?#{params}")
|
|
30
|
+
|
|
31
|
+
req = Net::HTTP::Get.new(uri)
|
|
32
|
+
req['API-OPR'] = @api_key
|
|
33
|
+
req['User-Agent'] = 'gsc-cli/2.1'
|
|
34
|
+
|
|
35
|
+
res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true, open_timeout: 5, read_timeout: 15) do |http|
|
|
36
|
+
http.request(req)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
if res.code == '200'
|
|
40
|
+
data = JSON.parse(res.body)
|
|
41
|
+
records = (data['response'] || []).map do |r|
|
|
42
|
+
{
|
|
43
|
+
domain: r['domain'],
|
|
44
|
+
page_rank_decimal: r['page_rank_decimal'] || 0.0,
|
|
45
|
+
page_rank_integer: r['page_rank_integer'] || 0,
|
|
46
|
+
rank: r['rank'],
|
|
47
|
+
status_code: r['status_code']
|
|
48
|
+
}
|
|
49
|
+
end
|
|
50
|
+
{
|
|
51
|
+
status: 'success',
|
|
52
|
+
status_code: data['status_code'],
|
|
53
|
+
records: records
|
|
54
|
+
}
|
|
55
|
+
else
|
|
56
|
+
{
|
|
57
|
+
status: 'error',
|
|
58
|
+
code: res.code.to_i,
|
|
59
|
+
message: res.body
|
|
60
|
+
}
|
|
61
|
+
end
|
|
62
|
+
rescue StandardError => e
|
|
63
|
+
{ status: 'error', message: e.message }
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def clean_domain(input)
|
|
67
|
+
d = input.to_s.strip.downcase
|
|
68
|
+
d = d.sub(%r{^https?://}, '').sub(%r{/.*$}, '').sub(/^www\./, '')
|
|
69
|
+
d
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|