wayback_machine_downloader_straw 2.4.6 → 2.4.8

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: e52df092b59b0eec27b390b5b00fcfc17fab271acd6cd9df774912f57cfc4dc1
4
- data.tar.gz: ce170b42caad7e8136b07c2aa5cb6e751f57dd64bd40c0addcd42a31798d0047
3
+ metadata.gz: 12259647dc99ef1c263b3d797098233be4f194bf1a4e9bc047e1dfb4d0fc9468
4
+ data.tar.gz: 5d9d6bd47efca6b43a5c1d6c2606bb2871edea0b4dc781f206bbab8c9c66aaa0
5
5
  SHA512:
6
- metadata.gz: 558e9cdfc3d7d4d2081ccb49b12a96bdb64b7768697eb0a1b9a431ed1ad3017ce894975e046a6e50766928c3863797715c7b45d013b6ab7ad78bca59ea86c6d0
7
- data.tar.gz: af3064f1489d32cf078fd5d87d2773700e9dfa498075f089029e0e7ec47c500c7815e84d51f426bb6fc3067bf02c9a9404da3b6f74d263c99b4ae96fc32dab35
6
+ metadata.gz: 670efe274663448a9967d2e39920ce234af1ba5c0aae82135c56f64899904f510096ac19b6367a5b90af43d3878b156a21b2fb1138a2e567041b55205fc97c67
7
+ data.tar.gz: 2d4d6e927664ea831c0611bbbc89d74ee6c144629f8afb2b2a58428cff1f78846832979bd994dfd97a51cff03ee52ab9c5c09beebc1b007248f1c043aa3bdd2a
@@ -48,6 +48,10 @@ option_parser = OptionParser.new do |opts|
48
48
  options[:all] = true
49
49
  end
50
50
 
51
+ opts.on("--keep-duplicates", "Do not collapse duplicate CDX captures by digest") do |t|
52
+ options[:keep_duplicates] = true
53
+ end
54
+
51
55
  opts.on("-c", "--concurrency NUMBER", Integer, "Number of multiple files to download at a time", "Default is one file at a time (ie. 20)") do |t|
52
56
  options[:threads_count] = t
53
57
  end
@@ -80,6 +84,10 @@ option_parser = OptionParser.new do |opts|
80
84
  options[:keep] = true
81
85
  end
82
86
 
87
+ opts.on("--delay SECONDS", Float, "Delay between downloads in seconds (default: 0)") do |t|
88
+ options[:delay] = t
89
+ end
90
+
83
91
  opts.on("--rt", "--retry N", Integer, "Maximum number of retries for failed downloads (default: 3)") do |t|
84
92
  options[:max_retries] = t
85
93
  end
@@ -4,36 +4,53 @@ require 'uri'
4
4
  module ArchiveAPI
5
5
 
6
6
  def get_raw_list_from_api(url, page_index, http)
7
- # Automatically append /* if the URL doesn't contain a path after the domain
7
+ # Automatically append /* for host-only URLs
8
8
  # This is a workaround for an issue with the API and *some* domains.
9
9
  # See https://github.com/StrawberryMaster/wayback-machine-downloader/issues/6
10
- # But don't do this when exact_url flag is set
11
- if url && !url.match(/^https?:\/\/.*\//i) && !@exact_url
12
- url = "#{url}/*"
10
+ # But don't do this when exact_url flag is set, and never append twice
11
+ 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
22
+ match_type = "prefix"
23
+ end
13
24
  end
14
25
 
15
26
  request_url = URI("https://web.archive.org/cdx/search/cdx")
16
27
  params = [["output", "json"], ["url", url]] + parameters_for_api(page_index)
28
+ params << ["matchType", match_type] if match_type
17
29
  request_url.query = URI.encode_www_form(params)
18
30
 
19
31
  retries = 0
20
32
  max_retries = (@max_retries || 3)
21
- delay = WaybackMachineDownloader::RETRY_DELAY rescue 2
33
+ base_delay = WaybackMachineDownloader::RETRY_DELAY rescue 2
22
34
 
23
35
  begin
24
- request = Net::HTTP::Get.new(request_url)
25
- request["User-Agent"] = "wmd-straw/#{WaybackMachineDownloader::VERSION}"
26
- request["Connection"] = "keep-alive"
27
- request["Accept-Encoding"] = "gzip"
28
- response = http.request(request)
36
+ if HTTPX_AVAILABLE && http.is_a?(HTTPX::Session)
37
+ response = http.get(request_url)
38
+ raise response.error if response.is_a?(HTTPX::ErrorResponse)
29
39
 
30
- case response.code.to_i
40
+ code = response.status
41
+ body = response.body.to_s.strip
42
+ else
43
+ request = Net::HTTP::Get.new(request_url)
44
+ request["User-Agent"] = "wmd-straw/#{WaybackMachineDownloader::VERSION rescue '2.4.8'}"
45
+ request["Connection"] = "keep-alive"
46
+ request["Accept-Encoding"] = "gzip, deflate"
47
+ response = http.request(request)
48
+ code = response.code.to_i
49
+ body = decompress_body(response)
50
+ end
51
+
52
+ case code
31
53
  when 200
32
- body = if response['content-encoding'] == 'gzip'
33
- Zlib::GzipReader.new(StringIO.new(response.body)).read
34
- else
35
- response.body.to_s.strip
36
- end
37
54
  return [] if body.empty?
38
55
  begin
39
56
  json = JSON.parse(body)
@@ -44,16 +61,22 @@ module ArchiveAPI
44
61
  raise "Malformed JSON response: #{e.message}"
45
62
  end
46
63
  when 429, 500, 502, 503, 504
47
- raise "Server error #{response.code}: #{response.message}"
64
+ raise "Server error #{code}: #{response.respond_to?(:message) ? response.message : ''}"
48
65
  else
49
- warn "Unexpected API response #{response.code} for #{url}"
66
+ warn "Unexpected API response #{code} for #{url}"
50
67
  []
51
68
  end
52
69
  rescue Net::ReadTimeout, Net::OpenTimeout, StandardError => e
53
70
  if retries < max_retries
54
71
  retries += 1
55
- warn "Error talking to Wayback CDX API (#{e.class}: #{e.message}) for #{url}, retry #{retries}/#{max_retries}..."
56
- sleep(delay * retries)
72
+
73
+ jitter = rand(0.0..1.0)
74
+ sleep_time = (base_delay * (2 ** (retries - 1))) + jitter
75
+
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})..."
78
+
79
+ sleep(sleep_time)
57
80
  retry
