wayback_machine_downloader_straw 2.4.7 → 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: 558d3187ee31faeadb08cf83e32a87307ae9d55a3327206598f27a78fb715e08
4
- data.tar.gz: 9845999e0e618afde419869bb01b04277aca318aa80b238feb8252540fc16315
3
+ metadata.gz: 12259647dc99ef1c263b3d797098233be4f194bf1a4e9bc047e1dfb4d0fc9468
4
+ data.tar.gz: 5d9d6bd47efca6b43a5c1d6c2606bb2871edea0b4dc781f206bbab8c9c66aaa0
5
5
  SHA512:
6
- metadata.gz: b323c1065ea1ab1d3c5909458cae726462ba1b88fd89effe8cd1efbdd1301d2022363c56b191f8ddbe28c0143fe87dfc94ca847865bb08e3564e29ba36f231a4
7
- data.tar.gz: 00c4e775ee05e176e1048d6e5ddd9ddc436edaf9bf7fea61dbab33480e4adc499ea8a3a584e591858aad3a4c2f10096a3b6eb1ff6c8146f26ba6a3d42f104e32
6
+ metadata.gz: 670efe274663448a9967d2e39920ce234af1ba5c0aae82135c56f64899904f510096ac19b6367a5b90af43d3878b156a21b2fb1138a2e567041b55205fc97c67
7
+ data.tar.gz: 2d4d6e927664ea831c0611bbbc89d74ee6c144629f8afb2b2a58428cff1f78846832979bd994dfd97a51cff03ee52ab9c5c09beebc1b007248f1c043aa3bdd2a
@@ -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
@@ -8,6 +8,7 @@ module ArchiveAPI
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
10
  # But don't do this when exact_url flag is set, and never append twice
11
+ match_type = nil
11
12
  if url && !@exact_url
12
13
  normalized_url = url.to_s
13
14
  has_wildcard = normalized_url.include?('*')
@@ -18,32 +19,38 @@ module ArchiveAPI
18
19
  has_path = host_and_rest.include?('/')
19
20
 
20
21
  unless has_wildcard || has_path
21
- url = "#{normalized_url}/*"
22
+ match_type = "prefix"
22
23
  end
23
24
  end
24
25
 
25
26
  request_url = URI("https://web.archive.org/cdx/search/cdx")
26
27
  params = [["output", "json"], ["url", url]] + parameters_for_api(page_index)
28
+ params << ["matchType", match_type] if match_type
27
29
  request_url.query = URI.encode_www_form(params)
28
30
 
29
31
  retries = 0
30
32
  max_retries = (@max_retries || 3)
31
- delay = WaybackMachineDownloader::RETRY_DELAY rescue 2
33
+ base_delay = WaybackMachineDownloader::RETRY_DELAY rescue 2
32
34
 
33
35
  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)
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)
39
39
 
40
- 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
41
53
  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
54
  return [] if body.empty?
48
55
  begin
49
56
  json = JSON.parse(body)
@@ -54,16 +61,22 @@ module ArchiveAPI
54
61
  raise "Malformed JSON response: #{e.message}"
55
62
  end
56
63
  when 429, 500, 502, 503, 504
57
- raise "Server error #{response.code}: #{response.message}"
64
+ raise "Server error #{code}: #{response.respond_to?(:message) ? response.message : ''}"
58
65
  else
59
- warn "Unexpected API response #{response.code} for #{url}"
66
+ warn "Unexpected API response #{code} for #{url}"
60
67
  []
61
68
  end
62
69
  rescue Net::ReadTimeout, Net::OpenTimeout, StandardError => e
63
70
  if retries < max_retries
64
71
  retries += 1
65
- warn "Error talking to Wayback CDX API (#{e.class}: #{e.message}) for #{url}, retry #{retries}/#{max_retries}..."
66
- 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)
67
80
  retry
68
81
  else
69
82
  warn "Giving up on Wayback CDX API for #{url} after #{max_retries} attempts. (Last error: #{e.message})"
@@ -82,4 +95,19 @@ module ArchiveAPI
82
95
  parameters
83
96
  end
84
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
85
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('.')
@@ -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,24 @@ 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
+ http.start
151
+ http
152
+ end
124
153
  end
125
154
  end
126
155
 
@@ -130,7 +159,7 @@ class WaybackMachineDownloader
130
159
  include SubdomainProcessor
131
160
  include URLRewrite
132
161
 
133
- VERSION = "2.4.7"
162
+ VERSION = "2.4.8"
134
163
  DEFAULT_TIMEOUT = 30
135
164
  MAX_RETRIES = 3
136
165
  RETRY_DELAY = 2
