crawlscope 0.7.2 → 0.8.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.
@@ -0,0 +1,138 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Crawlscope
4
+ class ServerTiming
5
+ include Enumerable
6
+
7
+ TOKEN = /\A[!#$%&'*+\-.^_`|~0-9A-Za-z]+\z/
8
+ NUMBER = /\A-?(?:(?:\d+(?:\.\d*)?)|(?:\.\d+))(?:[eE][+-]?\d+)?\z/
9
+ QUOTED_STRING = /\A"(?:[^"\\\x00-\x1F]|\\[\t\x20-\x7E])*"\z/
10
+ EMPTY_LIST_MEMBER = /\A[ \t]*\z/
11
+
12
+ Metric = Data.define(:name, :duration, :description) do
13
+ def timed?
14
+ !duration.nil?
15
+ end
16
+ end
17
+
18
+ attr_reader :invalid_count
19
+
20
+ def initialize(header)
21
+ @header = header
22
+ @invalid_count = 0
23
+ @metrics = parse.freeze
24
+ end
25
+
26
+ def each(&block)
27
+ @metrics.each(&block)
28
+ end
29
+
30
+ def empty?
31
+ @metrics.empty?
32
+ end
33
+
34
+ def present?
35
+ !@header.nil?
36
+ end
37
+
38
+ def size
39
+ @metrics.size
40
+ end
41
+
42
+ private
43
+
44
+ def parse
45
+ Array(@header).flatten.compact.flat_map do |header|
46
+ metrics_from(header.to_s)
47
+ end
48
+ end
49
+
50
+ def metrics_from(header)
51
+ fields, complete = split(header, ",")
52
+
53
+ unless complete
54
+ @invalid_count += 1
55
+ fields.pop
56
+ end
57
+
58
+ fields.filter_map do |field|
59
+ metric_from(field) unless EMPTY_LIST_MEMBER.match?(field)
60
+ end
61
+ end
62
+
63
+ def metric_from(field)
64
+ parts, complete = split(field, ";")
65
+ name = parts.shift.to_s.strip
66
+
67
+ if complete && TOKEN.match?(name)
68
+ metric(name, parameters_from(parts))
69
+ else
70
+ @invalid_count += 1
71
+ nil
72
+ end
73
+ end
74
+
75
+ def parameters_from(parts)
76
+ parts.each_with_object({}) do |part, parameters|
77
+ name, value = part.strip.split("=", 2)
78
+ next unless name
79
+
80
+ name = name.downcase
81
+ next if parameters.key?(name)
82
+
83
+ parameters[name] = parameter_value(value.to_s.strip)
84
+ end
85
+ end
86
+
87
+ def parameter_value(value)
88
+ if TOKEN.match?(value)
89
+ value
90
+ elsif QUOTED_STRING.match?(value)
91
+ value[1...-1].gsub(/\\(.)/m, '\1')
92
+ end
93
+ end
94
+
95
+ def metric(name, parameters)
96
+ duration = duration_from(parameters["dur"])
97
+
98
+ if (parameters.key?("dur") && duration.nil?) ||
99
+ (parameters.key?("desc") && parameters["desc"].nil?)
100
+ @invalid_count += 1
101
+ nil
102
+ else
103
+ Metric.new(
104
+ name: name,
105
+ duration: duration,
106
+ description: parameters["desc"]
107
+ )
108
+ end
109
+ end
110
+
111
+ def duration_from(value)
112
+ if value && NUMBER.match?(value)
113
+ duration = Float(value)
114
+ duration if duration.finite?
115
+ end
116
+ end
117
+
118
+ def split(value, delimiter)
119
+ fields, quoted, escaped = [+""], false, false
120
+
121
+ value.each_char do |character|
122
+ if escaped
123
+ escaped = false
124
+ elsif quoted && character == "\\"
125
+ escaped = true
126
+ elsif character == "\""
127
+ quoted = !quoted
128
+ elsif character == delimiter && !quoted
129
+ fields << +""
130
+ end
131
+
132
+ fields.last << character unless character == delimiter && !quoted
133
+ end
134
+
135
+ [fields, !quoted && !escaped]
136
+ end
137
+ end
138
+ end
@@ -9,11 +9,12 @@ 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:, adapter: nil, concurrency: Configuration::DEFAULT_CONCURRENCY, fetch_executor: Configuration::DEFAULT_FETCH_EXECUTOR, timeout_seconds: Configuration::DEFAULT_TIMEOUT_SECONDS)
12
+ def initialize(path:, adapter: nil, concurrency: Configuration::DEFAULT_CONCURRENCY, fetch_executor: Configuration::DEFAULT_FETCH_EXECUTOR, profile_token: nil, timeout_seconds: Configuration::DEFAULT_TIMEOUT_SECONDS)
13
13
  @path = path
