crawlscope 0.7.2 → 0.8.1
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 +27 -0
- data/README.md +95 -18
- data/lib/crawlscope/browser.rb +18 -1
- data/lib/crawlscope/configuration.rb +6 -1
- data/lib/crawlscope/crawl.rb +5 -3
- data/lib/crawlscope/http.rb +13 -2
- data/lib/crawlscope/page.rb +8 -0
- data/lib/crawlscope/reporter.rb +144 -61
- data/lib/crawlscope/request_headers.rb +34 -0
- data/lib/crawlscope/result.rb +4 -0
- data/lib/crawlscope/rules/content_quality.rb +5 -5
- data/lib/crawlscope/rules/indexability.rb +1 -1
- data/lib/crawlscope/rules/links.rb +28 -34
- data/lib/crawlscope/rules/metadata.rb +25 -25
- data/lib/crawlscope/rules/structured_data.rb +13 -4
- data/lib/crawlscope/rules/uniqueness.rb +9 -7
- data/lib/crawlscope/run.rb +0 -10
- data/lib/crawlscope/schema_registry.rb +17 -1
- data/lib/crawlscope/server_timing/reporter.rb +114 -0
- data/lib/crawlscope/server_timing/summary.rb +107 -0
- data/lib/crawlscope/server_timing.rb +138 -0
- data/lib/crawlscope/sitemap.rb +17 -5
- data/lib/crawlscope/structured_data/error_formatter.rb +42 -0
- data/lib/crawlscope/structured_data/reporter.rb +10 -10
- data/lib/crawlscope/version.rb +1 -1
- data/lib/generators/crawlscope/templates/initializer.rb.tt +1 -1
- data/test/crawlscope/browser_test.rb +56 -3
- data/test/crawlscope/configuration_test.rb +50 -10
- data/test/crawlscope/crawl_test.rb +65 -0
- data/test/crawlscope/http_test.rb +60 -1
- data/test/crawlscope/install_generator_test.rb +9 -11
- data/test/crawlscope/links_rule_test.rb +40 -4
- data/test/crawlscope/metadata_rule_test.rb +37 -4
- data/test/crawlscope/reporter_test.rb +249 -20
- data/test/crawlscope/run_test.rb +4 -12
- data/test/crawlscope/schema_registry_test.rb +33 -0
- data/test/crawlscope/server_timing_summary_test.rb +87 -0
- data/test/crawlscope/server_timing_test.rb +101 -0
- data/test/crawlscope/sitemap_test.rb +21 -0
- data/test/crawlscope/structured_data_error_formatter_test.rb +46 -0
- data/test/crawlscope/structured_data_reporter_test.rb +26 -0
- data/test/crawlscope/structured_data_rule_test.rb +2 -0
- data/test/crawlscope/uniqueness_rule_test.rb +46 -3
- metadata +10 -2
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "uri"
|
|
4
|
+
|
|
5
|
+
module Crawlscope
|
|
6
|
+
class ServerTiming
|
|
7
|
+
class Reporter
|
|
8
|
+
SLOW_PAGE_THRESHOLD_MS = 50
|
|
9
|
+
|
|
10
|
+
def initialize(io:)
|
|
11
|
+
@io = io
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def report(summary, base_url:)
|
|
15
|
+
return unless summary.present?
|
|
16
|
+
|
|
17
|
+
@io.puts("")
|
|
18
|
+
@io.puts("Server Timing:")
|
|
19
|
+
@io.puts(
|
|
20
|
+
" Coverage: #{summary.coverage}/#{summary.pages.size} pages " \
|
|
21
|
+
"(#{percentage(summary.coverage, summary.pages.size)}); " \
|
|
22
|
+
"durations on #{summary.duration_pages} #{pluralize(:page, summary.duration_pages)}"
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
report_metrics(summary.metrics)
|
|
26
|
+
report_signals(summary.signals)
|
|
27
|
+
report_worst_pages(summary.worst_pages(limit: summary.pages.size), base_url: base_url)
|
|
28
|
+
@io.puts(" Ignored malformed entries: #{summary.invalid_count}") if summary.invalid_count.positive?
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
private
|
|
32
|
+
|
|
33
|
+
def report_metrics(metrics)
|
|
34
|
+
return if metrics.empty?
|
|
35
|
+
|
|
36
|
+
@io.puts(" Response times (ms):")
|
|
37
|
+
metrics.each do |metric|
|
|
38
|
+
label = metric[:name]
|
|
39
|
+
label += " #{description(metric[:description])}" if metric[:description]
|
|
40
|
+
|
|
41
|
+
@io.puts(
|
|
42
|
+
" #{label}: " \
|
|
43
|
+
"#{metric[:samples]} #{pluralize(:sample, metric[:samples])} / " \
|
|
44
|
+
"#{metric[:pages]} #{pluralize(:page, metric[:pages])}; " \
|
|
45
|
+
"avg #{duration(metric[:average])}; " \
|
|
46
|
+
"p50 #{duration(metric[:p50])}; " \
|
|
47
|
+
"p95 #{duration(metric[:p95])}; " \
|
|
48
|
+
"max #{duration(metric[:maximum])}"
|
|
49
|
+
)
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def report_signals(signals)
|
|
54
|
+
return if signals.empty?
|
|
55
|
+
|
|
56
|
+
@io.puts(" Signals:")
|
|
57
|
+
signals.each do |signal|
|
|
58
|
+
label = signal[:name]
|
|
59
|
+
label += " #{description(signal[:description])}" if signal[:description]
|
|
60
|
+
|
|
61
|
+
@io.puts(
|
|
62
|
+
" #{label}: " \
|
|
63
|
+
"#{signal[:samples]} #{pluralize(:sample, signal[:samples])} / " \
|
|
64
|
+
"#{signal[:pages]} #{pluralize(:page, signal[:pages])}"
|
|
65
|
+
)
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def report_worst_pages(samples, base_url:)
|
|
70
|
+
samples = samples.select { |sample| sample[:metric].duration > SLOW_PAGE_THRESHOLD_MS }
|
|
71
|
+
return if samples.empty?
|
|
72
|
+
|
|
73
|
+
@io.puts(" Pages above #{SLOW_PAGE_THRESHOLD_MS} ms:")
|
|
74
|
+
samples.each do |sample|
|
|
75
|
+
metric = sample[:metric]
|
|
76
|
+
detail = metric.description ? " #{description(metric.description)}" : ""
|
|
77
|
+
@io.puts(
|
|
78
|
+
" #{relative_url(sample[:page].url, base_url: base_url)}: " \
|
|
79
|
+
"#{duration(metric.duration)} (#{metric.name}#{detail})"
|
|
80
|
+
)
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def duration(value)
|
|
85
|
+
"#{format("%.3f", value).sub(/\.?0+\z/, "")}ms"
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def description(value)
|
|
89
|
+
value = value.to_s
|
|
90
|
+
value.inspect
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def percentage(numerator, denominator)
|
|
94
|
+
return "0.0%" if denominator.zero?
|
|
95
|
+
|
|
96
|
+
format("%.1f%%", numerator.fdiv(denominator) * 100)
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def relative_url(url, base_url:)
|
|
100
|
+
uri = URI.parse(url)
|
|
101
|
+
base_uri = URI.parse(base_url)
|
|
102
|
+
return url unless uri.host == base_uri.host && uri.scheme == base_uri.scheme && uri.port == base_uri.port
|
|
103
|
+
|
|
104
|
+
uri.request_uri
|
|
105
|
+
rescue URI::InvalidURIError
|
|
106
|
+
url
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def pluralize(word, count)
|
|
110
|
+
(count == 1) ? word.to_s : "#{word}s"
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Crawlscope
|
|
4
|
+
class ServerTiming
|
|
5
|
+
class Summary
|
|
6
|
+
WORST_PAGE_LIMIT = 10
|
|
7
|
+
|
|
8
|
+
attr_reader :pages
|
|
9
|
+
|
|
10
|
+
def initialize(pages)
|
|
11
|
+
@pages = pages
|
|
12
|
+
@timings = pages.filter_map do |page|
|
|
13
|
+
timing = page.server_timing
|
|
14
|
+
[page, timing] if timing.present?
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def present?
|
|
19
|
+
@timings.any?
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def duration_pages
|
|
23
|
+
@timings.count { |_page, timing| timing.any?(&:timed?) }
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def invalid_count
|
|
27
|
+
@timings.sum { |_page, timing| timing.invalid_count }
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def metrics
|
|
31
|
+
timed_metrics
|
|
32
|
+
.group_by { |_page, metric| [metric.name, metric.description] }
|
|
33
|
+
.map { |(name, description), samples| metric_summary(name, description, samples) }
|
|
34
|
+
.sort_by { |metric| [-metric[:p95], -metric[:maximum], metric[:name]] }
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def signals
|
|
38
|
+
all_metrics
|
|
39
|
+
.reject { |_page, metric| metric.timed? }
|
|
40
|
+
.group_by { |_page, metric| [metric.name, metric.description] }
|
|
41
|
+
.map { |(name, description), samples| signal_summary(name, description, samples) }
|
|
42
|
+
.sort_by { |signal| [-signal[:samples], signal[:name], signal[:description].to_s] }
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def worst_pages(limit: WORST_PAGE_LIMIT)
|
|
46
|
+
@timings
|
|
47
|
+
.filter_map { |page, timing| worst_metric(page, timing) }
|
|
48
|
+
.min_by(limit) { |sample| [-sample[:metric].duration, sample[:page].url] }
|
|
49
|
+
.sort_by { |sample| [-sample[:metric].duration, sample[:page].url] }
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def coverage
|
|
53
|
+
@timings.size
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
private
|
|
57
|
+
|
|
58
|
+
def timed_metrics
|
|
59
|
+
all_metrics.select { |_page, metric| metric.timed? }
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def all_metrics
|
|
63
|
+
@all_metrics ||= @timings.flat_map do |page, timing|
|
|
64
|
+
timing.map { |metric| [page, metric] }
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def metric_summary(name, description, samples)
|
|
69
|
+
durations = samples.map { |_page, metric| metric.duration }.sort
|
|
70
|
+
|
|
71
|
+
{
|
|
72
|
+
name: name,
|
|
73
|
+
description: description,
|
|
74
|
+
samples: samples.size,
|
|
75
|
+
pages: samples.map(&:first).uniq.size,
|
|
76
|
+
average: durations.sum.fdiv(durations.size),
|
|
77
|
+
p50: percentile(durations, 0.50),
|
|
78
|
+
p95: percentile(durations, 0.95),
|
|
79
|
+
maximum: durations.last
|
|
80
|
+
}
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def signal_summary(name, description, samples)
|
|
84
|
+
{
|
|
85
|
+
name: name,
|
|
86
|
+
description: description,
|
|
87
|
+
samples: samples.size,
|
|
88
|
+
pages: samples.map(&:first).uniq.size
|
|
89
|
+
}
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def percentile(values, percentile)
|
|
93
|
+
position = (values.size - 1) * percentile
|
|
94
|
+
lower = values[position.floor]
|
|
95
|
+
upper = values[position.ceil]
|
|
96
|
+
|
|
97
|
+
lower + (upper - lower) * (position - position.floor)
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def worst_metric(page, timing)
|
|
101
|
+
if (metric = timing.select(&:timed?).max_by(&:duration))
|
|
102
|
+
{page: page, metric: metric}
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Crawlscope
|
|
4
|
+
class ServerTiming
|
|
5
|
+
include Enumerable
|
|
6
|
+
|
|
7
|
+
TOKEN = /\A[!#$%&'*+\-.^_`|~0-9A-Za-z]+\z/
|
|
8
|
+
NUMBER = /\A-?(?:(?:\d+(?:\.\d*)?)|(?:\.\d+))(?:[eE][+-]?\d+)?\z/
|
|
9
|
+
QUOTED_STRING = /\A"(?:[^"\\\x00-\x1F]|\\[\t\x20-\x7E])*"\z/
|
|
10
|
+
EMPTY_LIST_MEMBER = /\A[ \t]*\z/
|
|
11
|
+
|
|
12
|
+
Metric = Data.define(:name, :duration, :description) do
|
|
13
|
+
def timed?
|
|
14
|
+
!duration.nil?
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
attr_reader :invalid_count
|
|
19
|
+
|
|
20
|
+
def initialize(header)
|
|
21
|
+
@header = header
|
|
22
|
+
@invalid_count = 0
|
|
23
|
+
@metrics = parse.freeze
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def each(&block)
|
|
27
|
+
@metrics.each(&block)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def empty?
|
|
31
|
+
@metrics.empty?
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def present?
|
|
35
|
+
!@header.nil?
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def size
|
|
39
|
+
@metrics.size
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
private
|
|
43
|
+
|
|
44
|
+
def parse
|
|
45
|
+
Array(@header).flatten.compact.flat_map do |header|
|
|
46
|
+
metrics_from(header.to_s)
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def metrics_from(header)
|
|
51
|
+
fields, complete = split(header, ",")
|
|
52
|
+
|
|
53
|
+
unless complete
|
|
54
|
+
@invalid_count += 1
|
|
55
|
+
fields.pop
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
fields.filter_map do |field|
|
|
59
|
+
metric_from(field) unless EMPTY_LIST_MEMBER.match?(field)
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def metric_from(field)
|
|
64
|
+
parts, complete = split(field, ";")
|
|
65
|
+
name = parts.shift.to_s.strip
|
|
66
|
+
|
|
67
|
+
if complete && TOKEN.match?(name)
|
|
68
|
+
metric(name, parameters_from(parts))
|
|
69
|
+
else
|
|
70
|
+
@invalid_count += 1
|
|
71
|
+
nil
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def parameters_from(parts)
|
|
76
|
+
parts.each_with_object({}) do |part, parameters|
|
|
77
|
+
name, value = part.strip.split("=", 2)
|
|
78
|
+
next unless name
|
|
79
|
+
|
|
80
|
+
name = name.downcase
|
|
81
|
+
next if parameters.key?(name)
|
|
82
|
+
|
|
83
|
+
parameters[name] = parameter_value(value.to_s.strip)
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def parameter_value(value)
|
|
88
|
+
if TOKEN.match?(value)
|
|
89
|
+
value
|
|
90
|
+
elsif QUOTED_STRING.match?(value)
|
|
91
|
+
value[1...-1].gsub(/\\(.)/m, '\1')
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def metric(name, parameters)
|
|
96
|
+
duration = duration_from(parameters["dur"])
|
|
97
|
+
|
|
98
|
+
if (parameters.key?("dur") && duration.nil?) ||
|
|
99
|
+
(parameters.key?("desc") && parameters["desc"].nil?)
|
|
100
|
+
@invalid_count += 1
|
|
101
|
+
nil
|
|
102
|
+
else
|
|
103
|
+
Metric.new(
|
|
104
|
+
name: name,
|
|
105
|
+
duration: duration,
|
|
106
|
+
description: parameters["desc"]
|
|
107
|
+
)
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def duration_from(value)
|
|
112
|
+
if value && NUMBER.match?(value)
|
|
113
|
+
duration = Float(value)
|
|
114
|
+
duration if duration.finite?
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def split(value, delimiter)
|
|
119
|
+
fields, quoted, escaped = [+""], false, false
|
|
120
|
+
|
|
121
|
+
value.each_char do |character|
|
|
122
|
+
if escaped
|
|
123
|
+
escaped = false
|
|
124
|
+
elsif quoted && character == "\\"
|
|
125
|
+
escaped = true
|
|
126
|
+
elsif character == "\""
|
|
127
|
+
quoted = !quoted
|
|
128
|
+
elsif character == delimiter && !quoted
|
|
129
|
+
fields << +""
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
fields.last << character unless character == delimiter && !quoted
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
[fields, !quoted && !escaped]
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
end
|
data/lib/crawlscope/sitemap.rb
CHANGED
|
@@ -9,11 +9,12 @@ module Crawlscope
|
|
|
9
9
|
class Sitemap
|
|
10
10
|
SITEMAP_NAMESPACE = {"xmlns" => "http://www.sitemaps.org/schemas/sitemap/0.9"}.freeze
|
|
11
11
|
|
|
12
|
-
def initialize(path:, adapter: nil, concurrency: Configuration::DEFAULT_CONCURRENCY, fetch_executor: Configuration::DEFAULT_FETCH_EXECUTOR, timeout_seconds: Configuration::DEFAULT_TIMEOUT_SECONDS)
|
|
12
|
+
def initialize(path:, adapter: nil, concurrency: Configuration::DEFAULT_CONCURRENCY, fetch_executor: Configuration::DEFAULT_FETCH_EXECUTOR, profile_token: nil, timeout_seconds: Configuration::DEFAULT_TIMEOUT_SECONDS)
|
|
13
13
|
@path = path
|
|
14
14
|
@adapter = adapter
|
|
15
15
|
@concurrency = concurrency
|
|
16
16
|
@fetch_executor = fetch_executor
|
|
17
|
+
@profile_token = profile_token
|
|
17
18
|
@timeout_seconds = timeout_seconds
|
|
18
19
|
end
|
|
19
20
|
|
|
@@ -34,7 +35,7 @@ module Crawlscope
|
|
|
34
35
|
end
|
|
35
36
|
return [] if already_visited
|
|
36
37
|
|
|
37
|
-
document = Nokogiri::XML(read(source))
|
|
38
|
+
document = Nokogiri::XML(read(source, base_url: base_url))
|
|
38
39
|
root_name = document.root&.name
|
|
39
40
|
unless %w[sitemapindex urlset].include?(root_name)
|
|
40
41
|
raise ValidationError, "Sitemap #{source} has unexpected root #{root_name.inspect}"
|
|
@@ -55,9 +56,16 @@ module Crawlscope
|
|
|
55
56
|
end
|
|
56
57
|
end
|
|
57
58
|
|
|
58
|
-
def read(source)
|
|
59
|
+
def read(source, base_url:)
|
|
59
60
|
if Url.remote?(source)
|
|
60
|
-
response = connection.get(source)
|
|
61
|
+
response = connection.get(source) do |request|
|
|
62
|
+
RequestHeaders.add_profile_token(
|
|
63
|
+
request.headers,
|
|
64
|
+
url: source,
|
|
65
|
+
base_url: base_url,
|
|
66
|
+
profile_token: @profile_token
|
|
67
|
+
)
|
|
68
|
+
end
|
|
61
69
|
unless response.status.to_i.between?(200, 299)
|
|
62
70
|
raise ValidationError, "Sitemap #{source} returned HTTP #{response.status}"
|
|
63
71
|
end
|
|
@@ -92,7 +100,11 @@ module Crawlscope
|
|
|
92
100
|
|
|
93
101
|
def connection
|
|
94
102
|
Faraday.new do |faraday|
|
|
95
|
-
faraday.response
|
|
103
|
+
faraday.response(
|
|
104
|
+
:follow_redirects,
|
|
105
|
+
limit: Http::MAX_REDIRECTS,
|
|
106
|
+
callback: RequestHeaders.method(:strip_profile_token_on_cross_origin_redirect)
|
|
107
|
+
)
|
|
96
108
|
faraday.options.timeout = @timeout_seconds
|
|
97
109
|
faraday.options.open_timeout = @timeout_seconds
|
|
98
110
|
faraday.adapter @adapter if @adapter
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Crawlscope
|
|
4
|
+
module StructuredData
|
|
5
|
+
class ErrorFormatter
|
|
6
|
+
def self.format(error)
|
|
7
|
+
field = (error[:field] || error["field"]).to_s.sub(/\A#\/?/, "")
|
|
8
|
+
type = error[:type] || error["type"]
|
|
9
|
+
issue = error[:issue] || error["issue"] || "Unknown error"
|
|
10
|
+
|
|
11
|
+
if (match = issue.match(/did not contain a required property of '([^']+)'/))
|
|
12
|
+
field = [field, match[1]].reject(&:empty?).join("/")
|
|
13
|
+
message = "is required"
|
|
14
|
+
else
|
|
15
|
+
message = concise_message(issue, error[:length] || error["length"])
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
location = [type, field.empty? ? nil : field].compact.join(".")
|
|
19
|
+
"#{location.empty? ? "$" : location}: #{message}"
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def self.concise_message(issue, length)
|
|
23
|
+
if length
|
|
24
|
+
unit = (length == 1) ? "character" : "characters"
|
|
25
|
+
measured_length = "#{length} #{unit}"
|
|
26
|
+
end
|
|
27
|
+
case issue
|
|
28
|
+
when /was not of a maximum string length of (\d+)/
|
|
29
|
+
length ? "#{measured_length}; maximum #{Regexp.last_match(1)}" : "exceeds #{Regexp.last_match(1)} characters"
|
|
30
|
+
when /was not of a minimum string length of (\d+)/
|
|
31
|
+
length ? "#{measured_length}; minimum #{Regexp.last_match(1)}" : "must have at least #{Regexp.last_match(1)} characters"
|
|
32
|
+
when /of type (\w+) did not match the following type: (\w+)/
|
|
33
|
+
"must be #{Regexp.last_match(2)} (got #{Regexp.last_match(1)})"
|
|
34
|
+
else
|
|
35
|
+
issue.sub(/\AThe property '[^']*' /, "")
|
|
36
|
+
.sub(/ in schema [\h-]{36}(?:#\S*)?\z/, "")
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
private_class_method :concise_message
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
@@ -67,10 +67,9 @@ module Crawlscope
|
|
|
67
67
|
@io.puts("• #{extract_path(entry.url)}")
|
|
68
68
|
|
|
69
69
|
entry.errors.each do |error|
|
|
70
|
+
@io.puts(" #{source_label(error[:source])}:")
|
|
70
71
|
error[:errors].each do |validation_error|
|
|
71
|
-
|
|
72
|
-
issue = validation_error[:issue] || validation_error["issue"] || "Unknown error"
|
|
73
|
-
@io.puts(" - #{field}: #{issue}")
|
|
72
|
+
@io.puts(" - #{ErrorFormatter.format(validation_error)}")
|
|
74
73
|
end
|
|
75
74
|
end
|
|
76
75
|
end
|
|
@@ -83,9 +82,7 @@ module Crawlscope
|
|
|
83
82
|
end
|
|
84
83
|
|
|
85
84
|
def report_entry(entry, debug:)
|
|
86
|
-
@io.puts("=" * 80)
|
|
87
85
|
@io.puts("URL: #{entry.url}")
|
|
88
|
-
@io.puts("=" * 80)
|
|
89
86
|
|
|
90
87
|
if entry.fetch_error
|
|
91
88
|
@io.puts("Error: #{entry.fetch_error}")
|
|
@@ -124,19 +121,22 @@ module Crawlscope
|
|
|
124
121
|
|
|
125
122
|
def report_validation(entry)
|
|
126
123
|
@io.puts("")
|
|
127
|
-
@io.puts("Validation results:")
|
|
128
|
-
|
|
129
124
|
if entry.errors.empty?
|
|
130
|
-
@io.puts("
|
|
125
|
+
@io.puts("Validation: OK")
|
|
131
126
|
else
|
|
127
|
+
@io.puts("Validation: FAILED")
|
|
132
128
|
entry.errors.each do |error|
|
|
133
|
-
@io.puts(" #{error[:
|
|
129
|
+
@io.puts(" #{source_label(error[:source])}:")
|
|
134
130
|
error[:errors].each do |validation_error|
|
|
135
|
-
@io.puts(" -
|
|
131
|
+
@io.puts(" - #{ErrorFormatter.format(validation_error)}")
|
|
136
132
|
end
|
|
137
133
|
end
|
|
138
134
|
end
|
|
139
135
|
end
|
|
136
|
+
|
|
137
|
+
def source_label(source)
|
|
138
|
+
(source == "json-ld") ? "JSON-LD" : source
|
|
139
|
+
end
|
|
140
140
|
end
|
|
141
141
|
end
|
|
142
142
|
end
|
data/lib/crawlscope/version.rb
CHANGED
|
@@ -14,7 +14,7 @@ module CrawlscopeConfiguration
|
|
|
14
14
|
Crawlscope.configure do |config|
|
|
15
15
|
config.base_url = -> { ENV.fetch("CRAWLSCOPE_BASE_URL", "http://localhost:3000") }
|
|
16
16
|
config.sitemap_path = lambda {
|
|
17
|
-
ENV.fetch("SITEMAP",
|
|
17
|
+
ENV.fetch("SITEMAP", "#{config.base_url.to_s.chomp("/")}/sitemap.xml")
|
|
18
18
|
}
|
|
19
19
|
config.site_name = ENV.fetch("CRAWLSCOPE_SITE_NAME", "Application")
|
|
20
20
|
end
|
|
@@ -14,13 +14,14 @@ class CrawlscopeBrowserTest < Minitest::Test
|
|
|
14
14
|
end
|
|
15
15
|
|
|
16
16
|
class FakeNetwork
|
|
17
|
-
attr_reader :cleared, :idle_waits, :status
|
|
17
|
+
attr_reader :cleared, :idle_waits, :interceptions, :status
|
|
18
18
|
|
|
19
19
|
def initialize(response:, status: 200)
|
|
20
20
|
@response = response
|
|
21
21
|
@status = status
|
|
22
22
|
@cleared = []
|
|
23
23
|
@idle_waits = []
|
|
24
|
+
@interceptions = []
|
|
24
25
|
end
|
|
25
26
|
|
|
26
27
|
def clear(scope)
|
|
@@ -29,6 +30,10 @@ class CrawlscopeBrowserTest < Minitest::Test
|
|
|
29
30
|
|
|
30
31
|
attr_reader :response
|
|
31
32
|
|
|
33
|
+
def intercept(**options)
|
|
34
|
+
@interceptions << options
|
|
35
|
+
end
|
|
36
|
+
|
|
32
37
|
def wait_for_idle(duration:, timeout:)
|
|
33
38
|
@idle_waits << {duration: duration, timeout: timeout}
|
|
34
39
|
end
|
|
@@ -43,6 +48,7 @@ class CrawlscopeBrowserTest < Minitest::Test
|
|
|
43
48
|
@current_url = current_url
|
|
44
49
|
@url = url
|
|
45
50
|
@evaluations = []
|
|
51
|
+
@callbacks = {}
|
|
46
52
|
end
|
|
47
53
|
|
|
48
54
|
attr_reader :body
|
|
@@ -57,11 +63,40 @@ class CrawlscopeBrowserTest < Minitest::Test
|
|
|
57
63
|
@visited_url = url
|
|
58
64
|
end
|
|
59
65
|
|
|
66
|
+
def on(event, &callback)
|
|
67
|
+
@callbacks[event] = callback
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def publish(event, value)
|
|
71
|
+
@callbacks.fetch(event).call(value)
|
|
72
|
+
end
|
|
73
|
+
|
|
60
74
|
attr_reader :url
|
|
61
75
|
end
|
|
62
76
|
|
|
77
|
+
class FakeRequest
|
|
78
|
+
attr_reader :continued_headers, :headers, :url
|
|
79
|
+
|
|
80
|
+
def initialize(url:, headers: {})
|
|
81
|
+
@url = url
|
|
82
|
+
@headers = headers
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def continue(headers:)
|
|
86
|
+
@continued_headers = headers.to_h { |header| [header.fetch(:name), header.fetch(:value)] }
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
63
90
|
def test_fetch_returns_rendered_page
|
|
64
|
-
network = FakeNetwork.new(
|
|
91
|
+
network = FakeNetwork.new(
|
|
92
|
+
response: Response.new(
|
|
93
|
+
url: "https://example.com/final",
|
|
94
|
+
headers: {
|
|
95
|
+
"content-type" => "text/html",
|
|
96
|
+
"server-timing" => "app;dur=47.2"
|
|
97
|
+
}
|
|
98
|
+
)
|
|
99
|
+
)
|
|
65
100
|
page = FakePage.new(network: network, body: "<html><body>Hello</body></html>")
|
|
66
101
|
browser = browser_with(page: page, scroll_page: false)
|
|
67
102
|
|
|
@@ -73,6 +108,7 @@ class CrawlscopeBrowserTest < Minitest::Test
|
|
|
73
108
|
assert_equal "https://example.com/final", result.normalized_final_url
|
|
74
109
|
assert_equal 200, result.status
|
|
75
110
|
assert result.html?
|
|
111
|
+
assert_equal 47.2, result.server_timing.first.duration
|
|
76
112
|
assert_equal [], page.evaluations
|
|
77
113
|
end
|
|
78
114
|
|
|
@@ -88,6 +124,21 @@ class CrawlscopeBrowserTest < Minitest::Test
|
|
|
88
124
|
assert_equal 4, network.idle_waits.size
|
|
89
125
|
end
|
|
90
126
|
|
|
127
|
+
def test_profile_token_is_limited_to_same_origin_documents
|
|
128
|
+
network = FakeNetwork.new(response: nil)
|
|
129
|
+
page = FakePage.new(network: network)
|
|
130
|
+
browser_with(page: page, profile_token: "profile-token")
|
|
131
|
+
same_origin_request = FakeRequest.new(url: "https://example.com/page")
|
|
132
|
+
cross_origin_request = FakeRequest.new(url: "https://cdn.example.net/frame")
|
|
133
|
+
|
|
134
|
+
page.publish(:request, same_origin_request)
|
|
135
|
+
page.publish(:request, cross_origin_request)
|
|
136
|
+
|
|
137
|
+
assert_equal [{resource_type: :document}], network.interceptions
|
|
138
|
+
assert_equal "profile-token", same_origin_request.continued_headers.fetch("X-Profile-Token")
|
|
139
|
+
refute_includes cross_origin_request.continued_headers, "X-Profile-Token"
|
|
140
|
+
end
|
|
141
|
+
|
|
91
142
|
def test_fetch_falls_back_to_page_url_and_original_url
|
|
92
143
|
page_url_network = FakeNetwork.new(response: nil)
|
|
93
144
|
page_url = FakePage.new(network: page_url_network, url: "https://example.com/page")
|
|
@@ -142,14 +193,16 @@ class CrawlscopeBrowserTest < Minitest::Test
|
|
|
142
193
|
|
|
143
194
|
private
|
|
144
195
|
|
|
145
|
-
def browser_with(page: FakePage.new(network: FakeNetwork.new(response: nil)), browser: FakeBrowser.new, scroll_page: false)
|
|
196
|
+
def browser_with(page: FakePage.new(network: FakeNetwork.new(response: nil)), browser: FakeBrowser.new, profile_token: nil, scroll_page: false)
|
|
146
197
|
Crawlscope::Browser.allocate.tap do |instance|
|
|
147
198
|
instance.instance_variable_set(:@base_url, "https://example.com")
|
|
148
199
|
instance.instance_variable_set(:@timeout_seconds, 20)
|
|
149
200
|
instance.instance_variable_set(:@network_idle_timeout_seconds, 5)
|
|
201
|
+
instance.instance_variable_set(:@profile_token, profile_token)
|
|
150
202
|
instance.instance_variable_set(:@scroll_page, scroll_page)
|
|
151
203
|
instance.instance_variable_set(:@browser, browser)
|
|
152
204
|
instance.instance_variable_set(:@page, page)
|
|
205
|
+
instance.send(:configure_profile_requests)
|
|
153
206
|
end
|
|
154
207
|
end
|
|
155
208
|
end
|