crawlscope 0.4.0 → 0.6.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.
Files changed (44) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +36 -0
  3. data/README.md +38 -0
  4. data/lib/crawlscope/cli.rb +20 -1
  5. data/lib/crawlscope/configuration.rb +10 -1
  6. data/lib/crawlscope/context.rb +1 -1
  7. data/lib/crawlscope/crawl.rb +74 -14
  8. data/lib/crawlscope/crawler.rb +3 -17
  9. data/lib/crawlscope/document_text.rb +7 -2
  10. data/lib/crawlscope/fetch_executor/async.rb +32 -0
  11. data/lib/crawlscope/fetch_executor/threaded.rb +32 -0
  12. data/lib/crawlscope/fetch_executor.rb +43 -0
  13. data/lib/crawlscope/http.rb +7 -1
  14. data/lib/crawlscope/rake_tasks.rb +27 -12
  15. data/lib/crawlscope/reporter.rb +131 -7
  16. data/lib/crawlscope/result.rb +1 -1
  17. data/lib/crawlscope/rules/content_quality.rb +1 -1
  18. data/lib/crawlscope/rules/indexability.rb +155 -20
  19. data/lib/crawlscope/rules/links.rb +379 -12
  20. data/lib/crawlscope/rules/metadata.rb +61 -6
  21. data/lib/crawlscope/rules/structured_data.rb +31 -0
  22. data/lib/crawlscope/rules/uniqueness.rb +45 -4
  23. data/lib/crawlscope/sitemap.rb +39 -12
  24. data/lib/crawlscope/version.rb +1 -1
  25. data/lib/tasks/crawlscope_tasks.rake +24 -24
  26. data/test/crawlscope/cli_test.rb +29 -2
  27. data/test/crawlscope/configuration_test.rb +21 -0
  28. data/test/crawlscope/content_quality_rule_test.rb +18 -0
  29. data/test/crawlscope/crawl_test.rb +167 -3
  30. data/test/crawlscope/crawler_test.rb +61 -0
  31. data/test/crawlscope/fetch_executor_test.rb +44 -0
  32. data/test/crawlscope/indexability_rule_test.rb +33 -0
  33. data/test/crawlscope/links_rule_test.rb +249 -3
  34. data/test/crawlscope/metadata_rule_test.rb +36 -0
  35. data/test/crawlscope/rake_tasks_test.rb +70 -0
  36. data/test/crawlscope/reporter_test.rb +136 -7
  37. data/test/crawlscope/result_test.rb +35 -0
  38. data/test/crawlscope/sitemap_test.rb +76 -0
  39. data/test/crawlscope/structured_data_rule_test.rb +56 -0
  40. data/test/crawlscope/uniqueness_rule_test.rb +17 -2
  41. data/test/performance/async_fetch_benchmark.rb +127 -0
  42. data/test/performance/fetch_executor_matrix.rb +162 -0
  43. data/test/performance/sitemap_expansion_benchmark.rb +121 -0
  44. metadata +39 -2
@@ -1,7 +1,11 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "uri"
4
+
3
5
  module Crawlscope
4
6
  class Reporter
7
+ MAX_ISSUES_PER_GROUP = 20
8
+
5
9
  def initialize(io:)
6
10
  @io = io
7
11
  end
@@ -13,21 +17,141 @@ module Crawlscope
13
17
  @io.puts("URLs: #{result.urls.size}")
14
18
  @io.puts("Pages: #{result.pages.size}")
15
19
 
16
- if result.ok?
20
+ if result.issues.size.zero?
17
21
  @io.puts("Status: OK")
18
22
  return
19
23
  end
20
24
 
21
- @io.puts("Status: FAILED")
22
- @io.puts("Issues: #{result.issues.size}")
25
+ @io.puts("Status: #{status_for(result.issues)}")
26
+ @io.puts("Issues: #{result.issues.size} total (#{severity_summary(result.issues)})")
27
+ @io.puts("")
28
+
29
+ report_summary(result.issues)
30
+ @io.puts("")
31
+ report_issue_groups(result.issues, base_url: result.base_url)
32
+ end
33
+
34
+ private
35
+
36
+ def status_for(issues)
37
+ grouped = issues.by_severity
23
38
 
24
- result.issues.by_severity.sort_by { |severity, _issues| severity.to_s }.each do |severity, issues|
25
- @io.puts("#{severity}: #{issues.size}")
39
+ if grouped.key?(:error)
40
+ "FAILED"
41
+ elsif grouped.key?(:warning)
42
+ "WARNINGS"
43
+ else
44
+ "NOTICES"
26
45
  end
