crawlscope 0.3.0 → 0.5.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.
@@ -25,6 +25,9 @@ module Crawlscope
25
25
  visited.add(source)
26
26
  document = Nokogiri::XML(read(source))
27
27
  root_name = document.root&.name
28
+ unless %w[sitemapindex urlset].include?(root_name)
29
+ raise ValidationError, "Sitemap #{source} has unexpected root #{root_name.inspect}"
30
+ end
28
31
 
29
32
  if root_name == "sitemapindex"
30
33
  document.xpath("//xmlns:sitemap/xmlns:loc", SITEMAP_NAMESPACE).flat_map do |node|
@@ -40,7 +43,12 @@ module Crawlscope
40
43
 
41
44
  def read(source)
42
45
  if Url.remote?(source)
43
- connection.get(source).body
46
+ response = connection.get(source)
47
+ unless response.status.to_i.between?(200, 299)
48
+ raise ValidationError, "Sitemap #{source} returned HTTP #{response.status}"
49
+ end
50
+
51
+ response.body
44
52
  else
45
53
  File.read(source)
46
54
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Crawlscope
4
- VERSION = "0.3.0"
4
+ VERSION = "0.5.0"
5
5
  end
@@ -1,33 +1,43 @@
1
1
  namespace :crawlscope do
2
- desc "Validate URLs with all default Crawlscope rules. ENV: URL, SITEMAP, RULES, JS=1, TIMEOUT, NETWORK_IDLE_TIMEOUT, CONCURRENCY"
3
- task validate: :environment do
4
- Crawlscope::RakeTasks.validate
2
+ desc "Validate URLs with all default Crawlscope rules. Args: [url,sitemap,rules]. ENV: URL, SITEMAP, RULES, JS=1, TIMEOUT, NETWORK_IDLE_TIMEOUT, CONCURRENCY"
3
+ task :validate, [:url, :sitemap, :rules] => :environment do |_task, args|
4
+ Crawlscope::RakeTasks.validate(url: args[:url], sitemap_path: args[:sitemap], rule_names: args[:rules])
5
5
  end
6
6
 
7
7
  namespace :validate do
8
- desc "Directly validate JSON-LD on one or more URLs. ENV: URL (semicolon-separated), DEBUG=1, JS=1, TIMEOUT, NETWORK_IDLE_TIMEOUT, REPORT_PATH, SUMMARY=1"
9
- task ldjson: :environment do
10
- Crawlscope::RakeTasks.ldjson
8
+ desc "Directly validate JSON-LD on one URL. Args: [url]. ENV: URL (semicolon-separated), DEBUG=1, JS=1, TIMEOUT, NETWORK_IDLE_TIMEOUT, REPORT_PATH, SUMMARY=1"
9
+ task :ldjson, [:url] => :environment do |_task, args|
10
+ Crawlscope::RakeTasks.ldjson(urls: args[:url])
11
11
  end
12
12
 
13
- desc "Validate URLs with the metadata rule. ENV: URL, SITEMAP, JS=1"
14
- task metadata: :environment do
15
- Crawlscope::RakeTasks.validate_rule("metadata")
13
+ desc "Validate URLs with the indexability rule. Args: [url,sitemap]. ENV: URL, SITEMAP, JS=1"
14
+ task :indexability, [:url, :sitemap] => :environment do |_task, args|
15
+ Crawlscope::RakeTasks.validate_rule("indexability", url: args[:url], sitemap_path: args[:sitemap])
16
16
  end
17
17
 
18
- desc "Validate sitemap URLs with the structured_data rule. ENV: URL, SITEMAP, JS=1"
19
- task structured_data: :environment do
20
- Crawlscope::RakeTasks.validate_rule("structured_data")
18
+ desc "Validate URLs with the metadata rule. Args: [url,sitemap]. ENV: URL, SITEMAP, JS=1"
19
+ task :metadata, [:url, :sitemap] => :environment do |_task, args|
20
+ Crawlscope::RakeTasks.validate_rule("metadata", url: args[:url], sitemap_path: args[:sitemap])
21
21
  end
