archive (3361B)
1 #!/usr/bin/env ruby 2 3 require "uri" 4 require "digest" 5 require "time" 6 require "optparse" 7 require "nokogiri" 8 require "ferrum" 9 10 def absolutize_url(url, base_url) 11 return url if url.nil? || url.empty? 12 return url if url.match?(/\A(?:data|javascript|mailto|tel|about):/i) 13 14 URI.join(base_url, url).to_s 15 rescue URI::InvalidURIError 16 url 17 end 18 19 def absolutize_srcset(srcset, base_url) 20 srcset.split(",").map do |entry| 21 parts = entry.strip.split(/\s+/, 2) 22 next if parts.empty? 23 24 src = absolutize_url(parts[0], base_url) 25 descriptor = parts[1] 26 27 [src, descriptor].compact.join(" ") 28 end.compact.join(", ") 29 end 30 31 def absolutize_links!(doc, base_url) 32 %w[href src poster].each do |attr| 33 doc.css("[#{attr}]").each do |node| 34 node[attr] = absolutize_url(node[attr].to_s.strip, base_url) 35 end 36 end 37 38 doc.css("[srcset]").each do |node| 39 node["srcset"] = absolutize_srcset(node["srcset"].to_s.strip, base_url) 40 end 41 end 42 43 def text_from_html(html) 44 IO.popen(["w3m", "-dump", "-T", "text/html", "-o", "display_link_number=1"], "r+") do |io| 45 io.write(html) 46 io.close_write 47 io.read 48 end 49 end 50 51 options = { 52 manual: false, 53 browser_path: nil 54 } 55 56 OptionParser.new do |parser| 57 parser.banner = "Usage: bin/archive [--manual] [--browser-path PATH] URL [URL ...]" 58 59 parser.on("--manual", "Open a visible browser window so you can complete anti-bot challenges") do 60 options[:manual] = true 61 end 62 63 parser.on("--browser-path PATH", "Path to the browser binary to launch") do |path| 64 options[:browser_path] = path 65 end 66 end.parse! 67 68 urls = ARGV 69 clipboard = "" 70 71 unless urls.any? 72 warn "Please supply one or more URLs" 73 exit 1 74 end 75 76 puts "references:" 77 78 if options[:manual] && options[:browser_path].nil? 79 brave_path = "/Applications/Brave Browser.app/Contents/MacOS/Brave Browser" 80 options[:browser_path] = brave_path if File.exist?(brave_path) 81 end 82 83 browser = Ferrum::Browser.new( 84 headless: !options[:manual], 85 timeout: 30, 86 process_timeout: options[:manual] ? 60 : 30, 87 browser_path: options[:browser_path], 88 browser_options: { "no-sandbox": nil } 89 ) 90 page = browser.create_page 91 92 begin 93 urls.each do |url| 94 begin 95 page.goto(url) 96 97 if options[:manual] 98 warn "Manual mode: finish any challenge in the browser window for #{url}" 99 warn "Press Enter here once the page is fully loaded." 100 $stdin.gets 101 warn "Capturing page..." 102 else 103 page.network.wait_for_idle(timeout: 10) 104 end 105 106 html = page.body.encode("UTF-8", invalid: :replace, undef: :replace, replace: "") 107 doc = Nokogiri::HTML(html) 108 absolutize_links!(doc, url) 109 110 title = doc.at("title")&.text&.strip 111 raise "No title found" if title.to_s.empty? 112 113 text_content = text_from_html(doc.to_html) 114 rescue => ex 115 warn "Archive error (#{ex}; #{url})" 116 exit 1 117 end 118 119 hash = Digest::MD5.base64digest(url + text_content) 120 .scan(/[a-z0-9]/i) 121 .first(6) 122 .join 123 .downcase 124 125 filename = "#{URI.parse(url).host.gsub(".", "-")}-#{hash}.txt" 126 127 File.write("static/archive/#{filename}", text_content) 128 129 yaml = <<~STR 130 - title: "#{title}" 131 url: #{url} 132 date: #{Time.now.utc.iso8601} 133 file: #{filename} 134 STR 135 136 puts yaml 137 138 clipboard += yaml 139 end 140 ensure 141 browser.quit 142 end 143 144 IO.popen("pbcopy", "w") { |pb| pb.write(clipboard) }