down 5.3.1 → 5.4.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 +14 -0
- data/README.md +34 -15
- data/down.gemspec +1 -1
- data/lib/down/http.rb +1 -1
- data/lib/down/httpx.rb +175 -0
- data/lib/down/net_http.rb +3 -2
- data/lib/down/utils.rb +1 -1
- data/lib/down/version.rb +1 -1
- metadata +20 -13
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 5e25eaeebd866fe407bcb39282937568f1c5489feb0b3ac479d92c2b19d8920c
|
|
4
|
+
data.tar.gz: 151e9d28039d7698a32e25ff7b1dae39c32e871ed295809f11a586c5f6221bcc
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 3ee68adf94333adcb92f9453ec0c65e23437458e6586f0078471ba8b5691d3ca0b5e377656a16fb6750ee585f255cd5af38218dbf873fd6b7e5e9ec1997a8997
|
|
7
|
+
data.tar.gz: 49f80f1b039ba67e86e1515aac927382249f2bdca33cb8d71e8be8753d82d6f69c56d010be4521c2e661a51662adb698478348949fc01ac22e1419212cfc640c
|
data/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,17 @@
|
|
|
1
|
+
## 5.4.1 (2023-05-20)
|
|
2
|
+
|
|
3
|
+
* Handle additional params in `Content-Disposition` header (@janko)
|
|
4
|
+
|
|
5
|
+
* Add ability to detect response URI when using net/http (@aglushkov)
|
|
6
|
+
|
|
7
|
+
* Avoid deprecation warning in HTTPX (@ollym)
|
|
8
|
+
|
|
9
|
+
* Handle unknown response status in net/http backend (@janko)
|
|
10
|
+
|
|
11
|
+
## 5.4.0 (2022-12-26)
|
|
12
|
+
|
|
13
|
+
* Add new HTTPX backend, which supports HTTP/2 protocol among other features (@HoneyryderChuck)
|
|
14
|
+
|
|
1
15
|
## 5.3.1 (2022-03-25)
|
|
2
16
|
|
|
3
17
|
* Correctly split cookie headers on `;` instead of `,` when forwarding them on redirects (@ermolaev)
|
data/README.md
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
# Down
|
|
2
2
|
|
|
3
3
|
Down is a utility tool for streaming, flexible and safe downloading of remote
|
|
4
|
-
files. It can use [open-uri] + `Net::HTTP`, [http.rb] or `wget` as
|
|
5
|
-
HTTP library.
|
|
4
|
+
files. It can use [open-uri] + `Net::HTTP`, [http.rb], [HTTPX], or `wget` as
|
|
5
|
+
the backend HTTP library.
|
|
6
6
|
|
|
7
7
|
## Installation
|
|
8
8
|
|
|
@@ -234,6 +234,7 @@ The following backends are available:
|
|
|
234
234
|
|
|
235
235
|
* [Down::NetHttp](#downnethttp) (default)
|
|
236
236
|
* [Down::Http](#downhttp)
|
|
237
|
+
* [Down::Httpx](#downhttpx)
|
|
237
238
|
* [Down::Wget](#downwget)
|
|
238
239
|
|
|
239
240
|
You can use the backend directly:
|
|
@@ -442,6 +443,28 @@ down = Down::Http.new(method: :post)
|
|
|
442
443
|
down.download("http://example.org/image.jpg")
|
|
443
444
|
```
|
|
444
445
|
|
|
446
|
+
### Down::Httpx
|
|
447
|
+
|
|
448
|
+
The `Down::Httpx` backend implements downloads using the [HTTPX] gem, which
|
|
449
|
+
supports the HTTP/2 protocol, in addition to many other features.
|
|
450
|
+
|
|
451
|
+
```rb
|
|
452
|
+
gem "down", "~> 5.0"
|
|
453
|
+
gem "httpx", "~> 0.22"
|
|
454
|
+
```
|
|
455
|
+
```rb
|
|
456
|
+
require "down/httpx"
|
|
457
|
+
|
|
458
|
+
tempfile = Down::Httpx.download("http://nature.com/forest.jpg")
|
|
459
|
+
tempfile #=> #<Tempfile:/var/folders/k7/6zx6dx6x7ys3rv3srh0nyfj00000gn/T/20150925-55456-z7vxqz.jpg>
|
|
460
|
+
|
|
461
|
+
io = Down::Httpx.open("http://nature.com/forest.jpg")
|
|
462
|
+
io #=> #<Down::ChunkedIO ...>
|
|
463
|
+
```
|
|
464
|
+
|
|
465
|
+
It's implemented in much of the same way as `Down::Http`, so be sure to check
|
|
466
|
+
its docs for ways to pass additional options.
|
|
467
|
+
|
|
445
468
|
### Down::Wget (experimental)
|
|
446
469
|
|
|
447
470
|
The `Down::Wget` backend implements downloads using the `wget` command line
|
|
@@ -488,26 +511,21 @@ wget.download("http://nature.com/forest.jpg")
|
|
|
488
511
|
wget.open("http://nature.com/forest.jpg")
|
|
489
512
|
```
|
|
490
513
|
|
|
491
|
-
##
|
|
514
|
+
## Development
|
|
492
515
|
|
|
493
|
-
|
|
494
|
-
* MRI 2.4
|
|
495
|
-
* MRI 2.5
|
|
496
|
-
* MRI 2.6
|
|
497
|
-
* MRI 2.7
|
|
498
|
-
* JRuby 9.2
|
|
516
|
+
Tests require that a [httpbin] server is running locally, which you can do via Docker:
|
|
499
517
|
|
|
500
|
-
|
|
518
|
+
```sh
|
|
519
|
+
$ docker pull kennethreitz/httpbin
|
|
520
|
+
$ docker run -p 80:80 kennethreitz/httpbin
|
|
521
|
+
```
|
|
501
522
|
|
|
502
|
-
|
|
523
|
+
Then you can run tests:
|
|
503
524
|
|
|
504
525
|
```
|
|
505
526
|
$ bundle exec rake test
|
|
506
527
|
```
|
|
507
528
|
|
|
508
|
-
The test suite pulls and runs [kennethreitz/httpbin] as a Docker container, so
|
|
509
|
-
you'll need to have Docker installed and running.
|
|
510
|
-
|
|
511
529
|
## License
|
|
512
530
|
|
|
513
531
|
[MIT](LICENSE.txt)
|
|
@@ -515,5 +533,6 @@ you'll need to have Docker installed and running.
|
|
|
515
533
|
[open-uri]: http://ruby-doc.org/stdlib-2.3.0/libdoc/open-uri/rdoc/OpenURI.html
|
|
516
534
|
[Net::HTTP]: https://ruby-doc.org/stdlib-2.4.1/libdoc/net/http/rdoc/Net/HTTP.html
|
|
517
535
|
[http.rb]: https://github.com/httprb/http
|
|
536
|
+
[HTTPX]: https://github.com/HoneyryderChuck/httpx
|
|
518
537
|
[Addressable::URI]: https://github.com/sporkmonger/addressable
|
|
519
|
-
[
|
|
538
|
+
[httpbin]: https://github.com/postmanlabs/httpbin
|
data/down.gemspec
CHANGED
|
@@ -20,6 +20,7 @@ Gem::Specification.new do |spec|
|
|
|
20
20
|
spec.add_development_dependency "minitest", "~> 5.8"
|
|
21
21
|
spec.add_development_dependency "mocha", "~> 1.5"
|
|
22
22
|
spec.add_development_dependency "rake"
|
|
23
|
+
spec.add_development_dependency "httpx", "~> 0.22", ">= 0.22.2"
|
|
23
24
|
# http 5.0 drop support of ruby 2.3 and 2.4. We still support those versions.
|
|
24
25
|
if RUBY_VERSION >= "2.5"
|
|
25
26
|
spec.add_development_dependency "http", "~> 5.0"
|
|
@@ -28,6 +29,5 @@ Gem::Specification.new do |spec|
|
|
|
28
29
|
end
|
|
29
30
|
spec.add_development_dependency "posix-spawn" unless RUBY_ENGINE == "jruby"
|
|
30
31
|
spec.add_development_dependency "http_parser.rb" unless RUBY_ENGINE == "jruby"
|
|
31
|
-
spec.add_development_dependency "docker-api"
|
|
32
32
|
spec.add_development_dependency "warning" if RUBY_VERSION >= "2.4"
|
|
33
33
|
end
|
data/lib/down/http.rb
CHANGED
data/lib/down/httpx.rb
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
# frozen-string-literal: true
|
|
2
|
+
|
|
3
|
+
require "uri"
|
|
4
|
+
require "tempfile"
|
|
5
|
+
require "httpx"
|
|
6
|
+
|
|
7
|
+
require "down/backend"
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
module Down
|
|
11
|
+
# Provides streaming downloads implemented with HTTPX.
|
|
12
|
+
class Httpx < Backend
|
|
13
|
+
# Initializes the backend
|
|
14
|
+
|
|
15
|
+
USER_AGENT = "Down/#{Down::VERSION}"
|
|
16
|
+
|
|
17
|
+
def initialize(**options, &block)
|
|
18
|
+
@method = options.delete(:method) || :get
|
|
19
|
+
headers = options.delete(:headers) || {}
|
|
20
|
+
@client = HTTPX
|
|
21
|
+
.plugin(:follow_redirects, max_redirects: 2)
|
|
22
|
+
.plugin(:basic_authentication)
|
|
23
|
+
.plugin(:stream)
|
|
24
|
+
.with(
|
|
25
|
+
headers: { "user-agent": USER_AGENT }.merge(headers),
|
|
26
|
+
timeout: { connect_timeout: 30, write_timeout: 30, read_timeout: 30 },
|
|
27
|
+
**options
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
@client = block.call(@client) if block
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
# Downlods the remote file to disk. Accepts HTTPX options via a hash or a
|
|
35
|
+
# block, and some additional options as well.
|
|
36
|
+
def download(url, max_size: nil, progress_proc: nil, content_length_proc: nil, destination: nil, extension: nil, **options, &block)
|
|
37
|
+
client = @client
|
|
38
|
+
|
|
39
|
+
response = request(client, url, **options, &block)
|
|
40
|
+
|
|
41
|
+
content_length = nil
|
|
42
|
+
|
|
43
|
+
if response.headers.key?("content-length")
|
|
44
|
+
content_length = response.headers["content-length"].to_i
|
|
45
|
+
|
|
46
|
+
content_length_proc.call(content_length) if content_length_proc
|
|
47
|
+
|
|
48
|
+
if max_size && content_length > max_size
|
|
49
|
+
response.close
|
|
50
|
+
raise Down::TooLarge, "file is too large (#{content_length/1024/1024}MB, max is #{max_size/1024/1024}MB)"
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
extname = extension ? ".#{extension}" : File.extname(response.uri.path)
|
|
55
|
+
tempfile = Tempfile.new(["down-http", extname], binmode: true)
|
|
56
|
+
|
|
57
|
+
stream_body(response) do |chunk|
|
|
58
|
+
tempfile.write(chunk)
|
|
59
|
+
chunk.clear # deallocate string
|
|
60
|
+
|
|
61
|
+
progress_proc.call(tempfile.size) if progress_proc
|
|
62
|
+
|
|
63
|
+
if max_size && tempfile.size > max_size
|
|
64
|
+
raise Down::TooLarge, "file is too large (#{tempfile.size/1024/1024}MB, max is #{max_size/1024/1024}MB)"
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
tempfile.open # flush written content
|
|
69
|
+
|
|
70
|
+
tempfile.extend DownloadedFile
|
|
71
|
+
tempfile.url = response.uri.to_s
|
|
72
|
+
tempfile.headers = normalize_headers(response.headers.to_h)
|
|
73
|
+
tempfile.content_type = response.content_type.mime_type
|
|
74
|
+
tempfile.charset = response.body.encoding
|
|
75
|
+
|
|
76
|
+
download_result(tempfile, destination)
|
|
77
|
+
rescue
|
|
78
|
+
tempfile.close! if tempfile
|
|
79
|
+
raise
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Starts retrieving the remote file and returns an IO-like object which
|
|
83
|
+
# downloads the response body on-demand. Accepts HTTP.rb options via a hash
|
|
84
|
+
# or a block.
|
|
85
|
+
def open(url, rewindable: true, **options, &block)
|
|
86
|
+
response = request(@client, url, stream: true, **options, &block)
|
|
87
|
+
size = response.headers["content-length"]
|
|
88
|
+
size = size.to_i if size
|
|
89
|
+
Down::ChunkedIO.new(
|
|
90
|
+
chunks: enum_for(:stream_body, response),
|
|
91
|
+
size: size,
|
|
92
|
+
encoding: response.body.encoding,
|
|
93
|
+
rewindable: rewindable,
|
|
94
|
+
data: {
|
|
95
|
+
status: response.status,
|
|
96
|
+
headers: normalize_headers(response.headers.to_h),
|
|
97
|
+
response: response
|
|
98
|
+
},
|
|
99
|
+
)
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
private
|
|
103
|
+
|
|
104
|
+
# Yields chunks of the response body to the block.
|
|
105
|
+
def stream_body(response, &block)
|
|
106
|
+
response.each(&block)
|
|
107
|
+
rescue => exception
|
|
108
|
+
request_error!(exception)
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def request(client, url, method: @method, **options, &block)
|
|
112
|
+
response = send_request(client, method, url, **options, &block)
|
|
113
|
+
response.raise_for_status
|
|
114
|
+
response_error!(response) unless (200..299).include?(response.status)
|
|
115
|
+
response
|
|
116
|
+
rescue HTTPX::HTTPError
|
|
117
|
+
response_error!(response)
|
|
118
|
+
rescue => error
|
|
119
|
+
request_error!(error)
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def send_request(client, method, url, **options, &block)
|
|
123
|
+
uri = URI(url)
|
|
124
|
+
client = @client
|
|
125
|
+
if uri.user || uri.password
|
|
126
|
+
client = client.basic_auth(uri.user, uri.password)
|
|
127
|
+
uri.user = uri.password = nil
|
|
128
|
+
end
|
|
129
|
+
client = block.call(client) if block
|
|
130
|
+
|
|
131
|
+
client.request(method.to_s.upcase, uri, stream: true, **options)
|
|
132
|
+
rescue => exception
|
|
133
|
+
request_error!(exception)
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# Raises non-sucessful response as a Down::ResponseError.
|
|
137
|
+
def response_error!(response)
|
|
138
|
+
args = [response.status.to_s, response]
|
|
139
|
+
|
|
140
|
+
case response.status
|
|
141
|
+
when 300..399 then raise Down::TooManyRedirects, "too many redirects"
|
|
142
|
+
when 404 then raise Down::NotFound.new(*args)
|
|
143
|
+
when 400..499 then raise Down::ClientError.new(*args)
|
|
144
|
+
when 500..599 then raise Down::ServerError.new(*args)
|
|
145
|
+
else raise Down::ResponseError.new(*args)
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# Re-raise HTTP.rb exceptions as Down::Error exceptions.
|
|
150
|
+
def request_error!(exception)
|
|
151
|
+
case exception
|
|
152
|
+
when URI::Error, HTTPX::UnsupportedSchemeError
|
|
153
|
+
raise Down::InvalidUrl, exception.message
|
|
154
|
+
when HTTPX::ConnectionError
|
|
155
|
+
raise Down::ConnectionError, exception.message
|
|
156
|
+
when HTTPX::TimeoutError
|
|
157
|
+
raise Down::TimeoutError, exception.message
|
|
158
|
+
when OpenSSL::SSL::SSLError
|
|
159
|
+
raise Down::SSLError, exception.message
|
|
160
|
+
else
|
|
161
|
+
raise exception
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
# Defines some additional attributes for the returned Tempfile.
|
|
166
|
+
module DownloadedFile
|
|
167
|
+
attr_accessor :url, :headers, :charset, :content_type
|
|
168
|
+
|
|
169
|
+
def original_filename
|
|
170
|
+
Utils.filename_from_content_disposition(headers["Content-Disposition"]) ||
|
|
171
|
+
Utils.filename_from_path(URI.parse(url).path)
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
end
|
data/lib/down/net_http.rb
CHANGED
|
@@ -270,7 +270,7 @@ module Down
|
|
|
270
270
|
headers = options[:headers].to_h
|
|
271
271
|
headers["Accept-Encoding"] = "" # Net::HTTP's inflater causes FiberErrors
|
|
272
272
|
|
|
273
|
-
get = Net::HTTP::Get.new(uri
|
|
273
|
+
get = Net::HTTP::Get.new(uri, headers)
|
|
274
274
|
|
|
275
275
|
user, password = options[:http_basic_authentication] || [uri.user, uri.password]
|
|
276
276
|
get.basic_auth(user, password) if user || password
|
|
@@ -312,13 +312,14 @@ module Down
|
|
|
312
312
|
# rebuild the Net::HTTP response object.
|
|
313
313
|
def rebuild_response_from_open_uri_exception(exception)
|
|
314
314
|
code, message = exception.io.status
|
|
315
|
+
message ||= "Unknown"
|
|
315
316
|
|
|
316
317
|
response_class = Net::HTTPResponse::CODE_TO_OBJ.fetch(code) do |c|
|
|
317
318
|
Net::HTTPResponse::CODE_CLASS_TO_OBJ.fetch(c[0]) do
|
|
318
319
|
Net::HTTPUnknownResponse
|
|
319
320
|
end
|
|
320
321
|
end
|
|
321
|
-
response
|
|
322
|
+
response = response_class.new(nil, code, message)
|
|
322
323
|
|
|
323
324
|
exception.io.metas.each do |name, values|
|
|
324
325
|
values.each { |value| response.add_field(name, value) }
|
data/lib/down/utils.rb
CHANGED
|
@@ -11,7 +11,7 @@ module Down
|
|
|
11
11
|
escaped_filename =
|
|
12
12
|
content_disposition[/filename\*=UTF-8''(\S+)/, 1] ||
|
|
13
13
|
content_disposition[/filename="([^"]*)"/, 1] ||
|
|
14
|
-
content_disposition[/filename=(
|
|
14
|
+
content_disposition[/filename=([^\s;]+)/, 1]
|
|
15
15
|
|
|
16
16
|
filename = CGI.unescape(escaped_filename.to_s)
|
|
17
17
|
|
data/lib/down/version.rb
CHANGED
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: down
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 5.
|
|
4
|
+
version: 5.4.1
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Janko Marohnić
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: bin
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date:
|
|
11
|
+
date: 2023-05-20 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: addressable
|
|
@@ -67,35 +67,41 @@ dependencies:
|
|
|
67
67
|
- !ruby/object:Gem::Version
|
|
68
68
|
version: '0'
|
|
69
69
|
- !ruby/object:Gem::Dependency
|
|
70
|
-
name:
|
|
70
|
+
name: httpx
|
|
71
71
|
requirement: !ruby/object:Gem::Requirement
|
|
72
72
|
requirements:
|
|
73
73
|
- - "~>"
|
|
74
74
|
- !ruby/object:Gem::Version
|
|
75
|
-
version: '
|
|
75
|
+
version: '0.22'
|
|
76
|
+
- - ">="
|
|
77
|
+
- !ruby/object:Gem::Version
|
|
78
|
+
version: 0.22.2
|
|
76
79
|
type: :development
|
|
77
80
|
prerelease: false
|
|
78
81
|
version_requirements: !ruby/object:Gem::Requirement
|
|
79
82
|
requirements:
|
|
80
83
|
- - "~>"
|
|
81
84
|
- !ruby/object:Gem::Version
|
|
82
|
-
version: '
|
|
85
|
+
version: '0.22'
|
|
86
|
+
- - ">="
|
|
87
|
+
- !ruby/object:Gem::Version
|
|
88
|
+
version: 0.22.2
|
|
83
89
|
- !ruby/object:Gem::Dependency
|
|
84
|
-
name:
|
|
90
|
+
name: http
|
|
85
91
|
requirement: !ruby/object:Gem::Requirement
|
|
86
92
|
requirements:
|
|
87
|
-
- - "
|
|
93
|
+
- - "~>"
|
|
88
94
|
- !ruby/object:Gem::Version
|
|
89
|
-
version: '0'
|
|
95
|
+
version: '5.0'
|
|
90
96
|
type: :development
|
|
91
97
|
prerelease: false
|
|
92
98
|
version_requirements: !ruby/object:Gem::Requirement
|
|
93
99
|
requirements:
|
|
94
|
-
- - "
|
|
100
|
+
- - "~>"
|
|
95
101
|
- !ruby/object:Gem::Version
|
|
96
|
-
version: '0'
|
|
102
|
+
version: '5.0'
|
|
97
103
|
- !ruby/object:Gem::Dependency
|
|
98
|
-
name:
|
|
104
|
+
name: posix-spawn
|
|
99
105
|
requirement: !ruby/object:Gem::Requirement
|
|
100
106
|
requirements:
|
|
101
107
|
- - ">="
|
|
@@ -109,7 +115,7 @@ dependencies:
|
|
|
109
115
|
- !ruby/object:Gem::Version
|
|
110
116
|
version: '0'
|
|
111
117
|
- !ruby/object:Gem::Dependency
|
|
112
|
-
name:
|
|
118
|
+
name: http_parser.rb
|
|
113
119
|
requirement: !ruby/object:Gem::Requirement
|
|
114
120
|
requirements:
|
|
115
121
|
- - ">="
|
|
@@ -152,6 +158,7 @@ files:
|
|
|
152
158
|
- lib/down/chunked_io.rb
|
|
153
159
|
- lib/down/errors.rb
|
|
154
160
|
- lib/down/http.rb
|
|
161
|
+
- lib/down/httpx.rb
|
|
155
162
|
- lib/down/net_http.rb
|
|
156
163
|
- lib/down/utils.rb
|
|
157
164
|
- lib/down/version.rb
|
|
@@ -175,7 +182,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
175
182
|
- !ruby/object:Gem::Version
|
|
176
183
|
version: '0'
|
|
177
184
|
requirements: []
|
|
178
|
-
rubygems_version: 3.
|
|
185
|
+
rubygems_version: 3.4.12
|
|
179
186
|
signing_key:
|
|
180
187
|
specification_version: 4
|
|
181
188
|
summary: Robust streaming downloads using Net::HTTP, HTTP.rb or wget.
|