url_canonicalize 0.2.1 → 1.0.2
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 +67 -87
- data/README.md +141 -7
- data/SECURITY.md +25 -0
- data/lib/monkey_patches/addressable/uri.rb +2 -2
- data/lib/monkey_patches/string.rb +2 -2
- data/lib/monkey_patches/uri.rb +2 -2
- data/lib/url_canonicalize/bounded_body.rb +23 -0
- data/lib/url_canonicalize/destination.rb +120 -0
- data/lib/url_canonicalize/exception.rb +3 -0
- data/lib/url_canonicalize/http.rb +81 -54
- data/lib/url_canonicalize/link_header.rb +34 -0
- data/lib/url_canonicalize/media_type.rb +23 -0
- data/lib/url_canonicalize/options.rb +79 -0
- data/lib/url_canonicalize/request.rb +81 -70
- data/lib/url_canonicalize/response.rb +9 -6
- data/lib/url_canonicalize/uri.rb +8 -10
- data/lib/url_canonicalize/version.rb +1 -1
- data/lib/url_canonicalize.rb +10 -5
- metadata +15 -20
- data/.codeclimate.yml +0 -25
- data/.gitignore +0 -51
- data/.hound.yml +0 -4
- data/.rspec +0 -5
- data/.rubocop.yml +0 -61
- data/.ruby-gemset +0 -1
- data/.travis.yml +0 -9
- data/Gemfile +0 -22
- data/Gemfile.lock +0 -102
- data/Guardfile +0 -18
- data/Rakefile +0 -3
- data/circle.yml +0 -29
- data/url_canonicalize.gemspec +0 -30
|
@@ -4,7 +4,11 @@ module URLCanonicalize
|
|
|
4
4
|
# Persistent connection for possible repeated requests to the same host
|
|
5
5
|
class HTTP
|
|
6
6
|
def fetch
|
|
7
|
-
|
|
7
|
+
::Timeout.timeout(
|
|
8
|
+
options[:total_timeout],
|
|
9
|
+
URLCanonicalize::Exception::Timeout,
|
|
10
|
+
"Canonicalization exceeded #{options[:total_timeout]} seconds"
|
|
11
|
+
) { fetch_within_deadline }
|
|
8
12
|
end
|
|
9
13
|
|
|
10
14
|
def uri
|
|
@@ -17,15 +21,24 @@ module URLCanonicalize
|
|
|
17
21
|
end
|
|
18
22
|
|
|
19
23
|
def do_request(http_request)
|
|
20
|
-
http.request http_request
|
|
24
|
+
http.request(http_request) { |response| read_response_body(http_request, response) }
|
|
21
25
|
end
|
|
22
26
|
|
|
23
27
|
private
|
|
24
28
|
|
|
25
29
|
attr_accessor :last_known_good
|
|
30
|
+
attr_reader :options
|
|
26
31
|
|
|
27
|
-
def
|
|
32
|
+
def fetch_within_deadline
|
|
33
|
+
loop do
|
|
34
|
+
result = handle_response
|
|
35
|
+
break result if result
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def initialize(raw_url, **options)
|
|
28
40
|
@raw_url = raw_url
|
|
41
|
+
@options = URLCanonicalize::Options.new(**options)
|
|
29
42
|
end
|
|
30
43
|
|
|
31
44
|
# Fetch the response
|
|
@@ -45,7 +58,8 @@ module URLCanonicalize
|
|
|
45
58
|
request.with_uri(uri).fetch
|
|
46
59
|
end
|
|
47
60
|
|
|
48
|
-
# Parse the response, and clear the response ready to follow the next redirect
|
|
61
|
+
# Parse the response, and clear the response ready to follow the next redirect.
|
|
62
|
+
# Returns the final response when canonicalization is complete, or nil to continue
|
|
49
63
|
def handle_response
|
|
50
64
|
result = parse_response
|
|
51
65
|
@response = nil
|
|
@@ -59,7 +73,7 @@ module URLCanonicalize
|
|
|
59
73
|
when URLCanonicalize::Response::Success
|
|
60
74
|
handle_success
|
|
61
75
|
when URLCanonicalize::Response::Redirect
|
|
62
|
-
|
|
76
|
+
handle_redirect
|
|
63
77
|
when URLCanonicalize::Response::CanonicalFound
|
|
64
78
|
handle_canonical_found
|
|
65
79
|
when URLCanonicalize::Response::Failure
|
|
@@ -69,54 +83,49 @@ module URLCanonicalize
|
|
|
69
83
|
end
|
|
70
84
|
end
|
|
71
85
|
|
|
72
|
-
def
|
|
73
|
-
|
|
74
|
-
|
|
86
|
+
def handle_redirect
|
|
87
|
+
reason = hop_refusal_reason
|
|
88
|
+
return follow_hop unless reason
|
|
75
89
|
|
|
76
|
-
|
|
77
|
-
end
|
|
78
|
-
|
|
79
|
-
redirect_list << response_url
|
|
80
|
-
increment_redirects
|
|
81
|
-
set_url_from_response
|
|
82
|
-
false
|
|
90
|
+
last_known_good || raise(URLCanonicalize::Exception::Redirect, reason)
|
|
83
91
|
end
|
|
84
92
|
|
|
85
|
-
def
|
|
86
|
-
|
|
87
|
-
return
|
|
93
|
+
def handle_canonical_found
|
|
94
|
+
self.last_known_good = response.response
|
|
95
|
+
return last_known_good if hop_refusal_reason
|
|
88
96
|
|
|
89
|
-
|
|
97
|
+
follow_hop
|
|
90
98
|
end
|
|
91
99
|
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
100
|
+
# Redirects and followed canonical links share one visited-URL set and one
|
|
101
|
+
# hop budget, so any cycle or over-long chain terminates deterministically
|
|
102
|
+
def hop_refusal_reason
|
|
103
|
+
return 'Redirect loop detected' if visited.include?(response_url)
|
|
104
|
+
return "#{hops + 1} redirects is too many" if hops >= options[:max_redirects]
|
|
95
105
|
|
|
96
|
-
|
|
97
|
-
@redirects = redirects + 1
|
|
106
|
+
nil
|
|
98
107
|
end
|
|
99
108
|
|
|
100
|
-
def
|
|
101
|
-
|
|
109
|
+
def follow_hop
|
|
110
|
+
visited << response_url
|
|
111
|
+
@hops = hops + 1
|
|
112
|
+
self.url = response_url
|
|
113
|
+
nil
|
|
102
114
|
end
|
|
103
115
|
|
|
104
|
-
def
|
|
105
|
-
|
|
106
|
-
return true if response_url == url || redirect_list.include?(response_url)
|
|
107
|
-
|
|
108
|
-
set_url_from_response
|
|
109
|
-
false
|
|
116
|
+
def visited
|
|
117
|
+
@visited ||= [url]
|
|
110
118
|
end
|
|
111
119
|
|
|
112
|
-
def
|
|
113
|
-
|
|
120
|
+
def hops
|
|
121
|
+
@hops ||= 0
|
|
114
122
|
end
|
|
115
123
|
|
|
116
124
|
def handle_failure
|
|
117
|
-
return
|
|
125
|
+
return last_known_good if last_known_good
|
|
118
126
|
|
|
119
|
-
raise URLCanonicalize::Exception::Failure, "#{response.failure_class}: #{response.message}"
|
|
127
|
+
raise URLCanonicalize::Exception::Failure, "#{response.failure_class}: #{response.message}",
|
|
128
|
+
cause: response.error
|
|
120
129
|
end
|
|
121
130
|
|
|
122
131
|
def handle_unhandled_response
|
|
@@ -125,7 +134,6 @@ module URLCanonicalize
|
|
|
125
134
|
|
|
126
135
|
def handle_success
|
|
127
136
|
self.last_known_good = response
|
|
128
|
-
true
|
|
129
137
|
end
|
|
130
138
|
|
|
131
139
|
def url
|
|
@@ -133,46 +141,65 @@ module URLCanonicalize
|
|
|
133
141
|
end
|
|
134
142
|
|
|
135
143
|
def http
|
|
136
|
-
|
|
144
|
+
URLCanonicalize::Destination.validate!(uri, options)
|
|
145
|
+
return @http if same_origin? # reuse connection
|
|
137
146
|
|
|
138
147
|
@previous = uri
|
|
139
148
|
@http = new_http
|
|
140
149
|
end
|
|
141
150
|
|
|
142
|
-
def
|
|
143
|
-
uri.host == previous.host && uri.port == previous.port
|
|
151
|
+
def same_origin?
|
|
152
|
+
uri.scheme == previous.scheme && uri.host == previous.host && uri.port == previous.port
|
|
144
153
|
end
|
|
145
154
|
|
|
146
155
|
def previous
|
|
147
|
-
@previous ||= Struct.new(:host, :port).new
|
|
156
|
+
@previous ||= Struct.new(:scheme, :host, :port).new
|
|
148
157
|
end
|
|
149
158
|
|
|
150
159
|
def new_http
|
|
151
|
-
h = Net::HTTP.new uri.host, uri.port
|
|
152
|
-
|
|
153
|
-
h.open_timeout = options[:open_timeout]
|
|
154
|
-
h.read_timeout = options[:read_timeout]
|
|
160
|
+
h = Net::HTTP.new uri.host, uri.port, nil
|
|
155
161
|
|
|
162
|
+
h.ipaddr = URLCanonicalize::Destination.resolve(uri, options)
|
|
163
|
+
configure_timeouts(h)
|
|
156
164
|
ssl!(h)
|
|
157
165
|
|
|
158
166
|
h
|
|
159
167
|
end
|
|
160
168
|
|
|
169
|
+
def read_response_body(http_request, response)
|
|
170
|
+
if buffer_response_body?(http_request, response)
|
|
171
|
+
enforce_content_length!(response)
|
|
172
|
+
response.read_body(URLCanonicalize::BoundedBody.new(options[:max_body_bytes]))
|
|
173
|
+
else
|
|
174
|
+
response.read_body { |_chunk| nil }
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def buffer_response_body?(http_request, response)
|
|
179
|
+
http_request.method == 'GET' && response.is_a?(Net::HTTPSuccess) && URLCanonicalize::MediaType.buffered?(response)
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
def enforce_content_length!(response)
|
|
183
|
+
return unless response.content_length && response.content_length > options[:max_body_bytes]
|
|
184
|
+
|
|
185
|
+
raise URLCanonicalize::Exception::ResponseTooLarge,
|
|
186
|
+
"Response body exceeds #{options[:max_body_bytes]} bytes"
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
def configure_timeouts(http)
|
|
190
|
+
http.open_timeout = options[:open_timeout]
|
|
191
|
+
http.read_timeout = options[:read_timeout]
|
|
192
|
+
http.write_timeout = options[:write_timeout]
|
|
193
|
+
end
|
|
194
|
+
|
|
161
195
|
def ssl!(http)
|
|
162
196
|
if uri.scheme == 'https'
|
|
163
197
|
http.use_ssl = true # Can generate exception
|
|
164
|
-
http.verify_mode = OpenSSL::SSL::
|
|
198
|
+
http.verify_mode = OpenSSL::SSL::VERIFY_PEER
|
|
199
|
+
http.verify_hostname = true
|
|
165
200
|
else
|
|
166
201
|
http.use_ssl = false
|
|
167
202
|
end
|
|
168
203
|
end
|
|
169
|
-
|
|
170
|
-
def options
|
|
171
|
-
@options ||= {
|
|
172
|
-
open_timeout: 8, # Twitter responds in >5s
|
|
173
|
-
read_timeout: 15,
|
|
174
|
-
max_redirects: 10
|
|
175
|
-
}
|
|
176
|
-
end
|
|
177
204
|
end
|
|
178
205
|
end
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module URLCanonicalize
|
|
4
|
+
# Parses HTTP Link header fields (RFC 8288) far enough to find the target of
|
|
5
|
+
# the first link whose rel tokens include "canonical"
|
|
6
|
+
module LinkHeader
|
|
7
|
+
LINK_VALUE = /<([^>]*)>((?:\s*;\s*[\w!#$%&'*+.^`|~-]+\s*=\s*(?:"[^"]*"|[^",;]+))*)/
|
|
8
|
+
REL_PARAM = /;\s*rel\s*=\s*(?:"([^"]*)"|([^",;\s]+))/i
|
|
9
|
+
CANONICAL = 'canonical'
|
|
10
|
+
|
|
11
|
+
extend self
|
|
12
|
+
|
|
13
|
+
def canonical(response)
|
|
14
|
+
fields = response.get_fields('link')
|
|
15
|
+
return unless fields
|
|
16
|
+
|
|
17
|
+
fields.each do |field|
|
|
18
|
+
field.scan(LINK_VALUE) do |target, params|
|
|
19
|
+
return target if canonical?(params)
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
nil
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def canonical?(params)
|
|
27
|
+
match = REL_PARAM.match(params)
|
|
28
|
+
return false unless match
|
|
29
|
+
|
|
30
|
+
rel = match[1] || match[2]
|
|
31
|
+
rel.split.any? { |token| token.casecmp?(CANONICAL) }
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module URLCanonicalize
|
|
4
|
+
# Normalizes response media types for safe buffering and parsing decisions
|
|
5
|
+
module MediaType
|
|
6
|
+
HTML = %w[text/html application/xhtml+xml].freeze
|
|
7
|
+
BUFFERED = (HTML + %w[application/xml text/xml]).freeze
|
|
8
|
+
|
|
9
|
+
extend self
|
|
10
|
+
|
|
11
|
+
def buffered?(response)
|
|
12
|
+
BUFFERED.include?(normalized(response))
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def html?(response)
|
|
16
|
+
HTML.include?(normalized(response))
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def normalized(response)
|
|
20
|
+
response['content-type'].to_s.split(';', 2).first.to_s.strip.downcase
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module URLCanonicalize
|
|
4
|
+
# Validated configuration for one canonicalization operation
|
|
5
|
+
class Options
|
|
6
|
+
DEFAULTS = {
|
|
7
|
+
allow_private_networks: false,
|
|
8
|
+
allowed_ports: [80, 443].freeze,
|
|
9
|
+
max_body_bytes: 1_048_576,
|
|
10
|
+
total_timeout: 30,
|
|
11
|
+
open_timeout: 8,
|
|
12
|
+
read_timeout: 15,
|
|
13
|
+
write_timeout: 8,
|
|
14
|
+
max_redirects: 10
|
|
15
|
+
}.freeze
|
|
16
|
+
|
|
17
|
+
POSITIVE_NUMERIC_OPTIONS = %i[total_timeout open_timeout read_timeout write_timeout].freeze
|
|
18
|
+
POSITIVE_INTEGER_OPTIONS = %i[max_body_bytes max_redirects].freeze
|
|
19
|
+
|
|
20
|
+
def initialize(**values)
|
|
21
|
+
validate_known_options!(values)
|
|
22
|
+
|
|
23
|
+
@values = DEFAULTS.merge(values)
|
|
24
|
+
validate!
|
|
25
|
+
@values[:allowed_ports] = @values[:allowed_ports].dup.freeze
|
|
26
|
+
@values.freeze
|
|
27
|
+
freeze
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def [](key)
|
|
31
|
+
@values.fetch(key)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def to_h
|
|
35
|
+
@values.merge(allowed_ports: @values[:allowed_ports].dup)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
private
|
|
39
|
+
|
|
40
|
+
def validate!
|
|
41
|
+
validate_private_networks!
|
|
42
|
+
validate_ports!
|
|
43
|
+
validate_positive_values!
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def validate_known_options!(values)
|
|
47
|
+
unknown = values.keys - DEFAULTS.keys
|
|
48
|
+
raise ArgumentError, "Unknown options: #{unknown.join(', ')}" unless unknown.empty?
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def validate_private_networks!
|
|
52
|
+
return if [true, false].include?(@values[:allow_private_networks])
|
|
53
|
+
|
|
54
|
+
raise ArgumentError, 'allow_private_networks must be true or false'
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def validate_ports!
|
|
58
|
+
ports = @values[:allowed_ports]
|
|
59
|
+
valid = ports.is_a?(Array) && !ports.empty? && ports.all? do |port|
|
|
60
|
+
port.is_a?(Integer) && port.between?(1, 65_535)
|
|
61
|
+
end
|
|
62
|
+
return if valid
|
|
63
|
+
|
|
64
|
+
raise ArgumentError, 'allowed_ports must contain only valid TCP ports'
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def validate_positive_values!
|
|
68
|
+
POSITIVE_NUMERIC_OPTIONS.each do |name|
|
|
69
|
+
value = @values[name]
|
|
70
|
+
raise ArgumentError, "#{name} must be positive" unless value.is_a?(Numeric) && value.positive?
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
POSITIVE_INTEGER_OPTIONS.each do |name|
|
|
74
|
+
value = @values[name]
|
|
75
|
+
raise ArgumentError, "#{name} must be positive" unless value.is_a?(Integer) && value.positive?
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
@@ -3,6 +3,30 @@
|
|
|
3
3
|
module URLCanonicalize
|
|
4
4
|
# Make an HTTP request
|
|
5
5
|
class Request
|
|
6
|
+
NETWORK_EXCEPTIONS = [
|
|
7
|
+
EOFError,
|
|
8
|
+
Errno::ECONNREFUSED,
|
|
9
|
+
Errno::ECONNRESET,
|
|
10
|
+
Errno::EHOSTUNREACH,
|
|
11
|
+
Errno::EINVAL,
|
|
12
|
+
Errno::ENETUNREACH,
|
|
13
|
+
Errno::ETIMEDOUT,
|
|
14
|
+
Net::OpenTimeout,
|
|
15
|
+
Net::ReadTimeout,
|
|
16
|
+
OpenSSL::SSL::SSLError,
|
|
17
|
+
SocketError,
|
|
18
|
+
Timeout::Error,
|
|
19
|
+
Zlib::BufError,
|
|
20
|
+
Zlib::DataError
|
|
21
|
+
].freeze
|
|
22
|
+
|
|
23
|
+
# Unsuccessful responses to a HEAD request that trigger a retry with GET
|
|
24
|
+
HEAD_FALLBACK_RESPONSES = [
|
|
25
|
+
Net::HTTPForbidden,
|
|
26
|
+
Net::HTTPMethodNotAllowed,
|
|
27
|
+
Net::HTTPNotImplemented
|
|
28
|
+
].freeze
|
|
29
|
+
|
|
6
30
|
def fetch
|
|
7
31
|
handle_response
|
|
8
32
|
end
|
|
@@ -11,15 +35,13 @@ module URLCanonicalize
|
|
|
11
35
|
@location ||= relative_to_absolute(response['location'])
|
|
12
36
|
end
|
|
13
37
|
|
|
38
|
+
# Point this request at a new URI, discarding all state from the previous
|
|
39
|
+
# response so nothing leaks between hops
|
|
14
40
|
def with_uri(uri)
|
|
15
41
|
@uri = uri
|
|
16
42
|
|
|
17
43
|
@url = nil
|
|
18
|
-
|
|
19
|
-
@request = nil
|
|
20
|
-
@response = nil
|
|
21
|
-
@location = nil
|
|
22
|
-
@html = nil
|
|
44
|
+
self.http_method = @default_http_method
|
|
23
45
|
|
|
24
46
|
self
|
|
25
47
|
end
|
|
@@ -31,6 +53,7 @@ module URLCanonicalize
|
|
|
31
53
|
def initialize(http, http_method = :head)
|
|
32
54
|
@http = http
|
|
33
55
|
@http_method = http_method
|
|
56
|
+
@default_http_method = http_method
|
|
34
57
|
end
|
|
35
58
|
|
|
36
59
|
def response
|
|
@@ -55,41 +78,51 @@ module URLCanonicalize
|
|
|
55
78
|
when Net::HTTPRedirection
|
|
56
79
|
handle_redirection
|
|
57
80
|
else
|
|
58
|
-
|
|
81
|
+
handle_unsuccessful
|
|
59
82
|
end
|
|
60
83
|
rescue *NETWORK_EXCEPTIONS => e
|
|
61
|
-
handle_failure(e.class, e.message)
|
|
84
|
+
handle_failure(e.class, e.message, e)
|
|
62
85
|
end
|
|
63
86
|
|
|
64
87
|
def handle_success
|
|
65
|
-
@canonical_url =
|
|
88
|
+
@canonical_url = relative_to_absolute(URLCanonicalize::LinkHeader.canonical(response))
|
|
66
89
|
|
|
67
90
|
return enhanced_response if canonical_url || http_method == :get
|
|
68
91
|
|
|
92
|
+
fallback_to_get
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# Some servers reject HEAD requests outright, so any HEAD request rejected
|
|
96
|
+
# with one of these statuses is retried as a GET before giving up
|
|
97
|
+
def handle_unsuccessful
|
|
98
|
+
return fallback_to_get if http_method == :head && HEAD_FALLBACK_RESPONSES.any? { |klass| response.is_a?(klass) }
|
|
99
|
+
|
|
100
|
+
handle_failure
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def fallback_to_get
|
|
69
104
|
self.http_method = :get
|
|
70
105
|
fetch
|
|
71
106
|
end
|
|
72
107
|
|
|
108
|
+
# Follow any redirection that carries a usable Location header, whether the
|
|
109
|
+
# redirect is temporary or permanent. A redirection without one cannot be
|
|
110
|
+
# followed, so it is reported as a failure.
|
|
73
111
|
def handle_redirection
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
if location
|
|
79
|
-
URLCanonicalize::Response::Redirect.new(location)
|
|
80
|
-
else
|
|
81
|
-
URLCanonicalize::Response::Failure.new(::URI::InvalidURIError, response['location'])
|
|
82
|
-
end
|
|
112
|
+
if location
|
|
113
|
+
URLCanonicalize::Response::Redirect.new(location)
|
|
114
|
+
else
|
|
115
|
+
URLCanonicalize::Response::Failure.new(::URI::InvalidURIError, response['location'])
|
|
83
116
|
end
|
|
84
117
|
end
|
|
85
118
|
|
|
86
|
-
def handle_failure(klass = response.class, message = response.message)
|
|
87
|
-
URLCanonicalize::Response::Failure.new(klass, message)
|
|
119
|
+
def handle_failure(klass = response.class, message = response.message, error = nil)
|
|
120
|
+
URLCanonicalize::Response::Failure.new(klass, message, error)
|
|
88
121
|
end
|
|
89
122
|
|
|
90
123
|
def enhanced_response
|
|
91
124
|
if canonical_url
|
|
92
|
-
puts " * canonical_url:\t#{canonical_url}" if ENV
|
|
125
|
+
puts " * canonical_url:\t#{canonical_url}" if ENV.fetch('DEBUG', nil)
|
|
93
126
|
response_plus = URLCanonicalize::Response::Success.new(canonical_url, response, html)
|
|
94
127
|
URLCanonicalize::Response::CanonicalFound.new(canonical_url, response_plus)
|
|
95
128
|
else
|
|
@@ -98,19 +131,26 @@ module URLCanonicalize
|
|
|
98
131
|
end
|
|
99
132
|
|
|
100
133
|
def html
|
|
101
|
-
@html ||= Nokogiri::HTML
|
|
134
|
+
@html ||= Nokogiri::HTML(response.body) if URLCanonicalize::MediaType.html?(response)
|
|
102
135
|
end
|
|
103
136
|
|
|
104
137
|
def canonical_url
|
|
105
|
-
@canonical_url ||= relative_to_absolute(
|
|
138
|
+
@canonical_url ||= relative_to_absolute(canonical_url_element&.[]('href'), document_base_url)
|
|
106
139
|
end
|
|
107
140
|
|
|
108
|
-
|
|
109
|
-
|
|
141
|
+
# The first HTML link element whose rel tokens include "canonical", however
|
|
142
|
+
# the tokens are cased, provided it has a usable href
|
|
143
|
+
def canonical_url_element
|
|
144
|
+
@canonical_url_element ||= html&.css('head link[rel]')&.find do |element|
|
|
145
|
+
element['rel'].split.any? { |token| token.casecmp?('canonical') } && !element['href'].to_s.strip.empty?
|
|
146
|
+
end
|
|
110
147
|
end
|
|
111
148
|
|
|
112
|
-
|
|
113
|
-
|
|
149
|
+
# HTML canonical links resolve against the document base URL, not the
|
|
150
|
+
# request URL, when the document declares one
|
|
151
|
+
def document_base_url
|
|
152
|
+
base_href = html&.at_css('head base[href]')&.[]('href')
|
|
153
|
+
relative_to_absolute(base_href) || url
|
|
114
154
|
end
|
|
115
155
|
|
|
116
156
|
def uri
|
|
@@ -121,10 +161,6 @@ module URLCanonicalize
|
|
|
121
161
|
@url ||= uri.to_s
|
|
122
162
|
end
|
|
123
163
|
|
|
124
|
-
def host
|
|
125
|
-
@host ||= uri.host
|
|
126
|
-
end
|
|
127
|
-
|
|
128
164
|
def request_for_method
|
|
129
165
|
r = base_request
|
|
130
166
|
headers.each { |header_key, header_value| r[header_key] = header_value }
|
|
@@ -132,8 +168,6 @@ module URLCanonicalize
|
|
|
132
168
|
end
|
|
133
169
|
|
|
134
170
|
def base_request
|
|
135
|
-
check_http_method
|
|
136
|
-
|
|
137
171
|
case http_method
|
|
138
172
|
when :head
|
|
139
173
|
Net::HTTP::Head.new uri
|
|
@@ -147,8 +181,8 @@ module URLCanonicalize
|
|
|
147
181
|
def headers
|
|
148
182
|
@headers ||= {
|
|
149
183
|
'Accept-Language' => 'en-US,en;q=0.8',
|
|
150
|
-
'User-Agent' => 'Mozilla/5.0 (Windows NT 10.0; WOW64) '\
|
|
151
|
-
'AppleWebKit/537.36 (KHTML, like Gecko) '\
|
|
184
|
+
'User-Agent' => 'Mozilla/5.0 (Windows NT 10.0; WOW64) ' \
|
|
185
|
+
'AppleWebKit/537.36 (KHTML, like Gecko) ' \
|
|
152
186
|
'Chrome/51.0.2704.103 Safari/537.36'
|
|
153
187
|
}
|
|
154
188
|
end
|
|
@@ -159,54 +193,31 @@ module URLCanonicalize
|
|
|
159
193
|
@response = nil
|
|
160
194
|
@location = nil
|
|
161
195
|
@html = nil
|
|
196
|
+
@canonical_url = nil
|
|
197
|
+
@canonical_url_element = nil
|
|
162
198
|
end
|
|
163
199
|
|
|
164
|
-
#
|
|
165
|
-
#
|
|
166
|
-
#
|
|
167
|
-
def
|
|
168
|
-
|
|
169
|
-
end
|
|
200
|
+
# Resolve absolute, protocol-relative, root-relative, path-relative and
|
|
201
|
+
# query-only references against the base URL per RFC 3986. Only http(s)
|
|
202
|
+
# results are usable
|
|
203
|
+
def relative_to_absolute(reference, base = url)
|
|
204
|
+
return unless reference
|
|
170
205
|
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
partial_uri = ::URI.parse(partial_url)
|
|
175
|
-
|
|
176
|
-
if partial_uri.host
|
|
177
|
-
partial_url # It's already absolute
|
|
178
|
-
else
|
|
179
|
-
::URI.join((uri || url), partial_url).to_s
|
|
180
|
-
end
|
|
181
|
-
rescue ::URI::InvalidURIError
|
|
206
|
+
absolute = ::URI.join(base, reference.strip)
|
|
207
|
+
absolute.to_s if absolute.is_a?(::URI::HTTP)
|
|
208
|
+
rescue ::URI::InvalidURIError, ArgumentError, Encoding::CompatibilityError
|
|
182
209
|
nil
|
|
183
210
|
end
|
|
184
211
|
|
|
185
212
|
def log_response
|
|
186
|
-
|
|
213
|
+
debug = ENV.fetch('DEBUG', nil)
|
|
214
|
+
return unless debug
|
|
187
215
|
|
|
188
216
|
puts "#{http_method.upcase} #{url} #{response.code} #{response.message}"
|
|
189
217
|
|
|
190
|
-
return unless
|
|
218
|
+
return unless debug.casecmp?('headers')
|
|
191
219
|
|
|
192
220
|
response.each { |k, v| puts " #{k}:\t#{v}" }
|
|
193
221
|
end
|
|
194
|
-
|
|
195
|
-
NETWORK_EXCEPTIONS = [
|
|
196
|
-
EOFError,
|
|
197
|
-
Errno::ECONNREFUSED,
|
|
198
|
-
Errno::ECONNRESET,
|
|
199
|
-
Errno::EHOSTUNREACH,
|
|
200
|
-
Errno::EINVAL,
|
|
201
|
-
Errno::ENETUNREACH,
|
|
202
|
-
Errno::ETIMEDOUT,
|
|
203
|
-
Net::OpenTimeout,
|
|
204
|
-
Net::ReadTimeout,
|
|
205
|
-
OpenSSL::SSL::SSLError,
|
|
206
|
-
SocketError,
|
|
207
|
-
Timeout::Error,
|
|
208
|
-
Zlib::BufError,
|
|
209
|
-
Zlib::DataError
|
|
210
|
-
].freeze
|
|
211
222
|
end
|
|
212
223
|
end
|
|
@@ -13,7 +13,8 @@ module URLCanonicalize
|
|
|
13
13
|
end
|
|
14
14
|
end
|
|
15
15
|
|
|
16
|
-
|
|
16
|
+
# A redirect to another URL
|
|
17
|
+
class Redirect < Generic; end
|
|
17
18
|
|
|
18
19
|
# Add HTML to a successful response
|
|
19
20
|
class Success < Generic
|
|
@@ -28,7 +29,7 @@ module URLCanonicalize
|
|
|
28
29
|
def initialize(url, response, html)
|
|
29
30
|
@response = response
|
|
30
31
|
@html = html
|
|
31
|
-
super
|
|
32
|
+
super(url)
|
|
32
33
|
end
|
|
33
34
|
end
|
|
34
35
|
|
|
@@ -40,19 +41,21 @@ module URLCanonicalize
|
|
|
40
41
|
|
|
41
42
|
def initialize(url, response)
|
|
42
43
|
@response = response
|
|
43
|
-
super
|
|
44
|
+
super(url)
|
|
44
45
|
end
|
|
45
46
|
end
|
|
46
47
|
|
|
47
|
-
# It barfed
|
|
48
|
+
# It barfed. When the failure came from a rescued exception, `error` holds
|
|
49
|
+
# the original exception so callers can see the real cause
|
|
48
50
|
class Failure
|
|
49
|
-
attr_reader :failure_class, :message
|
|
51
|
+
attr_reader :failure_class, :message, :error
|
|
50
52
|
|
|
51
53
|
private
|
|
52
54
|
|
|
53
|
-
def initialize(failure_class, message)
|
|
55
|
+
def initialize(failure_class, message, error = nil)
|
|
54
56
|
@failure_class = failure_class
|
|
55
57
|
@message = message
|
|
58
|
+
@error = error
|
|
56
59
|
end
|
|
57
60
|
end
|
|
58
61
|
end
|