@@ -164,7 +193,14 @@ class WaybackMachineDownloader
164
193
  @all = params[:all]
165
194
  @keep_duplicates = params[:keep_duplicates] || false
166
195
  @maximum_pages = params[:maximum_pages] ? params[:maximum_pages].to_i : 100
167
- @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
168
204
  @rewritten = params[:rewritten]
169
205
  @reset = params[:reset]
170
206
  @keep = params[:keep]
@@ -174,6 +210,7 @@ class WaybackMachineDownloader
174
210
  @connection_pool = ConnectionPool.new(CONNECTION_POOL_SIZE)
175
211
  @db_mutex = Mutex.new
176
212
  @rewrite = params[:rewrite] || false
213
+ @delay = params[:delay] ? params[:delay].to_f : 0.0
177
214
  @recursive_subdomains = params[:recursive_subdomains] || false
178
215
  @subdomain_depth = params[:subdomain_depth] || 1
179
216
  @snapshot_at = params[:snapshot_at] ? params[:snapshot_at].to_i : nil
@@ -181,6 +218,12 @@ class WaybackMachineDownloader
181
218
  @page_requisites = params[:page_requisites] || false
182
219
  @pending_jobs = Concurrent::AtomicFixnum.new(0)
183
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
+
184
227
  # URL for rejecting invalid/unencoded wayback urls
185
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])|([!$&'('')'*+,;=])|:|@)|\/|\?)*)?)$/
186
229
 
@@ -545,12 +588,40 @@ class WaybackMachineDownloader
545
588
  end
546
589
 
547
590
  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}")
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)
554
625
  end
555
626
  end
556
627
  end
@@ -587,9 +658,18 @@ class WaybackMachineDownloader
587
658
  end
588
659
  end
589
660
  end
590
-
661
+
662
+ @@engine_printed = false
663
+
591
664
  def download_files
592
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
+
593
673
  puts "Downloading #{@base_url} to #{backup_path} from Wayback Machine archives."
594
674
 
595
675
  FileUtils.mkdir_p(backup_path)
@@ -608,7 +688,7 @@ class WaybackMachineDownloader
608
688
 
609
689
  # Load IDs of already downloaded files
610
690
  downloaded_ids = load_downloaded_ids
611
-
691
+
612
692
  # We use a thread-safe Set to track what we have queued/downloaded in this session
613
693
  # to avoid infinite loops with page requisites
614
694
  @session_downloaded_ids = Concurrent::Set.new
@@ -624,7 +704,7 @@ class WaybackMachineDownloader
624
704
  if skipped_count > 0
625
705
  puts "Found #{skipped_count} previously downloaded files, skipping them."
626
706
  end
627
-
707
+
628
708
  if remaining_count == 0 && !@page_requisites
629
709
  puts "All matching files have already been downloaded."
630
710
  cleanup
@@ -691,6 +771,9 @@ class WaybackMachineDownloader
691
771
  download_success = false
692
772
  downloaded_path = nil
693
773
 
774
+ # if delay was set by the user
775
+ sleep(@delay) if @delay > 0
776
+
694
777
  # fast-path for resumed runs: if file already exists locally, avoid HTTP work entirely
695
778
  existing_path = local_path_for_file_id(file_remote_info[:file_id])
696
779
  if existing_path && File.exist?(existing_path)
@@ -707,14 +790,14 @@ class WaybackMachineDownloader
707
790
  end
708
791
  return
709
792
  end
710
-
793
+
711
794
  @connection_pool.with_connection do |connection|
712
795
  result_message, downloaded_path = download_file(file_remote_info, connection)
713
-
796
+
714
797
  if downloaded_path && File.exist?(downloaded_path)
715
798
  download_success = true
716
799
  end
717
-
800
+
718
801
  @download_mutex.synchronize do
719
802
  @processed_file_count += 1 if @processed_file_count < @total_to_download
720
803
  # only print if it's a "User" file or a requisite we found
@@ -724,7 +807,7 @@ class WaybackMachineDownloader
724
807
 
725
808
  if download_success
726
809
  append_to_db(file_remote_info[:file_id])
727
-
810
+
728
811
  if @page_requisites && downloaded_path && File.extname(downloaded_path) =~ /\.(html?|php|asp|aspx|jsp)$/i
729
812
  process_page_requisites(downloaded_path, file_remote_info)
730
813
  end
@@ -732,7 +815,7 @@ class WaybackMachineDownloader
732
815
  rescue => e
733
816
  @logger.error("Error processing file #{file_remote_info[:file_url]}: #{e.message}")
734
817
  end
735
-
818
+
736
819
  def process_page_requisites(file_path, parent_remote_info)
737
820
  return unless File.exist?(file_path)
