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.
Files changed (44) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +36 -0
  3. data/README.md +38 -0
  4. data/lib/crawlscope/cli.rb +20 -1
  5. data/lib/crawlscope/configuration.rb +10 -1
  6. data/lib/crawlscope/context.rb +1 -1
  7. data/lib/crawlscope/crawl.rb +74 -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/rake_tasks.rb +27 -12
  15. data/lib/crawlscope/reporter.rb +131 -7
  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 +155 -20
  19. data/lib/crawlscope/rules/links.rb +379 -12
  20. data/lib/crawlscope/rules/metadata.rb +61 -6
  21. data/lib/crawlscope/rules/structured_data.rb +31 -0
  22. data/lib/crawlscope/rules/uniqueness.rb +45 -4
  23. data/lib/crawlscope/sitemap.rb +39 -12
  24. data/lib/crawlscope/version.rb +1 -1
  25. data/lib/tasks/crawlscope_tasks.rake +24 -24
  26. data/test/crawlscope/cli_test.rb +29 -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 +167 -3
  30. data/test/crawlscope/crawler_test.rb +61 -0
  31. data/test/crawlscope/fetch_executor_test.rb +44 -0
  32. data/test/crawlscope/indexability_rule_test.rb +33 -0
  33. data/test/crawlscope/links_rule_test.rb +249 -3
  34. data/test/crawlscope/metadata_rule_test.rb +36 -0
  35. data/test/crawlscope/rake_tasks_test.rb +70 -0
  36. data/test/crawlscope/reporter_test.rb +136 -7
  37. data/test/crawlscope/result_test.rb +35 -0
  38. data/test/crawlscope/sitemap_test.rb +76 -0
  39. data/test/crawlscope/structured_data_rule_test.rb +56 -0
  40. data/test/crawlscope/uniqueness_rule_test.rb +17 -2
  41. data/test/performance/async_fetch_benchmark.rb +127 -0
  42. data/test/performance/fetch_executor_matrix.rb +162 -0
  43. data/test/performance/sitemap_expansion_benchmark.rb +121 -0
  44. metadata +39 -2
