crawlscope 0.7.2 → 0.8.1

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 (45) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +27 -0
  3. data/README.md +95 -18
  4. data/lib/crawlscope/browser.rb +18 -1
  5. data/lib/crawlscope/configuration.rb +6 -1
  6. data/lib/crawlscope/crawl.rb +5 -3
  7. data/lib/crawlscope/http.rb +13 -2
  8. data/lib/crawlscope/page.rb +8 -0
  9. data/lib/crawlscope/reporter.rb +144 -61
  10. data/lib/crawlscope/request_headers.rb +34 -0
  11. data/lib/crawlscope/result.rb +4 -0
  12. data/lib/crawlscope/rules/content_quality.rb +5 -5
  13. data/lib/crawlscope/rules/indexability.rb +1 -1
  14. data/lib/crawlscope/rules/links.rb +28 -34
  15. data/lib/crawlscope/rules/metadata.rb +25 -25
  16. data/lib/crawlscope/rules/structured_data.rb +13 -4
  17. data/lib/crawlscope/rules/uniqueness.rb +9 -7
  18. data/lib/crawlscope/run.rb +0 -10
  19. data/lib/crawlscope/schema_registry.rb +17 -1
  20. data/lib/crawlscope/server_timing/reporter.rb +114 -0
  21. data/lib/crawlscope/server_timing/summary.rb +107 -0
  22. data/lib/crawlscope/server_timing.rb +138 -0
  23. data/lib/crawlscope/sitemap.rb +17 -5
  24. data/lib/crawlscope/structured_data/error_formatter.rb +42 -0
  25. data/lib/crawlscope/structured_data/reporter.rb +10 -10
  26. data/lib/crawlscope/version.rb +1 -1
  27. data/lib/generators/crawlscope/templates/initializer.rb.tt +1 -1
  28. data/test/crawlscope/browser_test.rb +56 -3
  29. data/test/crawlscope/configuration_test.rb +50 -10
  30. data/test/crawlscope/crawl_test.rb +65 -0
  31. data/test/crawlscope/http_test.rb +60 -1
  32. data/test/crawlscope/install_generator_test.rb +9 -11
  33. data/test/crawlscope/links_rule_test.rb +40 -4
  34. data/test/crawlscope/metadata_rule_test.rb +37 -4
  35. data/test/crawlscope/reporter_test.rb +249 -20
  36. data/test/crawlscope/run_test.rb +4 -12
  37. data/test/crawlscope/schema_registry_test.rb +33 -0
  38. data/test/crawlscope/server_timing_summary_test.rb +87 -0
  39. data/test/crawlscope/server_timing_test.rb +101 -0
  40. data/test/crawlscope/sitemap_test.rb +21 -0
  41. data/test/crawlscope/structured_data_error_formatter_test.rb +46 -0
  42. data/test/crawlscope/structured_data_reporter_test.rb +26 -0
  43. data/test/crawlscope/structured_data_rule_test.rb +2 -0
  44. data/test/crawlscope/uniqueness_rule_test.rb +46 -3
  45. metadata +10 -2
@@ -14,6 +14,7 @@ class CrawlscopeConfigurationTest < Minitest::Test
14
14
  config.site_name = -> { "Example" }
15
15
  config.concurrency = -> { 4 }
16
16
  config.fetch_executor = -> { :threaded }
17
+ config.profile_token = -> { "profile-token" }
17
18
  end
18
19
 
19
20
  audit = Crawlscope.configuration.audit
@@ -22,6 +23,7 @@ class CrawlscopeConfigurationTest < Minitest::Test
22
23
  assert_equal "/tmp/sitemap.xml", audit.instance_variable_get(:@sitemap_path)
23
24
  assert_equal 4, audit.instance_variable_get(:@concurrency)
24
25
  assert_equal :threaded, audit.instance_variable_get(:@fetch_executor)