46
+ end
47
+
48
+ def severity_summary(issues)
49
+ grouped = issues.by_severity
50
+ return "" if grouped.empty?
51
+
52
+ grouped
53
+ .sort_by { |severity, severity_issues| [-severity_issues.size, severity.to_s] }
54
+ .map { |severity, severity_issues| "#{severity_issues.size} #{pluralize(severity, severity_issues.size)}" }
55
+ .join(", ")
56
+ end
57
+
58
+ def report_summary(issues)
59
+ @io.puts("Summary:")
27
60
 
28
- result.issues.each do |issue|
29
- @io.puts("- [#{issue.severity}] #{issue.url} #{issue.message}")
61
+ issues.by_category
62
+ .sort_by { |category, category_issues| [-category_issues.size, category.to_s] }
63
+ .each do |category, category_issues|
64
+ @io.puts(" #{category.to_s.ljust(16)} #{category_issues.size}")
65
+ end
66
+ end
67
+
68
+ def report_issue_groups(issues, base_url:)
69
+ grouped = issues.to_a.group_by { |issue| [issue.category, issue.code] }
70
+
71
+ grouped
72
+ .sort_by { |(category, code), grouped_issues| [-grouped_issues.size, category.to_s, code.to_s] }
73
+ .each do |(category, code), grouped_issues|
74
+ @io.puts("#{category} / #{code}: #{grouped_issues.size}")
75
+
76
+ grouped_issues.first(MAX_ISSUES_PER_GROUP).each do |issue|
77
+ @io.puts(" - #{compact_issue(issue, base_url: base_url)}")
78
+ end
79
+
80
+ remaining_count = grouped_issues.size - MAX_ISSUES_PER_GROUP
81
+ @io.puts(" ... #{remaining_count} more") if remaining_count.positive?
82
+ @io.puts("")
83
+ end
84
+ end
85
+
86
+ def compact_issue(issue, base_url:)
87
+ parts = []
88
+ parts << relative_url(issue.url, base_url: base_url) if issue.url
89
+
90
+ detail = compact_detail(issue, base_url: base_url)
91
+ parts << detail unless detail.empty?
92
+
93
+ parts.compact.join(" ")
94
+ end
95
+
96
+ def compact_detail(issue, base_url:)
97
+ details = issue.details || {}
98
+ fragments = []
99
+
100
+ inbound = details[:dofollow_inbound_count] || details[:inbound_count]
101
+ fragments << "inbound #{inbound}/#{details[:minimum]}" if inbound && details[:minimum]
102
+
103
+ if details[:ratio] && details[:threshold]
104
+ fragments << "ratio #{format_number(details[:ratio])}/#{format_number(details[:threshold])}"
105
+ end
106
+
107
+ fragments << "count #{details[:count]}" if details[:count]
108
+ fragments << "length #{details[:length]}" if details[:length]
109
+ fragments << "status #{details[:status]}" if details[:status]
110
+ fragments << "final: #{relative_url(details[:final_url], base_url: base_url)}" if details[:final_url]
111
+ fragments << "sources: #{relative_urls(details[:source_urls], base_url: base_url).join(", ")}" if details[:source_urls]&.any?
112
+ fragments << "source: #{relative_url(details[:source_url], base_url: base_url)}" if details[:source_url]
113
+ fragments << "targets: #{relative_urls(details[:target_urls], base_url: base_url).join(", ")}" if details[:target_urls]&.any?
114
+
115
+ return issue.message if fragments.empty?
116
+
117
+ case issue.code
118
+ when :low_dofollow_inlinks, :low_inbound_anchor_links, :low_unique_token_ratio, :low_visible_text_ratio
119
+ fragments.join(" ")
120
+ else
121
+ ([issue.message] + fragments).join(" ")
30
122
  end
31
123
  end
124
+
125
+ def relative_urls(urls, base_url:)
126
+ Array(urls).map { |url| relative_url(url, base_url: base_url) }
127
+ end
128
+
129
+ def relative_url(url, base_url:)
130
+ return url unless url && base_url
131
+
132
+ uri = URI.parse(url)
133
+ base_uri = URI.parse(base_url)
134
+
135
+ return url unless uri.host == base_uri.host && uri.scheme == base_uri.scheme && uri.port == base_uri.port
136
+
137
+ relative = uri.path.to_s.empty? ? "/" : uri.path
138
+ relative += "?#{uri.query}" if uri.query
139
+ relative += "##{uri.fragment}" if uri.fragment
140
+ relative
141
+ rescue URI::InvalidURIError
142
+ url
143
+ end
144
+
145
+ def format_number(value)
146
+ return format("%.3f", value) if value.is_a?(Float)
147
+
148
+ value.to_s
149
+ end
150
+
151
+ def pluralize(word, count)
152
+ return word.to_s if count == 1
153
+
154
+ "#{word}s"
155
+ end
32
156
  end
