crawlscope 0.8.0 → 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.
@@ -21,7 +21,7 @@ module Crawlscope
21
21
  sitemap_urls = normalized_sitemap_urls(urls)
22
22
 
23
23
  pages.each do |page|
24
- next unless page.html?
24
+ next unless page.html? && page.normalized_url.to_s == page.normalized_final_url.to_s
25
25
 
26
26
  validate_h1(page, issues)
27
27
  validate_title(page, issues)
@@ -47,7 +47,7 @@ module Crawlscope
47
47
  severity: :warning,
48
48
  category: :metadata,
49
49
  url: page.url,
50
- message: "empty <h1>",
50
+ message: "empty <h1> tags: #{empty_h1s.size}",
51
51
  details: {count: empty_h1s.size}
52
52
  )
53
53
  end
@@ -93,7 +93,7 @@ module Crawlscope
93
93
  if title.empty?
94
94
  issues.add(code: :missing_title, severity: :warning, category: :metadata, url: page.url, message: "missing <title>", details: {})
95
95
  elsif title.length > TITLE_MAX_LENGTH
96
- issues.add(code: :title_too_long, severity: :warning, category: :metadata, url: page.url, message: "title too long (#{title.length})", details: {length: title.length})
96
+ issues.add(code: :title_too_long, severity: :warning, category: :metadata, url: page.url, message: "title: #{title.length} characters; maximum #{TITLE_MAX_LENGTH}", details: {length: title.length})
97
97
  elsif repeated_site_name?(title)
98
98
  issues.add(code: :title_repeats_site_name, severity: :warning, category: :metadata, url: page.url, message: "title repeats #{@site_name}", details: {site_name: @site_name})
99
99
  end
@@ -117,9 +117,9 @@ module Crawlscope
117
117
  if description.empty?
118
118
  issues.add(code: :missing_meta_description, severity: :warning, category: :metadata, url: page.url, message: "missing meta description", details: {})
119
119
  elsif description.length < DESCRIPTION_MIN_LENGTH
120
- issues.add(code: :meta_description_too_short, severity: :warning, category: :metadata, url: page.url, message: "meta description too short (#{description.length})", details: {length: description.length, minimum: DESCRIPTION_MIN_LENGTH})
120
+ issues.add(code: :meta_description_too_short, severity: :warning, category: :metadata, url: page.url, message: "meta description: #{description.length} characters; minimum #{DESCRIPTION_MIN_LENGTH}", details: {length: description.length, minimum: DESCRIPTION_MIN_LENGTH})
121
121
  elsif description.length > DESCRIPTION_MAX_LENGTH
122
- issues.add(code: :meta_description_too_long, severity: :warning, category: :metadata, url: page.url, message: "meta description too long (#{description.length})", details: {length: description.length})
122
+ issues.add(code: :meta_description_too_long, severity: :warning, category: :metadata, url: page.url, message: "meta description: #{description.length} characters; maximum #{DESCRIPTION_MAX_LENGTH}", details: {length: description.length})
123
123
  end
124
124
  end
125
125
 
@@ -136,25 +136,25 @@ module Crawlscope
136
136
  return if canonical_matches_page?(normalized_canonical, normalized_page_url)
137
137
 
138
138
  details = {canonical: canonical}
139
- issues.add(
140
- code: :canonical_mismatch,
141
- severity: :warning,
142
- category: :metadata,
143
- url: page.url,
144
- message: "canonical mismatch (#{canonical})",
145
- details: details
146
- )
147
-
148
- return unless sitemap_urls.include?(normalized_page_url)
149
-
150
- issues.add(
151
- code: :non_canonical_page_in_sitemap,
152
- severity: :warning,
153
- category: :sitemaps,
154
- url: page.url,
155
- message: "non-canonical page is included in sitemap",
156
- details: details
157
- )
139
+ if sitemap_urls.include?(normalized_page_url)
140
+ issues.add(
141
+ code: :non_canonical_page_in_sitemap,
142
+ severity: :warning,
143
+ category: :sitemaps,
144
+ url: page.url,
145
+ message: "sitemap lists this URL, but its canonical points to #{canonical}",
146
+ details: details
147
+ )
148
+ else
149
+ issues.add(
150
+ code: :canonical_mismatch,
151
+ severity: :warning,
152
+ category: :metadata,
153
+ url: page.url,
154
+ message: "canonical points to a different URL: #{canonical}",
155
+ details: details
156
+ )
157
+ end
158
158
  end
159
159
 
160
160
  def repeated_site_name?(title)
@@ -174,7 +174,7 @@ module Crawlscope
174
174
  severity: :warning,
175
175
  category: :metadata,
176
176
  url: page.url,
177
- message: "Open Graph tags incomplete (missing #{missing.join(", ")})",
177
+ message: "missing Open Graph tags: #{missing.join(", ")}",
178
178
  details: {missing: missing}
179
179
  )
180
180
  end
@@ -49,7 +49,7 @@ module Crawlscope
49
49
  severity: :warning,
50
50
  category: :structured_data,
51
51
  url: page.url,
52
- message: "#{source} parse error: #{data[:message]}",
52
+ message: "#{source_label(source)} parse error: #{data[:message]}",
53
53
  details: {source: source}
54
54
  )
55
55
  next
@@ -65,7 +65,7 @@ module Crawlscope
65
65
  severity: :warning,
66
66
  category: :structured_data,
67
67
  url: page.url,
68
- message: "#{source} schema errors: #{errors.to_json}",
68
+ message: schema_error_message(source, errors),
69
69
  details: {errors: errors, source: source}
70
70
  )
71
71
  end
@@ -73,6 +73,15 @@ module Crawlscope
73
73
  validate_job_posting_count(page, items, issues)
74
74
  end
75
75
 
76
+ def source_label(source)
77
+ (source == "json-ld") ? "JSON-LD" : source
78
+ end
79
+
80
+ def schema_error_message(source, errors)
81
+ messages = errors.map { |error| Crawlscope::StructuredData::ErrorFormatter.format(error) }
82
+ "#{source_label(source)} #{messages.join("; ")}"
83
+ end
84
+
76
85
  def validate_job_posting_count(page, items, issues)
77
86
  job_postings = items.select { |item| structured_data_types(item.data).include?("JobPosting") }
78
87
  return if job_postings.size == 1
@@ -83,7 +92,7 @@ module Crawlscope
83
92
  severity: :warning,
84
93
  category: :structured_data,
85
94
  url: page.url,
86
- message: "multiple JobPosting structured data blocks found",
95
+ message: "JobPosting blocks: #{job_postings.size}; expected 1",
87
96
  details: {count: job_postings.size}
88
97
  )
89
98
  elsif career_detail_page?(page.url)
@@ -107,7 +116,7 @@ module Crawlscope
107
116
  severity: :warning,
108
117
  category: :structured_data,
109
118
  url: page.url,
110
- message: "#{source} structured data missing @type",
119
+ message: "#{source_label(source)} structured data missing @type",
111
120
  details: {paths: missing_paths, source: source}
112
121
  )
113
122
  end
@@ -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.8.1"
5
5
  end
@@ -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,
@@ -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,