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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +27 -0
- data/README.md +95 -18
- data/lib/crawlscope/browser.rb +18 -1
- data/lib/crawlscope/configuration.rb +6 -1
- data/lib/crawlscope/crawl.rb +5 -3
- data/lib/crawlscope/http.rb +13 -2
- data/lib/crawlscope/page.rb +8 -0
- data/lib/crawlscope/reporter.rb +144 -61
- data/lib/crawlscope/request_headers.rb +34 -0
- data/lib/crawlscope/result.rb +4 -0
- data/lib/crawlscope/rules/content_quality.rb +5 -5
- data/lib/crawlscope/rules/indexability.rb +1 -1
- data/lib/crawlscope/rules/links.rb +28 -34
- data/lib/crawlscope/rules/metadata.rb +25 -25
- data/lib/crawlscope/rules/structured_data.rb +13 -4
- data/lib/crawlscope/rules/uniqueness.rb +9 -7
- data/lib/crawlscope/run.rb +0 -10
- data/lib/crawlscope/schema_registry.rb +17 -1
- data/lib/crawlscope/server_timing/reporter.rb +114 -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/structured_data/error_formatter.rb +42 -0
- data/lib/crawlscope/structured_data/reporter.rb +10 -10
- 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 +65 -0
- data/test/crawlscope/http_test.rb +60 -1
- data/test/crawlscope/install_generator_test.rb +9 -11
- data/test/crawlscope/links_rule_test.rb +40 -4
- data/test/crawlscope/metadata_rule_test.rb +37 -4
- data/test/crawlscope/reporter_test.rb +249 -20
- data/test/crawlscope/run_test.rb +4 -12
- data/test/crawlscope/schema_registry_test.rb +33 -0
- 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
- data/test/crawlscope/structured_data_error_formatter_test.rb +46 -0
- data/test/crawlscope/structured_data_reporter_test.rb +26 -0
- data/test/crawlscope/structured_data_rule_test.rb +2 -0
- data/test/crawlscope/uniqueness_rule_test.rb +46 -3
- metadata +10 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 89be661c49b5f2148a9d7962d81d522eedaedbba89c1f9dcf8474d62cfffd5f2
|
|
4
|
+
data.tar.gz: d27a26792cc5cfdab191371c3d005cf43298b02896cb7cbdf2113a5f341e6c12
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 5c90219d9430a09a3c95c5cb6367817cbc996f4ba69857781ddf6d2050373c9bfef2ad3de545a328542fa7362494df05be85cfa2c5e11f359b84870c8bee2ea6
|
|
7
|
+
data.tar.gz: 632ddff7f77d1c0eb62e6f9db0c9a36f8c3c080d2ac3b6b93bc03fcea65453d916ecbf7b39eb34e9b16d750309e7814586e2a84b4cd0157609a47402bcccf8ef
|
data/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,33 @@ 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.1] - 2026-10-06
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
### Fixed
|
|
12
|
+
|
|
13
|
+
- clarify findings and prevent redirect false positives
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
## [0.8.0] - 2026-07-24
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
### Added
|
|
21
|
+
|
|
22
|
+
- report Server-Timing metrics
|
|
23
|
+
|
|
24
|
+
- authenticate timing crawl requests
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
### Fixed
|
|
30
|
+
|
|
31
|
+
- use portable crawl configuration defaults
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
|
|
8
35
|
## [0.7.2] - 2026-07-23
|
|
9
36
|
|
|
10
37
|
|
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
|
|
@@ -101,21 +106,32 @@ Base URL: https://example.com
|
|
|
101
106
|
Sitemap: https://example.com/sitemap.xml
|
|
102
107
|
URLs: 24
|
|
103
108
|
Pages: 24
|
|
104
|
-
Status:
|
|
105
|
-
Issues: 3 3 warnings
|
|
106
|
-
|
|
107
|
-
Summary:
|
|
108
|
-
links 2
|
|
109
|
-
metadata 1
|
|
109
|
+
Status: WARNINGS
|
|
110
|
+
Issues: 3 total (3 warnings)
|
|
110
111
|
|
|
111
|
-
links
|
|
112
|
-
- /pricing
|
|
113
|
-
|
|
112
|
+
Links: Too few incoming links without nofollow (2 warnings)
|
|
113
|
+
- /pricing incoming links without nofollow: 1; minimum 2
|
|
114
|
+
linked from: /
|
|
115
|
+
- /features incoming links without nofollow: 1; minimum 2
|
|
116
|
+
linked from: /
|
|
114
117
|
|
|
115
|
-
|
|
118
|
+
Metadata: Missing title (1 warning)
|
|
116
119
|
- /draft missing <title>
|
|
117
120
|
```
|
|
118
121
|
|
|
122
|
+
Reports include all issues and related URLs. Errors appear before warnings and
|
|
123
|
+
notices. Each related URL has a label, such as `linked from`, `links to`, or
|
|
124
|
+
`page`. JSON-LD errors appear below the page URL with the type, field, and
|
|
125
|
+
constraint. Same-origin URLs use paths; other URLs retain their scheme and host.
|
|
126
|
+
For automation in Ruby, use `result.issues` and each issue's `code`, `severity`,
|
|
127
|
+
`url`, and `details` rather than matching the report text.
|
|
128
|
+
|
|
129
|
+
Metadata length limits appear in the group heading; each row shows the measured
|
|
130
|
+
length. The default maximums are 160 characters for a meta description and 110
|
|
131
|
+
characters for an Article JSON-LD `headline`. These are separate Crawlscope
|
|
132
|
+
validation limits for separate fields. Server timing lists pages only when the
|
|
133
|
+
reported duration exceeds 50 ms; aggregate statistics include all responses.
|
|
134
|
+
|
|
119
135
|
## Ruby Usage
|
|
120
136
|
|
|
121
137
|
```ruby
|
|
@@ -141,10 +157,69 @@ puts result.issues.to_a.map(&:message)
|
|
|
141
157
|
- `urls`: sitemap URLs selected for validation
|
|
142
158
|
- `pages`: fetched page snapshots
|
|
143
159
|
- `issues`: structured issues with `code`, `severity`, `category`, `url`, and `message`
|
|
160
|
+
- `server_timing_summary`: aggregate response timing data when pages publish it
|
|
144
161
|
|
|
145
162
|
`result.ok?` returns `false` when an error is present. Warnings and notices
|
|
146
163
|
remain available through `result.issues` without making the result fail.
|
|
147
164
|
|
|
165
|
+
## Server Timing
|
|
166
|
+
|
|
167
|
+
Crawlscope parses the
|
|
168
|
+
[`Server-Timing`](https://www.w3.org/TR/server-timing/) response header for both
|
|
169
|
+
HTTP and browser-rendered crawls. Each page exposes its parsed header through
|
|
170
|
+
`page.server_timing`:
|
|
171
|
+
|
|
172
|
+
```ruby
|
|
173
|
+
page.server_timing.each do |metric|
|
|
174
|
+
puts [metric.name, metric.duration, metric.description].compact.join(": ")
|
|
175
|
+
end
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
The validation report adds a `Server Timing` section only when at least one page
|
|
179
|
+
publishes the header. It includes:
|
|
180
|
+
|
|
181
|
+
- header coverage and the number of pages publishing durations
|
|
182
|
+
- sample and page counts with average, p50, p95, and maximum per metric
|
|
183
|
+
- non-duration signals, including cache status and routing descriptions
|
|
184
|
+
- the ten pages with the largest individual metric
|
|
185
|
+
- the number of malformed entries ignored during parsing
|
|
186
|
+
|
|
187
|
+
`dur` values are reported as milliseconds, as recommended by the specification.
|
|
188
|
+
Crawlscope does not add durations together because metrics such as `total`,
|
|
189
|
+
`app`, and `db` may overlap. A page's worst-offender entry is its largest
|
|
190
|
+
individual metric instead.
|
|
191
|
+
|
|
192
|
+
The current HTTP and browser transports expose response headers, not response
|
|
193
|
+
trailers. Metrics published only in a `Server-Timing` trailer are therefore not
|
|
194
|
+
available to Crawlscope.
|
|
195
|
+
|
|
196
|
+
Rails 8 applications can enable `ActionDispatch::ServerTiming` with:
|
|
197
|
+
|
|
198
|
+
```ruby
|
|
199
|
+
config.server_timing = true
|
|
200
|
+
```
|
|
201
|
+
|
|
202
|
+
Applications that expose timing only to authenticated diagnostics can set
|
|
203
|
+
`config.profile_token`. When this value is present, Crawlscope sends it as
|
|
204
|
+
`X-Profile-Token` on same-origin sitemap, HTTP, and browser document requests.
|
|
205
|
+
The header survives same-origin redirects but is removed before a cross-origin
|
|
206
|
+
redirect, and browser subresources never receive it. Keep the token in the host
|
|
207
|
+
application's secret store rather than a URL, command argument, report, or
|
|
208
|
+
checked-in configuration.
|
|
209
|
+
|
|
210
|
+
`CRAWLSCOPE_PROFILE_TOKEN` is the portable default for Rails, the standalone
|
|
211
|
+
CLI, Rake tasks, and plain Ruby callers. Rails applications may instead assign
|
|
212
|
+
`config.profile_token` from encrypted credentials when that is their established
|
|
213
|
+
secret-management boundary; explicit configuration takes precedence over the
|
|
214
|
+
environment.
|
|
215
|
+
|
|
216
|
+
New Rails applications enable it in development by default; production remains
|
|
217
|
+
opt-in. Rails publishes the Active Support notification names observed during
|
|
218
|
+
each request and sums repeated events with the same name. The exact metrics
|
|
219
|
+
therefore depend on the application and request, but commonly include controller,
|
|
220
|
+
view, database, and cache instrumentation. Crawlscope accepts Rails' dotted
|
|
221
|
+
metric names and reports each metric independently.
|
|
222
|
+
|
|
148
223
|
## Rails Usage
|
|
149
224
|
|
|
150
225
|
Run the install generator after adding the gem:
|
|
@@ -162,10 +237,11 @@ Customize the `Crawlscope.configure` block inside the generated initializer:
|
|
|
162
237
|
|
|
163
238
|
```ruby
|
|
164
239
|
Crawlscope.configure do |config|
|
|
165
|
-
config.base_url = -> { "
|
|
166
|
-
config.sitemap_path =
|
|
167
|
-
|
|
168
|
-
|
|
240
|
+
config.base_url = -> { ENV.fetch("CRAWLSCOPE_BASE_URL", "http://localhost:3000") }
|
|
241
|
+
config.sitemap_path = lambda {
|
|
242
|
+
ENV.fetch("SITEMAP", "#{config.base_url.to_s.chomp("/")}/sitemap.xml")
|
|
243
|
+
}
|
|
244
|
+
config.site_name = ENV.fetch("CRAWLSCOPE_SITE_NAME", "Application")
|
|
169
245
|
end
|
|
170
246
|
```
|
|
171
247
|
|
|
@@ -193,6 +269,7 @@ Available environment overrides:
|
|
|
193
269
|
|
|
194
270
|
- `URL`
|
|
195
271
|
- `SITEMAP`
|
|
272
|
+
- `CRAWLSCOPE_PROFILE_TOKEN`
|
|
196
273
|
- `RULES=metadata,links`
|
|
197
274
|
- `JS=1` or `RENDERER=browser`
|
|
198
275
|
- `TIMEOUT=30`
|
|
@@ -226,10 +303,10 @@ bundle exec rake 'crawlscope:validate:ldjson[https://example.com/article]'
|
|
|
226
303
|
|
|
227
304
|
`crawlscope:validate` runs all default sitemap rules: indexability, metadata,
|
|
228
305
|
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.
|
|
306
|
+
base. Without `SITEMAP`, Crawlscope uses the configured sitemap URL, then fetches
|
|
307
|
+
`/sitemap.xml` from `URL` over HTTP. With `SITEMAP`, Crawlscope uses `URL` as
|
|
308
|
+
the site base and validates URLs from that sitemap. `SITEMAP` may be a full URL
|
|
309
|
+
or an explicitly selected local file path.
|
|
233
310
|
|
|
234
311
|
Plain `rake` does not pass `--url` style flags to tasks. Use `URL=...` or the
|
|
235
312
|
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
|
|
|
@@ -111,7 +114,6 @@ module Crawlscope
|
|
|
111
114
|
elsif !@allowed_statuses.include?(page.status)
|
|
112
115
|
issues.add(code: :unexpected_status, severity: :error, category: :crawl, url: page.url, message: "HTTP #{page.status}", details: {status: page.status})
|
|
113
116
|
elsif redirected?(page)
|
|
114
|
-
issues.add(code: :redirected_page, severity: :warning, category: :crawl, url: page.url, message: "redirects to #{page.final_url}", details: {final_url: page.final_url, status: page.status})
|
|
115
117
|
issues.add(code: :sitemap_redirect_url, severity: :warning, category: :sitemaps, url: page.url, message: "sitemap URL redirects to #{page.final_url}", details: {final_url: page.final_url, status: page.status})
|
|
116
118
|
end
|
|
117
119
|
end
|
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
|
@@ -4,7 +4,37 @@ require "uri"
|
|
|
4
4
|
|
|
5
5
|
module Crawlscope
|
|
6
6
|
class Reporter
|
|
7
|
-
|
|
7
|
+
GROUP_LABELS = {
|
|
8
|
+
structured_data_schema_error: "Schema errors",
|
|
9
|
+
structured_data_parse_error: "Parse errors",
|
|
10
|
+
structured_data_missing_type: "Missing @type",
|
|
11
|
+
sitemap_redirect_url: "Redirected URLs",
|
|
12
|
+
non_canonical_page_in_sitemap: "URLs with a different canonical",
|
|
13
|
+
indexable_page_missing_from_sitemap: "Linked pages missing from sitemap",
|
|
14
|
+
sitemap_noindex_url: "URLs that block indexing",
|
|
15
|
+
low_dofollow_inlinks: "Too few incoming links without nofollow",
|
|
16
|
+
low_inbound_anchor_links: "Too few incoming links",
|
|
17
|
+
low_unique_token_ratio: "Repeated words",
|
|
18
|
+
low_visible_text_ratio: "Too little text for the HTML size",
|
|
19
|
+
thin_visible_text: "Too few words",
|
|
20
|
+
orphan_page: "Pages with no incoming links",
|
|
21
|
+
nofollow_internal_outlinks: "Outgoing links with nofollow",
|
|
22
|
+
only_nofollow_internal_inlinks: "Incoming links all use nofollow",
|
|
23
|
+
mixed_follow_internal_inlinks: "Incoming links with and without nofollow",
|
|
24
|
+
http_internal_link: "Links from HTTPS to HTTP",
|
|
25
|
+
canonical_no_internal_inlinks: "Canonical URLs with no incoming links without nofollow",
|
|
26
|
+
canonical_points_to_error: "Canonical URLs that return errors",
|
|
27
|
+
url_double_slash: "Repeated slashes in URL paths",
|
|
28
|
+
multiple_h1: "Multiple <h1> tags",
|
|
29
|
+
multiple_title_tags: "Multiple <title> tags",
|
|
30
|
+
incomplete_open_graph_tags: "Missing Open Graph tags",
|
|
31
|
+
title_too_long: "Title exceeds #{Rules::Metadata::TITLE_MAX_LENGTH} characters",
|
|
32
|
+
meta_description_too_long: "Meta description exceeds #{Rules::Metadata::DESCRIPTION_MAX_LENGTH} characters",
|
|
33
|
+
meta_description_too_short: "Meta description below #{Rules::Metadata::DESCRIPTION_MIN_LENGTH} characters",
|
|
34
|
+
duplicate_content_fingerprint: "Duplicate page text",
|
|
35
|
+
near_duplicate_content: "Similar page text",
|
|
36
|
+
near_duplicate_scan_skipped: "Similar-page check skipped"
|
|
37
|
+
}.freeze
|
|
8
38
|
|
|
9
39
|
def initialize(io:)
|
|
10
40
|
@io = io
|
|
@@ -19,16 +49,17 @@ module Crawlscope
|
|
|
19
49
|
|
|
20
50
|
if result.issues.size.zero?
|
|
21
51
|
@io.puts("Status: OK")
|
|
22
|
-
|
|
52
|
+
else
|
|
53
|
+
@io.puts("Status: #{status_for(result.issues)}")
|
|
54
|
+
@io.puts("Issues: #{result.issues.size} total (#{severity_summary(result.issues)})")
|
|
23
55
|
end
|
|
24
56
|
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
57
|
+
unless result.issues.size.zero?
|
|
58
|
+
@io.puts("")
|
|
59
|
+
report_issue_groups(result.issues, base_url: result.base_url)
|
|
60
|
+
end
|
|
28
61
|
|
|
29
|
-
|
|
30
|
-
@io.puts("")
|
|
31
|
-
report_issue_groups(result.issues, base_url: result.base_url)
|
|
62
|
+
ServerTiming::Reporter.new(io: @io).report(result.server_timing_summary, base_url: result.base_url)
|
|
32
63
|
end
|
|
33
64
|
|
|
34
65
|
private
|
|
@@ -46,43 +77,45 @@ module Crawlscope
|
|
|
46
77
|
end
|
|
47
78
|
|
|
48
79
|
def severity_summary(issues)
|
|
49
|
-
|
|
50
|
-
return "" if grouped.empty?
|
|
51
|
-
|
|
52
|
-
grouped
|
|
53
|
-
.sort_by { |severity, severity_issues| [-severity_issues.size, severity.to_s] }
|
|
54
|
-
.map { |severity, severity_issues| "#{severity_issues.size} #{pluralize(severity, severity_issues.size)}" }
|
|
55
|
-
.join(", ")
|
|
56
|
-
end
|
|
57
|
-
|
|
58
|
-
def report_summary(issues)
|
|
59
|
-
@io.puts("Summary:")
|
|
60
|
-
|
|
61
|
-
issues.by_category
|
|
62
|
-
.sort_by { |category, category_issues| [-category_issues.size, category.to_s] }
|
|
63
|
-
.each do |category, category_issues|
|
|
64
|
-
@io.puts(" #{category.to_s.ljust(16)} #{category_issues.size}")
|
|
65
|
-
end
|
|
80
|
+
severity_summary_for(issues.to_a)
|
|
66
81
|
end
|
|
67
82
|
|
|
68
83
|
def report_issue_groups(issues, base_url:)
|
|
69
84
|
grouped = issues.to_a.group_by { |issue| [issue.category, issue.code] }
|
|
70
85
|
|
|
71
86
|
grouped
|
|
72
|
-
.sort_by { |(category, code), grouped_issues| [-grouped_issues.size, category.to_s, code.to_s] }
|
|
87
|
+
.sort_by { |(category, code), grouped_issues| [grouped_issues.map { |issue| severity_order(issue.severity) }.min, -grouped_issues.size, category.to_s, code.to_s] }
|
|
73
88
|
.each do |(category, code), grouped_issues|
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
89
|
+
label = GROUP_LABELS.fetch(code) { code.to_s.tr("_", " ").capitalize }
|
|
90
|
+
@io.puts("#{category.to_s.tr("_", " ").capitalize}: #{label} (#{severity_summary_for(grouped_issues)})")
|
|
91
|
+
|
|
92
|
+
grouped_issues.each do |issue|
|
|
93
|
+
details = issue.details || {}
|
|
94
|
+
if issue.code == :structured_data_schema_error && details[:errors]&.any?
|
|
95
|
+
source = (details[:source] == "json-ld") ? "JSON-LD" : details[:source]
|
|
96
|
+
@io.puts(" - #{relative_url(issue.url, base_url: base_url)} #{source}")
|
|
97
|
+
details[:errors].each do |error|
|
|
98
|
+
@io.puts(" #{StructuredData::ErrorFormatter.format(error)}")
|
|
99
|
+
end
|
|
100
|
+
else
|
|
101
|
+
@io.puts(" - #{compact_issue(issue, base_url: base_url)}")
|
|
102
|
+
report_related_urls(issue, details, base_url: base_url)
|
|
103
|
+
end
|
|
78
104
|
end
|
|
79
105
|
|
|
80
|
-
remaining_count = grouped_issues.size - MAX_ISSUES_PER_GROUP
|
|
81
|
-
@io.puts(" ... #{remaining_count} more") if remaining_count.positive?
|
|
82
106
|
@io.puts("")
|
|
83
107
|
end
|
|
84
108
|
end
|
|
85
109
|
|
|
110
|
+
def severity_order(severity)
|
|
111
|
+
{error: 0, warning: 1, notice: 2}.fetch(severity, 3)
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def severity_summary_for(issues)
|
|
115
|
+
issues.group_by(&:severity).sort_by { |severity, _| severity_order(severity) }
|
|
116
|
+
.map { |severity, entries| "#{entries.size} #{pluralize(severity, entries.size)}" }.join(", ")
|
|
117
|
+
end
|
|
118
|
+
|
|
86
119
|
def compact_issue(issue, base_url:)
|
|
87
120
|
parts = []
|
|
88
121
|
parts << relative_url(issue.url, base_url: base_url) if issue.url
|
|
@@ -95,35 +128,87 @@ module Crawlscope
|
|
|
95
128
|
|
|
96
129
|
def compact_detail(issue, base_url:)
|
|
97
130
|
details = issue.details || {}
|
|
98
|
-
fragments = []
|
|
99
|
-
|
|
100
|
-
inbound = details[:dofollow_inbound_count] || details[:inbound_count]
|
|
101
|
-
fragments << "inbound #{inbound}/#{details[:minimum]}" if inbound && details[:minimum]
|
|
102
|
-
|
|
103
|
-
if details[:ratio] && details[:threshold]
|
|
104
|
-
fragments << "ratio #{format_number(details[:ratio])}/#{format_number(details[:threshold])}"
|
|
105
|
-
end
|
|
106
|
-
|
|
107
|
-
fragments << "count #{details[:count]}" if details[:count]
|
|
108
|
-
fragments << "length #{details[:length]}" if details[:length]
|
|
109
|
-
fragments << "status #{details[:status]}" if details[:status]
|
|
110
|
-
fragments << "final: #{relative_url(details[:final_url], base_url: base_url)}" if details[:final_url]
|
|
111
|
-
fragments << "sources: #{relative_urls(details[:source_urls], base_url: base_url).join(", ")}" if details[:source_urls]&.any?
|
|
112
|
-
fragments << "source: #{relative_url(details[:source_url], base_url: base_url)}" if details[:source_url]
|
|
113
|
-
fragments << "targets: #{relative_urls(details[:target_urls], base_url: base_url).join(", ")}" if details[:target_urls]&.any?
|
|
114
|
-
|
|
115
|
-
return issue.message if fragments.empty?
|
|
116
|
-
|
|
117
131
|
case issue.code
|
|
118
|
-
when :
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
132
|
+
when :redirected_page, :sitemap_redirect_url, :internal_link_redirects
|
|
133
|
+
if details[:final_url]
|
|
134
|
+
prefix = (issue.code == :sitemap_redirect_url) ? "sitemap URL redirects to" : "redirects to"
|
|
135
|
+
status = details[:status] ? " (final HTTP #{details[:status]})" : ""
|
|
136
|
+
return "#{prefix} #{relative_url(details[:final_url], base_url: base_url)}#{status}"
|
|
137
|
+
end
|
|
138
|
+
when :canonical_mismatch, :non_canonical_page_in_sitemap
|
|
139
|
+
if details[:canonical]
|
|
140
|
+
prefix = (issue.code == :non_canonical_page_in_sitemap) ? "sitemap lists this URL, but its canonical points to" : "canonical points to a different URL:"
|
|
141
|
+
return "#{prefix} #{relative_url(details[:canonical], base_url: base_url)}"
|
|
142
|
+
end
|
|
143
|
+
when :unexpected_status
|
|
144
|
+
return "HTTP #{details[:status]}" if details[:status]
|
|
145
|
+
when :title_too_long, :meta_description_too_long, :meta_description_too_short
|
|
146
|
+
return "#{details[:length]} characters" if details[:length]
|
|
147
|
+
when :broken_internal_link
|
|
148
|
+
return "linked URL returns HTTP #{details[:status]}"
|
|
149
|
+
when :unresolved_internal_link
|
|
150
|
+
return ["cannot check linked URL", details[:error]].compact.join(": ")
|
|
151
|
+
when :indexable_page_missing_from_sitemap
|
|
152
|
+
return "linked page is missing from sitemap"
|
|
153
|
+
when :canonical_points_to_redirect
|
|
154
|
+
status = details[:status] ? " (final HTTP #{details[:status]})" : ""
|
|
155
|
+
return "canonical #{relative_url(details[:canonical], base_url: base_url)} redirects to #{relative_url(details[:final_url], base_url: base_url)}#{status}"
|
|
156
|
+
when :canonical_points_to_error
|
|
157
|
+
return "canonical #{relative_url(details[:canonical], base_url: base_url)} returns HTTP #{details[:status]}"
|
|
158
|
+
when :low_dofollow_inlinks, :low_inbound_anchor_links
|
|
159
|
+
count = details[:dofollow_inbound_count] || details[:inbound_count]
|
|
160
|
+
label = (issue.code == :low_dofollow_inlinks) ? "incoming links without nofollow" : "incoming links"
|
|
161
|
+
return "#{label}: #{count}; minimum #{details[:minimum]}"
|
|
162
|
+
when :low_unique_token_ratio, :low_visible_text_ratio
|
|
163
|
+
label = (issue.code == :low_unique_token_ratio) ? "unique words" : "visible text / HTML bytes"
|
|
164
|
+
return "#{label}: #{percentage(details[:ratio])}; minimum #{percentage(details[:threshold])}"
|
|
165
|
+
when :thin_visible_text
|
|
166
|
+
return "visible words: #{details[:word_count]}; minimum #{details[:minimum]}"
|
|
167
|
+
when :mixed_follow_internal_inlinks
|
|
168
|
+
return "incoming links: #{details[:dofollow_inbound_count]} without nofollow, #{details[:nofollow_inbound_count]} with nofollow"
|
|
169
|
+
when :only_nofollow_internal_inlinks
|
|
170
|
+
return "all #{details[:nofollow_inbound_count]} incoming internal links use nofollow"
|
|
171
|
+
when :duplicate_title, :duplicate_meta_description
|
|
172
|
+
label = (issue.code == :duplicate_title) ? "same title" : "same meta description"
|
|
173
|
+
return "#{label}: #{details[:value].to_s.inspect}"
|
|
174
|
+
when :duplicate_content_fingerprint
|
|
175
|
+
return "same page text"
|
|
176
|
+
when :duplicate_pages_without_canonical
|
|
177
|
+
return "same page text; at least one page has no canonical link"
|
|
178
|
+
when :near_duplicate_content
|
|
179
|
+
return "text similarity: #{percentage(details[:similarity])}; threshold #{percentage(details[:threshold])}"
|
|
180
|
+
when :near_duplicate_scan_skipped
|
|
181
|
+
return "similar-page check skipped: #{details[:page_count]} pages; limit #{details[:max_pages]}"
|
|
182
|
+
when :structured_data_missing_type
|
|
183
|
+
return "#{issue.message} at #{Array(details[:paths]).join(", ")}" if details[:paths]&.any?
|
|
184
|
+
when :noindex_meta, :nofollow_meta, :noindex_follow_meta, :noindex_nofollow_meta,
|
|
185
|
+
:noindex_header, :nofollow_header, :noindex_follow_header, :noindex_nofollow_header,
|
|
186
|
+
:sitemap_noindex_url
|
|
187
|
+
if details[:content]
|
|
188
|
+
source = details[:name] || details[:source]
|
|
189
|
+
label = source ? "#{source}: " : ""
|
|
190
|
+
return "#{issue.message} (#{label}#{details[:content]})"
|
|
191
|
+
end
|
|
122
192
|
end
|
|
193
|
+
issue.message
|
|
123
194
|
end
|
|
124
195
|
|
|
125
|
-
def
|
|
126
|
-
|
|
196
|
+
def report_related_urls(issue, details, base_url:)
|
|
197
|
+
source_label = (issue.code == :canonical_no_internal_inlinks) ? "canonical on" : "linked from"
|
|
198
|
+
sources = Array(details[:source_urls])
|
|
199
|
+
sources += [details[:source_url]] if details[:source_url]
|
|
200
|
+
relationships = {
|
|
201
|
+
source_label => sources.uniq,
|
|
202
|
+
"links to" => details[:target_urls],
|
|
203
|
+
"page" => details[:urls],
|
|
204
|
+
"linked from (without nofollow)" => details[:dofollow_source_urls],
|
|
205
|
+
"linked from (nofollow)" => details[:nofollow_source_urls]
|
|
206
|
+
}
|
|
207
|
+
relationships.each do |label, urls|
|
|
208
|
+
Array(urls).each do |url|
|
|
209
|
+
@io.puts(" #{label}: #{relative_url(url, base_url: base_url)}")
|
|
210
|
+
end
|
|
211
|
+
end
|
|
127
212
|
end
|
|
128
213
|
|
|
129
214
|
def relative_url(url, base_url:)
|
|
@@ -142,10 +227,8 @@ module Crawlscope
|
|
|
142
227
|
url
|
|
143
228
|
end
|
|
144
229
|
|
|
145
|
-
def
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
value.to_s
|
|
230
|
+
def percentage(value)
|
|
231
|
+
"#{format("%.3f", value * 100).sub(/0+\z/, "").sub(/\.\z/, "")}%"
|
|
149
232
|
end
|
|
150
233
|
|
|
151
234
|
def pluralize(word, count)
|