58
81
  else
59
82
  warn "Giving up on Wayback CDX API for #{url} after #{max_retries} attempts. (Last error: #{e.message})"
@@ -63,12 +86,28 @@ module ArchiveAPI
63
86
  end
64
87
 
65
88
  def parameters_for_api(page_index)
66
- parameters = [["fl", "timestamp,original"], ["collapse", "digest"], ["gzip", "true"]]
67
- parameters.push(["filter", "statuscode:200"]) unless @all
89
+ parameters = [["fl", "timestamp,original"], ["gzip", "true"]]
90
+ parameters.push(["collapse", "digest"]) unless @keep_duplicates || @all_timestamps
91
+ parameters.push(["filter", "statuscode:2..|30[12378]"]) unless @all
68
92
  parameters.push(["from", @from_timestamp.to_s]) if @from_timestamp && @from_timestamp != 0
69
93
  parameters.push(["to", @to_timestamp.to_s]) if @to_timestamp && @to_timestamp != 0
70
94
  parameters.push(["page", page_index]) if page_index
71
95
  parameters
72
96
  end
73
97
 
98
+ private
99
+
100
+ def decompress_body(response)
101
+ body = response.body.to_s
102
+ return body if body.empty?
103
+
104
+ case response['content-encoding']
105
+ when 'gzip'
106
+ Zlib::GzipReader.new(StringIO.new(body)).read rescue body
107
+ when 'deflate'
108
+ Zlib::Inflate.inflate(body) rescue body
109
+ else
110
+ body.strip
111
+ end
112
+ end
74
113
  end
@@ -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('.')
@@ -2,16 +2,15 @@
2
2
 
3
3
  require 'thread'
4
4
  require 'net/http'
5
- require 'open-uri'
6
5
  require 'fileutils'
7
- require 'cgi'
8
6
  require 'json'
9
- require 'time'
7
+ require 'pathname'
10
8
  require 'concurrent-ruby'
11
9
  require 'logger'
12
10
  require 'zlib'
13
11
  require 'stringio'
14
12
  require 'digest'
13
+ require 'etc'
15
14
  require_relative 'wayback_machine_downloader/tidy_bytes'
16
15
  require_relative 'wayback_machine_downloader/to_regex'
17
16
  require_relative 'wayback_machine_downloader/archive_api'
@@ -19,6 +18,13 @@ require_relative 'wayback_machine_downloader/page_requisites'
19
18
  require_relative 'wayback_machine_downloader/subdom_processor'
20
19
  require_relative 'wayback_machine_downloader/url_rewrite'
21
20
 
21
+ begin
22
+ require 'httpx'
23
+ HTTPX_AVAILABLE = true
24
+ rescue LoadError
25
+ HTTPX_AVAILABLE = false
26
+ end
27
+
22
28
  class ConnectionPool
23
29
  MAX_AGE = 300
24
30
  CLEANUP_INTERVAL = 60
@@ -69,7 +75,12 @@ class ConnectionPool
69
75
  def stale?(entry)
70
76
  return true if entry.nil? || entry[:http].nil?
71
77
  http = entry[:http]
72
- !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
73
84
  end
74
85
 
75
86
  def build_connection_entry
@@ -77,7 +88,12 @@ class ConnectionPool
77
88
  end
78
89
 
79
90
  def safe_finish(http)
80
- 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
81
97
  rescue StandardError
82
98
  nil
83
99
  end
@@ -116,14 +132,24 @@ class ConnectionPool
116
132
  end
117
133
 
118
134
  def create_connection
119
- http = Net::HTTP.new("web.archive.org", 443)
120
- http.use_ssl = true
121
- http.read_timeout = DEFAULT_TIMEOUT
122
- http.open_timeout = DEFAULT_TIMEOUT
123
- http.keep_alive_timeout = 30
124
- http.max_retries = MAX_RETRIES
125
- http.start
126
- 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
+ http.start
151
+ http
152
+ end
127
153
  end