22
22
 
23
- desc "Validate URLs with the uniqueness rule. ENV: URL, SITEMAP, JS=1"
24
- task uniqueness: :environment do
25
- Crawlscope::RakeTasks.validate_rule("uniqueness")
23
+ desc "Validate sitemap URLs with the structured_data rule. Args: [url,sitemap]. ENV: URL, SITEMAP, JS=1"
24
+ task :structured_data, [:url, :sitemap] => :environment do |_task, args|
25
+ Crawlscope::RakeTasks.validate_rule("structured_data", url: args[:url], sitemap_path: args[:sitemap])
26
26
  end
27
27
 
28
- desc "Validate URLs with the links rule. ENV: URL, SITEMAP, JS=1"
29
- task links: :environment do
30
- Crawlscope::RakeTasks.validate_rule("links")
28
+ desc "Validate URLs with the uniqueness rule. Args: [url,sitemap]. ENV: URL, SITEMAP, JS=1"
29
+ task :uniqueness, [:url, :sitemap] => :environment do |_task, args|
30
+ Crawlscope::RakeTasks.validate_rule("uniqueness", url: args[:url], sitemap_path: args[:sitemap])
31
+ end
32
+
33
+ desc "Validate URLs with the content_quality rule. Args: [url,sitemap]. ENV: URL, SITEMAP, JS=1"
34
+ task :content_quality, [:url, :sitemap] => :environment do |_task, args|
35
+ Crawlscope::RakeTasks.validate_rule("content_quality", url: args[:url], sitemap_path: args[:sitemap])
36
+ end
37
+
38
+ desc "Validate URLs with the links rule. Args: [url,sitemap]. ENV: URL, SITEMAP, JS=1"
39
+ task :links, [:url, :sitemap] => :environment do |_task, args|
40
+ Crawlscope::RakeTasks.validate_rule("links", url: args[:url], sitemap_path: args[:sitemap])
31
41
  end
32
42
  end
33
43
  end
@@ -265,6 +265,7 @@ class CrawlscopeCliTest < Minitest::Test
265
265
 
266
266
  assert_equal 1, status
267
267
  assert_includes err.string, "No URLs found in sitemap"
268
+ refute_includes err.string, "Usage:"
268
269
  end
269
270
 
270
271
  private
@@ -20,7 +20,14 @@ class CrawlscopeConfigurationTest < Minitest::Test
20
20
  assert_equal "https://example.com", audit.instance_variable_get(:@base_url)
21
21
  assert_equal "/tmp/sitemap.xml", audit.instance_variable_get(:@sitemap_path)
22
22
  assert_equal 4, audit.instance_variable_get(:@concurrency)
23
- assert_equal %i[metadata structured_data uniqueness links], audit.instance_variable_get(:@rules).map(&:code)
23
+ assert_equal %i[
24
+ indexability
25
+ metadata
26
+ structured_data
27
+ uniqueness
28
+ content_quality
29
+ links
30
+ ], audit.instance_variable_get(:@rules).map(&:code)
24
31
  end
25
32
 
