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
|
@@ -26,11 +26,10 @@ module Crawlscope
|
|
|
26
26
|
end
|
|
27
27
|
|
|
28
28
|
def call(urls:, pages:, issues:, context:)
|
|
29
|
-
|
|
30
|
-
|
|
29
|
+
@concurrency = context_value(context, :concurrency, default: 1)
|
|
30
|
+
@fetch_executor = context_value(context, :fetch_executor)
|
|
31
31
|
|
|
32
|
-
|
|
33
|
-
end
|
|
32
|
+
page_summaries = summarize_pages(pages)
|
|
34
33
|
|
|
35
34
|
validate_duplicates(page_summaries, issues)
|
|
36
35
|
validate_near_duplicates(page_summaries, issues)
|
|
@@ -58,6 +57,7 @@ module Crawlscope
|
|
|
58
57
|
|
|
59
58
|
{
|
|
60
59
|
content_fingerprint_digest: content_fingerprint_digest(page.doc),
|
|
60
|
+
canonical: page.doc.at_css('link[rel="canonical"]')&.[]("href").to_s.strip,
|
|
61
61
|
description: page.doc.at_css('meta[name="description"]')&.[]("content").to_s.strip,
|
|
62
62
|
shingles: shingles_for(tokens),
|
|
63
63
|
title: page.doc.at_css("title")&.text.to_s.strip,
|
|
@@ -65,6 +65,16 @@ module Crawlscope
|
|
|
65
65
|
}
|
|
66
66
|
end
|
|
67
67
|
|
|
68
|
+
def summarize_pages(pages)
|
|
69
|
+
html_pages = pages.select(&:html?)
|
|
70
|
+
|
|
71
|
+
FetchExecutor.map(
|
|
72
|
+
name: @fetch_executor,
|
|
73
|
+
concurrency: @concurrency,
|
|
74
|
+
items: html_pages
|
|
75
|
+
) { |page| summary_for(page) }
|
|
76
|
+
end
|
|
77
|
+
|
|
68
78
|
def validate_duplicates(page_summaries, issues)
|
|
69
79
|
duplicates_for(page_summaries, :title).each do |value, urls|
|
|
70
80
|
issues.add(
|
|
@@ -98,6 +108,27 @@ module Crawlscope
|
|
|
98
108
|
details: {urls: urls}
|
|
99
109
|
)
|
|
100
110
|
end
|
|
111
|
+
|
|
112
|
+
duplicate_content_clusters_without_canonical(page_summaries).each do |urls|
|
|
113
|
+
issues.add(
|
|
114
|
+
code: :duplicate_pages_without_canonical,
|
|
115
|
+
severity: :warning,
|
|
116
|
+
category: :uniqueness,
|
|
117
|
+
url: nil,
|
|
118
|
+
message: "duplicate pages without canonical => #{urls.join(", ")}",
|
|
119
|
+
details: {urls: urls}
|
|
120
|
+
)
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def duplicate_content_clusters_without_canonical(page_summaries)
|
|
125
|
+
page_summaries
|
|
126
|
+
.select { |page| !page[:content_fingerprint_digest].nil? }
|
|
127
|
+
.group_by { |page| page[:content_fingerprint_digest] }
|
|
128
|
+
.values
|
|
129
|
+
.select { |pages| pages.size > 1 }
|
|
130
|
+
.select { |pages| pages.any? { |page| page[:canonical].to_s.empty? } }
|
|
131
|
+
.map { |pages| pages.map { |page| page[:url] } }
|
|
101
132
|
end
|
|
102
133
|
|
|
103
134
|
def shingles_for(tokens)
|
|
@@ -155,6 +186,16 @@ module Crawlscope
|
|
|
155
186
|
|
|
156
187
|
intersection_size.to_f / smaller_set_size
|
|
157
188
|
end
|
|
189
|
+
|
|
190
|
+
def context_value(context, name, default: nil)
|
|
191
|
+
if context.respond_to?(name)
|
|
192
|
+
context.public_send(name)
|
|
193
|
+
elsif context.respond_to?(:key?) && context.key?(name)
|
|
194
|
+
context[name]
|
|
195
|
+
else
|
|
196
|
+
default
|
|
197
|
+
end
|
|
198
|
+
end
|
|
158
199
|
end
|
|
159
200
|
end
|
|
160
201
|
end
|
data/lib/crawlscope/sitemap.rb
CHANGED
|
@@ -9,28 +9,45 @@ module Crawlscope
|
|
|
9
9
|
class Sitemap
|
|
10
10
|
SITEMAP_NAMESPACE = {"xmlns" => "http://www.sitemaps.org/schemas/sitemap/0.9"}.freeze
|
|
11
11
|
|
|
12
|
-
def initialize(path:)
|
|
12
|
+
def initialize(path:, adapter: nil, concurrency: Configuration::DEFAULT_CONCURRENCY, fetch_executor: Configuration::DEFAULT_FETCH_EXECUTOR, timeout_seconds: Configuration::DEFAULT_TIMEOUT_SECONDS)
|
|
13
13
|
@path = path
|
|
14
|
+
@adapter = adapter
|
|
15
|
+
@concurrency = concurrency
|
|
16
|
+
@fetch_executor = fetch_executor
|
|
17
|
+
@timeout_seconds = timeout_seconds
|
|
14
18
|
end
|
|
15
19
|
|
|
16
20
|
def urls(base_url:)
|
|
17
|
-
collect_urls(@path, base_url: base_url, visited: Set.new).uniq
|
|
21
|
+
collect_urls(@path, base_url: base_url, visited: Set.new, visited_mutex: Mutex.new).uniq
|
|
18
22
|
end
|
|
19
23
|
|
|
20
24
|
private
|
|
21
25
|
|
|
22
|
-
def collect_urls(source, base_url:, visited:)
|
|
23
|
-
|
|
26
|
+
def collect_urls(source, base_url:, visited:, visited_mutex:)
|
|
27
|
+
already_visited = visited_mutex.synchronize do
|
|
28
|
+
if visited.include?(source)
|
|
29
|
+
true
|
|
30
|
+
else
|
|
31
|
+
visited.add(source)
|
|
32
|
+
false
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
return [] if already_visited
|
|
24
36
|
|
|
25
|
-
visited.add(source)
|
|
26
37
|
document = Nokogiri::XML(read(source))
|
|
27
38
|
root_name = document.root&.name
|
|
39
|
+
unless %w[sitemapindex urlset].include?(root_name)
|
|
40
|
+
raise ValidationError, "Sitemap #{source} has unexpected root #{root_name.inspect}"
|
|
41
|
+
end
|
|
28
42
|
|
|
29
43
|
if root_name == "sitemapindex"
|
|
30
|
-
document.xpath("//xmlns:sitemap/xmlns:loc", SITEMAP_NAMESPACE).
|
|
31
|
-
|
|
32
|
-
collect_urls(child_source, base_url: base_url, visited: visited)
|
|
44
|
+
child_sources = document.xpath("//xmlns:sitemap/xmlns:loc", SITEMAP_NAMESPACE).map do |node|
|
|
45
|
+
resolve_child_source(source, node.text.to_s.strip, base_url: base_url)
|
|
33
46
|
end
|
|
47
|
+
|
|
48
|
+
fetch_executor.call(child_sources) do |child_source|
|
|
49
|
+
collect_urls(child_source, base_url: base_url, visited: visited, visited_mutex: visited_mutex)
|
|
50
|
+
end.flatten
|
|
34
51
|
else
|
|
35
52
|
document.xpath("//xmlns:url/xmlns:loc", SITEMAP_NAMESPACE).map do |node|
|
|
36
53
|
Url.normalize_for_base(node.text.to_s.strip, base_url: base_url)
|
|
@@ -40,7 +57,12 @@ module Crawlscope
|
|
|
40
57
|
|
|
41
58
|
def read(source)
|
|
42
59
|
if Url.remote?(source)
|
|
43
|
-
connection.get(source)
|
|
60
|
+
response = connection.get(source)
|
|
61
|
+
unless response.status.to_i.between?(200, 299)
|
|
62
|
+
raise ValidationError, "Sitemap #{source} returned HTTP #{response.status}"
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
response.body
|
|
44
66
|
else
|
|
45
67
|
File.read(source)
|
|
46
68
|
end
|
|
@@ -69,11 +91,16 @@ module Crawlscope
|
|
|
69
91
|
end
|
|
70
92
|
|
|
71
93
|
def connection
|
|
72
|
-
|
|
94
|
+
Faraday.new do |faraday|
|
|
73
95
|
faraday.response :follow_redirects, limit: Http::MAX_REDIRECTS
|
|
74
|
-
faraday.options.timeout =
|
|
75
|
-
faraday.options.open_timeout =
|
|
96
|
+
faraday.options.timeout = @timeout_seconds
|
|
97
|
+
faraday.options.open_timeout = @timeout_seconds
|
|
98
|
+
faraday.adapter @adapter if @adapter
|
|
76
99
|
end
|
|
77
100
|
end
|
|
101
|
+
|
|
102
|
+
def fetch_executor
|
|
103
|
+
@fetch_executor_instance ||= FetchExecutor.build(name: @fetch_executor, concurrency: @concurrency)
|
|
104
|
+
end
|
|
78
105
|
end
|
|
79
106
|
end
|
data/lib/crawlscope/version.rb
CHANGED
|
@@ -1,43 +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, FETCH_EXECUTOR"
|
|
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 indexability rule. ENV: URL, SITEMAP, JS=1"
|
|
14
|
-
task indexability: :environment do
|
|
15
|
-
Crawlscope::RakeTasks.validate_rule("indexability")
|
|
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 URLs with the metadata rule. ENV: URL, SITEMAP, JS=1"
|
|
19
|
-
task metadata: :environment do
|
|
20
|
-
Crawlscope::RakeTasks.validate_rule("metadata")
|
|
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 sitemap URLs with the structured_data rule. ENV: URL, SITEMAP, JS=1"
|
|
24
|
-
task structured_data: :environment do
|
|
25
|
-
Crawlscope::RakeTasks.validate_rule("structured_data")
|
|
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 uniqueness rule. ENV: URL, SITEMAP, JS=1"
|
|
29
|
-
task uniqueness: :environment do
|
|
30
|
-
Crawlscope::RakeTasks.validate_rule("uniqueness")
|
|
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
31
|
end
|
|
32
32
|
|
|
33
|
-
desc "Validate URLs with the content_quality rule. ENV: URL, SITEMAP, JS=1"
|
|
34
|
-
task content_quality: :environment do
|
|
35
|
-
Crawlscope::RakeTasks.validate_rule("content_quality")
|
|
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
36
|
end
|
|
37
37
|
|
|
38
|
-
desc "Validate URLs with the links rule. ENV: URL, SITEMAP, JS=1"
|
|
39
|
-
task links: :environment do
|
|
40
|
-
Crawlscope::RakeTasks.validate_rule("links")
|
|
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])
|
|
41
41
|
end
|
|
42
42
|
end
|
|
43
43
|
end
|
data/test/crawlscope/cli_test.rb
CHANGED
|
@@ -4,11 +4,12 @@ require "test_helper"
|
|
|
4
4
|
|
|
5
5
|
class CrawlscopeCliTest < Minitest::Test
|
|
6
6
|
class FakeConfiguration
|
|
7
|
-
attr_accessor :base_url, :concurrency, :network_idle_timeout_seconds, :output, :renderer, :timeout_seconds
|
|
7
|
+
attr_accessor :base_url, :concurrency, :fetch_executor, :network_idle_timeout_seconds, :output, :renderer, :timeout_seconds
|
|
8
8
|
|
|
9
9
|
def initialize
|
|
10
10
|
@base_url = nil
|
|
11
11
|
@concurrency = 10
|
|
12
|
+
@fetch_executor = :async
|
|
12
13
|
@network_idle_timeout_seconds = 5
|
|
13
14
|
@renderer = :http
|
|
14
15
|
@timeout_seconds = 20
|
|
@@ -95,7 +96,7 @@ class CrawlscopeCliTest < Minitest::Test
|
|
|
95
96
|
err = StringIO.new
|
|
96
97
|
|
|
97
98
|
status = Crawlscope::Cli.start(
|
|
98
|
-
["validate", "--url", "https://example.com", "--sitemap", "https://example.com/sitemap-pages.xml", "--rules", "metadata,links", "--renderer", "browser", "--timeout", "30", "--network-idle-timeout", "9", "--concurrency", "3"],
|
|
99
|
+
["validate", "--url", "https://example.com", "--sitemap", "https://example.com/sitemap-pages.xml", "--rules", "metadata,links", "--renderer", "browser", "--timeout", "30", "--network-idle-timeout", "9", "--concurrency", "3", "--fetch-executor", "async"],
|
|
99
100
|
out: out,
|
|
100
101
|
err: err,
|
|
101
102
|
configuration: configuration,
|
|
@@ -115,10 +116,35 @@ class CrawlscopeCliTest < Minitest::Test
|
|
|
115
116
|
assert_equal 30, configuration.timeout_seconds
|
|
116
117
|
assert_equal 9, configuration.network_idle_timeout_seconds
|
|
117
118
|
assert_equal 3, configuration.concurrency
|
|
119
|
+
assert_equal "async", configuration.fetch_executor
|
|
118
120
|
assert_same out, configuration.output
|
|
119
121
|
assert_empty err.string
|
|
120
122
|
end
|
|
121
123
|
|
|
124
|
+
def test_validate_reads_fetch_executor_from_environment
|
|
125
|
+
configuration = FakeConfiguration.new
|
|
126
|
+
task = FakeTask.new
|
|
127
|
+
|
|
128
|
+
with_env("FETCH_EXECUTOR" => "async") do
|
|
129
|
+
status = Crawlscope::Cli.start(["validate", "--url", "https://example.com"], out: StringIO.new, err: StringIO.new, configuration: configuration, task: task)
|
|
130
|
+
|
|
131
|
+
assert_equal 0, status
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
assert_equal "async", configuration.fetch_executor
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def test_validate_uses_threaded_executor_for_browser_rendering_by_default
|
|
138
|
+
configuration = FakeConfiguration.new
|
|
139
|
+
task = FakeTask.new
|
|
140
|
+
|
|
141
|
+
status = Crawlscope::Cli.start(["validate", "--url", "https://example.com", "--renderer", "browser"], out: StringIO.new, err: StringIO.new, configuration: configuration, task: task)
|
|
142
|
+
|
|
143
|
+
assert_equal 0, status
|
|
144
|
+
assert_equal :browser, configuration.renderer
|
|
145
|
+
assert_equal :threaded, configuration.fetch_executor
|
|
146
|
+
end
|
|
147
|
+
|
|
122
148
|
def test_ldjson_reads_urls_from_environment
|
|
123
149
|
configuration = FakeConfiguration.new
|
|
124
150
|
task = FakeTask.new
|
|
@@ -265,6 +291,7 @@ class CrawlscopeCliTest < Minitest::Test
|
|
|
265
291
|
|
|
266
292
|
assert_equal 1, status
|
|
267
293
|
assert_includes err.string, "No URLs found in sitemap"
|
|
294
|
+
refute_includes err.string, "Usage:"
|
|
268
295
|
end
|
|
269
296
|
|
|
270
297
|
private
|
|
@@ -13,6 +13,7 @@ class CrawlscopeConfigurationTest < Minitest::Test
|
|
|
13
13
|
config.sitemap_path = -> { "/tmp/sitemap.xml" }
|
|
14
14
|
config.site_name = -> { "Example" }
|
|
15
15
|
config.concurrency = -> { 4 }
|
|
16
|
+
config.fetch_executor = -> { :threaded }
|
|
16
17
|
end
|
|
17
18
|
|
|
18
19
|
audit = Crawlscope.configuration.audit
|
|
@@ -20,6 +21,7 @@ class CrawlscopeConfigurationTest < Minitest::Test
|
|
|
20
21
|
assert_equal "https://example.com", audit.instance_variable_get(:@base_url)
|
|
21
22
|
assert_equal "/tmp/sitemap.xml", audit.instance_variable_get(:@sitemap_path)
|
|
22
23
|
assert_equal 4, audit.instance_variable_get(:@concurrency)
|
|
24
|
+
assert_equal :threaded, audit.instance_variable_get(:@fetch_executor)
|
|
23
25
|
assert_equal %i[
|
|
24
26
|
indexability
|
|
25
27
|
metadata
|
|
@@ -55,6 +57,7 @@ class CrawlscopeConfigurationTest < Minitest::Test
|
|
|
55
57
|
|
|
56
58
|
assert_equal [200, 301, 302], config.allowed_statuses
|
|
57
59
|
assert_equal 10, config.concurrency
|
|
60
|
+
assert_equal :async, config.fetch_executor
|
|
58
61
|
assert_equal 4, config.browser_concurrency
|
|
59
62
|
assert_equal 5, config.network_idle_timeout_seconds
|
|
60
63
|
assert_equal :http, config.renderer
|
|
@@ -63,10 +66,18 @@ class CrawlscopeConfigurationTest < Minitest::Test
|
|
|
63
66
|
assert config.scroll_page?
|
|
64
67
|
end
|
|
65
68
|
|
|
69
|
+
def test_browser_renderer_defaults_to_threaded_fetch_executor
|
|
70
|
+
config = Crawlscope::Configuration.new
|
|
71
|
+
config.renderer = :browser
|
|
72
|
+
|
|
73
|
+
assert_equal :threaded, config.fetch_executor
|
|
74
|
+
end
|
|
75
|
+
|
|
66
76
|
def test_configured_values_are_normalized
|
|
67
77
|
config = Crawlscope::Configuration.new
|
|
68
78
|
config.allowed_statuses = ["200", "404"]
|
|
69
79
|
config.concurrency = "2"
|
|
80
|
+
config.fetch_executor = "async"
|
|
70
81
|
config.network_idle_timeout_seconds = "7"
|
|
71
82
|
config.renderer = "browser"
|
|
72
83
|
config.timeout_seconds = "9"
|
|
@@ -74,6 +85,7 @@ class CrawlscopeConfigurationTest < Minitest::Test
|
|
|
74
85
|
|
|
75
86
|
assert_equal [200, 404], config.allowed_statuses
|
|
76
87
|
assert_equal 2, config.concurrency
|
|
88
|
+
assert_equal :async, config.fetch_executor
|
|
77
89
|
assert_equal 2, config.browser_concurrency
|
|
78
90
|
assert_equal 7, config.network_idle_timeout_seconds
|
|
79
91
|
assert_equal :browser, config.renderer
|
|
@@ -90,6 +102,15 @@ class CrawlscopeConfigurationTest < Minitest::Test
|
|
|
90
102
|
assert_equal "Crawlscope renderer must be http or browser", error.message
|
|
91
103
|
end
|
|
92
104
|
|
|
105
|
+
def test_fetch_executor_must_be_supported
|
|
106
|
+
config = Crawlscope::Configuration.new
|
|
107
|
+
config.fetch_executor = "processes"
|
|
108
|
+
|
|
109
|
+
error = assert_raises(Crawlscope::ConfigurationError) { config.fetch_executor }
|
|
110
|
+
|
|
111
|
+
assert_equal "Crawlscope fetch_executor must be threaded or async", error.message
|
|
112
|
+
end
|
|
113
|
+
|
|
93
114
|
def test_numeric_values_must_be_positive_integers
|
|
94
115
|
config = Crawlscope::Configuration.new
|
|
95
116
|
config.concurrency = "0"
|
|
@@ -27,6 +27,24 @@ class CrawlscopeContentQualityRuleTest < Minitest::Test
|
|
|
27
27
|
refute_includes issues.to_a.map(&:code), :low_visible_text_ratio
|
|
28
28
|
end
|
|
29
29
|
|
|
30
|
+
def test_visible_text_ratio_ignores_form_payload_markup
|
|
31
|
+
issues = Crawlscope::IssueCollection.new
|
|
32
|
+
page = page_with(
|
|
33
|
+
main: <<~HTML
|
|
34
|
+
<p>#{Array.new(260) { |index| "word#{index}" }.join(" ")}</p>
|
|
35
|
+
<form>
|
|
36
|
+
<div data-select-autocomplete-options-value="#{"x" * 50_000}">
|
|
37
|
+
<input type="text" name="country">
|
|
38
|
+
</div>
|
|
39
|
+
</form>
|
|
40
|
+
HTML
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
Crawlscope::Rules::ContentQuality.new.call(urls: [page.url], pages: [page], issues: issues)
|
|
44
|
+
|
|
45
|
+
refute_includes issues.to_a.map(&:code), :low_visible_text_ratio
|
|
46
|
+
end
|
|
47
|
+
|
|
30
48
|
def test_reports_low_unique_token_ratio_for_repetitive_content
|
|
31
49
|
issues = Crawlscope::IssueCollection.new
|
|
32
50
|
page = page_with(main: ("hotel location service " * 100).strip)
|
|
@@ -3,6 +3,36 @@
|
|
|
3
3
|
require "test_helper"
|
|
4
4
|
|
|
5
5
|
class CrawlscopeCrawlTest < Minitest::Test
|
|
6
|
+
class RecordingExecutor
|
|
7
|
+
attr_reader :batches
|
|
8
|
+
|
|
9
|
+
def initialize
|
|
10
|
+
@batches = []
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def call(urls)
|
|
14
|
+
@batches << urls
|
|
15
|
+
urls.map { |url| yield(url) }
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
class PageMapFetcher
|
|
20
|
+
attr_reader :closed
|
|
21
|
+
|
|
22
|
+
def initialize(pages)
|
|
23
|
+
@pages = pages
|
|
24
|
+
@closed = false
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def close
|
|
28
|
+
@closed = true
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def fetch(url)
|
|
32
|
+
@pages.fetch(url)
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
6
36
|
def setup
|
|
7
37
|
@tmp_dir = Dir.mktmpdir
|
|
8
38
|
@sitemap_path = File.join(@tmp_dir, "sitemap.xml")
|
|
@@ -12,6 +42,29 @@ class CrawlscopeCrawlTest < Minitest::Test
|
|
|
12
42
|
FileUtils.rm_rf(@tmp_dir)
|
|
13
43
|
end
|
|
14
44
|
|
|
45
|
+
def test_http_renderer_defaults_to_async_executor
|
|
46
|
+
crawl = Crawlscope::Crawl.new(
|
|
47
|
+
base_url: "https://example.com",
|
|
48
|
+
sitemap_path: @sitemap_path,
|
|
49
|
+
rules: [],
|
|
50
|
+
schema_registry: Crawlscope::SchemaRegistry.default
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
assert_equal :async, crawl.instance_variable_get(:@fetch_executor)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def test_browser_renderer_defaults_to_threaded_executor
|
|
57
|
+
crawl = Crawlscope::Crawl.new(
|
|
58
|
+
base_url: "https://example.com",
|
|
59
|
+
sitemap_path: @sitemap_path,
|
|
60
|
+
rules: [],
|
|
61
|
+
schema_registry: Crawlscope::SchemaRegistry.default,
|
|
62
|
+
renderer: :browser
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
assert_equal :threaded, crawl.instance_variable_get(:@fetch_executor)
|
|
66
|
+
end
|
|
67
|
+
|
|
15
68
|
def test_returns_ok_when_metadata_is_valid
|
|
16
69
|
File.write(
|
|
17
70
|
@sitemap_path,
|
|
@@ -56,7 +109,8 @@ class CrawlscopeCrawlTest < Minitest::Test
|
|
|
56
109
|
base_url: "https://example.com",
|
|
57
110
|
sitemap_path: @sitemap_path,
|
|
58
111
|
rules: Crawlscope::RuleRegistry.default(site_name: "Example").rules,
|
|
59
|
-
schema_registry: Crawlscope::SchemaRegistry.default
|
|
112
|
+
schema_registry: Crawlscope::SchemaRegistry.default,
|
|
113
|
+
fetch_executor: :threaded
|
|
60
114
|
).call
|
|
61
115
|
|
|
62
116
|
assert result.ok?
|
|
@@ -97,10 +151,11 @@ class CrawlscopeCrawlTest < Minitest::Test
|
|
|
97
151
|
base_url: "https://example.com",
|
|
98
152
|
sitemap_path: @sitemap_path,
|
|
99
153
|
rules: Crawlscope::RuleRegistry.default(site_name: "Example").rules,
|
|
100
|
-
schema_registry: Crawlscope::SchemaRegistry.default
|
|
154
|
+
schema_registry: Crawlscope::SchemaRegistry.default,
|
|
155
|
+
fetch_executor: :threaded
|
|
101
156
|
).call
|
|
102
157
|
|
|
103
|
-
|
|
158
|
+
assert result.ok?
|
|
104
159
|
assert_equal %i[
|
|
105
160
|
incomplete_open_graph_tags
|
|
106
161
|
meta_description_too_long
|
|
@@ -188,4 +243,113 @@ class CrawlscopeCrawlTest < Minitest::Test
|
|
|
188
243
|
assert_equal ["https://example.com/pricing"], fake_browser.urls
|
|
189
244
|
assert fake_browser.closed
|
|
190
245
|
end
|
|
246
|
+
|
|
247
|
+
def test_async_executor_requires_http_renderer
|
|
248
|
+
File.write(
|
|
249
|
+
@sitemap_path,
|
|
250
|
+
<<~XML
|
|
251
|
+
<?xml version="1.0" encoding="UTF-8"?>
|
|
252
|
+
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
|
253
|
+
<url><loc>https://example.com/pricing</loc></url>
|
|
254
|
+
</urlset>
|
|
255
|
+
XML
|
|
256
|
+
)
|
|
257
|
+
|
|
258
|
+
error = assert_raises(Crawlscope::ConfigurationError) do
|
|
259
|
+
Crawlscope::Crawl.new(
|
|
260
|
+
base_url: "https://example.com",
|
|
261
|
+
sitemap_path: @sitemap_path,
|
|
262
|
+
rules: [],
|
|
263
|
+
schema_registry: Crawlscope::SchemaRegistry.default,
|
|
264
|
+
renderer: :browser,
|
|
265
|
+
fetch_executor: :async
|
|
266
|
+
).call
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
assert_equal "Async fetch execution is only supported with http rendering", error.message
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
def test_reports_sitemap_redirect_url
|
|
273
|
+
File.write(
|
|
274
|
+
@sitemap_path,
|
|
275
|
+
<<~XML
|
|
276
|
+
<?xml version="1.0" encoding="UTF-8"?>
|
|
277
|
+
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
|
278
|
+
<url><loc>https://example.com/old</loc></url>
|
|
279
|
+
</urlset>
|
|
280
|
+
XML
|
|
281
|
+
)
|
|
282
|
+
|
|
283
|
+
stub_request(:get, "https://example.com/old")
|
|
284
|
+
.to_return(status: 301, headers: {"Location" => "https://example.com/new"}, body: "")
|
|
285
|
+
stub_request(:get, "https://example.com/new")
|
|
286
|
+
.to_return(status: 200, headers: {"Content-Type" => "text/html"}, body: "<html><body>Moved</body></html>")
|
|
287
|
+
|
|
288
|
+
result = Crawlscope::Crawl.new(
|
|
289
|
+
base_url: "https://example.com",
|
|
290
|
+
sitemap_path: @sitemap_path,
|
|
291
|
+
rules: [],
|
|
292
|
+
schema_registry: Crawlscope::SchemaRegistry.default,
|
|
293
|
+
fetch_executor: :threaded
|
|
294
|
+
).call
|
|
295
|
+
|
|
296
|
+
assert_includes result.issues.to_a.map(&:code), :sitemap_redirect_url
|
|
297
|
+
end
|
|
298
|
+
|
|
299
|
+
def test_resolves_uncrawled_link_targets_as_a_bounded_batch
|
|
300
|
+
File.write(
|
|
301
|
+
@sitemap_path,
|
|
302
|
+
<<~XML
|
|
303
|
+
<?xml version="1.0" encoding="UTF-8"?>
|
|
304
|
+
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
|
305
|
+
<url><loc>https://example.com/guide</loc></url>
|
|
306
|
+
</urlset>
|
|
307
|
+
XML
|
|
308
|
+
)
|
|
309
|
+
|
|
310
|
+
executor = RecordingExecutor.new
|
|
311
|
+
fetcher = PageMapFetcher.new(
|
|
312
|
+
"https://example.com/guide" => page(
|
|
313
|
+
"https://example.com/guide",
|
|
314
|
+
"<main><a href=\"/one\">One</a><a href=\"/two\">Two</a></main>"
|
|
315
|
+
),
|
|
316
|
+
"https://example.com/one" => page("https://example.com/one", "<main>One</main>"),
|
|
317
|
+
"https://example.com/two" => page("https://example.com/two", "<main>Two</main>")
|
|
318
|
+
)
|
|
319
|
+
|
|
320
|
+
Crawlscope::Crawl.new(
|
|
321
|
+
base_url: "https://example.com",
|
|
322
|
+
sitemap_path: @sitemap_path,
|
|
323
|
+
rules: [Crawlscope::Rules::Links.new],
|
|
324
|
+
schema_registry: Crawlscope::SchemaRegistry.default,
|
|
325
|
+
renderer: :browser,
|
|
326
|
+
browser_factory: -> { fetcher },
|
|
327
|
+
fetch_executor: executor,
|
|
328
|
+
concurrency: 2
|
|
329
|
+
).call
|
|
330
|
+
|
|
331
|
+
assert_equal(
|
|
332
|
+
[
|
|
333
|
+
["https://example.com/guide"],
|
|
334
|
+
["https://example.com/one", "https://example.com/two"]
|
|
335
|
+
],
|
|
336
|
+
executor.batches
|
|
337
|
+
)
|
|
338
|
+
assert fetcher.closed
|
|
339
|
+
end
|
|
340
|
+
|
|
341
|
+
private
|
|
342
|
+
|
|
343
|
+
def page(url, body)
|
|
344
|
+
Crawlscope::Page.new(
|
|
345
|
+
url: url,
|
|
346
|
+
normalized_url: url,
|
|
347
|
+
final_url: url,
|
|
348
|
+
normalized_final_url: url,
|
|
349
|
+
status: 200,
|
|
350
|
+
headers: {"content-type" => "text/html"},
|
|
351
|
+
body: body,
|
|
352
|
+
doc: Nokogiri::HTML(body)
|
|
353
|
+
)
|
|
354
|
+
end
|
|
191
355
|
end
|
|
@@ -31,4 +31,65 @@ class CrawlscopeCrawlerTest < Minitest::Test
|
|
|
31
31
|
assert_nil error_page.status
|
|
32
32
|
assert_equal "Timeout::Error: fetch timed out", error_page.error
|
|
33
33
|
end
|
|
34
|
+
|
|
35
|
+
def test_preserves_input_order
|
|
36
|
+
pages = Crawlscope::Crawler.new(page_fetcher: RaisingFetcher.new, concurrency: 2).call(
|
|
37
|
+
["https://example.com/one", "https://example.com/two", "https://example.com/three"]
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
assert_equal(
|
|
41
|
+
["https://example.com/one", "https://example.com/two", "https://example.com/three"],
|
|
42
|
+
pages.map(&:url)
|
|
43
|
+
)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
class AsyncFetcher
|
|
47
|
+
attr_reader :active_fetches
|
|
48
|
+
|
|
49
|
+
def initialize
|
|
50
|
+
@active_fetches = 0
|
|
51
|
+
@max_active_fetches = 0
|
|
52
|
+
@mutex = Mutex.new
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def fetch(url)
|
|
56
|
+
@mutex.synchronize do
|
|
57
|
+
@active_fetches += 1
|
|
58
|
+
@max_active_fetches = [@max_active_fetches, @active_fetches].max
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
Async::Task.current.sleep(0.01)
|
|
62
|
+
|
|
63
|
+
Crawlscope::Page.new(
|
|
64
|
+
url: url,
|
|
65
|
+
normalized_url: url,
|
|
66
|
+
final_url: url,
|
|
67
|
+
normalized_final_url: url,
|
|
68
|
+
status: 200,
|
|
69
|
+
headers: {},
|
|
70
|
+
body: "<html></html>",
|
|
71
|
+
doc: Nokogiri::HTML("<html></html>")
|
|
72
|
+
)
|
|
73
|
+
ensure
|
|
74
|
+
@mutex.synchronize { @active_fetches -= 1 }
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def max_active_fetches
|
|
78
|
+
@mutex.synchronize { @max_active_fetches }
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def test_async_executor_respects_concurrency_and_preserves_order
|
|
83
|
+
fetcher = AsyncFetcher.new
|
|
84
|
+
|
|
85
|
+
pages = Crawlscope::Crawler.new(page_fetcher: fetcher, concurrency: 2, fetch_executor: :async).call(
|
|
86
|
+
["https://example.com/one", "https://example.com/two", "https://example.com/three"]
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
assert_equal(
|
|
90
|
+
["https://example.com/one", "https://example.com/two", "https://example.com/three"],
|
|
91
|
+
pages.map(&:url)
|
|
92
|
+
)
|
|
93
|
+
assert_operator fetcher.max_active_fetches, :<=, 2
|
|
94
|
+
end
|
|
34
95
|
end
|