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.
Files changed (40) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +38 -0
  3. data/README.md +50 -1
  4. data/lib/crawlscope/cli.rb +16 -0
  5. data/lib/crawlscope/configuration.rb +10 -1
  6. data/lib/crawlscope/context.rb +1 -1
  7. data/lib/crawlscope/crawl.rb +72 -14
  8. data/lib/crawlscope/crawler.rb +3 -17
  9. data/lib/crawlscope/document_text.rb +7 -2
  10. data/lib/crawlscope/fetch_executor/async.rb +32 -0
  11. data/lib/crawlscope/fetch_executor/threaded.rb +32 -0
  12. data/lib/crawlscope/fetch_executor.rb +43 -0
  13. data/lib/crawlscope/http.rb +7 -1
  14. data/lib/crawlscope/railtie.rb +1 -1
  15. data/lib/crawlscope/reporter.rb +123 -14
  16. data/lib/crawlscope/result.rb +1 -1
  17. data/lib/crawlscope/rules/content_quality.rb +1 -1
  18. data/lib/crawlscope/rules/indexability.rb +28 -6
  19. data/lib/crawlscope/rules/links.rb +80 -16
  20. data/lib/crawlscope/rules/uniqueness.rb +23 -4
  21. data/lib/crawlscope/sitemap.rb +30 -11
  22. data/lib/crawlscope/tasks.rb +8 -0
  23. data/lib/crawlscope/version.rb +1 -1
  24. data/lib/crawlscope.rb +1 -0
  25. data/lib/tasks/crawlscope_tasks.rake +1 -1
  26. data/test/crawlscope/cli_test.rb +28 -2
  27. data/test/crawlscope/configuration_test.rb +21 -0
  28. data/test/crawlscope/content_quality_rule_test.rb +18 -0
  29. data/test/crawlscope/crawl_test.rb +142 -4
  30. data/test/crawlscope/crawler_test.rb +61 -0
  31. data/test/crawlscope/fetch_executor_test.rb +44 -0
  32. data/test/crawlscope/links_rule_test.rb +101 -0
  33. data/test/crawlscope/loader_test.rb +37 -0
  34. data/test/crawlscope/reporter_test.rb +136 -11
  35. data/test/crawlscope/result_test.rb +35 -0
  36. data/test/crawlscope/sitemap_test.rb +52 -0
  37. data/test/performance/async_fetch_benchmark.rb +127 -0
  38. data/test/performance/fetch_executor_matrix.rb +162 -0
  39. data/test/performance/sitemap_expansion_benchmark.rb +121 -0
  40. 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 test_reports_failed_result_with_grouped_counts_and_offenses
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
- issues.add(code: :broken_internal_link, severity: :notice, category: :links, url: "https://example.com/b", message: "broken internal link", details: {})
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
- assert_includes output, "Severity:"
46
- assert_includes output, "notice: 1"
47
- assert_includes output, "warning: 1"
48
- assert_includes output, "Category:"
49
- assert_includes output, "links: 1"
50
- assert_includes output, "metadata: 1"
51
- assert_includes output, " - [warning] missing_title https://example.com/a missing <title>"
52
- assert_includes output, " - [notice] broken_internal_link https://example.com/b broken internal link"
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