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,108 @@
|
|
|
1
|
+
# encoding: utf-8
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module GSC
|
|
5
|
+
class PageComparator
|
|
6
|
+
attr_reader :url1, :url2, :data1, :data2
|
|
7
|
+
|
|
8
|
+
def initialize(url1, url2)
|
|
9
|
+
@url1 = url1
|
|
10
|
+
@url2 = url2
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def compare
|
|
14
|
+
pa1 = GSC::PageAnalyzer.new(@url1)
|
|
15
|
+
pa2 = GSC::PageAnalyzer.new(@url2)
|
|
16
|
+
|
|
17
|
+
@data1 = pa1.fetch_and_analyze
|
|
18
|
+
@data2 = pa2.fetch_and_analyze
|
|
19
|
+
|
|
20
|
+
diffs = {
|
|
21
|
+
meta: compare_meta,
|
|
22
|
+
headings: compare_headings,
|
|
23
|
+
images: compare_images,
|
|
24
|
+
links: compare_links,
|
|
25
|
+
performance: compare_perf,
|
|
26
|
+
structured_data: compare_schema
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
{
|
|
30
|
+
page1: { url: @url1, status: @data1[:http_status] },
|
|
31
|
+
page2: { url: @url2, status: @data2[:http_status] },
|
|
32
|
+
comparison: diffs
|
|
33
|
+
}
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
private
|
|
37
|
+
|
|
38
|
+
def compare_meta
|
|
39
|
+
t1 = @data1.dig(:title, :text) || ''
|
|
40
|
+
t2 = @data2.dig(:title, :text) || ''
|
|
41
|
+
m1 = @data1.dig(:meta_description, :text) || ''
|
|
42
|
+
m2 = @data2.dig(:meta_description, :text) || ''
|
|
43
|
+
|
|
44
|
+
{
|
|
45
|
+
title: {
|
|
46
|
+
page1: { text: t1, length: t1.length, optimal: t1.length.between?(30, 60) },
|
|
47
|
+
page2: { text: t2, length: t2.length, optimal: t2.length.between?(30, 60) }
|
|
48
|
+
},
|
|
49
|
+
meta_description: {
|
|
50
|
+
page1: { text: m1, length: m1.length, optimal: m1.length.between?(70, 155) },
|
|
51
|
+
page2: { text: m2, length: m2.length, optimal: m2.length.between?(70, 155) }
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def compare_headings
|
|
57
|
+
h1_1 = @data1.dig(:headings, :h1) || []
|
|
58
|
+
h1_2 = @data2.dig(:headings, :h1) || []
|
|
59
|
+
h2_1 = @data1.dig(:headings, :h2) || []
|
|
60
|
+
h2_2 = @data2.dig(:headings, :h2) || []
|
|
61
|
+
|
|
62
|
+
{
|
|
63
|
+
h1_count: { page1: h1_1.length, page2: h1_2.length },
|
|
64
|
+
h1_text: { page1: h1_1.first, page2: h1_2.first },
|
|
65
|
+
h2_count: { page1: h2_1.length, page2: h2_2.length }
|
|
66
|
+
}
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def compare_images
|
|
70
|
+
img1 = @data1[:images] || {}
|
|
71
|
+
img2 = @data2[:images] || {}
|
|
72
|
+
|
|
73
|
+
{
|
|
74
|
+
total_images: { page1: img1[:total] || 0, page2: img2[:total] || 0 },
|
|
75
|
+
missing_alt: { page1: img1[:missing_alt] || 0, page2: img2[:missing_alt] || 0 }
|
|
76
|
+
}
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def compare_links
|
|
80
|
+
l1 = @data1[:links] || {}
|
|
81
|
+
l2 = @data2[:links] || {}
|
|
82
|
+
|
|
83
|
+
{
|
|
84
|
+
internal: { page1: l1[:internal_count] || 0, page2: l2[:internal_count] || 0 },
|
|
85
|
+
external: { page1: l1[:external_count] || 0, page2: l2[:external_count] || 0 }
|
|
86
|
+
}
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def compare_perf
|
|
90
|
+
{
|
|
91
|
+
response_time_ms: { page1: @data1[:response_time_ms], page2: @data2[:response_time_ms] }
|
|
92
|
+
}
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def compare_schema
|
|
96
|
+
s1 = @data1.dig(:structured_data, :schemas) || []
|
|
97
|
+
s2 = @data2.dig(:structured_data, :schemas) || []
|
|
98
|
+
|
|
99
|
+
types1 = s1.map { |s| s['@type'] }.compact
|
|
100
|
+
types2 = s2.map { |s| s['@type'] }.compact
|
|
101
|
+
|
|
102
|
+
{
|
|
103
|
+
schema_count: { page1: s1.length, page2: s2.length },
|
|
104
|
+
schema_types: { page1: types1, page2: types2 }
|
|
105
|
+
}
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
|
@@ -0,0 +1,110 @@
|
|
|
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 PageSpeed
|
|
10
|
+
API_URL = 'https://www.googleapis.com/pagespeedonline/v5/runPagespeed'
|
|
11
|
+
|
|
12
|
+
attr_reader :url, :strategy, :api_key
|
|
13
|
+
|
|
14
|
+
def initialize(url, strategy: 'mobile', api_key: nil)
|
|
15
|
+
@url = url.to_s.strip
|
|
16
|
+
@strategy = strategy.to_s.downcase == 'desktop' ? 'desktop' : 'mobile'
|
|
17
|
+
@api_key = api_key || ENV['PAGESPEED_API_KEY'] || GSC::Config.get('pagespeed_api_key')
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def run
|
|
21
|
+
query_params = [
|
|
22
|
+
"url=#{URI.encode_www_form_component(@url)}",
|
|
23
|
+
"strategy=#{@strategy}",
|
|
24
|
+
"category=performance",
|
|
25
|
+
"category=seo"
|
|
26
|
+
]
|
|
27
|
+
query_params << "key=#{URI.encode_www_form_component(@api_key)}" if @api_key && !@api_key.empty?
|
|
28
|
+
|
|
29
|
+
uri = URI("#{API_URL}?#{query_params.join('&')}")
|
|
30
|
+
req = Net::HTTP::Get.new(uri)
|
|
31
|
+
req['User-Agent'] = 'gsc-cli/2.1'
|
|
32
|
+
|
|
33
|
+
res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true, open_timeout: 10, read_timeout: 30) do |http|
|
|
34
|
+
http.request(req)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
if res.code == '200'
|
|
38
|
+
parse_response(JSON.parse(res.body))
|
|
39
|
+
else
|
|
40
|
+
{
|
|
41
|
+
error: true,
|
|
42
|
+
status_code: res.code.to_i,
|
|
43
|
+
message: res.body
|
|
44
|
+
}
|
|
45
|
+
end
|
|
46
|
+
rescue StandardError => e
|
|
47
|
+
{ error: true, message: e.message }
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
private
|
|
51
|
+
|
|
52
|
+
def parse_response(data)
|
|
53
|
+
lhr = data['lighthouseResult'] || {}
|
|
54
|
+
categories = lhr['categories'] || {}
|
|
55
|
+
perf_score = ((categories.dig('performance', 'score') || 0) * 100).round
|
|
56
|
+
seo_score = ((categories.dig('seo', 'score') || 0) * 100).round
|
|
57
|
+
|
|
58
|
+
audits = lhr['audits'] || {}
|
|
59
|
+
|
|
60
|
+
# Core Web Vitals
|
|
61
|
+
fcp = audits.dig('first-contentful-paint', 'displayValue')
|
|
62
|
+
lcp = audits.dig('largest-contentful-paint', 'displayValue')
|
|
63
|
+
cls = audits.dig('cumulative-layout-shift', 'displayValue')
|
|
64
|
+
tbt = audits.dig('total-blocking-time', 'displayValue')
|
|
65
|
+
si = audits.dig('speed-index', 'displayValue')
|
|
66
|
+
|
|
67
|
+
# CrUX Field Data (if available)
|
|
68
|
+
crux_metrics = {}
|
|
69
|
+
crux = data.dig('loadingExperience', 'metrics') || {}
|
|
70
|
+
crux.each do |k, v|
|
|
71
|
+
crux_metrics[k] = {
|
|
72
|
+
percentile: v['percentile'],
|
|
73
|
+
category: v['category']
|
|
74
|
+
}
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Opportunities
|
|
78
|
+
opportunities = []
|
|
79
|
+
audits.each do |k, v|
|
|
80
|
+
next unless v['details'] && v['details']['type'] == 'opportunity'
|
|
81
|
+
next unless v['numericValue'] && v['numericValue'] > 100
|
|
82
|
+
|
|
83
|
+
opportunities << {
|
|
84
|
+
id: k,
|
|
85
|
+
title: v['title'],
|
|
86
|
+
savings_ms: v['numericValue'] ? v['numericValue'].round : 0,
|
|
87
|
+
display: v['displayValue']
|
|
88
|
+
}
|
|
89
|
+
end
|
|
90
|
+
opportunities.sort_by! { |o| -o[:savings_ms] }
|
|
91
|
+
|
|
92
|
+
{
|
|
93
|
+
url: @url,
|
|
94
|
+
strategy: @strategy,
|
|
95
|
+
fetch_time: lhr['fetchTime'],
|
|
96
|
+
performance_score: perf_score,
|
|
97
|
+
seo_score: seo_score,
|
|
98
|
+
metrics: {
|
|
99
|
+
fcp: fcp,
|
|
100
|
+
lcp: lcp,
|
|
101
|
+
cls: cls,
|
|
102
|
+
tbt: tbt,
|
|
103
|
+
speed_index: si
|
|
104
|
+
},
|
|
105
|
+
field_data: crux_metrics,
|
|
106
|
+
opportunities: opportunities.first(5)
|
|
107
|
+
}
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# encoding: utf-8
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require 'net/http'
|
|
5
|
+
require 'uri'
|
|
6
|
+
|
|
7
|
+
module GSC
|
|
8
|
+
class RobotsChecker
|
|
9
|
+
attr_reader :base_url, :robots_content
|
|
10
|
+
|
|
11
|
+
def initialize(target_url)
|
|
12
|
+
@target_url = target_url.to_s.strip
|
|
13
|
+
@target_url = "https://#{@target_url}" unless @target_url =~ %r{^https?://}
|
|
14
|
+
@uri = URI.parse(@target_url)
|
|
15
|
+
@robots_url = "#{@uri.scheme}://#{@uri.host}:#{@uri.port}/robots.txt"
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def fetch_robots_txt
|
|
19
|
+
res = Net::HTTP.get_response(URI.parse(@robots_url))
|
|
20
|
+
return '' unless res.code == '200'
|
|
21
|
+
|
|
22
|
+
res.body.force_encoding('UTF-8')
|
|
23
|
+
rescue StandardError
|
|
24
|
+
''
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def check(path_to_test = nil, user_agent = 'googlebot')
|
|
28
|
+
@robots_content ||= fetch_robots_txt
|
|
29
|
+
path = path_to_test || @uri.path
|
|
30
|
+
path = '/' if path.empty?
|
|
31
|
+
|
|
32
|
+
ua = user_agent.to_s.downcase
|
|
33
|
+
rules = parse_rules(ua)
|
|
34
|
+
|
|
35
|
+
allowed = true
|
|
36
|
+
matched_rule = nil
|
|
37
|
+
|
|
38
|
+
rules.each do |rule|
|
|
39
|
+
pattern = rule[:path]
|
|
40
|
+
regex = Regexp.new('^' + Regexp.escape(pattern).gsub('\*', '.*'))
|
|
41
|
+
if path =~ regex
|
|
42
|
+
allowed = (rule[:type] == :allow)
|
|
43
|
+
matched_rule = rule
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
{
|
|
48
|
+
robots_url: @robots_url,
|
|
49
|
+
user_agent: user_agent,
|
|
50
|
+
tested_path: path,
|
|
51
|
+
allowed: allowed,
|
|
52
|
+
matched_rule: matched_rule,
|
|
53
|
+
has_robots_txt: !@robots_content.empty?
|
|
54
|
+
}
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
private
|
|
58
|
+
|
|
59
|
+
def parse_rules(target_ua)
|
|
60
|
+
rules = []
|
|
61
|
+
current_ua = nil
|
|
62
|
+
applies = false
|
|
63
|
+
|
|
64
|
+
@robots_content.each_line do |line|
|
|
65
|
+
line = line.strip.sub(/#.*$/, '')
|
|
66
|
+
next if line.empty?
|
|
67
|
+
|
|
68
|
+
if line =~ /^User-agent:\s*(.+)$/i
|
|
69
|
+
current_ua = $1.strip.downcase
|
|
70
|
+
applies = (current_ua == '*' || current_ua == target_ua)
|
|
71
|
+
elsif applies && line =~ /^Disallow:\s*(.*)$/i
|
|
72
|
+
val = $1.strip
|
|
73
|
+
rules << { type: :disallow, path: val } unless val.empty?
|
|
74
|
+
elsif applies && line =~ /^Allow:\s*(.*)$/i
|
|
75
|
+
val = $1.strip
|
|
76
|
+
rules << { type: :allow, path: val } unless val.empty?
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
rules
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
# encoding: utf-8
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require 'json'
|
|
5
|
+
|
|
6
|
+
module GSC
|
|
7
|
+
class SchemaValidator
|
|
8
|
+
attr_reader :url, :schemas, :validation_results
|
|
9
|
+
|
|
10
|
+
def initialize(url)
|
|
11
|
+
@url = url.to_s.strip
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def audit
|
|
15
|
+
pa = GSC::PageAnalyzer.new(@url)
|
|
16
|
+
data = pa.fetch_and_analyze
|
|
17
|
+
@schemas = data.dig(:structured_data, :schemas) || []
|
|
18
|
+
|
|
19
|
+
results = []
|
|
20
|
+
@schemas.each_with_index do |schema, idx|
|
|
21
|
+
results << validate_single_schema(schema, idx)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
{
|
|
25
|
+
url: @url,
|
|
26
|
+
total_schemas: @schemas.length,
|
|
27
|
+
schemas: results
|
|
28
|
+
}
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def validate_single_schema(schema, idx)
|
|
32
|
+
type = schema['@type'] || 'Unknown'
|
|
33
|
+
errors = []
|
|
34
|
+
warnings = []
|
|
35
|
+
|
|
36
|
+
case type
|
|
37
|
+
when 'SoftwareApplication', 'WebApplication'
|
|
38
|
+
errors << 'Missing "name"' unless schema['name']
|
|
39
|
+
warnings << 'Missing "operatingSystem"' unless schema['operatingSystem']
|
|
40
|
+
warnings << 'Missing "applicationCategory"' unless schema['applicationCategory']
|
|
41
|
+
warnings << 'Missing "offers"' unless schema['offers']
|
|
42
|
+
warnings << 'Missing "aggregateRating"' unless schema['aggregateRating']
|
|
43
|
+
|
|
44
|
+
when 'FAQPage'
|
|
45
|
+
main_entity = schema['mainEntity']
|
|
46
|
+
if !main_entity || !main_entity.is_a?(Array) || main_entity.empty?
|
|
47
|
+
errors << 'FAQPage must contain a non-empty "mainEntity" array'
|
|
48
|
+
else
|
|
49
|
+
main_entity.each_with_index do |q, q_idx|
|
|
50
|
+
errors << "Question ##{q_idx + 1} missing name" unless q['name']
|
|
51
|
+
errors << "Question ##{q_idx + 1} missing acceptedAnswer" unless q['acceptedAnswer']
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
when 'Product'
|
|
56
|
+
errors << 'Missing "name"' unless schema['name']
|
|
57
|
+
warnings << 'Missing "image"' unless schema['image']
|
|
58
|
+
warnings << 'Missing "offers"' unless schema['offers']
|
|
59
|
+
|
|
60
|
+
when 'Article', 'BlogPosting'
|
|
61
|
+
errors << 'Missing "headline"' unless schema['headline']
|
|
62
|
+
errors << 'Missing "author"' unless schema['author']
|
|
63
|
+
warnings << 'Missing "datePublished"' unless schema['datePublished']
|
|
64
|
+
warnings << 'Missing "image"' unless schema['image']
|
|
65
|
+
|
|
66
|
+
when 'Organization', 'LocalBusiness'
|
|
67
|
+
errors << 'Missing "name"' unless schema['name']
|
|
68
|
+
errors << 'Missing "url"' unless schema['url']
|
|
69
|
+
warnings << 'Missing "logo"' unless schema['logo']
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
{
|
|
73
|
+
index: idx,
|
|
74
|
+
type: type,
|
|
75
|
+
valid: errors.empty?,
|
|
76
|
+
errors: errors,
|
|
77
|
+
warnings: warnings,
|
|
78
|
+
raw: schema
|
|
79
|
+
}
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def self.generate_template(type, params = {})
|
|
83
|
+
case type.to_s.downcase
|
|
84
|
+
when 'faq'
|
|
85
|
+
{
|
|
86
|
+
'@context' => 'https://schema.org',
|
|
87
|
+
'@type' => 'FAQPage',
|
|
88
|
+
'mainEntity' => [
|
|
89
|
+
{
|
|
90
|
+
'@type' => 'Question',
|
|
91
|
+
'name' => params[:question] || 'What is PackingLog?',
|
|
92
|
+
'acceptedAnswer' => {
|
|
93
|
+
'@type' => 'Answer',
|
|
94
|
+
'text' => params[:answer] || 'PackingLog is a free moving box and inventory management system.'
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
]
|
|
98
|
+
}
|
|
99
|
+
when 'software', 'app'
|
|
100
|
+
{
|
|
101
|
+
'@context' => 'https://schema.org',
|
|
102
|
+
'@type' => 'SoftwareApplication',
|
|
103
|
+
'name' => params[:name] || 'PackingLog',
|
|
104
|
+
'applicationCategory' => params[:category] || 'UtilitiesApplication',
|
|
105
|
+
'operatingSystem' => 'Web, iOS, Android',
|
|
106
|
+
'offers' => {
|
|
107
|
+
'@type' => 'Offer',
|
|
108
|
+
'price' => '0',
|
|
109
|
+
'priceCurrency' => 'USD'
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
else
|
|
113
|
+
{
|
|
114
|
+
'@context' => 'https://schema.org',
|
|
115
|
+
'@type' => 'Organization',
|
|
116
|
+
'name' => params[:name] || 'PackingLog',
|
|
117
|
+
'url' => params[:url] || 'https://packinglog.com'
|
|
118
|
+
}
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
end
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# encoding: utf-8
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module GSC
|
|
5
|
+
class SerpPreview
|
|
6
|
+
attr_reader :url, :data
|
|
7
|
+
|
|
8
|
+
def initialize(url)
|
|
9
|
+
@url = url.to_s.strip
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def generate
|
|
13
|
+
pa = GSC::PageAnalyzer.new(@url)
|
|
14
|
+
@data = pa.fetch_and_analyze
|
|
15
|
+
|
|
16
|
+
title = @data.dig(:title, :text) || 'Untitled Page'
|
|
17
|
+
desc = @data.dig(:meta_description, :text) || 'No meta description found.'
|
|
18
|
+
canonical = @data.dig(:canonical, :url) || @url
|
|
19
|
+
|
|
20
|
+
# SERP pixel calculation approximation:
|
|
21
|
+
# ~10px per character average for Arial 18px title
|
|
22
|
+
title_chars = title.length
|
|
23
|
+
is_truncated = title_chars > 60
|
|
24
|
+
|
|
25
|
+
desktop_title = is_truncated ? "#{title[0..56]}..." : title
|
|
26
|
+
desktop_snippet = desc.length > 155 ? "#{desc[0..152]}..." : desc
|
|
27
|
+
|
|
28
|
+
og = @data[:open_graph] || {}
|
|
29
|
+
twitter = @data[:twitter_card] || {}
|
|
30
|
+
|
|
31
|
+
{
|
|
32
|
+
url: @url,
|
|
33
|
+
canonical: canonical,
|
|
34
|
+
title: title,
|
|
35
|
+
meta_description: desc,
|
|
36
|
+
truncation_risk: is_truncated,
|
|
37
|
+
desktop_serp: {
|
|
38
|
+
title: desktop_title,
|
|
39
|
+
snippet: desktop_snippet,
|
|
40
|
+
breadcrumb: format_breadcrumb(canonical)
|
|
41
|
+
},
|
|
42
|
+
social: {
|
|
43
|
+
og_title: og['og:title'] || title,
|
|
44
|
+
og_description: og['og:description'] || desc,
|
|
45
|
+
og_image: og['og:image'],
|
|
46
|
+
twitter_card: twitter['twitter:card'] || 'summary_large_image'
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
private
|
|
52
|
+
|
|
53
|
+
def format_breadcrumb(url_str)
|
|
54
|
+
uri = URI.parse(url_str) rescue nil
|
|
55
|
+
return url_str unless uri && uri.host
|
|
56
|
+
|
|
57
|
+
domain = uri.host.sub(/^www\./, '')
|
|
58
|
+
parts = uri.path.split('/').reject(&:empty?)
|
|
59
|
+
if parts.empty?
|
|
60
|
+
"https://#{domain}"
|
|
61
|
+
else
|
|
62
|
+
"https://#{domain} > #{parts.join(' > ')}"
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
data/lib/gsc/version.rb
CHANGED
data/lib/gsc.rb
CHANGED
|
@@ -26,7 +26,20 @@ require_relative 'gsc/keywords_everywhere'
|
|
|
26
26
|
require_relative 'gsc/prompts'
|
|
27
27
|
require_relative 'gsc/page_analyzer'
|
|
28
28
|
require_relative 'gsc/site_crawler'
|
|
29
|
+
require_relative 'gsc/google_suggest'
|
|
30
|
+
require_relative 'gsc/open_page_rank'
|
|
31
|
+
require_relative 'gsc/page_speed'
|
|
32
|
+
require_relative 'gsc/page_comparator'
|
|
33
|
+
require_relative 'gsc/content_gap'
|
|
34
|
+
require_relative 'gsc/internal_links'
|
|
35
|
+
require_relative 'gsc/schema_validator'
|
|
36
|
+
require_relative 'gsc/llms_generator'
|
|
37
|
+
require_relative 'gsc/serp_preview'
|
|
38
|
+
require_relative 'gsc/network_tracer'
|
|
39
|
+
require_relative 'gsc/robots_checker'
|
|
40
|
+
require_relative 'gsc/backlinks_manager'
|
|
29
41
|
require_relative 'gsc/command_registry'
|
|
42
|
+
require_relative 'gsc/cli_advanced'
|
|
30
43
|
require_relative 'gsc/cli'
|
|
31
44
|
|
|
32
45
|
# Top-level aliases for compatibility
|
|
@@ -36,3 +49,16 @@ KeywordsEverywhere = GSC::KeywordsEverywhere unless defined?(KeywordsEverywhere)
|
|
|
36
49
|
Prompts = GSC::Prompts unless defined?(Prompts)
|
|
37
50
|
PageAnalyzer = GSC::PageAnalyzer unless defined?(PageAnalyzer)
|
|
38
51
|
SiteCrawler = GSC::SiteCrawler unless defined?(SiteCrawler)
|
|
52
|
+
|
|
53
|
+
GoogleSuggest = GSC::GoogleSuggest unless defined?(GoogleSuggest)
|
|
54
|
+
OpenPageRank = GSC::OpenPageRank unless defined?(OpenPageRank)
|
|
55
|
+
PageSpeed = GSC::PageSpeed unless defined?(PageSpeed)
|
|
56
|
+
PageComparator = GSC::PageComparator unless defined?(PageComparator)
|
|
57
|
+
ContentGap = GSC::ContentGap unless defined?(ContentGap)
|
|
58
|
+
InternalLinks = GSC::InternalLinks unless defined?(InternalLinks)
|
|
59
|
+
SchemaValidator = GSC::SchemaValidator unless defined?(SchemaValidator)
|
|
60
|
+
LlmsGenerator = GSC::LlmsGenerator unless defined?(LlmsGenerator)
|
|
61
|
+
SerpPreview = GSC::SerpPreview unless defined?(SerpPreview)
|
|
62
|
+
NetworkTracer = GSC::NetworkTracer unless defined?(NetworkTracer)
|
|
63
|
+
RobotsChecker = GSC::RobotsChecker unless defined?(RobotsChecker)
|
|
64
|
+
BacklinksManager = GSC::BacklinksManager unless defined?(BacklinksManager)
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: gsc-cli
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 2.0
|
|
4
|
+
version: 2.1.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- ApollosWave LLC
|
|
@@ -28,6 +28,8 @@ executables:
|
|
|
28
28
|
extensions: []
|
|
29
29
|
extra_rdoc_files: []
|
|
30
30
|
files:
|
|
31
|
+
- AUTH.md
|
|
32
|
+
- FUNDING.md
|
|
31
33
|
- LICENSE
|
|
32
34
|
- README.md
|
|
33
35
|
- bin/gsc
|
|
@@ -35,16 +37,29 @@ files:
|
|
|
35
37
|
- lib/gsc.rb
|
|
36
38
|
- lib/gsc/api.rb
|
|
37
39
|
- lib/gsc/auth.rb
|
|
40
|
+
- lib/gsc/backlinks_manager.rb
|
|
38
41
|
- lib/gsc/cli.rb
|
|
42
|
+
- lib/gsc/cli_advanced.rb
|
|
39
43
|
- lib/gsc/client.rb
|
|
40
44
|
- lib/gsc/color.rb
|
|
41
45
|
- lib/gsc/command_registry.rb
|
|
42
46
|
- lib/gsc/config.rb
|
|
47
|
+
- lib/gsc/content_gap.rb
|
|
48
|
+
- lib/gsc/google_suggest.rb
|
|
43
49
|
- lib/gsc/google_trends.rb
|
|
50
|
+
- lib/gsc/internal_links.rb
|
|
44
51
|
- lib/gsc/keyword_planner.rb
|
|
45
52
|
- lib/gsc/keywords_everywhere.rb
|
|
53
|
+
- lib/gsc/llms_generator.rb
|
|
54
|
+
- lib/gsc/network_tracer.rb
|
|
55
|
+
- lib/gsc/open_page_rank.rb
|
|
46
56
|
- lib/gsc/page_analyzer.rb
|
|
57
|
+
- lib/gsc/page_comparator.rb
|
|
58
|
+
- lib/gsc/page_speed.rb
|
|
47
59
|
- lib/gsc/prompts.rb
|
|
60
|
+
- lib/gsc/robots_checker.rb
|
|
61
|
+
- lib/gsc/schema_validator.rb
|
|
62
|
+
- lib/gsc/serp_preview.rb
|
|
48
63
|
- lib/gsc/site_crawler.rb
|
|
49
64
|
- lib/gsc/sitemap_loader.rb
|
|
50
65
|
- lib/gsc/version.rb
|
|
@@ -57,7 +72,7 @@ metadata:
|
|
|
57
72
|
documentation_uri: https://github.com/ApollosWave/gsc-cli#readme
|
|
58
73
|
bug_tracker_uri: https://github.com/ApollosWave/gsc-cli/issues
|
|
59
74
|
changelog_uri: https://github.com/ApollosWave/gsc-cli/blob/main/README.md
|
|
60
|
-
funding_uri: https://
|
|
75
|
+
funding_uri: https://github.com/ApollosWave/gsc-cli#-sponsorship--backing
|
|
61
76
|
rdoc_options: []
|
|
62
77
|
require_paths:
|
|
63
78
|
- lib
|