128
154
  end
129
155
 
@@ -133,7 +159,7 @@ class WaybackMachineDownloader
133
159
  include SubdomainProcessor
134
160
  include URLRewrite
135
161
 
136
- VERSION = "2.4.6"
162
+ VERSION = "2.4.8"
137
163
  DEFAULT_TIMEOUT = 30
138
164
  MAX_RETRIES = 3
139
165
  RETRY_DELAY = 2
@@ -146,7 +172,7 @@ class WaybackMachineDownloader
146
172
 
147
173
  attr_accessor :base_url, :exact_url, :directory, :all_timestamps,
148
174
  :from_timestamp, :to_timestamp, :only_filter, :exclude_filter,
149
- :all, :maximum_pages, :threads_count, :logger, :reset, :keep, :rewrite,
175
+ :all, :keep_duplicates, :maximum_pages, :threads_count, :logger, :reset, :keep, :rewrite,
150
176
  :snapshot_at, :page_requisites
151
177
 
152
178
  def initialize params
@@ -165,8 +191,16 @@ class WaybackMachineDownloader
165
191
  @only_filter = params[:only_filter]
166
192
  @exclude_filter = params[:exclude_filter]
167
193
  @all = params[:all]
194
+ @keep_duplicates = params[:keep_duplicates] || false
168
195
  @maximum_pages = params[:maximum_pages] ? params[:maximum_pages].to_i : 100
169
- @threads_count = [params[:threads_count].to_i, 1].max
196
+ default_threads = begin
197
+ [Etc.nprocessors, 4].max
198
+ rescue StandardError
199
+ 4
200
+ end
201
+ threads_param = params[:threads_count] ? params[:threads_count].to_i : 0
202
+ threads_param = default_threads if threads_param <= 0
203
+ @threads_count = [threads_param, 1].max
170
204
  @rewritten = params[:rewritten]
171
205
  @reset = params[:reset]
172
206
  @keep = params[:keep]
@@ -176,6 +210,7 @@ class WaybackMachineDownloader
176
210
  @connection_pool = ConnectionPool.new(CONNECTION_POOL_SIZE)
177
211
  @db_mutex = Mutex.new
178
212
  @rewrite = params[:rewrite] || false
213
+ @delay = params[:delay] ? params[:delay].to_f : 0.0
179
214
  @recursive_subdomains = params[:recursive_subdomains] || false
180
215
  @subdomain_depth = params[:subdomain_depth] || 1
181
216
  @snapshot_at = params[:snapshot_at] ? params[:snapshot_at].to_i : nil
@@ -183,6 +218,12 @@ class WaybackMachineDownloader
183
218
  @page_requisites = params[:page_requisites] || false
184
219
  @pending_jobs = Concurrent::AtomicFixnum.new(0)
185
220
 
221
+ @db_buffer = []
222
+ @db_buffer_mutex = Mutex.new
223
+ @db_last_flush = Time.now
224
+ @db_flush_threshold = 50 # flush to disk every 50 completed files
225
+ @db_flush_interval = 5.0 # or flush every 5 seconds even if count isn't met
226
+
186
227
  # URL for rejecting invalid/unencoded wayback urls
187
228
  @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])|([!$&'('')'*+,;=])|:|@)|\/|\?)*)?)$/
188
229
 
@@ -293,10 +334,8 @@ class WaybackMachineDownloader
293
334
 
294
335
  # if snapshot_at is set, limit CDX queries to snapshots at or before that timestamp
295
336
  original_to = @to_timestamp
296
- skip_cache = false
297
337
  if @snapshot_at
298
338
  @to_timestamp = @snapshot_at
299
- skip_cache = true
300
339
  end
301
340
 
302
341
  puts "Getting snapshot pages from Wayback Machine API..."
@@ -329,7 +368,7 @@ class WaybackMachineDownloader
329
368
  Concurrent::Future.execute(executor: fetch_pool) do
330
369
  result = nil
331
370
  @connection_pool.with_connection do |connection|
332
- result = get_raw_list_from_api("#{@base_url}/*", page, connection)
371
+ result = get_raw_list_from_api(@base_url, page, connection)
333
372
  end
334
373
  result ||= []
335
374
  [page, result]
@@ -382,10 +421,8 @@ class WaybackMachineDownloader
382
421
  # save the fetched list to the cache file
383
422
  begin
384
423
  FileUtils.mkdir_p(File.dirname(cdx_path))
385
- unless skip_cache
386
- File.write(cdx_path, JSON.pretty_generate(snapshot_list_to_consider.to_a)) # Convert Concurrent::Array back to Array for JSON
387
- puts "Saved snapshot list to #{cdx_path}"
388
- end
424
+ File.write(cdx_path, JSON.pretty_generate(snapshot_list_to_consider.to_a)) # Convert Concurrent::Array back to Array for JSON
425
+ puts "Saved snapshot list to #{cdx_path}"
389
426
  rescue => e
390
427
  puts "Error saving snapshot cache to #{cdx_path}: #{e.message}"
391
428
  ensure
@@ -532,7 +569,16 @@ class WaybackMachineDownloader
532
569
  if File.exist?(db_path) && !@reset
533
570
  puts "Loading list of already downloaded files from #{db_path}"
534
571
  begin
