wayback_machine_downloader_straw 2.4.7 → 2.4.9
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/bin/wayback_machine_downloader +4 -0
- data/lib/wayback_machine_downloader/archive_api.rb +159 -33
- data/lib/wayback_machine_downloader/page_requisites.rb +31 -8
- data/lib/wayback_machine_downloader/subdom_processor.rb +12 -6
- data/lib/wayback_machine_downloader/url_rewrite.rb +82 -50
- data/lib/wayback_machine_downloader.rb +278 -176
- metadata +3 -3
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: cdb773f6bbfb5404d827d6c484f4b8aa0ba2b1c4aed192c86ba12f137526f814
|
|
4
|
+
data.tar.gz: a8a898bdf0640c40e6946d78e3135bb04e8fa0f76ab14a267448a514a503af2f
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: d608cf38715e49fb50bcd6aaa6b692556ef9ca0fe3c36894e0738e2e8c2487dd99d04cf7f242a053b96ced3cd7e2cf92c455f83a83113102b1dfb926c914fb58
|
|
7
|
+
data.tar.gz: 96b2ca1e63d659936a25423717aad49e8f2fa342040a4564cf425c76766dc025f09ef32b9821737a0bec8127cfee6328ecba3717286ff59c1b15c84a713752a3
|
|
@@ -84,6 +84,10 @@ option_parser = OptionParser.new do |opts|
|
|
|
84
84
|
options[:keep] = true
|
|
85
85
|
end
|
|
86
86
|
|
|
87
|
+
opts.on("--delay SECONDS", Float, "Delay between downloads in seconds (default: 0)") do |t|
|
|
88
|
+
options[:delay] = t
|
|
89
|
+
end
|
|
90
|
+
|
|
87
91
|
opts.on("--rt", "--retry N", Integer, "Maximum number of retries for failed downloads (default: 3)") do |t|
|
|
88
92
|
options[:max_retries] = t
|
|
89
93
|
end
|
|
@@ -1,49 +1,119 @@
|
|
|
1
1
|
require 'json'
|
|
2
2
|
require 'uri'
|
|
3
|
+
require 'time'
|
|
4
|
+
require 'thread'
|
|
3
5
|
|
|
4
6
|
module ArchiveAPI
|
|
7
|
+
DEFAULT_RATE_LIMIT_COOLDOWN = 30.0
|
|
8
|
+
DEFAULT_CDX_INTERVAL = 2.5 # 1 request every 2.5 seconds
|
|
9
|
+
|
|
10
|
+
class RateLimitError < StandardError
|
|
11
|
+
attr_reader :retry_after
|
|
12
|
+
|
|
13
|
+
def initialize(message, retry_after = nil)
|
|
14
|
+
super(message)
|
|
15
|
+
@retry_after = retry_after
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
@cdx_mutex = Mutex.new
|
|
20
|
+
@cdx_cv = ConditionVariable.new
|
|
21
|
+
@next_allowed_cdx_at = 0.0
|
|
22
|
+
@cdx_interval = DEFAULT_CDX_INTERVAL
|
|
23
|
+
|
|
24
|
+
class << self
|
|
25
|
+
attr_accessor :cdx_interval
|
|
26
|
+
|
|
27
|
+
# pace CDX requests to avoid exceeding the rate limit
|
|
28
|
+
def pace_cdx_request
|
|
29
|
+
@cdx_mutex.synchronize do
|
|
30
|
+
loop do
|
|
31
|
+
now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
32
|
+
if now < @next_allowed_cdx_at
|
|
33
|
+
wait_time = @next_allowed_cdx_at - now
|
|
34
|
+
@cdx_cv.wait(@cdx_mutex, wait_time)
|
|
35
|
+
else
|
|
36
|
+
interval = @cdx_interval || DEFAULT_CDX_INTERVAL
|
|
37
|
+
@next_allowed_cdx_at = now + interval
|
|
38
|
+
return
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# extend the cooldown period for CDX requests, e.g., after receiving a 429 response
|
|
45
|
+
def extend_cdx_cooldown(seconds)
|
|
46
|
+
seconds = seconds.to_f
|
|
47
|
+
return if seconds <= 0
|
|
48
|
+
|
|
49
|
+
@cdx_mutex.synchronize do
|
|
50
|
+
now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
51
|
+
candidate = now + seconds
|
|
52
|
+
@next_allowed_cdx_at = candidate if candidate > @next_allowed_cdx_at
|
|
53
|
+
@cdx_cv.broadcast
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def reset_cdx_limiter
|
|
58
|
+
@cdx_mutex.synchronize do
|
|
59
|
+
@next_allowed_cdx_at = 0.0
|
|
60
|
+
@cdx_cv.broadcast
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
5
64
|
|
|
6
65
|
def get_raw_list_from_api(url, page_index, http)
|
|
7
66
|
# Automatically append /* for host-only URLs
|
|
8
67
|
# This is a workaround for an issue with the API and *some* domains.
|
|
9
68
|
# See https://github.com/StrawberryMaster/wayback-machine-downloader/issues/6
|
|
10
69
|
# But don't do this when exact_url flag is set, and never append twice
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
70
|
+
normalized_url = url.to_s.strip
|
|
71
|
+
|
|
72
|
+
# strip protocol for CDX query
|
|
73
|
+
clean_url = normalized_url.sub(%r{\Ahttps?://}i, '')
|
|
74
|
+
# ensure wildcard/matchType for domain-wide crawling
|
|
75
|
+
match_type = nil
|
|
76
|
+
unless @exact_url || clean_url.include?('*')
|
|
77
|
+
if clean_url.end_with?('/')
|
|
78
|
+
clean_url = "#{clean_url}*"
|
|
79
|
+
elsif !clean_url.include?('/')
|
|
80
|
+
match_type = "prefix"
|
|
81
|
+
else
|
|
82
|
+
clean_url = "#{clean_url}/*"
|
|
22
83
|
end
|
|
23
84
|
end
|
|
24
85
|
|
|
25
86
|
request_url = URI("https://web.archive.org/cdx/search/cdx")
|
|
26
|
-
params = [["output", "json"], ["url",
|
|
87
|
+
params = [["output", "json"], ["url", clean_url]] + parameters_for_api(page_index)
|
|
88
|
+
params << ["matchType", match_type] if match_type
|
|
27
89
|
request_url.query = URI.encode_www_form(params)
|
|
28
90
|
|
|
29
91
|
retries = 0
|
|
30
92
|
max_retries = (@max_retries || 3)
|
|
31
|
-
|
|
93
|
+
base_delay = 2
|
|
32
94
|
|
|
33
95
|
begin
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
request["Connection"] = "keep-alive"
|
|
37
|
-
request["Accept-Encoding"] = "gzip"
|
|
38
|
-
response = http.request(request)
|
|
96
|
+
# acquire slot from the process-wide proactive pacer before sending request
|
|
97
|
+
ArchiveAPI.pace_cdx_request
|
|
39
98
|
|
|
40
|
-
|
|
99
|
+
if HTTPX_AVAILABLE && http.is_a?(HTTPX::Session)
|
|
100
|
+
response = http.get(request_url)
|
|
101
|
+
raise response.error if response.is_a?(HTTPX::ErrorResponse)
|
|
102
|
+
|
|
103
|
+
code = response.status
|
|
104
|
+
body = response.body.to_s.strip
|
|
105
|
+
else
|
|
106
|
+
request = Net::HTTP::Get.new(request_url)
|
|
107
|
+
request["User-Agent"] = "wmd-straw/#{WaybackMachineDownloader::VERSION rescue '2.4.8'}"
|
|
108
|
+
request["Connection"] = "keep-alive"
|
|
109
|
+
request["Accept-Encoding"] = "gzip, deflate"
|
|
110
|
+
response = http.request(request)
|
|
111
|
+
code = response.code.to_i
|
|
112
|
+
body = decompress_body(response)
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
case code
|
|
41
116
|
when 200
|
|
42
|
-
body = if response['content-encoding'] == 'gzip'
|
|
43
|
-
Zlib::GzipReader.new(StringIO.new(response.body)).read
|
|
44
|
-
else
|
|
45
|
-
response.body.to_s.strip
|
|
46
|
-
end
|
|
47
117
|
return [] if body.empty?
|
|
48
118
|
begin
|
|
49
119
|
json = JSON.parse(body)
|
|
@@ -53,21 +123,45 @@ module ArchiveAPI
|
|
|
53
123
|
rescue JSON::ParserError => e
|
|
54
124
|
raise "Malformed JSON response: #{e.message}"
|
|
55
125
|
end
|
|
56
|
-
when
|
|
57
|
-
|
|
126
|
+
when 400
|
|
127
|
+
# CDX API occasionally returns 400 when page index exceeds total available pages (that is, end of pagination)
|
|
128
|
+
return []
|
|
129
|
+
when 429
|
|
130
|
+
retry_after = retry_after_seconds(response)
|
|
131
|
+
raise RateLimitError.new(
|
|
132
|
+
"Server error 429: #{response.respond_to?(:message) ? response.message : 'Too Many Requests'}",
|
|
133
|
+
retry_after
|
|
134
|
+
)
|
|
135
|
+
when 500, 502, 503, 504
|
|
136
|
+
raise "Server error #{code}: #{response.respond_to?(:message) ? response.message : ''}"
|
|
58
137
|
else
|
|
59
|
-
|
|
60
|
-
[]
|
|
138
|
+
raise "Unexpected API response #{code} for #{url}"
|
|
61
139
|
end
|
|
62
140
|
rescue Net::ReadTimeout, Net::OpenTimeout, StandardError => e
|
|
63
141
|
if retries < max_retries
|
|
64
142
|
retries += 1
|
|
65
|
-
|
|
66
|
-
|
|
143
|
+
jitter = rand(0.0..1.0)
|
|
144
|
+
|
|
145
|
+
if e.is_a?(RateLimitError)
|
|
146
|
+
# if the server provided a Retry-After header, use that; otherwise, use an exponential backoff with a minimum cooldown
|
|
147
|
+
fallback = [DEFAULT_RATE_LIMIT_COOLDOWN, base_delay * (2 ** (retries - 1))].max
|
|
148
|
+
cooldown = (e.retry_after || fallback) + (e.retry_after ? 0 : jitter)
|
|
149
|
+
ArchiveAPI.extend_cdx_cooldown(cooldown)
|
|
150
|
+
|
|
151
|
+
warn "Wayback CDX API rate limited (429) for #{url}. " \
|
|
152
|
+
"Pausing CDX requests for #{cooldown.round(2)}s " \
|
|
153
|
+
"(attempt #{retries}/#{max_retries})..."
|
|
154
|
+
else
|
|
155
|
+
sleep_time = (base_delay * (2 ** (retries - 1))) + jitter
|
|
156
|
+
warn "Error talking to Wayback CDX API (#{e.class}: #{e.message}) for #{url}. " \
|
|
157
|
+
"Retrying in #{sleep_time.round(2)}s (attempt #{retries}/#{max_retries})..."
|
|
158
|
+
sleep(sleep_time)
|
|
159
|
+
end
|
|
160
|
+
|
|
67
161
|
retry
|
|
68
162
|
else
|
|
69
163
|
warn "Giving up on Wayback CDX API for #{url} after #{max_retries} attempts. (Last error: #{e.message})"
|
|
70
|
-
|
|
164
|
+
raise
|
|
71
165
|
end
|
|
72
166
|
end
|
|
73
167
|
end
|
|
@@ -78,8 +172,40 @@ module ArchiveAPI
|
|
|
78
172
|
parameters.push(["filter", "statuscode:2..|30[12378]"]) unless @all
|
|
79
173
|
parameters.push(["from", @from_timestamp.to_s]) if @from_timestamp && @from_timestamp != 0
|
|
80
174
|
parameters.push(["to", @to_timestamp.to_s]) if @to_timestamp && @to_timestamp != 0
|
|
81
|
-
parameters.push(["page", page_index]) if page_index
|
|
175
|
+
parameters.push(["page", page_index.to_s]) if page_index && page_index > 0
|
|
82
176
|
parameters
|
|
83
177
|
end
|
|
84
178
|
|
|
85
|
-
|
|
179
|
+
private
|
|
180
|
+
|
|
181
|
+
def retry_after_seconds(response)
|
|
182
|
+
raw = if HTTPX_AVAILABLE && defined?(HTTPX::Response) && response.is_a?(HTTPX::Response)
|
|
183
|
+
response.headers['retry-after']
|
|
184
|
+
elsif response.respond_to?(:[])
|
|
185
|
+
response['Retry-After'] || response['retry-after']
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
value = Array(raw).first.to_s.strip
|
|
189
|
+
return nil if value.empty?
|
|
190
|
+
return value.to_f if value.match?(/\A\d+(?:\.\d+)?\z/)
|
|
191
|
+
|
|
192
|
+
delay = Time.httpdate(value) - Time.now
|
|
193
|
+
delay.positive? ? delay : 0.0
|
|
194
|
+
rescue ArgumentError
|
|
195
|
+
nil
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
def decompress_body(response)
|
|
199
|
+
body = response.body.to_s
|
|
200
|
+
return body if body.empty?
|
|
201
|
+
|
|
202
|
+
case response['content-encoding']
|
|
203
|
+
when 'gzip'
|
|
204
|
+
Zlib::GzipReader.new(StringIO.new(body)).read rescue body
|
|
205
|
+
when 'deflate'
|
|
206
|
+
Zlib::Inflate.inflate(body) rescue body
|
|
207
|
+
else
|
|
208
|
+
body.strip
|
|
209
|
+
end
|
|
210
|
+
end
|
|
211
|
+
end
|
|
@@ -1,18 +1,30 @@
|
|
|
1
1
|
module PageRequisites
|
|
2
2
|
# regex to find links in href, src, url(), and srcset
|
|
3
3
|
# this ignores data: URIs, mailto:, and anchors
|
|
4
|
-
ASSET_REGEX = /(?:href|src|data-src|data-url)\s*=\s*["']([^"']+)["']|url\(\s*["']?([^"'\)]+)["']?\s*\)|srcset\s*=\s*["']([^"']+)["']/i
|
|
4
|
+
ASSET_REGEX = /(?:(href|src|data-src|data-url)\s*=\s*["']([^"']+)["'])|url\(\s*["']?([^"'\)]+)["']?\s*\)|srcset\s*=\s*["']([^"']+)["']/i
|
|
5
|
+
PAGE_EXTENSIONS = %w[
|
|
6
|
+
.html .htm .shtml .shtm .xhtml
|
|
7
|
+
.asp .aspx .asa .ashx .asmx
|
|
8
|
+
.php .php3 .php4 .php5 .phtml
|
|
9
|
+
.jsp .jspx .do .action
|
|
10
|
+
.cgi .pl .cfm .dll
|
|
11
|
+
].freeze
|
|
5
12
|
|
|
6
13
|
def self.extract(html_content)
|
|
7
14
|
assets = []
|
|
8
|
-
|
|
15
|
+
|
|
9
16
|
html_content.scan(ASSET_REGEX) do |match|
|
|
10
|
-
|
|
11
|
-
url =
|
|
17
|
+
attribute, attr_url, css_url, srcset_url = match
|
|
18
|
+
url = attr_url || css_url || srcset_url
|
|
12
19
|
next unless url
|
|
13
|
-
|
|
14
|
-
#
|
|
15
|
-
|
|
20
|
+
|
|
21
|
+
# href is also used for navigation. Do not turn links to server-rendered
|
|
22
|
+
# pages into prerequisite CDX lookups; doing so can multiply API traffic
|
|
23
|
+
# dramatically on old sites that use extensions such as .php3.
|
|
24
|
+
next if attribute&.downcase == 'href' && page_url?(url)
|
|
25
|
+
|
|
26
|
+
# handle srcset (e.g. comma separated values like "image.jpg 1x, image2.jpg 2w")
|
|
27
|
+
if srcset_url
|
|
16
28
|
url.split(',').each do |src_def|
|
|
17
29
|
src_url = src_def.strip.split(' ').first
|
|
18
30
|
assets << src_url if valid_asset?(src_url)
|
|
@@ -25,9 +37,20 @@ module PageRequisites
|
|
|
25
37
|
assets.uniq
|
|
26
38
|
end
|
|
27
39
|
|
|
40
|
+
def self.page_url?(url)
|
|
41
|
+
path = begin
|
|
42
|
+
URI.parse(url).path
|
|
43
|
+
rescue URI::InvalidURIError
|
|
44
|
+
url.to_s.split(/[?#]/, 2).first
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
ext = File.extname(path.to_s).downcase
|
|
48
|
+
ext.empty? || PAGE_EXTENSIONS.include?(ext)
|
|
49
|
+
end
|
|
50
|
+
|
|
28
51
|
def self.valid_asset?(url)
|
|
29
52
|
return false if url.strip.empty?
|
|
30
53
|
return false if url.start_with?('data:', 'mailto:', '#', 'javascript:')
|
|
31
54
|
true
|
|
32
55
|
end
|
|
33
|
-
end
|
|
56
|
+
end
|
|
@@ -12,7 +12,7 @@ module SubdomainProcessor
|
|
|
12
12
|
@subdomain_queue = Queue.new
|
|
13
13
|
|
|
14
14
|
# scan downloaded files for subdomain links
|
|
15
|
-
initial_files = Dir.glob(File.join(backup_path, "**/*.{html,htm,css,js}"))
|
|
15
|
+
initial_files = Dir.glob(File.join(backup_path, "**/*.{html,htm,shtml,css,js,asp,aspx,ashx,php,jsp,cgi,pl}"))
|
|
16
16
|
puts "Scanning #{initial_files.size} downloaded files for subdomain links..."
|
|
17
17
|
|
|
18
18
|
subdomains_found = scan_files_for_subdomains(initial_files, base_domain)
|
|
@@ -40,11 +40,17 @@ module SubdomainProcessor
|
|
|
40
40
|
private
|
|
41
41
|
|
|
42
42
|
def extract_base_domain(url)
|
|
43
|
-
|
|
43
|
+
# ensure the URL has a scheme for URI parsing
|
|
44
|
+
normalized_url = url.match?(/^https?:\/\//i) ? url : "http://#{url}"
|
|
45
|
+
uri = URI.parse(normalized_url) rescue nil
|
|
44
46
|
return nil unless uri
|
|
45
47
|
|
|
46
|
-
host
|
|
47
|
-
host = host.
|
|
48
|
+
# extract the host (and default to parsing path if host is missing)
|
|
49
|
+
host = uri.host || (uri.path || '').split('/').first
|
|
50
|
+
return nil unless host
|
|
51
|
+
|
|
52
|
+
# strip port numbers if present
|
|
53
|
+
host = host.split(':').first.downcase
|
|
48
54
|
|
|
49
55
|
# extract the base domain (e.g., "example.com" from "sub.example.com")
|
|
50
56
|
parts = host.split('.')
|
|
@@ -120,7 +126,7 @@ module SubdomainProcessor
|
|
|
120
126
|
# if we need to go deeper, scan the newly downloaded files
|
|
121
127
|
if depth + 1 < max_depth
|
|
122
128
|
# get all files in the subdomains directory
|
|
123
|
-
new_files = Dir.glob(File.join(backup_path, "subdomains", "**/*.{html,htm,css,js}"))
|
|
129
|
+
new_files = Dir.glob(File.join(backup_path, "subdomains", "**/*.{html,htm,shtml,css,js,asp,aspx,ashx,php,jsp,cgi,pl}"))
|
|
124
130
|
new_subdomains = scan_files_for_subdomains(new_files, base_domain)
|
|
125
131
|
|
|
126
132
|
# filter out already processed subdomains
|
|
@@ -235,4 +241,4 @@ module SubdomainProcessor
|
|
|
235
241
|
|
|
236
242
|
puts "Rewrote links in #{rewritten_count} files"
|
|
237
243
|
end
|
|
238
|
-
end
|
|
244
|
+
end
|
|
@@ -2,84 +2,116 @@
|
|
|
2
2
|
|
|
3
3
|
module URLRewrite
|
|
4
4
|
# server-side extensions that should work locally
|
|
5
|
-
SERVER_SIDE_EXTS = %w[
|
|
5
|
+
SERVER_SIDE_EXTS = %w[
|
|
6
|
+
.php .php3 .phtml
|
|
7
|
+
.asp .aspx .ashx .asmx .asa
|
|
8
|
+
.jsp .jspx .do .action
|
|
9
|
+
.cgi .pl .py .cfm .shtml
|
|
10
|
+
].freeze
|
|
11
|
+
|
|
12
|
+
def rewrite_html_attr_urls(content, root_prefix = './')
|
|
13
|
+
target_host = current_host
|
|
6
14
|
|
|
7
|
-
def rewrite_html_attr_urls(content)
|
|
8
15
|
# rewrite URLs to relative paths
|
|
9
|
-
content.gsub
|
|
10
|
-
prefix, path, suffix = $1, $2, $3
|
|
11
|
-
|
|
12
|
-
"#{prefix}#{
|
|
16
|
+
content = content.gsub(/(\s(?:href|src|action|data-src|data-url)=["'])https?:\/\/web\.archive\.org\/web\/\d+[a-z_]*\/https?:\/\/[^\/"']+(\/[^"']*)?(["'])/i) do
|
|
17
|
+
prefix, path, suffix = $1, ($2 || "/index.html"), $3
|
|
18
|
+
local = normalize_path_for_local(path, root_prefix)
|
|
19
|
+
"#{prefix}#{local}#{suffix}"
|
|
13
20
|
end
|
|
14
21
|
|
|
15
22
|
# rewrite absolute URLs to same domain as relative
|
|
16
|
-
|
|
23
|
+
if target_host && !target_host.empty?
|
|
24
|
+
content = content.gsub(/(\s(?:href|src|action|data-src|data-url)=["'])https?:\/\/#{Regexp.escape(target_host)}(?::\d+)?(\/[^"']*)?(["'])/i) do
|
|
25
|
+
prefix, path, suffix = $1, ($2 || "/index.html"), $3
|
|
26
|
+
local = normalize_path_for_local(path, root_prefix)
|
|
27
|
+
"#{prefix}#{local}#{suffix}"
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# rewrite root-relative URLs
|
|
32
|
+
content = content.gsub(/(\s(?:href|src|action|data-src|data-url)=["'])\/([^"'\/][^"']*)(["'])/i) do
|
|
17
33
|
prefix, path, suffix = $1, $2, $3
|
|
18
|
-
|
|
19
|
-
"#{prefix}#{
|
|
34
|
+
local = normalize_path_for_local("/#{path}", root_prefix)
|
|
35
|
+
"#{prefix}#{local}#{suffix}"
|
|
20
36
|
end
|
|
21
37
|
|
|
22
38
|
content
|
|
23
39
|
end
|
|
24
40
|
|
|
25
|
-
def rewrite_css_urls(content)
|
|
41
|
+
def rewrite_css_urls(content, root_prefix = './')
|
|
42
|
+
target_host = current_host
|
|
43
|
+
|
|
26
44
|
# rewrite URLs in CSS
|
|
27
|
-
content.gsub
|
|
28
|
-
path =
|
|
29
|
-
|
|
45
|
+
content = content.gsub(/url\(\s*["']?https?:\/\/web\.archive\.org\/web\/\d+[a-z_]*\/https?:\/\/[^\/"'\)]+(\/[^"'\)]*)?["']?\s*\)/i) do
|
|
46
|
+
path = $1 || "/index.html"
|
|
47
|
+
local = normalize_path_for_local(path, root_prefix)
|
|
48
|
+
"url(\"#{local}\")"
|
|
30
49
|
end
|
|
31
50
|
|
|
32
51
|
# rewrite absolute URLs in CSS
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
52
|
+
if target_host && !target_host.empty?
|
|
53
|
+
content = content.gsub(/url\(\s*["']?https?:\/\/#{Regexp.escape(target_host)}(?::\d+)?(\/[^"'\)]*)?["']?\s*\)/i) do
|
|
54
|
+
path = $1 || "/index.html"
|
|
55
|
+
local = normalize_path_for_local(path, root_prefix)
|
|
56
|
+
"url(\"#{local}\")"
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# rewrite root-relative in CSS
|
|
61
|
+
content = content.gsub(/url\(\s*["']?\/([^"'\)\/][^"'\)]*?)["']?\s*\)/i) do
|
|
62
|
+
path = $1
|
|
63
|
+
local = normalize_path_for_local("/#{path}", root_prefix)
|
|
64
|
+
"url(\"#{local}\")"
|
|
36
65
|
end
|
|
37
66
|
|
|
38
67
|
content
|
|
39
68
|
end
|
|
40
69
|
|
|
41
|
-
def rewrite_js_urls(content)
|
|
70
|
+
def rewrite_js_urls(content, root_prefix = './')
|
|
42
71
|
# rewrite archive.org URLs in JavaScript strings
|
|
43
|
-
content.gsub
|
|
44
|
-
quote_start, path, quote_end = $1, $2, $3
|
|
45
|
-
|
|
46
|
-
"#{quote_start}#{
|
|
72
|
+
content.gsub(/(["'])https?:\/\/web\.archive\.org\/web\/\d+[a-z_]*\/https?:\/\/[^\/"']+(\/[^"']*)?(["'])/i) do
|
|
73
|
+
quote_start, path, quote_end = $1, ($2 || "/index.html"), $3
|
|
74
|
+
local = normalize_path_for_local(path, root_prefix)
|
|
75
|
+
"#{quote_start}#{local}#{quote_end}"
|
|
47
76
|
end
|
|
77
|
+
end
|
|
48
78
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
next "#{quote_start}http#{$2}#{quote_end}" if $2.start_with?('s://', '://')
|
|
53
|
-
path = normalize_path_for_local(path)
|
|
54
|
-
"#{quote_start}#{path}#{quote_end}"
|
|
55
|
-
end
|
|
79
|
+
def normalize_path_for_local(path, root_prefix = './')
|
|
80
|
+
path = path.to_s.strip
|
|
81
|
+
return "#{root_prefix}index.html" if path.empty? || path == "/"
|
|
56
82
|
|
|
57
|
-
|
|
58
|
-
|
|
83
|
+
path_part, query_part = path.split('?', 2)
|
|
84
|
+
path_part = "/index.html" if path_part.nil? || path_part.empty? || path_part == "/"
|
|
59
85
|
|
|
60
|
-
|
|
86
|
+
# hash query parameters to match sanitize_and_prepare_id
|
|
87
|
+
if query_part && !query_part.empty?
|
|
88
|
+
q_digest = Digest::SHA256.hexdigest(query_part)[0, 12]
|
|
89
|
+
if path_part.include?('.')
|
|
90
|
+
pre, _sep, post = path_part.rpartition('.')
|
|
91
|
+
path_part = "#{pre}__q#{q_digest}.#{post}"
|
|
92
|
+
else
|
|
93
|
+
path_part = "#{path_part}__q#{q_digest}"
|
|
94
|
+
end
|
|
95
|
+
end
|
|
61
96
|
|
|
62
|
-
def normalize_path_for_local(path)
|
|
63
|
-
return "./index.html" if path.empty? || path == "/"
|
|
64
|
-
|
|
65
|
-
# handle query strings - they're already part of the filename
|
|
66
|
-
path = path.split('?').first if path.include?('?')
|
|
67
|
-
|
|
68
97
|
# check if this is a server-side script
|
|
69
|
-
ext = File.extname(
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
else
|
|
74
|
-
# regular file handling
|
|
75
|
-
path = "./#{path}" unless path.start_with?('./', '/')
|
|
76
|
-
|
|
77
|
-
# if it looks like a directory, add index.html
|
|
78
|
-
if path.end_with?('/') || !path.include?('.')
|
|
79
|
-
path = "#{path.chomp('/')}/index.html"
|
|
98
|
+
ext = File.extname(path_part).downcase
|
|
99
|
+
unless SERVER_SIDE_EXTS.include?(ext)
|
|
100
|
+
if path_part.end_with?('/') || !path_part.include?('.')
|
|
101
|
+
path_part = "#{path_part.chomp('/')}/index.html"
|
|
80
102
|
end
|
|
81
103
|
end
|
|
82
|
-
|
|
83
|
-
|
|
104
|
+
|
|
105
|
+
clean_path = path_part.sub(%r{\A/+}, '')
|
|
106
|
+
"#{root_prefix}#{clean_path}"
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
private
|
|
110
|
+
|
|
111
|
+
def current_host
|
|
112
|
+
return nil unless @base_url
|
|
113
|
+
clean = @base_url.to_s.sub(%r{\A\*\.}, '')
|
|
114
|
+
clean = clean.match?(%r{\Ahttps?://}i) ? clean : "http://#{clean}"
|
|
115
|
+
URI.parse(clean).host rescue nil
|
|
84
116
|
end
|
|
85
117
|
end
|
|
@@ -4,11 +4,13 @@ require 'thread'
|
|
|
4
4
|
require 'net/http'
|
|
5
5
|
require 'fileutils'
|
|
6
6
|
require 'json'
|
|
7
|
+
require 'pathname'
|
|
7
8
|
require 'concurrent-ruby'
|
|
8
9
|
require 'logger'
|
|
9
10
|
require 'zlib'
|
|
10
11
|
require 'stringio'
|
|
11
12
|
require 'digest'
|
|
13
|
+
require 'etc'
|
|
12
14
|
require_relative 'wayback_machine_downloader/tidy_bytes'
|
|
13
15
|
require_relative 'wayback_machine_downloader/to_regex'
|
|
14
16
|
require_relative 'wayback_machine_downloader/archive_api'
|
|
@@ -16,6 +18,13 @@ require_relative 'wayback_machine_downloader/page_requisites'
|
|
|
16
18
|
require_relative 'wayback_machine_downloader/subdom_processor'
|
|
17
19
|
require_relative 'wayback_machine_downloader/url_rewrite'
|
|
18
20
|
|
|
21
|
+
begin
|
|
22
|
+
require 'httpx'
|
|
23
|
+
HTTPX_AVAILABLE = true
|
|
24
|
+
rescue LoadError
|
|
25
|
+
HTTPX_AVAILABLE = false
|
|
26
|
+
end
|
|
27
|
+
|
|
19
28
|
class ConnectionPool
|
|
20
29
|
MAX_AGE = 300
|
|
21
30
|
CLEANUP_INTERVAL = 60
|
|
@@ -66,7 +75,12 @@ class ConnectionPool
|
|
|
66
75
|
def stale?(entry)
|
|
67
76
|
return true if entry.nil? || entry[:http].nil?
|
|
68
77
|
http = entry[:http]
|
|
69
|
-
|
|
78
|
+
|
|
79
|
+
if HTTPX_AVAILABLE && http.is_a?(HTTPX::Session)
|
|
80
|
+
Time.now - entry[:created_at] > MAX_AGE
|
|
81
|
+
else
|
|
82
|
+
!http.started? || (Time.now - entry[:created_at] > MAX_AGE)
|
|
83
|
+
end
|
|
70
84
|
end
|
|
71
85
|
|
|
72
86
|
def build_connection_entry
|
|
@@ -74,7 +88,12 @@ class ConnectionPool
|
|
|
74
88
|
end
|
|
75
89
|
|
|
76
90
|
def safe_finish(http)
|
|
77
|
-
|
|
91
|
+
return if http.nil?
|
|
92
|
+
if HTTPX_AVAILABLE && http.is_a?(HTTPX::Session)
|
|
93
|
+
http.close
|
|
94
|
+
else
|
|
95
|
+
http.finish if http&.started?
|
|
96
|
+
end
|
|
78
97
|
rescue StandardError
|
|
79
98
|
nil
|
|
80
99
|
end
|
|
@@ -113,14 +132,37 @@ class ConnectionPool
|
|
|
113
132
|
end
|
|
114
133
|
|
|
115
134
|
def create_connection
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
135
|
+
if HTTPX_AVAILABLE
|
|
136
|
+
HTTPX.with(
|
|
137
|
+
timeout: {
|
|
138
|
+
connect_timeout: DEFAULT_TIMEOUT,
|
|
139
|
+
read_timeout: DEFAULT_TIMEOUT,
|
|
140
|
+
write_timeout: DEFAULT_TIMEOUT
|
|
141
|
+
}
|
|
142
|
+
)
|
|
143
|
+
else
|
|
144
|
+
http = Net::HTTP.new("web.archive.org", 443)
|
|
145
|
+
http.use_ssl = true
|
|
146
|
+
http.read_timeout = DEFAULT_TIMEOUT
|
|
147
|
+
http.open_timeout = DEFAULT_TIMEOUT
|
|
148
|
+
http.keep_alive_timeout = 30
|
|
149
|
+
http.max_retries = MAX_RETRIES
|
|
150
|
+
|
|
151
|
+
retries = 0
|
|
152
|
+
begin
|
|
153
|
+
http.start
|
|
154
|
+
rescue Net::OpenTimeout, Net::ReadTimeout, Errno::ECONNRESET, Errno::ETIMEDOUT, SocketError => e
|
|
155
|
+
if retries < MAX_RETRIES
|
|
156
|
+
retries += 1
|
|
157
|
+
sleep(2 * retries)
|
|
158
|
+
retry
|
|
159
|
+
else
|
|
160
|
+
raise e
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
http
|
|
165
|
+
end
|
|
124
166
|
end
|
|
125
167
|
end
|
|
126
168
|
|
|
@@ -130,7 +172,7 @@ class WaybackMachineDownloader
|
|
|
130
172
|
include SubdomainProcessor
|
|
131
173
|
include URLRewrite
|
|
132
174
|
|
|
133
|
-
VERSION = "2.4.
|
|
175
|
+
VERSION = "2.4.9"
|
|
134
176
|
DEFAULT_TIMEOUT = 30
|
|
135
177
|
MAX_RETRIES = 3
|
|
136
178
|
RETRY_DELAY = 2
|
|
@@ -164,7 +206,14 @@ class WaybackMachineDownloader
|
|
|
164
206
|
@all = params[:all]
|
|
165
207
|
@keep_duplicates = params[:keep_duplicates] || false
|
|
166
208
|
@maximum_pages = params[:maximum_pages] ? params[:maximum_pages].to_i : 100
|
|
167
|
-
|
|
209
|
+
default_threads = begin
|
|
210
|
+
[Etc.nprocessors, 4].max
|
|
211
|
+
rescue StandardError
|
|
212
|
+
4
|
|
213
|
+
end
|
|
214
|
+
threads_param = params[:threads_count] ? params[:threads_count].to_i : 0
|
|
215
|
+
threads_param = default_threads if threads_param <= 0
|
|
216
|
+
@threads_count = [threads_param, 1].max
|
|
168
217
|
@rewritten = params[:rewritten]
|
|
169
218
|
@reset = params[:reset]
|
|
170
219
|
@keep = params[:keep]
|
|
@@ -174,6 +223,7 @@ class WaybackMachineDownloader
|
|
|
174
223
|
@connection_pool = ConnectionPool.new(CONNECTION_POOL_SIZE)
|
|
175
224
|
@db_mutex = Mutex.new
|
|
176
225
|
@rewrite = params[:rewrite] || false
|
|
226
|
+
@delay = params[:delay] ? params[:delay].to_f : 0.0
|
|
177
227
|
@recursive_subdomains = params[:recursive_subdomains] || false
|
|
178
228
|
@subdomain_depth = params[:subdomain_depth] || 1
|
|
179
229
|
@snapshot_at = params[:snapshot_at] ? params[:snapshot_at].to_i : nil
|
|
@@ -181,6 +231,12 @@ class WaybackMachineDownloader
|
|
|
181
231
|
@page_requisites = params[:page_requisites] || false
|
|
182
232
|
@pending_jobs = Concurrent::AtomicFixnum.new(0)
|
|
183
233
|
|
|
234
|
+
@db_buffer = []
|
|
235
|
+
@db_buffer_mutex = Mutex.new
|
|
236
|
+
@db_last_flush = Time.now
|
|
237
|
+
@db_flush_threshold = 50 # flush to disk every 50 completed files
|
|
238
|
+
@db_flush_interval = 5.0 # or flush every 5 seconds even if count isn't met
|
|
239
|
+
|
|
184
240
|
# URL for rejecting invalid/unencoded wayback urls
|
|
185
241
|
@url_regexp = /^(([A-Za-z][A-Za-z0-9+.-]*):((\/\/(((([A-Za-z0-9._~-])|(%[ABCDEFabcdef0-9][ABCDEFabcdef0-9])|([!$&'('')'*+,;=]))+)(:([0-9]*))?)(((\/((([A-Za-z0-9._~-])|(%[ABCDEFabcdef0-9][ABCDEFabcdef0-9])|([!$&'('')'*+,;=])|:|@)*))*)))|((\/(((([A-Za-z0-9._~-])|(%[ABCDEFabcdef0-9][ABCDEFabcdef0-9])|([!$&'('')'*+,;=])|:|@)+)(\/((([A-Za-z0-9._~-])|(%[ABCDEFabcdef0-9][ABCDEFabcdef0-9])|([!$&'('')'*+,;=])|:|@)*))*)?))|((((([A-Za-z0-9._~-])|(%[ABCDEFabcdef0-9][ABCDEFabcdef0-9])|([!$&'('')'*+,;=])|:|@)+)(\/((([A-Za-z0-9._~-])|(%[ABCDEFabcdef0-9][ABCDEFabcdef0-9])|([!$&'('')'*+,;=])|:|@)*))*)))(\?((([A-Za-z0-9._~-])|(%[ABCDEFabcdef0-9][ABCDEFabcdef0-9])|([!$&'('')'*+,;=])|:|@)|\/|\?)*)?(\#((([A-Za-z0-9._~-])|(%[ABCDEFabcdef0-9][ABCDEFabcdef0-9])|([!$&'('')'*+,;=])|:|@)|\/|\?)*)?)$/
|
|
186
242
|
|
|
@@ -274,12 +330,8 @@ class WaybackMachineDownloader
|
|
|
274
330
|
puts "Loading snapshot list from #{cdx_path}"
|
|
275
331
|
begin
|
|
276
332
|
snapshot_list_to_consider = JSON.parse(File.read(cdx_path))
|
|
277
|
-
puts "Loaded #{snapshot_list_to_consider.length} snapshots from cache
|
|
278
|
-
puts
|
|
333
|
+
puts "Loaded #{snapshot_list_to_consider.length} snapshots from cache.\n\n"
|
|
279
334
|
return Concurrent::Array.new(snapshot_list_to_consider)
|
|
280
|
-
rescue JSON::ParserError => e
|
|
281
|
-
puts "Error reading snapshot cache file #{cdx_path}: #{e.message}. Refetching..."
|
|
282
|
-
FileUtils.rm_f(cdx_path)
|
|
283
335
|
rescue => e
|
|
284
336
|
puts "Error loading snapshot cache #{cdx_path}: #{e.message}. Refetching..."
|
|
285
337
|
FileUtils.rm_f(cdx_path)
|
|
@@ -287,89 +339,38 @@ class WaybackMachineDownloader
|
|
|
287
339
|
end
|
|
288
340
|
|
|
289
341
|
snapshot_list_to_consider = Concurrent::Array.new
|
|
290
|
-
mutex = Mutex.new
|
|
291
|
-
|
|
292
|
-
# if snapshot_at is set, limit CDX queries to snapshots at or before that timestamp
|
|
293
342
|
original_to = @to_timestamp
|
|
294
|
-
if @snapshot_at
|
|
295
|
-
@to_timestamp = @snapshot_at
|
|
296
|
-
end
|
|
343
|
+
@to_timestamp = @snapshot_at if @snapshot_at
|
|
297
344
|
|
|
298
345
|
puts "Getting snapshot pages from Wayback Machine API..."
|
|
299
346
|
|
|
300
|
-
#
|
|
347
|
+
# fetch initial page (page 0)
|
|
301
348
|
@connection_pool.with_connection do |connection|
|
|
302
349
|
initial_list = get_raw_list_from_api(@base_url, 0, connection)
|
|
303
|
-
initial_list
|
|
304
|
-
mutex.synchronize do
|
|
350
|
+
if initial_list && !initial_list.empty?
|
|
305
351
|
snapshot_list_to_consider.concat(initial_list)
|
|
306
352
|
print "."
|
|
307
353
|
$stdout.flush
|
|
308
354
|
end
|
|
309
355
|
end
|
|
310
356
|
|
|
311
|
-
#
|
|
357
|
+
# sequentially fetch subsequent pages
|
|
312
358
|
unless @exact_url || snapshot_list_to_consider.empty?
|
|
313
359
|
page_index = 1
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
# Determine the range of pages to fetch in this batch
|
|
320
|
-
end_index = [page_index + batch_size, @maximum_pages].min
|
|
321
|
-
current_batch = (page_index...end_index).to_a
|
|
322
|
-
|
|
323
|
-
# Create futures for concurrent API calls
|
|
324
|
-
futures = current_batch.map do |page|
|
|
325
|
-
Concurrent::Future.execute(executor: fetch_pool) do
|
|
326
|
-
result = nil
|
|
327
|
-
@connection_pool.with_connection do |connection|
|
|
328
|
-
result = get_raw_list_from_api(@base_url, page, connection)
|
|
329
|
-
end
|
|
330
|
-
result ||= []
|
|
331
|
-
[page, result]
|
|
332
|
-
end
|
|
333
|
-
end
|
|
334
|
-
|
|
335
|
-
results = []
|
|
336
|
-
|
|
337
|
-
futures.each do |future|
|
|
338
|
-
begin
|
|
339
|
-
val = future.value
|
|
340
|
-
# only append if valid
|
|
341
|
-
if val && val.is_a?(Array) && val.first.is_a?(Integer)
|
|
342
|
-
results << val
|
|
343
|
-
end
|
|
344
|
-
rescue => e
|
|
345
|
-
puts "\nError fetching page #{future}: #{e.message}"
|
|
346
|
-
end
|
|
347
|
-
end
|
|
348
|
-
|
|
349
|
-
# Sort results by page number to maintain order
|
|
350
|
-
results.sort_by! { |page, _| page }
|
|
351
|
-
|
|
352
|
-
# Process results and check for empty pages
|
|
353
|
-
results.each do |page, result|
|
|
354
|
-
if result.nil? || result.empty?
|
|
355
|
-
continue_fetching = false
|
|
356
|
-
break
|
|
357
|
-
else
|
|
358
|
-
mutex.synchronize do
|
|
359
|
-
snapshot_list_to_consider.concat(result)
|
|
360
|
-
print "."
|
|
361
|
-
$stdout.flush
|
|
362
|
-
end
|
|
363
|
-
end
|
|
364
|
-
end
|
|
365
|
-
|
|
366
|
-
page_index = end_index
|
|
360
|
+
while page_index < @maximum_pages
|
|
361
|
+
result = nil
|
|
362
|
+
@connection_pool.with_connection do |connection|
|
|
363
|
+
result = get_raw_list_from_api(@base_url, page_index, connection)
|
|
364
|
+
end
|
|
367
365
|
|
|
368
|
-
|
|
366
|
+
if result.nil? || result.empty?
|
|
367
|
+
break
|
|
368
|
+
else
|
|
369
|
+
snapshot_list_to_consider.concat(result)
|
|
370
|
+
print "."
|
|
371
|
+
$stdout.flush
|
|
372
|
+
page_index += 1
|
|
369
373
|
end
|
|
370
|
-
ensure
|
|
371
|
-
fetch_pool.shutdown
|
|
372
|
-
fetch_pool.wait_for_termination
|
|
373
374
|
end
|
|
374
375
|
end
|
|
375
376
|
|
|
@@ -378,7 +379,7 @@ class WaybackMachineDownloader
|
|
|
378
379
|
# save the fetched list to the cache file
|
|
379
380
|
begin
|
|
380
381
|
FileUtils.mkdir_p(File.dirname(cdx_path))
|
|
381
|
-
File.write(cdx_path, JSON.pretty_generate(snapshot_list_to_consider.to_a))
|
|
382
|
+
File.write(cdx_path, JSON.pretty_generate(snapshot_list_to_consider.to_a))
|
|
382
383
|
puts "Saved snapshot list to #{cdx_path}"
|
|
383
384
|
rescue => e
|
|
384
385
|
puts "Error saving snapshot cache to #{cdx_path}: #{e.message}"
|
|
@@ -391,6 +392,19 @@ class WaybackMachineDownloader
|
|
|
391
392
|
snapshot_list_to_consider
|
|
392
393
|
end
|
|
393
394
|
|
|
395
|
+
def extract_path_and_query(file_url)
|
|
396
|
+
return "" if file_url.nil? || file_url.empty?
|
|
397
|
+
normalized = file_url.match?(%r{\Ahttps?://}i) ? file_url : "http://#{file_url}"
|
|
398
|
+
uri = URI.parse(normalized)
|
|
399
|
+
# returns path + query
|
|
400
|
+
path = uri.path.to_s
|
|
401
|
+
path += "?#{uri.query}" if uri.query && !uri.query.empty?
|
|
402
|
+
path.sub(%r{\A/}, '')
|
|
403
|
+
rescue URI::InvalidURIError
|
|
404
|
+
# for ill-formed URLs
|
|
405
|
+
file_url.sub(%r{\Ahttps?://[^/]+/?}i, '').sub(%r{\A[^/]+/?}, '')
|
|
406
|
+
end
|
|
407
|
+
|
|
394
408
|
# Get a composite snapshot file list for a specific timestamp
|
|
395
409
|
def get_composite_snapshot_file_list(target_timestamp)
|
|
396
410
|
file_versions = {}
|
|
@@ -399,7 +413,7 @@ class WaybackMachineDownloader
|
|
|
399
413
|
next if file_timestamp.to_i > target_timestamp
|
|
400
414
|
|
|
401
415
|
# allow empty path by treating missing tail as empty string
|
|
402
|
-
raw_tail = file_url
|
|
416
|
+
raw_tail = extract_path_and_query(file_url)
|
|
403
417
|
file_id = sanitize_and_prepare_id(raw_tail, file_url)
|
|
404
418
|
next if file_id.nil?
|
|
405
419
|
next if match_exclude_filter(file_url)
|
|
@@ -424,7 +438,7 @@ class WaybackMachineDownloader
|
|
|
424
438
|
get_all_snapshots_to_consider.each do |file_timestamp, file_url|
|
|
425
439
|
next unless file_url.include?('/')
|
|
426
440
|
|
|
427
|
-
raw_tail = file_url
|
|
441
|
+
raw_tail = extract_path_and_query(file_url)
|
|
428
442
|
file_id = sanitize_and_prepare_id(raw_tail, file_url)
|
|
429
443
|
if file_id.nil?
|
|
430
444
|
puts "Malformed file url, ignoring: #{file_url}"
|
|
@@ -439,7 +453,7 @@ class WaybackMachineDownloader
|
|
|
439
453
|
elsif !match_only_filter(file_url)
|
|
440
454
|
puts "File url doesn't match only filter, ignoring: #{file_url}"
|
|
441
455
|
elsif file_list_curated[file_id]
|
|
442
|
-
unless file_list_curated[file_id][:timestamp] > file_timestamp
|
|
456
|
+
unless file_list_curated[file_id][:timestamp].to_i > file_timestamp.to_i
|
|
443
457
|
file_list_curated[file_id] = { file_url: file_url, timestamp: file_timestamp }
|
|
444
458
|
end
|
|
445
459
|
else
|
|
@@ -455,7 +469,7 @@ class WaybackMachineDownloader
|
|
|
455
469
|
get_all_snapshots_to_consider.each do |file_timestamp, file_url|
|
|
456
470
|
next unless file_url.include?('/')
|
|
457
471
|
|
|
458
|
-
raw_tail = file_url
|
|
472
|
+
raw_tail = extract_path_and_query(file_url)
|
|
459
473
|
file_id = sanitize_and_prepare_id(raw_tail, file_url)
|
|
460
474
|
if file_id.nil?
|
|
461
475
|
puts "Malformed file url, ignoring: #{file_url}"
|
|
@@ -545,12 +559,40 @@ class WaybackMachineDownloader
|
|
|
545
559
|
end
|
|
546
560
|
|
|
547
561
|
def append_to_db(file_id)
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
562
|
+
flush_needed = false
|
|
563
|
+
|
|
564
|
+
@db_buffer_mutex.synchronize do
|
|
565
|
+
@db_buffer << file_id
|
|
566
|
+
if @db_buffer.size >= @db_flush_threshold || (Time.now - @db_last_flush) >= @db_flush_interval
|
|
567
|
+
flush_needed = true
|
|
568
|
+
end
|
|
569
|
+
end
|
|
570
|
+
|
|
571
|
+
flush_db if flush_needed
|
|
572
|
+
end
|
|
573
|
+
|
|
574
|
+
def flush_db
|
|
575
|
+
lines_to_write = nil
|
|
576
|
+
|
|
577
|
+
@db_buffer_mutex.synchronize do
|
|
578
|
+
return if @db_buffer.empty?
|
|
579
|
+
lines_to_write = @db_buffer.dup
|
|
580
|
+
@db_buffer.clear
|
|
581
|
+
@db_last_flush = Time.now
|
|
582
|
+
end
|
|
583
|
+
|
|
584
|
+
return if lines_to_write.nil? || lines_to_write.empty?
|
|
585
|
+
|
|
586
|
+
begin
|
|
587
|
+
FileUtils.mkdir_p(File.dirname(db_path))
|
|
588
|
+
File.open(db_path, 'a') do |f|
|
|
589
|
+
lines_to_write.each { |id| f.puts(id) }
|
|
590
|
+
end
|
|
591
|
+
rescue StandardError => e
|
|
592
|
+
@logger.error("Failed to write batch to #{db_path}: #{e.message}")
|
|
593
|
+
|
|
594
|
+
@db_buffer_mutex.synchronize do
|
|
595
|
+
@db_buffer.unshift(*lines_to_write)
|
|
554
596
|
end
|
|
555
597
|
end
|
|
556
598
|
end
|
|
@@ -587,9 +629,18 @@ class WaybackMachineDownloader
|
|
|
587
629
|
end
|
|
588
630
|
end
|
|
589
631
|
end
|
|
590
|
-
|
|
632
|
+
|
|
633
|
+
@@engine_printed = false
|
|
634
|
+
|
|
591
635
|
def download_files
|
|
592
636
|
start_time = Time.now
|
|
637
|
+
|
|
638
|
+
unless @@engine_printed
|
|
639
|
+
engine_name = HTTPX_AVAILABLE ? "HTTPX" : "Net::HTTP"
|
|
640
|
+
puts "Connection engine used: #{color(engine_name, :green)}"
|
|
641
|
+
@@engine_printed = true
|
|
642
|
+
end
|
|
643
|
+
|
|
593
644
|
puts "Downloading #{@base_url} to #{backup_path} from Wayback Machine archives."
|
|
594
645
|
|
|
595
646
|
FileUtils.mkdir_p(backup_path)
|
|
@@ -608,7 +659,7 @@ class WaybackMachineDownloader
|
|
|
608
659
|
|
|
609
660
|
# Load IDs of already downloaded files
|
|
610
661
|
downloaded_ids = load_downloaded_ids
|
|
611
|
-
|
|
662
|
+
|
|
612
663
|
# We use a thread-safe Set to track what we have queued/downloaded in this session
|
|
613
664
|
# to avoid infinite loops with page requisites
|
|
614
665
|
@session_downloaded_ids = Concurrent::Set.new
|
|
@@ -624,7 +675,7 @@ class WaybackMachineDownloader
|
|
|
624
675
|
if skipped_count > 0
|
|
625
676
|
puts "Found #{skipped_count} previously downloaded files, skipping them."
|
|
626
677
|
end
|
|
627
|
-
|
|
678
|
+
|
|
628
679
|
if remaining_count == 0 && !@page_requisites
|
|
629
680
|
puts "All matching files have already been downloaded."
|
|
630
681
|
cleanup
|
|
@@ -691,6 +742,9 @@ class WaybackMachineDownloader
|
|
|
691
742
|
download_success = false
|
|
692
743
|
downloaded_path = nil
|
|
693
744
|
|
|
745
|
+
# if delay was set by the user
|
|
746
|
+
sleep(@delay) if @delay > 0
|
|
747
|
+
|
|
694
748
|
# fast-path for resumed runs: if file already exists locally, avoid HTTP work entirely
|
|
695
749
|
existing_path = local_path_for_file_id(file_remote_info[:file_id])
|
|
696
750
|
if existing_path && File.exist?(existing_path)
|
|
@@ -702,19 +756,19 @@ class WaybackMachineDownloader
|
|
|
702
756
|
|
|
703
757
|
append_to_db(file_remote_info[:file_id])
|
|
704
758
|
|
|
705
|
-
if @page_requisites && File.extname(existing_path) =~ /\.(html?|php|asp|aspx|jsp)$/i
|
|
759
|
+
if @page_requisites && File.extname(existing_path) =~ /\.(html?|shtml|php|asp|aspx|ashx|jsp|do|action|cgi|pl|cfm)$/i
|
|
706
760
|
process_page_requisites(existing_path, file_remote_info)
|
|
707
761
|
end
|
|
708
762
|
return
|
|
709
763
|
end
|
|
710
|
-
|
|
764
|
+
|
|
711
765
|
@connection_pool.with_connection do |connection|
|
|
712
766
|
result_message, downloaded_path = download_file(file_remote_info, connection)
|
|
713
|
-
|
|
767
|
+
|
|
714
768
|
if downloaded_path && File.exist?(downloaded_path)
|
|
715
769
|
download_success = true
|
|
716
770
|
end
|
|
717
|
-
|
|
771
|
+
|
|
718
772
|
@download_mutex.synchronize do
|
|
719
773
|
@processed_file_count += 1 if @processed_file_count < @total_to_download
|
|
720
774
|
# only print if it's a "User" file or a requisite we found
|
|
@@ -724,7 +778,7 @@ class WaybackMachineDownloader
|
|
|
724
778
|
|
|
725
779
|
if download_success
|
|
726
780
|
append_to_db(file_remote_info[:file_id])
|
|
727
|
-
|
|
781
|
+
|
|
728
782
|
if @page_requisites && downloaded_path && File.extname(downloaded_path) =~ /\.(html?|php|asp|aspx|jsp)$/i
|
|
729
783
|
process_page_requisites(downloaded_path, file_remote_info)
|
|
730
784
|
end
|
|
@@ -732,7 +786,7 @@ class WaybackMachineDownloader
|
|
|
732
786
|
rescue => e
|
|
733
787
|
@logger.error("Error processing file #{file_remote_info[:file_url]}: #{e.message}")
|
|
734
788
|
end
|
|
735
|
-
|
|
789
|
+
|
|
736
790
|
def process_page_requisites(file_path, parent_remote_info)
|
|
737
791
|
return unless File.exist?(file_path)
|
|
738
792
|
|
|
@@ -744,7 +798,7 @@ class WaybackMachineDownloader
|
|
|
744
798
|
# prepare base URI for resolving relative paths
|
|
745
799
|
parent_raw = parent_remote_info[:file_url]
|
|
746
800
|
parent_raw = "http://#{parent_raw}" unless parent_raw.match?(/^https?:\/\//)
|
|
747
|
-
|
|
801
|
+
|
|
748
802
|
begin
|
|
749
803
|
base_uri = URI(parent_raw)
|
|
750
804
|
# calculate the "root" host of the site we are downloading to compare later
|
|
@@ -778,7 +832,7 @@ class WaybackMachineDownloader
|
|
|
778
832
|
path = resolved_uri.path
|
|
779
833
|
ext = File.extname(path).downcase
|
|
780
834
|
if ext.empty? || ['.html', '.htm', '.php', '.asp', '.aspx'].include?(ext)
|
|
781
|
-
next
|
|
835
|
+
next
|
|
782
836
|
end
|
|
783
837
|
|
|
784
838
|
# construct the original URL to query the Wayback API
|
|
@@ -849,7 +903,7 @@ class WaybackMachineDownloader
|
|
|
849
903
|
rescue Errno::EEXIST, Errno::ENOTDIR => e
|
|
850
904
|
file_already_existing = nil
|
|
851
905
|
check_path = dir_path
|
|
852
|
-
|
|
906
|
+
|
|
853
907
|
# walk up the path to find the specific file that is blocking directory creation
|
|
854
908
|
while check_path != "." && check_path != "/"
|
|
855
909
|
if File.exist?(check_path) && !File.directory?(check_path)
|
|
@@ -864,11 +918,11 @@ class WaybackMachineDownloader
|
|
|
864
918
|
if file_already_existing
|
|
865
919
|
file_already_existing_temporary = file_already_existing + '.temp'
|
|
866
920
|
file_already_existing_permanent = file_already_existing + '/index.html'
|
|
867
|
-
|
|
921
|
+
|
|
868
922
|
FileUtils::mv file_already_existing, file_already_existing_temporary
|
|
869
923
|
FileUtils::mkdir_p file_already_existing
|
|
870
924
|
FileUtils::mv file_already_existing_temporary, file_already_existing_permanent
|
|
871
|
-
|
|
925
|
+
|
|
872
926
|
puts "#{file_already_existing} -> #{file_already_existing_permanent}"
|
|
873
927
|
# retry the directory creation now that the path is clear
|
|
874
928
|
structure_dir_path dir_path
|
|
@@ -880,13 +934,13 @@ class WaybackMachineDownloader
|
|
|
880
934
|
|
|
881
935
|
def rewrite_local_files
|
|
882
936
|
puts "Scanning #{backup_path} for files to rewrite..."
|
|
883
|
-
files = Dir.glob(File.join(backup_path, "**/*.{html,htm,css,js,php,asp,aspx,jsp}"))
|
|
884
|
-
|
|
937
|
+
files = Dir.glob(File.join(backup_path, "**/*.{html,htm,shtml,css,js,php,asp,aspx,ashx,jsp,do,action,cgi,pl,cfm}"))
|
|
938
|
+
|
|
885
939
|
puts "Found #{files.size} files. Rewriting links for local browsing..."
|
|
886
|
-
|
|
940
|
+
|
|
887
941
|
pool = Concurrent::FixedThreadPool.new(@threads_count)
|
|
888
942
|
progress = Concurrent::AtomicFixnum.new(0)
|
|
889
|
-
|
|
943
|
+
|
|
890
944
|
files.each do |file_path|
|
|
891
945
|
pool.post do
|
|
892
946
|
rewrite_urls_to_relative(file_path)
|
|
@@ -894,7 +948,7 @@ class WaybackMachineDownloader
|
|
|
894
948
|
print "\rProgress: #{current}/#{files.size}" if current % 100 == 0
|
|
895
949
|
end
|
|
896
950
|
end
|
|
897
|
-
|
|
951
|
+
|
|
898
952
|
pool.shutdown
|
|
899
953
|
pool.wait_for_termination
|
|
900
954
|
puts "\nFinished rewriting all files."
|
|
@@ -902,55 +956,82 @@ class WaybackMachineDownloader
|
|
|
902
956
|
|
|
903
957
|
def rewrite_urls_to_relative(file_path)
|
|
904
958
|
return unless File.exist?(file_path)
|
|
905
|
-
|
|
959
|
+
|
|
906
960
|
file_ext = File.extname(file_path).downcase
|
|
907
|
-
|
|
961
|
+
|
|
908
962
|
begin
|
|
909
963
|
content = File.binread(file_path)
|
|
910
964
|
|
|
911
|
-
# detect encoding for HTML files
|
|
912
|
-
if
|
|
913
|
-
|
|
914
|
-
|
|
965
|
+
# detect encoding for HTML/server-rendered files
|
|
966
|
+
if %w[.html .htm .php .asp .jsp .shtml].include?(file_ext)
|
|
967
|
+
encoding_match = content.match(/<meta.*?charset=["'\s]?([^"'\s>;]+)/i)
|
|
968
|
+
encoding_name = encoding_match ? encoding_match.captures.first : 'UTF-8'
|
|
969
|
+
|
|
970
|
+
begin
|
|
971
|
+
encoding = Encoding.find(encoding_name)
|
|
972
|
+
rescue ArgumentError
|
|
973
|
+
encoding = Encoding::UTF_8
|
|
974
|
+
end
|
|
975
|
+
|
|
976
|
+
content.force_encoding(encoding)
|
|
915
977
|
else
|
|
916
978
|
content.force_encoding('UTF-8')
|
|
917
979
|
end
|
|
918
980
|
|
|
981
|
+
# convert the content to valid UTF-8
|
|
982
|
+
if !content.valid_encoding? || content.encoding != Encoding::UTF_8
|
|
983
|
+
begin
|
|
984
|
+
content = content.encode(Encoding::UTF_8, invalid: :replace, undef: :replace, replace: '')
|
|
985
|
+
rescue Encoding::UndefinedConversionError, Encoding::InvalidByteSequenceError, ArgumentError
|
|
986
|
+
content = content.tidy_bytes
|
|
987
|
+
end
|
|
988
|
+
end
|
|
989
|
+
|
|
990
|
+
root_prefix = site_root_relative_prefix(file_path)
|
|
991
|
+
|
|
919
992
|
# URLs in HTML attributes
|
|
920
|
-
content = rewrite_html_attr_urls(content)
|
|
921
|
-
|
|
993
|
+
content = rewrite_html_attr_urls(content, root_prefix)
|
|
994
|
+
|
|
922
995
|
# URLs in CSS
|
|
923
|
-
content = rewrite_css_urls(content)
|
|
924
|
-
|
|
996
|
+
content = rewrite_css_urls(content, root_prefix)
|
|
997
|
+
|
|
925
998
|
# URLs in JavaScript
|
|
926
|
-
content = rewrite_js_urls(content)
|
|
927
|
-
|
|
928
|
-
# for URLs that start with a single slash, make them relative
|
|
929
|
-
content.gsub!(/(\s(?:href|src|action|data-src|data-url)=["'])\/([^"'\/][^"']*)(["'])/i) do
|
|
930
|
-
prefix, path, suffix = $1, $2, $3
|
|
931
|
-
"#{prefix}./#{path}#{suffix}"
|
|
932
|
-
end
|
|
933
|
-
|
|
934
|
-
# for URLs in CSS that start with a single slash, make them relative
|
|
935
|
-
content.gsub!(/url\(\s*["']?\/([^"'\)\/][^"'\)]*?)["']?\s*\)/i) do
|
|
936
|
-
path = $1
|
|
937
|
-
"url(\"./#{path}\")"
|
|
938
|
-
end
|
|
999
|
+
content = rewrite_js_urls(content, root_prefix)
|
|
939
1000
|
|
|
940
1001
|
# save the modified content back to the file
|
|
941
1002
|
File.binwrite(file_path, content)
|
|
942
1003
|
puts "Rewrote URLs in #{file_path} to be relative."
|
|
943
1004
|
rescue Errno::ENOENT => e
|
|
944
1005
|
@logger.warn("Error reading file #{file_path}: #{e.message}")
|
|
1006
|
+
rescue StandardError => e
|
|
1007
|
+
@logger.error("Failed to rewrite URLs in #{file_path}: #{e.message}")
|
|
945
1008
|
end
|
|
946
1009
|
end
|
|
947
1010
|
|
|
1011
|
+
def site_root_relative_prefix(file_path)
|
|
1012
|
+
file_dir = File.dirname(File.expand_path(file_path))
|
|
1013
|
+
root_dir = File.expand_path(backup_path)
|
|
1014
|
+
|
|
1015
|
+
begin
|
|
1016
|
+
relative_dir = Pathname.new(file_dir).relative_path_from(Pathname.new(root_dir)).to_s
|
|
1017
|
+
rescue ArgumentError
|
|
1018
|
+
return './'
|
|
1019
|
+
end
|
|
1020
|
+
|
|
1021
|
+
return './' if relative_dir == '.' || relative_dir.empty?
|
|
1022
|
+
|
|
1023
|
+
depth = relative_dir.split(/[\\\/]+/).reject(&:empty?).length
|
|
1024
|
+
return './' if depth <= 0
|
|
1025
|
+
|
|
1026
|
+
'../' * depth
|
|
1027
|
+
end
|
|
1028
|
+
|
|
948
1029
|
def download_file (file_remote_info, http)
|
|
949
1030
|
current_encoding = "".encoding
|
|
950
1031
|
file_url = file_remote_info[:file_url].encode(current_encoding)
|
|
951
1032
|
file_id = file_remote_info[:file_id]
|
|
952
1033
|
file_timestamp = file_remote_info[:timestamp]
|
|
953
|
-
|
|
1034
|
+
|
|
954
1035
|
# sanitize file_id to ensure it is a valid path component
|
|
955
1036
|
raw_path_elements = file_id.split('/')
|
|
956
1037
|
|
|
@@ -964,7 +1045,7 @@ class WaybackMachineDownloader
|
|
|
964
1045
|
element
|
|
965
1046
|
end
|
|
966
1047
|
end
|
|
967
|
-
|
|
1048
|
+
|
|
968
1049
|
current_backup_path = backup_path
|
|
969
1050
|
|
|
970
1051
|
if file_id == ""
|
|
@@ -993,7 +1074,7 @@ class WaybackMachineDownloader
|
|
|
993
1074
|
|
|
994
1075
|
case status
|
|
995
1076
|
when :saved
|
|
996
|
-
if @rewrite && File.extname(file_path) =~ /\.(html?|css|js)$/i
|
|
1077
|
+
if @rewrite && File.extname(file_path) =~ /\.(html?|shtml|css|js|php|asp|aspx|jsp|cgi|pl)$/i
|
|
997
1078
|
rewrite_urls_to_relative(file_path)
|
|
998
1079
|
end
|
|
999
1080
|
return ["#{color("[SAVED]", :green)} #{file_url} (#{@processed_file_count + 1}/#{@total_to_download})", file_path]
|
|
@@ -1079,7 +1160,7 @@ class WaybackMachineDownloader
|
|
|
1079
1160
|
end
|
|
1080
1161
|
logger
|
|
1081
1162
|
end
|
|
1082
|
-
|
|
1163
|
+
|
|
1083
1164
|
# safely sanitize a file id (or id+timestamp)
|
|
1084
1165
|
def sanitize_and_prepare_id(raw, file_url)
|
|
1085
1166
|
return nil if raw.nil?
|
|
@@ -1221,65 +1302,85 @@ class WaybackMachineDownloader
|
|
|
1221
1302
|
return :skipped_not_found
|
|
1222
1303
|
end
|
|
1223
1304
|
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
if response['content-encoding'] == 'gzip' && body && !body.empty?
|
|
1235
|
-
begin
|
|
1236
|
-
gz = Zlib::GzipReader.new(StringIO.new(body))
|
|
1237
|
-
decompressed_body = gz.read
|
|
1238
|
-
gz.close
|
|
1239
|
-
file.write(decompressed_body)
|
|
1240
|
-
rescue Zlib::GzipFile::Error => e
|
|
1241
|
-
@logger.warn("Failure decompressing gzip file #{file_url}: #{e.message}. Writing raw body.")
|
|
1242
|
-
file.write(body)
|
|
1243
|
-
end
|
|
1305
|
+
if HTTPX_AVAILABLE && connection.is_a?(HTTPX::Session)
|
|
1306
|
+
response = connection.get(wayback_url)
|
|
1307
|
+
|
|
1308
|
+
raise response.error if response.is_a?(HTTPX::ErrorResponse)
|
|
1309
|
+
|
|
1310
|
+
code = response.status
|
|
1311
|
+
|
|
1312
|
+
save_response_body = lambda do
|
|
1313
|
+
if response.respond_to?(:copy_to)
|
|
1314
|
+
response.copy_to(file_path)
|
|
1244
1315
|
else
|
|
1245
|
-
|
|
1316
|
+
response.body.copy_to(file_path)
|
|
1317
|
+
end
|
|
1318
|
+
end
|
|
1319
|
+
else
|
|
1320
|
+
request = Net::HTTP::Get.new(URI(wayback_url))
|
|
1321
|
+
request["Connection"] = "keep-alive"
|
|
1322
|
+
request["User-Agent"] = "WaybackMachineDownloader/#{VERSION}"
|
|
1323
|
+
request["Accept-Encoding"] = "gzip, deflate"
|
|
1324
|
+
|
|
1325
|
+
response = connection.request(request)
|
|
1326
|
+
code = response.code.to_i
|
|
1327
|
+
|
|
1328
|
+
save_response_body = lambda do
|
|
1329
|
+
File.open(file_path, "wb") do |file|
|
|
1330
|
+
body = response.body
|
|
1331
|
+
if response['content-encoding'] == 'gzip' && body && !body.empty?
|
|
1332
|
+
begin
|
|
1333
|
+
gz = Zlib::GzipReader.new(StringIO.new(body))
|
|
1334
|
+
decompressed_body = gz.read
|
|
1335
|
+
gz.close
|
|
1336
|
+
file.write(decompressed_body)
|
|
1337
|
+
rescue Zlib::GzipFile::Error => e
|
|
1338
|
+
@logger.warn("Failure decompressing gzip file #{file_url}: #{e.message}. Writing raw body.")
|
|
1339
|
+
file.write(body)
|
|
1340
|
+
end
|
|
1341
|
+
else
|
|
1342
|
+
file.write(body) if body
|
|
1343
|
+
end
|
|
1246
1344
|
end
|
|
1247
1345
|
end
|
|
1248
1346
|
end
|
|
1249
1347
|
|
|
1250
1348
|
if @all
|
|
1251
|
-
case
|
|
1252
|
-
when
|
|
1349
|
+
case code
|
|
1350
|
+
when 200..599
|
|
1253
1351
|
save_response_body.call
|
|
1254
|
-
if
|
|
1255
|
-
@logger.info("Saved redirect page for #{file_url} (status #{
|
|
1256
|
-
elsif
|
|
1257
|
-
@logger.info("Saved error page for #{file_url} (status #{
|
|
1352
|
+
if (300..399).cover?(code)
|
|
1353
|
+
@logger.info("Saved redirect page for #{file_url} (status #{code}).")
|
|
1354
|
+
elsif (400..599).cover?(code)
|
|
1355
|
+
@logger.info("Saved error page for #{file_url} (status #{code}).")
|
|
1258
1356
|
end
|
|
1259
1357
|
return :saved
|
|
1260
1358
|
else
|
|
1261
1359
|
# for any other response type when --all is true, treat as an error to be retried or failed
|
|
1262
|
-
raise "Unhandled HTTP response: #{
|
|
1360
|
+
raise "Unhandled HTTP response: #{code}"
|
|
1263
1361
|
end
|
|
1264
1362
|
else # not @all (our default behavior)
|
|
1265
|
-
|
|
1266
|
-
when Net::HTTPSuccess
|
|
1363
|
+
if (200..299).cover?(code)
|
|
1267
1364
|
save_response_body.call
|
|
1268
1365
|
return :saved
|
|
1269
|
-
|
|
1366
|
+
elsif (300..399).cover?(code)
|
|
1270
1367
|
raise "Too many redirects for #{file_url}" if redirect_count >= 5
|
|
1271
|
-
location =
|
|
1368
|
+
location = if HTTPX_AVAILABLE && connection.is_a?(HTTPX::Session)
|
|
1369
|
+
response.headers['location']
|
|
1370
|
+
else
|
|
1371
|
+
response['location']
|
|
1372
|
+
end
|
|
1272
1373
|
@logger.warn("Redirect found for #{file_url} -> #{location}")
|
|
1273
1374
|
redirected_source = resolve_redirect_source(file_url, location)
|
|
1274
1375
|
return download_with_retry(file_path, redirected_source, file_timestamp, connection, redirect_count + 1)
|
|
1275
|
-
|
|
1376
|
+
elsif code == 429
|
|
1276
1377
|
sleep(RATE_LIMIT * 2)
|
|
1277
1378
|
raise "Rate limited, retrying..."
|
|
1278
|
-
|
|
1379
|
+
elsif code == 404
|
|
1279
1380
|
@logger.warn("File not found, skipping: #{file_url}")
|
|
1280
1381
|
return :skipped_not_found
|
|
1281
1382
|
else
|
|
1282
|
-
raise "HTTP Error: #{
|
|
1383
|
+
raise "HTTP Error: #{code}"
|
|
1283
1384
|
end
|
|
1284
1385
|
end
|
|
1285
1386
|
|
|
@@ -1297,6 +1398,7 @@ class WaybackMachineDownloader
|
|
|
1297
1398
|
end
|
|
1298
1399
|
|
|
1299
1400
|
def cleanup
|
|
1401
|
+
flush_db
|
|
1300
1402
|
@connection_pool.shutdown
|
|
1301
1403
|
|
|
1302
1404
|
if @failed_downloads.any?
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: wayback_machine_downloader_straw
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 2.4.
|
|
4
|
+
version: 2.4.9
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- strawberrymaster
|
|
@@ -87,14 +87,14 @@ required_ruby_version: !ruby/object:Gem::Requirement
|
|
|
87
87
|
requirements:
|
|
88
88
|
- - ">="
|
|
89
89
|
- !ruby/object:Gem::Version
|
|
90
|
-
version: 3.
|
|
90
|
+
version: 3.0.0
|
|
91
91
|
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
92
92
|
requirements:
|
|
93
93
|
- - ">="
|
|
94
94
|
- !ruby/object:Gem::Version
|
|
95
95
|
version: '0'
|
|
96
96
|
requirements: []
|
|
97
|
-
rubygems_version: 4.0.
|
|
97
|
+
rubygems_version: 4.0.16
|
|
98
98
|
specification_version: 4
|
|
99
99
|
summary: Download an entire website from the Wayback Machine.
|
|
100
100
|
test_files: []
|