crawlscope 0.3.0 → 0.5.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 +73 -0
- data/README.md +50 -7
- data/lib/crawlscope/cli.rb +4 -1
- data/lib/crawlscope/crawl.rb +2 -0
- data/lib/crawlscope/document_text.rb +40 -0
- data/lib/crawlscope/rake_tasks.rb +27 -12
- data/lib/crawlscope/reporter.rb +20 -5
- data/lib/crawlscope/rule_registry.rb +3 -1
- data/lib/crawlscope/rules/content_quality.rb +99 -0
- data/lib/crawlscope/rules/indexability.rb +179 -0
- data/lib/crawlscope/rules/links.rb +312 -9
- data/lib/crawlscope/rules/metadata.rb +61 -6
- data/lib/crawlscope/rules/structured_data.rb +31 -0
- data/lib/crawlscope/rules/uniqueness.rb +98 -4
- data/lib/crawlscope/sitemap.rb +9 -1
- data/lib/crawlscope/version.rb +1 -1
- data/lib/tasks/crawlscope_tasks.rake +28 -18
- data/test/crawlscope/cli_test.rb +1 -0
- data/test/crawlscope/configuration_test.rb +8 -1
- data/test/crawlscope/content_quality_rule_test.rb +68 -0
- data/test/crawlscope/crawl_test.rb +37 -1
- data/test/crawlscope/indexability_rule_test.rb +129 -0
- data/test/crawlscope/links_rule_test.rb +148 -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 +7 -3
- data/test/crawlscope/sitemap_test.rb +24 -0
- data/test/crawlscope/structured_data_rule_test.rb +56 -0
- data/test/crawlscope/uniqueness_rule_test.rb +59 -3
- data/test/release_task_test.rb +86 -0
- metadata +9 -2
data/lib/crawlscope/sitemap.rb
CHANGED
|
@@ -25,6 +25,9 @@ module Crawlscope
|
|
|
25
25
|
visited.add(source)
|
|
26
26
|
document = Nokogiri::XML(read(source))
|
|
27
27
|
root_name = document.root&.name
|
|
28
|
+
unless %w[sitemapindex urlset].include?(root_name)
|
|
29
|
+
raise ValidationError, "Sitemap #{source} has unexpected root #{root_name.inspect}"
|
|
30
|
+
end
|
|
28
31
|
|
|
29
32
|
if root_name == "sitemapindex"
|
|
30
33
|
document.xpath("//xmlns:sitemap/xmlns:loc", SITEMAP_NAMESPACE).flat_map do |node|
|
|
@@ -40,7 +43,12 @@ module Crawlscope
|
|
|
40
43
|
|
|
41
44
|
def read(source)
|
|
42
45
|
if Url.remote?(source)
|
|
43
|
-
connection.get(source)
|
|
46
|
+
response = connection.get(source)
|
|
47
|
+
unless response.status.to_i.between?(200, 299)
|
|
48
|
+
raise ValidationError, "Sitemap #{source} returned HTTP #{response.status}"
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
response.body
|
|
44
52
|
else
|
|
45
53
|
File.read(source)
|
|
46
54
|
end
|
data/lib/crawlscope/version.rb
CHANGED
|
@@ -1,33 +1,43 @@
|
|
|
1
1
|
namespace :crawlscope do
|
|
2
|
-
desc "Validate URLs with all default Crawlscope rules. ENV: URL, SITEMAP, RULES, JS=1, TIMEOUT, NETWORK_IDLE_TIMEOUT, CONCURRENCY"
|
|
3
|
-
task validate: :environment do
|
|
4
|
-
Crawlscope::RakeTasks.validate
|
|
2
|
+
desc "Validate URLs with all default Crawlscope rules. Args: [url,sitemap,rules]. ENV: URL, SITEMAP, RULES, JS=1, TIMEOUT, NETWORK_IDLE_TIMEOUT, CONCURRENCY"
|
|
3
|
+
task :validate, [:url, :sitemap, :rules] => :environment do |_task, args|
|
|
4
|
+
Crawlscope::RakeTasks.validate(url: args[:url], sitemap_path: args[:sitemap], rule_names: args[:rules])
|
|
5
5
|
end
|
|
6
6
|
|
|
7
7
|
namespace :validate do
|
|
8
|
-
desc "Directly validate JSON-LD on one
|
|
9
|
-
task ldjson: :environment do
|
|
10
|
-
Crawlscope::RakeTasks.ldjson
|
|
8
|
+
desc "Directly validate JSON-LD on one URL. Args: [url]. ENV: URL (semicolon-separated), DEBUG=1, JS=1, TIMEOUT, NETWORK_IDLE_TIMEOUT, REPORT_PATH, SUMMARY=1"
|
|
9
|
+
task :ldjson, [:url] => :environment do |_task, args|
|
|
10
|
+
Crawlscope::RakeTasks.ldjson(urls: args[:url])
|
|
11
11
|
end
|
|
12
12
|
|
|
13
|
-
desc "Validate URLs with the
|
|
14
|
-
task
|
|
15
|
-
Crawlscope::RakeTasks.validate_rule("
|
|
13
|
+
desc "Validate URLs with the indexability rule. Args: [url,sitemap]. ENV: URL, SITEMAP, JS=1"
|
|
14
|
+
task :indexability, [:url, :sitemap] => :environment do |_task, args|
|
|
15
|
+
Crawlscope::RakeTasks.validate_rule("indexability", url: args[:url], sitemap_path: args[:sitemap])
|
|
16
16
|
end
|
|
17
17
|
|
|
18
|
-
desc "Validate
|
|
19
|
-
task
|
|
20
|
-
Crawlscope::RakeTasks.validate_rule("
|
|
18
|
+
desc "Validate URLs with the metadata rule. Args: [url,sitemap]. ENV: URL, SITEMAP, JS=1"
|
|
19
|
+
task :metadata, [:url, :sitemap] => :environment do |_task, args|
|
|
20
|
+
Crawlscope::RakeTasks.validate_rule("metadata", url: args[:url], sitemap_path: args[:sitemap])
|
|
21
21
|
end
|
|
22
22
|
|
|
23
|
-
desc "Validate URLs with the
|
|
24
|
-
task
|
|
25
|
-
Crawlscope::RakeTasks.validate_rule("
|
|
23
|
+
desc "Validate sitemap URLs with the structured_data rule. Args: [url,sitemap]. ENV: URL, SITEMAP, JS=1"
|
|
24
|
+
task :structured_data, [:url, :sitemap] => :environment do |_task, args|
|
|
25
|
+
Crawlscope::RakeTasks.validate_rule("structured_data", url: args[:url], sitemap_path: args[:sitemap])
|
|
26
26
|
end
|
|
27
27
|
|
|
28
|
-
desc "Validate URLs with the
|
|
29
|
-
task
|
|
30
|
-
Crawlscope::RakeTasks.validate_rule("
|
|
28
|
+
desc "Validate URLs with the uniqueness rule. Args: [url,sitemap]. ENV: URL, SITEMAP, JS=1"
|
|
29
|
+
task :uniqueness, [:url, :sitemap] => :environment do |_task, args|
|
|
30
|
+
Crawlscope::RakeTasks.validate_rule("uniqueness", url: args[:url], sitemap_path: args[:sitemap])
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
desc "Validate URLs with the content_quality rule. Args: [url,sitemap]. ENV: URL, SITEMAP, JS=1"
|
|
34
|
+
task :content_quality, [:url, :sitemap] => :environment do |_task, args|
|
|
35
|
+
Crawlscope::RakeTasks.validate_rule("content_quality", url: args[:url], sitemap_path: args[:sitemap])
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
desc "Validate URLs with the links rule. Args: [url,sitemap]. ENV: URL, SITEMAP, JS=1"
|
|
39
|
+
task :links, [:url, :sitemap] => :environment do |_task, args|
|
|
40
|
+
Crawlscope::RakeTasks.validate_rule("links", url: args[:url], sitemap_path: args[:sitemap])
|
|
31
41
|
end
|
|
32
42
|
end
|
|
33
43
|
end
|
data/test/crawlscope/cli_test.rb
CHANGED
|
@@ -20,7 +20,14 @@ class CrawlscopeConfigurationTest < Minitest::Test
|
|
|
20
20
|
assert_equal "https://example.com", audit.instance_variable_get(:@base_url)
|
|
21
21
|
assert_equal "/tmp/sitemap.xml", audit.instance_variable_get(:@sitemap_path)
|
|
22
22
|
assert_equal 4, audit.instance_variable_get(:@concurrency)
|
|
23
|
-
assert_equal %i[
|
|
23
|
+
assert_equal %i[
|
|
24
|
+
indexability
|
|
25
|
+
metadata
|
|
26
|
+
structured_data
|
|
27
|
+
uniqueness
|
|
28
|
+
content_quality
|
|
29
|
+
links
|
|
30
|
+
], audit.instance_variable_get(:@rules).map(&:code)
|
|
24
31
|
end
|
|
25
32
|
|
|
26
33
|
def test_audit_raises_without_base_url
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "test_helper"
|
|
4
|
+
|
|
5
|
+
class CrawlscopeContentQualityRuleTest < Minitest::Test
|
|
6
|
+
def test_reports_thin_visible_text_and_low_html_text_ratio
|
|
7
|
+
issues = Crawlscope::IssueCollection.new
|
|
8
|
+
page = page_with(main: "Short page <div>#{"<span></span>" * 500}</div>")
|
|
9
|
+
|
|
10
|
+
Crawlscope::Rules::ContentQuality.new.call(urls: [page.url], pages: [page], issues: issues)
|
|
11
|
+
|
|
12
|
+
codes = issues.to_a.map(&:code)
|
|
13
|
+
assert_includes codes, :thin_visible_text
|
|
14
|
+
assert_includes codes, :low_visible_text_ratio
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def test_visible_text_ratio_ignores_markup_outside_main_content
|
|
18
|
+
issues = Crawlscope::IssueCollection.new
|
|
19
|
+
page = page_with(
|
|
20
|
+
main: Array.new(260) { |index| "word#{index}" }.join(" "),
|
|
21
|
+
head_markup: "<style>#{"body{}" * 10_000}</style>",
|
|
22
|
+
extra_markup: "<nav>#{"<a href=\"/\">Navigation</a>" * 500}</nav>"
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
Crawlscope::Rules::ContentQuality.new.call(urls: [page.url], pages: [page], issues: issues)
|
|
26
|
+
|
|
27
|
+
refute_includes issues.to_a.map(&:code), :low_visible_text_ratio
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def test_reports_low_unique_token_ratio_for_repetitive_content
|
|
31
|
+
issues = Crawlscope::IssueCollection.new
|
|
32
|
+
page = page_with(main: ("hotel location service " * 100).strip)
|
|
33
|
+
|
|
34
|
+
Crawlscope::Rules::ContentQuality.new.call(urls: [page.url], pages: [page], issues: issues)
|
|
35
|
+
|
|
36
|
+
issue = issues.to_a.find { |item| item.code == :low_unique_token_ratio }
|
|
37
|
+
assert issue
|
|
38
|
+
assert_operator issue.details[:ratio], :<, issue.details[:threshold]
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
private
|
|
42
|
+
|
|
43
|
+
def page_with(main:, extra_markup: "", head_markup: "")
|
|
44
|
+
body = <<~HTML
|
|
45
|
+
<html>
|
|
46
|
+
<head>
|
|
47
|
+
<title>Content quality</title>
|
|
48
|
+
#{head_markup}
|
|
49
|
+
</head>
|
|
50
|
+
<body>
|
|
51
|
+
#{extra_markup}
|
|
52
|
+
<main>#{main}</main>
|
|
53
|
+
</body>
|
|
54
|
+
</html>
|
|
55
|
+
HTML
|
|
56
|
+
|
|
57
|
+
Crawlscope::Page.new(
|
|
58
|
+
url: "https://example.com/page",
|
|
59
|
+
normalized_url: "https://example.com/page",
|
|
60
|
+
final_url: "https://example.com/page",
|
|
61
|
+
normalized_final_url: "https://example.com/page",
|
|
62
|
+
status: 200,
|
|
63
|
+
headers: {"content-type" => "text/html"},
|
|
64
|
+
body: body,
|
|
65
|
+
doc: Nokogiri::HTML(body)
|
|
66
|
+
)
|
|
67
|
+
end
|
|
68
|
+
end
|
|
@@ -45,6 +45,7 @@ class CrawlscopeCrawlTest < Minitest::Test
|
|
|
45
45
|
<body>
|
|
46
46
|
<main>
|
|
47
47
|
<h1>Pricing</h1>
|
|
48
|
+
<p>#{Array.new(260) { |index| "pricing#{index}" }.join(" ")}</p>
|
|
48
49
|
</main>
|
|
49
50
|
</body>
|
|
50
51
|
</html>
|
|
@@ -100,7 +101,15 @@ class CrawlscopeCrawlTest < Minitest::Test
|
|
|
100
101
|
).call
|
|
101
102
|
|
|
102
103
|
refute result.ok?
|
|
103
|
-
assert_equal %i[
|
|
104
|
+
assert_equal %i[
|
|
105
|
+
incomplete_open_graph_tags
|
|
106
|
+
meta_description_too_long
|
|
107
|
+
missing_canonical
|
|
108
|
+
missing_h1
|
|
109
|
+
missing_structured_data
|
|
110
|
+
thin_visible_text
|
|
111
|
+
title_repeats_site_name
|
|
112
|
+
].sort, result.issues.to_a.map(&:code).uniq.sort
|
|
104
113
|
end
|
|
105
114
|
|
|
106
115
|
def test_uses_browser_when_renderer_is_browser
|
|
@@ -147,6 +156,7 @@ class CrawlscopeCrawlTest < Minitest::Test
|
|
|
147
156
|
<body>
|
|
148
157
|
<main>
|
|
149
158
|
<h1>Pricing</h1>
|
|
159
|
+
<p>#{Array.new(260) { |index| "pricing#{index}" }.join(" ")}</p>
|
|
150
160
|
</main>
|
|
151
161
|
</body>
|
|
152
162
|
</html>
|
|
@@ -178,4 +188,30 @@ class CrawlscopeCrawlTest < Minitest::Test
|
|
|
178
188
|
assert_equal ["https://example.com/pricing"], fake_browser.urls
|
|
179
189
|
assert fake_browser.closed
|
|
180
190
|
end
|
|
191
|
+
|
|
192
|
+
def test_reports_sitemap_redirect_url
|
|
193
|
+
File.write(
|
|
194
|
+
@sitemap_path,
|
|
195
|
+
<<~XML
|
|
196
|
+
<?xml version="1.0" encoding="UTF-8"?>
|
|
197
|
+
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
|
198
|
+
<url><loc>https://example.com/old</loc></url>
|
|
199
|
+
</urlset>
|
|
200
|
+
XML
|
|
201
|
+
)
|
|
202
|
+
|
|
203
|
+
stub_request(:get, "https://example.com/old")
|
|
204
|
+
.to_return(status: 301, headers: {"Location" => "https://example.com/new"}, body: "")
|
|
205
|
+
stub_request(:get, "https://example.com/new")
|
|
206
|
+
.to_return(status: 200, headers: {"Content-Type" => "text/html"}, body: "<html><body>Moved</body></html>")
|
|
207
|
+
|
|
208
|
+
result = Crawlscope::Crawl.new(
|
|
209
|
+
base_url: "https://example.com",
|
|
210
|
+
sitemap_path: @sitemap_path,
|
|
211
|
+
rules: [],
|
|
212
|
+
schema_registry: Crawlscope::SchemaRegistry.default
|
|
213
|
+
).call
|
|
214
|
+
|
|
215
|
+
assert_includes result.issues.to_a.map(&:code), :sitemap_redirect_url
|
|
216
|
+
end
|
|
181
217
|
end
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "test_helper"
|
|
4
|
+
|
|
5
|
+
class CrawlscopeIndexabilityRuleTest < Minitest::Test
|
|
6
|
+
def test_reports_meta_noindex
|
|
7
|
+
issues = Crawlscope::IssueCollection.new
|
|
8
|
+
page = page_with(
|
|
9
|
+
body: <<~HTML
|
|
10
|
+
<html>
|
|
11
|
+
<head><meta name="robots" content="noindex, follow"></head>
|
|
12
|
+
<body><main>Visible content</main></body>
|
|
13
|
+
</html>
|
|
14
|
+
HTML
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
Crawlscope::Rules::Indexability.new.call(urls: [page.url], pages: [page], issues: issues)
|
|
18
|
+
|
|
19
|
+
issue = issues.to_a.fetch(0)
|
|
20
|
+
assert_equal :noindex_meta, issue.code
|
|
21
|
+
assert_equal :error, issue.severity
|
|
22
|
+
assert_equal "noindex, follow", issue.details[:content]
|
|
23
|
+
|
|
24
|
+
codes = issues.to_a.map(&:code)
|
|
25
|
+
assert_includes codes, :noindex_follow_meta
|
|
26
|
+
assert_includes codes, :sitemap_noindex_url
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def test_reports_meta_nofollow
|
|
30
|
+
issues = Crawlscope::IssueCollection.new
|
|
31
|
+
page = page_with(
|
|
32
|
+
body: <<~HTML
|
|
33
|
+
<html>
|
|
34
|
+
<head><meta name="robots" content="nofollow"></head>
|
|
35
|
+
<body><main>Visible content</main></body>
|
|
36
|
+
</html>
|
|
37
|
+
HTML
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
Crawlscope::Rules::Indexability.new.call(urls: [page.url], pages: [page], issues: issues)
|
|
41
|
+
|
|
42
|
+
assert_equal [:nofollow_meta], issues.to_a.map(&:code)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def test_reports_noindex_nofollow_header
|
|
46
|
+
issues = Crawlscope::IssueCollection.new
|
|
47
|
+
page = page_with(headers: {"X-Robots-Tag" => "googlebot: noindex, nofollow"})
|
|
48
|
+
|
|
49
|
+
Crawlscope::Rules::Indexability.new.call(urls: [page.url], pages: [page], issues: issues)
|
|
50
|
+
|
|
51
|
+
codes = issues.to_a.map(&:code)
|
|
52
|
+
assert_includes codes, :noindex_header
|
|
53
|
+
assert_includes codes, :nofollow_header
|
|
54
|
+
assert_includes codes, :noindex_nofollow_header
|
|
55
|
+
assert_includes codes, :sitemap_noindex_url
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def test_reports_x_robots_tag_noindex
|
|
59
|
+
issues = Crawlscope::IssueCollection.new
|
|
60
|
+
page = page_with(headers: {"X-Robots-Tag" => "noindex"})
|
|
61
|
+
|
|
62
|
+
Crawlscope::Rules::Indexability.new.call(urls: [page.url], pages: [page], issues: issues)
|
|
63
|
+
|
|
64
|
+
issue = issues.to_a.fetch(0)
|
|
65
|
+
assert_equal :noindex_header, issue.code
|
|
66
|
+
assert_equal :error, issue.severity
|
|
67
|
+
assert_equal "noindex", issue.details[:content]
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def test_reports_x_robots_tag_noindex_for_non_html_response
|
|
71
|
+
issues = Crawlscope::IssueCollection.new
|
|
72
|
+
page = page_with(
|
|
73
|
+
body: "%PDF-1.7",
|
|
74
|
+
doc: nil,
|
|
75
|
+
headers: {"content-type" => "application/pdf", "X-Robots-Tag" => "noindex"}
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
Crawlscope::Rules::Indexability.new.call(urls: [page.url], pages: [page], issues: issues)
|
|
79
|
+
|
|
80
|
+
issue = issues.to_a.fetch(0)
|
|
81
|
+
assert_equal :noindex_header, issue.code
|
|
82
|
+
assert_equal :error, issue.severity
|
|
83
|
+
assert_equal "noindex", issue.details[:content]
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def test_reports_scoped_x_robots_tag_noindex
|
|
87
|
+
issues = Crawlscope::IssueCollection.new
|
|
88
|
+
page = page_with(headers: {"X-Robots-Tag" => "googlebot: noindex, nofollow"})
|
|
89
|
+
|
|
90
|
+
Crawlscope::Rules::Indexability.new.call(urls: [page.url], pages: [page], issues: issues)
|
|
91
|
+
|
|
92
|
+
issue = issues.to_a.fetch(0)
|
|
93
|
+
assert_equal :noindex_header, issue.code
|
|
94
|
+
assert_equal "googlebot: noindex, nofollow", issue.details[:content]
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def test_reports_x_robots_tag_none
|
|
98
|
+
issues = Crawlscope::IssueCollection.new
|
|
99
|
+
page = page_with(headers: {"X-Robots-Tag" => "none"})
|
|
100
|
+
|
|
101
|
+
Crawlscope::Rules::Indexability.new.call(urls: [page.url], pages: [page], issues: issues)
|
|
102
|
+
|
|
103
|
+
issue = issues.to_a.fetch(0)
|
|
104
|
+
assert_equal :noindex_header, issue.code
|
|
105
|
+
assert_equal "none", issue.details[:content]
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
private
|
|
109
|
+
|
|
110
|
+
def page_with(body: nil, doc: :parse, headers: {"content-type" => "text/html"})
|
|
111
|
+
body ||= <<~HTML
|
|
112
|
+
<html>
|
|
113
|
+
<head><title>Indexable</title></head>
|
|
114
|
+
<body><main>Visible content</main></body>
|
|
115
|
+
</html>
|
|
116
|
+
HTML
|
|
117
|
+
|
|
118
|
+
Crawlscope::Page.new(
|
|
119
|
+
url: "https://example.com/page",
|
|
120
|
+
normalized_url: "https://example.com/page",
|
|
121
|
+
final_url: "https://example.com/page",
|
|
122
|
+
normalized_final_url: "https://example.com/page",
|
|
123
|
+
status: 200,
|
|
124
|
+
headers: headers,
|
|
125
|
+
body: body,
|
|
126
|
+
doc: (doc == :parse) ? Nokogiri::HTML(body) : doc
|
|
127
|
+
)
|
|
128
|
+
end
|
|
129
|
+
end
|
|
@@ -41,7 +41,7 @@ class CrawlscopeLinksRuleTest < Minitest::Test
|
|
|
41
41
|
context: context
|
|
42
42
|
)
|
|
43
43
|
|
|
44
|
-
|
|
44
|
+
assert_includes issues.to_a.map(&:code), :broken_internal_link
|
|
45
45
|
assert_includes issues.to_a.first.message, "HTTP 404"
|
|
46
46
|
end
|
|
47
47
|
|
|
@@ -114,8 +114,151 @@ class CrawlscopeLinksRuleTest < Minitest::Test
|
|
|
114
114
|
context: context
|
|
115
115
|
)
|
|
116
116
|
|
|
117
|
-
|
|
118
|
-
|
|
117
|
+
orphan_issue = issues.to_a.find { |item| item.code == :orphan_page }
|
|
118
|
+
assert orphan_issue
|
|
119
|
+
assert_includes issues.to_a.map(&:code), :low_dofollow_inlinks
|
|
120
|
+
assert_equal "https://example.com/guide", orphan_issue.url
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def test_reports_pages_with_no_outgoing_internal_links
|
|
124
|
+
issues = Crawlscope::IssueCollection.new
|
|
125
|
+
|
|
126
|
+
Crawlscope::Rules::Links.new.call(
|
|
127
|
+
urls: ["https://example.com/guide", "https://example.com/pricing"],
|
|
128
|
+
pages: [
|
|
129
|
+
page(url: "https://example.com/guide", body: "<main><a href=\"/pricing\">Pricing</a></main>"),
|
|
130
|
+
page(url: "https://example.com/pricing", body: "<main><p>Pricing</p></main>")
|
|
131
|
+
],
|
|
132
|
+
issues: issues,
|
|
133
|
+
context: context
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
issue = issues.to_a.find { |item| item.code == :page_has_no_outgoing_links }
|
|
137
|
+
assert issue
|
|
138
|
+
assert_equal "https://example.com/pricing", issue.url
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def test_reports_nofollow_outlinks_and_inlink_follow_mix
|
|
142
|
+
issues = Crawlscope::IssueCollection.new
|
|
143
|
+
|
|
144
|
+
Crawlscope::Rules::Links.new.call(
|
|
145
|
+
urls: ["https://example.com/guide", "https://example.com/pricing", "https://example.com/about"],
|
|
146
|
+
pages: [
|
|
147
|
+
page(url: "https://example.com/guide", body: "<main><a href=\"/pricing\" rel=\"nofollow\">Pricing</a><a href=\"/about\">About</a></main>"),
|
|
148
|
+
page(url: "https://example.com/about", body: "<main><a href=\"/pricing\">Pricing</a></main>"),
|
|
149
|
+
page(url: "https://example.com/pricing", body: "<main><p>Pricing</p></main>")
|
|
150
|
+
],
|
|
151
|
+
issues: issues,
|
|
152
|
+
context: context(resolver: ->(target_url) { {crawled: true, error: nil, final_url: target_url, status: 200} })
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
codes = issues.to_a.map(&:code)
|
|
156
|
+
assert_includes codes, :nofollow_internal_outlinks
|
|
157
|
+
assert_includes codes, :mixed_follow_internal_inlinks
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def test_reports_only_nofollow_internal_inlinks
|
|
161
|
+
issues = Crawlscope::IssueCollection.new
|
|
162
|
+
|
|
163
|
+
Crawlscope::Rules::Links.new.call(
|
|
164
|
+
urls: ["https://example.com/guide", "https://example.com/pricing"],
|
|
165
|
+
pages: [
|
|
166
|
+
page(url: "https://example.com/guide", body: "<main><a href=\"/pricing\" rel=\"nofollow\">Pricing</a></main>"),
|
|
167
|
+
page(url: "https://example.com/pricing", body: "<main><p>Pricing</p></main>")
|
|
168
|
+
],
|
|
169
|
+
issues: issues,
|
|
170
|
+
context: context(resolver: ->(target_url) { {crawled: true, error: nil, final_url: target_url, status: 200} })
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
assert_includes issues.to_a.map(&:code), :only_nofollow_internal_inlinks
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def test_reports_https_pages_linking_to_internal_http_urls
|
|
177
|
+
issues = Crawlscope::IssueCollection.new
|
|
178
|
+
|
|
179
|
+
Crawlscope::Rules::Links.new.call(
|
|
180
|
+
urls: ["https://example.com/guide"],
|
|
181
|
+
pages: [page(url: "https://example.com/guide", body: "<main><a href=\"http://example.com/pricing\">Pricing</a></main>")],
|
|
182
|
+
issues: issues,
|
|
183
|
+
context: context(resolver: ->(target_url) { {crawled: true, error: nil, final_url: target_url, status: 200} })
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
assert_includes issues.to_a.map(&:code), :http_internal_link
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
def test_reports_canonical_target_link_issues
|
|
190
|
+
issues = Crawlscope::IssueCollection.new
|
|
191
|
+
resolver = lambda do |target_url|
|
|
192
|
+
redirects = target_url == "https://example.com/canonical-about"
|
|
193
|
+
status = redirects ? 301 : 200
|
|
194
|
+
final_url = redirects ? "https://example.com/about" : target_url
|
|
195
|
+
{crawled: false, error: nil, final_url: final_url, status: status}
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
Crawlscope::Rules::Links.new.call(
|
|
199
|
+
urls: ["https://example.com/guide", "https://example.com/about"],
|
|
200
|
+
pages: [
|
|
201
|
+
page(url: "https://example.com/guide", body: "<main><a href=\"/about\">About</a></main>"),
|
|
202
|
+
page(
|
|
203
|
+
url: "https://example.com/about",
|
|
204
|
+
body: <<~HTML
|
|
205
|
+
<html>
|
|
206
|
+
<head><link rel="canonical" href="https://example.com/canonical-about"></head>
|
|
207
|
+
<body><main><p>About</p></main></body>
|
|
208
|
+
</html>
|
|
209
|
+
HTML
|
|
210
|
+
)
|
|
211
|
+
],
|
|
212
|
+
issues: issues,
|
|
213
|
+
context: context(resolver: resolver)
|
|
214
|
+
)
|
|
215
|
+
|
|
216
|
+
codes = issues.to_a.map(&:code)
|
|
217
|
+
assert_includes codes, :canonical_no_internal_inlinks
|
|
218
|
+
assert_includes codes, :canonical_points_to_redirect
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
def test_reports_indexable_internal_pages_missing_from_sitemap
|
|
222
|
+
issues = Crawlscope::IssueCollection.new
|
|
223
|
+
resolver = lambda do |target_url|
|
|
224
|
+
{
|
|
225
|
+
crawled: false,
|
|
226
|
+
error: nil,
|
|
227
|
+
final_url: target_url,
|
|
228
|
+
html: true,
|
|
229
|
+
status: 200
|
|
230
|
+
}
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
Crawlscope::Rules::Links.new.call(
|
|
234
|
+
urls: ["https://example.com/guide"],
|
|
235
|
+
pages: [page(url: "https://example.com/guide", body: "<main><a href=\"/hidden\">Hidden</a></main>")],
|
|
236
|
+
issues: issues,
|
|
237
|
+
context: context(resolver: resolver)
|
|
238
|
+
)
|
|
239
|
+
|
|
240
|
+
issue = issues.to_a.find { |item| item.code == :indexable_page_missing_from_sitemap }
|
|
241
|
+
assert issue
|
|
242
|
+
assert_equal "https://example.com/hidden", issue.url
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
def test_reports_url_hygiene_issues
|
|
246
|
+
issues = Crawlscope::IssueCollection.new
|
|
247
|
+
long_path = "a" * 2_050
|
|
248
|
+
|
|
249
|
+
Crawlscope::Rules::Links.new.call(
|
|
250
|
+
urls: ["https://example.com//bad", "https://example.com/#{long_path}"],
|
|
251
|
+
pages: [
|
|
252
|
+
page(url: "https://example.com//bad", body: "<main><a href=\"/ok\">OK</a></main>"),
|
|
253
|
+
page(url: "https://example.com/#{long_path}", body: "<main><a href=\"/ok\">OK</a></main>")
|
|
254
|
+
],
|
|
255
|
+
issues: issues,
|
|
256
|
+
context: context(resolver: ->(target_url) { {crawled: false, error: nil, final_url: target_url, html: true, status: 200} })
|
|
257
|
+
)
|
|
258
|
+
|
|
259
|
+
codes = issues.to_a.map(&:code)
|
|
260
|
+
assert_includes codes, :url_double_slash
|
|
261
|
+
assert_includes codes, :url_too_long
|
|
119
262
|
end
|
|
120
263
|
|
|
121
264
|
def test_counts_root_page_links_as_inbound_links
|
|
@@ -217,6 +360,7 @@ class CrawlscopeLinksRuleTest < Minitest::Test
|
|
|
217
360
|
crawled: true,
|
|
218
361
|
error: nil,
|
|
219
362
|
final_url: target_url,
|
|
363
|
+
html: true,
|
|
220
364
|
status: 200
|
|
221
365
|
}
|
|
222
366
|
when "https://example.com/missing"
|
|
@@ -224,6 +368,7 @@ class CrawlscopeLinksRuleTest < Minitest::Test
|
|
|
224
368
|
crawled: false,
|
|
225
369
|
error: nil,
|
|
226
370
|
final_url: target_url,
|
|
371
|
+
html: false,
|
|
227
372
|
status: 404
|
|
228
373
|
}
|
|
229
374
|
end
|
|
@@ -48,6 +48,42 @@ class CrawlscopeMetadataRuleTest < Minitest::Test
|
|
|
48
48
|
refute_includes issues.to_a.map(&:code), :canonical_mismatch
|
|
49
49
|
end
|
|
50
50
|
|
|
51
|
+
def test_reports_multiple_title_multiple_descriptions_empty_h1_and_sitemap_canonical_mismatch
|
|
52
|
+
issues = Crawlscope::IssueCollection.new
|
|
53
|
+
invalid_page = page(
|
|
54
|
+
body: <<~HTML
|
|
55
|
+
<html>
|
|
56
|
+
<head>
|
|
57
|
+
<title>About</title>
|
|
58
|
+
<title>Duplicate About</title>
|
|
59
|
+
<meta name="description" content="A clear description that is long enough for search snippets, local validation checks, and realistic production metadata audits.">
|
|
60
|
+
<meta name="description" content="Duplicate description">
|
|
61
|
+
<link rel="canonical" href="https://example.com/canonical-about">
|
|
62
|
+
<meta property="og:title" content="About">
|
|
63
|
+
<meta property="og:description" content="About page">
|
|
64
|
+
<meta property="og:url" content="https://example.com/about">
|
|
65
|
+
<meta property="og:type" content="website">
|
|
66
|
+
<meta property="og:image" content="https://example.com/icon.png">
|
|
67
|
+
</head>
|
|
68
|
+
<body><main><h1> </h1></main></body>
|
|
69
|
+
</html>
|
|
70
|
+
HTML
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
Crawlscope::Rules::Metadata.new.call(
|
|
74
|
+
urls: [invalid_page.url],
|
|
75
|
+
pages: [invalid_page],
|
|
76
|
+
issues: issues
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
codes = issues.to_a.map(&:code)
|
|
80
|
+
assert_includes codes, :multiple_title_tags
|
|
81
|
+
assert_includes codes, :multiple_meta_descriptions
|
|
82
|
+
assert_includes codes, :empty_h1
|
|
83
|
+
assert_includes codes, :canonical_mismatch
|
|
84
|
+
assert_includes codes, :non_canonical_page_in_sitemap
|
|
85
|
+
end
|
|
86
|
+
|
|
51
87
|
private
|
|
52
88
|
|
|
53
89
|
def page(url: "https://example.com/about", body: nil)
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "test_helper"
|
|
4
|
+
|
|
5
|
+
class CrawlscopeRakeTasksTest < Minitest::Test
|
|
6
|
+
def setup
|
|
7
|
+
@original_start = Crawlscope::Cli.method(:start)
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def teardown
|
|
11
|
+
singleton_class = class << Crawlscope::Cli; self; end
|
|
12
|
+
original_start = @original_start
|
|
13
|
+
singleton_class.define_method(:start) do |*args, **kwargs|
|
|
14
|
+
original_start.call(*args, **kwargs)
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def test_validate_passes_rake_arguments_to_cli
|
|
19
|
+
calls = capture_cli_calls
|
|
20
|
+
|
|
21
|
+
Crawlscope::RakeTasks.validate(
|
|
22
|
+
url: "http://localhost:3001",
|
|
23
|
+
sitemap_path: "http://localhost:3001/sitemap.xml",
|
|
24
|
+
rule_names: "metadata,links"
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
assert_equal(
|
|
28
|
+
["validate", "--url", "http://localhost:3001", "--sitemap", "http://localhost:3001/sitemap.xml", "--rules", "metadata,links"],
|
|
29
|
+
calls.fetch(0).fetch(:argv)
|
|
30
|
+
)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def test_validate_rule_passes_rule_and_rake_arguments_to_cli
|
|
34
|
+
calls = capture_cli_calls
|
|
35
|
+
|
|
36
|
+
Crawlscope::RakeTasks.validate_rule(
|
|
37
|
+
"metadata",
|
|
38
|
+
url: "http://localhost:3001",
|
|
39
|
+
sitemap_path: "http://localhost:3001/sitemap.xml"
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
assert_equal(
|
|
43
|
+
["validate", "--url", "http://localhost:3001", "--sitemap", "http://localhost:3001/sitemap.xml", "--rules", "metadata"],
|
|
44
|
+
calls.fetch(0).fetch(:argv)
|
|
45
|
+
)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def test_ldjson_passes_rake_url_argument_to_cli
|
|
49
|
+
calls = capture_cli_calls
|
|
50
|
+
|
|
51
|
+
Crawlscope::RakeTasks.ldjson(urls: "http://localhost:3001/article")
|
|
52
|
+
|
|
53
|
+
assert_equal(
|
|
54
|
+
["ldjson", "--url", "http://localhost:3001/article"],
|
|
55
|
+
calls.fetch(0).fetch(:argv)
|
|
56
|
+
)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
private
|
|
60
|
+
|
|
61
|
+
def capture_cli_calls
|
|
62
|
+
calls = []
|
|
63
|
+
singleton_class = class << Crawlscope::Cli; self; end
|
|
64
|
+
singleton_class.define_method(:start) do |argv, **kwargs|
|
|
65
|
+
calls << {argv: argv, kwargs: kwargs}
|
|
66
|
+
0
|
|
67
|
+
end
|
|
68
|
+
calls
|
|
69
|
+
end
|
|
70
|
+
end
|