535
- File.foreach(db_path) { |line| downloaded_ids.add(line.strip) }
572
+ File.foreach(db_path) do |line|
573
+ id = line.strip
574
+ # only trust DB entries that actually exist on disk; this helps when resuming
575
+ path = local_path_for_file_id(id)
576
+ if path && File.exist?(path)
577
+ downloaded_ids.add(id)
578
+ else
579
+ puts "Found DB entry but file missing, will requeue: #{id}" if @logger && @logger.level == Logger::DEBUG
580
+ end
581
+ end
536
582
  rescue => e
537
583
  puts "Error reading downloaded files list #{db_path}: #{e.message}. Assuming no files downloaded."
538
584
  downloaded_ids.clear
@@ -542,12 +588,40 @@ class WaybackMachineDownloader
542
588
  end
543
589
 
544
590
  def append_to_db(file_id)
545
- @db_mutex.synchronize do
546
- begin
547
- FileUtils.mkdir_p(File.dirname(db_path))
548
- File.open(db_path, 'a') { |f| f.puts(file_id) }
549
- rescue => e
550
- @logger.error("Failed to append downloaded file ID #{file_id} to #{db_path}: #{e.message}")
591
+ flush_needed = false
592
+
593
+ @db_buffer_mutex.synchronize do
594
+ @db_buffer << file_id
595
+ if @db_buffer.size >= @db_flush_threshold || (Time.now - @db_last_flush) >= @db_flush_interval
596
+ flush_needed = true
597
+ end
598
+ end
599
+
600
+ flush_db if flush_needed
601
+ end
602
+
603
+ def flush_db
604
+ lines_to_write = nil
605
+
606
+ @db_buffer_mutex.synchronize do
607
+ return if @db_buffer.empty?
608
+ lines_to_write = @db_buffer.dup
609
+ @db_buffer.clear
610
+ @db_last_flush = Time.now
611
+ end
612
+
613
+ return if lines_to_write.nil? || lines_to_write.empty?
614
+
615
+ begin
616
+ FileUtils.mkdir_p(File.dirname(db_path))
617
+ File.open(db_path, 'a') do |f|
618
+ lines_to_write.each { |id| f.puts(id) }
619
+ end
620
+ rescue StandardError => e
621
+ @logger.error("Failed to write batch to #{db_path}: #{e.message}")
622
+
623
+ @db_buffer_mutex.synchronize do
624
+ @db_buffer.unshift(*lines_to_write)
551
625
  end
552
626
  end
553
627
  end
@@ -584,9 +658,18 @@ class WaybackMachineDownloader
584
658
  end
585
659
  end
586
660
  end
587
-
661
+
662
+ @@engine_printed = false
663
+
588
664
  def download_files
589
665
  start_time = Time.now
666
+
667
+ unless @@engine_printed
668
+ engine_name = HTTPX_AVAILABLE ? "HTTPX" : "Net::HTTP"
669
+ puts "Connection engine used: #{color(engine_name, :green)}"
670
+ @@engine_printed = true
671
+ end
672
+
590
673
  puts "Downloading #{@base_url} to #{backup_path} from Wayback Machine archives."
591
674
 
592
675
  FileUtils.mkdir_p(backup_path)
@@ -605,7 +688,7 @@ class WaybackMachineDownloader
605
688
 
606
689
  # Load IDs of already downloaded files
607
690
  downloaded_ids = load_downloaded_ids
608
-
691
+
609
692
  # We use a thread-safe Set to track what we have queued/downloaded in this session
610
693
  # to avoid infinite loops with page requisites
611
694
  @session_downloaded_ids = Concurrent::Set.new
@@ -621,7 +704,7 @@ class WaybackMachineDownloader
621
704
  if skipped_count > 0
622
705
  puts "Found #{skipped_count} previously downloaded files, skipping them."
623
706
  end
624
-
707
+
625
708
  if remaining_count == 0 && !@page_requisites
626
709
  puts "All matching files have already been downloaded."
627
710
  cleanup
@@ -687,14 +770,34 @@ class WaybackMachineDownloader
687
770
  def process_single_file(file_remote_info)
688
771
  download_success = false
689
772
  downloaded_path = nil
690
-
773
+
774
+ # if delay was set by the user
775
+ sleep(@delay) if @delay > 0
776
+
777
+ # fast-path for resumed runs: if file already exists locally, avoid HTTP work entirely
778
+ existing_path = local_path_for_file_id(file_remote_info[:file_id])
779
+ if existing_path && File.exist?(existing_path)
780
+ result_message = "#{color("[EXISTS]", :cyan)} #{file_remote_info[:file_url]} (#{@processed_file_count + 1}/#{@total_to_download})"
781
+ @download_mutex.synchronize do
782
+ @processed_file_count += 1 if @processed_file_count < @total_to_download
783
+ puts result_message
784
+ end
785
+
786
+ append_to_db(file_remote_info[:file_id])
787
+
788
+ if @page_requisites && File.extname(existing_path) =~ /\.(html?|php|asp|aspx|jsp)$/i
789
+ process_page_requisites(existing_path, file_remote_info)
790
+ end
791
+ return
792
+ end
793
+
691
794
  @connection_pool.with_connection do |connection|
