crawlscope 0.9.2 → 0.10.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 1aeb91b01f3329f1d9550c47396ed4e100a8426977134fac5371db1324f344ae
4
- data.tar.gz: f1c6c3a9db8d540c5e94b247e43bc9e632095b1d4265ee590e6a80bfa6cd1e69
3
+ metadata.gz: da58b612eb199f881ec2263858feea4bf93e11355ffce7b19921bb266451818e
4
+ data.tar.gz: bcfa91a8bd74cf5ae670dcc5a1b3e762ad0c6a74037cd82f92b700f8b3a2d7d0
5
5
  SHA512:
6
- metadata.gz: c7f9e054435cbef172f7f6ee5b35019615d7ced832637e343c90895472cf2dec4c203ddbead1b1e455564ea67adce9663be21004ce73cf20778829cc67a625fe
7
- data.tar.gz: 69d2b788ee3e646c5010ade5e9b0f543350a326c4a8189df76c8223d36b7464d95f322303391e24a8d53740693f5503bde974dd54a6b58ab18bc8fc9bfc5916e
6
+ metadata.gz: e3001de98212ddd2dca5f15feec4bf32eb9b3a975bef21c948c15c588c9a6d9f18bd130985fc3a6cfa78d1dfd34c2c5c4e944563ecd27be87cc49819475aed46
7
+ data.tar.gz: 784d47ba0dbf3effdbacb53d9211472e044b22da28c2d00e21bbcb8844aa2a1309eba072c0c631412b1dd7bad10f6713ad0adb107f2805df42310b1b31ad7991
data/CHANGELOG.md CHANGED
@@ -5,6 +5,22 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [0.10.0] - 2026-11-06
9
+
10
+
11
+ ### Changed
12
+
13
+ - keep checks local and adopt Gemkit 0.3.1
14
+
15
+
16
+
17
+
18
+ ### Fixed
19
+
20
+ - distinguish filtered URLs in crawl results
21
+
22
+
23
+
8
24
  ## [0.9.2] - 2026-11-05
9
25
 
10
26
 
data/README.md CHANGED
@@ -1,7 +1,7 @@
1
1
  # Crawlscope
2
2
 