26
33
  def test_audit_raises_without_base_url
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "test_helper"
4
+
5
+ class CrawlscopeContentQualityRuleTest < Minitest::Test
6
+ def test_reports_thin_visible_text_and_low_html_text_ratio
7
+ issues = Crawlscope::IssueCollection.new
8
+ page = page_with(main: "Short page <div>#{"<span></span>" * 500}</div>")
9
+
10
+ Crawlscope::Rules::ContentQuality.new.call(urls: [page.url], pages: [page], issues: issues)
11
+
12
+ codes = issues.to_a.map(&:code)
13
+ assert_includes codes, :thin_visible_text
14
+ assert_includes codes, :low_visible_text_ratio
15
+ end
16
+
17
+ def test_visible_text_ratio_ignores_markup_outside_main_content
18
+ issues = Crawlscope::IssueCollection.new
19
+ page = page_with(
20
+ main: Array.new(260) { |index| "word#{index}" }.join(" "),
21
+ head_markup: "<style>#{"body{}" * 10_000}</style>",
22
+ extra_markup: "<nav>#{"<a href=\"/\">Navigation</a>" * 500}</nav>"
23
+ )
24
+
25
+ Crawlscope::Rules::ContentQuality.new.call(urls: [page.url], pages: [page], issues: issues)
26
+
27
+ refute_includes issues.to_a.map(&:code), :low_visible_text_ratio
28
+ end
29
+
30
+ def test_reports_low_unique_token_ratio_for_repetitive_content
31
+ issues = Crawlscope::IssueCollection.new
32
+ page = page_with(main: ("hotel location service " * 100).strip)
33
+
34
+ Crawlscope::Rules::ContentQuality.new.call(urls: [page.url], pages: [page], issues: issues)
35
+
36
+ issue = issues.to_a.find { |item| item.code == :low_unique_token_ratio }
37
+ assert issue
38
+ assert_operator issue.details[:ratio], :<, issue.details[:threshold]
39
+ end
40
+
41
+ private
42
+
43
+ def page_with(main:, extra_markup: "", head_markup: "")
44
+ body = <<~HTML
45
+ <html>
46
+ <head>
47
+ <title>Content quality</title>
48
+ #{head_markup}
49
+ </head>
50
+ <body>
51
+ #{extra_markup}
52
+ <main>#{main}</main>
53
+ </body>
54
+ </html>
55
+ HTML
56
+
57
+ Crawlscope::Page.new(
58
+ url: "https://example.com/page",
59
+ normalized_url: "https://example.com/page",
60
+ final_url: "https://example.com/page",
61
+ normalized_final_url: "https://example.com/page",
62
+ status: 200,
63
+ headers: {"content-type" => "text/html"},
64
+ body: body,
65
+ doc: Nokogiri::HTML(body)
66
+ )
67
+ end
68
+ end
@@ -45,6 +45,7 @@ class CrawlscopeCrawlTest < Minitest::Test
45
45
  <body>
46
46
  <main>
47
47
  <h1>Pricing</h1>
48
+ <p>#{Array.new(260) { |index| "pricing#{index}" }.join(" ")}</p>
48
49
  </main>
49
50
  </body>
50
51
  </html>
@@ -100,7 +101,15 @@ class CrawlscopeCrawlTest < Minitest::Test
100
101
  ).call
101
102
 
102
103
  refute result.ok?
103
- assert_equal %i[incomplete_open_graph_tags meta_description_too_long missing_canonical missing_h1 missing_structured_data title_repeats_site_name].sort, result.issues.to_a.map(&:code).uniq.sort
104
+ assert_equal %i[
105
+ incomplete_open_graph_tags
106
+ meta_description_too_long
107
+ missing_canonical
108
+ missing_h1
109
+ missing_structured_data
110
+ thin_visible_text
111
+ title_repeats_site_name
112
+ ].sort, result.issues.to_a.map(&:code).uniq.sort
104
113
  end
105
114
 
106
115
  def test_uses_browser_when_renderer_is_browser
@@ -147,6 +156,7 @@ class CrawlscopeCrawlTest < Minitest::Test
147
156
  <body>
148
157
  <main>
149
158
  <h1>Pricing</h1>
159
+ <p>#{Array.new(260) { |index| "pricing#{index}" }.join(" ")}</p>
150
160
  </main>
151
161
  </body>
152
162
  </html>
@@ -178,4 +188,30 @@ class CrawlscopeCrawlTest < Minitest::Test
178
188
  assert_equal ["https://example.com/pricing"], fake_browser.urls
