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