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.
@@ -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