crawlscope 0.8.0 → 0.9.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.
@@ -66,7 +66,9 @@ module Crawlscope
66
66
  end
67
67
 
68
68
  def summarize_pages(pages)
69
- html_pages = pages.select(&:html?)
69
+ html_pages = pages.select do |page|
70
+ page.html? && page.normalized_url.to_s == page.normalized_final_url.to_s
71
+ end
70
72
 
71
73
  FetchExecutor.map(
72
74
  name: @fetch_executor,
@@ -82,7 +84,7 @@ module Crawlscope
82
84
  severity: :warning,
83
85
  category: :uniqueness,
84
86
  url: nil,
85
- message: "duplicate title '#{value}' => #{urls.join(", ")}",
87
+ message: "same title: #{value.inspect}",
86
88
  details: {urls: urls, value: value}
87
89
  )
88
90
  end
@@ -93,7 +95,7 @@ module Crawlscope
93
95
  severity: :warning,
94
96
  category: :uniqueness,
95
97
  url: nil,
96
- message: "duplicate meta description '#{value}' => #{urls.join(", ")}",
98
+ message: "same meta description: #{value.inspect}",
97
99
  details: {urls: urls, value: value}
98
100
  )
99
101
  end
@@ -104,7 +106,7 @@ module Crawlscope
104
106
  severity: :warning,
105
107
  category: :uniqueness,
106
108
  url: nil,
107
- message: "duplicate page content fingerprint => #{urls.join(", ")}",
109
+ message: "same page text",
108
110
  details: {urls: urls}
109
111
  )
110
112
  end
@@ -115,7 +117,7 @@ module Crawlscope
115
117
  severity: :warning,
116
118
  category: :uniqueness,
117
119
  url: nil,
118
- message: "duplicate pages without canonical => #{urls.join(", ")}",
120
+ message: "same page text; at least one page has no canonical link",
119
121
  details: {urls: urls}
120
122
  )
121
123
  end
@@ -144,7 +146,7 @@ module Crawlscope
144
146
  severity: :warning,
145
147
  category: :uniqueness,
146
148
  url: nil,
147
- message: "near duplicate scan skipped for #{page_summaries.size} pages",
149
+ message: "similar-page check skipped: #{page_summaries.size} pages; limit #{@max_near_duplicate_pages}",
148
150
  details: {max_pages: @max_near_duplicate_pages, page_count: page_summaries.size}
149
151
  )
150
152
  return
@@ -164,7 +166,7 @@ module Crawlscope
164
166
  severity: :warning,
165
167
  category: :uniqueness,
166
168
  url: nil,
167
- message: "near duplicate page content (#{format("%.2f", similarity)}) => #{urls.join(", ")}",
169
+ message: "text similarity: #{format("%.1f", similarity * 100)}%; threshold #{format("%.1f", @near_duplicate_threshold * 100)}%",
168
170
  details: {similarity: similarity.round(3), threshold: @near_duplicate_threshold, urls: urls}
169
171
  )
170
172
  end
@@ -51,11 +51,16 @@ module Crawlscope
51
51
  return errors if schema.nil?
52
52
 
53
53
  JSON::Validator.fully_validate(schema, item, errors_as_objects: true).each do |error|