@@ -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(
@@ -49,6 +62,30 @@ class CrawlscopeSitemapTest < Minitest::Test
49
62
  assert_equal ["https://www.example.com/features/reviews"], parser.urls(base_url: "https://www.example.com")
50
63
  end
51
64
 
65
+ def test_remote_sitemap_http_error_is_explicit
66
+ stub_request(:get, "https://www.example.com/sitemap.xml")
67
+ .to_return(status: 500, body: "<html><body>Error</body></html>")
68
+
69
+ parser = Crawlscope::Sitemap.new(path: "https://www.example.com/sitemap.xml")
70
+
71
+ error = assert_raises(Crawlscope::ValidationError) do
72
+ parser.urls(base_url: "https://www.example.com")
73
+ end
74
+ assert_equal "Sitemap https://www.example.com/sitemap.xml returned HTTP 500", error.message
75
+ end
76
+
77
+ def test_invalid_sitemap_root_is_explicit
78
+ stub_request(:get, "https://www.example.com/sitemap.xml")
79
+ .to_return(status: 200, body: "<html><body>Error</body></html>")
80
+
81
+ parser = Crawlscope::Sitemap.new(path: "https://www.example.com/sitemap.xml")
82
+
83
+ error = assert_raises(Crawlscope::ValidationError) do
84
+ parser.urls(base_url: "https://www.example.com")
85
+ end
86
+ assert_equal 'Sitemap https://www.example.com/sitemap.xml has unexpected root "html"', error.message
87
+ end
88
+
52
89
  def test_rebases_remote_sitemap_index_children_to_base_url
53
90
  stub_request(:get, "http://localhost:3000/sitemap.xml")
54
91
  .to_return(
@@ -103,4 +140,43 @@ class CrawlscopeSitemapTest < Minitest::Test
103
140
  assert_equal ["http://localhost:3000/features/reviews"], parser.urls(base_url: "http://localhost:3000")
104
141
  end
105
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
106
182
  end
@@ -79,6 +79,62 @@ class CrawlscopeStructuredDataRuleTest < Minitest::Test
79
79
  assert_equal ["json-ld", "microdata"], issues.to_a.first.details[:expected_sources]
80
80
  end
81
81
 
82
+ def test_reports_structured_data_missing_type
83
+ issues = Crawlscope::IssueCollection.new
84
+ rule = Crawlscope::Rules::StructuredData.new
85
+ page = page(
86
+ url: "https://example.com/articles/test",
87
+ body: <<~HTML
88
+ <html>
89
+ <head>
90
+ <script type="application/ld+json">
91
+ {"@context":"https://schema.org","headline":"Untyped article"}
92
+ </script>
93
+ </head>
94
+ <body><h1>Article</h1></body>
95
+ </html>
96
+ HTML
97
+ )
98
+
99
+ rule.call(
100
+ urls: [page.url],
101
+ pages: [page],
102
+ issues: issues,
103
+ context: {schema_registry: Crawlscope::SchemaRegistry.default}
104
+ )
105
+
106
+ assert_includes issues.to_a.map(&:code), :structured_data_missing_type
107
+ end
108
+
109
+ def test_reports_graph_entries_missing_type
110
+ issues = Crawlscope::IssueCollection.new
111
+ rule = Crawlscope::Rules::StructuredData.new
112
+ page = page(
113
+ url: "https://example.com/articles/test",
114
+ body: <<~HTML
115
+ <html>
116
+ <head>
117
+ <script type="application/ld+json">
118
+ {"@context":"https://schema.org","@type":"WebPage","@graph":[{"name":"Untyped node"}]}
119
+ </script>
120
+ </head>
121
+ <body><h1>Article</h1></body>
122
+ </html>
123
+ HTML
124
+ )
125
+
126
+ rule.call(
127
+ urls: [page.url],
128
+ pages: [page],
129
+ issues: issues,
130
+ context: {schema_registry: Crawlscope::SchemaRegistry.default}
131
+ )
132
+
133
+ issue = issues.to_a.find { |item| item.code == :structured_data_missing_type }
134
+ assert issue
135
+ assert_equal ["$.@graph[0]"], issue.details[:paths]
136
+ end
137
+
82
138
  def test_validates_job_posting_markup
83
139
  issues = Crawlscope::IssueCollection.new
84
140
  rule = Crawlscope::Rules::StructuredData.new
@@ -13,7 +13,20 @@ class CrawlscopeUniquenessRuleTest < Minitest::Test
13
13
 
14
14
  rule.call(urls: pages.map(&:url), pages: pages, issues: issues, context: {})
15
15
 
16
- assert_equal %i[duplicate_content_fingerprint duplicate_meta_description duplicate_title].sort, issues.to_a.map(&:code).sort
16
+ assert_equal %i[duplicate_content_fingerprint duplicate_meta_description duplicate_pages_without_canonical duplicate_title].sort, issues.to_a.map(&:code).sort
17
+ end
18
+
19
+ def test_allows_duplicate_pages_when_canonicals_are_present
20
+ issues = Crawlscope::IssueCollection.new
21
+ rule = Crawlscope::Rules::Uniqueness.new
22
+ pages = [
23
+ page(url: "https://example.com/a", canonical: "https://example.com/a"),
24
+ page(url: "https://example.com/b", canonical: "https://example.com/a")
25
+ ]
26
+
27
+ rule.call(urls: pages.map(&:url), pages: pages, issues: issues, context: {})
28
+
29
+ refute_includes issues.to_a.map(&:code), :duplicate_pages_without_canonical
17
30
  end
18
31
 
19
32
  def test_reports_near_duplicate_content
@@ -59,13 +72,15 @@ class CrawlscopeUniquenessRuleTest < Minitest::Test
59
72
  TEXT
60
73
  end
61
74
 
62
- def page(url:, content: nil)
75
+ def page(url:, content: nil, canonical: nil)
63
76
  repeated_text = content || ("Useful content " * 30).strip
77
+ canonical_tag = canonical ? %(<link rel="canonical" href="#{canonical}">) : ""
64
78
  body = <<~HTML
65
79
  <html>
66
80
  <head>
67
81
  <title>Example Title</title>
68
82
  <meta name="description" content="Example description">
83
+ #{canonical_tag}
69
84
  </head>
70
85
  <body>
71
86
  <main>#{repeated_text}</main>
@@ -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
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: crawlscope
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.4.0
4
+ version: 0.6.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Paulo Fidalgo
@@ -24,6 +24,34 @@ dependencies:
24
24
  - - ">="
25
25
  - !ruby/object:Gem::Version
26
26
  version: '1.3'
27
+ - !ruby/object:Gem::Dependency
28
+ name: async
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - ">="
32
+ - !ruby/object:Gem::Version
33
+ version: '2.0'
34
+ type: :runtime
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - ">="
39
+ - !ruby/object:Gem::Version
40
+ version: '2.0'
41
+ - !ruby/object:Gem::Dependency
42
+ name: async-http-faraday
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - ">="
46
+ - !ruby/object:Gem::Version
47
+ version: '0.22'
48
+ type: :runtime
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - ">="
53
+ - !ruby/object:Gem::Version
54
+ version: '0.22'
27
55
  - !ruby/object:Gem::Dependency
28
56
  name: faraday
29
57
  requirement: !ruby/object:Gem::Requirement
@@ -200,6 +228,9 @@ files:
200
228
  - lib/crawlscope/crawl.rb
201
229
  - lib/crawlscope/crawler.rb
202
230
  - lib/crawlscope/document_text.rb
231
+ - lib/crawlscope/fetch_executor.rb
232
+ - lib/crawlscope/fetch_executor/async.rb
233
+ - lib/crawlscope/fetch_executor/threaded.rb
203
234
  - lib/crawlscope/http.rb
204
235
  - lib/crawlscope/issue.rb
205
236
  - lib/crawlscope/issue_collection.rb
@@ -234,12 +265,15 @@ files:
234
265
  - test/crawlscope/content_quality_rule_test.rb
235
266
  - test/crawlscope/crawl_test.rb
236
267
  - test/crawlscope/crawler_test.rb
268
+ - test/crawlscope/fetch_executor_test.rb
237
269
  - test/crawlscope/http_test.rb
238
270
  - test/crawlscope/indexability_rule_test.rb
239
271
  - test/crawlscope/links_rule_test.rb
240
272
  - test/crawlscope/loader_test.rb
241
273
  - test/crawlscope/metadata_rule_test.rb
274
+ - test/crawlscope/rake_tasks_test.rb
242
275
  - test/crawlscope/reporter_test.rb
276
+ - test/crawlscope/result_test.rb
243
277
  - test/crawlscope/rule_registry_test.rb
244
278
  - test/crawlscope/run_test.rb
245
279
  - test/crawlscope/schema_registry_test.rb
@@ -252,6 +286,9 @@ files:
252
286
  - test/crawlscope/structured_data_writer_test.rb
253
287
  - test/crawlscope/uniqueness_rule_test.rb
254
288
  - test/crawlscope/url_test.rb
289
+ - test/performance/async_fetch_benchmark.rb
290
+ - test/performance/fetch_executor_matrix.rb
291
+ - test/performance/sitemap_expansion_benchmark.rb
255
292
  - test/release_task_test.rb
256
293
  - test/test_helper.rb
257
294
  homepage: https://www.ethos-link.com/opensource/crawlscope
@@ -274,7 +311,7 @@ required_ruby_version: !ruby/object:Gem::Requirement
274
311
  requirements:
275
312
  - - ">="
276
313
  - !ruby/object:Gem::Version
277
- version: 3.2.0
314
+ version: 3.3.0
278
315
  required_rubygems_version: !ruby/object:Gem::Requirement
279
316
  requirements:
280
317
  - - ">="