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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 12259647dc99ef1c263b3d797098233be4f194bf1a4e9bc047e1dfb4d0fc9468
4
- data.tar.gz: 5d9d6bd47efca6b43a5c1d6c2606bb2871edea0b4dc781f206bbab8c9c66aaa0
3
+ metadata.gz: cdb773f6bbfb5404d827d6c484f4b8aa0ba2b1c4aed192c86ba12f137526f814
4
+ data.tar.gz: a8a898bdf0640c40e6946d78e3135bb04e8fa0f76ab14a267448a514a503af2f
5
5
  SHA512:
6
- metadata.gz: 670efe274663448a9967d2e39920ce234af1ba5c0aae82135c56f64899904f510096ac19b6367a5b90af43d3878b156a21b2fb1138a2e567041b55205fc97c67
7
- data.tar.gz: 2d4d6e927664ea831c0611bbbc89d74ee6c144629f8afb2b2a58428cff1f78846832979bd994dfd97a51cff03ee52ab9c5c09beebc1b007248f1c043aa3bdd2a
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
- if url && !@exact_url
13
- normalized_url = url.to_s
14
- has_wildcard = normalized_url.include?('*')
15
- host_and_rest = normalized_url
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", url]] + parameters_for_api(page_index)
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 = WaybackMachineDownloader::RETRY_DELAY rescue 2
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 429, 500, 502, 503, 504
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
- warn "Unexpected API response #{code} for #{url}"
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
- warn "Error talking to Wayback CDX API (#{e.class}: #{e.message}) for #{url}. " \
77
- "Retrying in #{sleep_time.round(2)}s (attempt #{retries}/#{max_retries})..."
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
- # 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)
@@ -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[.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
@@ -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
- http.start
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.8"
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
- # Fetch the initial set of snapshots, sequentially
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
- # Fetch additional pages if the exact URL flag is not set and the first page wasn't empty
357
+ # sequentially fetch subsequent pages
355
358
  unless @exact_url || snapshot_list_to_consider.empty?
356
359
  page_index = 1
357
- batch_size = [@threads_count, 5].min
358
- continue_fetching = true
359
- fetch_pool = Concurrent::FixedThreadPool.new([@threads_count, 1].max)
360
- begin
361
- while continue_fetching && page_index < @maximum_pages
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
- 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
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)) # Convert Concurrent::Array back to Array for JSON
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.split('/')[3..-1]&.join('/') || ''
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.split('/')[3..-1]&.join('/') || ''
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.split('/')[3..-1]&.join('/') || ''
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 file_ext == '.html' || file_ext == '.htm' || file_ext == '.php' || file_ext == '.asp'
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.8
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.10
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: []