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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 4b830667c3f7647baba6ca372e78daa5f4116e2178fc585aff4f387671e71b81
4
- data.tar.gz: 357a5ceab12f86addda14927ba03be7357f20d301ddeb58b72a87527133653ce
3
+ metadata.gz: 89be661c49b5f2148a9d7962d81d522eedaedbba89c1f9dcf8474d62cfffd5f2
4
+ data.tar.gz: d27a26792cc5cfdab191371c3d005cf43298b02896cb7cbdf2113a5f341e6c12
5
5
  SHA512:
6
- metadata.gz: 954a7f1044ae369e01fc571331acbf0b9ebd47f56d8047d028c6967a85cf5caefa673a3bfa05b782a814d3233f883ea13ca98a3ab961d2e8de47d788804f6158
7
- data.tar.gz: 8a59e970c7191847a25b855e7342d0ebaab7b881540d41ac78b9b558460ffd9a679fbfff3c54ef78e22f2bd287d8009f9d58961128b9c4b74ac3773ce8dc82dd
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: FAILED
110
- Issues: 3 3 warnings
109
+ Status: WARNINGS
110
+ Issues: 3 total (3 warnings)
111
111
 
112
- Summary:
113
- links 2
114
- metadata 1
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
- links / low_dofollow_inlinks: 2
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
@@ -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
@@ -4,7 +4,37 @@ require "uri"
4
4
 
5
5
  module Crawlscope
6
6
  class Reporter
7
- MAX_ISSUES_PER_GROUP = 20
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
- ServerTiming::Reporter.new(io: @io).report(
28
- result.server_timing_summary,
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.puts("")
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
- grouped = issues.by_severity
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
- @io.puts("#{category} / #{code}: #{grouped_issues.size}")
80
-
81
- grouped_issues.first(MAX_ISSUES_PER_GROUP).each do |issue|
82
- @io.puts(" - #{compact_issue(issue, base_url: base_url)}")
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 :low_dofollow_inlinks, :low_inbound_anchor_links, :low_unique_token_ratio, :low_visible_text_ratio
124
- fragments.join(" ")
125
- else
126
- ([issue.message] + fragments).join(" ")
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 relative_urls(urls, base_url:)
131
- Array(urls).map { |url| relative_url(url, base_url: base_url) }
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 format_number(value)
151
- return format("%.3f", value) if value.is_a?(Float)
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: "visible text has low token variety (#{format_ratio(ratio)})",
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: "low visible text to HTML ratio (#{format_ratio(ratio)})",
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: "thin visible text (#{word_count} words)",
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 format_ratio(value)
95
- format("%.2f", value)
94
+ def format_percentage(value)
95
+ "#{format("%.3f", value * 100).sub(/0+\z/, "").sub(/\.\z/, "")}%"
96
96
  end
97
97
  end
98
98
  end
@@ -192,7 +192,7 @@ module Crawlscope
192
192
  severity: :error,
193
193
  category: :sitemaps,
194
194
  url: page.url,
195
- message: "sitemap URL is noindex",
195
+ message: "sitemap lists a URL that blocks indexing",
196
196
  details: {content: content, source: source}
197
197
  )
198
198
  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.first(MAX_SOURCES_IN_ERROR)
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: "broken internal link (HTTP #{status}, sources: #{source_urls.join(", ")})",
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.first(MAX_SOURCES_IN_ERROR)
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.first(MAX_SOURCES_IN_ERROR)
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.first(MAX_SOURCES_IN_ERROR)
174
- suffix = (resolution && resolution[:error]) ? " (#{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: "unable to validate internal link#{suffix} (sources: #{source_urls.join(", ")})",
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.first(MAX_SOURCES_IN_ERROR)
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} (sources: #{source_urls.join(", ")})",
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].first(MAX_SOURCES_IN_ERROR)
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: "inbound anchor links #{inbound_count} below #{MIN_INBOUND_ANCHOR_LINKS}#{source_info}",
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 too long (#{url.length})",
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
- reported_urls = Set.new
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: "indexable internal page is missing from sitemap",
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].first(MAX_SOURCES_IN_ERROR)
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: "dofollow inbound links #{dofollow_count} below #{MIN_DOFOLLOW_INBOUND_LINKS}#{source_info}",
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: "page has nofollow incoming internal links only",
511
- details: {nofollow_inbound_count: nofollow_count, source_urls: nofollow_sources.first(MAX_SOURCES_IN_ERROR)}
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.first(MAX_SOURCES_IN_ERROR),
528
- nofollow_source_urls: nofollow_sources.first(MAX_SOURCES_IN_ERROR)
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 { |page| page.html? && sitemap_urls.include?(page.normalized_url) }
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 points to redirect",
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 points to HTTP #{target.status}",
615
+ message: "canonical #{canonical_url} returns HTTP #{target.status}",
622
616
  details: {canonical: canonical_url, status: target.status}
623
617
  )
624
618
  end