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
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "test_helper"
|
|
4
|
+
|
|
5
|
+
class CrawlscopeFetchExecutorTest < Minitest::Test
|
|
6
|
+
class RecordingExecutor
|
|
7
|
+
attr_reader :items
|
|
8
|
+
|
|
9
|
+
def call(items)
|
|
10
|
+
@items = items
|
|
11
|
+
items.map { |item| yield(item) }
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def test_map_preserves_input_order
|
|
16
|
+
results = Crawlscope::FetchExecutor.map(name: :threaded, concurrency: 2, items: [3, 1, 2]) do |item|
|
|
17
|
+
item * 10
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
assert_equal [30, 10, 20], results
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def test_map_uses_sequential_fallback_for_single_item
|
|
24
|
+
executor = RecordingExecutor.new
|
|
25
|
+
|
|
26
|
+
results = Crawlscope::FetchExecutor.map(name: executor, concurrency: 4, items: ["one"]) do |item|
|
|
27
|
+
item.upcase
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
assert_equal ["ONE"], results
|
|
31
|
+
assert_nil executor.items
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def test_map_uses_injected_executor_for_parallel_work
|
|
35
|
+
executor = RecordingExecutor.new
|
|
36
|
+
|
|
37
|
+
results = Crawlscope::FetchExecutor.map(name: executor, concurrency: 4, items: %w[a b]) do |item|
|
|
38
|
+
item.upcase
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
assert_equal %w[A B], results
|
|
42
|
+
assert_equal %w[a b], executor.items
|
|
43
|
+
end
|
|
44
|
+
end
|
|
@@ -20,6 +20,39 @@ class CrawlscopeIndexabilityRuleTest < Minitest::Test
|
|
|
20
20
|
assert_equal :noindex_meta, issue.code
|
|
21
21
|
assert_equal :error, issue.severity
|
|
22
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
|
|
23
56
|
end
|
|
24
57
|
|
|
25
58
|
def test_reports_x_robots_tag_noindex
|
|
@@ -41,7 +41,7 @@ class CrawlscopeLinksRuleTest < Minitest::Test
|
|
|
41
41
|
context: context
|
|
42
42
|
)
|
|
43
43
|
|
|
44
|
-
|
|
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,226 @@ class CrawlscopeLinksRuleTest < Minitest::Test
|
|
|
114
114
|
context: context
|
|
115
115
|
)
|
|
116
116
|
|
|
117
|
-
|
|
118
|
-
|
|
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_does_not_report_missing_inlinks_for_root_canonical
|
|
222
|
+
issues = Crawlscope::IssueCollection.new
|
|
223
|
+
resolver = lambda do |target_url|
|
|
224
|
+
{crawled: true, error: nil, final_url: target_url, html: true, status: 200}
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
Crawlscope::Rules::Links.new.call(
|
|
228
|
+
urls: ["https://example.com/", "https://example.com/about"],
|
|
229
|
+
pages: [
|
|
230
|
+
page(
|
|
231
|
+
url: "https://example.com/",
|
|
232
|
+
body: <<~HTML
|
|
233
|
+
<html>
|
|
234
|
+
<head><link rel="canonical" href="https://example.com/"></head>
|
|
235
|
+
<body><main><a href="/about">About</a></main></body>
|
|
236
|
+
</html>
|
|
237
|
+
HTML
|
|
238
|
+
),
|
|
239
|
+
page(url: "https://example.com/about", body: "<main><a href=\"/\">Home</a></main>")
|
|
240
|
+
],
|
|
241
|
+
issues: issues,
|
|
242
|
+
context: context(resolver: resolver)
|
|
243
|
+
)
|
|
244
|
+
|
|
245
|
+
refute_includes issues.to_a.map(&:code), :canonical_no_internal_inlinks
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
def test_reports_indexable_internal_pages_missing_from_sitemap
|
|
249
|
+
issues = Crawlscope::IssueCollection.new
|
|
250
|
+
resolver = lambda do |target_url|
|
|
251
|
+
{
|
|
252
|
+
crawled: false,
|
|
253
|
+
error: nil,
|
|
254
|
+
final_url: target_url,
|
|
255
|
+
html: true,
|
|
256
|
+
status: 200
|
|
257
|
+
}
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
Crawlscope::Rules::Links.new.call(
|
|
261
|
+
urls: ["https://example.com/guide"],
|
|
262
|
+
pages: [page(url: "https://example.com/guide", body: "<main><a href=\"/hidden\">Hidden</a></main>")],
|
|
263
|
+
issues: issues,
|
|
264
|
+
context: context(resolver: resolver)
|
|
265
|
+
)
|
|
266
|
+
|
|
267
|
+
issue = issues.to_a.find { |item| item.code == :indexable_page_missing_from_sitemap }
|
|
268
|
+
assert issue
|
|
269
|
+
assert_equal "https://example.com/hidden", issue.url
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
def test_does_not_report_noindex_internal_pages_missing_from_sitemap
|
|
273
|
+
issues = Crawlscope::IssueCollection.new
|
|
274
|
+
resolver = lambda do |target_url|
|
|
275
|
+
{
|
|
276
|
+
crawled: false,
|
|
277
|
+
doc: Nokogiri::HTML("<head><meta name=\"robots\" content=\"noindex, follow\"></head>"),
|
|
278
|
+
error: nil,
|
|
279
|
+
final_url: target_url,
|
|
280
|
+
headers: {},
|
|
281
|
+
html: true,
|
|
282
|
+
status: 200
|
|
283
|
+
}
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
Crawlscope::Rules::Links.new.call(
|
|
287
|
+
urls: ["https://example.com/guide"],
|
|
288
|
+
pages: [page(url: "https://example.com/guide", body: "<main><a href=\"/hidden\">Hidden</a></main>")],
|
|
289
|
+
issues: issues,
|
|
290
|
+
context: context(resolver: resolver)
|
|
291
|
+
)
|
|
292
|
+
|
|
293
|
+
refute_includes issues.to_a.map(&:code), :indexable_page_missing_from_sitemap
|
|
294
|
+
end
|
|
295
|
+
|
|
296
|
+
def test_does_not_report_x_robots_noindex_internal_pages_missing_from_sitemap
|
|
297
|
+
issues = Crawlscope::IssueCollection.new
|
|
298
|
+
resolver = lambda do |target_url|
|
|
299
|
+
{
|
|
300
|
+
crawled: false,
|
|
301
|
+
doc: Nokogiri::HTML("<main>Hidden</main>"),
|
|
302
|
+
error: nil,
|
|
303
|
+
final_url: target_url,
|
|
304
|
+
headers: {"X-Robots-Tag" => "noindex"},
|
|
305
|
+
html: true,
|
|
306
|
+
status: 200
|
|
307
|
+
}
|
|
308
|
+
end
|
|
309
|
+
|
|
310
|
+
Crawlscope::Rules::Links.new.call(
|
|
311
|
+
urls: ["https://example.com/guide"],
|
|
312
|
+
pages: [page(url: "https://example.com/guide", body: "<main><a href=\"/hidden\">Hidden</a></main>")],
|
|
313
|
+
issues: issues,
|
|
314
|
+
context: context(resolver: resolver)
|
|
315
|
+
)
|
|
316
|
+
|
|
317
|
+
refute_includes issues.to_a.map(&:code), :indexable_page_missing_from_sitemap
|
|
318
|
+
end
|
|
319
|
+
|
|
320
|
+
def test_reports_url_hygiene_issues
|
|
321
|
+
issues = Crawlscope::IssueCollection.new
|
|
322
|
+
long_path = "a" * 2_050
|
|
323
|
+
|
|
324
|
+
Crawlscope::Rules::Links.new.call(
|
|
325
|
+
urls: ["https://example.com//bad", "https://example.com/#{long_path}"],
|
|
326
|
+
pages: [
|
|
327
|
+
page(url: "https://example.com//bad", body: "<main><a href=\"/ok\">OK</a></main>"),
|
|
328
|
+
page(url: "https://example.com/#{long_path}", body: "<main><a href=\"/ok\">OK</a></main>")
|
|
329
|
+
],
|
|
330
|
+
issues: issues,
|
|
331
|
+
context: context(resolver: ->(target_url) { {crawled: false, error: nil, final_url: target_url, html: true, status: 200} })
|
|
332
|
+
)
|
|
333
|
+
|
|
334
|
+
codes = issues.to_a.map(&:code)
|
|
335
|
+
assert_includes codes, :url_double_slash
|
|
336
|
+
assert_includes codes, :url_too_long
|
|
119
337
|
end
|
|
120
338
|
|
|
121
339
|
def test_counts_root_page_links_as_inbound_links
|
|
@@ -157,6 +375,32 @@ class CrawlscopeLinksRuleTest < Minitest::Test
|
|
|
157
375
|
assert_includes redirect_issue.message, "https://example.com/pricing"
|
|
158
376
|
end
|
|
159
377
|
|
|
378
|
+
def test_reuses_link_target_resolution_for_later_link_checks
|
|
379
|
+
issues = Crawlscope::IssueCollection.new
|
|
380
|
+
resolution_counts = Hash.new(0)
|
|
381
|
+
resolver = lambda do |target_url|
|
|
382
|
+
resolution_counts[target_url] += 1
|
|
383
|
+
{
|
|
384
|
+
crawled: false,
|
|
385
|
+
doc: Nokogiri::HTML("<main>Hidden</main>"),
|
|
386
|
+
error: nil,
|
|
387
|
+
final_url: target_url,
|
|
388
|
+
headers: {},
|
|
389
|
+
html: true,
|
|
390
|
+
status: 200
|
|
391
|
+
}
|
|
392
|
+
end
|
|
393
|
+
|
|
394
|
+
Crawlscope::Rules::Links.new.call(
|
|
395
|
+
urls: ["https://example.com/guide"],
|
|
396
|
+
pages: [page(url: "https://example.com/guide", body: "<main><a href=\"/hidden\">Hidden</a></main>")],
|
|
397
|
+
issues: issues,
|
|
398
|
+
context: context(resolver: resolver)
|
|
399
|
+
)
|
|
400
|
+
|
|
401
|
+
assert_equal 1, resolution_counts.fetch("https://example.com/hidden")
|
|
402
|
+
end
|
|
403
|
+
|
|
160
404
|
def test_ignores_links_that_should_not_be_crawled
|
|
161
405
|
issues = Crawlscope::IssueCollection.new
|
|
162
406
|
|
|
@@ -217,6 +461,7 @@ class CrawlscopeLinksRuleTest < Minitest::Test
|
|
|
217
461
|
crawled: true,
|
|
218
462
|
error: nil,
|
|
219
463
|
final_url: target_url,
|
|
464
|
+
html: true,
|
|
220
465
|
status: 200
|
|
221
466
|
}
|
|
222
467
|
when "https://example.com/missing"
|
|
@@ -224,6 +469,7 @@ class CrawlscopeLinksRuleTest < Minitest::Test
|
|
|
224
469
|
crawled: false,
|
|
225
470
|
error: nil,
|
|
226
471
|
final_url: target_url,
|
|
472
|
+
html: false,
|
|
227
473
|
status: 404
|
|
228
474
|
}
|
|
229
475
|
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
|
|
@@ -23,11 +23,25 @@ class CrawlscopeReporterTest < Minitest::Test
|
|
|
23
23
|
refute_includes output, "Status: FAILED"
|
|
24
24
|
end
|
|
25
25
|
|
|
26
|
-
def
|
|
26
|
+
def test_reports_warning_result_with_grouped_one_line_issues
|
|
27
27
|
io = StringIO.new
|
|
28
28
|
issues = Crawlscope::IssueCollection.new
|
|
29
|
+
4.times do |index|
|
|
30
|
+
issues.add(
|
|
31
|
+
code: :low_dofollow_inlinks,
|
|
32
|
+
severity: :warning,
|
|
33
|
+
category: :links,
|
|
34
|
+
url: "https://example.com/page-#{index + 1}",
|
|
35
|
+
message: "dofollow inbound links 1 below 2",
|
|
36
|
+
details: {
|
|
37
|
+
dofollow_inbound_count: 1,
|
|
38
|
+
minimum: 2,
|
|
39
|
+
source_urls: ["https://example.com/source-#{index + 1}"]
|
|
40
|
+
}
|
|
41
|
+
)
|
|
42
|
+
end
|
|
29
43
|
issues.add(code: :missing_title, severity: :warning, category: :metadata, url: "https://example.com/a", message: "missing <title>", details: {})
|
|
30
|
-
|
|
44
|
+
|
|
31
45
|
result = Crawlscope::Result.new(
|
|
32
46
|
base_url: "https://example.com",
|
|
33
47
|
sitemap_path: "/tmp/sitemap.xml",
|
|
@@ -40,11 +54,126 @@ class CrawlscopeReporterTest < Minitest::Test
|
|
|
40
54
|
|
|
41
55
|
output = io.string
|
|
42
56
|
|
|
57
|
+
assert_includes output, "Status: WARNINGS"
|
|
58
|
+
refute_includes output, "Status: FAILED"
|
|
59
|
+
assert_includes output, "Issues: 5 total (5 warnings)"
|
|
60
|
+
assert_includes output, "Summary:"
|
|
61
|
+
assert_includes output, "links / low_dofollow_inlinks: 4"
|
|
62
|
+
assert_includes output, " - /page-1 inbound 1/2 sources: /source-1"
|
|
63
|
+
assert_includes output, " - /page-4 inbound 1/2 sources: /source-4"
|
|
64
|
+
assert_includes output, "metadata / missing_title: 1"
|
|
65
|
+
refute_includes output, "Severity:"
|
|
66
|
+
refute_includes output, "Category:"
|
|
67
|
+
refute_includes output, "... 1 more"
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def test_reports_failed_status_when_errors_are_present
|
|
71
|
+
io = StringIO.new
|
|
72
|
+
issues = Crawlscope::IssueCollection.new
|
|
73
|
+
issues.add(code: :fetch_failed, severity: :error, category: :crawl, url: "https://example.com/a", message: "timeout", details: {})
|
|
74
|
+
issues.add(code: :missing_title, severity: :warning, category: :metadata, url: "https://example.com/a", message: "missing <title>", details: {})
|
|
75
|
+
|
|
76
|
+
result = Crawlscope::Result.new(
|
|
77
|
+
base_url: "https://example.com",
|
|
78
|
+
sitemap_path: "/tmp/sitemap.xml",
|
|
79
|
+
urls: ["https://example.com/a"],
|
|
80
|
+
pages: [Object.new],
|
|
81
|
+
issues: issues
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
Crawlscope::Reporter.new(io: io).report(result)
|
|
85
|
+
|
|
86
|
+
output = io.string
|
|
87
|
+
|
|
43
88
|
assert_includes output, "Status: FAILED"
|
|
44
|
-
assert_includes output, "Issues: 2"
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
89
|
+
assert_includes output, "Issues: 2 total (1 error, 1 warning)"
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def test_limits_large_issue_groups
|
|
93
|
+
io = StringIO.new
|
|
94
|
+
issues = Crawlscope::IssueCollection.new
|
|
95
|
+
21.times do |index|
|
|
96
|
+
issues.add(
|
|
97
|
+
code: :low_dofollow_inlinks,
|
|
98
|
+
severity: :warning,
|
|
99
|
+
category: :links,
|
|
100
|
+
url: "https://example.com/page-#{index + 1}",
|
|
101
|
+
message: "dofollow inbound links 1 below 2",
|
|
102
|
+
details: {dofollow_inbound_count: 1, minimum: 2}
|
|
103
|
+
)
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
result = Crawlscope::Result.new(
|
|
107
|
+
base_url: "https://example.com",
|
|
108
|
+
sitemap_path: "/tmp/sitemap.xml",
|
|
109
|
+
urls: ["https://example.com"],
|
|
110
|
+
pages: [Object.new],
|
|
111
|
+
issues: issues
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
Crawlscope::Reporter.new(io: io).report(result)
|
|
115
|
+
|
|
116
|
+
output = io.string
|
|
117
|
+
|
|
118
|
+
assert_includes output, "links / low_dofollow_inlinks: 21"
|
|
119
|
+
assert_includes output, " - /page-20 inbound 1/2"
|
|
120
|
+
refute_includes output, " - /page-21"
|
|
121
|
+
assert_includes output, " ... 1 more"
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def test_reports_ratio_with_enough_precision_to_show_threshold_difference
|
|
125
|
+
io = StringIO.new
|
|
126
|
+
issues = Crawlscope::IssueCollection.new
|
|
127
|
+
issues.add(
|
|
128
|
+
code: :low_unique_token_ratio,
|
|
129
|
+
severity: :warning,
|
|
130
|
+
category: :content_quality,
|
|
131
|
+
url: "https://example.com/a",
|
|
132
|
+
message: "visible text has low token variety",
|
|
133
|
+
details: {ratio: 0.249, threshold: 0.25}
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
result = Crawlscope::Result.new(
|
|
137
|
+
base_url: "https://example.com",
|
|
138
|
+
sitemap_path: "/tmp/sitemap.xml",
|
|
139
|
+
urls: ["https://example.com/a"],
|
|
140
|
+
pages: [Object.new],
|
|
141
|
+
issues: issues
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
Crawlscope::Reporter.new(io: io).report(result)
|
|
145
|
+
|
|
146
|
+
assert_includes io.string, "ratio 0.249/0.250"
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def test_reports_source_details_on_one_line
|
|
150
|
+
io = StringIO.new
|
|
151
|
+
issues = Crawlscope::IssueCollection.new
|
|
152
|
+
4.times do |index|
|
|
153
|
+
issues.add(
|
|
154
|
+
code: :indexable_page_missing_from_sitemap,
|
|
155
|
+
severity: :warning,
|
|
156
|
+
category: :sitemaps,
|
|
157
|
+
url: "https://example.com/overview-#{index + 1}",
|
|
158
|
+
message: "indexable internal page is missing from sitemap",
|
|
159
|
+
details: {source_url: "https://example.com/source-#{index + 1}"}
|
|
160
|
+
)
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
result = Crawlscope::Result.new(
|
|
164
|
+
base_url: "https://example.com",
|
|
165
|
+
sitemap_path: "/tmp/sitemap.xml",
|
|
166
|
+
urls: ["https://example.com"],
|
|
167
|
+
pages: [Object.new],
|
|
168
|
+
issues: issues
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
Crawlscope::Reporter.new(io: io).report(result)
|
|
172
|
+
|
|
173
|
+
output = io.string
|
|
174
|
+
|
|
175
|
+
assert_includes output, "sitemaps / indexable_page_missing_from_sitemap: 4"
|
|
176
|
+
assert_includes output, " - /overview-1 indexable internal page is missing from sitemap source: /source-1"
|
|
177
|
+
assert_includes output, " - /overview-4 indexable internal page is missing from sitemap source: /source-4"
|
|
49
178
|
end
|
|
50
179
|
end
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "test_helper"
|
|
4
|
+
|
|
5
|
+
class CrawlscopeResultTest < Minitest::Test
|
|
6
|
+
def test_ok_when_result_has_warnings_only
|
|
7
|
+
issues = Crawlscope::IssueCollection.new
|
|
8
|
+
issues.add(code: :missing_title, severity: :warning, category: :metadata, url: "https://example.com", message: "missing <title>", details: {})
|
|
9
|
+
|
|
10
|
+
result = result_with(issues)
|
|
11
|
+
|
|
12
|
+
assert result.ok?
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def test_not_ok_when_result_has_errors
|
|
16
|
+
issues = Crawlscope::IssueCollection.new
|
|
17
|
+
issues.add(code: :fetch_failed, severity: :error, category: :crawl, url: "https://example.com", message: "timeout", details: {})
|
|
18
|
+
|
|
19
|
+
result = result_with(issues)
|
|
20
|
+
|
|
21
|
+
refute result.ok?
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
private
|
|
25
|
+
|
|
26
|
+
def result_with(issues)
|
|
27
|
+
Crawlscope::Result.new(
|
|
28
|
+
base_url: "https://example.com",
|
|
29
|
+
sitemap_path: "/tmp/sitemap.xml",
|
|
30
|
+
urls: ["https://example.com"],
|
|
31
|
+
pages: [Object.new],
|
|
32
|
+
issues: issues
|
|
33
|
+
)
|
|
34
|
+
end
|
|
35
|
+
end
|