179
189
  assert fake_browser.closed
180
190
  end
191
+
192
+ def test_reports_sitemap_redirect_url
193
+ File.write(
194
+ @sitemap_path,
195
+ <<~XML
196
+ <?xml version="1.0" encoding="UTF-8"?>
197
+ <urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
198
+ <url><loc>https://example.com/old</loc></url>
199
+ </urlset>
200
+ XML
201
+ )
202
+
203
+ stub_request(:get, "https://example.com/old")
204
+ .to_return(status: 301, headers: {"Location" => "https://example.com/new"}, body: "")
205
+ stub_request(:get, "https://example.com/new")
206
+ .to_return(status: 200, headers: {"Content-Type" => "text/html"}, body: "<html><body>Moved</body></html>")
207
+
208
+ result = Crawlscope::Crawl.new(
209
+ base_url: "https://example.com",
210
+ sitemap_path: @sitemap_path,
211
+ rules: [],
212
+ schema_registry: Crawlscope::SchemaRegistry.default
213
+ ).call
214
+
215
+ assert_includes result.issues.to_a.map(&:code), :sitemap_redirect_url
216
+ end
181
217
  end
@@ -0,0 +1,129 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "test_helper"
4
+
5
+ class CrawlscopeIndexabilityRuleTest < Minitest::Test
6
+ def test_reports_meta_noindex
7
+ issues = Crawlscope::IssueCollection.new
8
+ page = page_with(
9
+ body: <<~HTML
10
+ <html>
11
+ <head><meta name="robots" content="noindex, follow"></head>
12
+ <body><main>Visible content</main></body>
13
+ </html>
14
+ HTML
15
+ )
16
+
17
+ Crawlscope::Rules::Indexability.new.call(urls: [page.url], pages: [page], issues: issues)
18
+
19
+ issue = issues.to_a.fetch(0)
20
+ assert_equal :noindex_meta, issue.code
21
+ assert_equal :error, issue.severity
22
+ assert_equal "noindex, follow", issue.details[:content]
23
+
24
+ codes = issues.to_a.map(&:code)
25
+ assert_includes codes, :noindex_follow_meta
26
+ assert_includes codes, :sitemap_noindex_url
27
+ end
28
+
29
+ def test_reports_meta_nofollow
30
+ issues = Crawlscope::IssueCollection.new
31
+ page = page_with(
32
+ body: <<~HTML
33
+ <html>
34
+ <head><meta name="robots" content="nofollow"></head>
35
+ <body><main>Visible content</main></body>
36
+ </html>
37
+ HTML
38
+ )
39
+
40
+ Crawlscope::Rules::Indexability.new.call(urls: [page.url], pages: [page], issues: issues)
41
+
42
+ assert_equal [:nofollow_meta], issues.to_a.map(&:code)
43
+ end
44
+
45
+ def test_reports_noindex_nofollow_header
46
+ issues = Crawlscope::IssueCollection.new
47
+ page = page_with(headers: {"X-Robots-Tag" => "googlebot: noindex, nofollow"})
48
+
49
+ Crawlscope::Rules::Indexability.new.call(urls: [page.url], pages: [page], issues: issues)
50
+
51
+ codes = issues.to_a.map(&:code)
52
+ assert_includes codes, :noindex_header
53
+ assert_includes codes, :nofollow_header
54
+ assert_includes codes, :noindex_nofollow_header
55
+ assert_includes codes, :sitemap_noindex_url
56
+ end
57
+
58
+ def test_reports_x_robots_tag_noindex
59
+ issues = Crawlscope::IssueCollection.new
60
+ page = page_with(headers: {"X-Robots-Tag" => "noindex"})
61
+
62
+ Crawlscope::Rules::Indexability.new.call(urls: [page.url], pages: [page], issues: issues)
63
+
64
+ issue = issues.to_a.fetch(0)
65
+ assert_equal :noindex_header, issue.code
66
+ assert_equal :error, issue.severity
67
+ assert_equal "noindex", issue.details[:content]
68
+ end
69
+
70
+ def test_reports_x_robots_tag_noindex_for_non_html_response
71
+ issues = Crawlscope::IssueCollection.new
72
+ page = page_with(
73
+ body: "%PDF-1.7",
74
+ doc: nil,
75
+ headers: {"content-type" => "application/pdf", "X-Robots-Tag" => "noindex"}
76
+ )
77
+
78
+ Crawlscope::Rules::Indexability.new.call(urls: [page.url], pages: [page], issues: issues)
79
+
80
+ issue = issues.to_a.fetch(0)
81
+ assert_equal :noindex_header, issue.code
82
+ assert_equal :error, issue.severity
83
+ assert_equal "noindex", issue.details[:content]
84
+ end
85
+
86
+ def test_reports_scoped_x_robots_tag_noindex
87
+ issues = Crawlscope::IssueCollection.new
88
+ page = page_with(headers: {"X-Robots-Tag" => "googlebot: noindex, nofollow"})
89
+
90
+ Crawlscope::Rules::Indexability.new.call(urls: [page.url], pages: [page], issues: issues)
91
+
92
+ issue = issues.to_a.fetch(0)
93
+ assert_equal :noindex_header, issue.code
94
+ assert_equal "googlebot: noindex, nofollow", issue.details[:content]
95
+ end
96
+
97
+ def test_reports_x_robots_tag_none
98
+ issues = Crawlscope::IssueCollection.new
99
+ page = page_with(headers: {"X-Robots-Tag" => "none"})
100
+
101
+ Crawlscope::Rules::Indexability.new.call(urls: [page.url], pages: [page], issues: issues)
102
+
103
+ issue = issues.to_a.fetch(0)
104
+ assert_equal :noindex_header, issue.code
105
+ assert_equal "none", issue.details[:content]
106
+ end
107
+
108
+ private
109
+
110
+ def page_with(body: nil, doc: :parse, headers: {"content-type" => "text/html"})
111
+ body ||= <<~HTML
112
+ <html>
113
+ <head><title>Indexable</title></head>
114
+ <body><main>Visible content</main></body>
115
+ </html>
116
+ HTML
117
+
118
+ Crawlscope::Page.new(
119
+ url: "https://example.com/page",
120
+ normalized_url: "https://example.com/page",
121
+ final_url: "https://example.com/page",
122
+ normalized_final_url: "https://example.com/page",
123
+ status: 200,
124
+ headers: headers,
125
+ body: body,
126
+ doc: (doc == :parse) ? Nokogiri::HTML(body) : doc
127
+ )
128
+ end
129
+ end
@@ -41,7 +41,7 @@ class CrawlscopeLinksRuleTest < Minitest::Test
41
41
  context: context