26
+ assert_equal "profile-token", audit.instance_variable_get(:@profile_token)
25
27
  assert_equal %i[
26
28
  indexability
27
29
  metadata
@@ -53,17 +55,35 @@ class CrawlscopeConfigurationTest < Minitest::Test
53
55
  end
54
56
 
55
57
  def test_defaults_are_normalized
56
- config = Crawlscope::Configuration.new
58
+ with_profile_token(nil) do
59
+ config = Crawlscope::Configuration.new
60
+
61
+ assert_equal [200, 301, 302], config.allowed_statuses
62
+ assert_equal 10, config.concurrency
63
+ assert_equal :async, config.fetch_executor
64
+ assert_equal 4, config.browser_concurrency
65
+ assert_equal 5, config.network_idle_timeout_seconds
66
+ assert_equal :http, config.renderer
67
+ assert_equal 20, config.timeout_seconds
68
+ assert_equal $stdout, config.output
69
+ assert_nil config.profile_token
70
+ assert config.scroll_page?
71
+ end
72
+ end
57
73
 
58
- assert_equal [200, 301, 302], config.allowed_statuses
59
- assert_equal 10, config.concurrency
60
- assert_equal :async, config.fetch_executor
61
- assert_equal 4, config.browser_concurrency
62
- assert_equal 5, config.network_idle_timeout_seconds
63
- assert_equal :http, config.renderer
64
- assert_equal 20, config.timeout_seconds
65
- assert_equal $stdout, config.output
66
- assert config.scroll_page?
74
+ def test_profile_token_defaults_to_the_environment
75
+ with_profile_token("environment-profile-token") do
76
+ assert_equal "environment-profile-token", Crawlscope::Configuration.new.profile_token
77
+ end
78
+ end
79
+
80
+ def test_configured_profile_token_precedes_the_environment
81
+ with_profile_token("environment-profile-token") do
82
+ config = Crawlscope::Configuration.new
83
+ config.profile_token = "configured-profile-token"
84
+
85
+ assert_equal "configured-profile-token", config.profile_token
86
+ end
67
87
  end
68
88
 
69
89
  def test_browser_renderer_defaults_to_threaded_fetch_executor
@@ -119,4 +139,24 @@ class CrawlscopeConfigurationTest < Minitest::Test
119
139
 
120
140
  assert_equal "Crawlscope concurrency must be an integer >= 1", error.message
121
141
  end
142
+
143
+ private
144
+
145
+ def with_profile_token(value)
146
+ previous_value = ENV["CRAWLSCOPE_PROFILE_TOKEN"]
147
+
148
+ if value.nil?
149
+ ENV.delete("CRAWLSCOPE_PROFILE_TOKEN")
150
+ else
151
+ ENV["CRAWLSCOPE_PROFILE_TOKEN"] = value
152
+ end
153
+
154
+ yield
155
+ ensure
156
+ if previous_value.nil?
157
+ ENV.delete("CRAWLSCOPE_PROFILE_TOKEN")
158
+ else
159
+ ENV["CRAWLSCOPE_PROFILE_TOKEN"] = previous_value
160
+ end
161
+ end
122
162
  end
@@ -167,6 +167,35 @@ class CrawlscopeCrawlTest < Minitest::Test
167
167
  ].sort, result.issues.to_a.map(&:code).uniq.sort
168
168
  end
169
169
 
170
+ def test_profile_token_reaches_remote_sitemap_and_page_requests
171
+ sitemap_request = stub_request(:get, "https://example.com/sitemap.xml")
172
+ .with(headers: {"X-Profile-Token" => "profile-token"})
173
+ .to_return(
174
+ status: 200,
175
+ body: <<~XML
176
+ <?xml version="1.0" encoding="UTF-8"?>
177
+ <urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
178
+ <url><loc>https://example.com/about</loc></url>
179
+ </urlset>
180
+ XML
181
+ )
182
+ page_request = stub_request(:get, "https://example.com/about")
183
+ .with(headers: {"X-Profile-Token" => "profile-token"})
184
+ .to_return(status: 200, body: "<html><body>About</body></html>")
185
+
186
+ Crawlscope::Crawl.new(
187
+ base_url: "https://example.com",
188
+ sitemap_path: "https://example.com/sitemap.xml",
189
+ rules: [],
190
+ schema_registry: Crawlscope::SchemaRegistry.default,
191
+ fetch_executor: :threaded,
192
+ profile_token: "profile-token"
193
+ ).call
194
+
195
+ assert_requested sitemap_request
196
+ assert_requested page_request
197
+ end
198
+
170
199
  def test_uses_browser_when_renderer_is_browser