738
821
 
@@ -744,7 +827,7 @@ class WaybackMachineDownloader
744
827
  # prepare base URI for resolving relative paths
745
828
  parent_raw = parent_remote_info[:file_url]
746
829
  parent_raw = "http://#{parent_raw}" unless parent_raw.match?(/^https?:\/\//)
747
-
830
+
748
831
  begin
749
832
  base_uri = URI(parent_raw)
750
833
  # calculate the "root" host of the site we are downloading to compare later
@@ -778,7 +861,7 @@ class WaybackMachineDownloader
778
861
  path = resolved_uri.path
779
862
  ext = File.extname(path).downcase
780
863
  if ext.empty? || ['.html', '.htm', '.php', '.asp', '.aspx'].include?(ext)
781
- next
864
+ next
782
865
  end
783
866
 
784
867
  # construct the original URL to query the Wayback API
@@ -849,7 +932,7 @@ class WaybackMachineDownloader
849
932
  rescue Errno::EEXIST, Errno::ENOTDIR => e
850
933
  file_already_existing = nil
851
934
  check_path = dir_path
852
-
935
+
853
936
  # walk up the path to find the specific file that is blocking directory creation
854
937
  while check_path != "." && check_path != "/"
855
938
  if File.exist?(check_path) && !File.directory?(check_path)
@@ -864,11 +947,11 @@ class WaybackMachineDownloader
864
947
  if file_already_existing
865
948
  file_already_existing_temporary = file_already_existing + '.temp'
866
949
  file_already_existing_permanent = file_already_existing + '/index.html'
867
-
950
+
868
951
  FileUtils::mv file_already_existing, file_already_existing_temporary
869
952
  FileUtils::mkdir_p file_already_existing
870
953
  FileUtils::mv file_already_existing_temporary, file_already_existing_permanent
871
-
954
+
872
955
  puts "#{file_already_existing} -> #{file_already_existing_permanent}"
873
956
  # retry the directory creation now that the path is clear
874
957
  structure_dir_path dir_path
@@ -881,12 +964,12 @@ class WaybackMachineDownloader
881
964
  def rewrite_local_files
882
965
  puts "Scanning #{backup_path} for files to rewrite..."
883
966
  files = Dir.glob(File.join(backup_path, "**/*.{html,htm,css,js,php,asp,aspx,jsp}"))
884
-
967
+
885
968
  puts "Found #{files.size} files. Rewriting links for local browsing..."
886
-
969
+
887
970
  pool = Concurrent::FixedThreadPool.new(@threads_count)
888
971
  progress = Concurrent::AtomicFixnum.new(0)
889
-
972
+
890
973
  files.each do |file_path|
891
974
  pool.post do
892
975
  rewrite_urls_to_relative(file_path)
@@ -894,7 +977,7 @@ class WaybackMachineDownloader
894
977
  print "\rProgress: #{current}/#{files.size}" if current % 100 == 0
895
978
  end
896
979
  end
897
-
980
+
898
981
  pool.shutdown
899
982
  pool.wait_for_termination
900
983
  puts "\nFinished rewriting all files."
@@ -902,39 +985,58 @@ class WaybackMachineDownloader
902
985
 
903
986
  def rewrite_urls_to_relative(file_path)
904
987
  return unless File.exist?(file_path)
905
-
988
+
906
989
  file_ext = File.extname(file_path).downcase
907
-
990
+
908
991
  begin
909
992
  content = File.binread(file_path)
910
993
 
911
994
  # detect encoding for HTML files
912
995
  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')
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)
915
1006
  else
916
1007
  content.force_encoding('UTF-8')
917
1008
  end
918
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
+
919
1019
  # URLs in HTML attributes
920
1020
  content = rewrite_html_attr_urls(content)
921
-
1021
+
922
1022
  # URLs in CSS
923
1023
  content = rewrite_css_urls(content)
924
-
1024
+
925
1025
  # URLs in JavaScript
926
1026
  content = rewrite_js_urls(content)
