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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +26 -0
- data/README.md +92 -52
- data/UPGRADE.md +138 -0
- data/lib/crawlscope/crawl.rb +0 -1
- data/lib/crawlscope/rails.rb +6 -0
- data/lib/crawlscope/railtie.rb +7 -0
- 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/lib/crawlscope.rb +1 -1
- data/test/crawlscope/crawl_test.rb +36 -0
- data/test/crawlscope/links_rule_test.rb +40 -4
- data/test/crawlscope/loader_test.rb +25 -0
- 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 +7 -46
- data/test/release_task_test.rb +0 -86
data/lib/crawlscope/reporter.rb
CHANGED
|
@@ -4,7 +4,37 @@ require "uri"
|
|
|
4
4
|
|
|
5
5
|
module Crawlscope
|
|
6
6
|
class Reporter
|
|
7
|
-
|
|
7
|
+
GROUP_LABELS = {
|
|
8
|
+
structured_data_schema_error: "Schema errors",
|
|
9
|
+
structured_data_parse_error: "Parse errors",
|
|
10
|
+
structured_data_missing_type: "Missing @type",
|
|
11
|
+
sitemap_redirect_url: "Redirected URLs",
|
|
12
|
+
non_canonical_page_in_sitemap: "URLs with a different canonical",
|
|
13
|
+
indexable_page_missing_from_sitemap: "Linked pages missing from sitemap",
|
|
14
|
+
sitemap_noindex_url: "URLs that block indexing",
|
|
15
|
+
low_dofollow_inlinks: "Too few incoming links without nofollow",
|
|
16
|
+
low_inbound_anchor_links: "Too few incoming links",
|
|
17
|
+
low_unique_token_ratio: "Repeated words",
|
|
18
|
+
low_visible_text_ratio: "Too little text for the HTML size",
|
|
19
|
+
thin_visible_text: "Too few words",
|
|
20
|
+
orphan_page: "Pages with no incoming links",
|
|
21
|
+
nofollow_internal_outlinks: "Outgoing links with nofollow",
|
|
22
|
+
only_nofollow_internal_inlinks: "Incoming links all use nofollow",
|
|
23
|
+
mixed_follow_internal_inlinks: "Incoming links with and without nofollow",
|
|
24
|
+
http_internal_link: "Links from HTTPS to HTTP",
|
|
25
|
+
canonical_no_internal_inlinks: "Canonical URLs with no incoming links without nofollow",
|
|
26
|
+
canonical_points_to_error: "Canonical URLs that return errors",
|
|
27
|
+
url_double_slash: "Repeated slashes in URL paths",
|
|
28
|
+
multiple_h1: "Multiple <h1> tags",
|
|
29
|
+
multiple_title_tags: "Multiple <title> tags",
|
|
30
|
+
incomplete_open_graph_tags: "Missing Open Graph tags",
|
|
31
|
+
title_too_long: "Title exceeds #{Rules::Metadata::TITLE_MAX_LENGTH} characters",
|
|
32
|
+
meta_description_too_long: "Meta description exceeds #{Rules::Metadata::DESCRIPTION_MAX_LENGTH} characters",
|
|
33
|
+
meta_description_too_short: "Meta description below #{Rules::Metadata::DESCRIPTION_MIN_LENGTH} characters",
|
|
34
|
+
duplicate_content_fingerprint: "Duplicate page text",
|
|
35
|
+
near_duplicate_content: "Similar page text",
|
|
36
|
+
near_duplicate_scan_skipped: "Similar-page check skipped"
|
|
37
|
+
}.freeze
|
|
8
38
|
|
|
9
39
|
def initialize(io:)
|
|
10
40
|
@io = io
|
|
@@ -24,16 +54,12 @@ module Crawlscope
|
|
|
24
54
|
@io.puts("Issues: #{result.issues.size} total (#{severity_summary(result.issues)})")
|
|
25
55
|
end
|
|
26
56
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
base_url: result.base_url
|
|
30
|
-
|
|
31
|
-
return if result.issues.size.zero?
|
|
57
|
+
unless result.issues.size.zero?
|
|
58
|
+
@io.puts("")
|
|
59
|
+
report_issue_groups(result.issues, base_url: result.base_url)
|
|
60
|
+
end
|
|
32
61
|
|
|
33
|
-
@io.
|
|
34
|
-
report_summary(result.issues)
|
|
35
|
-
@io.puts("")
|
|
36
|
-
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)
|
|
37
63
|
end
|
|
38
64
|
|
|
39
65
|
private
|
|
@@ -51,43 +77,45 @@ module Crawlscope
|
|
|
51
77
|
end
|
|
52
78
|
|
|
53
79
|
def severity_summary(issues)
|
|
54
|
-
|
|
55
|
-
return "" if grouped.empty?
|
|
56
|
-
|
|
57
|
-
grouped
|
|
58
|
-
.sort_by { |severity, severity_issues| [-severity_issues.size, severity.to_s] }
|
|
59
|
-
.map { |severity, severity_issues| "#{severity_issues.size} #{pluralize(severity, severity_issues.size)}" }
|
|
60
|
-
.join(", ")
|
|
61
|
-
end
|
|
62
|
-
|
|
63
|
-
def report_summary(issues)
|
|
64
|
-
@io.puts("Summary:")
|
|
65
|
-
|
|
66
|
-
issues.by_category
|
|
67
|
-
.sort_by { |category, category_issues| [-category_issues.size, category.to_s] }
|
|
68
|
-
.each do |category, category_issues|
|
|
69
|
-
@io.puts(" #{category.to_s.ljust(16)} #{category_issues.size}")
|
|
70
|
-
end
|
|
80
|
+
severity_summary_for(issues.to_a)
|
|
71
81
|
end
|
|
72
82
|
|
|
73
83
|
def report_issue_groups(issues, base_url:)
|
|
74
84
|
grouped = issues.to_a.group_by { |issue| [issue.category, issue.code] }
|
|
75
85
|
|
|
76
86
|
grouped
|
|
77
|
-
.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] }
|
|
78
88
|
.each do |(category, code), grouped_issues|
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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
|
|
83
104
|
end
|
|
84
105
|
|
|
85
|
-
remaining_count = grouped_issues.size - MAX_ISSUES_PER_GROUP
|
|
86
|
-
@io.puts(" ... #{remaining_count} more") if remaining_count.positive?
|
|
87
106
|
@io.puts("")
|
|
88
107
|
end
|
|
89
108
|
end
|
|
90
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
|
+
|
|
91
119
|
def compact_issue(issue, base_url:)
|
|
92
120
|
parts = []
|
|
93
121
|
parts << relative_url(issue.url, base_url: base_url) if issue.url
|
|
@@ -100,35 +128,87 @@ module Crawlscope
|
|
|
100
128
|
|
|
101
129
|
def compact_detail(issue, base_url:)
|
|
102
130
|
details = issue.details || {}
|
|
103
|
-
fragments = []
|
|
104
|
-
|
|
105
|
-
inbound = details[:dofollow_inbound_count] || details[:inbound_count]
|
|
106
|
-
fragments << "inbound #{inbound}/#{details[:minimum]}" if inbound && details[:minimum]
|
|
107
|
-
|
|
108
|
-
if details[:ratio] && details[:threshold]
|
|
109
|
-
fragments << "ratio #{format_number(details[:ratio])}/#{format_number(details[:threshold])}"
|
|
110
|
-
end
|
|
111
|
-
|
|
112
|
-
fragments << "count #{details[:count]}" if details[:count]
|
|
113
|
-
fragments << "length #{details[:length]}" if details[:length]
|
|
114
|
-
fragments << "status #{details[:status]}" if details[:status]
|
|
115
|
-
fragments << "final: #{relative_url(details[:final_url], base_url: base_url)}" if details[:final_url]
|
|
116
|
-
fragments << "sources: #{relative_urls(details[:source_urls], base_url: base_url).join(", ")}" if details[:source_urls]&.any?
|
|
117
|
-
fragments << "source: #{relative_url(details[:source_url], base_url: base_url)}" if details[:source_url]
|
|
118
|
-
fragments << "targets: #{relative_urls(details[:target_urls], base_url: base_url).join(", ")}" if details[:target_urls]&.any?
|
|
119
|
-
|
|
120
|
-
return issue.message if fragments.empty?
|
|
121
|
-
|
|
122
131
|
case issue.code
|
|
123
|
-
when :
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
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
|
|
127
192
|
end
|
|
193
|
+
issue.message
|
|
128
194
|
end
|
|
129
195
|
|
|
130
|
-
def
|
|
131
|
-
|
|
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
|
|
132
212
|
end
|
|
133
213
|
|
|
134
214
|
def relative_url(url, base_url:)
|
|
@@ -147,10 +227,8 @@ module Crawlscope
|
|
|
147
227
|
url
|
|
148
228
|
end
|
|
149
229
|
|
|
150
|
-
def
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
value.to_s
|
|
230
|
+
def percentage(value)
|
|
231
|
+
"#{format("%.3f", value * 100).sub(/0+\z/, "").sub(/\.\z/, "")}%"
|
|
154
232
|
end
|
|
155
233
|
|
|
156
234
|
def pluralize(word, count)
|
|
@@ -44,7 +44,7 @@ module Crawlscope
|
|
|
44
44
|
severity: :warning,
|
|
45
45
|
category: :content_quality,
|
|
46
46
|
url: page.url,
|
|
47
|
-
message: "
|
|
47
|
+
message: "unique words: #{format_percentage(ratio)}; minimum #{format_percentage(@min_unique_token_ratio)}",
|
|
48
48
|
details: {
|
|
49
49
|
ratio: ratio.round(3),
|
|
50
50
|
threshold: @min_unique_token_ratio,
|
|
@@ -67,7 +67,7 @@ module Crawlscope
|
|
|
67
67
|
severity: :warning,
|
|
68
68
|
category: :content_quality,
|
|
69
69
|
url: page.url,
|
|
70
|
-
message: "
|
|
70
|
+
message: "visible text / HTML bytes: #{format_percentage(ratio)}; minimum #{format_percentage(@min_visible_text_ratio)}",
|
|
71
71
|
details: {
|
|
72
72
|
html_bytes: html_bytes,
|
|
73
73
|
ratio: ratio.round(3),
|
|
@@ -86,13 +86,13 @@ module Crawlscope
|
|
|
86
86
|
severity: :warning,
|
|
87
87
|
category: :content_quality,
|
|
88
88
|
url: page.url,
|
|
89
|
-
message: "
|
|
89
|
+
message: "visible words: #{word_count}; minimum #{@min_visible_words}",
|
|
90
90
|
details: {word_count: word_count, minimum: @min_visible_words}
|
|
91
91
|
)
|
|
92
92
|
end
|
|
93
93
|
|
|
94
|
-
def
|
|
95
|
-
format("%.
|
|
94
|
+
def format_percentage(value)
|
|
95
|
+
"#{format("%.3f", value * 100).sub(/0+\z/, "").sub(/\.\z/, "")}%"
|
|
96
96
|
end
|
|
97
97
|
end
|
|
98
98
|
end
|
|
@@ -8,7 +8,6 @@ module Crawlscope
|
|
|
8
8
|
LINK_SELECTORS = "a[href]"
|
|
9
9
|
INTERNAL_PATH_PREFIXES_TO_SKIP = ["/rails/", "/cdn-cgi/"].freeze
|
|
10
10
|
LINK_SCHEMES_TO_SKIP = ["mailto:", "tel:", "javascript:", "data:"].freeze
|
|
11
|
-
MAX_SOURCES_IN_ERROR = 3
|
|
12
11
|
MIN_INBOUND_ANCHOR_LINKS = 1
|
|
13
12
|
MIN_DOFOLLOW_INBOUND_LINKS = 2
|
|
14
13
|
|
|
@@ -128,20 +127,20 @@ module Crawlscope
|
|
|
128
127
|
end
|
|
129
128
|
|
|
130
129
|
def report_broken_target(target_url, grouped_links, issues, status)
|
|
131
|
-
source_urls = grouped_links.map { |link| link[:source_url] }.uniq
|
|
130
|
+
source_urls = grouped_links.map { |link| link[:source_url] }.uniq
|
|
132
131
|
issues.add(
|
|
133
132
|
code: :broken_internal_link,
|
|
134
133
|
severity: :warning,
|
|
135
134
|
category: :links,
|
|
136
135
|
url: target_url,
|
|
137
|
-
message: "
|
|
136
|
+
message: "linked URL returns HTTP #{status}",
|
|
138
137
|
details: {source_urls: source_urls, status: status}
|
|
139
138
|
)
|
|
140
139
|
end
|
|
141
140
|
|
|
142
141
|
def validate_nofollow_outgoing_links(links, issues)
|
|
143
142
|
links.select { |link| link[:nofollow] }.group_by { |link| link[:source_url] }.each do |source_url, grouped_links|
|
|
144
|
-
target_urls = grouped_links.map { |link| link[:target_url] }.uniq
|
|
143
|
+
target_urls = grouped_links.map { |link| link[:target_url] }.uniq
|
|
145
144
|
|
|
146
145
|
issues.add(
|
|
147
146
|
code: :nofollow_internal_outlinks,
|
|
@@ -156,7 +155,7 @@ module Crawlscope
|
|
|
156
155
|
|
|
157
156
|
def validate_http_internal_links(links, issues)
|
|
158
157
|
links.select { |link| link[:http_internal_link] }.group_by { |link| link[:source_url] }.each do |source_url, grouped_links|
|
|
159
|
-
target_urls = grouped_links.map { |link| link[:target_url] }.uniq
|
|
158
|
+
target_urls = grouped_links.map { |link| link[:target_url] }.uniq
|
|
160
159
|
|
|
161
160
|
issues.add(
|
|
162
161
|
code: :http_internal_link,
|
|
@@ -170,15 +169,15 @@ module Crawlscope
|
|
|
170
169
|
end
|
|
171
170
|
|
|
172
171
|
def report_unresolved_target(target_url, grouped_links, issues, resolution)
|
|
173
|
-
source_urls = grouped_links.map { |link| link[:source_url] }.uniq
|
|
174
|
-
suffix = (resolution && resolution[:error]) ? "
|
|
172
|
+
source_urls = grouped_links.map { |link| link[:source_url] }.uniq
|
|
173
|
+
suffix = (resolution && resolution[:error]) ? ": #{resolution[:error]}" : ""
|
|
175
174
|
|
|
176
175
|
issues.add(
|
|
177
176
|
code: :unresolved_internal_link,
|
|
178
177
|
severity: :warning,
|
|
179
178
|
category: :links,
|
|
180
179
|
url: target_url,
|
|
181
|
-
message: "
|
|
180
|
+
message: "cannot check linked URL#{suffix}",
|
|
182
181
|
details: {error: resolution && resolution[:error], source_urls: source_urls}
|
|
183
182
|
)
|
|
184
183
|
end
|
|
@@ -218,13 +217,13 @@ module Crawlscope
|
|
|
218
217
|
end
|
|
219
218
|
|
|
220
219
|
def report_redirect_target(target_url, grouped_links, issues, target)
|
|
221
|
-
source_urls = grouped_links.map { |link| link[:source_url] }.uniq
|
|
220
|
+
source_urls = grouped_links.map { |link| link[:source_url] }.uniq
|
|
222
221
|
issues.add(
|
|
223
222
|
code: :internal_link_redirects,
|
|
224
223
|
severity: :warning,
|
|
225
224
|
category: :links,
|
|
226
225
|
url: target_url,
|
|
227
|
-
message: "internal link redirects to #{target.final_url}
|
|
226
|
+
message: "internal link redirects to #{target.final_url}",
|
|
228
227
|
details: {final_url: target.final_url, source_urls: source_urls, status: target.status}
|
|
229
228
|
)
|
|
230
229
|
end
|
|
@@ -361,15 +360,14 @@ module Crawlscope
|
|
|
361
360
|
report_orphan_page(target_url, issues) if inbound_count.zero?
|
|
362
361
|
|
|
363
362
|
if inbound_count.positive? && inbound_count < MIN_INBOUND_ANCHOR_LINKS
|
|
364
|
-
source_samples = sample_sources_by_target[path]
|
|
365
|
-
source_info = source_samples.any? ? " (sources: #{source_samples.join(", ")})" : ""
|
|
363
|
+
source_samples = sample_sources_by_target[path]
|
|
366
364
|
|
|
367
365
|
issues.add(
|
|
368
366
|
code: :low_inbound_anchor_links,
|
|
369
367
|
severity: :warning,
|
|
370
368
|
category: :links,
|
|
371
369
|
url: target_url,
|
|
372
|
-
message: "
|
|
370
|
+
message: "incoming internal links: #{inbound_count}; minimum #{MIN_INBOUND_ANCHOR_LINKS}",
|
|
373
371
|
details: {inbound_count: inbound_count, minimum: MIN_INBOUND_ANCHOR_LINKS, source_urls: source_samples}
|
|
374
372
|
)
|
|
375
373
|
end
|
|
@@ -414,7 +412,7 @@ module Crawlscope
|
|
|
414
412
|
severity: :notice,
|
|
415
413
|
category: :url,
|
|
416
414
|
url: url,
|
|
417
|
-
message: "URL
|
|
415
|
+
message: "URL: #{url.length} characters; maximum 2048",
|
|
418
416
|
details: {length: url.length, maximum: 2_048}
|
|
419
417
|
)
|
|
420
418
|
end
|
|
@@ -446,27 +444,22 @@ module Crawlscope
|
|
|
446
444
|
|
|
447
445
|
def validate_indexable_pages_missing_from_sitemap(urls, resolved_links, issues)
|
|
448
446
|
sitemap_urls = urls.map { |url| Url.normalize(url, base_url: @base_url) }.compact.to_set
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
resolved_links.each do |link|
|
|
452
|
-
final_url = link[:final_url]
|
|
447
|
+
resolved_links.group_by { |link| link[:final_url] }.each do |final_url, grouped_links|
|
|
448
|
+
link = grouped_links.first
|
|
453
449
|
next if sitemap_urls.include?(final_url)
|
|
454
|
-
next if reported_urls.include?(final_url)
|
|
455
450
|
next unless crawlable_path?(link[:final_path])
|
|
456
451
|
|
|
457
452
|
target = target_for(final_url)
|
|
458
453
|
next unless target.allowed?(@allowed_statuses) && target.html?
|
|
459
454
|
next if target.noindex?
|
|
460
455
|
|
|
461
|
-
reported_urls << final_url
|
|
462
|
-
|
|
463
456
|
issues.add(
|
|
464
457
|
code: :indexable_page_missing_from_sitemap,
|
|
465
458
|
severity: :warning,
|
|
466
459
|
category: :sitemaps,
|
|
467
460
|
url: final_url,
|
|
468
|
-
message: "
|
|
469
|
-
details: {source_url: link[:source_url]}
|
|
461
|
+
message: "linked page is missing from sitemap",
|
|
462
|
+
details: {source_url: link[:source_url], source_urls: grouped_links.map { |entry| entry[:source_url] }.uniq}
|
|
470
463
|
)
|
|
471
464
|
end
|
|
472
465
|
end
|
|
@@ -486,15 +479,14 @@ module Crawlscope
|
|
|
486
479
|
return if dofollow_count.zero?
|
|
487
480
|
return if dofollow_count >= MIN_DOFOLLOW_INBOUND_LINKS
|
|
488
481
|
|
|
489
|
-
source_samples = sources_by_target[path]
|
|
490
|
-
source_info = source_samples.any? ? " (sources: #{source_samples.join(", ")})" : ""
|
|
482
|
+
source_samples = sources_by_target[path]
|
|
491
483
|
|
|
492
484
|
issues.add(
|
|
493
485
|
code: :low_dofollow_inlinks,
|
|
494
486
|
severity: :warning,
|
|
495
487
|
category: :links,
|
|
496
488
|
url: target_url,
|
|
497
|
-
message: "
|
|
489
|
+
message: "incoming internal links without nofollow: #{dofollow_count}; minimum #{MIN_DOFOLLOW_INBOUND_LINKS}",
|
|
498
490
|
details: {dofollow_inbound_count: dofollow_count, minimum: MIN_DOFOLLOW_INBOUND_LINKS, source_urls: source_samples}
|
|
499
491
|
)
|
|
500
492
|
end
|
|
@@ -507,8 +499,8 @@ module Crawlscope
|
|
|
507
499
|
severity: :warning,
|
|
508
500
|
category: :links,
|
|
509
501
|
url: target_url,
|
|
510
|
-
message: "
|
|
511
|
-
details: {nofollow_inbound_count: nofollow_count, source_urls: nofollow_sources
|
|
502
|
+
message: "all incoming internal links use nofollow",
|
|
503
|
+
details: {nofollow_inbound_count: nofollow_count, source_urls: nofollow_sources}
|
|
512
504
|
)
|
|
513
505
|
end
|
|
514
506
|
|
|
@@ -524,15 +516,17 @@ module Crawlscope
|
|
|
524
516
|
details: {
|
|
525
517
|
dofollow_inbound_count: dofollow_count,
|
|
526
518
|
nofollow_inbound_count: nofollow_count,
|
|
527
|
-
dofollow_source_urls: dofollow_sources
|
|
528
|
-
nofollow_source_urls: nofollow_sources
|
|
519
|
+
dofollow_source_urls: dofollow_sources,
|
|
520
|
+
nofollow_source_urls: nofollow_sources
|
|
529
521
|
}
|
|
530
522
|
)
|
|
531
523
|
end
|
|
532
524
|
|
|
533
525
|
def validate_canonical_targets(urls, pages, resolved_links, issues)
|
|
534
526
|
sitemap_urls = urls.map { |url| Url.normalize(url, base_url: @base_url) }.compact
|
|
535
|
-
sitemap_pages = pages.select
|
|
527
|
+
sitemap_pages = pages.select do |page|
|
|
528
|
+
page.html? && sitemap_urls.include?(page.normalized_url) && page.normalized_url.to_s == page.normalized_final_url.to_s
|
|
529
|
+
end
|
|
536
530
|
return if sitemap_pages.size < 2
|
|
537
531
|
|
|
538
532
|
dofollow_counts_by_path = dofollow_counts_by_final_path(resolved_links)
|
|
@@ -549,7 +543,7 @@ module Crawlscope
|
|
|
549
543
|
severity: :warning,
|
|
550
544
|
category: :links,
|
|
551
545
|
url: canonical_url,
|
|
552
|
-
message: "canonical URL has no incoming internal links",
|
|
546
|
+
message: "canonical URL has no incoming internal links without nofollow",
|
|
553
547
|
details: {source_url: page.url}
|
|
554
548
|
)
|
|
555
549
|
end
|
|
@@ -609,7 +603,7 @@ module Crawlscope
|
|
|
609
603
|
severity: :warning,
|
|
610
604
|
category: :metadata,
|
|
611
605
|
url: page.url,
|
|
612
|
-
message: "canonical
|
|
606
|
+
message: "canonical #{canonical_url} redirects to #{target.final_url}",
|
|
613
607
|
details: {canonical: canonical_url, final_url: target.final_url, status: target.status}
|
|
614
608
|
)
|
|
615
609
|
elsif !target.allowed?(@allowed_statuses)
|
|
@@ -618,7 +612,7 @@ module Crawlscope
|
|
|
618
612
|
severity: :warning,
|
|
619
613
|
category: :metadata,
|
|
620
614
|
url: page.url,
|
|
621
|
-
message: "canonical
|
|
615
|
+
message: "canonical #{canonical_url} returns HTTP #{target.status}",
|
|
622
616
|
details: {canonical: canonical_url, status: target.status}
|
|
623
617
|
)
|
|
624
618
|
end
|
|
@@ -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
|