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
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 89be661c49b5f2148a9d7962d81d522eedaedbba89c1f9dcf8474d62cfffd5f2
|
|
4
|
+
data.tar.gz: d27a26792cc5cfdab191371c3d005cf43298b02896cb7cbdf2113a5f341e6c12
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 5c90219d9430a09a3c95c5cb6367817cbc996f4ba69857781ddf6d2050373c9bfef2ad3de545a328542fa7362494df05be85cfa2c5e11f359b84870c8bee2ea6
|
|
7
|
+
data.tar.gz: 632ddff7f77d1c0eb62e6f9db0c9a36f8c3c080d2ac3b6b93bc03fcea65453d916ecbf7b39eb34e9b16d750309e7814586e2a84b4cd0157609a47402bcccf8ef
|
data/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,15 @@ All notable changes to this project will be documented in this file.
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
+
## [0.8.1] - 2026-10-06
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
### Fixed
|
|
12
|
+
|
|
13
|
+
- clarify findings and prevent redirect false positives
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
|
|
8
17
|
## [0.8.0] - 2026-07-24
|
|
9
18
|
|
|
10
19
|
|
data/README.md
CHANGED
|
@@ -106,21 +106,32 @@ Base URL: https://example.com
|
|
|
106
106
|
Sitemap: https://example.com/sitemap.xml
|
|
107
107
|
URLs: 24
|
|
108
108
|
Pages: 24
|
|
109
|
-
Status:
|
|
110
|
-
Issues: 3 3 warnings
|
|
109
|
+
Status: WARNINGS
|
|
110
|
+
Issues: 3 total (3 warnings)
|
|
111
111
|
|
|
112
|
-
|
|
113
|
-
links
|
|
114
|
-
|
|
112
|
+
Links: Too few incoming links without nofollow (2 warnings)
|
|
113
|
+
- /pricing incoming links without nofollow: 1; minimum 2
|
|
114
|
+
linked from: /
|
|
115
|
+
- /features incoming links without nofollow: 1; minimum 2
|
|
116
|
+
linked from: /
|
|
115
117
|
|
|
116
|
-
|
|
117
|
-
- /pricing inbound 1/2 sources: /
|
|
118
|
-
- /features inbound 1/2 sources: /
|
|
119
|
-
|
|
120
|
-
metadata / missing_title: 1
|
|
118
|
+
Metadata: Missing title (1 warning)
|
|
121
119
|
- /draft missing <title>
|
|
122
120
|
```
|
|
123
121
|
|
|
122
|
+
Reports include all issues and related URLs. Errors appear before warnings and
|
|
123
|
+
notices. Each related URL has a label, such as `linked from`, `links to`, or
|
|
124
|
+
`page`. JSON-LD errors appear below the page URL with the type, field, and
|
|
125
|
+
constraint. Same-origin URLs use paths; other URLs retain their scheme and host.
|
|
126
|
+
For automation in Ruby, use `result.issues` and each issue's `code`, `severity`,
|
|
127
|
+
`url`, and `details` rather than matching the report text.
|
|
128
|
+
|
|
129
|
+
Metadata length limits appear in the group heading; each row shows the measured
|
|
130
|
+
length. The default maximums are 160 characters for a meta description and 110
|
|
131
|
+
characters for an Article JSON-LD `headline`. These are separate Crawlscope
|
|
132
|
+
validation limits for separate fields. Server timing lists pages only when the
|
|
133
|
+
reported duration exceeds 50 ms; aggregate statistics include all responses.
|
|
134
|
+
|
|
124
135
|
## Ruby Usage
|
|
125
136
|
|
|
126
137
|
```ruby
|
data/lib/crawlscope/crawl.rb
CHANGED
|
@@ -114,7 +114,6 @@ module Crawlscope
|
|
|
114
114
|
elsif !@allowed_statuses.include?(page.status)
|
|
115
115
|
issues.add(code: :unexpected_status, severity: :error, category: :crawl, url: page.url, message: "HTTP #{page.status}", details: {status: page.status})
|
|
116
116
|
elsif redirected?(page)
|
|
117
|
-
issues.add(code: :redirected_page, severity: :warning, category: :crawl, url: page.url, message: "redirects to #{page.final_url}", details: {final_url: page.final_url, status: page.status})
|
|
118
117
|
issues.add(code: :sitemap_redirect_url, severity: :warning, category: :sitemaps, url: page.url, message: "sitemap URL redirects to #{page.final_url}", details: {final_url: page.final_url, status: page.status})
|
|
119
118
|
end
|
|
120
119
|
end
|
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
|