14
14
  @adapter = adapter
15
15
  @concurrency = concurrency
16
16
  @fetch_executor = fetch_executor
17
+ @profile_token = profile_token
17
18
  @timeout_seconds = timeout_seconds
18
19
  end
19
20
 
@@ -34,7 +35,7 @@ module Crawlscope
34
35
  end
35
36
  return [] if already_visited
36
37
 
37
- document = Nokogiri::XML(read(source))
38
+ document = Nokogiri::XML(read(source, base_url: base_url))
38
39
  root_name = document.root&.name
39
40
  unless %w[sitemapindex urlset].include?(root_name)
40
41
  raise ValidationError, "Sitemap #{source} has unexpected root #{root_name.inspect}"
@@ -55,9 +56,16 @@ module Crawlscope
55
56
  end
56
57
  end
57
58
 
58
- def read(source)
59
+ def read(source, base_url:)
59
60
  if Url.remote?(source)
60
- response = connection.get(source)
61
+ response = connection.get(source) do |request|
62
+ RequestHeaders.add_profile_token(
63
+ request.headers,
64
+ url: source,
65
+ base_url: base_url,
66
+ profile_token: @profile_token
67
+ )
68
+ end
61
69
  unless response.status.to_i.between?(200, 299)
62
70
  raise ValidationError, "Sitemap #{source} returned HTTP #{response.status}"
63
71
  end
@@ -92,7 +100,11 @@ module Crawlscope
92
100
 
93
101
  def connection
94
102
  Faraday.new do |faraday|
95
- faraday.response :follow_redirects, limit: Http::MAX_REDIRECTS
103
+ faraday.response(
104
+ :follow_redirects,
105
+ limit: Http::MAX_REDIRECTS,
106
+ callback: RequestHeaders.method(:strip_profile_token_on_cross_origin_redirect)
107
+ )
96
108
  faraday.options.timeout = @timeout_seconds
97
109
  faraday.options.open_timeout = @timeout_seconds
98
110
  faraday.adapter @adapter if @adapter
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Crawlscope
4
- VERSION = "0.7.2"
4
+ VERSION = "0.8.0"
5
5
  end
@@ -14,7 +14,7 @@ module CrawlscopeConfiguration
14
14
  Crawlscope.configure do |config|
15
15
  config.base_url = -> { ENV.fetch("CRAWLSCOPE_BASE_URL", "http://localhost:3000") }
16
16
  config.sitemap_path = lambda {
17
- ENV.fetch("SITEMAP", Rails.public_path.join("sitemap.xml").to_s)
17
+ ENV.fetch("SITEMAP", "#{config.base_url.to_s.chomp("/")}/sitemap.xml")
18
18
  }
19
19
  config.site_name = ENV.fetch("CRAWLSCOPE_SITE_NAME", "Application")
20
20
  end
@@ -14,13 +14,14 @@ class CrawlscopeBrowserTest < Minitest::Test
14
14
  end
15
15
 
16
16
  class FakeNetwork
17
- attr_reader :cleared, :idle_waits, :status
17
+ attr_reader :cleared, :idle_waits, :interceptions, :status
18
18
 
19
19
  def initialize(response:, status: 200)
20
20
  @response = response
21
21
  @status = status
22
22
  @cleared = []
23
23
  @idle_waits = []
24
+ @interceptions = []
24
25
  end
25
26
 
26
27
  def clear(scope)
@@ -29,6 +30,10 @@ class CrawlscopeBrowserTest < Minitest::Test
29
30
 
30
31
  attr_reader :response
31
32
 
33
+ def intercept(**options)
34
+ @interceptions << options
35
+ end
36
+
32
37
  def wait_for_idle(duration:, timeout:)
33
38
  @idle_waits << {duration: duration, timeout: timeout}
34
39
  end
@@ -43,6 +48,7 @@ class CrawlscopeBrowserTest < Minitest::Test
43
48
  @current_url = current_url
44
49
  @url = url
45
50
  @evaluations = []