3
3
  [![Gem Version](https://badge.fury.io/rb/crawlscope.svg)](https://badge.fury.io/rb/crawlscope)
4
- [![Ruby](https://github.com/ethos-link/crawlscope/actions/workflows/ruby.yml/badge.svg)](https://github.com/ethos-link/crawlscope/actions/workflows/ruby.yml)
4
+ [![Commit messages](https://github.com/ethos-link/crawlscope/actions/workflows/commitlint.yml/badge.svg)](https://github.com/ethos-link/crawlscope/actions/workflows/commitlint.yml)
5
5
 
6
6
  `crawlscope` is a small Ruby gem for sitemap-driven SEO validation.
7
7
 
data/UPGRADE.md CHANGED
@@ -1,8 +1,11 @@
1
1
  # Upgrade Guide
2
2
 
3
+ Run `bundle exec rake verify` locally before pushing or preparing a release.
4
+ GitHub Actions runs commitlint and release packaging/publication only.
5
+
3
6
  ## 0.9.1
4
7
 
5
- Development and release tooling uses Gemkit 0.3.0. Update the gem and workflow
8
+ Development and release tooling uses Gemkit 0.3.1. Update the gem and workflow
6
9
  pins together; see the [Gemkit upgrade guide](https://github.com/ethos-link/ethos-gemkit/blob/main/UPGRADE.md).
7
10
  Runtime APIs are unchanged.
8
11
 
@@ -34,6 +37,18 @@ in the README; the shared workflow does not inherit another gem's permission.
34
37
 
35
38
  ## Next Release
36
39
 
40
+ ### Query links retain their own URL identity
41
+
42
+ URL normalization now preserves query strings and still removes fragments.
43
+ Resolvers, caches and issue consumers must keep the query string when identifying
44
+ a URL. A filtered page no longer shares a resolution or inbound-link count with
45
+ its unfiltered page.
46
+
47
+ Nofollow warnings exclude self-links and destinations that explicitly declare
48
+ `noindex` in their response headers or HTML. Other indexable destinations,
49
+ including query URLs, still produce nofollow warnings. Broken and redirected
50
+ links remain checked. No host configuration changes are required.
51
+
37
52
  ### Structured-data length errors include the measured size
38
53
 
39
54
  String-length schema errors now include an optional `length` field with the
@@ -141,4 +156,4 @@ Recommended migration:
141
156
  4. Set `FETCH_EXECUTOR=threaded` or pass `--fetch-executor threaded` for a
142
157
  conservative rollout or for explicit thread-pool execution.
143
158
  5. Browser rendering continues to use threaded execution by default because
144
- async fetch execution is only supported with HTTP rendering.
159
+ async fetch execution is only supported with HTTP rendering.
@@ -31,7 +31,7 @@ module Crawlscope
31
31
  links = extract_links(pages)
32
32
  validate_url_hygiene(urls, links, issues)
33
33
  resolved_links = resolve_links(links, issues)
34
- validate_nofollow_outgoing_links(links, issues)
34
+ validate_nofollow_outgoing_links(resolved_links, issues)
35
35
  validate_http_internal_links(links, issues)
36
36
  validate_pages_with_no_outgoing_links(urls, pages, links, issues)
37
37
  validate_indexable_pages_missing_from_sitemap(urls, resolved_links, issues)
@@ -139,7 +139,9 @@ module Crawlscope
139
139
  end
140
140
 
141
141
  def validate_nofollow_outgoing_links(links, issues)
142
- links.select { |link| link[:nofollow] }.group_by { |link| link[:source_url] }.each do |source_url, grouped_links|
142
+ links.select do |link|
143
+ link[:nofollow] && link[:source_url] != link[:final_url] && !target_for(link[:target_url]).noindex?
144
+ end.group_by { |link| link[:source_url] }.each do |source_url, grouped_links|
143
145
  target_urls = grouped_links.map { |link| link[:target_url] }.uniq
144
146
 
145
147
  issues.add(
@@ -301,17 +303,17 @@ module Crawlscope
301
303
  end
302
304
 
303
305
  def validate_inbound_counts(urls, pages, resolved_links, issues)
304
- sitemap_paths = urls.each_with_object({}) do |url, memo|
306
+ sitemap_urls = urls.filter_map do |url|
305
307
  normalized_url = Url.normalize(url, base_url: @base_url)
306
308
  path = Url.path(normalized_url)
307
309
  next if path.nil?
308
310
  next if skip_internal_path?(path)
309
311
 
310
- memo[path] = normalized_url
311
- end
312
- return if sitemap_paths.size < 2
312
+ normalized_url
313
+ end.to_set
314
+ return if sitemap_urls.size < 2
313
315
 
314
- html_paths = pages.each_with_object(Set.new) do |page, result|
316
+ html_urls = pages.each_with_object(Set.new) do |page, result|
315
317
  next unless page.html?
316
318
 
317
319
  [page.normalized_url, page.normalized_final_url].compact.each do |url|
@@ -319,7 +321,7 @@ module Crawlscope
319
321
  next if path.nil?
320
322
  next if skip_internal_path?(path)
321
323
 
322
- result << path
324
+ result << url
323
325
  end
324
326
  end
325
327
 
@@ -331,36 +333,37 @@ module Crawlscope
331
333
  nofollow_sources_by_target = Hash.new { |hash, key| hash[key] = [] }
332
334
 
333
335
  resolved_links.each do |link|
334
- target_path = link[:final_path]
335
- next unless sitemap_paths.key?(target_path)
336
- next if link[:source_path] == target_path
336
+ target_url = link[:final_url]
337
+ next unless sitemap_urls.include?(target_url)
338
+ next if target_for(link[:target_url]).noindex?
339
+ next if link[:source_url] == target_url
337
340
 
338
- inbound_anchor_counts[target_path] += 1
339
- source_samples = sample_sources_by_target[target_path]
341
+ inbound_anchor_counts[target_url] += 1
342
+ source_samples = sample_sources_by_target[target_url]
340
343
  source_samples << link[:source_url] unless source_samples.include?(link[:source_url])
341
344
 
342
345
  if link[:nofollow]
343
- nofollow_inbound_counts[target_path] += 1
344
- nofollow_sources = nofollow_sources_by_target[target_path]
346
+ nofollow_inbound_counts[target_url] += 1
347
+ nofollow_sources = nofollow_sources_by_target[target_url]
345
348
  nofollow_sources << link[:source_url] unless nofollow_sources.include?(link[:source_url])
346
349
  else
347
- dofollow_inbound_counts[target_path] += 1
348
- dofollow_sources = dofollow_sources_by_target[target_path]
350
+ dofollow_inbound_counts[target_url] += 1
351
+ dofollow_sources = dofollow_sources_by_target[target_url]
349
352
  dofollow_sources << link[:source_url] unless dofollow_sources.include?(link[:source_url])
350
353
  end
351
354
  end
352
355
 
353
- sitemap_paths.each do |path, target_url|
354
- next unless html_paths.include?(path)
356
+ sitemap_urls.each do |target_url|
357
+ next unless html_urls.include?(target_url)
355
358
 
356
- inbound_count = inbound_anchor_counts[path]
357
- dofollow_count = dofollow_inbound_counts[path]
358
- nofollow_count = nofollow_inbound_counts[path]
359
+ inbound_count = inbound_anchor_counts[target_url]
360
+ dofollow_count = dofollow_inbound_counts[target_url]
361
+ nofollow_count = nofollow_inbound_counts[target_url]
359
362
 
360
363
  report_orphan_page(target_url, issues) if inbound_count.zero?
361
364
 
362
365
  if inbound_count.positive? && inbound_count < MIN_INBOUND_ANCHOR_LINKS
363
- source_samples = sample_sources_by_target[path]
366
+ source_samples = sample_sources_by_target[target_url]
364
367
 
365
368
  issues.add(
366
369
  code: :low_inbound_anchor_links,
@@ -372,9 +375,9 @@ module Crawlscope
372
375
  )
373
376
  end
374
377
 
375
- report_low_dofollow_inlinks(target_url, path, dofollow_count, dofollow_sources_by_target, issues)
376
- report_only_nofollow_internal_inlinks(target_url, nofollow_count, dofollow_count, nofollow_sources_by_target[path], issues)
377
- report_mixed_follow_internal_inlinks(target_url, nofollow_count, dofollow_count, nofollow_sources_by_target[path], dofollow_sources_by_target[path], issues)
378
+ report_low_dofollow_inlinks(target_url, dofollow_count, dofollow_sources_by_target, issues)
379
+ report_only_nofollow_internal_inlinks(target_url, nofollow_count, dofollow_count, nofollow_sources_by_target[target_url], issues)
380
+ report_mixed_follow_internal_inlinks(target_url, nofollow_count, dofollow_count, nofollow_sources_by_target[target_url], dofollow_sources_by_target[target_url], issues)
378
381
  end
379
382
  end
380
383
 
@@ -475,11 +478,11 @@ module Crawlscope
475
478
  )
476
479
  end
477
480
 
478
- def report_low_dofollow_inlinks(target_url, path, dofollow_count, sources_by_target, issues)
481
+ def report_low_dofollow_inlinks(target_url, dofollow_count, sources_by_target, issues)
479
482
  return if dofollow_count.zero?
480
483
  return if dofollow_count >= MIN_DOFOLLOW_INBOUND_LINKS
481
484
 
482
- source_samples = sources_by_target[path]
485
+ source_samples = sources_by_target[target_url]
483
486
 
484
487
  issues.add(
485
488
  code: :low_dofollow_inlinks,
@@ -529,7 +532,7 @@ module Crawlscope
529
532
  end
530
533
  return if sitemap_pages.size < 2
531
534
 
532
- dofollow_counts_by_path = dofollow_counts_by_final_path(resolved_links)
535
+ dofollow_counts_by_url = dofollow_counts_by_final_url(resolved_links)
533
536
  canonical_entries = canonical_entries_for(sitemap_pages)
534
537
  @resolved_targets_by_url.merge!(resolve_targets(canonical_entries.map { |entry| entry.fetch(:canonical_url) }))
535
538
 
@@ -537,7 +540,7 @@ module Crawlscope
537
540
  page = entry.fetch(:page)
538
541
  canonical_url = entry.fetch(:canonical_url)
539
542
  canonical_path = entry.fetch(:canonical_path)
540
- if canonical_path && !root_path?(canonical_path) && dofollow_counts_by_path[canonical_path].zero?
543
+ if canonical_path && !root_path?(canonical_path) && dofollow_counts_by_url[canonical_url].zero?
541
544
  issues.add(
542
545
  code: :canonical_no_internal_inlinks,
543
546
  severity: :warning,
@@ -570,12 +573,13 @@ module Crawlscope
570
573
  end
571
574
  end
572
575
 
573
- def dofollow_counts_by_final_path(resolved_links)
576
+ def dofollow_counts_by_final_url(resolved_links)
574
577
  resolved_links.each_with_object(Hash.new(0)) do |link, counts|
575
578
  next if link[:nofollow]
576
- next if link[:source_path] == link[:final_path]
579
+ next if link[:source_url] == link[:final_url]
580
+ next if target_for(link[:target_url]).noindex?
577
581
 
578
- counts[link[:final_path]] += 1
582
+ counts[link[:final_url]] += 1
579
583
  end
580
584
  end
581
585
 
@@ -18,7 +18,8 @@ module Crawlscope
18
18
  host = uri.host.to_s
19
19
  host = "#{host}:#{uri.port}" if uri.port && uri.port != uri.default_port
20
20
 
21
- "#{uri.scheme}://#{host}#{normalized_path}"
21
+ query = uri.query ? "?#{uri.query}" : ""
22
+ "#{uri.scheme}://#{host}#{normalized_path}#{query}"
22
23
  rescue URI::InvalidURIError
23
24
  url.to_s
24
25
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Crawlscope
4
- VERSION = "0.9.2"
4
+ VERSION = "0.10.0"
5
5
  end
@@ -42,6 +42,24 @@ class CrawlscopeCrawlTest < Minitest::Test
42
42
  FileUtils.rm_rf(@tmp_dir)
43
43
  end
44
44
 
45
+ def test_fetches_nofollow_query_targets_separately_from_cached_sitemap_pages
46
+ File.write(@sitemap_path, '<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"><url><loc>https://example.com/pricing</loc></url></urlset>')
47
+ stub_request(:get, "https://example.com/pricing").to_return(
48
+ headers: {"Content-Type" => "text/html"}, body: '<a href="/pricing?sample=cafe#demo" rel="nofollow">Demo</a>'
49
+ )
50
+ filtered = stub_request(:get, "https://example.com/pricing?sample=cafe").to_return(
51
+ headers: {"Content-Type" => "text/html", "X-Robots-Tag" => "noindex, follow"}, body: "Demo"
52
+ )
53
+
54
+ result = Crawlscope::Crawl.new(
55
+ base_url: "https://example.com", sitemap_path: @sitemap_path,
56
+ rules: [Crawlscope::Rules::Links.new], schema_registry: Crawlscope::SchemaRegistry.default
57
+ ).call
58
+
59
+ assert_requested filtered
60
+ assert_empty result.issues.to_a
61
+ end
62
+
45
63
  def test_http_renderer_defaults_to_async_executor
46
64
  crawl = Crawlscope::Crawl.new(
47
65
  base_url: "https://example.com",
@@ -3,6 +3,54 @@
3
3
  require "test_helper"
4
4
 
5
5
  class CrawlscopeLinksRuleTest < Minitest::Test
6
+ def test_nofollow_controls_do_not_count_as_links_to_the_indexable_page
7
+ issues = Crawlscope::IssueCollection.new
8
+ requested = []
9
+ resolver = lambda do |url|
10
+ requested << url
11
+ {crawled: false, error: nil, final_url: url, status: 200, html: true,
12
+ doc: Nokogiri::HTML('<meta name="robots" content="noindex,follow">')}
13
+ end
14
+ pages = [
15
+ page(url: "https://example.com/guide", body: '<a href="/pricing">Pricing</a><a href="/pricing?sample=cafe#demo" rel="nofollow">Demo</a><a href="/guide" rel="nofollow">Reset</a>'),
16
+ page(url: "https://example.com/about", body: '<a href="/pricing">Pricing</a>'),
17
+ page(url: "https://example.com/pricing", body: '<a href="/guide">Guide</a><a href="/about">About</a>')
18
+ ]
19
+ Crawlscope::Rules::Links.new.call(urls: pages.map(&:url), pages: pages, issues: issues, context: context(resolver: resolver))
20
+
21
+ assert_includes requested, "https://example.com/pricing?sample=cafe"
22
+ refute_includes issues.map(&:code), :nofollow_internal_outlinks
23
+ refute_includes issues.map(&:code), :mixed_follow_internal_inlinks
24
+ end
25
+
26
+ def test_still_reports_nofollow_query_links_to_indexable_pages
27
+ issues = Crawlscope::IssueCollection.new
28
+ url = "https://example.com/catalog?page=2"
29
+ Crawlscope::Rules::Links.new.call(
30
+ urls: [], pages: [page(url: "https://example.com/guide", body: '<a href="/catalog?page=2" rel="nofollow">Next</a>')],
31
+ issues: issues, context: context(resolver: ->(target) { {crawled: false, final_url: target, status: 200, html: true} })
32
+ )
33
+
34
+ issue = issues.find { |item| item.code == :nofollow_internal_outlinks }
35
+ assert_equal [url], issue.details[:target_urls]
36
+ end
37
+
38
+ def test_query_variants_have_separate_inbound_link_counts
39
+ issues = Crawlscope::IssueCollection.new
40
+ pages = [
41
+ page(url: "https://example.com/guide", body: '<a href="/catalog?page=2">Next</a><a href="/catalog">Catalog</a>'),
42
+ page(url: "https://example.com/catalog", body: '<a href="/guide">Guide</a>'),
43
+ page(url: "https://example.com/catalog?page=2", body: '<a href="/guide">Guide</a>')
44
+ ]
45
+ Crawlscope::Rules::Links.new.call(
46
+ urls: pages.map(&:url), pages: pages, issues: issues,
47
+ context: context(resolver: ->(target) { {crawled: true, final_url: target, status: 200, html: true} })
48
+ )
49
+
50
+ counts = issues.select { |item| item.code == :low_dofollow_inlinks }.to_h { |item| [item.url, item.details[:dofollow_inbound_count]] }
51
+ assert_equal({"https://example.com/catalog" => 1, "https://example.com/catalog?page=2" => 1}, counts)
52
+ end
53
+
6
54
  def test_reports_broken_internal_links
7
55
  issues = Crawlscope::IssueCollection.new
8
56
  rule = Crawlscope::Rules::Links.new
@@ -3,6 +3,10 @@
3
3
  require "test_helper"
4
4
 
5
5
  class CrawlscopeUrlTest < Minitest::Test
6
+ def test_normalize_preserves_query_strings_and_removes_fragments
7
+ assert_equal "https://example.com/reviews?rating=1", Crawlscope::Url.normalize("/reviews/?rating=1#results", base_url: "https://example.com")
8
+ end
9
+
6
10
  def test_normalize_resolves_relative_urls_and_removes_trailing_slash
7
11
  assert_equal "https://example.com/pricing", Crawlscope::Url.normalize("/pricing/", base_url: "https://example.com")
8
12
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: crawlscope
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.9.2
4
+ version: 0.10.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Paulo Fidalgo