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
@@ -26,11 +26,10 @@ module Crawlscope
26
26
  end
27
27
 
28
28
  def call(urls:, pages:, issues:, context:)
29
- page_summaries = pages.filter_map do |page|
30
- next unless page.html?
29
+ @concurrency = context_value(context, :concurrency, default: 1)
30
+ @fetch_executor = context_value(context, :fetch_executor)
31
31
 
32
- summary_for(page)
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
@@ -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
- return [] if visited.include?(source)
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).flat_map do |node|
31
- child_source = resolve_child_source(source, node.text.to_s.strip, base_url: base_url)
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).body
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
- @connection ||= Faraday.new do |faraday|
94
+ Faraday.new do |faraday|
73
95
  faraday.response :follow_redirects, limit: Http::MAX_REDIRECTS
74
- faraday.options.timeout = 20
75
- faraday.options.open_timeout = 20
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Crawlscope
4
- VERSION = "0.4.0"
4
+ VERSION = "0.6.0"
5
5
  end
@@ -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 or more URLs. ENV: URL (semicolon-separated), DEBUG=1, JS=1, TIMEOUT, NETWORK_IDLE_TIMEOUT, REPORT_PATH, SUMMARY=1"
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
@@ -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
- refute result.ok?
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