33
157
  end
@@ -3,7 +3,7 @@
3
3
  module Crawlscope
4
4
  Result = Data.define(:base_url, :sitemap_path, :urls, :pages, :issues) do
5
5
  def ok?
6
- issues.none?(&:error?) && issues.none?(&:warning?) && issues.none?(&:notice?)
6
+ issues.none?(&:error?)
7
7
  end
8
8
  end
9
9
  end
@@ -55,7 +55,7 @@ module Crawlscope
55
55
  end
56
56
 
57
57
  def validate_visible_text_ratio(page, issues)
58
- html_bytes = DocumentText.html_for(page.doc).bytesize
58
+ html_bytes = DocumentText.content_ratio_html_for(page.doc).bytesize
59
59
  return if html_bytes.zero?
60
60
 
61
61
  visible_text = DocumentText.text_for(page.doc)
@@ -6,6 +6,31 @@ module Crawlscope
6
6
  ROBOTS_META_SELECTOR = 'meta[name="robots"], meta[name="googlebot"]'
7
7
  X_ROBOTS_TAG_HEADER = "x-robots-tag"
8
8
 
9
+ def self.noindex_header?(headers)
10
+ noindex?(header_value(headers, X_ROBOTS_TAG_HEADER))
11
+ end
12
+
13
+ def self.noindex_meta?(doc)
14
+ return false unless doc
15
+
16
+ doc.css(ROBOTS_META_SELECTOR).any? { |tag| noindex?(tag["content"].to_s) }
17
+ end
18
+
19
+ def self.header_value(headers, name)
20
+ headers.find { |key, _value| key.to_s.casecmp?(name) }&.last.to_s
21
+ end
22
+
23
+ def self.directives(value)
24
+ value
25
+ .split(",")
26
+ .map { |directive| directive.split(":", 2).last.to_s.strip }
27
+ .reject(&:empty?)
28
+ end
29
+
30
+ def self.noindex?(value)
31
+ directives(value).any? { |directive| directive.casecmp?("noindex") || directive.casecmp?("none") }
32
+ end
33
+
9
34
  attr_reader :code
10
35
 
11
36
  def initialize
@@ -13,45 +38,107 @@ module Crawlscope
13
38
  end
14
39
 
15
40
  def call(urls:, pages:, issues:, context: nil)
41
+ sitemap_urls = normalized_sitemap_urls(urls)
42
+
16
43
  pages.each do |page|
17
- validate_meta_robots(page, issues) if page.html?
18
- validate_x_robots_tag(page, issues)
44
+ validate_meta_robots(page, issues, sitemap_urls) if page.html?
45
+ validate_x_robots_tag(page, issues, sitemap_urls)
19
46
  end
20
47
  end
21
48
 
22
49
  private
23
50
 
51
+ def normalized_sitemap_urls(urls)
52
+ urls.map { |url| Url.normalize(url, base_url: url) }.compact
53
+ end
54
+
24
55
  def header_value(page, name)
25
- page.headers.find { |key, _value| key.to_s.casecmp?(name) }&.last.to_s
56
+ self.class.header_value(page.headers, name)
57
+ end
58
+
59
+ def directives(value)
60
+ self.class.directives(value)
26
61
  end
27
62
 
28
63
  def noindex?(value)
29
- value
30
- .split(",")
31
- .map { |directive| directive.split(":", 2).last.to_s.strip }
32
- .any? { |directive| directive.casecmp?("noindex") || directive.casecmp?("none") }
64
+ self.class.noindex?(value)
65
+ end
66
+
67
+ def follow?(value)
68
+ directives(value).any? { |directive| directive.casecmp?("follow") }
33
69
  end
34
70
 
35
- def validate_meta_robots(page, issues)
71
+ def nofollow?(value)
72
+ directives(value).any? { |directive| directive.casecmp?("nofollow") || directive.casecmp?("none") }
73
+ end
74
+
75
+ def validate_meta_robots(page, issues, sitemap_urls)
36
76
  page.doc.css(ROBOTS_META_SELECTOR).each do |tag|
37
77
  content = tag["content"].to_s
38
- next unless noindex?(content)
39
-
40
- issues.add(
41
- code: :noindex_meta,
42
- severity: :error,
43
- category: :indexability,
44
- url: page.url,
45
- message: "robots meta tag prevents indexing",
46
- details: {content: content, name: tag["name"].to_s}
47
- )
78
+
79
+ report_noindex_meta(page, issues, content, tag["name"].to_s, sitemap_urls) if noindex?(content)
80
+ report_nofollow_meta(page, issues, content, tag["name"].to_s) if nofollow?(content)
81
+ report_noindex_follow_meta(page, issues, content, tag["name"].to_s) if noindex?(content) && follow?(content)
82
+ report_noindex_nofollow_meta(page, issues, content, tag["name"].to_s) if noindex?(content) && nofollow?(content)
48
83
  end