927
-
928
- # 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
929
1031
  content.gsub!(/(\s(?:href|src|action|data-src|data-url)=["'])\/([^"'\/][^"']*)(["'])/i) do
930
1032
  prefix, path, suffix = $1, $2, $3
931
- "#{prefix}./#{path}#{suffix}"
1033
+ "#{prefix}#{root_prefix}#{path}#{suffix}"
932
1034
  end
933
-
934
- # 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
935
1037
  content.gsub!(/url\(\s*["']?\/([^"'\)\/][^"'\)]*?)["']?\s*\)/i) do
936
1038
  path = $1
937
- "url(\"./#{path}\")"
1039
+ "url(\"#{root_prefix}#{path}\")"
938
1040
  end
939
1041
 
940
1042
  # save the modified content back to the file
@@ -942,15 +1044,35 @@ class WaybackMachineDownloader
942
1044
  puts "Rewrote URLs in #{file_path} to be relative."
943
1045
  rescue Errno::ENOENT => e
944
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}")
945
1049
  end
946
1050
  end
947
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 './'
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
1068
+ end
1069
+
948
1070
  def download_file (file_remote_info, http)
949
1071
  current_encoding = "".encoding
950
1072
  file_url = file_remote_info[:file_url].encode(current_encoding)
951
1073
  file_id = file_remote_info[:file_id]
952
1074
  file_timestamp = file_remote_info[:timestamp]
953
-
1075
+
954
1076
  # sanitize file_id to ensure it is a valid path component
955
1077
  raw_path_elements = file_id.split('/')
956
1078
 
@@ -964,7 +1086,7 @@ class WaybackMachineDownloader
964
1086
  element
965
1087
  end
966
1088
  end
967
-
1089
+
968
1090
  current_backup_path = backup_path
969
1091
 
970
1092
  if file_id == ""
@@ -1079,7 +1201,7 @@ class WaybackMachineDownloader
1079
1201
  end
1080
1202
  logger
1081
1203
  end
1082
-
1204
+
1083
1205
  # safely sanitize a file id (or id+timestamp)
1084
1206
  def sanitize_and_prepare_id(raw, file_url)
1085
1207
  return nil if raw.nil?
@@ -1221,65 +1343,85 @@ class WaybackMachineDownloader
1221
1343
  return :skipped_not_found
1222
1344
  end
1223
1345
 
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"
1346
+ if HTTPX_AVAILABLE && connection.is_a?(HTTPX::Session)
1347
+ response = connection.get(wayback_url)
1228
1348
 
1229
- response = connection.request(request)
1349
+ raise response.error if response.is_a?(HTTPX::ErrorResponse)
1230
1350
 
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
1351
+ code = response.status
1352
+
1353
+ save_response_body = lambda do
1354
+ if response.respond_to?(:copy_to)
1355
+ response.copy_to(file_path)
1244
1356
  else
1245
- 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
1246
1385
  end
1247
1386
  end
1248
1387
  end
1249
1388
 
1250
1389
  if @all
1251
- case response
1252
- when Net::HTTPSuccess, Net::HTTPRedirection, Net::HTTPClientError, Net::HTTPServerError
1390
+ case code
1391
+ when 200..599
1253
1392
  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}).")
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}).")
1258
1397
  end
1259
1398
  return :saved
1260
1399
  else
1261
1400
  # 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}"
1401
+ raise "Unhandled HTTP response: #{code}"
1263
1402
  end
1264
1403
  else # not @all (our default behavior)
1265
- case response
1266
- when Net::HTTPSuccess
1404
+ if (200..299).cover?(code)
1267
1405
  save_response_body.call
1268
1406
  return :saved
1269
- when Net::HTTPRedirection
1407
+ elsif (300..399).cover?(code)
1270
1408
  raise "Too many redirects for #{file_url}" if redirect_count >= 5
1271
- location = response['location']
1409
+ location = if HTTPX_AVAILABLE && connection.is_a?(HTTPX::Session)
1410
+ response.headers['location']
1411
+ else
1412
+ response['location']
1413
+ end
1272
1414
  @logger.warn("Redirect found for #{file_url} -> #{location}")
1273
1415
  redirected_source = resolve_redirect_source(file_url, location)
1274
1416
  return download_with_retry(file_path, redirected_source, file_timestamp, connection, redirect_count + 1)
1275
- when Net::HTTPTooManyRequests
1417
+ elsif code == 429
1276
1418
  sleep(RATE_LIMIT * 2)
1277
1419
  raise "Rate limited, retrying..."
1278
- when Net::HTTPNotFound
1420
+ elsif code == 404
1279
1421
  @logger.warn("File not found, skipping: #{file_url}")
1280
1422
  return :skipped_not_found
1281
1423
  else
1282
- raise "HTTP Error: #{response.code} #{response.message}"
1424
+ raise "HTTP Error: #{code}"
1283
1425
  end
1284
1426
  end
1285
1427
 
@@ -1297,6 +1439,7 @@ class WaybackMachineDownloader
1297
1439
  end
1298
1440
 
1299
1441
  def cleanup
1442
+ flush_db
1300
1443
  @connection_pool.shutdown
1301
1444
 
1302
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.7
4
+ version: 2.4.8
5
5
  platform: ruby
6
6
  authors:
7
7
  - strawberrymaster
@@ -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.6
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: []