42
42
  )
43
43
 
44
- assert_equal [:broken_internal_link], issues.to_a.map(&:code)
44
+ assert_includes issues.to_a.map(&:code), :broken_internal_link
45
45
  assert_includes issues.to_a.first.message, "HTTP 404"
46
46
  end
47
47
 
@@ -114,8 +114,151 @@ class CrawlscopeLinksRuleTest < Minitest::Test
114
114
  context: context
115
115
  )
116
116
 
117
- assert_equal [:low_inbound_anchor_links], issues.to_a.map(&:code)
118
- assert_equal "https://example.com/guide", issues.to_a.first.url
117
+ orphan_issue = issues.to_a.find { |item| item.code == :orphan_page }
118
+ assert orphan_issue
119
+ assert_includes issues.to_a.map(&:code), :low_dofollow_inlinks
120
+ assert_equal "https://example.com/guide", orphan_issue.url
121
+ end
122
+
123
+ def test_reports_pages_with_no_outgoing_internal_links
124
+ issues = Crawlscope::IssueCollection.new
125
+
126
+ Crawlscope::Rules::Links.new.call(
127
+ urls: ["https://example.com/guide", "https://example.com/pricing"],
128
+ pages: [
129
+ page(url: "https://example.com/guide", body: "<main><a href=\"/pricing\">Pricing</a></main>"),
130
+ page(url: "https://example.com/pricing", body: "<main><p>Pricing</p></main>")
131
+ ],
132
+ issues: issues,
133
+ context: context
134
+ )
135
+
136
+ issue = issues.to_a.find { |item| item.code == :page_has_no_outgoing_links }
137
+ assert issue
138
+ assert_equal "https://example.com/pricing", issue.url
139
+ end
140
+
141
+ def test_reports_nofollow_outlinks_and_inlink_follow_mix
142
+ issues = Crawlscope::IssueCollection.new
143
+
144
+ Crawlscope::Rules::Links.new.call(
145
+ urls: ["https://example.com/guide", "https://example.com/pricing", "https://example.com/about"],
146
+ pages: [
147
+ page(url: "https://example.com/guide", body: "<main><a href=\"/pricing\" rel=\"nofollow\">Pricing</a><a href=\"/about\">About</a></main>"),
148
+ page(url: "https://example.com/about", body: "<main><a href=\"/pricing\">Pricing</a></main>"),
149
+ page(url: "https://example.com/pricing", body: "<main><p>Pricing</p></main>")
150
+ ],
151
+ issues: issues,
152
+ context: context(resolver: ->(target_url) { {crawled: true, error: nil, final_url: target_url, status: 200} })
153
+ )
154
+
155
+ codes = issues.to_a.map(&:code)
156
+ assert_includes codes, :nofollow_internal_outlinks
157
+ assert_includes codes, :mixed_follow_internal_inlinks
158
+ end
159
+
160
+ def test_reports_only_nofollow_internal_inlinks
161
+ issues = Crawlscope::IssueCollection.new
162
+
163
+ Crawlscope::Rules::Links.new.call(
164
+ urls: ["https://example.com/guide", "https://example.com/pricing"],
165
+ pages: [
166
+ page(url: "https://example.com/guide", body: "<main><a href=\"/pricing\" rel=\"nofollow\">Pricing</a></main>"),
167
+ page(url: "https://example.com/pricing", body: "<main><p>Pricing</p></main>")
168
+ ],
169
+ issues: issues,
170
+ context: context(resolver: ->(target_url) { {crawled: true, error: nil, final_url: target_url, status: 200} })
171
+ )
172
+
173
+ assert_includes issues.to_a.map(&:code), :only_nofollow_internal_inlinks
174
+ end
175
+
176
+ def test_reports_https_pages_linking_to_internal_http_urls
177
+ issues = Crawlscope::IssueCollection.new
178
+
179
+ Crawlscope::Rules::Links.new.call(
180
+ urls: ["https://example.com/guide"],
181
+ pages: [page(url: "https://example.com/guide", body: "<main><a href=\"http://example.com/pricing\">Pricing</a></main>")],
182
+ issues: issues,
183
+ context: context(resolver: ->(target_url) { {crawled: true, error: nil, final_url: target_url, status: 200} })
184
+ )
185
+
186
+ assert_includes issues.to_a.map(&:code), :http_internal_link
187
+ end
188
+
189
+ def test_reports_canonical_target_link_issues
190
+ issues = Crawlscope::IssueCollection.new
191
+ resolver = lambda do |target_url|
192
+ redirects = target_url == "https://example.com/canonical-about"
193
+ status = redirects ? 301 : 200
194
+ final_url = redirects ? "https://example.com/about" : target_url
195
+ {crawled: false, error: nil, final_url: final_url, status: status}
196
+ end
197
+
198
+ Crawlscope::Rules::Links.new.call(
199
+ urls: ["https://example.com/guide", "https://example.com/about"],
200
+ pages: [
201
+ page(url: "https://example.com/guide", body: "<main><a href=\"/about\">About</a></main>"),
202
+ page(
203
+ url: "https://example.com/about",
204
+ body: <<~HTML
205
+ <html>
206
+ <head><link rel="canonical" href="https://example.com/canonical-about"></head>
207
+ <body><main><p>About</p></main></body>
208
+ </html>
209
+ HTML
210
+ )
211
+ ],
212
+ issues: issues,
213
+ context: context(resolver: resolver)
214
+ )
215
+
216
+ codes = issues.to_a.map(&:code)
217
+ assert_includes codes, :canonical_no_internal_inlinks
218
+ assert_includes codes, :canonical_points_to_redirect
219
+ end
220
+
221
+ def test_reports_indexable_internal_pages_missing_from_sitemap
222
+ issues = Crawlscope::IssueCollection.new
223
+ resolver = lambda do |target_url|
224
+ {
225
+ crawled: false,
226
+ error: nil,
227
+ final_url: target_url,
228
+ html: true,
229
+ status: 200
230
+ }
231
+ end
232
+
233
+ Crawlscope::Rules::Links.new.call(
234
+ urls: ["https://example.com/guide"],
235
+ pages: [page(url: "https://example.com/guide", body: "<main><a href=\"/hidden\">Hidden</a></main>")],
236
+ issues: issues,
237
+ context: context(resolver: resolver)
238
+ )
239
+
240
+ issue = issues.to_a.find { |item| item.code == :indexable_page_missing_from_sitemap }
241
+ assert issue
242
+ assert_equal "https://example.com/hidden", issue.url
243
+ end
244
+
245
+ def test_reports_url_hygiene_issues
246
+ issues = Crawlscope::IssueCollection.new
247
+ long_path = "a" * 2_050
248
+
249
+ Crawlscope::Rules::Links.new.call(
250
+ urls: ["https://example.com//bad", "https://example.com/#{long_path}"],
251
+ pages: [
252
+ page(url: "https://example.com//bad", body: "<main><a href=\"/ok\">OK</a></main>"),
253
+ page(url: "https://example.com/#{long_path}", body: "<main><a href=\"/ok\">OK</a></main>")
254
+ ],
255
+ issues: issues,
256
+ context: context(resolver: ->(target_url) { {crawled: false, error: nil, final_url: target_url, html: true, status: 200} })
257
+ )
258
+
259
+ codes = issues.to_a.map(&:code)
260
+ assert_includes codes, :url_double_slash
261
+ assert_includes codes, :url_too_long
119
262
  end
