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,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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module GSC
4
- VERSION = '2.0.2'
4
+ VERSION = '2.1.0'
5
5
  end
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.2
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://superspeedapp.com/?utm_source=rubygems&utm_medium=gem_sidebar&utm_campaign=gsc-cli
75
+ funding_uri: https://github.com/ApollosWave/gsc-cli#-sponsorship--backing
61
76
  rdoc_options: []
62
77
  require_paths:
63
78
  - lib