692
795
  result_message, downloaded_path = download_file(file_remote_info, connection)
693
-
796
+
694
797
  if downloaded_path && File.exist?(downloaded_path)
695
798
  download_success = true
696
799
  end
697
-
800
+
698
801
  @download_mutex.synchronize do
699
802
  @processed_file_count += 1 if @processed_file_count < @total_to_download
700
803
  # only print if it's a "User" file or a requisite we found
@@ -704,7 +807,7 @@ class WaybackMachineDownloader
704
807
 
705
808
  if download_success
706
809
  append_to_db(file_remote_info[:file_id])
707
-
810
+
708
811
  if @page_requisites && downloaded_path && File.extname(downloaded_path) =~ /\.(html?|php|asp|aspx|jsp)$/i
709
812
  process_page_requisites(downloaded_path, file_remote_info)
710
813
  end
@@ -712,7 +815,7 @@ class WaybackMachineDownloader
712
815
  rescue => e
713
816
  @logger.error("Error processing file #{file_remote_info[:file_url]}: #{e.message}")
714
817
  end
715
-
818
+
716
819
  def process_page_requisites(file_path, parent_remote_info)
717
820
  return unless File.exist?(file_path)
718
821
 
@@ -724,7 +827,7 @@ class WaybackMachineDownloader
724
827
  # prepare base URI for resolving relative paths
725
828
  parent_raw = parent_remote_info[:file_url]