120
263
 
121
264
  def test_counts_root_page_links_as_inbound_links
@@ -217,6 +360,7 @@ class CrawlscopeLinksRuleTest < Minitest::Test
217
360
  crawled: true,
218
361
  error: nil,
219
362
  final_url: target_url,
363
+ html: true,
220
364
  status: 200
221
365
  }
222
366
  when "https://example.com/missing"
@@ -224,6 +368,7 @@ class CrawlscopeLinksRuleTest < Minitest::Test
224
368
  crawled: false,
225
369
  error: nil,
226
370
  final_url: target_url,
371
+ html: false,
227
372
  status: 404
228
373
  }
229
374
  end
@@ -48,6 +48,42 @@ class CrawlscopeMetadataRuleTest < Minitest::Test
48
48
  refute_includes issues.to_a.map(&:code), :canonical_mismatch
49
49
  end
50
50
 
51
+ def test_reports_multiple_title_multiple_descriptions_empty_h1_and_sitemap_canonical_mismatch
52
+ issues = Crawlscope::IssueCollection.new
53
+ invalid_page = page(
54
+ body: <<~HTML
55
+ <html>
56
+ <head>
57
+ <title>About</title>
58
+ <title>Duplicate About</title>
59
+ <meta name="description" content="A clear description that is long enough for search snippets, local validation checks, and realistic production metadata audits.">
60
+ <meta name="description" content="Duplicate description">
61
+ <link rel="canonical" href="https://example.com/canonical-about">
62
+ <meta property="og:title" content="About">
63
+ <meta property="og:description" content="About page">
64
+ <meta property="og:url" content="https://example.com/about">
65
+ <meta property="og:type" content="website">
66
+ <meta property="og:image" content="https://example.com/icon.png">
67
+ </head>
68
+ <body><main><h1> </h1></main></body>
69
+ </html>
70
+ HTML
71
+ )
72
+
73
+ Crawlscope::Rules::Metadata.new.call(
74
+ urls: [invalid_page.url],
75
+ pages: [invalid_page],
76
+ issues: issues
77
+ )
78
+
79
+ codes = issues.to_a.map(&:code)
80
+ assert_includes codes, :multiple_title_tags
81
+ assert_includes codes, :multiple_meta_descriptions
82
+ assert_includes codes, :empty_h1
83
+ assert_includes codes, :canonical_mismatch
84
+ assert_includes codes, :non_canonical_page_in_sitemap
85
+ end
86
+
51
87
  private
