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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 558d3187ee31faeadb08cf83e32a87307ae9d55a3327206598f27a78fb715e08
4
- data.tar.gz: 9845999e0e618afde419869bb01b04277aca318aa80b238feb8252540fc16315
3
+ metadata.gz: cdb773f6bbfb5404d827d6c484f4b8aa0ba2b1c4aed192c86ba12f137526f814
4
+ data.tar.gz: a8a898bdf0640c40e6946d78e3135bb04e8fa0f76ab14a267448a514a503af2f
5
5
  SHA512:
6
- metadata.gz: b323c1065ea1ab1d3c5909458cae726462ba1b88fd89effe8cd1efbdd1301d2022363c56b191f8ddbe28c0143fe87dfc94ca847865bb08e3564e29ba36f231a4
7
- data.tar.gz: 00c4e775ee05e176e1048d6e5ddd9ddc436edaf9bf7fea61dbab33480e4adc499ea8a3a584e591858aad3a4c2f10096a3b6eb1ff6c8146f26ba6a3d42f104e32
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
- if url && !@exact_url
12
- normalized_url = url.to_s
13
- has_wildcard = normalized_url.include?('*')
14
- host_and_rest = normalized_url
15
- .sub(/\Ahttps?:\/\//i, '')
16
- .split(/[?#]/, 2)
17
- .first
18
- has_path = host_and_rest.include?('/')
19
-
20
- unless has_wildcard || has_path
21
- url = "#{normalized_url}/*"
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", url]] + parameters_for_api(page_index)
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
- delay = WaybackMachineDownloader::RETRY_DELAY rescue 2
93
+ base_delay = 2
32
94
 
33
95
  begin
34
- request = Net::HTTP::Get.new(request_url)
35
- request["User-Agent"] = "wmd-straw/#{WaybackMachineDownloader::VERSION}"
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
- case response.code.to_i
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 429, 500, 502, 503, 504
57
- raise "Server error #{response.code}: #{response.message}"
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
- warn "Unexpected API response #{response.code} for #{url}"
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
- warn "Error talking to Wayback CDX API (#{e.class}: #{e.message}) for #{url}, retry #{retries}/#{max_retries}..."
66
- sleep(delay * retries)
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
- end
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
- # match is an array of capture groups; find the one that matched
11
- url = match.compact.first
17
+ attribute, attr_url, css_url, srcset_url = match
18
+ url = attr_url || css_url || srcset_url
12
19
  next unless url
13
-
14
- # handle srcset (e.g. comma separated values like "image.jpg 1x, image2.jpg 2x")
15
- if url.include?(',') && (url.include?(' 1x') || url.include?(' 2w'))
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
- uri = URI.parse(url.gsub(/^https?:\/\//, '').split('/').first) rescue nil
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 = uri.host || uri.path.split('/').first
47
- host = host.downcase
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[.php .asp .aspx .jsp .cgi .pl .py].freeze
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!(/(\s(?:href|src|action|data-src|data-url)=["'])https?:\/\/web\.archive\.org\/web\/\d+(?:id_)?\/https?:\/\/[^\/]+([^"']*)(["'])/i) do
10
- prefix, path, suffix = $1, $2, $3
11
- path = normalize_path_for_local(path)
12
- "#{prefix}#{path}#{suffix}"
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
- content.gsub!(/(\s(?:href|src|action|data-src|data-url)=["'])https?:\/\/[^\/]+([^"']*)(["'])/i) do
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
- path = normalize_path_for_local(path)
19
- "#{prefix}#{path}#{suffix}"
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!(/url\(\s*["']?https?:\/\/web\.archive\.org\/web\/\d+(?:id_)?\/https?:\/\/[^\/]+([^"'\)]*?)["']?\s*\)/i) do
28
- path = normalize_path_for_local($1)
29
- "url(\"#{path}\")"
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
- content.gsub!(/url\(\s*["']?https?:\/\/[^\/]+([^"'\)]*?)["']?\s*\)/i) do
34
- path = normalize_path_for_local($1)
35
- "url(\"#{path}\")"
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!(/(["'])https?:\/\/web\.archive\.org\/web\/\d+(?:id_)?\/https?:\/\/[^\/]+([^"']*)(["'])/i) do
44
- quote_start, path, quote_end = $1, $2, $3
45
- path = normalize_path_for_local(path)
46
- "#{quote_start}#{path}#{quote_end}"
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
- # rewrite absolute URLs in JavaScript
50
- content.gsub!(/(["'])https?:\/\/[^\/]+([^"']*)(["'])/i) do
51
- quote_start, path, quote_end = $1, $2, $3
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
- content
58
- end
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
- private
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(path).downcase
70
- if SERVER_SIDE_EXTS.include?(ext)
71
- # keep the path as-is but ensure it starts with ./
72
- path = "./#{path}" unless path.start_with?('./', '/')
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
- path
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
- !http.started? || (Time.now - entry[:created_at] > MAX_AGE)
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
- http.finish if http&.started?
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
- http = Net::HTTP.new("web.archive.org", 443)
117
- http.use_ssl = true
118
- http.read_timeout = DEFAULT_TIMEOUT
119
- http.open_timeout = DEFAULT_TIMEOUT
120
- http.keep_alive_timeout = 30
121
- http.max_retries = MAX_RETRIES
122
- http.start
123
- http
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.7"
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
- @threads_count = [params[:threads_count].to_i, 1].max
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
- # Fetch the initial set of snapshots, sequentially
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
- # Fetch additional pages if the exact URL flag is not set and the first page wasn't empty
357
+ # sequentially fetch subsequent pages
312
358
  unless @exact_url || snapshot_list_to_consider.empty?
313
359
  page_index = 1
314
- batch_size = [@threads_count, 5].min
315
- continue_fetching = true
316
- fetch_pool = Concurrent::FixedThreadPool.new([@threads_count, 1].max)
317
- begin
318
- while continue_fetching && page_index < @maximum_pages
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
- sleep(RATE_LIMIT) if continue_fetching
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)) # Convert Concurrent::Array back to Array for JSON
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.split('/')[3..-1]&.join('/') || ''
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.split('/')[3..-1]&.join('/') || ''
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.split('/')[3..-1]&.join('/') || ''
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
- @db_mutex.synchronize do
549
- begin
550
- FileUtils.mkdir_p(File.dirname(db_path))
551
- File.open(db_path, 'a') { |f| f.puts(file_id) }
552
- rescue => e
553
- @logger.error("Failed to append downloaded file ID #{file_id} to #{db_path}: #{e.message}")
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 file_ext == '.html' || file_ext == '.htm' || file_ext == '.php' || file_ext == '.asp'
913
- encoding = content.match(/<meta\s+charset=["']?([^"'>]+)/i)&.captures&.first || 'UTF-8'
914
- content.force_encoding(encoding) rescue content.force_encoding('UTF-8')
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
- request = Net::HTTP::Get.new(URI(wayback_url))
1225
- request["Connection"] = "keep-alive"
1226
- request["User-Agent"] = "WaybackMachineDownloader/#{VERSION}"
1227
- request["Accept-Encoding"] = "gzip, deflate"
1228
-
1229
- response = connection.request(request)
1230
-
1231
- save_response_body = lambda do
1232
- File.open(file_path, "wb") do |file|
1233
- body = response.body
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
- file.write(body) if body
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 response
1252
- when Net::HTTPSuccess, Net::HTTPRedirection, Net::HTTPClientError, Net::HTTPServerError
1349
+ case code
1350
+ when 200..599
1253
1351
  save_response_body.call
1254
- if response.is_a?(Net::HTTPRedirection)
1255
- @logger.info("Saved redirect page for #{file_url} (status #{response.code}).")
1256
- elsif response.is_a?(Net::HTTPClientError) || response.is_a?(Net::HTTPServerError)
1257
- @logger.info("Saved error page for #{file_url} (status #{response.code}).")
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: #{response.code} #{response.message}"
1360
+ raise "Unhandled HTTP response: #{code}"
1263
1361
  end
1264
1362
  else # not @all (our default behavior)
1265
- case response
1266
- when Net::HTTPSuccess
1363
+ if (200..299).cover?(code)
1267
1364
  save_response_body.call
1268
1365
  return :saved
1269
- when Net::HTTPRedirection
1366
+ elsif (300..399).cover?(code)
1270
1367
  raise "Too many redirects for #{file_url}" if redirect_count >= 5
1271
- location = response['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
- when Net::HTTPTooManyRequests
1376
+ elsif code == 429
1276
1377
  sleep(RATE_LIMIT * 2)
1277
1378
  raise "Rate limited, retrying..."
1278
- when Net::HTTPNotFound
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: #{response.code} #{response.message}"
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.7
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.4.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.6
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: []