crawlscope 0.7.2 → 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.
Files changed (45) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +27 -0
  3. data/README.md +95 -18
  4. data/lib/crawlscope/browser.rb +18 -1
  5. data/lib/crawlscope/configuration.rb +6 -1
  6. data/lib/crawlscope/crawl.rb +5 -3
  7. data/lib/crawlscope/http.rb +13 -2
  8. data/lib/crawlscope/page.rb +8 -0
  9. data/lib/crawlscope/reporter.rb +144 -61
  10. data/lib/crawlscope/request_headers.rb +34 -0
  11. data/lib/crawlscope/result.rb +4 -0
  12. data/lib/crawlscope/rules/content_quality.rb +5 -5
  13. data/lib/crawlscope/rules/indexability.rb +1 -1
  14. data/lib/crawlscope/rules/links.rb +28 -34
  15. data/lib/crawlscope/rules/metadata.rb +25 -25
  16. data/lib/crawlscope/rules/structured_data.rb +13 -4
  17. data/lib/crawlscope/rules/uniqueness.rb +9 -7
  18. data/lib/crawlscope/run.rb +0 -10
  19. data/lib/crawlscope/schema_registry.rb +17 -1
  20. data/lib/crawlscope/server_timing/reporter.rb +114 -0
  21. data/lib/crawlscope/server_timing/summary.rb +107 -0
  22. data/lib/crawlscope/server_timing.rb +138 -0
  23. data/lib/crawlscope/sitemap.rb +17 -5
  24. data/lib/crawlscope/structured_data/error_formatter.rb +42 -0
  25. data/lib/crawlscope/structured_data/reporter.rb +10 -10
  26. data/lib/crawlscope/version.rb +1 -1
  27. data/lib/generators/crawlscope/templates/initializer.rb.tt +1 -1
  28. data/test/crawlscope/browser_test.rb +56 -3
  29. data/test/crawlscope/configuration_test.rb +50 -10
  30. data/test/crawlscope/crawl_test.rb +65 -0
  31. data/test/crawlscope/http_test.rb +60 -1
  32. data/test/crawlscope/install_generator_test.rb +9 -11
  33. data/test/crawlscope/links_rule_test.rb +40 -4
  34. data/test/crawlscope/metadata_rule_test.rb +37 -4
  35. data/test/crawlscope/reporter_test.rb +249 -20
  36. data/test/crawlscope/run_test.rb +4 -12
  37. data/test/crawlscope/schema_registry_test.rb +33 -0
  38. data/test/crawlscope/server_timing_summary_test.rb +87 -0
  39. data/test/crawlscope/server_timing_test.rb +101 -0
  40. data/test/crawlscope/sitemap_test.rb +21 -0
  41. data/test/crawlscope/structured_data_error_formatter_test.rb +46 -0
  42. data/test/crawlscope/structured_data_reporter_test.rb +26 -0
  43. data/test/crawlscope/structured_data_rule_test.rb +2 -0
  44. data/test/crawlscope/uniqueness_rule_test.rb +46 -3
  45. metadata +10 -2
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "uri"
4
+
5
+ module Crawlscope
6
+ module RequestHeaders
7
+ PROFILE_TOKEN = "X-Profile-Token"
8
+
9
+ module_function
10
+
11
+ def add_profile_token(headers, url:, base_url:, profile_token:)
12
+ return headers if profile_token.to_s.empty?
13
+ return headers unless same_origin?(url, base_url: base_url)
14
+
15
+ headers[PROFILE_TOKEN] = profile_token
16
+ headers
17
+ end
18
+
19
+ def strip_profile_token_on_cross_origin_redirect(response_env, request_env)
20
+ return if same_origin?(request_env[:url], base_url: response_env[:url])
21
+
22
+ request_env[:request_headers].delete(PROFILE_TOKEN)
23
+ end
24
+
25
+ def same_origin?(url, base_url:)
26
+ url_uri = URI.join(base_url.to_s, url.to_s)
27
+ base_uri = URI.parse(base_url.to_s)
28
+
29
+ [url_uri.scheme, url_uri.host, url_uri.port] == [base_uri.scheme, base_uri.host, base_uri.port]
30
+ rescue URI::Error
31
+ false
32
+ end
33
+ end
34
+ end
@@ -5,5 +5,9 @@ module Crawlscope
5
5
  def ok?
6
6
  issues.none?(&:error?)
7
7
  end
8
+
9
+ def server_timing_summary
10
+ ServerTiming::Summary.new(pages)
11
+ end
8
12
  end
9
13
  end
@@ -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
@@ -66,7 +66,9 @@ module Crawlscope
66
66
  end
67
67
 
68
68
  def summarize_pages(pages)
