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.
Files changed (44) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +36 -0
  3. data/README.md +38 -0
  4. data/lib/crawlscope/cli.rb +20 -1
  5. data/lib/crawlscope/configuration.rb +10 -1
  6. data/lib/crawlscope/context.rb +1 -1
  7. data/lib/crawlscope/crawl.rb +74 -14
  8. data/lib/crawlscope/crawler.rb +3 -17
  9. data/lib/crawlscope/document_text.rb +7 -2
  10. data/lib/crawlscope/fetch_executor/async.rb +32 -0
  11. data/lib/crawlscope/fetch_executor/threaded.rb +32 -0
  12. data/lib/crawlscope/fetch_executor.rb +43 -0
  13. data/lib/crawlscope/http.rb +7 -1
  14. data/lib/crawlscope/rake_tasks.rb +27 -12
  15. data/lib/crawlscope/reporter.rb +131 -7
  16. data/lib/crawlscope/result.rb +1 -1
  17. data/lib/crawlscope/rules/content_quality.rb +1 -1
  18. data/lib/crawlscope/rules/indexability.rb +155 -20
  19. data/lib/crawlscope/rules/links.rb +379 -12
  20. data/lib/crawlscope/rules/metadata.rb +61 -6
  21. data/lib/crawlscope/rules/structured_data.rb +31 -0
  22. data/lib/crawlscope/rules/uniqueness.rb +45 -4
  23. data/lib/crawlscope/sitemap.rb +39 -12
  24. data/lib/crawlscope/version.rb +1 -1
  25. data/lib/tasks/crawlscope_tasks.rake +24 -24
  26. data/test/crawlscope/cli_test.rb +29 -2
  27. data/test/crawlscope/configuration_test.rb +21 -0
  28. data/test/crawlscope/content_quality_rule_test.rb +18 -0
  29. data/test/crawlscope/crawl_test.rb +167 -3
  30. data/test/crawlscope/crawler_test.rb +61 -0
  31. data/test/crawlscope/fetch_executor_test.rb +44 -0
  32. data/test/crawlscope/indexability_rule_test.rb +33 -0
  33. data/test/crawlscope/links_rule_test.rb +249 -3
  34. data/test/crawlscope/metadata_rule_test.rb +36 -0
  35. data/test/crawlscope/rake_tasks_test.rb +70 -0
  36. data/test/crawlscope/reporter_test.rb +136 -7
  37. data/test/crawlscope/result_test.rb +35 -0
  38. data/test/crawlscope/sitemap_test.rb +76 -0
  39. data/test/crawlscope/structured_data_rule_test.rb +56 -0
  40. data/test/crawlscope/uniqueness_rule_test.rb +17 -2
  41. data/test/performance/async_fetch_benchmark.rb +127 -0
  42. data/test/performance/fetch_executor_matrix.rb +162 -0
  43. data/test/performance/sitemap_expansion_benchmark.rb +121 -0
  44. 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
- return if links.empty?
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?).flat_map { |page| page_links(page) }
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
- links.group_by { |link| link[:target_url] }.each do |target_url, grouped_links|
130
- target = resolve_target(target_url)
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
- next if inbound_count >= MIN_INBOUND_ANCHOR_LINKS
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
- source_samples = sample_sources_by_target[path].first(MAX_SOURCES_IN_ERROR)
257
- source_info = source_samples.any? ? " (sources: #{source_samples.join(", ")})" : ""
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: :low_inbound_anchor_links,
437
+ code: :page_has_no_outgoing_links,
261
438
  severity: :warning,
262
439
  category: :links,
263
- url: target_url,
264
- message: "inbound anchor links #{inbound_count} below #{MIN_INBOUND_ANCHOR_LINKS}#{source_info}",
265
- details: {inbound_count: inbound_count, minimum: MIN_INBOUND_ANCHOR_LINKS, source_urls: source_samples}
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
- return if h1s.one?
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
- title = page.doc.at_css("title")&.text.to_s.strip
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
- description = page.doc.at_css('meta[name="description"]')&.[]("content").to_s.strip
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: {canonical: 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
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