726
829
  parent_raw = "http://#{parent_raw}" unless parent_raw.match?(/^https?:\/\//)
727
-
830
+
728
831
  begin
729
832
  base_uri = URI(parent_raw)
730
833
  # calculate the "root" host of the site we are downloading to compare later
@@ -758,7 +861,7 @@ class WaybackMachineDownloader
758
861
  path = resolved_uri.path
759
862
  ext = File.extname(path).downcase
760
863
  if ext.empty? || ['.html', '.htm', '.php', '.asp', '.aspx'].include?(ext)
761
- next
864
+ next
762
865
  end
763
866
 
764
867
  # construct the original URL to query the Wayback API
@@ -829,7 +932,7 @@ class WaybackMachineDownloader
829
932
  rescue Errno::EEXIST, Errno::ENOTDIR => e
830
933
  file_already_existing = nil
831
934
  check_path = dir_path
832
-
935
+
833
936
  # walk up the path to find the specific file that is blocking directory creation
834
937
  while check_path != "." && check_path != "/"
835
938
  if File.exist?(check_path) && !File.directory?(check_path)
@@ -844,11 +947,11 @@ class WaybackMachineDownloader
844
947
  if file_already_existing
845
948
  file_already_existing_temporary = file_already_existing + '.temp'
846
949
  file_already_existing_permanent = file_already_existing + '/index.html'
847
-
950
+
848
951
  FileUtils::mv file_already_existing, file_already_existing_temporary
849
952
  FileUtils::mkdir_p file_already_existing
850
953
  FileUtils::mv file_already_existing_temporary, file_already_existing_permanent
851
-
954
+
852
955
  puts "#{file_already_existing} -> #{file_already_existing_permanent}"
853
956
  # retry the directory creation now that the path is clear
854
957
  structure_dir_path dir_path
@@ -861,12 +964,12 @@ class WaybackMachineDownloader
861
964
  def rewrite_local_files
862
965
  puts "Scanning #{backup_path} for files to rewrite..."
863
966
  files = Dir.glob(File.join(backup_path, "**/*.{html,htm,css,js,php,asp,aspx,jsp}"))
864
-
967
+
865
968
  puts "Found #{files.size} files. Rewriting links for local browsing..."
866
-
969
+
867
970
  pool = Concurrent::FixedThreadPool.new(@threads_count)
868
971
  progress = Concurrent::AtomicFixnum.new(0)
869
-
972
+
870
973
  files.each do |file_path|
871
974
  pool.post do
872
975
  rewrite_urls_to_relative(file_path)
@@ -874,7 +977,7 @@ class WaybackMachineDownloader
874
977
  print "\rProgress: #{current}/#{files.size}" if current % 100 == 0
875
978
  end
876
979
  end
877
-
980
+
878
981
  pool.shutdown
879
982
  pool.wait_for_termination
880
983
  puts "\nFinished rewriting all files."
@@ -882,39 +985,58 @@ class WaybackMachineDownloader
882
985
 
883
986
  def rewrite_urls_to_relative(file_path)
884
987
  return unless File.exist?(file_path)
885
-
988
+
886
989
  file_ext = File.extname(file_path).downcase
887
-
990
+
888
991
  begin
889
992
  content = File.binread(file_path)
890
993
 
891
994
  # detect encoding for HTML files
892
995
  if file_ext == '.html' || file_ext == '.htm' || file_ext == '.php' || file_ext == '.asp'
893
- encoding = content.match(/<meta\s+charset=["']?([^"'>]+)/i)&.captures&.first || 'UTF-8'
894
- content.force_encoding(encoding) rescue content.force_encoding('UTF-8')
996
+ encoding_match = content.match(/<meta.*?charset=["'\s]?([^"'\s>;]+)/i)
997
+ encoding_name = encoding_match ? encoding_match.captures.first : 'UTF-8'
998
+
999
+ begin
1000
+ encoding = Encoding.find(encoding_name)
1001
+ rescue ArgumentError
1002
+ encoding = Encoding::UTF_8
1003
+ end
1004
+
1005
+ content.force_encoding(encoding)
895
1006
  else
896
1007
  content.force_encoding('UTF-8')
897
1008
  end
898
1009
 
1010
+ # convert the content to valid UTF-8
1011
+ if !content.valid_encoding? || content.encoding != Encoding::UTF_8
1012
+ begin
1013
+ content = content.encode(Encoding::UTF_8, invalid: :replace, undef: :replace, replace: '')
1014
+ rescue Encoding::UndefinedConversionError, Encoding::InvalidByteSequenceError, ArgumentError
1015
+ content = content.tidy_bytes
1016
+ end
1017
+ end
1018
+
899
1019
  # URLs in HTML attributes
900
1020
  content = rewrite_html_attr_urls(content)
901
-
1021
+
902
1022
  # URLs in CSS
903
1023
  content = rewrite_css_urls(content)
904
-
1024
+
905
1025
  # URLs in JavaScript
906
1026
  content = rewrite_js_urls(content)
907
-
908
- # for URLs that start with a single slash, make them relative
1027
+
1028
+ root_prefix = site_root_relative_prefix(file_path)
1029
+
1030
+ # rewrite root-absolute links to paths relative to the downloaded site root
909
1031
  content.gsub!(/(\s(?:href|src|action|data-src|data-url)=["'])\/([^"'\/][^"']*)(["'])/i) do
910
1032
  prefix, path, suffix = $1, $2, $3
911
- "#{prefix}./#{path}#{suffix}"
1033
+ "#{prefix}#{root_prefix}#{path}#{suffix}"
912
1034
  end
913
-
914
- # for URLs in CSS that start with a single slash, make them relative
1035
+
1036
+ # apply the same root-relative conversion to CSS url(...) references
915
1037
  content.gsub!(/url\(\s*["']?\/([^"'\)\/][^"'\)]*?)["']?\s*\)/i) do
916
1038
  path = $1
917
- "url(\"./#{path}\")"
1039
+ "url(\"#{root_prefix}#{path}\")"
918
1040
  end
919
1041
 
920
1042
  # save the modified content back to the file
@@ -922,7 +1044,27 @@ class WaybackMachineDownloader
922
1044
  puts "Rewrote URLs in #{file_path} to be relative."
923
1045
  rescue Errno::ENOENT => e
924
1046
  @logger.warn("Error reading file #{file_path}: #{e.message}")
1047
+ rescue StandardError => e
1048
+ @logger.error("Failed to rewrite URLs in #{file_path}: #{e.message}")
1049
+ end
1050
+ end
1051
+
1052
+ def site_root_relative_prefix(file_path)
1053
+ file_dir = File.dirname(File.expand_path(file_path))
1054
+ root_dir = File.expand_path(backup_path)
1055
+
1056
+ begin
1057
+ relative_dir = Pathname.new(file_dir).relative_path_from(Pathname.new(root_dir)).to_s
1058
+ rescue ArgumentError
1059
+ return './'
925
1060
  end
1061
+
1062
+ return './' if relative_dir == '.' || relative_dir.empty?
1063
+
1064
+ depth = relative_dir.split(/[\\\/]+/).reject(&:empty?).length
1065
+ return './' if depth <= 0
1066
+
1067
+ '../' * depth
926
1068
  end
927
1069
 
928
1070
  def download_file (file_remote_info, http)
@@ -930,7 +1072,7 @@ class WaybackMachineDownloader
930
1072
  file_url = file_remote_info[:file_url].encode(current_encoding)
931
1073
  file_id = file_remote_info[:file_id]
932
1074
  file_timestamp = file_remote_info[:timestamp]
933
-
1075
+
934
1076
  # sanitize file_id to ensure it is a valid path component
935
1077
  raw_path_elements = file_id.split('/')
936
1078
 
@@ -944,7 +1086,7 @@ class WaybackMachineDownloader
944
1086
  element
945
1087
  end
946
1088
  end
947
-
1089
+
948
1090
  current_backup_path = backup_path
949
1091
 
950
1092
  if file_id == ""
@@ -994,6 +1136,27 @@ class WaybackMachineDownloader
994
1136
  end
995
1137
  end
996
1138
 
1139
+ # derive the local filesystem path for a sanitized `file_id` stored in the DB
1140
+ def local_path_for_file_id(file_id)
1141
+ return nil if file_id.nil?
1142
+ current_backup_path = backup_path
1143
+
1144
+ # file_id coming from DB is expected to already be sanitized
1145
+ raw_path_elements = file_id.split('/')
1146
+
1147
+ if file_id == ""
1148
+ dir_path = current_backup_path
1149
+ return File.join(dir_path, 'index.html')
1150
+ elsif file_id[-1] == '/' || (raw_path_elements.last && !raw_path_elements.last.include?('.'))
1151
+ dir_path = File.join(current_backup_path, *raw_path_elements)
1152
+ return File.join(dir_path, 'index.html')
1153
+ else
1154
+ filename = raw_path_elements.pop
1155
+ dir_path = File.join(current_backup_path, *raw_path_elements)
1156
+ return File.join(dir_path, filename)
1157
+ end
1158
+ end
1159
+
997
1160
  def color(text, color_code)
998
1161
  return text if Gem.win_platform? && !ENV['ENABLE_ANSI']
999
1162
  codes = { red: 31, green: 32, yellow: 33, blue: 34, magenta: 35, cyan: 36, white: 37 }
@@ -1038,7 +1201,7 @@ class WaybackMachineDownloader
1038
1201
  end
1039
1202
  logger
1040
1203
  end
1041
-
1204
+
1042
1205
  # safely sanitize a file id (or id+timestamp)
1043
1206
  def sanitize_and_prepare_id(raw, file_url)
1044
1207
  return nil if raw.nil?
@@ -1111,6 +1274,46 @@ class WaybackMachineDownloader
1111
1274
  end
1112
1275
  end
1113
1276
 
1277
+ def build_wayback_url(source_url, file_timestamp)
1278
+ source = source_url.to_s
1279
+ return source if wayback_archive_url?(source)
1280
+
1281
+ if source.start_with?('/web/')
1282
+ return "https://web.archive.org#{source}"
1283
+ end
1284
+
1285
+ if @rewritten
1286
+ "https://web.archive.org/web/#{file_timestamp}/#{source}"
1287
+ else
1288
+ "https://web.archive.org/web/#{file_timestamp}id_/#{source}"
1289
+ end
1290
+ end
1291
+
1292
+ def wayback_archive_url?(url)
1293
+ url.to_s.match?(%r{\Ahttps?://web\.archive\.org/web/})
1294
+ end
1295
+
1296
+ def extract_original_url(url)
1297
+ match = url.to_s.match(%r{\Ahttps?://web\.archive\.org/web/\d{1,14}(?:[a-z_]*)/(https?://.+)\z})
1298
+ match && match[1]
1299
+ end
1300
+
1301
+ def resolve_redirect_source(current_source_url, location)
1302
+ return nil if location.nil? || location.empty?
1303
+
1304
+ location = location.to_s
1305
+ return location if wayback_archive_url?(location)
1306
+
1307
+ if location.start_with?('/web/')
1308
+ return "https://web.archive.org#{location}"
1309
+ end
1310
+
1311
+ base_url = extract_original_url(current_source_url) || current_source_url.to_s
1312
+ URI.join(base_url, location).to_s
1313
+ rescue URI::InvalidURIError
1314
+ location
1315
+ end
1316
+
1114
1317
  # wrap URL in parentheses if it contains characters that commonly break unquoted
1115
1318
  # Windows CMD usage (e.g., &). This is only for display; user still must quote
1116
1319
  # when invoking manually.
@@ -1122,11 +1325,7 @@ class WaybackMachineDownloader
1122
1325
  def download_with_retry(file_path, file_url, file_timestamp, connection, redirect_count = 0)
1123
1326
  retries = 0
1124
1327
  begin
1125
- wayback_url = if @rewritten
1126
- "https://web.archive.org/web/#{file_timestamp}/#{file_url}"
1127
- else
1128
- "https://web.archive.org/web/#{file_timestamp}id_/#{file_url}"
1129
- end
1328
+ wayback_url = build_wayback_url(file_url, file_timestamp)
1130
1329
 
1131
1330
  # Escape characters that are not valid in URI()
1132
1331
  wayback_url = wayback_url.gsub(' ', '%20').gsub('[', '%5B').gsub(']', '%5D')
@@ -1144,64 +1343,85 @@ class WaybackMachineDownloader
1144
1343
  return :skipped_not_found
1145
1344
  end
1146
1345
 
1147
- request = Net::HTTP::Get.new(URI(wayback_url))
1148
- request["Connection"] = "keep-alive"
1149
- request["User-Agent"] = "WaybackMachineDownloader/#{VERSION}"
1150
- request["Accept-Encoding"] = "gzip, deflate"
1346
+ if HTTPX_AVAILABLE && connection.is_a?(HTTPX::Session)
1347
+ response = connection.get(wayback_url)
1151
1348
 
1152
- response = connection.request(request)
1349
+ raise response.error if response.is_a?(HTTPX::ErrorResponse)
1153
1350
 
1154
- save_response_body = lambda do
1155
- File.open(file_path, "wb") do |file|
1156
- body = response.body
1157
- if response['content-encoding'] == 'gzip' && body && !body.empty?
1158
- begin
1159
- gz = Zlib::GzipReader.new(StringIO.new(body))
1160
- decompressed_body = gz.read
1161
- gz.close
1162
- file.write(decompressed_body)
1163
- rescue Zlib::GzipFile::Error => e
1164
- @logger.warn("Failure decompressing gzip file #{file_url}: #{e.message}. Writing raw body.")
1165
- file.write(body)
1166
- end
1351
+ code = response.status
1352
+
1353
+ save_response_body = lambda do
1354
+ if response.respond_to?(:copy_to)
1355
+ response.copy_to(file_path)
1167
1356
  else
1168
- file.write(body) if body
1357
+ response.body.copy_to(file_path)
1358
+ end
1359
+ end
1360
+ else
1361
+ request = Net::HTTP::Get.new(URI(wayback_url))
1362
+ request["Connection"] = "keep-alive"
1363
+ request["User-Agent"] = "WaybackMachineDownloader/#{VERSION}"
1364
+ request["Accept-Encoding"] = "gzip, deflate"
1365
+
1366
+ response = connection.request(request)
1367
+ code = response.code.to_i
1368
+
1369
+ save_response_body = lambda do
1370
+ File.open(file_path, "wb") do |file|
1371
+ body = response.body
1372
+ if response['content-encoding'] == 'gzip' && body && !body.empty?
1373
+ begin
1374
+ gz = Zlib::GzipReader.new(StringIO.new(body))
1375
+ decompressed_body = gz.read
1376
+ gz.close
1377
+ file.write(decompressed_body)
1378
+ rescue Zlib::GzipFile::Error => e
1379
+ @logger.warn("Failure decompressing gzip file #{file_url}: #{e.message}. Writing raw body.")
1380
+ file.write(body)
1381
+ end
1382
+ else
1383
+ file.write(body) if body
1384
+ end
1169
1385
  end
1170
1386
  end
1171
1387
  end
1172
1388
 
1173
1389
  if @all
1174
- case response
1175
- when Net::HTTPSuccess, Net::HTTPRedirection, Net::HTTPClientError, Net::HTTPServerError
1390
+ case code
1391
+ when 200..599
1176
1392
  save_response_body.call
1177
- if response.is_a?(Net::HTTPRedirection)
1178
- @logger.info("Saved redirect page for #{file_url} (status #{response.code}).")
1179
- elsif response.is_a?(Net::HTTPClientError) || response.is_a?(Net::HTTPServerError)
1180
- @logger.info("Saved error page for #{file_url} (status #{response.code}).")
1393
+ if (300..399).cover?(code)
1394
+ @logger.info("Saved redirect page for #{file_url} (status #{code}).")
1395
+ elsif (400..599).cover?(code)
1396
+ @logger.info("Saved error page for #{file_url} (status #{code}).")
1181
1397
  end
1182
1398
  return :saved
1183
1399
  else
1184
1400
  # for any other response type when --all is true, treat as an error to be retried or failed
1185
- raise "Unhandled HTTP response: #{response.code} #{response.message}"
1401
+ raise "Unhandled HTTP response: #{code}"
1186
1402
  end
1187
1403
  else # not @all (our default behavior)
1188
- case response
1189
- when Net::HTTPSuccess
1404
+ if (200..299).cover?(code)
1190
1405
  save_response_body.call
1191
1406
  return :saved
1192
- when Net::HTTPRedirection
1407
+ elsif (300..399).cover?(code)
1193
1408
  raise "Too many redirects for #{file_url}" if redirect_count >= 5
1194
- location = response['location']
1409
+ location = if HTTPX_AVAILABLE && connection.is_a?(HTTPX::Session)
1410
+ response.headers['location']
1411
+ else
1412
+ response['location']
1413
+ end
1195
1414
  @logger.warn("Redirect found for #{file_url} -> #{location}")
1196
- return download_with_retry(file_path, location, file_timestamp, connection, redirect_count + 1)
1197
- when Net::HTTPTooManyRequests
1415
+ redirected_source = resolve_redirect_source(file_url, location)
1416
+ return download_with_retry(file_path, redirected_source, file_timestamp, connection, redirect_count + 1)
1417
+ elsif code == 429
1198
1418
  sleep(RATE_LIMIT * 2)
1199
1419
  raise "Rate limited, retrying..."
1200
- when Net::HTTPNotFound
1420
+ elsif code == 404
1201
1421
  @logger.warn("File not found, skipping: #{file_url}")
1202
1422
  return :skipped_not_found
1203
1423
  else
1204
- raise "HTTP Error: #{response.code} #{response.message}"
1424
+ raise "HTTP Error: #{code}"
1205
1425
  end
1206
1426
  end
1207
1427
 
@@ -1219,6 +1439,7 @@ class WaybackMachineDownloader
1219
1439
  end
1220
1440
 
1221
1441
  def cleanup
1442
+ flush_db
1222
1443
  @connection_pool.shutdown
1223
1444
 
1224
1445
  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.6
4
+ version: 2.4.8
5
5
  platform: ruby
6
6
  authors:
7
7
  - strawberrymaster
@@ -18,7 +18,7 @@ dependencies:
18
18
  version: '1.3'
19
19
  - - ">="
20
20
  - !ruby/object:Gem::Version
21
- version: 1.3.4
21
+ version: 1.3.6
22
22
  type: :runtime
23
23
  prerelease: false
24
24
  version_requirements: !ruby/object:Gem::Requirement
@@ -28,7 +28,7 @@ dependencies:
28
28
  version: '1.3'
29
29
  - - ">="
30
30
  - !ruby/object:Gem::Version
31
- version: 1.3.4
31
+ version: 1.3.6
32
32
  - !ruby/object:Gem::Dependency
33
33
  name: rake
34
34
  requirement: !ruby/object:Gem::Requirement
@@ -94,7 +94,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
94
94
  - !ruby/object:Gem::Version
95
95
  version: '0'
96
96
  requirements: []
97
- rubygems_version: 4.0.3
97
+ rubygems_version: 4.0.10
98
98
  specification_version: 4
99
99
  summary: Download an entire website from the Wayback Machine.
100
100
  test_files: []