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
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 18717d5f025a5e0dc10b3f5a231b52d788a7ad09fe017b803815dc1cf22b7f11
4
- data.tar.gz: 25f80cac563922d3c34d069c34b6c5521064fb4a74dd98cc54f6bb04e6de283a
3
+ metadata.gz: 89be661c49b5f2148a9d7962d81d522eedaedbba89c1f9dcf8474d62cfffd5f2
4
+ data.tar.gz: d27a26792cc5cfdab191371c3d005cf43298b02896cb7cbdf2113a5f341e6c12
5
5
  SHA512:
6
- metadata.gz: 322dacf831bfa19dd16a6ebf3917e8d8bcaab462b440468945e4e9e07768a42955afe7f418b217594f9654c22490d32124f6fdb95004a20f23508f76bca6d7db
7
- data.tar.gz: b301ad0f2c7c92dd3dc436f7019c62d7958bdd402eb8a887626d938a9bfd085d8d21bdab76a5243f3e2d9684d94ffce554e5108db26995d67b63140fcadbb9e2
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: FAILED
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 / low_dofollow_inlinks: 2
112
- - /pricing inbound 1/2 sources: /
113
- - /features inbound 1/2 sources: /
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
- metadata / missing_title: 1
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 = -> { "https://example.com" }
166
- config.sitemap_path = -> { Rails.public_path.join("sitemap.xml").to_s }
167
- config.site_name = "Example"
168
- config.schema_registry = -> { Crawlscope::SchemaRegistry.default }
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 path, then
230
- falls back to `/sitemap.xml`. With `SITEMAP`, Crawlscope uses `URL` as the site
231
- base and validates URLs from that sitemap. `SITEMAP` may be a full URL or a
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.
@@ -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,
@@ -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
@@ -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 :follow_redirects, limit: MAX_REDIRECTS
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
@@ -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
@@ -4,7 +4,37 @@ require "uri"
4
4
 
5
5
  module Crawlscope
6
6
  class Reporter
7
- MAX_ISSUES_PER_GROUP = 20
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
- return
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
- @io.puts("Status: #{status_for(result.issues)}")
26
- @io.puts("Issues: #{result.issues.size} total (#{severity_summary(result.issues)})")
27
- @io.puts("")
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
- report_summary(result.issues)
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
- grouped = issues.by_severity
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
- @io.puts("#{category} / #{code}: #{grouped_issues.size}")
75
-
76
- grouped_issues.first(MAX_ISSUES_PER_GROUP).each do |issue|
77
- @io.puts(" - #{compact_issue(issue, base_url: base_url)}")
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 :low_dofollow_inlinks, :low_inbound_anchor_links, :low_unique_token_ratio, :low_visible_text_ratio
119
- fragments.join(" ")
120
- else
121
- ([issue.message] + fragments).join(" ")
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 relative_urls(urls, base_url:)
126
- Array(urls).map { |url| relative_url(url, base_url: base_url) }
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 format_number(value)
146
- return format("%.3f", value) if value.is_a?(Float)
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)