49
84
  end
50
85
 
51
- def validate_x_robots_tag(page, issues)
86
+ def validate_x_robots_tag(page, issues, sitemap_urls)
52
87
  content = header_value(page, X_ROBOTS_TAG_HEADER)
53
- return unless noindex?(content)
88
+ return if content.empty?
89
+
90
+ report_noindex_header(page, issues, content, sitemap_urls) if noindex?(content)
91
+ report_nofollow_header(page, issues, content) if nofollow?(content)
92
+ report_noindex_follow_header(page, issues, content) if noindex?(content) && follow?(content)
93
+ report_noindex_nofollow_header(page, issues, content) if noindex?(content) && nofollow?(content)
94
+ end
95
+
96
+ def report_noindex_meta(page, issues, content, name, sitemap_urls)
97
+ issues.add(
98
+ code: :noindex_meta,
99
+ severity: :error,
100
+ category: :indexability,
101
+ url: page.url,
102
+ message: "robots meta tag prevents indexing",
103
+ details: {content: content, name: name}
104
+ )
105
+ report_sitemap_noindex_url(page, issues, content, source: "meta", sitemap_urls: sitemap_urls)
106
+ end
107
+
108
+ def report_nofollow_meta(page, issues, content, name)
109
+ issues.add(
110
+ code: :nofollow_meta,
111
+ severity: :warning,
112
+ category: :indexability,
113
+ url: page.url,
114
+ message: "robots meta tag prevents following links",
115
+ details: {content: content, name: name}
116
+ )
117
+ end
54
118
 
119
+ def report_noindex_follow_meta(page, issues, content, name)
120
+ issues.add(
121
+ code: :noindex_follow_meta,
122
+ severity: :warning,
123
+ category: :indexability,
124
+ url: page.url,
125
+ message: "robots meta tag prevents indexing but allows following links",
126
+ details: {content: content, name: name}
127
+ )
128
+ end
129
+
130
+ def report_noindex_nofollow_meta(page, issues, content, name)
131
+ issues.add(
132
+ code: :noindex_nofollow_meta,
133
+ severity: :error,
134
+ category: :indexability,
135
+ url: page.url,
136
+ message: "robots meta tag prevents indexing and following links",
137
+ details: {content: content, name: name}
138
+ )
139
+ end
140
+
141
+ def report_noindex_header(page, issues, content, sitemap_urls)
55
142
  issues.add(
56
143
  code: :noindex_header,
57
144
  severity: :error,
@@ -60,6 +147,54 @@ module Crawlscope
60
147
  message: "X-Robots-Tag header prevents indexing",
61
148
  details: {content: content}
62
149
  )
150
+ report_sitemap_noindex_url(page, issues, content, source: "header", sitemap_urls: sitemap_urls)
151
+ end
152
+
153
+ def report_nofollow_header(page, issues, content)
154
+ issues.add(
155
+ code: :nofollow_header,
156
+ severity: :warning,
157
+ category: :indexability,
158
+ url: page.url,
159
+ message: "X-Robots-Tag header prevents following links",
160
+ details: {content: content}
161
+ )
162
+ end
163
+
164
+ def report_noindex_follow_header(page, issues, content)
165
+ issues.add(
166
+ code: :noindex_follow_header,
167
+ severity: :warning,
168
+ category: :indexability,
169
+ url: page.url,
170
+ message: "X-Robots-Tag header prevents indexing but allows following links",
171
+ details: {content: content}
172
+ )
173
+ end
174
+
175
+ def report_noindex_nofollow_header(page, issues, content)
176
+ issues.add(
177
+ code: :noindex_nofollow_header,
178
+ severity: :error,
179
+ category: :indexability,
180
+ url: page.url,
181
+ message: "X-Robots-Tag header prevents indexing and following links",
182
+ details: {content: content}
183
+ )
184
+ end
185
+
186
+ def report_sitemap_noindex_url(page, issues, content, source:, sitemap_urls:)
187
+ normalized_url = Url.normalize(page.url, base_url: page.url)
188
+ return unless sitemap_urls.include?(normalized_url)
189
+
190
+ issues.add(
191
+ code: :sitemap_noindex_url,
192
+ severity: :error,
193
+ category: :sitemaps,
194
+ url: page.url,
195
+ message: "sitemap URL is noindex",
196
+ details: {content: content, source: source}
197
+ )
63
198
  end
64
199
  end
65
200
  end