52
88
 
53
89
  def page(url: "https://example.com/about", body: nil)
@@ -0,0 +1,70 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "test_helper"
4
+
5
+ class CrawlscopeRakeTasksTest < Minitest::Test
6
+ def setup
7
+ @original_start = Crawlscope::Cli.method(:start)
8
+ end
9
+
10
+ def teardown
11
+ singleton_class = class << Crawlscope::Cli; self; end
12
+ original_start = @original_start
13
+ singleton_class.define_method(:start) do |*args, **kwargs|
14
+ original_start.call(*args, **kwargs)
15
+ end
16
+ end
17
+
18
+ def test_validate_passes_rake_arguments_to_cli
19
+ calls = capture_cli_calls
20
+
21
+ Crawlscope::RakeTasks.validate(
22
+ url: "http://localhost:3001",
23
+ sitemap_path: "http://localhost:3001/sitemap.xml",
24
+ rule_names: "metadata,links"
25
+ )
26
+
27
+ assert_equal(
28
+ ["validate", "--url", "http://localhost:3001", "--sitemap", "http://localhost:3001/sitemap.xml", "--rules", "metadata,links"],
29
+ calls.fetch(0).fetch(:argv)
30
+ )
31
+ end
32
+
33
+ def test_validate_rule_passes_rule_and_rake_arguments_to_cli
34
+ calls = capture_cli_calls
35
+
36
+ Crawlscope::RakeTasks.validate_rule(
37
+ "metadata",
38
+ url: "http://localhost:3001",
39
+ sitemap_path: "http://localhost:3001/sitemap.xml"
40
+ )
41
+
42
+ assert_equal(
43
+ ["validate", "--url", "http://localhost:3001", "--sitemap", "http://localhost:3001/sitemap.xml", "--rules", "metadata"],
44
+ calls.fetch(0).fetch(:argv)
45
+ )
46
+ end
47
+
48
+ def test_ldjson_passes_rake_url_argument_to_cli
49
+ calls = capture_cli_calls
50
+
51
+ Crawlscope::RakeTasks.ldjson(urls: "http://localhost:3001/article")
52
+
53
+ assert_equal(
54
+ ["ldjson", "--url", "http://localhost:3001/article"],
55
+ calls.fetch(0).fetch(:argv)
56
+ )
57
+ end
58
+
59
+ private
60
+
61
+ def capture_cli_calls
62
+ calls = []
63
+ singleton_class = class << Crawlscope::Cli; self; end
64
+ singleton_class.define_method(:start) do |argv, **kwargs|
65
+ calls << {argv: argv, kwargs: kwargs}
66
+ 0
67
+ end
68
+ calls
69
+ end
70
+ end