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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +18 -0
- data/README.md +74 -8
- data/lib/crawlscope/browser.rb +18 -1
- data/lib/crawlscope/configuration.rb +6 -1
- data/lib/crawlscope/crawl.rb +5 -2
- data/lib/crawlscope/http.rb +13 -2
- data/lib/crawlscope/page.rb +8 -0
- data/lib/crawlscope/reporter.rb +9 -4
- data/lib/crawlscope/request_headers.rb +34 -0
- data/lib/crawlscope/result.rb +4 -0
- data/lib/crawlscope/run.rb +0 -10
- data/lib/crawlscope/server_timing/reporter.rb +112 -0
- data/lib/crawlscope/server_timing/summary.rb +107 -0
- data/lib/crawlscope/server_timing.rb +138 -0
- data/lib/crawlscope/sitemap.rb +17 -5
- data/lib/crawlscope/version.rb +1 -1
- data/lib/generators/crawlscope/templates/initializer.rb.tt +1 -1
- data/test/crawlscope/browser_test.rb +56 -3
- data/test/crawlscope/configuration_test.rb +50 -10
- data/test/crawlscope/crawl_test.rb +29 -0
- data/test/crawlscope/http_test.rb +60 -1
- data/test/crawlscope/install_generator_test.rb +9 -11
- data/test/crawlscope/reporter_test.rb +64 -6
- data/test/crawlscope/run_test.rb +3 -11
- data/test/crawlscope/server_timing_summary_test.rb +87 -0
- data/test/crawlscope/server_timing_test.rb +101 -0
- data/test/crawlscope/sitemap_test.rb +21 -0
- metadata +7 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 4b830667c3f7647baba6ca372e78daa5f4116e2178fc585aff4f387671e71b81
|
|
4
|
+
data.tar.gz: 357a5ceab12f86addda14927ba03be7357f20d301ddeb58b72a87527133653ce
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 954a7f1044ae369e01fc571331acbf0b9ebd47f56d8047d028c6967a85cf5caefa673a3bfa05b782a814d3233f883ea13ca98a3ab961d2e8de47d788804f6158
|
|
7
|
+
data.tar.gz: 8a59e970c7191847a25b855e7342d0ebaab7b881540d41ac78b9b558460ffd9a679fbfff3c54ef78e22f2bd287d8009f9d58961128b9c4b74ac3773ce8dc82dd
|
data/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,24 @@ All notable changes to this project will be documented in this file.
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
+
## [0.8.0] - 2026-07-24
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
|
|
13
|
+
- report Server-Timing metrics
|
|
14
|
+
|
|
15
|
+
- authenticate timing crawl requests
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
### Fixed
|
|
21
|
+
|
|
22
|
+
- use portable crawl configuration defaults
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
|
|
8
26
|
## [0.7.2] - 2026-07-23
|
|
9
27
|
|
|
10
28
|
|
data/README.md
CHANGED
|
@@ -93,6 +93,11 @@ crawlscope validate --url https://example.com --sitemap https://example.com/site
|
|
|
93
93
|
|
|
94
94
|
Child sitemap indexes are supported automatically.
|
|
95
95
|
|
|
96
|
+
Set `CRAWLSCOPE_PROFILE_TOKEN` through the process environment or your secret
|
|
97
|
+
manager when the site protects diagnostic timing with `X-Profile-Token`.
|
|
98
|
+
Crawlscope intentionally has no `--profile-token` flag because command arguments
|
|
99
|
+
can be exposed through shell history and process listings.
|
|
100
|
+
|
|
96
101
|
Validation output is grouped for terminal scanning:
|
|
97
102
|
|
|
98
103
|
```text
|
|
@@ -141,10 +146,69 @@ puts result.issues.to_a.map(&:message)
|
|
|
141
146
|
- `urls`: sitemap URLs selected for validation
|
|
142
147
|
- `pages`: fetched page snapshots
|
|
143
148
|
- `issues`: structured issues with `code`, `severity`, `category`, `url`, and `message`
|
|
149
|
+
- `server_timing_summary`: aggregate response timing data when pages publish it
|
|
144
150
|
|
|
145
151
|
`result.ok?` returns `false` when an error is present. Warnings and notices
|
|
146
152
|
remain available through `result.issues` without making the result fail.
|
|
147
153
|
|
|
154
|
+
## Server Timing
|
|
155
|
+
|
|
156
|
+
Crawlscope parses the
|
|
157
|
+
[`Server-Timing`](https://www.w3.org/TR/server-timing/) response header for both
|
|
158
|
+
HTTP and browser-rendered crawls. Each page exposes its parsed header through
|
|
159
|
+
`page.server_timing`:
|
|
160
|
+
|
|
161
|
+
```ruby
|
|
162
|
+
page.server_timing.each do |metric|
|
|
163
|
+
puts [metric.name, metric.duration, metric.description].compact.join(": ")
|
|
164
|
+
end
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
The validation report adds a `Server Timing` section only when at least one page
|
|
168
|
+
publishes the header. It includes:
|
|
169
|
+
|
|
170
|
+
- header coverage and the number of pages publishing durations
|
|
171
|
+
- sample and page counts with average, p50, p95, and maximum per metric
|
|
172
|
+
- non-duration signals, including cache status and routing descriptions
|
|
173
|
+
- the ten pages with the largest individual metric
|
|
174
|
+
- the number of malformed entries ignored during parsing
|
|
175
|
+
|
|
176
|
+
`dur` values are reported as milliseconds, as recommended by the specification.
|
|
177
|
+
Crawlscope does not add durations together because metrics such as `total`,
|
|
178
|
+
`app`, and `db` may overlap. A page's worst-offender entry is its largest
|
|
179
|
+
individual metric instead.
|
|
180
|
+
|
|
181
|
+
The current HTTP and browser transports expose response headers, not response
|
|
182
|
+
trailers. Metrics published only in a `Server-Timing` trailer are therefore not
|
|
183
|
+
available to Crawlscope.
|
|
184
|
+
|
|
185
|
+
Rails 8 applications can enable `ActionDispatch::ServerTiming` with:
|
|
186
|
+
|
|
187
|
+
```ruby
|
|
188
|
+
config.server_timing = true
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
Applications that expose timing only to authenticated diagnostics can set
|
|
192
|
+
`config.profile_token`. When this value is present, Crawlscope sends it as
|
|
193
|
+
`X-Profile-Token` on same-origin sitemap, HTTP, and browser document requests.
|
|
194
|
+
The header survives same-origin redirects but is removed before a cross-origin
|
|
195
|
+
redirect, and browser subresources never receive it. Keep the token in the host
|
|
196
|
+
application's secret store rather than a URL, command argument, report, or
|
|
197
|
+
checked-in configuration.
|
|
198
|
+
|
|
199
|
+
`CRAWLSCOPE_PROFILE_TOKEN` is the portable default for Rails, the standalone
|
|
200
|
+
CLI, Rake tasks, and plain Ruby callers. Rails applications may instead assign
|
|
201
|
+
`config.profile_token` from encrypted credentials when that is their established
|
|
202
|
+
secret-management boundary; explicit configuration takes precedence over the
|
|
203
|
+
environment.
|
|
204
|
+
|
|
205
|
+
New Rails applications enable it in development by default; production remains
|
|
206
|
+
opt-in. Rails publishes the Active Support notification names observed during
|
|
207
|
+
each request and sums repeated events with the same name. The exact metrics
|
|
208
|
+
therefore depend on the application and request, but commonly include controller,
|
|
209
|
+
view, database, and cache instrumentation. Crawlscope accepts Rails' dotted
|
|
210
|
+
metric names and reports each metric independently.
|
|
211
|
+
|
|
148
212
|
## Rails Usage
|
|
149
213
|
|
|
150
214
|
Run the install generator after adding the gem:
|
|
@@ -162,10 +226,11 @@ Customize the `Crawlscope.configure` block inside the generated initializer:
|
|
|
162
226
|
|
|
163
227
|
```ruby
|
|
164
228
|
Crawlscope.configure do |config|
|
|
165
|
-
config.base_url = -> { "
|
|
166
|
-
config.sitemap_path =
|
|
167
|
-
|
|
168
|
-
|
|
229
|
+
config.base_url = -> { ENV.fetch("CRAWLSCOPE_BASE_URL", "http://localhost:3000") }
|
|
230
|
+
config.sitemap_path = lambda {
|
|
231
|
+
ENV.fetch("SITEMAP", "#{config.base_url.to_s.chomp("/")}/sitemap.xml")
|
|
232
|
+
}
|
|
233
|
+
config.site_name = ENV.fetch("CRAWLSCOPE_SITE_NAME", "Application")
|
|
169
234
|
end
|
|
170
235
|
```
|
|
171
236
|
|
|
@@ -193,6 +258,7 @@ Available environment overrides:
|
|
|
193
258
|
|
|
194
259
|
- `URL`
|
|
195
260
|
- `SITEMAP`
|
|
261
|
+
- `CRAWLSCOPE_PROFILE_TOKEN`
|
|
196
262
|
- `RULES=metadata,links`
|
|
197
263
|
- `JS=1` or `RENDERER=browser`
|
|
198
264
|
- `TIMEOUT=30`
|
|
@@ -226,10 +292,10 @@ bundle exec rake 'crawlscope:validate:ldjson[https://example.com/article]'
|
|
|
226
292
|
|
|
227
293
|
`crawlscope:validate` runs all default sitemap rules: indexability, metadata,
|
|
228
294
|
structured data, uniqueness, content quality, and links. `URL` is the site
|
|
229
|
-
base. Without `SITEMAP`, Crawlscope uses the configured sitemap
|
|
230
|
-
|
|
231
|
-
base and validates URLs from that sitemap. `SITEMAP` may be a full URL
|
|
232
|
-
local file path.
|
|
295
|
+
base. Without `SITEMAP`, Crawlscope uses the configured sitemap URL, then fetches
|
|
296
|
+
`/sitemap.xml` from `URL` over HTTP. With `SITEMAP`, Crawlscope uses `URL` as
|
|
297
|
+
the site base and validates URLs from that sitemap. `SITEMAP` may be a full URL
|
|
298
|
+
or an explicitly selected local file path.
|
|
233
299
|
|
|
234
300
|
Plain `rake` does not pass `--url` style flags to tasks. Use `URL=...` or the
|
|
235
301
|
task-argument form above instead.
|
data/lib/crawlscope/browser.rb
CHANGED
|
@@ -4,13 +4,15 @@ require "nokogiri"
|
|
|
4
4
|
|
|
5
5
|
module Crawlscope
|
|
6
6
|
class Browser
|
|
7
|
-
def initialize(base_url:, timeout_seconds:, network_idle_timeout_seconds:, scroll_page:)
|
|
7
|
+
def initialize(base_url:, timeout_seconds:, network_idle_timeout_seconds:, scroll_page:, profile_token: nil)
|
|
8
8
|
@base_url = base_url
|
|
9
9
|
@timeout_seconds = timeout_seconds
|
|
10
10
|
@network_idle_timeout_seconds = network_idle_timeout_seconds
|
|
11
|
+
@profile_token = profile_token
|
|
11
12
|
@scroll_page = scroll_page
|
|
12
13
|
@browser = build_browser
|
|
13
14
|
@page = @browser.create_page
|
|
15
|
+
configure_profile_requests
|
|
14
16
|
end
|
|
15
17
|
|
|
16
18
|
def close
|
|
@@ -72,6 +74,21 @@ module Crawlscope
|
|
|
72
74
|
)
|
|
73
75
|
end
|
|
74
76
|
|
|
77
|
+
def configure_profile_requests
|
|
78
|
+
return if @profile_token.to_s.empty?
|
|
79
|
+
|
|
80
|
+
@page.network.intercept(resource_type: :document)
|
|
81
|
+
@page.on(:request) do |request|
|
|
82
|
+
headers = RequestHeaders.add_profile_token(
|
|
83
|
+
request.headers.dup,
|
|
84
|
+
url: request.url,
|
|
85
|
+
base_url: @base_url,
|
|
86
|
+
profile_token: @profile_token
|
|
87
|
+
)
|
|
88
|
+
request.continue(headers: headers.map { |name, value| {name: name.to_s, value: value.to_s} })
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
|
|
75
92
|
def scroll_for_render
|
|
76
93
|
@page.evaluate("(function() { if (document.body) { window.scrollTo(0, document.body.scrollHeight); } })()")
|
|
77
94
|
wait_for_network_idle
|
|
@@ -11,7 +11,7 @@ module Crawlscope
|
|
|
11
11
|
RENDERERS = %i[http browser].freeze
|
|
12
12
|
DEFAULT_TIMEOUT_SECONDS = 20
|
|
13
13
|
|
|
14
|
-
attr_writer :allowed_statuses, :base_url, :browser_factory, :concurrency, :fetch_executor, :network_idle_timeout_seconds, :output, :renderer, :rule_registry, :schema_registry, :scroll_page, :site_name, :sitemap_path, :timeout_seconds
|
|
14
|
+
attr_writer :allowed_statuses, :base_url, :browser_factory, :concurrency, :fetch_executor, :network_idle_timeout_seconds, :output, :profile_token, :renderer, :rule_registry, :schema_registry, :scroll_page, :site_name, :sitemap_path, :timeout_seconds
|
|
15
15
|
|
|
16
16
|
def allowed_statuses
|
|
17
17
|
value = resolve(@allowed_statuses)
|
|
@@ -59,6 +59,10 @@ module Crawlscope
|
|
|
59
59
|
value.nil? ? $stdout : value
|
|
60
60
|
end
|
|
61
61
|
|
|
62
|
+
def profile_token
|
|
63
|
+
resolve(@profile_token) || ENV["CRAWLSCOPE_PROFILE_TOKEN"]
|
|
64
|
+
end
|
|
65
|
+
|
|
62
66
|
def renderer
|
|
63
67
|
value = resolve(@renderer)
|
|
64
68
|
normalized_value = value.to_s.strip
|
|
@@ -93,6 +97,7 @@ module Crawlscope
|
|
|
93
97
|
concurrency: concurrency,
|
|
94
98
|
fetch_executor: fetch_executor,
|
|
95
99
|
network_idle_timeout_seconds: network_idle_timeout_seconds,
|
|
100
|
+
profile_token: profile_token,
|
|
96
101
|
renderer: renderer,
|
|
97
102
|
timeout_seconds: timeout_seconds,
|
|
98
103
|
allowed_statuses: allowed_statuses,
|
data/lib/crawlscope/crawl.rb
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
module Crawlscope
|
|
4
4
|
class Crawl
|
|
5
|
-
def initialize(base_url:, sitemap_path:, rules:, schema_registry:, browser_factory: nil, concurrency: Configuration::DEFAULT_CONCURRENCY, fetch_executor: nil, network_idle_timeout_seconds: Configuration::DEFAULT_BROWSER_NETWORK_IDLE_TIMEOUT_SECONDS, renderer: :http, scroll_page: Configuration::DEFAULT_BROWSER_SCROLL_PAGE, timeout_seconds: Configuration::DEFAULT_TIMEOUT_SECONDS, allowed_statuses: Configuration::DEFAULT_ALLOWED_STATUSES)
|
|
5
|
+
def initialize(base_url:, sitemap_path:, rules:, schema_registry:, browser_factory: nil, concurrency: Configuration::DEFAULT_CONCURRENCY, fetch_executor: nil, network_idle_timeout_seconds: Configuration::DEFAULT_BROWSER_NETWORK_IDLE_TIMEOUT_SECONDS, profile_token: nil, renderer: :http, scroll_page: Configuration::DEFAULT_BROWSER_SCROLL_PAGE, timeout_seconds: Configuration::DEFAULT_TIMEOUT_SECONDS, allowed_statuses: Configuration::DEFAULT_ALLOWED_STATUSES)
|
|
6
6
|
@base_url = base_url
|
|
7
7
|
@sitemap_path = sitemap_path
|
|
8
8
|
@rules = Array(rules)
|
|
@@ -10,6 +10,7 @@ module Crawlscope
|
|
|
10
10
|
@browser_factory = browser_factory
|
|
11
11
|
@concurrency = concurrency
|
|
12
12
|
@network_idle_timeout_seconds = network_idle_timeout_seconds
|
|
13
|
+
@profile_token = profile_token
|
|
13
14
|
@renderer = renderer.to_sym
|
|
14
15
|
@fetch_executor = fetch_executor || default_fetch_executor
|
|
15
16
|
@scroll_page = scroll_page
|
|
@@ -49,6 +50,7 @@ module Crawlscope
|
|
|
49
50
|
adapter: http_adapter,
|
|
50
51
|
concurrency: @concurrency,
|
|
51
52
|
fetch_executor: @fetch_executor,
|
|
53
|
+
profile_token: @profile_token,
|
|
52
54
|
timeout_seconds: @timeout_seconds
|
|
53
55
|
).urls(base_url: @base_url)
|
|
54
56
|
raise ValidationError, "No URLs found in sitemap: #{@sitemap_path}" if urls.empty?
|
|
@@ -61,6 +63,7 @@ module Crawlscope
|
|
|
61
63
|
base_url: @base_url,
|
|
62
64
|
timeout_seconds: @timeout_seconds,
|
|
63
65
|
network_idle_timeout_seconds: @network_idle_timeout_seconds,
|
|
66
|
+
profile_token: @profile_token,
|
|
64
67
|
scroll_page: @scroll_page
|
|
65
68
|
)
|
|
66
69
|
rescue LoadError => error
|
|
@@ -71,7 +74,7 @@ module Crawlscope
|
|
|
71
74
|
if @renderer == :browser
|
|
72
75
|
(@browser_factory || method(:browser)).call
|
|
73
76
|
else
|
|
74
|
-
Http.new(base_url: @base_url, timeout_seconds: @timeout_seconds, adapter: http_adapter)
|
|
77
|
+
Http.new(base_url: @base_url, timeout_seconds: @timeout_seconds, adapter: http_adapter, profile_token: @profile_token)
|
|
75
78
|
end
|
|
76
79
|
end
|
|
77
80
|
|
data/lib/crawlscope/http.rb
CHANGED
|
@@ -10,10 +10,11 @@ module Crawlscope
|
|
|
10
10
|
MAX_REDIRECTS = 5
|
|
11
11
|
USER_AGENT = "Mozilla/5.0 (compatible; Crawlscope/1.0)"
|
|
12
12
|
|
|
13
|
-
def initialize(base_url:, timeout_seconds:, adapter: nil)
|
|
13
|
+
def initialize(base_url:, timeout_seconds:, adapter: nil, profile_token: nil)
|
|
14
14
|
@base_url = base_url
|
|
15
15
|
@timeout_seconds = timeout_seconds
|
|
16
16
|
@adapter = adapter
|
|
17
|
+
@profile_token = profile_token
|
|
17
18
|
@connections_by_thread = Concurrent::Map.new
|
|
18
19
|
end
|
|
19
20
|
|
|
@@ -28,6 +29,12 @@ module Crawlscope
|
|
|
28
29
|
def fetch(url)
|
|
29
30
|
response = connection.get(url) do |request|
|
|
30
31
|
request.headers["User-Agent"] = USER_AGENT
|
|
32
|
+
RequestHeaders.add_profile_token(
|
|
33
|
+
request.headers,
|
|
34
|
+
url: url,
|
|
35
|
+
base_url: @base_url,
|
|
36
|
+
profile_token: @profile_token
|
|
37
|
+
)
|
|
31
38
|
end
|
|
32
39
|
|
|
33
40
|
final_url = response.env.url.to_s
|
|
@@ -67,7 +74,11 @@ module Crawlscope
|
|
|
67
74
|
def connection
|
|
68
75
|
@connections_by_thread.compute_if_absent(Thread.current.object_id) do
|
|
69
76
|
Faraday.new do |faraday|
|
|
70
|
-
faraday.response
|
|
77
|
+
faraday.response(
|
|
78
|
+
:follow_redirects,
|
|
79
|
+
limit: MAX_REDIRECTS,
|
|
80
|
+
callback: RequestHeaders.method(:strip_profile_token_on_cross_origin_redirect)
|
|
81
|
+
)
|
|
71
82
|
faraday.options.timeout = @timeout_seconds
|
|
72
83
|
faraday.options.open_timeout = @timeout_seconds
|
|
73
84
|
faraday.adapter @adapter if @adapter
|
data/lib/crawlscope/page.rb
CHANGED
|
@@ -19,5 +19,13 @@ module Crawlscope
|
|
|
19
19
|
def html?
|
|
20
20
|
!doc.nil?
|
|
21
21
|
end
|
|
22
|
+
|
|
23
|
+
def header(name)
|
|
24
|
+
headers.find { |key, _value| key.to_s.casecmp?(name.to_s) }&.last
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def server_timing
|
|
28
|
+
@server_timing ||= ServerTiming.new(header("server-timing"))
|
|
29
|
+
end
|
|
22
30
|
end
|
|
23
31
|
end
|
data/lib/crawlscope/reporter.rb
CHANGED
|
@@ -19,13 +19,18 @@ module Crawlscope
|
|
|
19
19
|
|
|
20
20
|
if result.issues.size.zero?
|
|
21
21
|
@io.puts("Status: OK")
|
|
22
|
-
|
|
22
|
+
else
|
|
23
|
+
@io.puts("Status: #{status_for(result.issues)}")
|
|
24
|
+
@io.puts("Issues: #{result.issues.size} total (#{severity_summary(result.issues)})")
|
|
23
25
|
end
|
|
24
26
|
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
27
|
+
ServerTiming::Reporter.new(io: @io).report(
|
|
28
|
+
result.server_timing_summary,
|
|
29
|
+
base_url: result.base_url
|
|
30
|
+
)
|
|
31
|
+
return if result.issues.size.zero?
|
|
28
32
|
|
|
33
|
+
@io.puts("")
|
|
29
34
|
report_summary(result.issues)
|
|
30
35
|
@io.puts("")
|
|
31
36
|
report_issue_groups(result.issues, base_url: result.base_url)
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "uri"
|
|
4
|
+
|
|
5
|
+
module Crawlscope
|
|
6
|
+
module RequestHeaders
|
|
7
|
+
PROFILE_TOKEN = "X-Profile-Token"
|
|
8
|
+
|
|
9
|
+
module_function
|
|
10
|
+
|
|
11
|
+
def add_profile_token(headers, url:, base_url:, profile_token:)
|
|
12
|
+
return headers if profile_token.to_s.empty?
|
|
13
|
+
return headers unless same_origin?(url, base_url: base_url)
|
|
14
|
+
|
|
15
|
+
headers[PROFILE_TOKEN] = profile_token
|
|
16
|
+
headers
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def strip_profile_token_on_cross_origin_redirect(response_env, request_env)
|
|
20
|
+
return if same_origin?(request_env[:url], base_url: response_env[:url])
|
|
21
|
+
|
|
22
|
+
request_env[:request_headers].delete(PROFILE_TOKEN)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def same_origin?(url, base_url:)
|
|
26
|
+
url_uri = URI.join(base_url.to_s, url.to_s)
|
|
27
|
+
base_uri = URI.parse(base_url.to_s)
|
|
28
|
+
|
|
29
|
+
[url_uri.scheme, url_uri.host, url_uri.port] == [base_uri.scheme, base_uri.host, base_uri.port]
|
|
30
|
+
rescue URI::Error
|
|
31
|
+
false
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
data/lib/crawlscope/result.rb
CHANGED
data/lib/crawlscope/run.rb
CHANGED
|
@@ -44,17 +44,7 @@ module Crawlscope
|
|
|
44
44
|
value = @configuration.sitemap_path
|
|
45
45
|
return value unless value.to_s.strip.empty?
|
|
46
46
|
|
|
47
|
-
local_path = File.expand_path("public/sitemap.xml", Dir.pwd)
|
|
48
|
-
return local_path if local_path_default?(base_url: base_url) && File.exist?(local_path)
|
|
49
|
-
|
|
50
47
|
"#{base_url.to_s.chomp("/")}/sitemap.xml"
|
|
51
48
|
end
|
|
52
|
-
|
|
53
|
-
def local_path_default?(base_url:)
|
|
54
|
-
host = URI.parse(base_url.to_s).host.to_s
|
|
55
|
-
["localhost", "127.0.0.1"].include?(host)
|
|
56
|
-
rescue URI::InvalidURIError
|
|
57
|
-
false
|
|
58
|
-
end
|
|
59
49
|
end
|
|
60
50
|
end
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "uri"
|
|
4
|
+
|
|
5
|
+
module Crawlscope
|
|
6
|
+
class ServerTiming
|
|
7
|
+
class Reporter
|
|
8
|
+
def initialize(io:)
|
|
9
|
+
@io = io
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def report(summary, base_url:)
|
|
13
|
+
return unless summary.present?
|
|
14
|
+
|
|
15
|
+
@io.puts("")
|
|
16
|
+
@io.puts("Server Timing:")
|
|
17
|
+
@io.puts(
|
|
18
|
+
" Coverage: #{summary.coverage}/#{summary.pages.size} pages " \
|
|
19
|
+
"(#{percentage(summary.coverage, summary.pages.size)}); " \
|
|
20
|
+
"durations on #{summary.duration_pages} #{pluralize(:page, summary.duration_pages)}"
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
report_metrics(summary.metrics)
|
|
24
|
+
report_signals(summary.signals)
|
|
25
|
+
report_worst_pages(summary.worst_pages, base_url: base_url)
|
|
26
|
+
@io.puts(" Ignored malformed entries: #{summary.invalid_count}") if summary.invalid_count.positive?
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
private
|
|
30
|
+
|
|
31
|
+
def report_metrics(metrics)
|
|
32
|
+
return if metrics.empty?
|
|
33
|
+
|
|
34
|
+
@io.puts(" Duration metrics (dur; milliseconds assumed):")
|
|
35
|
+
metrics.each do |metric|
|
|
36
|
+
label = metric[:name]
|
|
37
|
+
label += " #{description(metric[:description])}" if metric[:description]
|
|
38
|
+
|
|
39
|
+
@io.puts(
|
|
40
|
+
" #{label}: " \
|
|
41
|
+
"#{metric[:samples]} #{pluralize(:sample, metric[:samples])} / " \
|
|
42
|
+
"#{metric[:pages]} #{pluralize(:page, metric[:pages])}; " \
|
|
43
|
+
"avg #{duration(metric[:average])}; " \
|
|
44
|
+
"p50 #{duration(metric[:p50])}; " \
|
|
45
|
+
"p95 #{duration(metric[:p95])}; " \
|
|
46
|
+
"max #{duration(metric[:maximum])}"
|
|
47
|
+
)
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def report_signals(signals)
|
|
52
|
+
return if signals.empty?
|
|
53
|
+
|
|
54
|
+
@io.puts(" Signals:")
|
|
55
|
+
signals.each do |signal|
|
|
56
|
+
label = signal[:name]
|
|
57
|
+
label += " #{description(signal[:description])}" if signal[:description]
|
|
58
|
+
|
|
59
|
+
@io.puts(
|
|
60
|
+
" #{label}: " \
|
|
61
|
+
"#{signal[:samples]} #{pluralize(:sample, signal[:samples])} / " \
|
|
62
|
+
"#{signal[:pages]} #{pluralize(:page, signal[:pages])}"
|
|
63
|
+
)
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def report_worst_pages(samples, base_url:)
|
|
68
|
+
return if samples.empty?
|
|
69
|
+
|
|
70
|
+
@io.puts(" Worst pages:")
|
|
71
|
+
samples.each do |sample|
|
|
72
|
+
metric = sample[:metric]
|
|
73
|
+
detail = metric.description ? " #{description(metric.description)}" : ""
|
|
74
|
+
@io.puts(
|
|
75
|
+
" #{relative_url(sample[:page].url, base_url: base_url)}: " \
|
|
76
|
+
"#{duration(metric.duration)} (#{metric.name}#{detail})"
|
|
77
|
+
)
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def duration(value)
|
|
82
|
+
"#{format("%.3f", value).sub(/\.?0+\z/, "")}ms"
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def description(value)
|
|
86
|
+
value = value.to_s
|
|
87
|
+
value = "#{value[0, 77]}..." if value.length > 80
|
|
88
|
+
value.inspect
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def percentage(numerator, denominator)
|
|
92
|
+
return "0.0%" if denominator.zero?
|
|
93
|
+
|
|
94
|
+
format("%.1f%%", numerator.fdiv(denominator) * 100)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def relative_url(url, base_url:)
|
|
98
|
+
uri = URI.parse(url)
|
|
99
|
+
base_uri = URI.parse(base_url)
|
|
100
|
+
return url unless uri.host == base_uri.host && uri.scheme == base_uri.scheme && uri.port == base_uri.port
|
|
101
|
+
|
|
102
|
+
uri.request_uri
|
|
103
|
+
rescue URI::InvalidURIError
|
|
104
|
+
url
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def pluralize(word, count)
|
|
108
|
+
(count == 1) ? word.to_s : "#{word}s"
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Crawlscope
|
|
4
|
+
class ServerTiming
|
|
5
|
+
class Summary
|
|
6
|
+
WORST_PAGE_LIMIT = 10
|
|
7
|
+
|
|
8
|
+
attr_reader :pages
|
|
9
|
+
|
|
10
|
+
def initialize(pages)
|
|
11
|
+
@pages = pages
|
|
12
|
+
@timings = pages.filter_map do |page|
|
|
13
|
+
timing = page.server_timing
|
|
14
|
+
[page, timing] if timing.present?
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def present?
|
|
19
|
+
@timings.any?
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def duration_pages
|
|
23
|
+
@timings.count { |_page, timing| timing.any?(&:timed?) }
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def invalid_count
|
|
27
|
+
@timings.sum { |_page, timing| timing.invalid_count }
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def metrics
|
|
31
|
+
timed_metrics
|
|
32
|
+
.group_by { |_page, metric| [metric.name, metric.description] }
|
|
33
|
+
.map { |(name, description), samples| metric_summary(name, description, samples) }
|
|
34
|
+
.sort_by { |metric| [-metric[:p95], -metric[:maximum], metric[:name]] }
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def signals
|
|
38
|
+
all_metrics
|
|
39
|
+
.reject { |_page, metric| metric.timed? }
|
|
40
|
+
.group_by { |_page, metric| [metric.name, metric.description] }
|
|
41
|
+
.map { |(name, description), samples| signal_summary(name, description, samples) }
|
|
42
|
+
.sort_by { |signal| [-signal[:samples], signal[:name], signal[:description].to_s] }
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def worst_pages(limit: WORST_PAGE_LIMIT)
|
|
46
|
+
@timings
|
|
47
|
+
.filter_map { |page, timing| worst_metric(page, timing) }
|
|
48
|
+
.min_by(limit) { |sample| [-sample[:metric].duration, sample[:page].url] }
|
|
49
|
+
.sort_by { |sample| [-sample[:metric].duration, sample[:page].url] }
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def coverage
|
|
53
|
+
@timings.size
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
private
|
|
57
|
+
|
|
58
|
+
def timed_metrics
|
|
59
|
+
all_metrics.select { |_page, metric| metric.timed? }
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def all_metrics
|
|
63
|
+
@all_metrics ||= @timings.flat_map do |page, timing|
|
|
64
|
+
timing.map { |metric| [page, metric] }
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def metric_summary(name, description, samples)
|
|
69
|
+
durations = samples.map { |_page, metric| metric.duration }.sort
|
|
70
|
+
|
|
71
|
+
{
|
|
72
|
+
name: name,
|
|
73
|
+
description: description,
|
|
74
|
+
samples: samples.size,
|
|
75
|
+
pages: samples.map(&:first).uniq.size,
|
|
76
|
+
average: durations.sum.fdiv(durations.size),
|
|
77
|
+
p50: percentile(durations, 0.50),
|
|
78
|
+
p95: percentile(durations, 0.95),
|
|
79
|
+
maximum: durations.last
|
|
80
|
+
}
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def signal_summary(name, description, samples)
|
|
84
|
+
{
|
|
85
|
+
name: name,
|
|
86
|
+
description: description,
|
|
87
|
+
samples: samples.size,
|
|
88
|
+
pages: samples.map(&:first).uniq.size
|
|
89
|
+
}
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def percentile(values, percentile)
|
|
93
|
+
position = (values.size - 1) * percentile
|
|
94
|
+
lower = values[position.floor]
|
|
95
|
+
upper = values[position.ceil]
|
|
96
|
+
|
|
97
|
+
lower + (upper - lower) * (position - position.floor)
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def worst_metric(page, timing)
|
|
101
|
+
if (metric = timing.select(&:timed?).max_by(&:duration))
|
|
102
|
+
{page: page, metric: metric}
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|