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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +36 -0
- data/README.md +38 -0
- data/lib/crawlscope/cli.rb +20 -1
- data/lib/crawlscope/configuration.rb +10 -1
- data/lib/crawlscope/context.rb +1 -1
- data/lib/crawlscope/crawl.rb +74 -14
- data/lib/crawlscope/crawler.rb +3 -17
- data/lib/crawlscope/document_text.rb +7 -2
- data/lib/crawlscope/fetch_executor/async.rb +32 -0
- data/lib/crawlscope/fetch_executor/threaded.rb +32 -0
- data/lib/crawlscope/fetch_executor.rb +43 -0
- data/lib/crawlscope/http.rb +7 -1
- data/lib/crawlscope/rake_tasks.rb +27 -12
- data/lib/crawlscope/reporter.rb +131 -7
- data/lib/crawlscope/result.rb +1 -1
- data/lib/crawlscope/rules/content_quality.rb +1 -1
- data/lib/crawlscope/rules/indexability.rb +155 -20
- data/lib/crawlscope/rules/links.rb +379 -12
- data/lib/crawlscope/rules/metadata.rb +61 -6
- data/lib/crawlscope/rules/structured_data.rb +31 -0
- data/lib/crawlscope/rules/uniqueness.rb +45 -4
- data/lib/crawlscope/sitemap.rb +39 -12
- data/lib/crawlscope/version.rb +1 -1
- data/lib/tasks/crawlscope_tasks.rake +24 -24
- data/test/crawlscope/cli_test.rb +29 -2
- data/test/crawlscope/configuration_test.rb +21 -0
- data/test/crawlscope/content_quality_rule_test.rb +18 -0
- data/test/crawlscope/crawl_test.rb +167 -3
- data/test/crawlscope/crawler_test.rb +61 -0
- data/test/crawlscope/fetch_executor_test.rb +44 -0
- data/test/crawlscope/indexability_rule_test.rb +33 -0
- data/test/crawlscope/links_rule_test.rb +249 -3
- data/test/crawlscope/metadata_rule_test.rb +36 -0
- data/test/crawlscope/rake_tasks_test.rb +70 -0
- data/test/crawlscope/reporter_test.rb +136 -7
- data/test/crawlscope/result_test.rb +35 -0
- data/test/crawlscope/sitemap_test.rb +76 -0
- data/test/crawlscope/structured_data_rule_test.rb +56 -0
- data/test/crawlscope/uniqueness_rule_test.rb +17 -2
- data/test/performance/async_fetch_benchmark.rb +127 -0
- data/test/performance/fetch_executor_matrix.rb +162 -0
- data/test/performance/sitemap_expansion_benchmark.rb +121 -0
- metadata +39 -2
data/lib/crawlscope/reporter.rb
CHANGED
|
@@ -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.
|
|
20
|
+
if result.issues.size.zero?
|
|
17
21
|
@io.puts("Status: OK")
|
|
18
22
|
return
|
|
19
23
|
end
|
|
20
24
|
|
|
21
|
-
@io.puts("Status:
|
|
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
|
-
|
|
25
|
-
|
|
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
|
-
|
|
29
|
-
|
|
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
|
data/lib/crawlscope/result.rb
CHANGED
|
@@ -55,7 +55,7 @@ module Crawlscope
|
|
|
55
55
|
end
|
|
56
56
|
|
|
57
57
|
def validate_visible_text_ratio(page, issues)
|
|
58
|
-
html_bytes = DocumentText.
|
|
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
|
|
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
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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
|
|
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
|
-
|
|
39
|
-
|
|
40
|
-
issues.
|
|
41
|
-
|
|
42
|
-
|
|
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
|
|
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
|