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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +27 -0
- data/README.md +95 -18
- data/lib/crawlscope/browser.rb +18 -1
- data/lib/crawlscope/configuration.rb +6 -1
- data/lib/crawlscope/crawl.rb +5 -3
- data/lib/crawlscope/http.rb +13 -2
- data/lib/crawlscope/page.rb +8 -0
- data/lib/crawlscope/reporter.rb +144 -61
- data/lib/crawlscope/request_headers.rb +34 -0
- data/lib/crawlscope/result.rb +4 -0
- 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/run.rb +0 -10
- data/lib/crawlscope/schema_registry.rb +17 -1
- data/lib/crawlscope/server_timing/reporter.rb +114 -0
- data/lib/crawlscope/server_timing/summary.rb +107 -0
- data/lib/crawlscope/server_timing.rb +138 -0
- data/lib/crawlscope/sitemap.rb +17 -5
- data/lib/crawlscope/structured_data/error_formatter.rb +42 -0
- data/lib/crawlscope/structured_data/reporter.rb +10 -10
- data/lib/crawlscope/version.rb +1 -1
- data/lib/generators/crawlscope/templates/initializer.rb.tt +1 -1
- data/test/crawlscope/browser_test.rb +56 -3
- data/test/crawlscope/configuration_test.rb +50 -10
- data/test/crawlscope/crawl_test.rb +65 -0
- data/test/crawlscope/http_test.rb +60 -1
- data/test/crawlscope/install_generator_test.rb +9 -11
- data/test/crawlscope/links_rule_test.rb +40 -4
- data/test/crawlscope/metadata_rule_test.rb +37 -4
- data/test/crawlscope/reporter_test.rb +249 -20
- data/test/crawlscope/run_test.rb +4 -12
- data/test/crawlscope/schema_registry_test.rb +33 -0
- data/test/crawlscope/server_timing_summary_test.rb +87 -0
- data/test/crawlscope/server_timing_test.rb +101 -0
- data/test/crawlscope/sitemap_test.rb +21 -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 +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
|
data/lib/crawlscope/result.rb
CHANGED
|
@@ -44,7 +44,7 @@ module Crawlscope
|
|
|
44
44
|
severity: :warning,
|
|
45
45
|
category: :content_quality,
|
|
46
46
|
url: page.url,
|
|
47
|
-
message: "
|
|
47
|
+
message: "unique words: #{format_percentage(ratio)}; minimum #{format_percentage(@min_unique_token_ratio)}",
|
|
48
48
|
details: {
|
|
49
49
|
ratio: ratio.round(3),
|
|
50
50
|
threshold: @min_unique_token_ratio,
|
|
@@ -67,7 +67,7 @@ module Crawlscope
|
|
|
67
67
|
severity: :warning,
|
|
68
68
|
category: :content_quality,
|
|
69
69
|
url: page.url,
|
|
70
|
-
message: "
|
|
70
|
+
message: "visible text / HTML bytes: #{format_percentage(ratio)}; minimum #{format_percentage(@min_visible_text_ratio)}",
|
|
71
71
|
details: {
|
|
72
72
|
html_bytes: html_bytes,
|
|
73
73
|
ratio: ratio.round(3),
|
|
@@ -86,13 +86,13 @@ module Crawlscope
|
|
|
86
86
|
severity: :warning,
|
|
87
87
|
category: :content_quality,
|
|
88
88
|
url: page.url,
|
|
89
|
-
message: "
|
|
89
|
+
message: "visible words: #{word_count}; minimum #{@min_visible_words}",
|
|
90
90
|
details: {word_count: word_count, minimum: @min_visible_words}
|
|
91
91
|
)
|
|
92
92
|
end
|
|
93
93
|
|
|
94
|
-
def
|
|
95
|
-
format("%.
|
|
94
|
+
def format_percentage(value)
|
|
95
|
+
"#{format("%.3f", value * 100).sub(/0+\z/, "").sub(/\.\z/, "")}%"
|
|
96
96
|
end
|
|
97
97
|
end
|
|
98
98
|
end
|
|
@@ -8,7 +8,6 @@ module Crawlscope
|
|
|
8
8
|
LINK_SELECTORS = "a[href]"
|
|
9
9
|
INTERNAL_PATH_PREFIXES_TO_SKIP = ["/rails/", "/cdn-cgi/"].freeze
|
|
10
10
|
LINK_SCHEMES_TO_SKIP = ["mailto:", "tel:", "javascript:", "data:"].freeze
|
|
11
|
-
MAX_SOURCES_IN_ERROR = 3
|
|
12
11
|
MIN_INBOUND_ANCHOR_LINKS = 1
|
|
13
12
|
MIN_DOFOLLOW_INBOUND_LINKS = 2
|
|
14
13
|
|
|
@@ -128,20 +127,20 @@ module Crawlscope
|
|
|
128
127
|
end
|
|
129
128
|
|
|
130
129
|
def report_broken_target(target_url, grouped_links, issues, status)
|
|
131
|
-
source_urls = grouped_links.map { |link| link[:source_url] }.uniq
|
|
130
|
+
source_urls = grouped_links.map { |link| link[:source_url] }.uniq
|
|
132
131
|
issues.add(
|
|
133
132
|
code: :broken_internal_link,
|
|
134
133
|
severity: :warning,
|
|
135
134
|
category: :links,
|
|
136
135
|
url: target_url,
|
|
137
|
-
message: "
|
|
136
|
+
message: "linked URL returns HTTP #{status}",
|
|
138
137
|
details: {source_urls: source_urls, status: status}
|
|
139
138
|
)
|
|
140
139
|
end
|
|
141
140
|
|
|
142
141
|
def validate_nofollow_outgoing_links(links, issues)
|
|
143
142
|
links.select { |link| link[:nofollow] }.group_by { |link| link[:source_url] }.each do |source_url, grouped_links|
|
|
144
|
-
target_urls = grouped_links.map { |link| link[:target_url] }.uniq
|
|
143
|
+
target_urls = grouped_links.map { |link| link[:target_url] }.uniq
|
|
145
144
|
|
|
146
145
|
issues.add(
|
|
147
146
|
code: :nofollow_internal_outlinks,
|
|
@@ -156,7 +155,7 @@ module Crawlscope
|
|
|
156
155
|
|
|
157
156
|
def validate_http_internal_links(links, issues)
|
|
158
157
|
links.select { |link| link[:http_internal_link] }.group_by { |link| link[:source_url] }.each do |source_url, grouped_links|
|
|
159
|
-
target_urls = grouped_links.map { |link| link[:target_url] }.uniq
|
|
158
|
+
target_urls = grouped_links.map { |link| link[:target_url] }.uniq
|
|
160
159
|
|
|
161
160
|
issues.add(
|
|
162
161
|
code: :http_internal_link,
|
|
@@ -170,15 +169,15 @@ module Crawlscope
|
|
|
170
169
|
end
|
|
171
170
|
|
|
172
171
|
def report_unresolved_target(target_url, grouped_links, issues, resolution)
|
|
173
|
-
source_urls = grouped_links.map { |link| link[:source_url] }.uniq
|
|
174
|
-
suffix = (resolution && resolution[:error]) ? "
|
|
172
|
+
source_urls = grouped_links.map { |link| link[:source_url] }.uniq
|
|
173
|
+
suffix = (resolution && resolution[:error]) ? ": #{resolution[:error]}" : ""
|
|
175
174
|
|
|
176
175
|
issues.add(
|
|
177
176
|
code: :unresolved_internal_link,
|
|
178
177
|
severity: :warning,
|
|
179
178
|
category: :links,
|
|
180
179
|
url: target_url,
|
|
181
|
-
message: "
|
|
180
|
+
message: "cannot check linked URL#{suffix}",
|
|
182
181
|
details: {error: resolution && resolution[:error], source_urls: source_urls}
|
|
183
182
|
)
|
|
184
183
|
end
|
|
@@ -218,13 +217,13 @@ module Crawlscope
|
|
|
218
217
|
end
|
|
219
218
|
|
|
220
219
|
def report_redirect_target(target_url, grouped_links, issues, target)
|
|
221
|
-
source_urls = grouped_links.map { |link| link[:source_url] }.uniq
|
|
220
|
+
source_urls = grouped_links.map { |link| link[:source_url] }.uniq
|
|
222
221
|
issues.add(
|
|
223
222
|
code: :internal_link_redirects,
|
|
224
223
|
severity: :warning,
|
|
225
224
|
category: :links,
|
|
226
225
|
url: target_url,
|
|
227
|
-
message: "internal link redirects to #{target.final_url}
|
|
226
|
+
message: "internal link redirects to #{target.final_url}",
|
|
228
227
|
details: {final_url: target.final_url, source_urls: source_urls, status: target.status}
|
|
229
228
|
)
|
|
230
229
|
end
|
|
@@ -361,15 +360,14 @@ module Crawlscope
|
|
|
361
360
|
report_orphan_page(target_url, issues) if inbound_count.zero?
|
|
362
361
|
|
|
363
362
|
if inbound_count.positive? && inbound_count < MIN_INBOUND_ANCHOR_LINKS
|
|
364
|
-
source_samples = sample_sources_by_target[path]
|
|
365
|
-
source_info = source_samples.any? ? " (sources: #{source_samples.join(", ")})" : ""
|
|
363
|
+
source_samples = sample_sources_by_target[path]
|
|
366
364
|
|
|
367
365
|
issues.add(
|
|
368
366
|
code: :low_inbound_anchor_links,
|
|
369
367
|
severity: :warning,
|
|
370
368
|
category: :links,
|
|
371
369
|
url: target_url,
|
|
372
|
-
message: "
|
|
370
|
+
message: "incoming internal links: #{inbound_count}; minimum #{MIN_INBOUND_ANCHOR_LINKS}",
|
|
373
371
|
details: {inbound_count: inbound_count, minimum: MIN_INBOUND_ANCHOR_LINKS, source_urls: source_samples}
|
|
374
372
|
)
|
|
375
373
|
end
|
|
@@ -414,7 +412,7 @@ module Crawlscope
|
|
|
414
412
|
severity: :notice,
|
|
415
413
|
category: :url,
|
|
416
414
|
url: url,
|
|
417
|
-
message: "URL
|
|
415
|
+
message: "URL: #{url.length} characters; maximum 2048",
|
|
418
416
|
details: {length: url.length, maximum: 2_048}
|
|
419
417
|
)
|
|
420
418
|
end
|
|
@@ -446,27 +444,22 @@ module Crawlscope
|
|
|
446
444
|
|
|
447
445
|
def validate_indexable_pages_missing_from_sitemap(urls, resolved_links, issues)
|
|
448
446
|
sitemap_urls = urls.map { |url| Url.normalize(url, base_url: @base_url) }.compact.to_set
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
resolved_links.each do |link|
|
|
452
|
-
final_url = link[:final_url]
|
|
447
|
+
resolved_links.group_by { |link| link[:final_url] }.each do |final_url, grouped_links|
|
|
448
|
+
link = grouped_links.first
|
|
453
449
|
next if sitemap_urls.include?(final_url)
|
|
454
|
-
next if reported_urls.include?(final_url)
|
|
455
450
|
next unless crawlable_path?(link[:final_path])
|
|
456
451
|
|
|
457
452
|
target = target_for(final_url)
|
|
458
453
|
next unless target.allowed?(@allowed_statuses) && target.html?
|
|
459
454
|
next if target.noindex?
|
|
460
455
|
|
|
461
|
-
reported_urls << final_url
|
|
462
|
-
|
|
463
456
|
issues.add(
|
|
464
457
|
code: :indexable_page_missing_from_sitemap,
|
|
465
458
|
severity: :warning,
|
|
466
459
|
category: :sitemaps,
|
|
467
460
|
url: final_url,
|
|
468
|
-
message: "
|
|
469
|
-
details: {source_url: link[:source_url]}
|
|
461
|
+
message: "linked page is missing from sitemap",
|
|
462
|
+
details: {source_url: link[:source_url], source_urls: grouped_links.map { |entry| entry[:source_url] }.uniq}
|
|
470
463
|
)
|
|
471
464
|
end
|
|
472
465
|
end
|
|
@@ -486,15 +479,14 @@ module Crawlscope
|
|
|
486
479
|
return if dofollow_count.zero?
|
|
487
480
|
return if dofollow_count >= MIN_DOFOLLOW_INBOUND_LINKS
|
|
488
481
|
|
|
489
|
-
source_samples = sources_by_target[path]
|
|
490
|
-
source_info = source_samples.any? ? " (sources: #{source_samples.join(", ")})" : ""
|
|
482
|
+
source_samples = sources_by_target[path]
|
|
491
483
|
|
|
492
484
|
issues.add(
|
|
493
485
|
code: :low_dofollow_inlinks,
|
|
494
486
|
severity: :warning,
|
|
495
487
|
category: :links,
|
|
496
488
|
url: target_url,
|
|
497
|
-
message: "
|
|
489
|
+
message: "incoming internal links without nofollow: #{dofollow_count}; minimum #{MIN_DOFOLLOW_INBOUND_LINKS}",
|
|
498
490
|
details: {dofollow_inbound_count: dofollow_count, minimum: MIN_DOFOLLOW_INBOUND_LINKS, source_urls: source_samples}
|
|
499
491
|
)
|
|
500
492
|
end
|
|
@@ -507,8 +499,8 @@ module Crawlscope
|
|
|
507
499
|
severity: :warning,
|
|
508
500
|
category: :links,
|
|
509
501
|
url: target_url,
|
|
510
|
-
message: "
|
|
511
|
-
details: {nofollow_inbound_count: nofollow_count, source_urls: nofollow_sources
|
|
502
|
+
message: "all incoming internal links use nofollow",
|
|
503
|
+
details: {nofollow_inbound_count: nofollow_count, source_urls: nofollow_sources}
|
|
512
504
|
)
|
|
513
505
|
end
|
|
514
506
|
|
|
@@ -524,15 +516,17 @@ module Crawlscope
|
|
|
524
516
|
details: {
|
|
525
517
|
dofollow_inbound_count: dofollow_count,
|
|
526
518
|
nofollow_inbound_count: nofollow_count,
|
|
527
|
-
dofollow_source_urls: dofollow_sources
|
|
528
|
-
nofollow_source_urls: nofollow_sources
|
|
519
|
+
dofollow_source_urls: dofollow_sources,
|
|
520
|
+
nofollow_source_urls: nofollow_sources
|
|
529
521
|
}
|
|
530
522
|
)
|
|
531
523
|
end
|
|
532
524
|
|
|
533
525
|
def validate_canonical_targets(urls, pages, resolved_links, issues)
|
|
534
526
|
sitemap_urls = urls.map { |url| Url.normalize(url, base_url: @base_url) }.compact
|
|
535
|
-
sitemap_pages = pages.select
|
|
527
|
+
sitemap_pages = pages.select do |page|
|
|
528
|
+
page.html? && sitemap_urls.include?(page.normalized_url) && page.normalized_url.to_s == page.normalized_final_url.to_s
|
|
529
|
+
end
|
|
536
530
|
return if sitemap_pages.size < 2
|
|
537
531
|
|
|
538
532
|
dofollow_counts_by_path = dofollow_counts_by_final_path(resolved_links)
|
|
@@ -549,7 +543,7 @@ module Crawlscope
|
|
|
549
543
|
severity: :warning,
|
|
550
544
|
category: :links,
|
|
551
545
|
url: canonical_url,
|
|
552
|
-
message: "canonical URL has no incoming internal links",
|
|
546
|
+
message: "canonical URL has no incoming internal links without nofollow",
|
|
553
547
|
details: {source_url: page.url}
|
|
554
548
|
)
|
|
555
549
|
end
|
|
@@ -609,7 +603,7 @@ module Crawlscope
|
|
|
609
603
|
severity: :warning,
|
|
610
604
|
category: :metadata,
|
|
611
605
|
url: page.url,
|
|
612
|
-
message: "canonical
|
|
606
|
+
message: "canonical #{canonical_url} redirects to #{target.final_url}",
|
|
613
607
|
details: {canonical: canonical_url, final_url: target.final_url, status: target.status}
|
|
614
608
|
)
|
|
615
609
|
elsif !target.allowed?(@allowed_statuses)
|
|
@@ -618,7 +612,7 @@ module Crawlscope
|
|
|
618
612
|
severity: :warning,
|
|
619
613
|
category: :metadata,
|
|
620
614
|
url: page.url,
|
|
621
|
-
message: "canonical
|
|
615
|
+
message: "canonical #{canonical_url} returns HTTP #{target.status}",
|
|
622
616
|
details: {canonical: canonical_url, status: target.status}
|
|
623
617
|
)
|
|
624
618
|
end
|
|
@@ -21,7 +21,7 @@ module Crawlscope
|
|
|
21
21
|
sitemap_urls = normalized_sitemap_urls(urls)
|
|
22
22
|
|
|
23
23
|
pages.each do |page|
|
|
24
|
-
next unless page.html?
|
|
24
|
+
next unless page.html? && page.normalized_url.to_s == page.normalized_final_url.to_s
|
|
25
25
|
|
|
26
26
|
validate_h1(page, issues)
|
|
27
27
|
validate_title(page, issues)
|
|
@@ -47,7 +47,7 @@ module Crawlscope
|
|
|
47
47
|
severity: :warning,
|
|
48
48
|
category: :metadata,
|
|
49
49
|
url: page.url,
|
|
50
|
-
message: "empty <h1>",
|
|
50
|
+
message: "empty <h1> tags: #{empty_h1s.size}",
|
|
51
51
|
details: {count: empty_h1s.size}
|
|
52
52
|
)
|
|
53
53
|
end
|
|
@@ -93,7 +93,7 @@ module Crawlscope
|
|
|
93
93
|
if title.empty?
|
|
94
94
|
issues.add(code: :missing_title, severity: :warning, category: :metadata, url: page.url, message: "missing <title>", details: {})
|
|
95
95
|
elsif title.length > TITLE_MAX_LENGTH
|
|
96
|
-
issues.add(code: :title_too_long, severity: :warning, category: :metadata, url: page.url, message: "title
|
|
96
|
+
issues.add(code: :title_too_long, severity: :warning, category: :metadata, url: page.url, message: "title: #{title.length} characters; maximum #{TITLE_MAX_LENGTH}", details: {length: title.length})
|
|
97
97
|
elsif repeated_site_name?(title)
|
|
98
98
|
issues.add(code: :title_repeats_site_name, severity: :warning, category: :metadata, url: page.url, message: "title repeats #{@site_name}", details: {site_name: @site_name})
|
|
99
99
|
end
|
|
@@ -117,9 +117,9 @@ module Crawlscope
|
|
|
117
117
|
if description.empty?
|
|
118
118
|
issues.add(code: :missing_meta_description, severity: :warning, category: :metadata, url: page.url, message: "missing meta description", details: {})
|
|
119
119
|
elsif description.length < DESCRIPTION_MIN_LENGTH
|
|
120
|
-
issues.add(code: :meta_description_too_short, severity: :warning, category: :metadata, url: page.url, message: "meta description
|
|
120
|
+
issues.add(code: :meta_description_too_short, severity: :warning, category: :metadata, url: page.url, message: "meta description: #{description.length} characters; minimum #{DESCRIPTION_MIN_LENGTH}", details: {length: description.length, minimum: DESCRIPTION_MIN_LENGTH})
|
|
121
121
|
elsif description.length > DESCRIPTION_MAX_LENGTH
|
|
122
|
-
issues.add(code: :meta_description_too_long, severity: :warning, category: :metadata, url: page.url, message: "meta description
|
|
122
|
+
issues.add(code: :meta_description_too_long, severity: :warning, category: :metadata, url: page.url, message: "meta description: #{description.length} characters; maximum #{DESCRIPTION_MAX_LENGTH}", details: {length: description.length})
|
|
123
123
|
end
|
|
124
124
|
end
|
|
125
125
|
|
|
@@ -136,25 +136,25 @@ module Crawlscope
|
|
|
136
136
|
return if canonical_matches_page?(normalized_canonical, normalized_page_url)
|
|
137
137
|
|
|
138
138
|
details = {canonical: canonical}
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
139
|
+
if sitemap_urls.include?(normalized_page_url)
|
|
140
|
+
issues.add(
|
|
141
|
+
code: :non_canonical_page_in_sitemap,
|
|
142
|
+
severity: :warning,
|
|
143
|
+
category: :sitemaps,
|
|
144
|
+
url: page.url,
|
|
145
|
+
message: "sitemap lists this URL, but its canonical points to #{canonical}",
|
|
146
|
+
details: details
|
|
147
|
+
)
|
|
148
|
+
else
|
|
149
|
+
issues.add(
|
|
150
|
+
code: :canonical_mismatch,
|
|
151
|
+
severity: :warning,
|
|
152
|
+
category: :metadata,
|
|
153
|
+
url: page.url,
|
|
154
|
+
message: "canonical points to a different URL: #{canonical}",
|
|
155
|
+
details: details
|
|
156
|
+
)
|
|
157
|
+
end
|
|
158
158
|
end
|
|
159
159
|
|
|
160
160
|
def repeated_site_name?(title)
|
|
@@ -174,7 +174,7 @@ module Crawlscope
|
|
|
174
174
|
severity: :warning,
|
|
175
175
|
category: :metadata,
|
|
176
176
|
url: page.url,
|
|
177
|
-
message: "Open Graph tags
|
|
177
|
+
message: "missing Open Graph tags: #{missing.join(", ")}",
|
|
178
178
|
details: {missing: missing}
|
|
179
179
|
)
|
|
180
180
|
end
|
|
@@ -49,7 +49,7 @@ module Crawlscope
|
|
|
49
49
|
severity: :warning,
|
|
50
50
|
category: :structured_data,
|
|
51
51
|
url: page.url,
|
|
52
|
-
message: "#{source} parse error: #{data[:message]}",
|
|
52
|
+
message: "#{source_label(source)} parse error: #{data[:message]}",
|
|
53
53
|
details: {source: source}
|
|
54
54
|
)
|
|
55
55
|
next
|
|
@@ -65,7 +65,7 @@ module Crawlscope
|
|
|
65
65
|
severity: :warning,
|
|
66
66
|
category: :structured_data,
|
|
67
67
|
url: page.url,
|
|
68
|
-
message:
|
|
68
|
+
message: schema_error_message(source, errors),
|
|
69
69
|
details: {errors: errors, source: source}
|
|
70
70
|
)
|
|
71
71
|
end
|
|
@@ -73,6 +73,15 @@ module Crawlscope
|
|
|
73
73
|
validate_job_posting_count(page, items, issues)
|
|
74
74
|
end
|
|
75
75
|
|
|
76
|
+
def source_label(source)
|
|
77
|
+
(source == "json-ld") ? "JSON-LD" : source
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def schema_error_message(source, errors)
|
|
81
|
+
messages = errors.map { |error| Crawlscope::StructuredData::ErrorFormatter.format(error) }
|
|
82
|
+
"#{source_label(source)} #{messages.join("; ")}"
|
|
83
|
+
end
|
|
84
|
+
|
|
76
85
|
def validate_job_posting_count(page, items, issues)
|
|
77
86
|
job_postings = items.select { |item| structured_data_types(item.data).include?("JobPosting") }
|
|
78
87
|
return if job_postings.size == 1
|
|
@@ -83,7 +92,7 @@ module Crawlscope
|
|
|
83
92
|
severity: :warning,
|
|
84
93
|
category: :structured_data,
|
|
85
94
|
url: page.url,
|
|
86
|
-
message: "
|
|
95
|
+
message: "JobPosting blocks: #{job_postings.size}; expected 1",
|
|
87
96
|
details: {count: job_postings.size}
|
|
88
97
|
)
|
|
89
98
|
elsif career_detail_page?(page.url)
|
|
@@ -107,7 +116,7 @@ module Crawlscope
|
|
|
107
116
|
severity: :warning,
|
|
108
117
|
category: :structured_data,
|
|
109
118
|
url: page.url,
|
|
110
|
-
message: "#{source} structured data missing @type",
|
|
119
|
+
message: "#{source_label(source)} structured data missing @type",
|
|
111
120
|
details: {paths: missing_paths, source: source}
|
|
112
121
|
)
|
|
113
122
|
end
|
|
@@ -66,7 +66,9 @@ module Crawlscope
|
|
|
66
66
|
end
|
|
67
67
|
|
|
68
68
|
def summarize_pages(pages)
|
|
69
|
-
html_pages = pages.select
|
|
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: "
|
|
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: "
|
|
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: "
|
|
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: "
|
|
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: "
|
|
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: "
|
|
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
|
data/lib/crawlscope/run.rb
CHANGED
|
@@ -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
|
-
|
|
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
|