51
+ @callbacks = {}
46
52
  end
47
53
 
48
54
  attr_reader :body
@@ -57,11 +63,40 @@ class CrawlscopeBrowserTest < Minitest::Test
57
63
  @visited_url = url
58
64
  end
59
65
 
66
+ def on(event, &callback)
67
+ @callbacks[event] = callback
68
+ end
69
+
70
+ def publish(event, value)
71
+ @callbacks.fetch(event).call(value)
72
+ end
73
+
60
74
  attr_reader :url
61
75
  end
62
76
 
77
+ class FakeRequest
78
+ attr_reader :continued_headers, :headers, :url
79
+
80
+ def initialize(url:, headers: {})
81
+ @url = url
82
+ @headers = headers
83
+ end
84
+
85
+ def continue(headers:)
86
+ @continued_headers = headers.to_h { |header| [header.fetch(:name), header.fetch(:value)] }
87
+ end
88
+ end
89
+
63
90
  def test_fetch_returns_rendered_page
64
- network = FakeNetwork.new(response: Response.new(url: "https://example.com/final", headers: {"content-type" => "text/html"}))
91
+ network = FakeNetwork.new(
92
+ response: Response.new(
93
+ url: "https://example.com/final",
94
+ headers: {
95
+ "content-type" => "text/html",
96
+ "server-timing" => "app;dur=47.2"
97
+ }
98
+ )
99
+ )
65
100
  page = FakePage.new(network: network, body: "<html><body>Hello</body></html>")
66
101
  browser = browser_with(page: page, scroll_page: false)
67
102
 
@@ -73,6 +108,7 @@ class CrawlscopeBrowserTest < Minitest::Test
73
108
  assert_equal "https://example.com/final", result.normalized_final_url
74
109
  assert_equal 200, result.status
75
110
  assert result.html?
111
+ assert_equal 47.2, result.server_timing.first.duration
76
112
  assert_equal [], page.evaluations
77
113
  end
78
114
 
@@ -88,6 +124,21 @@ class CrawlscopeBrowserTest < Minitest::Test
88
124
  assert_equal 4, network.idle_waits.size
89
125
  end
90
126
 
127
+ def test_profile_token_is_limited_to_same_origin_documents
128
+ network = FakeNetwork.new(response: nil)
129
+ page = FakePage.new(network: network)
130
+ browser_with(page: page, profile_token: "profile-token")
131
+ same_origin_request = FakeRequest.new(url: "https://example.com/page")
132
+ cross_origin_request = FakeRequest.new(url: "https://cdn.example.net/frame")
133
+
134
+ page.publish(:request, same_origin_request)
135
+ page.publish(:request, cross_origin_request)
136
+
137
+ assert_equal [{resource_type: :document}], network.interceptions
138
+ assert_equal "profile-token", same_origin_request.continued_headers.fetch("X-Profile-Token")
139
+ refute_includes cross_origin_request.continued_headers, "X-Profile-Token"
140
+ end
141
+
91
142
  def test_fetch_falls_back_to_page_url_and_original_url
92
143
  page_url_network = FakeNetwork.new(response: nil)
93
144
  page_url = FakePage.new(network: page_url_network, url: "https://example.com/page")
@@ -142,14 +193,16 @@ class CrawlscopeBrowserTest < Minitest::Test
142
193
 
143
194
  private
144
195
 
145
- def browser_with(page: FakePage.new(network: FakeNetwork.new(response: nil)), browser: FakeBrowser.new, scroll_page: false)
196
+ def browser_with(page: FakePage.new(network: FakeNetwork.new(response: nil)), browser: FakeBrowser.new, profile_token: nil, scroll_page: false)
146
197
  Crawlscope::Browser.allocate.tap do |instance|
147
198
  instance.instance_variable_set(:@base_url, "https://example.com")
148
199
  instance.instance_variable_set(:@timeout_seconds, 20)
149
200
  instance.instance_variable_set(:@network_idle_timeout_seconds, 5)
201
+ instance.instance_variable_set(:@profile_token, profile_token)
150
202
  instance.instance_variable_set(:@scroll_page, scroll_page)
151
203
  instance.instance_variable_set(:@browser, browser)
152
204
  instance.instance_variable_set(:@page, page)
205
+ instance.send(:configure_profile_requests)
153
206
  end
154
207
  end
155
208
  end
@@ -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,
@@ -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,