171
200
  File.write(
172
201
  @sitemap_path,
@@ -296,6 +325,42 @@ class CrawlscopeCrawlTest < Minitest::Test
296
325
  assert_includes result.issues.to_a.map(&:code), :sitemap_redirect_url
297
326
  end
298
327
 
328
+ def test_reports_redirects_without_duplicate_destination_content
329
+ File.write(
330
+ @sitemap_path,
331
+ <<~XML
332
+ <?xml version="1.0" encoding="UTF-8"?>
333
+ <urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
334
+ <url><loc>https://example.com/old</loc></url>
335
+ <url><loc>https://example.com/alias</loc></url>
336
+ <url><loc>https://example.com/new</loc></url>
337
+ </urlset>
338
+ XML
339
+ )
340
+ ["old", "alias"].each do |path|
341
+ stub_request(:get, "https://example.com/#{path}")
342
+ .to_return(status: 301, headers: {"Location" => "https://example.com/new"}, body: "")
343
+ end
344
+ body = %(<html><head><title>Current page</title><meta name="description" content="Current description"><link rel="canonical" href="https://example.com/new"></head><body>#{"Useful content " * 30}</body></html>)
345
+ stub_request(:get, "https://example.com/new")
346
+ .to_return(status: 200, headers: {"Content-Type" => "text/html"}, body: body)
347
+
348
+ result = Crawlscope::Crawl.new(
349
+ base_url: "https://example.com",
350
+ sitemap_path: @sitemap_path,
351
+ rules: [Crawlscope::Rules::Uniqueness.new, Crawlscope::Rules::Metadata.new],
352
+ schema_registry: Crawlscope::SchemaRegistry.default,
353
+ fetch_executor: :threaded
354
+ ).call
355
+
356
+ assert_equal 3, result.pages.size
357
+ assert_equal ["https://example.com/old", "https://example.com/alias"], result.issues.select { |issue| issue.code == :sitemap_redirect_url }.map(&:url)
358
+ refute_includes result.issues.to_a.map(&:code), :redirected_page
359
+ assert_empty result.issues.select { |issue| issue.category == :uniqueness }
360
+ assert_empty result.issues.select { |issue| [:canonical_mismatch, :non_canonical_page_in_sitemap].include?(issue.code) }
361
+ assert_equal 1, result.issues.count { |issue| issue.code == :meta_description_too_short }
362
+ end
363
+
299
364
  def test_resolves_uncrawled_link_targets_as_a_bounded_batch
300
365
  File.write(
301
366
  @sitemap_path,
@@ -5,13 +5,24 @@ require "test_helper"
5
5
  class CrawlscopeHttpTest < Minitest::Test
6
6
  def test_fetch_parses_html_response
7
7
  stub_request(:get, "https://example.com/page")
8
- .to_return(status: 200, headers: {"Content-Type" => "text/html"}, body: "<html><body>Hello</body></html>")
8
+ .to_return(
9
+ status: 200,
10
+ headers: {
11
+ "Content-Type" => "text/html",
12
+ "Server-Timing" => 'db;dur=12.5;desc="Primary database"'
13
+ },
14
+ body: "<html><body>Hello</body></html>"
15
+ )
9
16
 
10
17
  page = Crawlscope::Http.new(base_url: "https://example.com", timeout_seconds: 2).fetch("https://example.com/page")
11
18
 
12
19
  assert_equal 200, page.status
13
20
  assert page.html?
14
21
  assert_equal "Hello", page.doc.at_css("body").text
22
+ timing = page.server_timing
23
+ assert_same timing, page.server_timing
24
+ assert_equal 12.5, timing.first.duration
25
+ assert_equal "Primary database", timing.first.description
15
26
  end
16
27
 
17
28
  def test_fetch_parses_responses_without_content_type_as_html
@@ -23,6 +34,54 @@ class CrawlscopeHttpTest < Minitest::Test
23
34
  assert page.html?
24
35
  end
25
36
 
37
+ def test_fetch_sends_profile_token_to_same_origin_requests
38
+ request = stub_request(:get, "https://example.com/page")
39
+ .with(headers: {"X-Profile-Token" => "profile-token"})
40
+ .to_return(status: 200, body: "<html></html>")
41
+
42
+ Crawlscope::Http.new(
43
+ base_url: "https://example.com",
44
+ timeout_seconds: 2,
45
+ profile_token: "profile-token"
46
+ ).fetch("https://example.com/page")
47
+
48
+ assert_requested request
49
+ end
50
+
51
+ def test_fetch_preserves_profile_token_on_same_origin_redirects
52
+ stub_request(:get, "https://example.com/start")
53
+ .with(headers: {"X-Profile-Token" => "profile-token"})
54
+ .to_return(status: 302, headers: {"Location" => "/final"})
55
+ final_request = stub_request(:get, "https://example.com/final")
56
+ .with(headers: {"X-Profile-Token" => "profile-token"})
57
+ .to_return(status: 200, body: "<html></html>")
58
+
59
+ Crawlscope::Http.new(
60
+ base_url: "https://example.com",
61
+ timeout_seconds: 2,
62
+ profile_token: "profile-token"
63
+ ).fetch("https://example.com/start")
64
+
65
+ assert_requested final_request
66
+ end
67
+
68
+ def test_fetch_removes_profile_token_from_cross_origin_redirects
69
+ stub_request(:get, "https://example.com/start")
70
+ .with(headers: {"X-Profile-Token" => "profile-token"})
71
+ .to_return(status: 302, headers: {"Location" => "https://other.example/final"})
72
+ final_request = stub_request(:get, "https://other.example/final")
73
+ .with { |request| !request.headers.key?("X-Profile-Token") }
74
+ .to_return(status: 200, body: "<html></html>")
75
+
76
+ Crawlscope::Http.new(
77
+ base_url: "https://example.com",
78
+ timeout_seconds: 2,
79
+ profile_token: "profile-token"
80
+ ).fetch("https://example.com/start")
81
+
82
+ assert_requested final_request
83
+ end
84
+
26
85
  def test_fetch_leaves_non_html_response_unparsed
27
86
  stub_request(:get, "https://example.com/feed.xml")
28
87
  .to_return(status: 200, headers: {"content-type" => "application/xml"}, body: "<feed></feed>")
@@ -16,11 +16,14 @@ class CrawlscopeInstallGeneratorTest < Minitest::Test
16
16
  Crawlscope::Generators::InstallGenerator.start([], destination_root: destination)
17
17
 
18
18
  initializer = File.join(destination, "config/initializers/crawlscope.rb")
19
+ initializer_contents = File.read(initializer)
19
20
  rakefile = File.read(File.join(destination, "Rakefile"))
20
21
 
21
22
  assert File.exist?(initializer)
22
- assert_includes File.read(initializer), "module CrawlscopeConfiguration"
23
- assert_includes File.read(initializer), "CrawlscopeConfiguration.apply if defined?(Crawlscope)"
23
+ assert_includes initializer_contents, "module CrawlscopeConfiguration"
24
+ assert_includes initializer_contents, "CrawlscopeConfiguration.apply if defined?(Crawlscope)"
25
+ assert_includes initializer_contents, "\#{config.base_url.to_s.chomp(\"/\")}/sitemap.xml"
26
+ refute_includes initializer_contents, "Rails.public_path"
24
27
  assert_includes rakefile, 'require "crawlscope/tasks"'
25
28
  assert_operator rakefile.index('require "crawlscope/tasks"'), :<, rakefile.index('require_relative "config/application"')
26
29
 
@@ -28,14 +31,6 @@ class CrawlscopeInstallGeneratorTest < Minitest::Test
28
31
  assert status.success?, stderr
29
32
 
30
33
  script = <<~RUBY
31
- require "pathname"
32
-
33
- module Rails
34
- def self.public_path
35
- Pathname.new("public")
36
- end
37
- end
38
-
39
34
  require "crawlscope"
40
35
  load ARGV.fetch(0)
41
36
 
@@ -43,7 +38,10 @@ class CrawlscopeInstallGeneratorTest < Minitest::Test
43
38
  CrawlscopeConfiguration.apply
44
39
  abort "configuration replaced" unless Crawlscope.configuration.equal?(configuration)
45
40
  abort "base URL missing" unless configuration.base_url == "http://localhost:3000"
46
- abort "sitemap missing" unless configuration.sitemap_path == "public/sitemap.xml"
41
+ abort "sitemap missing" unless configuration.sitemap_path == "http://localhost:3000/sitemap.xml"
42
+
43
+ ENV["CRAWLSCOPE_BASE_URL"] = "https://example.com"
44
+ abort "configured sitemap missing" unless configuration.sitemap_path == "https://example.com/sitemap.xml"
47
45
  RUBY
48
46
  _stdout, stderr, status = Open3.capture3(
49
47
  RbConfig.ruby,
@@ -56,7 +56,7 @@ class CrawlscopeLinksRuleTest < Minitest::Test
56
56
  )
57
57
 
58
58
  assert_includes issues.to_a.map(&:code), :unresolved_internal_link
59
- assert_includes issues.to_a.find { |issue| issue.code == :unresolved_internal_link }.message, "unable to validate internal link"
59
+ assert_includes issues.to_a.find { |issue| issue.code == :unresolved_internal_link }.message, "cannot check linked URL"
60
60
  end
61
61
 
62
62
  def test_ignores_fetch_errors_for_urls_already_crawled
@@ -429,6 +429,42 @@ class CrawlscopeLinksRuleTest < Minitest::Test
429
429
  assert_empty issues.to_a
430
430
  end
431
431
 
432
+ def test_retains_all_link_sources_and_targets_in_issue_details
433
+ source_urls = 5.times.map { |index| "https://example.com/source-#{index}" }
434
+ target_urls = 5.times.map { |index| "https://example.com/target-#{index}" }
435
+ pages = source_urls.map do |url|
436
+ targets = target_urls.map { |target| %(<a href="#{target}" rel="nofollow">Target</a>) }.join
437
+ page(url: url, body: %(<html><body>#{targets}<a href="/missing">Missing</a><a href="/pricing">Pricing</a></body></html>))
438
+ end
439
+ resolver = lambda do |url|
440
+ {crawled: false, error: nil, final_url: url, html: true, status: url.end_with?("/missing") ? 404 : 200}
441
+ end
442
+ issues = Crawlscope::IssueCollection.new
443
+
444
+ Crawlscope::Rules::Links.new.call(urls: source_urls, pages: pages, issues: issues, context: context(resolver: resolver))
445
+
446
+ broken = issues.find { |issue| issue.code == :broken_internal_link }
447
+ assert_equal source_urls, broken.details[:source_urls]
448
+ outgoing = issues.select { |issue| issue.code == :nofollow_internal_outlinks }
449
+ assert_equal 5, outgoing.size
450
+ outgoing.each { |issue| assert_equal target_urls, issue.details[:target_urls] }
451
+ missing = issues.find { |issue| issue.code == :indexable_page_missing_from_sitemap && issue.url.end_with?("/pricing") }
452
+ assert_equal source_urls.first, missing.details[:source_url]
453
+ assert_equal source_urls, missing.details[:source_urls]
454
+ end
455
+
456
+ def test_does_not_check_destination_canonicals_under_redirect_aliases
457
+ pages = ["old", "alias"].map do |path|
458
+ page(url: "https://example.com/#{path}", final_url: "https://example.com/guide", body: '<html><head><link rel="canonical" href="https://example.com/missing"></head></html>')
459
+ end
460
+ issues = Crawlscope::IssueCollection.new
461
+
462
+ Crawlscope::Rules::Links.new.call(urls: pages.map(&:url), pages: pages, issues: issues, context: context)
463
+
464
+ canonical_issues = issues.select { |issue| [:canonical_no_internal_inlinks, :canonical_points_to_redirect, :canonical_points_to_error].include?(issue.code) }
465
+ assert_empty canonical_issues
466
+ end
467
+
432
468
  private
433
469
 
434
470
  def context(resolver: method(:resolve_target))
@@ -439,14 +475,14 @@ class CrawlscopeLinksRuleTest < Minitest::Test
439
475
  }
440
476
  end
441
477
 
442
- def page(url:, body:)
478
+ def page(url:, body:, final_url: url)
443
479
  doc = Nokogiri::HTML(body)
444
480
 
445
481
  Crawlscope::Page.new(
446
482
  url: url,
447
483
  normalized_url: url,
448
- final_url: url,
449
- normalized_final_url: url,
484
+ final_url: final_url,
485
+ normalized_final_url: final_url,
450
486
  status: 200,
451
487
  headers: {"content-type" => "text/html"},
452
488
  body: body,
@@ -80,13 +80,46 @@ class CrawlscopeMetadataRuleTest < Minitest::Test
80
80
  assert_includes codes, :multiple_title_tags
81
81
  assert_includes codes, :multiple_meta_descriptions
82
82
  assert_includes codes, :empty_h1
83
- assert_includes codes, :canonical_mismatch
83
+ refute_includes codes, :canonical_mismatch
84
84
  assert_includes codes, :non_canonical_page_in_sitemap
85
+ mismatch = issues.find { |issue| issue.code == :non_canonical_page_in_sitemap }
86
+ assert_equal "sitemap lists this URL, but its canonical points to https://example.com/canonical-about", mismatch.message
87
+ assert_equal "https://example.com/canonical-about", mismatch.details[:canonical]
88
+ end
89
+
90
+ def test_length_messages_include_measurement_units_and_limit
91
+ issues = Crawlscope::IssueCollection.new
92
+ long_page = page(body: %(<html><head><title>#{"T" * 73}</title><meta name="description" content="#{"D" * 161}"></head></html>))
93
+
94
+ Crawlscope::Rules::Metadata.new.call(urls: [long_page.url], pages: [long_page], issues: issues)
95
+
96
+ assert_equal "title: 73 characters; maximum 72", issues.find { |issue| issue.code == :title_too_long }.message
97
+ assert_equal "meta description: 161 characters; maximum 160", issues.find { |issue| issue.code == :meta_description_too_long }.message
98
+ end
99
+
100
+ def test_does_not_apply_destination_metadata_to_redirect_alias
101
+ redirected = page(url: "https://example.com/old", final_url: "https://example.com/new", body: '<html><head><link rel="canonical" href="https://example.com/new"></head></html>')
102
+ issues = Crawlscope::IssueCollection.new
103
+
104
+ Crawlscope::Rules::Metadata.new.call(urls: [redirected.url], pages: [redirected], issues: issues)
105
+
106
+ assert_empty issues.to_a
107
+ end
108
+
109
+ def test_keeps_canonical_mismatch_for_a_page_outside_the_sitemap
110
+ mismatched = page(body: '<html><head><link rel="canonical" href="https://example.com/preferred"></head></html>')
111
+ issues = Crawlscope::IssueCollection.new
112
+
113
+ Crawlscope::Rules::Metadata.new.call(urls: [], pages: [mismatched], issues: issues)
114
+
115
+ canonical_issues = issues.select { |issue| [:canonical_mismatch, :non_canonical_page_in_sitemap].include?(issue.code) }
116
+ assert_equal [:canonical_mismatch], canonical_issues.map(&:code)
117
+ assert_equal "https://example.com/preferred", canonical_issues.first.details[:canonical]
85
118
  end
86
119
 
87
120
  private
88
121
 
89
- def page(url: "https://example.com/about", body: nil)
122
+ def page(url: "https://example.com/about", body: nil, final_url: url)
90
123
  body ||= <<~HTML
91
124
  <html>
92
125
  <head>
@@ -102,8 +135,8 @@ class CrawlscopeMetadataRuleTest < Minitest::Test
102
135
  Crawlscope::Page.new(
103
136
  url: url,
104
137
  normalized_url: Crawlscope::Url.normalize(url, base_url: url),
105
- final_url: url,
106
- normalized_final_url: Crawlscope::Url.normalize(url, base_url: url),
138
+ final_url: final_url,
139
+ normalized_final_url: Crawlscope::Url.normalize(final_url, base_url: url),
107
140
  status: 200,
108
141
  headers: {"content-type" => "text/html"},
109
142
  body: body,