54
- errors << {
54
+ details = {
55
55
  field: error[:fragment].to_s.sub("#/", ""),
56
56
  issue: error[:message],
57
57
  type: type
58
58
  }
59
+ if %w[MaxLength MinLength].include?(error[:failed_attribute])
60
+ value = value_at_fragment(item, error[:fragment])
61
+ details[:length] = value.length if value.is_a?(String)
62
+ end
63
+ errors << details
59
64
  end
60
65
 
61
66
  errors
@@ -69,6 +74,17 @@ module Crawlscope
69
74
 
70
75
  private
71
76
 
77
+ def value_at_fragment(item, fragment)
78
+ fragment.to_s.delete_prefix("#/").split("/").reduce(item) do |value, key|
79
+ case value
80
+ when Hash
81
+ value[key]
82
+ when Array
83
+ value[key.to_i] if key.match?(/\A\d+\z/)
84
+ end
85
+ end
86
+ end
87
+
72
88
  def deep_copy(value)
73
89
  case value
74
90
  when Hash
@@ -5,6 +5,8 @@ require "uri"
5
5
  module Crawlscope
6
6
  class ServerTiming
7
7
  class Reporter
8
+ SLOW_PAGE_THRESHOLD_MS = 50
9
+
8
10
  def initialize(io:)
9
11
  @io = io
10
12
  end
@@ -22,7 +24,7 @@ module Crawlscope
22
24
 
23
25
  report_metrics(summary.metrics)
24
26
  report_signals(summary.signals)
25
- report_worst_pages(summary.worst_pages, base_url: base_url)
27
+ report_worst_pages(summary.worst_pages(limit: summary.pages.size), base_url: base_url)
26
28
  @io.puts(" Ignored malformed entries: #{summary.invalid_count}") if summary.invalid_count.positive?
27
29
  end
28
30
 
@@ -31,7 +33,7 @@ module Crawlscope
31
33
  def report_metrics(metrics)
32
34
  return if metrics.empty?
33
35
 
34
- @io.puts(" Duration metrics (dur; milliseconds assumed):")
36
+ @io.puts(" Response times (ms):")
35
37
  metrics.each do |metric|
36
38
  label = metric[:name]
37
39
  label += " #{description(metric[:description])}" if metric[:description]
@@ -65,9 +67,10 @@ module Crawlscope
65
67
  end
66
68
 
67
69
  def report_worst_pages(samples, base_url:)
70
+ samples = samples.select { |sample| sample[:metric].duration > SLOW_PAGE_THRESHOLD_MS }
68
71
  return if samples.empty?
69
72
 
70
- @io.puts(" Worst pages:")
73
+ @io.puts(" Pages above #{SLOW_PAGE_THRESHOLD_MS} ms:")
71
74
  samples.each do |sample|
72
75
  metric = sample[:metric]
73
76
  detail = metric.description ? " #{description(metric.description)}" : ""
@@ -84,7 +87,6 @@ module Crawlscope
84
87
 
85
88
  def description(value)
86
89
  value = value.to_s
87
- value = "#{value[0, 77]}..." if value.length > 80
88
90
  value.inspect
89
91
  end
90
92
 
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Crawlscope
4
+ module StructuredData
5
+ class ErrorFormatter
6
+ def self.format(error)
7
+ field = (error[:field] || error["field"]).to_s.sub(/\A#\/?/, "")
8
+ type = error[:type] || error["type"]
9
+ issue = error[:issue] || error["issue"] || "Unknown error"
10
+
11
+ if (match = issue.match(/did not contain a required property of '([^']+)'/))
12
+ field = [field, match[1]].reject(&:empty?).join("/")
13
+ message = "is required"
14
+ else
15
+ message = concise_message(issue, error[:length] || error["length"])
16
+ end
17
+
18
+ location = [type, field.empty? ? nil : field].compact.join(".")
19
+ "#{location.empty? ? "$" : location}: #{message}"
20
+ end
21
+
22
+ def self.concise_message(issue, length)
23
+ if length
24
+ unit = (length == 1) ? "character" : "characters"
25
+ measured_length = "#{length} #{unit}"
26
+ end
27
+ case issue
28
+ when /was not of a maximum string length of (\d+)/
29
+ length ? "#{measured_length}; maximum #{Regexp.last_match(1)}" : "exceeds #{Regexp.last_match(1)} characters"
30
+ when /was not of a minimum string length of (\d+)/
31
+ length ? "#{measured_length}; minimum #{Regexp.last_match(1)}" : "must have at least #{Regexp.last_match(1)} characters"
32
+ when /of type (\w+) did not match the following type: (\w+)/
33
+ "must be #{Regexp.last_match(2)} (got #{Regexp.last_match(1)})"
34
+ else
35
+ issue.sub(/\AThe property '[^']*' /, "")
36
+ .sub(/ in schema [\h-]{36}(?:#\S*)?\z/, "")
37
+ end
38
+ end
39
+ private_class_method :concise_message
40
+ end
41
+ end
42
+ end
@@ -67,10 +67,9 @@ module Crawlscope
67
67
  @io.puts("• #{extract_path(entry.url)}")
68
68
 
69
69
  entry.errors.each do |error|
70
+ @io.puts(" #{source_label(error[:source])}:")
70
71
  error[:errors].each do |validation_error|
71
- field = validation_error[:field] || validation_error["field"] || "$"
72
- issue = validation_error[:issue] || validation_error["issue"] || "Unknown error"
73
- @io.puts(" - #{field}: #{issue}")
72
+ @io.puts(" - #{ErrorFormatter.format(validation_error)}")
74
73
  end
75
74
  end
76
75
  end
@@ -83,9 +82,7 @@ module Crawlscope
83
82
  end
84
83
 
85
84
  def report_entry(entry, debug:)
86
- @io.puts("=" * 80)
87
85
  @io.puts("URL: #{entry.url}")
88
- @io.puts("=" * 80)
89
86
 
90
87
  if entry.fetch_error
91
88
  @io.puts("Error: #{entry.fetch_error}")
@@ -124,19 +121,22 @@ module Crawlscope
124
121
 
125
122
  def report_validation(entry)
126
123
  @io.puts("")
127
- @io.puts("Validation results:")
128
-
129
124
  if entry.errors.empty?
130
- @io.puts(" All valid!")
125
+ @io.puts("Validation: OK")
131
126
  else
127
+ @io.puts("Validation: FAILED")
132
128
  entry.errors.each do |error|
133
- @io.puts(" #{error[:type]}: INVALID [#{error[:source]}]")
129
+ @io.puts(" #{source_label(error[:source])}:")
134
130
  error[:errors].each do |validation_error|
135
- @io.puts(" - field: #{validation_error[:field]}, issue: #{validation_error[:issue]}")
131
+ @io.puts(" - #{ErrorFormatter.format(validation_error)}")
136
132
  end
137
133
  end
138
134
  end
139
135
  end
136
+
137
+ def source_label(source)
138
+ (source == "json-ld") ? "JSON-LD" : source
139
+ end
140
140
  end
141
141
  end
142
142
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Crawlscope
4
- VERSION = "0.8.0"
4
+ VERSION = "0.9.0"
5
5
  end
data/lib/crawlscope.rb CHANGED
@@ -29,7 +29,7 @@ end
29
29
  Crawlscope.instance_variable_set(:@loader, Zeitwerk::Loader.for_gem)
30
30
  Crawlscope.loader.ignore("#{__dir__}/generators")
31
31
  Crawlscope.loader.ignore("#{__dir__}/tasks")
32
- Crawlscope.loader.ignore("#{__dir__}/crawlscope/railtie.rb")
32
+ Crawlscope.loader.ignore("#{__dir__}/crawlscope/railtie.rb", "#{__dir__}/crawlscope/rails.rb")
33
33
  Crawlscope.loader.ignore("#{__dir__}/crawlscope/tasks.rb")
34
34
  Crawlscope.loader.setup
35
35
 
@@ -325,6 +325,42 @@ class CrawlscopeCrawlTest < Minitest::Test
325
325
  assert_includes result.issues.to_a.map(&:code), :sitemap_redirect_url
326
326
  end
327
327
 
328
+ def test_reports_redirects_without_duplicate_destination_content
329
+ File.write(
330
+ @sitemap_path,
331
+ <<~XML
332
+ <?xml version="1.0" encoding="UTF-8"?>
333
+ <urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
334
+ <url><loc>https://example.com/old</loc></url>
335
+ <url><loc>https://example.com/alias</loc></url>
336
+ <url><loc>https://example.com/new</loc></url>
337
+ </urlset>
338
+ XML
339
+ )
340
+ ["old", "alias"].each do |path|
341
+ stub_request(:get, "https://example.com/#{path}")
342
+ .to_return(status: 301, headers: {"Location" => "https://example.com/new"}, body: "")
343
+ end
344
+ body = %(<html><head><title>Current page</title><meta name="description" content="Current description"><link rel="canonical" href="https://example.com/new"></head><body>#{"Useful content " * 30}</body></html>)
345
+ stub_request(:get, "https://example.com/new")
346
+ .to_return(status: 200, headers: {"Content-Type" => "text/html"}, body: body)
347
+
348
+ result = Crawlscope::Crawl.new(
349
+ base_url: "https://example.com",
350
+ sitemap_path: @sitemap_path,
351
+ rules: [Crawlscope::Rules::Uniqueness.new, Crawlscope::Rules::Metadata.new],
352
+ schema_registry: Crawlscope::SchemaRegistry.default,
353
+ fetch_executor: :threaded
354
+ ).call
355
+
356
+ assert_equal 3, result.pages.size
357
+ assert_equal ["https://example.com/old", "https://example.com/alias"], result.issues.select { |issue| issue.code == :sitemap_redirect_url }.map(&:url)
358
+ refute_includes result.issues.to_a.map(&:code), :redirected_page
359
+ assert_empty result.issues.select { |issue| issue.category == :uniqueness }
360
+ assert_empty result.issues.select { |issue| [:canonical_mismatch, :non_canonical_page_in_sitemap].include?(issue.code) }
361
+ assert_equal 1, result.issues.count { |issue| issue.code == :meta_description_too_short }
362
+ end
363
+
328
364
  def test_resolves_uncrawled_link_targets_as_a_bounded_batch
329
365
  File.write(
330
366
  @sitemap_path,
@@ -56,7 +56,7 @@ class CrawlscopeLinksRuleTest < Minitest::Test
56
56
  )
57
57
 
58
58
  assert_includes issues.to_a.map(&:code), :unresolved_internal_link
59
- assert_includes issues.to_a.find { |issue| issue.code == :unresolved_internal_link }.message, "unable to validate internal link"
59
+ assert_includes issues.to_a.find { |issue| issue.code == :unresolved_internal_link }.message, "cannot check linked URL"
60
60
  end
61
61
 
62
62
  def test_ignores_fetch_errors_for_urls_already_crawled
@@ -429,6 +429,42 @@ class CrawlscopeLinksRuleTest < Minitest::Test
429
429
  assert_empty issues.to_a
430
430
  end
431
431
 
432
+ def test_retains_all_link_sources_and_targets_in_issue_details
433
+ source_urls = 5.times.map { |index| "https://example.com/source-#{index}" }
434
+ target_urls = 5.times.map { |index| "https://example.com/target-#{index}" }
435
+ pages = source_urls.map do |url|
436
+ targets = target_urls.map { |target| %(<a href="#{target}" rel="nofollow">Target</a>) }.join
437
+ page(url: url, body: %(<html><body>#{targets}<a href="/missing">Missing</a><a href="/pricing">Pricing</a></body></html>))
438
+ end
439
+ resolver = lambda do |url|
440
+ {crawled: false, error: nil, final_url: url, html: true, status: url.end_with?("/missing") ? 404 : 200}
441
+ end
442
+ issues = Crawlscope::IssueCollection.new
443
+
444
+ Crawlscope::Rules::Links.new.call(urls: source_urls, pages: pages, issues: issues, context: context(resolver: resolver))
445
+
446
+ broken = issues.find { |issue| issue.code == :broken_internal_link }
447
+ assert_equal source_urls, broken.details[:source_urls]
448
+ outgoing = issues.select { |issue| issue.code == :nofollow_internal_outlinks }
449
+ assert_equal 5, outgoing.size
450
+ outgoing.each { |issue| assert_equal target_urls, issue.details[:target_urls] }
451
+ missing = issues.find { |issue| issue.code == :indexable_page_missing_from_sitemap && issue.url.end_with?("/pricing") }
452
+ assert_equal source_urls.first, missing.details[:source_url]
453
+ assert_equal source_urls, missing.details[:source_urls]
454
+ end
455
+
456
+ def test_does_not_check_destination_canonicals_under_redirect_aliases
457
+ pages = ["old", "alias"].map do |path|
458
+ page(url: "https://example.com/#{path}", final_url: "https://example.com/guide", body: '<html><head><link rel="canonical" href="https://example.com/missing"></head></html>')
459
+ end
460
+ issues = Crawlscope::IssueCollection.new
461
+
462
+ Crawlscope::Rules::Links.new.call(urls: pages.map(&:url), pages: pages, issues: issues, context: context)
463
+
464
+ canonical_issues = issues.select { |issue| [:canonical_no_internal_inlinks, :canonical_points_to_redirect, :canonical_points_to_error].include?(issue.code) }
465
+ assert_empty canonical_issues
466
+ end
467
+
432
468
  private
433
469
 
434
470
  def context(resolver: method(:resolve_target))
@@ -439,14 +475,14 @@ class CrawlscopeLinksRuleTest < Minitest::Test
439
475
  }
440
476
  end
441
477
 
442
- def page(url:, body:)
478
+ def page(url:, body:, final_url: url)
443
479
  doc = Nokogiri::HTML(body)
444
480
 
445
481
  Crawlscope::Page.new(
446
482
  url: url,
447
483
  normalized_url: url,
448
- final_url: url,
449
- normalized_final_url: url,
484
+ final_url: final_url,
485
+ normalized_final_url: final_url,
450
486
  status: 200,
451
487
  headers: {"content-type" => "text/html"},
452
488
  body: body,
@@ -15,6 +15,7 @@ class CrawlscopeLoaderTest < Minitest::Test
15
15
  script = <<~RUBY
16
16
  require "crawlscope"
17
17
 
18
+ abort "Rails loaded by the core" if defined?(Rails)
18
19
  loaded = $LOADED_FEATURES.grep(%r{/(?:async|faraday|json-schema|nokogiri)(?:/|\\.rb)})
19
20
  abort loaded.join("\\n") unless loaded.empty?
20
21
  RUBY
@@ -29,6 +30,30 @@ class CrawlscopeLoaderTest < Minitest::Test
29
30
  assert status.success?, stderr
30
31
  end
31
32
 
33
+ def test_explicit_rails_integration_loads_after_the_core_and_registers_tasks
34
+ script = <<~RUBY
35
+ require "crawlscope"
36
+ require "crawlscope/rails"
37
+
38
+ abort "Railtie missing" unless Crawlscope::Railtie < Rails::Railtie
39
+ app = Class.new(Rails::Application)
40
+ app.config.eager_load = false
41
+ app.load_tasks
42
+ %w[validate validate:ldjson validate:indexability validate:content_quality].each do |task|
43
+ abort "task missing: \#{task}" unless Rake::Task.task_defined?("crawlscope:\#{task}")
44
+ end
45
+ RUBY
46
+
47
+ _stdout, stderr, status = Open3.capture3(
48
+ RbConfig.ruby,
49
+ "-I#{File.expand_path("../../lib", __dir__)}",
50
+ "-e",
51
+ script
52
+ )
53
+
54
+ assert status.success?, stderr
55
+ end
56
+
32
57
  def test_task_entrypoint_registers_tasks
33
58
  script = <<~RUBY
34
59
  require "crawlscope/tasks"
@@ -80,13 +80,46 @@ class CrawlscopeMetadataRuleTest < Minitest::Test
80
80
  assert_includes codes, :multiple_title_tags
81
81
  assert_includes codes, :multiple_meta_descriptions
82
82
  assert_includes codes, :empty_h1
83
- assert_includes codes, :canonical_mismatch
83
+ refute_includes codes, :canonical_mismatch
84
84
  assert_includes codes, :non_canonical_page_in_sitemap
85
+ mismatch = issues.find { |issue| issue.code == :non_canonical_page_in_sitemap }
86
+ assert_equal "sitemap lists this URL, but its canonical points to https://example.com/canonical-about", mismatch.message
87
+ assert_equal "https://example.com/canonical-about", mismatch.details[:canonical]
88
+ end
89
+
90
+ def test_length_messages_include_measurement_units_and_limit
91
+ issues = Crawlscope::IssueCollection.new
92
+ long_page = page(body: %(<html><head><title>#{"T" * 73}</title><meta name="description" content="#{"D" * 161}"></head></html>))
93
+
94
+ Crawlscope::Rules::Metadata.new.call(urls: [long_page.url], pages: [long_page], issues: issues)
95
+
96
+ assert_equal "title: 73 characters; maximum 72", issues.find { |issue| issue.code == :title_too_long }.message
97
+ assert_equal "meta description: 161 characters; maximum 160", issues.find { |issue| issue.code == :meta_description_too_long }.message
98
+ end
99
+
100
+ def test_does_not_apply_destination_metadata_to_redirect_alias
101
+ redirected = page(url: "https://example.com/old", final_url: "https://example.com/new", body: '<html><head><link rel="canonical" href="https://example.com/new"></head></html>')
102
+ issues = Crawlscope::IssueCollection.new
103
+
104
+ Crawlscope::Rules::Metadata.new.call(urls: [redirected.url], pages: [redirected], issues: issues)
105
+
106
+ assert_empty issues.to_a
107
+ end
108
+
109
+ def test_keeps_canonical_mismatch_for_a_page_outside_the_sitemap
110
+ mismatched = page(body: '<html><head><link rel="canonical" href="https://example.com/preferred"></head></html>')
111
+ issues = Crawlscope::IssueCollection.new
112
+
113
+ Crawlscope::Rules::Metadata.new.call(urls: [], pages: [mismatched], issues: issues)
114
+
115
+ canonical_issues = issues.select { |issue| [:canonical_mismatch, :non_canonical_page_in_sitemap].include?(issue.code) }
116
+ assert_equal [:canonical_mismatch], canonical_issues.map(&:code)
117
+ assert_equal "https://example.com/preferred", canonical_issues.first.details[:canonical]
85
118
  end
86
119
 
87
120
  private
88
121
 
89
- def page(url: "https://example.com/about", body: nil)
122
+ def page(url: "https://example.com/about", body: nil, final_url: url)
90
123
  body ||= <<~HTML
91
124
  <html>
92
125
  <head>
@@ -102,8 +135,8 @@ class CrawlscopeMetadataRuleTest < Minitest::Test
102
135
  Crawlscope::Page.new(
103
136
  url: url,
104
137
  normalized_url: Crawlscope::Url.normalize(url, base_url: url),
105
- final_url: url,
106
- normalized_final_url: Crawlscope::Url.normalize(url, base_url: url),
138
+ final_url: final_url,
139
+ normalized_final_url: Crawlscope::Url.normalize(final_url, base_url: url),
107
140
  status: 200,
108
141
  headers: {"content-type" => "text/html"},
109
142
  body: body,