diff --git a/kindle-download/Gemfile b/kindle-download/Gemfile
new file mode 100644
index 0000000..720b63d
--- /dev/null
+++ b/kindle-download/Gemfile
@@ -0,0 +1,9 @@
+# frozen_string_literal: true
+
+source "https://rubygems.org"
+
+gem "capybara"
+gem "cuprite", "~> 0.15"
+gem "debug"
+gem "ferrum"
+gem "logger"
diff --git a/kindle-download/Gemfile.lock b/kindle-download/Gemfile.lock
new file mode 100644
index 0000000..8ff00cb
--- /dev/null
+++ b/kindle-download/Gemfile.lock
@@ -0,0 +1,96 @@
+GEM
+ remote: https://rubygems.org/
+ specs:
+ addressable (2.8.7)
+ public_suffix (>= 2.0.2, < 7.0)
+ base64 (0.2.0)
+ capybara (3.40.0)
+ addressable
+ matrix
+ mini_mime (>= 0.1.3)
+ nokogiri (~> 1.11)
+ rack (>= 1.6.0)
+ rack-test (>= 0.6.3)
+ regexp_parser (>= 1.5, < 3.0)
+ xpath (~> 3.2)
+ concurrent-ruby (1.3.5)
+ cuprite (0.15.1)
+ capybara (~> 3.0)
+ ferrum (~> 0.15.0)
+ date (3.4.1)
+ debug (1.10.0)
+ irb (~> 1.10)
+ reline (>= 0.3.8)
+ ferrum (0.15)
+ addressable (~> 2.5)
+ concurrent-ruby (~> 1.1)
+ webrick (~> 1.7)
+ websocket-driver (~> 0.7)
+ io-console (0.8.0)
+ irb (1.15.1)
+ pp (>= 0.6.0)
+ rdoc (>= 4.0.0)
+ reline (>= 0.4.2)
+ logger (1.6.6)
+ matrix (0.4.2)
+ mini_mime (1.1.5)
+ nokogiri (1.18.2-aarch64-linux-gnu)
+ racc (~> 1.4)
+ nokogiri (1.18.2-aarch64-linux-musl)
+ racc (~> 1.4)
+ nokogiri (1.18.2-arm-linux-gnu)
+ racc (~> 1.4)
+ nokogiri (1.18.2-arm-linux-musl)
+ racc (~> 1.4)
+ nokogiri (1.18.2-arm64-darwin)
+ racc (~> 1.4)
+ nokogiri (1.18.2-x86_64-darwin)
+ racc (~> 1.4)
+ nokogiri (1.18.2-x86_64-linux-gnu)
+ racc (~> 1.4)
+ nokogiri (1.18.2-x86_64-linux-musl)
+ racc (~> 1.4)
+ pp (0.6.2)
+ prettyprint
+ prettyprint (0.2.0)
+ psych (5.2.3)
+ date
+ stringio
+ public_suffix (6.0.1)
+ racc (1.8.1)
+ rack (3.1.10)
+ rack-test (2.2.0)
+ rack (>= 1.3)
+ rdoc (6.12.0)
+ psych (>= 4.0.0)
+ regexp_parser (2.10.0)
+ reline (0.6.0)
+ io-console (~> 0.5)
+ stringio (3.1.3)
+ webrick (1.9.1)
+ websocket-driver (0.7.7)
+ base64
+ websocket-extensions (>= 0.1.0)
+ websocket-extensions (0.1.5)
+ xpath (3.2.0)
+ nokogiri (~> 1.8)
+
+PLATFORMS
+ aarch64-linux-gnu
+ aarch64-linux-musl
+ arm-linux-gnu
+ arm-linux-musl
+ arm64-darwin
+ x86_64-darwin
+ x86_64-linux-gnu
+ x86_64-linux-musl
+
+DEPENDENCIES
+ capybara
+ cuprite (~> 0.15)
+ debug
+ ferrum
+ logger
+
+BUNDLED WITH
+ 2.6.2
diff --git a/kindle-download/kindle-download.rb b/kindle-download/kindle-download.rb
new file mode 100755
index 0000000..388f1c7
--- /dev/null
+++ b/kindle-download/kindle-download.rb
@@ -0,0 +1,98 @@
+#!/usr/bin/env ruby
+
+require "logger"
+require "net/http"
+
+require "capybara/cuprite"
+Capybara.javascript_driver = :cuprite
+Capybara.register_driver(:cuprite) do |app|
+ Capybara::Cuprite::Driver.new(app, headless: false)
+end
+Capybara.current_driver = :cuprite
+
+require "capybara/dsl"
+original_verbosity = $VERBOSE
+$VERBOSE = nil
+include Capybara::DSL
+$VERBOSE = original_verbosity
+
+Book = Data.define(:id, :title)
+
+AMAZON_EMAIL = ENV.fetch("AMAZON_EMAIL")
+AMAZON_PASSWORD = ENV.fetch("AMAZON_PASSWORD")
+
+def wait_for_download
+ already_downloaded = Dir[File.expand_path("~/Downloads/*.azw*")]
+ yield
+ loop do
+ current = Dir[File.expand_path("~/Downloads/*.azw*")]
+ new_book, *rest = current - already_downloaded
+ if new_book.nil?
+ sleep 0.1
+ else
+ fail unless rest.empty?
+ return new_book
+ end
+ end
+end
+
+log = Logger.new(STDOUT)
+
+url = "https://www.amazon.com/hz/mycd/digital-console/contentlist/booksPurchases/dateDsc"
+visit url
+
+fill_in "email", with: AMAZON_EMAIL
+fill_in "password", with: AMAZON_PASSWORD
+find("#signInSubmit").click
+
+fill_in "otpCode", with: `op item get --otp ngcal7ifttkh4lbd5ww6slde4q`
+click_on "mfaSubmit"
+
+# I could use pstore here or something, but this is
+# pretty damn convenient
+files = Dir[File.expand_path("~/Downloads/*.azw*")]
+ids = files.map {
+ `xattr -p com.apple.metadata:kMDItemWhereFroms "#{it}"`.chomp
+}
+log.info("Already downloaded: #{files.zip(ids)}")
+
+# this could be smarter, but I am lazy
+(1..6).each do |page_number|
+ url = "https://www.amazon.com/hz/mycd/digital-console/contentlist/booksPurchases/dateDsc?pageNumber=#{page_number}"
+ visit url
+
+ books = all(".digital_entity_title").map {
+ Book.new(id: it["id"].split(?-).last, title: it.text)
+ }.map.with_index.reject {|book, _| ids.include?(book.id) }
+
+ log.info("Downloading: #{books.map(&:first).map(&:title)}")
+
+ books.each do |book, i|
+ # kindle and download don't seem to ever be
+ # visible, so do this awkward dance to actually
+ # associate them to the right book
+ more_actions, kindle, download = all(:xpath, "//span[text()='More actions']").zip(
+ all(:xpath, "//input[@name='actionListRadioButton']", visible: false),
+ all(
+ :xpath,
+ "//div[starts-with(@id, 'DOWNLOAD_AND_TRANSFER_ACTION_') and contains(@id, '_CONFIRM')]",
+ visible: false,
+ ),
+ ).fetch(i)
+
+ more_actions.base.node.scroll_into_view
+ more_actions.click
+ find(:xpath, "//span[text()='Download & transfer via USB']").click
+ kindle.click
+
+ log.info("Downloading #{book.title} (#{book.id})")
+
+ # I'd use page.driver.browser.page.downloads.wait
+ # here, but it (and also .files) don't seem to
+ # work quite right
+ downloaded = wait_for_download { download.click }
+ `xattr -w com.apple.metadata:kMDItemWhereFroms #{book.id} "#{downloaded}"`
+
+ find("#notification-close").click
+ end
+end