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 +4 -4
- data/CHANGELOG.md +16 -0
- data/README.md +1 -1
- data/UPGRADE.md +17 -2
- data/lib/crawlscope/rules/links.rb +37 -33
- data/lib/crawlscope/url.rb +2 -1
- data/lib/crawlscope/version.rb +1 -1
- data/test/crawlscope/crawl_test.rb +18 -0
- data/test/crawlscope/links_rule_test.rb +48 -0
- data/test/crawlscope/url_test.rb +4 -0
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: da58b612eb199f881ec2263858feea4bf93e11355ffce7b19921bb266451818e
|
|
4
|
+
data.tar.gz: bcfa91a8bd74cf5ae670dcc5a1b3e762ad0c6a74037cd82f92b700f8b3a2d7d0
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
[](https://badge.fury.io/rb/crawlscope)
|
|
4
|
-
[](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.
|
|
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(
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
311
|
-
end
|
|
312
|
-
return if
|
|
312
|
+
normalized_url
|
|
313
|
+
end.to_set
|
|
314
|
+
return if sitemap_urls.size < 2
|
|
313
315
|
|
|
314
|
-
|
|
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 <<
|
|
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
|
-
|
|
335
|
-
next unless
|
|
336
|
-
next if link[:
|
|
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[
|
|
339
|
-
source_samples = sample_sources_by_target[
|
|
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[
|
|
344
|
-
nofollow_sources = nofollow_sources_by_target[
|
|
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[
|
|
348
|
-
dofollow_sources = dofollow_sources_by_target[
|
|
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
|
-
|
|
354
|
-
next unless
|
|
356
|
+
sitemap_urls.each do |target_url|
|
|
357
|
+
next unless html_urls.include?(target_url)
|
|
355
358
|
|
|
356
|
-
inbound_count = inbound_anchor_counts[
|
|
357
|
-
dofollow_count = dofollow_inbound_counts[
|
|
358
|
-
nofollow_count = nofollow_inbound_counts[
|
|
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[
|
|
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,
|
|
376
|
-
report_only_nofollow_internal_inlinks(target_url, nofollow_count, dofollow_count, nofollow_sources_by_target[
|
|
377
|
-
report_mixed_follow_internal_inlinks(target_url, nofollow_count, dofollow_count, nofollow_sources_by_target[
|
|
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,
|
|
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[
|
|
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
|
-
|
|
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) &&
|
|
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
|
|
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[:
|
|
579
|
+
next if link[:source_url] == link[:final_url]
|
|
580
|
+
next if target_for(link[:target_url]).noindex?
|
|
577
581
|
|
|
578
|
-
counts[link[:
|
|
582
|
+
counts[link[:final_url]] += 1
|
|
579
583
|
end
|
|
580
584
|
end
|
|
581
585
|
|
data/lib/crawlscope/url.rb
CHANGED
|
@@ -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
|
-
"
|
|
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
|
data/lib/crawlscope/version.rb
CHANGED
|
@@ -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
|
data/test/crawlscope/url_test.rb
CHANGED
|
@@ -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
|