crawlscope 0.4.0 → 0.6.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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +36 -0
- data/README.md +38 -0
- data/lib/crawlscope/cli.rb +20 -1
- data/lib/crawlscope/configuration.rb +10 -1
- data/lib/crawlscope/context.rb +1 -1
- data/lib/crawlscope/crawl.rb +74 -14
- data/lib/crawlscope/crawler.rb +3 -17
- data/lib/crawlscope/document_text.rb +7 -2
- data/lib/crawlscope/fetch_executor/async.rb +32 -0
- data/lib/crawlscope/fetch_executor/threaded.rb +32 -0
- data/lib/crawlscope/fetch_executor.rb +43 -0
- data/lib/crawlscope/http.rb +7 -1
- data/lib/crawlscope/rake_tasks.rb +27 -12
- data/lib/crawlscope/reporter.rb +131 -7
- data/lib/crawlscope/result.rb +1 -1
- data/lib/crawlscope/rules/content_quality.rb +1 -1
- data/lib/crawlscope/rules/indexability.rb +155 -20
- data/lib/crawlscope/rules/links.rb +379 -12
- data/lib/crawlscope/rules/metadata.rb +61 -6
- data/lib/crawlscope/rules/structured_data.rb +31 -0
- data/lib/crawlscope/rules/uniqueness.rb +45 -4
- data/lib/crawlscope/sitemap.rb +39 -12
- data/lib/crawlscope/version.rb +1 -1
- data/lib/tasks/crawlscope_tasks.rake +24 -24
- data/test/crawlscope/cli_test.rb +29 -2
- data/test/crawlscope/configuration_test.rb +21 -0
- data/test/crawlscope/content_quality_rule_test.rb +18 -0
- data/test/crawlscope/crawl_test.rb +167 -3
- data/test/crawlscope/crawler_test.rb +61 -0
- data/test/crawlscope/fetch_executor_test.rb +44 -0
- data/test/crawlscope/indexability_rule_test.rb +33 -0
- data/test/crawlscope/links_rule_test.rb +249 -3
- data/test/crawlscope/metadata_rule_test.rb +36 -0
- data/test/crawlscope/rake_tasks_test.rb +70 -0
- data/test/crawlscope/reporter_test.rb +136 -7
- data/test/crawlscope/result_test.rb +35 -0
- data/test/crawlscope/sitemap_test.rb +76 -0
- data/test/crawlscope/structured_data_rule_test.rb +56 -0
- data/test/crawlscope/uniqueness_rule_test.rb +17 -2
- data/test/performance/async_fetch_benchmark.rb +127 -0
- data/test/performance/fetch_executor_matrix.rb +162 -0
- data/test/performance/sitemap_expansion_benchmark.rb +121 -0
- metadata +39 -2
|
@@ -10,6 +10,7 @@ module Crawlscope
|
|
|
10
10
|
LINK_SCHEMES_TO_SKIP = ["mailto:", "tel:", "javascript:", "data:"].freeze
|
|
11
11
|
MAX_SOURCES_IN_ERROR = 3
|
|
12
12
|
MIN_INBOUND_ANCHOR_LINKS = 1
|
|
13
|
+
MIN_DOFOLLOW_INBOUND_LINKS = 2
|
|
13
14
|
|
|
14
15
|
attr_reader :code
|
|
15
16
|
|
|
@@ -20,14 +21,23 @@ module Crawlscope
|
|
|
20
21
|
def call(urls:, pages:, issues:, context:)
|
|
21
22
|
@allowed_statuses = context.fetch(:allowed_statuses)
|
|
22
23
|
@base_url = context.fetch(:base_url)
|
|
24
|
+
@concurrency = context_value(context, :concurrency, default: 1)
|
|
25
|
+
@fetch_executor = context_value(context, :fetch_executor)
|
|
23
26
|
@resolve_target = context.fetch(:resolve_target)
|
|
27
|
+
@resolve_targets = context.resolve_targets if context.respond_to?(:resolve_targets)
|
|
28
|
+
@resolve_targets ||= context[:resolve_targets] if context.respond_to?(:[])
|
|
24
29
|
@base_host = URI.parse(@base_url).host
|
|
30
|
+
@resolved_targets_by_url = {}
|
|
25
31
|
|
|
26
32
|
links = extract_links(pages)
|
|
27
|
-
|
|
28
|
-
|
|
33
|
+
validate_url_hygiene(urls, links, issues)
|
|
29
34
|
resolved_links = resolve_links(links, issues)
|
|
35
|
+
validate_nofollow_outgoing_links(links, issues)
|
|
36
|
+
validate_http_internal_links(links, issues)
|
|
37
|
+
validate_pages_with_no_outgoing_links(urls, pages, links, issues)
|
|
38
|
+
validate_indexable_pages_missing_from_sitemap(urls, resolved_links, issues)
|
|
30
39
|
validate_inbound_counts(urls, pages, resolved_links, issues)
|
|
40
|
+
validate_canonical_targets(urls, pages, resolved_links, issues)
|
|
31
41
|
end
|
|
32
42
|
|
|
33
43
|
private
|
|
@@ -37,7 +47,12 @@ module Crawlscope
|
|
|
37
47
|
end
|
|
38
48
|
|
|
39
49
|
def extract_links(pages)
|
|
40
|
-
pages.select(&:html?)
|
|
50
|
+
html_pages = pages.select(&:html?)
|
|
51
|
+
FetchExecutor.map(
|
|
52
|
+
name: @fetch_executor,
|
|
53
|
+
concurrency: @concurrency,
|
|
54
|
+
items: html_pages
|
|
55
|
+
) { |page| page_links(page) }.flatten
|
|
41
56
|
end
|
|
42
57
|
|
|
43
58
|
def page_links(page)
|
|
@@ -64,6 +79,8 @@ module Crawlscope
|
|
|
64
79
|
|
|
65
80
|
{
|
|
66
81
|
anchor_text: anchor_text,
|
|
82
|
+
http_internal_link: http_internal_link?(page.normalized_url, href),
|
|
83
|
+
nofollow: nofollow_link?(node),
|
|
67
84
|
source_path: source_path,
|
|
68
85
|
source_url: page.normalized_url,
|
|
69
86
|
target_path: target_path,
|
|
@@ -86,6 +103,19 @@ module Crawlscope
|
|
|
86
103
|
text.to_s.gsub(/\s+/, " ").strip
|
|
87
104
|
end
|
|
88
105
|
|
|
106
|
+
def nofollow_link?(node)
|
|
107
|
+
node["rel"].to_s.split(/\s+/).any? { |value| value.casecmp?("nofollow") }
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def http_internal_link?(source_url, href)
|
|
111
|
+
source_uri = URI.parse(source_url.to_s)
|
|
112
|
+
target_uri = URI.parse(URI.join(source_url, href).to_s)
|
|
113
|
+
|
|
114
|
+
source_uri.scheme == "https" && target_uri.scheme == "http" && target_uri.host == @base_host
|
|
115
|
+
rescue URI::InvalidURIError
|
|
116
|
+
false
|
|
117
|
+
end
|
|
118
|
+
|
|
89
119
|
def normalize_internal_link(source_url, href)
|
|
90
120
|
absolute_url = URI.join(source_url, href).to_s
|
|
91
121
|
uri = URI.parse(absolute_url)
|
|
@@ -109,6 +139,36 @@ module Crawlscope
|
|
|
109
139
|
)
|
|
110
140
|
end
|
|
111
141
|
|
|
142
|
+
def validate_nofollow_outgoing_links(links, issues)
|
|
143
|
+
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)
|
|
145
|
+
|
|
146
|
+
issues.add(
|
|
147
|
+
code: :nofollow_internal_outlinks,
|
|
148
|
+
severity: :warning,
|
|
149
|
+
category: :links,
|
|
150
|
+
url: source_url,
|
|
151
|
+
message: "page has nofollow outgoing internal links",
|
|
152
|
+
details: {target_urls: target_urls}
|
|
153
|
+
)
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def validate_http_internal_links(links, issues)
|
|
158
|
+
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)
|
|
160
|
+
|
|
161
|
+
issues.add(
|
|
162
|
+
code: :http_internal_link,
|
|
163
|
+
severity: :warning,
|
|
164
|
+
category: :links,
|
|
165
|
+
url: source_url,
|
|
166
|
+
message: "HTTPS page links to internal HTTP URL",
|
|
167
|
+
details: {target_urls: target_urls}
|
|
168
|
+
)
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
|
|
112
172
|
def report_unresolved_target(target_url, grouped_links, issues, resolution)
|
|
113
173
|
source_urls = grouped_links.map { |link| link[:source_url] }.uniq.first(MAX_SOURCES_IN_ERROR)
|
|
114
174
|
suffix = (resolution && resolution[:error]) ? " (#{resolution[:error]})" : ""
|
|
@@ -125,9 +185,12 @@ module Crawlscope
|
|
|
125
185
|
|
|
126
186
|
def resolve_links(links, issues)
|
|
127
187
|
resolved_links = []
|
|
188
|
+
grouped_links_by_target = links.group_by { |link| link[:target_url] }
|
|
189
|
+
targets_by_url = resolve_targets(grouped_links_by_target.keys)
|
|
190
|
+
@resolved_targets_by_url.merge!(targets_by_url)
|
|
128
191
|
|
|
129
|
-
|
|
130
|
-
target =
|
|
192
|
+
grouped_links_by_target.each do |target_url, grouped_links|
|
|
193
|
+
target = target_for(target_url)
|
|
131
194
|
|
|
132
195
|
if target.unresolved?
|
|
133
196
|
report_unresolved_target(target_url, grouped_links, issues, target.resolution)
|
|
@@ -171,6 +234,23 @@ module Crawlscope
|
|
|
171
234
|
LinkTarget.new(target_url: target_url, resolution: resolution)
|
|
172
235
|
end
|
|
173
236
|
|
|
237
|
+
def resolve_targets(target_urls)
|
|
238
|
+
if @resolve_targets
|
|
239
|
+
@resolve_targets.call(target_urls).to_h do |target_url, resolution|
|
|
240
|
+
[target_url, LinkTarget.new(target_url: target_url, resolution: resolution)]
|
|
241
|
+
end
|
|
242
|
+
else
|
|
243
|
+
target_urls.to_h { |target_url| [target_url, resolve_target(target_url)] }
|
|
244
|
+
end
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
def target_for(target_url)
|
|
248
|
+
@resolved_targets_by_url.fetch(target_url) do
|
|
249
|
+
target = resolve_target(target_url)
|
|
250
|
+
@resolved_targets_by_url[target_url] = target
|
|
251
|
+
end
|
|
252
|
+
end
|
|
253
|
+
|
|
174
254
|
LinkTarget = Data.define(:target_url, :resolution) do
|
|
175
255
|
def allowed?(statuses)
|
|
176
256
|
statuses.include?(status)
|
|
@@ -189,6 +269,15 @@ module Crawlscope
|
|
|
189
269
|
resolution && status.nil? && resolution[:crawled] && resolution[:error]
|
|
190
270
|
end
|
|
191
271
|
|
|
272
|
+
def html?
|
|
273
|
+
resolution && resolution[:html]
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
def noindex?
|
|
277
|
+
Crawlscope::Rules::Indexability.noindex_header?(resolution[:headers] || {}) ||
|
|
278
|
+
Crawlscope::Rules::Indexability.noindex_meta?(resolution[:doc])
|
|
279
|
+
end
|
|
280
|
+
|
|
192
281
|
def status
|
|
193
282
|
resolution && resolution[:status]
|
|
194
283
|
end
|
|
@@ -221,6 +310,7 @@ module Crawlscope
|
|
|
221
310
|
|
|
222
311
|
memo[path] = normalized_url
|
|
223
312
|
end
|
|
313
|
+
return if sitemap_paths.size < 2
|
|
224
314
|
|
|
225
315
|
html_paths = pages.each_with_object(Set.new) do |page, result|
|
|
226
316
|
next unless page.html?
|
|
@@ -235,7 +325,11 @@ module Crawlscope
|
|
|
235
325
|
end
|
|
236
326
|
|
|
237
327
|
inbound_anchor_counts = Hash.new(0)
|
|
328
|
+
dofollow_inbound_counts = Hash.new(0)
|
|
329
|
+
nofollow_inbound_counts = Hash.new(0)
|
|
238
330
|
sample_sources_by_target = Hash.new { |hash, key| hash[key] = [] }
|
|
331
|
+
dofollow_sources_by_target = Hash.new { |hash, key| hash[key] = [] }
|
|
332
|
+
nofollow_sources_by_target = Hash.new { |hash, key| hash[key] = [] }
|
|
239
333
|
|
|
240
334
|
resolved_links.each do |link|
|
|
241
335
|
target_path = link[:final_path]
|
|
@@ -245,27 +339,300 @@ module Crawlscope
|
|
|
245
339
|
inbound_anchor_counts[target_path] += 1
|
|
246
340
|
source_samples = sample_sources_by_target[target_path]
|
|
247
341
|
source_samples << link[:source_url] unless source_samples.include?(link[:source_url])
|
|
342
|
+
|
|
343
|
+
if link[:nofollow]
|
|
344
|
+
nofollow_inbound_counts[target_path] += 1
|
|
345
|
+
nofollow_sources = nofollow_sources_by_target[target_path]
|
|
346
|
+
nofollow_sources << link[:source_url] unless nofollow_sources.include?(link[:source_url])
|
|
347
|
+
else
|
|
348
|
+
dofollow_inbound_counts[target_path] += 1
|
|
349
|
+
dofollow_sources = dofollow_sources_by_target[target_path]
|
|
350
|
+
dofollow_sources << link[:source_url] unless dofollow_sources.include?(link[:source_url])
|
|
351
|
+
end
|
|
248
352
|
end
|
|
249
353
|
|
|
250
354
|
sitemap_paths.each do |path, target_url|
|
|
251
355
|
next unless html_paths.include?(path)
|
|
252
356
|
|
|
253
357
|
inbound_count = inbound_anchor_counts[path]
|
|
254
|
-
|
|
358
|
+
dofollow_count = dofollow_inbound_counts[path]
|
|
359
|
+
nofollow_count = nofollow_inbound_counts[path]
|
|
360
|
+
|
|
361
|
+
report_orphan_page(target_url, issues) if inbound_count.zero?
|
|
362
|
+
|
|
363
|
+
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(", ")})" : ""
|
|
366
|
+
|
|
367
|
+
issues.add(
|
|
368
|
+
code: :low_inbound_anchor_links,
|
|
369
|
+
severity: :warning,
|
|
370
|
+
category: :links,
|
|
371
|
+
url: target_url,
|
|
372
|
+
message: "inbound anchor links #{inbound_count} below #{MIN_INBOUND_ANCHOR_LINKS}#{source_info}",
|
|
373
|
+
details: {inbound_count: inbound_count, minimum: MIN_INBOUND_ANCHOR_LINKS, source_urls: source_samples}
|
|
374
|
+
)
|
|
375
|
+
end
|
|
376
|
+
|
|
377
|
+
report_low_dofollow_inlinks(target_url, path, dofollow_count, dofollow_sources_by_target, issues)
|
|
378
|
+
report_only_nofollow_internal_inlinks(target_url, nofollow_count, dofollow_count, nofollow_sources_by_target[path], issues)
|
|
379
|
+
report_mixed_follow_internal_inlinks(target_url, nofollow_count, dofollow_count, nofollow_sources_by_target[path], dofollow_sources_by_target[path], issues)
|
|
380
|
+
end
|
|
381
|
+
end
|
|
382
|
+
|
|
383
|
+
def validate_url_hygiene(urls, links, issues)
|
|
384
|
+
checked_urls = urls.map { |url| Url.normalize(url, base_url: @base_url) }
|
|
385
|
+
checked_urls.concat(links.map { |link| link[:target_url] })
|
|
386
|
+
|
|
387
|
+
checked_urls.compact.uniq.each do |url|
|
|
388
|
+
report_url_double_slash(url, issues)
|
|
389
|
+
report_url_too_long(url, issues)
|
|
390
|
+
end
|
|
391
|
+
end
|
|
392
|
+
|
|
393
|
+
def report_url_double_slash(url, issues)
|
|
394
|
+
path = URI.parse(url).path.to_s
|
|
395
|
+
return unless path.match?(%r{//+})
|
|
396
|
+
|
|
397
|
+
issues.add(
|
|
398
|
+
code: :url_double_slash,
|
|
399
|
+
severity: :notice,
|
|
400
|
+
category: :url,
|
|
401
|
+
url: url,
|
|
402
|
+
message: "URL path contains duplicate slashes",
|
|
403
|
+
details: {path: path}
|
|
404
|
+
)
|
|
405
|
+
rescue URI::InvalidURIError
|
|
406
|
+
nil
|
|
407
|
+
end
|
|
255
408
|
|
|
256
|
-
|
|
257
|
-
|
|
409
|
+
def report_url_too_long(url, issues)
|
|
410
|
+
return unless url.length > 2_048
|
|
411
|
+
|
|
412
|
+
issues.add(
|
|
413
|
+
code: :url_too_long,
|
|
414
|
+
severity: :notice,
|
|
415
|
+
category: :url,
|
|
416
|
+
url: url,
|
|
417
|
+
message: "URL too long (#{url.length})",
|
|
418
|
+
details: {length: url.length, maximum: 2_048}
|
|
419
|
+
)
|
|
420
|
+
end
|
|
421
|
+
|
|
422
|
+
def validate_pages_with_no_outgoing_links(urls, pages, links, issues)
|
|
423
|
+
sitemap_urls = urls.map { |url| Url.normalize(url, base_url: @base_url) }.compact.to_set
|
|
424
|
+
return if sitemap_urls.size < 2
|
|
425
|
+
|
|
426
|
+
source_paths_with_links = links.map { |link| link[:source_path] }.to_set
|
|
427
|
+
|
|
428
|
+
pages.each do |page|
|
|
429
|
+
next unless page.html?
|
|
430
|
+
next unless sitemap_urls.include?(page.normalized_url)
|
|
431
|
+
|
|
432
|
+
source_path = Url.path(page.normalized_url)
|
|
433
|
+
next unless crawlable_source_path?(source_path)
|
|
434
|
+
next if source_paths_with_links.include?(source_path)
|
|
258
435
|
|
|
259
436
|
issues.add(
|
|
260
|
-
code: :
|
|
437
|
+
code: :page_has_no_outgoing_links,
|
|
261
438
|
severity: :warning,
|
|
262
439
|
category: :links,
|
|
263
|
-
url:
|
|
264
|
-
message: "
|
|
265
|
-
details: {
|
|
440
|
+
url: page.url,
|
|
441
|
+
message: "page has no outgoing internal links",
|
|
442
|
+
details: {}
|
|
443
|
+
)
|
|
444
|
+
end
|
|
445
|
+
end
|
|
446
|
+
|
|
447
|
+
def validate_indexable_pages_missing_from_sitemap(urls, resolved_links, issues)
|
|
448
|
+
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]
|
|
453
|
+
next if sitemap_urls.include?(final_url)
|
|
454
|
+
next if reported_urls.include?(final_url)
|
|
455
|
+
next unless crawlable_path?(link[:final_path])
|
|
456
|
+
|
|
457
|
+
target = target_for(final_url)
|
|
458
|
+
next unless target.allowed?(@allowed_statuses) && target.html?
|
|
459
|
+
next if target.noindex?
|
|
460
|
+
|
|
461
|
+
reported_urls << final_url
|
|
462
|
+
|
|
463
|
+
issues.add(
|
|
464
|
+
code: :indexable_page_missing_from_sitemap,
|
|
465
|
+
severity: :warning,
|
|
466
|
+
category: :sitemaps,
|
|
467
|
+
url: final_url,
|
|
468
|
+
message: "indexable internal page is missing from sitemap",
|
|
469
|
+
details: {source_url: link[:source_url]}
|
|
470
|
+
)
|
|
471
|
+
end
|
|
472
|
+
end
|
|
473
|
+
|
|
474
|
+
def report_orphan_page(target_url, issues)
|
|
475
|
+
issues.add(
|
|
476
|
+
code: :orphan_page,
|
|
477
|
+
severity: :warning,
|
|
478
|
+
category: :links,
|
|
479
|
+
url: target_url,
|
|
480
|
+
message: "page has no incoming internal links",
|
|
481
|
+
details: {}
|
|
482
|
+
)
|
|
483
|
+
end
|
|
484
|
+
|
|
485
|
+
def report_low_dofollow_inlinks(target_url, path, dofollow_count, sources_by_target, issues)
|
|
486
|
+
return if dofollow_count.zero?
|
|
487
|
+
return if dofollow_count >= MIN_DOFOLLOW_INBOUND_LINKS
|
|
488
|
+
|
|
489
|
+
source_samples = sources_by_target[path].first(MAX_SOURCES_IN_ERROR)
|
|
490
|
+
source_info = source_samples.any? ? " (sources: #{source_samples.join(", ")})" : ""
|
|
491
|
+
|
|
492
|
+
issues.add(
|
|
493
|
+
code: :low_dofollow_inlinks,
|
|
494
|
+
severity: :warning,
|
|
495
|
+
category: :links,
|
|
496
|
+
url: target_url,
|
|
497
|
+
message: "dofollow inbound links #{dofollow_count} below #{MIN_DOFOLLOW_INBOUND_LINKS}#{source_info}",
|
|
498
|
+
details: {dofollow_inbound_count: dofollow_count, minimum: MIN_DOFOLLOW_INBOUND_LINKS, source_urls: source_samples}
|
|
499
|
+
)
|
|
500
|
+
end
|
|
501
|
+
|
|
502
|
+
def report_only_nofollow_internal_inlinks(target_url, nofollow_count, dofollow_count, nofollow_sources, issues)
|
|
503
|
+
return unless nofollow_count.positive? && dofollow_count.zero?
|
|
504
|
+
|
|
505
|
+
issues.add(
|
|
506
|
+
code: :only_nofollow_internal_inlinks,
|
|
507
|
+
severity: :warning,
|
|
508
|
+
category: :links,
|
|
509
|
+
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)}
|
|
512
|
+
)
|
|
513
|
+
end
|
|
514
|
+
|
|
515
|
+
def report_mixed_follow_internal_inlinks(target_url, nofollow_count, dofollow_count, nofollow_sources, dofollow_sources, issues)
|
|
516
|
+
return unless nofollow_count.positive? && dofollow_count.positive?
|
|
517
|
+
|
|
518
|
+
issues.add(
|
|
519
|
+
code: :mixed_follow_internal_inlinks,
|
|
520
|
+
severity: :notice,
|
|
521
|
+
category: :links,
|
|
522
|
+
url: target_url,
|
|
523
|
+
message: "page has nofollow and dofollow incoming internal links",
|
|
524
|
+
details: {
|
|
525
|
+
dofollow_inbound_count: dofollow_count,
|
|
526
|
+
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)
|
|
529
|
+
}
|
|
530
|
+
)
|
|
531
|
+
end
|
|
532
|
+
|
|
533
|
+
def validate_canonical_targets(urls, pages, resolved_links, issues)
|
|
534
|
+
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) }
|
|
536
|
+
return if sitemap_pages.size < 2
|
|
537
|
+
|
|
538
|
+
dofollow_counts_by_path = dofollow_counts_by_final_path(resolved_links)
|
|
539
|
+
canonical_entries = canonical_entries_for(sitemap_pages)
|
|
540
|
+
@resolved_targets_by_url.merge!(resolve_targets(canonical_entries.map { |entry| entry.fetch(:canonical_url) }))
|
|
541
|
+
|
|
542
|
+
canonical_entries.each do |entry|
|
|
543
|
+
page = entry.fetch(:page)
|
|
544
|
+
canonical_url = entry.fetch(:canonical_url)
|
|
545
|
+
canonical_path = entry.fetch(:canonical_path)
|
|
546
|
+
if canonical_path && !root_path?(canonical_path) && dofollow_counts_by_path[canonical_path].zero?
|
|
547
|
+
issues.add(
|
|
548
|
+
code: :canonical_no_internal_inlinks,
|
|
549
|
+
severity: :warning,
|
|
550
|
+
category: :links,
|
|
551
|
+
url: canonical_url,
|
|
552
|
+
message: "canonical URL has no incoming internal links",
|
|
553
|
+
details: {source_url: page.url}
|
|
554
|
+
)
|
|
555
|
+
end
|
|
556
|
+
|
|
557
|
+
validate_canonical_target_status(page, canonical_url, issues)
|
|
558
|
+
end
|
|
559
|
+
end
|
|
560
|
+
|
|
561
|
+
def canonical_entries_for(pages)
|
|
562
|
+
pages.filter_map do |page|
|
|
563
|
+
canonical_url = canonical_url_for(page)
|
|
564
|
+
next if canonical_url.nil?
|
|
565
|
+
|
|
566
|
+
target_uri = URI.parse(canonical_url)
|
|
567
|
+
next if target_uri.host != @base_host
|
|
568
|
+
|
|
569
|
+
{
|
|
570
|
+
canonical_path: Url.path(canonical_url),
|
|
571
|
+
canonical_url: canonical_url,
|
|
572
|
+
page: page
|
|
573
|
+
}
|
|
574
|
+
rescue URI::InvalidURIError
|
|
575
|
+
nil
|
|
576
|
+
end
|
|
577
|
+
end
|
|
578
|
+
|
|
579
|
+
def dofollow_counts_by_final_path(resolved_links)
|
|
580
|
+
resolved_links.each_with_object(Hash.new(0)) do |link, counts|
|
|
581
|
+
next if link[:nofollow]
|
|
582
|
+
next if link[:source_path] == link[:final_path]
|
|
583
|
+
|
|
584
|
+
counts[link[:final_path]] += 1
|
|
585
|
+
end
|
|
586
|
+
end
|
|
587
|
+
|
|
588
|
+
def canonical_url_for(page)
|
|
589
|
+
canonical = page.doc.at_css('link[rel="canonical"]')&.[]("href").to_s.strip
|
|
590
|
+
return if canonical.empty?
|
|
591
|
+
|
|
592
|
+
Url.normalize(canonical, base_url: page.url)
|
|
593
|
+
end
|
|
594
|
+
|
|
595
|
+
def root_path?(path)
|
|
596
|
+
path == "/"
|
|
597
|
+
end
|
|
598
|
+
|
|
599
|
+
def validate_canonical_target_status(page, canonical_url, issues)
|
|
600
|
+
target = target_for(canonical_url)
|
|
601
|
+
|
|
602
|
+
if target.unresolved? || target.ignored_error?
|
|
603
|
+
return
|
|
604
|
+
end
|
|
605
|
+
|
|
606
|
+
if target.redirect?
|
|
607
|
+
issues.add(
|
|
608
|
+
code: :canonical_points_to_redirect,
|
|
609
|
+
severity: :warning,
|
|
610
|
+
category: :metadata,
|
|
611
|
+
url: page.url,
|
|
612
|
+
message: "canonical points to redirect",
|
|
613
|
+
details: {canonical: canonical_url, final_url: target.final_url, status: target.status}
|
|
614
|
+
)
|
|
615
|
+
elsif !target.allowed?(@allowed_statuses)
|
|
616
|
+
issues.add(
|
|
617
|
+
code: :canonical_points_to_error,
|
|
618
|
+
severity: :warning,
|
|
619
|
+
category: :metadata,
|
|
620
|
+
url: page.url,
|
|
621
|
+
message: "canonical points to HTTP #{target.status}",
|
|
622
|
+
details: {canonical: canonical_url, status: target.status}
|
|
266
623
|
)
|
|
267
624
|
end
|
|
268
625
|
end
|
|
626
|
+
|
|
627
|
+
def context_value(context, name, default: nil)
|
|
628
|
+
if context.respond_to?(name)
|
|
629
|
+
context.public_send(name)
|
|
630
|
+
elsif context.respond_to?(:key?) && context.key?(name)
|
|
631
|
+
context[name]
|
|
632
|
+
else
|
|
633
|
+
default
|
|
634
|
+
end
|
|
635
|
+
end
|
|
269
636
|
end
|
|
270
637
|
end
|
|
271
638
|
end
|
|
@@ -18,22 +18,41 @@ module Crawlscope
|
|
|
18
18
|
end
|
|
19
19
|
|
|
20
20
|
def call(urls:, pages:, issues:, context: nil)
|
|
21
|
+
sitemap_urls = normalized_sitemap_urls(urls)
|
|
22
|
+
|
|
21
23
|
pages.each do |page|
|
|
22
24
|
next unless page.html?
|
|
23
25
|
|
|
24
26
|
validate_h1(page, issues)
|
|
25
27
|
validate_title(page, issues)
|
|
26
28
|
validate_description(page, issues)
|
|
27
|
-
validate_canonical(page, issues)
|
|
29
|
+
validate_canonical(page, issues, sitemap_urls)
|
|
28
30
|
validate_open_graph(page, issues)
|
|
29
31
|
end
|
|
30
32
|
end
|
|
31
33
|
|
|
32
34
|
private
|
|
33
35
|
|
|
36
|
+
def normalized_sitemap_urls(urls)
|
|
37
|
+
urls.map { |url| Url.normalize(url, base_url: url) }.compact
|
|
38
|
+
end
|
|
39
|
+
|
|
34
40
|
def validate_h1(page, issues)
|
|
35
41
|
h1s = page.doc.css("h1")
|
|
36
|
-
|
|
42
|
+
empty_h1s = h1s.select { |node| node.text.to_s.strip.empty? }
|
|
43
|
+
|
|
44
|
+
if empty_h1s.any?
|
|
45
|
+
issues.add(
|
|
46
|
+
code: :empty_h1,
|
|
47
|
+
severity: :warning,
|
|
48
|
+
category: :metadata,
|
|
49
|
+
url: page.url,
|
|
50
|
+
message: "empty <h1>",
|
|
51
|
+
details: {count: empty_h1s.size}
|
|
52
|
+
)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
return if h1s.one? && empty_h1s.empty?
|
|
37
56
|
|
|
38
57
|
if h1s.empty?
|
|
39
58
|
issues.add(
|
|
@@ -57,7 +76,19 @@ module Crawlscope
|
|
|
57
76
|
end
|
|
58
77
|
|
|
59
78
|
def validate_title(page, issues)
|
|
60
|
-
|
|
79
|
+
titles = page.doc.css("head > title")
|
|
80
|
+
title = titles.first&.text.to_s.strip
|
|
81
|
+
|
|
82
|
+
if titles.size > 1
|
|
83
|
+
issues.add(
|
|
84
|
+
code: :multiple_title_tags,
|
|
85
|
+
severity: :warning,
|
|
86
|
+
category: :metadata,
|
|
87
|
+
url: page.url,
|
|
88
|
+
message: "multiple <title> tags (#{titles.size})",
|
|
89
|
+
details: {count: titles.size}
|
|
90
|
+
)
|
|
91
|
+
end
|
|
61
92
|
|
|
62
93
|
if title.empty?
|
|
63
94
|
issues.add(code: :missing_title, severity: :warning, category: :metadata, url: page.url, message: "missing <title>", details: {})
|
|
@@ -69,7 +100,19 @@ module Crawlscope
|
|
|
69
100
|
end
|
|
70
101
|
|
|
71
102
|
def validate_description(page, issues)
|
|
72
|
-
|
|
103
|
+
descriptions = page.doc.css('head > meta[name="description"]')
|
|
104
|
+
description = descriptions.first&.[]("content").to_s.strip
|
|
105
|
+
|
|
106
|
+
if descriptions.size > 1
|
|
107
|
+
issues.add(
|
|
108
|
+
code: :multiple_meta_descriptions,
|
|
109
|
+
severity: :warning,
|
|
110
|
+
category: :metadata,
|
|
111
|
+
url: page.url,
|
|
112
|
+
message: "multiple meta description tags (#{descriptions.size})",
|
|
113
|
+
details: {count: descriptions.size}
|
|
114
|
+
)
|
|
115
|
+
end
|
|
73
116
|
|
|
74
117
|
if description.empty?
|
|
75
118
|
issues.add(code: :missing_meta_description, severity: :warning, category: :metadata, url: page.url, message: "missing meta description", details: {})
|
|
@@ -80,7 +123,7 @@ module Crawlscope
|
|
|
80
123
|
end
|
|
81
124
|
end
|
|
82
125
|
|
|
83
|
-
def validate_canonical(page, issues)
|
|
126
|
+
def validate_canonical(page, issues, sitemap_urls)
|
|
84
127
|
canonical = page.doc.at_css('link[rel="canonical"]')&.[]("href").to_s.strip
|
|
85
128
|
|
|
86
129
|
if canonical.empty?
|
|
@@ -92,13 +135,25 @@ module Crawlscope
|
|
|
92
135
|
normalized_page_url = Url.normalize(page.url, base_url: page.url)
|
|
93
136
|
return if canonical_matches_page?(normalized_canonical, normalized_page_url)
|
|
94
137
|
|
|
138
|
+
details = {canonical: canonical}
|
|
95
139
|
issues.add(
|
|
96
140
|
code: :canonical_mismatch,
|
|
97
141
|
severity: :warning,
|
|
98
142
|
category: :metadata,
|
|
99
143
|
url: page.url,
|
|
100
144
|
message: "canonical mismatch (#{canonical})",
|
|
101
|
-
details:
|
|
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
|
|
102
157
|
)
|
|
103
158
|
end
|
|
104
159
|
|
|
@@ -55,6 +55,8 @@ module Crawlscope
|
|
|
55
55
|
next
|
|
56
56
|
end
|
|
57
57
|
|
|
58
|
+
validate_type_presence(page, source, data, issues)
|
|
59
|
+
|
|
58
60
|
errors = schema_registry.validate(data)
|
|
59
61
|
next if errors.empty?
|
|
60
62
|
|
|
@@ -96,6 +98,35 @@ module Crawlscope
|
|
|
96
98
|
end
|
|
97
99
|
end
|
|
98
100
|
|
|
101
|
+
def validate_type_presence(page, source, data, issues)
|
|
102
|
+
missing_paths = missing_type_paths(data)
|
|
103
|
+
return if missing_paths.empty?
|
|
104
|
+
|
|
105
|
+
issues.add(
|
|
106
|
+
code: :structured_data_missing_type,
|
|
107
|
+
severity: :warning,
|
|
108
|
+
category: :structured_data,
|
|
109
|
+
url: page.url,
|
|
110
|
+
message: "#{source} structured data missing @type",
|
|
111
|
+
details: {paths: missing_paths, source: source}
|
|
112
|
+
)
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def missing_type_paths(data, path = "$")
|
|
116
|
+
return [] unless data.is_a?(Hash)
|
|
117
|
+
|
|
118
|
+
paths = []
|
|
119
|
+
paths << path if data["@type"].to_s.strip.empty?
|
|
120
|
+
|
|
121
|
+
if data["@graph"].is_a?(Array)
|
|
122
|
+
data["@graph"].each_with_index do |entry, index|
|
|
123
|
+
paths.concat(missing_type_paths(entry, "#{path}.@graph[#{index}]"))
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
paths
|
|
128
|
+
end
|
|
129
|
+
|
|
99
130
|
def structured_data_types(data)
|
|
100
131
|
return [] unless data.is_a?(Hash)
|
|
101
132
|
|