wayback_machine_downloader_straw 2.4.8 → 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/lib/wayback_machine_downloader/archive_api.rb +121 -23
- data/lib/wayback_machine_downloader/page_requisites.rb +31 -8
- data/lib/wayback_machine_downloader/subdom_processor.rb +3 -3
- data/lib/wayback_machine_downloader/url_rewrite.rb +82 -50
- data/lib/wayback_machine_downloader.rb +60 -101
- 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
|
|
@@ -1,38 +1,101 @@
|
|
|
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
|
|
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
|
|
11
75
|
match_type = nil
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
.sub(/\Ahttps?:\/\//i, '')
|
|
17
|
-
.split(/[?#]/, 2)
|
|
18
|
-
.first
|
|
19
|
-
has_path = host_and_rest.include?('/')
|
|
20
|
-
|
|
21
|
-
unless has_wildcard || has_path
|
|
76
|
+
unless @exact_url || clean_url.include?('*')
|
|
77
|
+
if clean_url.end_with?('/')
|
|
78
|
+
clean_url = "#{clean_url}*"
|
|
79
|
+
elsif !clean_url.include?('/')
|
|
22
80
|
match_type = "prefix"
|
|
81
|
+
else
|
|
82
|
+
clean_url = "#{clean_url}/*"
|
|
23
83
|
end
|
|
24
84
|
end
|
|
25
85
|
|
|
26
86
|
request_url = URI("https://web.archive.org/cdx/search/cdx")
|
|
27
|
-
params = [["output", "json"], ["url",
|
|
87
|
+
params = [["output", "json"], ["url", clean_url]] + parameters_for_api(page_index)
|
|
28
88
|
params << ["matchType", match_type] if match_type
|
|
29
89
|
request_url.query = URI.encode_www_form(params)
|
|
30
90
|
|
|
31
91
|
retries = 0
|
|
32
92
|
max_retries = (@max_retries || 3)
|
|
33
|
-
base_delay =
|
|
93
|
+
base_delay = 2
|
|
34
94
|
|
|
35
95
|
begin
|
|
96
|
+
# acquire slot from the process-wide proactive pacer before sending request
|
|
97
|
+
ArchiveAPI.pace_cdx_request
|
|
98
|
+
|
|
36
99
|
if HTTPX_AVAILABLE && http.is_a?(HTTPX::Session)
|
|
37
100
|
response = http.get(request_url)
|
|
38
101
|
raise response.error if response.is_a?(HTTPX::ErrorResponse)
|
|
@@ -60,27 +123,45 @@ module ArchiveAPI
|
|
|
60
123
|
rescue JSON::ParserError => e
|
|
61
124
|
raise "Malformed JSON response: #{e.message}"
|
|
62
125
|
end
|
|
63
|
-
when
|
|
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
|
|
64
136
|
raise "Server error #{code}: #{response.respond_to?(:message) ? response.message : ''}"
|
|
65
137
|
else
|
|
66
|
-
|
|
67
|
-
[]
|
|
138
|
+
raise "Unexpected API response #{code} for #{url}"
|
|
68
139
|
end
|
|
69
140
|
rescue Net::ReadTimeout, Net::OpenTimeout, StandardError => e
|
|
70
141
|
if retries < max_retries
|
|
71
142
|
retries += 1
|
|
72
|
-
|
|
73
143
|
jitter = rand(0.0..1.0)
|
|
74
|
-
sleep_time = (base_delay * (2 ** (retries - 1))) + jitter
|
|
75
144
|
|
|
76
|
-
|
|
77
|
-
|
|
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
|
|
78
160
|
|
|
79
|
-
sleep(sleep_time)
|
|
80
161
|
retry
|
|
81
162
|
else
|
|
82
163
|
warn "Giving up on Wayback CDX API for #{url} after #{max_retries} attempts. (Last error: #{e.message})"
|
|
83
|
-
|
|
164
|
+
raise
|
|
84
165
|
end
|
|
85
166
|
end
|
|
86
167
|
end
|
|
@@ -91,12 +172,29 @@ module ArchiveAPI
|
|
|
91
172
|
parameters.push(["filter", "statuscode:2..|30[12378]"]) unless @all
|
|
92
173
|
parameters.push(["from", @from_timestamp.to_s]) if @from_timestamp && @from_timestamp != 0
|
|
93
174
|
parameters.push(["to", @to_timestamp.to_s]) if @to_timestamp && @to_timestamp != 0
|
|
94
|
-
parameters.push(["page", page_index]) if page_index
|
|
175
|
+
parameters.push(["page", page_index.to_s]) if page_index && page_index > 0
|
|
95
176
|
parameters
|
|
96
177
|
end
|
|
97
178
|
|
|
98
179
|
private
|
|
99
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
|
+
|
|
100
198
|
def decompress_body(response)
|
|
101
199
|
body = response.body.to_s
|
|
102
200
|
return body if body.empty?
|
|
@@ -110,4 +208,4 @@ module ArchiveAPI
|
|
|
110
208
|
body.strip
|
|
111
209
|
end
|
|
112
210
|
end
|
|
113
|
-
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)
|
|
@@ -126,7 +126,7 @@ module SubdomainProcessor
|
|
|
126
126
|
# if we need to go deeper, scan the newly downloaded files
|
|
127
127
|
if depth + 1 < max_depth
|
|
128
128
|
# get all files in the subdomains directory
|
|
129
|
-
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}"))
|
|
130
130
|
new_subdomains = scan_files_for_subdomains(new_files, base_domain)
|
|
131
131
|
|
|
132
132
|
# filter out already processed subdomains
|
|
@@ -241,4 +241,4 @@ module SubdomainProcessor
|
|
|
241
241
|
|
|
242
242
|
puts "Rewrote links in #{rewritten_count} files"
|
|
243
243
|
end
|
|
244
|
-
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
|
|
@@ -147,7 +147,20 @@ class ConnectionPool
|
|
|
147
147
|
http.open_timeout = DEFAULT_TIMEOUT
|
|
148
148
|
http.keep_alive_timeout = 30
|
|
149
149
|
http.max_retries = MAX_RETRIES
|
|
150
|
-
|
|
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
|
+
|
|
151
164
|
http
|
|
152
165
|
end
|
|
153
166
|
end
|
|
@@ -159,7 +172,7 @@ class WaybackMachineDownloader
|
|
|
159
172
|
include SubdomainProcessor
|
|
160
173
|
include URLRewrite
|
|
161
174
|
|
|
162
|
-
VERSION = "2.4.
|
|
175
|
+
VERSION = "2.4.9"
|
|
163
176
|
DEFAULT_TIMEOUT = 30
|
|
164
177
|
MAX_RETRIES = 3
|
|
165
178
|
RETRY_DELAY = 2
|
|
@@ -317,12 +330,8 @@ class WaybackMachineDownloader
|
|
|
317
330
|
puts "Loading snapshot list from #{cdx_path}"
|
|
318
331
|
begin
|
|
319
332
|
snapshot_list_to_consider = JSON.parse(File.read(cdx_path))
|
|
320
|
-
puts "Loaded #{snapshot_list_to_consider.length} snapshots from cache
|
|
321
|
-
puts
|
|
333
|
+
puts "Loaded #{snapshot_list_to_consider.length} snapshots from cache.\n\n"
|
|
322
334
|
return Concurrent::Array.new(snapshot_list_to_consider)
|
|
323
|
-
rescue JSON::ParserError => e
|
|
324
|
-
puts "Error reading snapshot cache file #{cdx_path}: #{e.message}. Refetching..."
|
|
325
|
-
FileUtils.rm_f(cdx_path)
|
|
326
335
|
rescue => e
|
|
327
336
|
puts "Error loading snapshot cache #{cdx_path}: #{e.message}. Refetching..."
|
|
328
337
|
FileUtils.rm_f(cdx_path)
|
|
@@ -330,89 +339,38 @@ class WaybackMachineDownloader
|
|
|
330
339
|
end
|
|
331
340
|
|
|
332
341
|
snapshot_list_to_consider = Concurrent::Array.new
|
|
333
|
-
mutex = Mutex.new
|
|
334
|
-
|
|
335
|
-
# if snapshot_at is set, limit CDX queries to snapshots at or before that timestamp
|
|
336
342
|
original_to = @to_timestamp
|
|
337
|
-
if @snapshot_at
|
|
338
|
-
@to_timestamp = @snapshot_at
|
|
339
|
-
end
|
|
343
|
+
@to_timestamp = @snapshot_at if @snapshot_at
|
|
340
344
|
|
|
341
345
|
puts "Getting snapshot pages from Wayback Machine API..."
|
|
342
346
|
|
|
343
|
-
#
|
|
347
|
+
# fetch initial page (page 0)
|
|
344
348
|
@connection_pool.with_connection do |connection|
|
|
345
349
|
initial_list = get_raw_list_from_api(@base_url, 0, connection)
|
|
346
|
-
initial_list
|
|
347
|
-
mutex.synchronize do
|
|
350
|
+
if initial_list && !initial_list.empty?
|
|
348
351
|
snapshot_list_to_consider.concat(initial_list)
|
|
349
352
|
print "."
|
|
350
353
|
$stdout.flush
|
|
351
354
|
end
|
|
352
355
|
end
|
|
353
356
|
|
|
354
|
-
#
|
|
357
|
+
# sequentially fetch subsequent pages
|
|
355
358
|
unless @exact_url || snapshot_list_to_consider.empty?
|
|
356
359
|
page_index = 1
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
# Determine the range of pages to fetch in this batch
|
|
363
|
-
end_index = [page_index + batch_size, @maximum_pages].min
|
|
364
|
-
current_batch = (page_index...end_index).to_a
|
|
365
|
-
|
|
366
|
-
# Create futures for concurrent API calls
|
|
367
|
-
futures = current_batch.map do |page|
|
|
368
|
-
Concurrent::Future.execute(executor: fetch_pool) do
|
|
369
|
-
result = nil
|
|
370
|
-
@connection_pool.with_connection do |connection|
|
|
371
|
-
result = get_raw_list_from_api(@base_url, page, connection)
|
|
372
|
-
end
|
|
373
|
-
result ||= []
|
|
374
|
-
[page, result]
|
|
375
|
-
end
|
|
376
|
-
end
|
|
377
|
-
|
|
378
|
-
results = []
|
|
379
|
-
|
|
380
|
-
futures.each do |future|
|
|
381
|
-
begin
|
|
382
|
-
val = future.value
|
|
383
|
-
# only append if valid
|
|
384
|
-
if val && val.is_a?(Array) && val.first.is_a?(Integer)
|
|
385
|
-
results << val
|
|
386
|
-
end
|
|
387
|
-
rescue => e
|
|
388
|
-
puts "\nError fetching page #{future}: #{e.message}"
|
|
389
|
-
end
|
|
390
|
-
end
|
|
391
|
-
|
|
392
|
-
# Sort results by page number to maintain order
|
|
393
|
-
results.sort_by! { |page, _| page }
|
|
394
|
-
|
|
395
|
-
# Process results and check for empty pages
|
|
396
|
-
results.each do |page, result|
|
|
397
|
-
if result.nil? || result.empty?
|
|
398
|
-
continue_fetching = false
|
|
399
|
-
break
|
|
400
|
-
else
|
|
401
|
-
mutex.synchronize do
|
|
402
|
-
snapshot_list_to_consider.concat(result)
|
|
403
|
-
print "."
|
|
404
|
-
$stdout.flush
|
|
405
|
-
end
|
|
406
|
-
end
|
|
407
|
-
end
|
|
408
|
-
|
|
409
|
-
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
|
|
410
365
|
|
|
411
|
-
|
|
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
|
|
412
373
|
end
|
|
413
|
-
ensure
|
|
414
|
-
fetch_pool.shutdown
|
|
415
|
-
fetch_pool.wait_for_termination
|
|
416
374
|
end
|
|
417
375
|
end
|
|
418
376
|
|
|
@@ -421,7 +379,7 @@ class WaybackMachineDownloader
|
|
|
421
379
|
# save the fetched list to the cache file
|
|
422
380
|
begin
|
|
423
381
|
FileUtils.mkdir_p(File.dirname(cdx_path))
|
|
424
|
-
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))
|
|
425
383
|
puts "Saved snapshot list to #{cdx_path}"
|
|
426
384
|
rescue => e
|
|
427
385
|
puts "Error saving snapshot cache to #{cdx_path}: #{e.message}"
|
|
@@ -434,6 +392,19 @@ class WaybackMachineDownloader
|
|
|
434
392
|
snapshot_list_to_consider
|
|
435
393
|
end
|
|
436
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
|
+
|
|
437
408
|
# Get a composite snapshot file list for a specific timestamp
|
|
438
409
|
def get_composite_snapshot_file_list(target_timestamp)
|
|
439
410
|
file_versions = {}
|
|
@@ -442,7 +413,7 @@ class WaybackMachineDownloader
|
|
|
442
413
|
next if file_timestamp.to_i > target_timestamp
|
|
443
414
|
|
|
444
415
|
# allow empty path by treating missing tail as empty string
|
|
445
|
-
raw_tail = file_url
|
|
416
|
+
raw_tail = extract_path_and_query(file_url)
|
|
446
417
|
file_id = sanitize_and_prepare_id(raw_tail, file_url)
|
|
447
418
|
next if file_id.nil?
|
|
448
419
|
next if match_exclude_filter(file_url)
|
|
@@ -467,7 +438,7 @@ class WaybackMachineDownloader
|
|
|
467
438
|
get_all_snapshots_to_consider.each do |file_timestamp, file_url|
|
|
468
439
|
next unless file_url.include?('/')
|
|
469
440
|
|
|
470
|
-
raw_tail = file_url
|
|
441
|
+
raw_tail = extract_path_and_query(file_url)
|
|
471
442
|
file_id = sanitize_and_prepare_id(raw_tail, file_url)
|
|
472
443
|
if file_id.nil?
|
|
473
444
|
puts "Malformed file url, ignoring: #{file_url}"
|
|
@@ -482,7 +453,7 @@ class WaybackMachineDownloader
|
|
|
482
453
|
elsif !match_only_filter(file_url)
|
|
483
454
|
puts "File url doesn't match only filter, ignoring: #{file_url}"
|
|
484
455
|
elsif file_list_curated[file_id]
|
|
485
|
-
unless file_list_curated[file_id][:timestamp] > file_timestamp
|
|
456
|
+
unless file_list_curated[file_id][:timestamp].to_i > file_timestamp.to_i
|
|
486
457
|
file_list_curated[file_id] = { file_url: file_url, timestamp: file_timestamp }
|
|
487
458
|
end
|
|
488
459
|
else
|
|
@@ -498,7 +469,7 @@ class WaybackMachineDownloader
|
|
|
498
469
|
get_all_snapshots_to_consider.each do |file_timestamp, file_url|
|
|
499
470
|
next unless file_url.include?('/')
|
|
500
471
|
|
|
501
|
-
raw_tail = file_url
|
|
472
|
+
raw_tail = extract_path_and_query(file_url)
|
|
502
473
|
file_id = sanitize_and_prepare_id(raw_tail, file_url)
|
|
503
474
|
if file_id.nil?
|
|
504
475
|
puts "Malformed file url, ignoring: #{file_url}"
|
|
@@ -785,7 +756,7 @@ class WaybackMachineDownloader
|
|
|
785
756
|
|
|
786
757
|
append_to_db(file_remote_info[:file_id])
|
|
787
758
|
|
|
788
|
-
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
|
|
789
760
|
process_page_requisites(existing_path, file_remote_info)
|
|
790
761
|
end
|
|
791
762
|
return
|
|
@@ -963,7 +934,7 @@ class WaybackMachineDownloader
|
|
|
963
934
|
|
|
964
935
|
def rewrite_local_files
|
|
965
936
|
puts "Scanning #{backup_path} for files to rewrite..."
|
|
966
|
-
files = Dir.glob(File.join(backup_path, "**/*.{html,htm,css,js,php,asp,aspx,jsp}"))
|
|
937
|
+
files = Dir.glob(File.join(backup_path, "**/*.{html,htm,shtml,css,js,php,asp,aspx,ashx,jsp,do,action,cgi,pl,cfm}"))
|
|
967
938
|
|
|
968
939
|
puts "Found #{files.size} files. Rewriting links for local browsing..."
|
|
969
940
|
|
|
@@ -991,8 +962,8 @@ class WaybackMachineDownloader
|
|
|
991
962
|
begin
|
|
992
963
|
content = File.binread(file_path)
|
|
993
964
|
|
|
994
|
-
# detect encoding for HTML files
|
|
995
|
-
if
|
|
965
|
+
# detect encoding for HTML/server-rendered files
|
|
966
|
+
if %w[.html .htm .php .asp .jsp .shtml].include?(file_ext)
|
|
996
967
|
encoding_match = content.match(/<meta.*?charset=["'\s]?([^"'\s>;]+)/i)
|
|
997
968
|
encoding_name = encoding_match ? encoding_match.captures.first : 'UTF-8'
|
|
998
969
|
|
|
@@ -1016,28 +987,16 @@ class WaybackMachineDownloader
|
|
|
1016
987
|
end
|
|
1017
988
|
end
|
|
1018
989
|
|
|
990
|
+
root_prefix = site_root_relative_prefix(file_path)
|
|
991
|
+
|
|
1019
992
|
# URLs in HTML attributes
|
|
1020
|
-
content = rewrite_html_attr_urls(content)
|
|
993
|
+
content = rewrite_html_attr_urls(content, root_prefix)
|
|
1021
994
|
|
|
1022
995
|
# URLs in CSS
|
|
1023
|
-
content = rewrite_css_urls(content)
|
|
996
|
+
content = rewrite_css_urls(content, root_prefix)
|
|
1024
997
|
|
|
1025
998
|
# URLs in JavaScript
|
|
1026
|
-
content = rewrite_js_urls(content)
|
|
1027
|
-
|
|
1028
|
-
root_prefix = site_root_relative_prefix(file_path)
|
|
1029
|
-
|
|
1030
|
-
# rewrite root-absolute links to paths relative to the downloaded site root
|
|
1031
|
-
content.gsub!(/(\s(?:href|src|action|data-src|data-url)=["'])\/([^"'\/][^"']*)(["'])/i) do
|
|
1032
|
-
prefix, path, suffix = $1, $2, $3
|
|
1033
|
-
"#{prefix}#{root_prefix}#{path}#{suffix}"
|
|
1034
|
-
end
|
|
1035
|
-
|
|
1036
|
-
# apply the same root-relative conversion to CSS url(...) references
|
|
1037
|
-
content.gsub!(/url\(\s*["']?\/([^"'\)\/][^"'\)]*?)["']?\s*\)/i) do
|
|
1038
|
-
path = $1
|
|
1039
|
-
"url(\"#{root_prefix}#{path}\")"
|
|
1040
|
-
end
|
|
999
|
+
content = rewrite_js_urls(content, root_prefix)
|
|
1041
1000
|
|
|
1042
1001
|
# save the modified content back to the file
|
|
1043
1002
|
File.binwrite(file_path, content)
|
|
@@ -1115,7 +1074,7 @@ class WaybackMachineDownloader
|
|
|
1115
1074
|
|
|
1116
1075
|
case status
|
|
1117
1076
|
when :saved
|
|
1118
|
-
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
|
|
1119
1078
|
rewrite_urls_to_relative(file_path)
|
|
1120
1079
|
end
|
|
1121
1080
|
return ["#{color("[SAVED]", :green)} #{file_url} (#{@processed_file_count + 1}/#{@total_to_download})", file_path]
|
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: []
|