69
- html_pages = pages.select(&:html?)
69
+ html_pages = pages.select do |page|
70
+ page.html? && page.normalized_url.to_s == page.normalized_final_url.to_s
71
+ end
70
72
 
71
73
  FetchExecutor.map(
72
74
  name: @fetch_executor,
@@ -82,7 +84,7 @@ module Crawlscope
82
84
  severity: :warning,
83
85
  category: :uniqueness,
84
86
  url: nil,
85
- message: "duplicate title '#{value}' => #{urls.join(", ")}",
87
+ message: "same title: #{value.inspect}",
86
88
  details: {urls: urls, value: value}
87
89
  )
88
90
  end
@@ -93,7 +95,7 @@ module Crawlscope
93
95
  severity: :warning,
94
96
  category: :uniqueness,
95
97
  url: nil,
96
- message: "duplicate meta description '#{value}' => #{urls.join(", ")}",
98
+ message: "same meta description: #{value.inspect}",
97
99
  details: {urls: urls, value: value}
98
100
  )
99
101
  end
@@ -104,7 +106,7 @@ module Crawlscope
104
106
  severity: :warning,
105
107
  category: :uniqueness,
106
108
  url: nil,
107
- message: "duplicate page content fingerprint => #{urls.join(", ")}",
109
+ message: "same page text",
108
110
  details: {urls: urls}
109
111
  )
110
112
  end
@@ -115,7 +117,7 @@ module Crawlscope
115
117
  severity: :warning,
116
118
  category: :uniqueness,
117
119
  url: nil,
118
- message: "duplicate pages without canonical => #{urls.join(", ")}",
120
+ message: "same page text; at least one page has no canonical link",
119
121
  details: {urls: urls}
120
122
  )
121
123
  end
@@ -144,7 +146,7 @@ module Crawlscope
144
146
  severity: :warning,
145
147
  category: :uniqueness,
146
148
  url: nil,
147
- message: "near duplicate scan skipped for #{page_summaries.size} pages",
149
+ message: "similar-page check skipped: #{page_summaries.size} pages; limit #{@max_near_duplicate_pages}",
148
150
  details: {max_pages: @max_near_duplicate_pages, page_count: page_summaries.size}
149
151
  )
150
152
  return
@@ -164,7 +166,7 @@ module Crawlscope
164
166
  severity: :warning,
165
167
  category: :uniqueness,
166
168
  url: nil,
167
- message: "near duplicate page content (#{format("%.2f", similarity)}) => #{urls.join(", ")}",
169
+ message: "text similarity: #{format("%.1f", similarity * 100)}%; threshold #{format("%.1f", @near_duplicate_threshold * 100)}%",
168
170
  details: {similarity: similarity.round(3), threshold: @near_duplicate_threshold, urls: urls}
169
171
  )
170
172
  end
@@ -44,17 +44,7 @@ module Crawlscope
44
44
  value = @configuration.sitemap_path
45
45
  return value unless value.to_s.strip.empty?
46
46
 
47
- local_path = File.expand_path("public/sitemap.xml", Dir.pwd)
48
- return local_path if local_path_default?(base_url: base_url) && File.exist?(local_path)
49
-
50
47
  "#{base_url.to_s.chomp("/")}/sitemap.xml"
51
48
  end
52
-
53
- def local_path_default?(base_url:)
54
- host = URI.parse(base_url.to_s).host.to_s
55
- ["localhost", "127.0.0.1"].include?(host)
56
- rescue URI::InvalidURIError
57
- false
58
- end
59
49
  end
60
50
  end
@@ -51,11 +51,16 @@ module Crawlscope
51
51
  return errors if schema.nil?
52
52
 
53
53
  JSON::Validator.fully_validate(schema, item, errors_as_objects: true).each do |error|
54
- errors << {
54
+ details = {
55
55
  field: error[:fragment].to_s.sub("#/", ""),
56
56
  issue: error[:message],
57
57
  type: type
58
58
  }
59
+ if %w[MaxLength MinLength].include?(error[:failed_attribute])
60
+ value = value_at_fragment(item, error[:fragment])
61
+ details[:length] = value.length if value.is_a?(String)
62
+ end
63
+ errors << details
59
64
  end
60
65
 
61
66
  errors
@@ -69,6 +74,17 @@ module Crawlscope
69
74
 
70
75
  private
71
76
 
77
+ def value_at_fragment(item, fragment)
78
+ fragment.to_s.delete_prefix("#/").split("/").reduce(item) do |value, key|
79
+ case value
80
+ when Hash
81
+ value[key]
82
+ when Array
83
+ value[key.to_i] if key.match?(/\A\d+\z/)
84
+ end
85
+ end
86
+ end
87
+
72
88
  def deep_copy(value)
73
89
  case value
74
90
  when Hash