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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +9 -0
- data/README.md +21 -10
- data/lib/crawlscope/crawl.rb +0 -1
- data/lib/crawlscope/reporter.rb +141 -63
- 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/schema_registry.rb +17 -1
- data/lib/crawlscope/server_timing/reporter.rb +6 -4
- 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/test/crawlscope/crawl_test.rb +36 -0
- data/test/crawlscope/links_rule_test.rb +40 -4
- data/test/crawlscope/metadata_rule_test.rb +37 -4
- data/test/crawlscope/reporter_test.rb +187 -16
- data/test/crawlscope/run_test.rb +1 -1
- data/test/crawlscope/schema_registry_test.rb +33 -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 +4 -2
|
@@ -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
|
|
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
|
|
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
|
|
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
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
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
|
|
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:
|
|
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: "
|
|
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
|
|
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: "
|
|
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: "
|
|
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: "
|
|
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: "
|
|
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: "
|
|
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: "
|
|
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
|
-
|
|
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("
|
|
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("
|
|
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
|
-
|
|
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("
|
|
125
|
+
@io.puts("Validation: OK")
|
|
131
126
|
else
|
|
127
|
+
@io.puts("Validation: FAILED")
|
|
132
128
|
entry.errors.each do |error|
|
|
133
|
-
@io.puts(" #{error[:
|
|
129
|
+
@io.puts(" #{source_label(error[:source])}:")
|
|
134
130
|
error[:errors].each do |validation_error|
|
|
135
|
-
@io.puts(" -
|
|
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
|
data/lib/crawlscope/version.rb
CHANGED
|
@@ -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, "
|
|
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:
|
|
449
|
-
normalized_final_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
|
-
|
|
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:
|
|
106
|
-
normalized_final_url: Crawlscope::Url.normalize(
|
|
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,
|