crawlscope 0.5.0 → 0.7.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 +38 -0
- data/README.md +50 -1
- data/lib/crawlscope/cli.rb +16 -0
- data/lib/crawlscope/configuration.rb +10 -1
- data/lib/crawlscope/context.rb +1 -1
- data/lib/crawlscope/crawl.rb +72 -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/railtie.rb +1 -1
- data/lib/crawlscope/reporter.rb +123 -14
- data/lib/crawlscope/result.rb +1 -1
- data/lib/crawlscope/rules/content_quality.rb +1 -1
- data/lib/crawlscope/rules/indexability.rb +28 -6
- data/lib/crawlscope/rules/links.rb +80 -16
- data/lib/crawlscope/rules/uniqueness.rb +23 -4
- data/lib/crawlscope/sitemap.rb +30 -11
- data/lib/crawlscope/tasks.rb +8 -0
- data/lib/crawlscope/version.rb +1 -1
- data/lib/crawlscope.rb +1 -0
- data/lib/tasks/crawlscope_tasks.rake +1 -1
- data/test/crawlscope/cli_test.rb +28 -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 +142 -4
- data/test/crawlscope/crawler_test.rb +61 -0
- data/test/crawlscope/fetch_executor_test.rb +44 -0
- data/test/crawlscope/links_rule_test.rb +101 -0
- data/test/crawlscope/loader_test.rb +37 -0
- data/test/crawlscope/reporter_test.rb +136 -11
- data/test/crawlscope/result_test.rb +35 -0
- data/test/crawlscope/sitemap_test.rb +52 -0
- 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 +40 -3
|
@@ -23,11 +23,25 @@ class CrawlscopeReporterTest < Minitest::Test
|
|
|
23
23
|
refute_includes output, "Status: FAILED"
|
|
24
24
|
end
|
|
25
25
|
|
|
26
|
-
def
|
|
26
|
+
def test_reports_warning_result_with_grouped_one_line_issues
|
|
27
27
|
io = StringIO.new
|
|
28
28
|
issues = Crawlscope::IssueCollection.new
|
|
29
|
+
4.times do |index|
|
|
30
|
+
issues.add(
|
|
31
|
+
code: :low_dofollow_inlinks,
|
|
32
|
+
severity: :warning,
|
|
33
|
+
category: :links,
|
|
34
|
+
url: "https://example.com/page-#{index + 1}",
|
|
35
|
+
message: "dofollow inbound links 1 below 2",
|
|
36
|
+
details: {
|
|
37
|
+
dofollow_inbound_count: 1,
|
|
38
|
+
minimum: 2,
|
|
39
|
+
source_urls: ["https://example.com/source-#{index + 1}"]
|
|
40
|
+
}
|
|
41
|
+
)
|
|
42
|
+
end
|
|
29
43
|
issues.add(code: :missing_title, severity: :warning, category: :metadata, url: "https://example.com/a", message: "missing <title>", details: {})
|
|
30
|
-
|
|
44
|
+
|
|
31
45
|
result = Crawlscope::Result.new(
|
|
32
46
|
base_url: "https://example.com",
|
|
33
47
|
sitemap_path: "/tmp/sitemap.xml",
|
|
@@ -40,15 +54,126 @@ class CrawlscopeReporterTest < Minitest::Test
|
|
|
40
54
|
|
|
41
55
|
output = io.string
|
|
42
56
|
|
|
57
|
+
assert_includes output, "Status: WARNINGS"
|
|
58
|
+
refute_includes output, "Status: FAILED"
|
|
59
|
+
assert_includes output, "Issues: 5 total (5 warnings)"
|
|
60
|
+
assert_includes output, "Summary:"
|
|
61
|
+
assert_includes output, "links / low_dofollow_inlinks: 4"
|
|
62
|
+
assert_includes output, " - /page-1 inbound 1/2 sources: /source-1"
|
|
63
|
+
assert_includes output, " - /page-4 inbound 1/2 sources: /source-4"
|
|
64
|
+
assert_includes output, "metadata / missing_title: 1"
|
|
65
|
+
refute_includes output, "Severity:"
|
|
66
|
+
refute_includes output, "Category:"
|
|
67
|
+
refute_includes output, "... 1 more"
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def test_reports_failed_status_when_errors_are_present
|
|
71
|
+
io = StringIO.new
|
|
72
|
+
issues = Crawlscope::IssueCollection.new
|
|
73
|
+
issues.add(code: :fetch_failed, severity: :error, category: :crawl, url: "https://example.com/a", message: "timeout", details: {})
|
|
74
|
+
issues.add(code: :missing_title, severity: :warning, category: :metadata, url: "https://example.com/a", message: "missing <title>", details: {})
|
|
75
|
+
|
|
76
|
+
result = Crawlscope::Result.new(
|
|
77
|
+
base_url: "https://example.com",
|
|
78
|
+
sitemap_path: "/tmp/sitemap.xml",
|
|
79
|
+
urls: ["https://example.com/a"],
|
|
80
|
+
pages: [Object.new],
|
|
81
|
+
issues: issues
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
Crawlscope::Reporter.new(io: io).report(result)
|
|
85
|
+
|
|
86
|
+
output = io.string
|
|
87
|
+
|
|
43
88
|
assert_includes output, "Status: FAILED"
|
|
44
|
-
assert_includes output, "Issues: 2"
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
89
|
+
assert_includes output, "Issues: 2 total (1 error, 1 warning)"
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def test_limits_large_issue_groups
|
|
93
|
+
io = StringIO.new
|
|
94
|
+
issues = Crawlscope::IssueCollection.new
|
|
95
|
+
21.times do |index|
|
|
96
|
+
issues.add(
|
|
97
|
+
code: :low_dofollow_inlinks,
|
|
98
|
+
severity: :warning,
|
|
99
|
+
category: :links,
|
|
100
|
+
url: "https://example.com/page-#{index + 1}",
|
|
101
|
+
message: "dofollow inbound links 1 below 2",
|
|
102
|
+
details: {dofollow_inbound_count: 1, minimum: 2}
|
|
103
|
+
)
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
result = Crawlscope::Result.new(
|
|
107
|
+
base_url: "https://example.com",
|
|
108
|
+
sitemap_path: "/tmp/sitemap.xml",
|
|
109
|
+
urls: ["https://example.com"],
|
|
110
|
+
pages: [Object.new],
|
|
111
|
+
issues: issues
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
Crawlscope::Reporter.new(io: io).report(result)
|
|
115
|
+
|
|
116
|
+
output = io.string
|
|
117
|
+
|
|
118
|
+
assert_includes output, "links / low_dofollow_inlinks: 21"
|
|
119
|
+
assert_includes output, " - /page-20 inbound 1/2"
|
|
120
|
+
refute_includes output, " - /page-21"
|
|
121
|
+
assert_includes output, " ... 1 more"
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def test_reports_ratio_with_enough_precision_to_show_threshold_difference
|
|
125
|
+
io = StringIO.new
|
|
126
|
+
issues = Crawlscope::IssueCollection.new
|
|
127
|
+
issues.add(
|
|
128
|
+
code: :low_unique_token_ratio,
|
|
129
|
+
severity: :warning,
|
|
130
|
+
category: :content_quality,
|
|
131
|
+
url: "https://example.com/a",
|
|
132
|
+
message: "visible text has low token variety",
|
|
133
|
+
details: {ratio: 0.249, threshold: 0.25}
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
result = Crawlscope::Result.new(
|
|
137
|
+
base_url: "https://example.com",
|
|
138
|
+
sitemap_path: "/tmp/sitemap.xml",
|
|
139
|
+
urls: ["https://example.com/a"],
|
|
140
|
+
pages: [Object.new],
|
|
141
|
+
issues: issues
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
Crawlscope::Reporter.new(io: io).report(result)
|
|
145
|
+
|
|
146
|
+
assert_includes io.string, "ratio 0.249/0.250"
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def test_reports_source_details_on_one_line
|
|
150
|
+
io = StringIO.new
|
|
151
|
+
issues = Crawlscope::IssueCollection.new
|
|
152
|
+
4.times do |index|
|
|
153
|
+
issues.add(
|
|
154
|
+
code: :indexable_page_missing_from_sitemap,
|
|
155
|
+
severity: :warning,
|
|
156
|
+
category: :sitemaps,
|
|
157
|
+
url: "https://example.com/overview-#{index + 1}",
|
|
158
|
+
message: "indexable internal page is missing from sitemap",
|
|
159
|
+
details: {source_url: "https://example.com/source-#{index + 1}"}
|
|
160
|
+
)
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
result = Crawlscope::Result.new(
|
|
164
|
+
base_url: "https://example.com",
|
|
165
|
+
sitemap_path: "/tmp/sitemap.xml",
|
|
166
|
+
urls: ["https://example.com"],
|
|
167
|
+
pages: [Object.new],
|
|
168
|
+
issues: issues
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
Crawlscope::Reporter.new(io: io).report(result)
|
|
172
|
+
|
|
173
|
+
output = io.string
|
|
174
|
+
|
|
175
|
+
assert_includes output, "sitemaps / indexable_page_missing_from_sitemap: 4"
|
|
176
|
+
assert_includes output, " - /overview-1 indexable internal page is missing from sitemap source: /source-1"
|
|
177
|
+
assert_includes output, " - /overview-4 indexable internal page is missing from sitemap source: /source-4"
|
|
53
178
|
end
|
|
54
179
|
end
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "test_helper"
|
|
4
|
+
|
|
5
|
+
class CrawlscopeResultTest < Minitest::Test
|
|
6
|
+
def test_ok_when_result_has_warnings_only
|
|
7
|
+
issues = Crawlscope::IssueCollection.new
|
|
8
|
+
issues.add(code: :missing_title, severity: :warning, category: :metadata, url: "https://example.com", message: "missing <title>", details: {})
|
|
9
|
+
|
|
10
|
+
result = result_with(issues)
|
|
11
|
+
|
|
12
|
+
assert result.ok?
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def test_not_ok_when_result_has_errors
|
|
16
|
+
issues = Crawlscope::IssueCollection.new
|
|
17
|
+
issues.add(code: :fetch_failed, severity: :error, category: :crawl, url: "https://example.com", message: "timeout", details: {})
|
|
18
|
+
|
|
19
|
+
result = result_with(issues)
|
|
20
|
+
|
|
21
|
+
refute result.ok?
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
private
|
|
25
|
+
|
|
26
|
+
def result_with(issues)
|
|
27
|
+
Crawlscope::Result.new(
|
|
28
|
+
base_url: "https://example.com",
|
|
29
|
+
sitemap_path: "/tmp/sitemap.xml",
|
|
30
|
+
urls: ["https://example.com"],
|
|
31
|
+
pages: [Object.new],
|
|
32
|
+
issues: issues
|
|
33
|
+
)
|
|
34
|
+
end
|
|
35
|
+
end
|
|
@@ -3,6 +3,19 @@
|
|
|
3
3
|
require "test_helper"
|
|
4
4
|
|
|
5
5
|
class CrawlscopeSitemapTest < Minitest::Test
|
|
6
|
+
class RecordingExecutor
|
|
7
|
+
attr_reader :batches
|
|
8
|
+
|
|
9
|
+
def initialize
|
|
10
|
+
@batches = []
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def call(items)
|
|
14
|
+
@batches << items
|
|
15
|
+
items.map { |item| yield(item) }
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
|
|
6
19
|
def test_parses_remote_sitemap_urlset
|
|
7
20
|
stub_request(:get, "https://www.example.com/sitemap.xml")
|
|
8
21
|
.to_return(
|
|
@@ -127,4 +140,43 @@ class CrawlscopeSitemapTest < Minitest::Test
|
|
|
127
140
|
assert_equal ["http://localhost:3000/features/reviews"], parser.urls(base_url: "http://localhost:3000")
|
|
128
141
|
end
|
|
129
142
|
end
|
|
143
|
+
|
|
144
|
+
def test_child_sitemaps_are_collected_through_the_fetch_executor
|
|
145
|
+
Dir.mktmpdir do |dir|
|
|
146
|
+
File.write(
|
|
147
|
+
File.join(dir, "sitemap.xml"),
|
|
148
|
+
<<~XML
|
|
149
|
+
<?xml version="1.0" encoding="UTF-8"?>
|
|
150
|
+
<sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
|
151
|
+
<sitemap><loc>first.xml</loc></sitemap>
|
|
152
|
+
<sitemap><loc>second.xml</loc></sitemap>
|
|
153
|
+
</sitemapindex>
|
|
154
|
+
XML
|
|
155
|
+
)
|
|
156
|
+
File.write(
|
|
157
|
+
File.join(dir, "first.xml"),
|
|
158
|
+
<<~XML
|
|
159
|
+
<?xml version="1.0" encoding="UTF-8"?>
|
|
160
|
+
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
|
161
|
+
<url><loc>http://localhost:3000/first</loc></url>
|
|
162
|
+
</urlset>
|
|
163
|
+
XML
|
|
164
|
+
)
|
|
165
|
+
File.write(
|
|
166
|
+
File.join(dir, "second.xml"),
|
|
167
|
+
<<~XML
|
|
168
|
+
<?xml version="1.0" encoding="UTF-8"?>
|
|
169
|
+
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
|
170
|
+
<url><loc>http://localhost:3000/second</loc></url>
|
|
171
|
+
</urlset>
|
|
172
|
+
XML
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
executor = RecordingExecutor.new
|
|
176
|
+
parser = Crawlscope::Sitemap.new(path: File.join(dir, "sitemap.xml"), fetch_executor: executor)
|
|
177
|
+
|
|
178
|
+
assert_equal ["http://localhost:3000/first", "http://localhost:3000/second"], parser.urls(base_url: "http://localhost:3000")
|
|
179
|
+
assert_equal [[File.join(dir, "first.xml"), File.join(dir, "second.xml")]], executor.batches
|
|
180
|
+
end
|
|
181
|
+
end
|
|
130
182
|
end
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
$LOAD_PATH.unshift File.expand_path("../../lib", __dir__)
|
|
4
|
+
|
|
5
|
+
require "bundler/setup"
|
|
6
|
+
require "crawlscope"
|
|
7
|
+
require "json"
|
|
8
|
+
require "socket"
|
|
9
|
+
require "time"
|
|
10
|
+
|
|
11
|
+
class DelayedHttpServer
|
|
12
|
+
attr_reader :base_url
|
|
13
|
+
|
|
14
|
+
def initialize(page_count:, delay_seconds:)
|
|
15
|
+
@page_count = page_count
|
|
16
|
+
@delay_seconds = delay_seconds
|
|
17
|
+
@server = TCPServer.new("127.0.0.1", 0)
|
|
18
|
+
@base_url = "http://127.0.0.1:#{@server.addr[1]}"
|
|
19
|
+
@threads = []
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def start
|
|
23
|
+
@thread = Thread.new do
|
|
24
|
+
loop do
|
|
25
|
+
socket = @server.accept
|
|
26
|
+
@threads << Thread.new(socket) { |client| respond(client) }
|
|
27
|
+
rescue IOError
|
|
28
|
+
break
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def stop
|
|
34
|
+
@server.close
|
|
35
|
+
@thread&.join
|
|
36
|
+
@threads.each(&:join)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
private
|
|
40
|
+
|
|
41
|
+
def respond(socket)
|
|
42
|
+
request_line = socket.gets.to_s
|
|
43
|
+
path = request_line.split[1].to_s
|
|
44
|
+
read_headers(socket)
|
|
45
|
+
|
|
46
|
+
if path == "/sitemap.xml"
|
|
47
|
+
write_response(socket, sitemap_xml, content_type: "application/xml")
|
|
48
|
+
else
|
|
49
|
+
sleep @delay_seconds
|
|
50
|
+
write_response(socket, page_html(path), content_type: "text/html")
|
|
51
|
+
end
|
|
52
|
+
ensure
|
|
53
|
+
socket.close
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def read_headers(socket)
|
|
57
|
+
loop do
|
|
58
|
+
line = socket.gets
|
|
59
|
+
break if line.nil? || line == "\r\n"
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def sitemap_xml
|
|
64
|
+
urls = (1..@page_count).map do |index|
|
|
65
|
+
"<url><loc>#{@base_url}/pages/#{index}</loc></url>"
|
|
66
|
+
end.join
|
|
67
|
+
|
|
68
|
+
%(<?xml version="1.0" encoding="UTF-8"?><urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">#{urls}</urlset>)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def page_html(path)
|
|
72
|
+
<<~HTML
|
|
73
|
+
<html>
|
|
74
|
+
<head><title>#{path}</title></head>
|
|
75
|
+
<body><main><h1>#{path}</h1><p>#{path} benchmark page</p></main></body>
|
|
76
|
+
</html>
|
|
77
|
+
HTML
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def write_response(socket, body, content_type:)
|
|
81
|
+
socket.write "HTTP/1.1 200 OK\r\n"
|
|
82
|
+
socket.write "Content-Type: #{content_type}\r\n"
|
|
83
|
+
socket.write "Content-Length: #{body.bytesize}\r\n"
|
|
84
|
+
socket.write "Connection: close\r\n"
|
|
85
|
+
socket.write "\r\n"
|
|
86
|
+
socket.write body
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def measure(name, base_url:, concurrency:, fetch_executor:)
|
|
91
|
+
started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
92
|
+
|
|
93
|
+
result = Crawlscope::Crawl.new(
|
|
94
|
+
base_url: base_url,
|
|
95
|
+
sitemap_path: "#{base_url}/sitemap.xml",
|
|
96
|
+
rules: [],
|
|
97
|
+
schema_registry: Crawlscope::SchemaRegistry.default,
|
|
98
|
+
concurrency: concurrency,
|
|
99
|
+
fetch_executor: fetch_executor
|
|
100
|
+
).call
|
|
101
|
+
|
|
102
|
+
elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - started_at
|
|
103
|
+
[name, {seconds: elapsed.round(3), pages: result.pages.size, issues: result.issues.size}]
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
server = DelayedHttpServer.new(page_count: 24, delay_seconds: 0.08)
|
|
107
|
+
server.start
|
|
108
|
+
|
|
109
|
+
begin
|
|
110
|
+
results = {}
|
|
111
|
+
[
|
|
112
|
+
measure("threaded_concurrency_1", base_url: server.base_url, concurrency: 1, fetch_executor: :threaded),
|
|
113
|
+
measure("threaded_concurrency_8", base_url: server.base_url, concurrency: 8, fetch_executor: :threaded),
|
|
114
|
+
measure("async_concurrency_8", base_url: server.base_url, concurrency: 8, fetch_executor: :async)
|
|
115
|
+
].each { |name, result| results[name] = result }
|
|
116
|
+
|
|
117
|
+
sequential = results.fetch("threaded_concurrency_1").fetch(:seconds)
|
|
118
|
+
threaded = results.fetch("threaded_concurrency_8").fetch(:seconds)
|
|
119
|
+
async = results.fetch("async_concurrency_8").fetch(:seconds)
|
|
120
|
+
|
|
121
|
+
abort "async benchmark failed: async was not meaningfully faster than sequential" unless async < sequential * 0.6
|
|
122
|
+
abort "async benchmark failed: async was more than 2x slower than threaded" if async > threaded * 2.0
|
|
123
|
+
|
|
124
|
+
puts JSON.pretty_generate(results)
|
|
125
|
+
ensure
|
|
126
|
+
server.stop
|
|
127
|
+
end
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
$LOAD_PATH.unshift File.expand_path("../../lib", __dir__)
|
|
4
|
+
|
|
5
|
+
require "bundler/setup"
|
|
6
|
+
require "crawlscope"
|
|
7
|
+
require "json"
|
|
8
|
+
require "socket"
|
|
9
|
+
|
|
10
|
+
class MatrixHttpServer
|
|
11
|
+
attr_reader :base_url
|
|
12
|
+
|
|
13
|
+
def initialize(page_count:, delay_seconds:, link_targets: false)
|
|
14
|
+
@page_count = page_count
|
|
15
|
+
@delay_seconds = delay_seconds
|
|
16
|
+
@link_targets = link_targets
|
|
17
|
+
@server = TCPServer.new("127.0.0.1", 0)
|
|
18
|
+
@base_url = "http://127.0.0.1:#{@server.addr[1]}"
|
|
19
|
+
@threads = []
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def start
|
|
23
|
+
@thread = Thread.new do
|
|
24
|
+
loop do
|
|
25
|
+
socket = @server.accept
|
|
26
|
+
@threads << Thread.new(socket) { |client| respond(client) }
|
|
27
|
+
rescue IOError
|
|
28
|
+
break
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def stop
|
|
34
|
+
@server.close
|
|
35
|
+
@thread&.join
|
|
36
|
+
@threads.each(&:join)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
private
|
|
40
|
+
|
|
41
|
+
def respond(socket)
|
|
42
|
+
request_line = socket.gets.to_s
|
|
43
|
+
path = request_line.split[1].to_s
|
|
44
|
+
read_headers(socket)
|
|
45
|
+
|
|
46
|
+
if path == "/sitemap.xml"
|
|
47
|
+
write_response(socket, sitemap_xml, content_type: "application/xml")
|
|
48
|
+
else
|
|
49
|
+
sleep @delay_seconds
|
|
50
|
+
write_response(socket, page_html(path), content_type: "text/html")
|
|
51
|
+
end
|
|
52
|
+
ensure
|
|
53
|
+
socket.close
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def read_headers(socket)
|
|
57
|
+
loop do
|
|
58
|
+
line = socket.gets
|
|
59
|
+
break if line.nil? || line == "\r\n"
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def sitemap_xml
|
|
64
|
+
paths = @link_targets ? ["/seed"] : (1..@page_count).map { |index| "/pages/#{index}" }
|
|
65
|
+
urls = paths.map { |path| "<url><loc>#{@base_url}#{path}</loc></url>" }.join
|
|
66
|
+
|
|
67
|
+
%(<?xml version="1.0" encoding="UTF-8"?><urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">#{urls}</urlset>)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def page_html(path)
|
|
71
|
+
links = if @link_targets && path == "/seed"
|
|
72
|
+
(1..@page_count).map { |index| %(<a href="/targets/#{index}">Target #{index}</a>) }.join
|
|
73
|
+
else
|
|
74
|
+
""
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
<<~HTML
|
|
78
|
+
<html>
|
|
79
|
+
<head>
|
|
80
|
+
<title>#{path}</title>
|
|
81
|
+
<meta name="robots" content="noindex">
|
|
82
|
+
</head>
|
|
83
|
+
<body>
|
|
84
|
+
<main><h1>#{path}</h1><p>#{path} benchmark page</p>#{links}</main>
|
|
85
|
+
</body>
|
|
86
|
+
</html>
|
|
87
|
+
HTML
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def write_response(socket, body, content_type:)
|
|
91
|
+
socket.write "HTTP/1.1 200 OK\r\n"
|
|
92
|
+
socket.write "Content-Type: #{content_type}\r\n"
|
|
93
|
+
socket.write "Content-Length: #{body.bytesize}\r\n"
|
|
94
|
+
socket.write "Connection: close\r\n"
|
|
95
|
+
socket.write "\r\n"
|
|
96
|
+
socket.write body
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def measure(base_url:, concurrency:, fetch_executor:, rules:)
|
|
101
|
+
started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
102
|
+
|
|
103
|
+
result = Crawlscope::Crawl.new(
|
|
104
|
+
base_url: base_url,
|
|
105
|
+
sitemap_path: "#{base_url}/sitemap.xml",
|
|
106
|
+
rules: rules,
|
|
107
|
+
schema_registry: Crawlscope::SchemaRegistry.default,
|
|
108
|
+
concurrency: concurrency,
|
|
109
|
+
fetch_executor: fetch_executor
|
|
110
|
+
).call
|
|
111
|
+
|
|
112
|
+
elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - started_at
|
|
113
|
+
{seconds: elapsed, pages: result.pages.size, issues: result.issues.size}
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def median(values)
|
|
117
|
+
sorted = values.sort
|
|
118
|
+
sorted[sorted.length / 2]
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def run_case(name:, page_count:, delay_seconds:, concurrency:, link_targets: false)
|
|
122
|
+
server = MatrixHttpServer.new(page_count: page_count, delay_seconds: delay_seconds, link_targets: link_targets)
|
|
123
|
+
server.start
|
|
124
|
+
|
|
125
|
+
rules = link_targets ? [Crawlscope::Rules::Links.new] : []
|
|
126
|
+
threaded = []
|
|
127
|
+
async = []
|
|
128
|
+
|
|
129
|
+
3.times do
|
|
130
|
+
threaded << measure(base_url: server.base_url, concurrency: concurrency, fetch_executor: :threaded, rules: rules)
|
|
131
|
+
async << measure(base_url: server.base_url, concurrency: concurrency, fetch_executor: :async, rules: rules)
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
threaded_seconds = median(threaded.map { |result| result.fetch(:seconds) })
|
|
135
|
+
async_seconds = median(async.map { |result| result.fetch(:seconds) })
|
|
136
|
+
|
|
137
|
+
{
|
|
138
|
+
name: name,
|
|
139
|
+
page_count: page_count,
|
|
140
|
+
delay_seconds: delay_seconds,
|
|
141
|
+
concurrency: concurrency,
|
|
142
|
+
link_targets: link_targets,
|
|
143
|
+
threaded_seconds: threaded_seconds.round(3),
|
|
144
|
+
async_seconds: async_seconds.round(3),
|
|
145
|
+
async_vs_threaded: (threaded_seconds / async_seconds).round(2),
|
|
146
|
+
pages: async.first.fetch(:pages),
|
|
147
|
+
issues: async.first.fetch(:issues)
|
|
148
|
+
}
|
|
149
|
+
ensure
|
|
150
|
+
server&.stop
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
cases = [
|
|
154
|
+
{name: "direct_pages_c8", page_count: 48, delay_seconds: 0.02, concurrency: 8},
|
|
155
|
+
{name: "direct_pages_c16", page_count: 48, delay_seconds: 0.02, concurrency: 16},
|
|
156
|
+
{name: "slow_direct_pages_c8", page_count: 48, delay_seconds: 0.08, concurrency: 8},
|
|
157
|
+
{name: "slow_direct_pages_c16", page_count: 48, delay_seconds: 0.08, concurrency: 16},
|
|
158
|
+
{name: "link_targets_c8", page_count: 48, delay_seconds: 0.02, concurrency: 8, link_targets: true},
|
|
159
|
+
{name: "slow_link_targets_c8", page_count: 48, delay_seconds: 0.08, concurrency: 8, link_targets: true}
|
|
160
|
+
]
|
|
161
|
+
|
|
162
|
+
puts JSON.pretty_generate(cases.map { |attributes| run_case(**attributes) })
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
$LOAD_PATH.unshift File.expand_path("../../lib", __dir__)
|
|
4
|
+
|
|
5
|
+
require "bundler/setup"
|
|
6
|
+
require "crawlscope"
|
|
7
|
+
require "json"
|
|
8
|
+
require "socket"
|
|
9
|
+
|
|
10
|
+
class DelayedSitemapServer
|
|
11
|
+
attr_reader :base_url
|
|
12
|
+
|
|
13
|
+
def initialize(child_count:, delay_seconds:)
|
|
14
|
+
@child_count = child_count
|
|
15
|
+
@delay_seconds = delay_seconds
|
|
16
|
+
@server = TCPServer.new("127.0.0.1", 0)
|
|
17
|
+
@base_url = "http://127.0.0.1:#{@server.addr[1]}"
|
|
18
|
+
@threads = []
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def start
|
|
22
|
+
@thread = Thread.new do
|
|
23
|
+
loop do
|
|
24
|
+
socket = @server.accept
|
|
25
|
+
@threads << Thread.new(socket) { |client| respond(client) }
|
|
26
|
+
rescue IOError
|
|
27
|
+
break
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def stop
|
|
33
|
+
@server.close
|
|
34
|
+
@thread&.join
|
|
35
|
+
@threads.each(&:join)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
private
|
|
39
|
+
|
|
40
|
+
def respond(socket)
|
|
41
|
+
request_line = socket.gets.to_s
|
|
42
|
+
path = request_line.split[1].to_s
|
|
43
|
+
read_headers(socket)
|
|
44
|
+
|
|
45
|
+
if path == "/sitemap.xml"
|
|
46
|
+
write_response(socket, sitemap_index, content_type: "application/xml")
|
|
47
|
+
else
|
|
48
|
+
sleep @delay_seconds
|
|
49
|
+
write_response(socket, child_sitemap(path), content_type: "application/xml")
|
|
50
|
+
end
|
|
51
|
+
ensure
|
|
52
|
+
socket.close
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def read_headers(socket)
|
|
56
|
+
loop do
|
|
57
|
+
line = socket.gets
|
|
58
|
+
break if line.nil? || line == "\r\n"
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def sitemap_index
|
|
63
|
+
children = (1..@child_count).map do |index|
|
|
64
|
+
"<sitemap><loc>#{@base_url}/sitemaps/#{index}.xml</loc></sitemap>"
|
|
65
|
+
end.join
|
|
66
|
+
|
|
67
|
+
%(<?xml version="1.0" encoding="UTF-8"?><sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">#{children}</sitemapindex>)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def child_sitemap(path)
|
|
71
|
+
index = File.basename(path, ".xml")
|
|
72
|
+
|
|
73
|
+
%(<?xml version="1.0" encoding="UTF-8"?><urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"><url><loc>#{@base_url}/pages/#{index}</loc></url></urlset>)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def write_response(socket, body, content_type:)
|
|
77
|
+
socket.write "HTTP/1.1 200 OK\r\n"
|
|
78
|
+
socket.write "Content-Type: #{content_type}\r\n"
|
|
79
|
+
socket.write "Content-Length: #{body.bytesize}\r\n"
|
|
80
|
+
socket.write "Connection: close\r\n"
|
|
81
|
+
socket.write "\r\n"
|
|
82
|
+
socket.write body
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def measure(name, base_url:, concurrency:, fetch_executor:)
|
|
87
|
+
started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
88
|
+
|
|
89
|
+
urls = Crawlscope::Sitemap.new(
|
|
90
|
+
path: "#{base_url}/sitemap.xml",
|
|
91
|
+
concurrency: concurrency,
|
|
92
|
+
fetch_executor: fetch_executor,
|
|
93
|
+
timeout_seconds: 5
|
|
94
|
+
).urls(base_url: base_url)
|
|
95
|
+
|
|
96
|
+
elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - started_at
|
|
97
|
+
[name, {seconds: elapsed.round(3), urls: urls.size}]
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
server = DelayedSitemapServer.new(child_count: 24, delay_seconds: 0.08)
|
|
101
|
+
server.start
|
|
102
|
+
|
|
103
|
+
begin
|
|
104
|
+
results = {}
|
|
105
|
+
[
|
|
106
|
+
measure("threaded_concurrency_1", base_url: server.base_url, concurrency: 1, fetch_executor: :threaded),
|
|
107
|
+
measure("threaded_concurrency_8", base_url: server.base_url, concurrency: 8, fetch_executor: :threaded),
|
|
108
|
+
measure("async_concurrency_8", base_url: server.base_url, concurrency: 8, fetch_executor: :async)
|
|
109
|
+
].each { |name, result| results[name] = result }
|
|
110
|
+
|
|
111
|
+
sequential = results.fetch("threaded_concurrency_1").fetch(:seconds)
|
|
112
|
+
threaded = results.fetch("threaded_concurrency_8").fetch(:seconds)
|
|
113
|
+
async = results.fetch("async_concurrency_8").fetch(:seconds)
|
|
114
|
+
|
|
115
|
+
abort "sitemap benchmark failed: threaded parallelism was not at least 2x faster" unless threaded < sequential * 0.5
|
|
116
|
+
abort "sitemap benchmark failed: async parallelism was not at least 2x faster" unless async < sequential * 0.5
|
|
117
|
+
|
|
118
|
+
puts JSON.pretty_generate(results)
|
|
119
|
+
ensure
|
|
120
|
+
server.stop
|
|
121
|
+
end
|