├── .gitignore ├── .ruby-version ├── Gemfile ├── Gemfile.lock ├── README.md ├── history.txt.example ├── lib ├── readmoo_dl.rb └── readmoo_dl │ ├── API.rb │ ├── downloader.rb │ ├── file.rb │ ├── files │ ├── container.rb │ └── content.rb │ └── lister.rb └── main.rb.example /.gitignore: -------------------------------------------------------------------------------- 1 | .byebug_history 2 | *.epub 3 | main.rb 4 | history.txt 5 | cookies.json 6 | -------------------------------------------------------------------------------- /.ruby-version: -------------------------------------------------------------------------------- 1 | 2.6.5 2 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | gem "http" 4 | gem 'nokogiri' 5 | gem 'byebug' 6 | gem 'rubyzip', '>= 1.0.0' 7 | gem 'selenium-webdriver' 8 | gem 'webdrivers', '>= 5.2.0' 9 | gem 'concurrent-ruby' 10 | -------------------------------------------------------------------------------- /Gemfile.lock: -------------------------------------------------------------------------------- 1 | GEM 2 | remote: https://rubygems.org/ 3 | specs: 4 | addressable (2.8.0) 5 | public_suffix (>= 2.0.2, < 5.0) 6 | byebug (11.1.3) 7 | childprocess (4.1.0) 8 | concurrent-ruby (1.3.5) 9 | domain_name (0.5.20190701) 10 | unf (>= 0.0.5, < 1.0.0) 11 | ffi (1.15.5) 12 | ffi-compiler (1.0.1) 13 | ffi (>= 1.0.0) 14 | rake 15 | http (5.0.4) 16 | addressable (~> 2.8) 17 | http-cookie (~> 1.0) 18 | http-form_data (~> 2.2) 19 | llhttp-ffi (~> 0.4.0) 20 | http-cookie (1.0.4) 21 | domain_name (~> 0.5) 22 | http-form_data (2.3.0) 23 | llhttp-ffi (0.4.0) 24 | ffi-compiler (~> 1.0) 25 | rake (~> 13.0) 26 | mini_portile2 (2.8.1) 27 | nokogiri (1.14.3) 28 | mini_portile2 (~> 2.8.0) 29 | racc (~> 1.4) 30 | public_suffix (4.0.7) 31 | racc (1.6.2) 32 | rake (13.0.6) 33 | rexml (3.2.5) 34 | rubyzip (2.3.2) 35 | selenium-webdriver (4.1.0) 36 | childprocess (>= 0.5, < 5.0) 37 | rexml (~> 3.2, >= 3.2.5) 38 | rubyzip (>= 1.2.2) 39 | unf (0.1.4) 40 | unf_ext 41 | unf_ext (0.0.8.1) 42 | webdrivers (5.3.1) 43 | nokogiri (~> 1.6) 44 | rubyzip (>= 1.3.0) 45 | selenium-webdriver (~> 4.0, < 4.11) 46 | 47 | PLATFORMS 48 | ruby 49 | 50 | DEPENDENCIES 51 | byebug 52 | concurrent-ruby 53 | http 54 | nokogiri 55 | rubyzip (>= 1.0.0) 56 | selenium-webdriver 57 | webdrivers (>= 5.2.0) 58 | 59 | BUNDLED WITH 60 | 2.3.12 61 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Readmoo EPUB 下載工具 2 | 3 | 本專案僅為學習 Ruby 使用,並不負責他人使用本工具造成的任何侵權問題。 4 | 5 | ## 使用方法 6 | 1. 安裝 Ruby (2.6?) 3.0 及其以上,安装Chrome或修改[此處](https://github.com/NomadThanatos/readmoo-dl/blob/master/lib/readmoo_dl/API.rb#L43)為您的瀏覽器 7 | 8 | 2. 安裝 Bundle 9 | 10 | ``` gem install bundler ``` 11 | 12 | 3. 在專案根目錄下執行套件安裝 13 | 14 | ``` bundle install ``` 15 | 16 | 4. 複製 main.rb.example 到專案根目錄,並且改名為 main.rb,複製 history.txt.example 到專案根目錄,並且改名為 history.txt 17 | 18 | ``` mv main.rb.example main.rb ``` 19 | 20 | ``` mv history.txt.example history.txt ``` 21 | 22 | 5. 依照 main.rb 裡面的說明修改程式 23 | 24 | 6. 在終端機中執行下方程式開始下載 25 | 26 | ``` bundle exec ruby main.rb ``` 27 | -------------------------------------------------------------------------------- /history.txt.example: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/NomadThanatos/readmoo-dl/ae78ab2b8885f106712670b02fc5f54eaa86b973/history.txt.example -------------------------------------------------------------------------------- /lib/readmoo_dl.rb: -------------------------------------------------------------------------------- 1 | require 'rubygems' 2 | require 'bundler' 3 | Bundler.require(:default) 4 | 5 | require 'json' 6 | 7 | module ReadmooDL 8 | LOGIN_URL = 'https://member.readmoo.com/login' 9 | API_URL = 'https://reader.readmoo.com' 10 | LIST_URL = 'https://new-read.readmoo.com/api/me/readings' 11 | end 12 | 13 | require_relative './readmoo_dl/API' 14 | require_relative './readmoo_dl/lister' 15 | require_relative './readmoo_dl/downloader' 16 | require_relative './readmoo_dl/file' 17 | 18 | Dir[File.join(__dir__, 'readmoo_dl', 'files', '*.rb')].each(&method(:require)) 19 | -------------------------------------------------------------------------------- /lib/readmoo_dl/API.rb: -------------------------------------------------------------------------------- 1 | module ReadmooDL 2 | class API 3 | COOKIE_FILE = 'cookies.json' # Define path for cookie storage 4 | FRONT_PAGE_URL = 'https://readmoo.com/'.freeze 5 | LOGGED_IN_SELECTOR = '.member-data-nav .top-nav-my'.freeze 6 | 7 | def initialize(args={}) 8 | @username = args[:username] 9 | @password = args[:password] 10 | @current_cookie = {} # Initialize before load attempt 11 | @default_headers = nil # Initialize before load attempt 12 | 13 | if load_cookies_from_file 14 | unless validate_cookies_headlessly 15 | puts "Loaded cookies are invalid or expired. Clearing and forcing login." 16 | @current_cookie = {} 17 | @default_headers = nil # Clear headers to ensure login? fails 18 | end 19 | end 20 | end 21 | 22 | def fetch(path, auth = {}) 23 | login unless login? 24 | response = HTTP.headers(default_headers.merge(auth)) 25 | .get("#{ReadmooDL::API_URL}#{path}") 26 | 27 | raise_fetch_fail(path, response) if response.code != 200 28 | set_cookie(response.cookies) 29 | response.to_s 30 | end 31 | 32 | def list() 33 | login unless login? 34 | response = HTTP.headers(default_headers) 35 | .get("#{ReadmooDL::LIST_URL}") 36 | 37 | raise_fetch_fail(path, response) if response.code != 200 38 | set_cookie(response.cookies) 39 | response.to_s 40 | end 41 | 42 | private 43 | 44 | require 'selenium-webdriver' 45 | 46 | def login_selenium(driver) 47 | sleep(1) 48 | driver.find_element(:name, 'email').send_key(@username) 49 | sleep(1) 50 | driver.find_element(:name, 'password').send_key(@password) 51 | driver.find_element(:id, 'sign-in-btn').click 52 | 53 | puts "請在瀏覽器中完成登入(包含 CAPTCHA),腳本將等待最多 5 分鐘..." 54 | wait = Selenium::WebDriver::Wait.new(timeout: 300) # 300 seconds timeout 55 | begin 56 | wait.until { driver.find_element(css: '.member-data-nav .top-nav-my').displayed? } 57 | puts "登入成功,繼續執行..." 58 | rescue Selenium::WebDriver::Error::TimeoutError 59 | puts "等待登入超時(超過 5 分鐘),請重試。" 60 | driver.quit 61 | raise "Login timed out after 300 seconds waiting for CAPTCHA completion." 62 | end 63 | 64 | driver 65 | end 66 | 67 | def login 68 | # Only proceed with Selenium login if cookies weren't loaded or are invalid 69 | return if login? 70 | 71 | driver = Selenium::WebDriver.for :chrome 72 | driver.navigate.to(ReadmooDL::LOGIN_URL) 73 | 74 | driver = login_selenium(driver) 75 | cookies = driver.manage.all_cookies.each{ |e| 76 | e[:expires]=(e[:expires]||Time.now).strftime('%a, %d-%b-%Y %T GMT') 77 | }.map{ |e| HTTP::Cookie.new(e) } 78 | 79 | driver.quit 80 | 81 | set_cookie(cookies) 82 | save_cookies_to_file # Save cookies after successful login 83 | end 84 | 85 | def default_headers 86 | @default_headers ||= { 87 | :'user-agent' => 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_14_2) ' \ 88 | 'AppleWebKit/537.36 (KHTML, like Gecko) ' \ 89 | 'Chrome/71.0.3578.98 Safari/537.36', 90 | :'x-requested-with' => 'XMLHttpRequest', 91 | :'referer' => 'https://reader.readmoo.com/reader/index.html', 92 | } 93 | end 94 | 95 | def current_cookie 96 | @current_cookie ||= {} 97 | end 98 | 99 | def set_cookie(cookie_jar) 100 | cookie_hash = cookie_jar.map { |cookie| [cookie.name, cookie.value] }.to_h 101 | current_cookie.merge!(cookie_hash) 102 | cookie = current_cookie.reduce('') { |cookie, (name, value)| cookie + "#{name}=#{value}; " }.strip 103 | 104 | # Ensure default_headers is initialized before merging 105 | @default_headers ||= { 106 | :'user-agent' => 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_14_2) ' \ 107 | 'AppleWebKit/537.36 (KHTML, like Gecko) ' \ 108 | 'Chrome/71.0.3578.98 Safari/537.36', 109 | :'x-requested-with' => 'XMLHttpRequest', 110 | :'referer' => 'https://reader.readmoo.com/reader/index.html', 111 | } 112 | default_headers.merge!(Cookie: cookie) 113 | # Consider saving cookies here as well if they can be updated by non-login fetches 114 | # save_cookies_to_file 115 | end 116 | 117 | def login? 118 | # Check both for the header key and if cookies were actually loaded/set 119 | @default_headers && @default_headers.key?(:Cookie) && !current_cookie.empty? 120 | end 121 | 122 | def raise_login_fail(response) 123 | raise "登入失敗, Details: StatusCode: #{response.code}, Body: #{response}, Headers: #{response.headers.inspect}" 124 | end 125 | 126 | def raise_fetch_fail(path, response) 127 | raise "取得 #{path} 失敗, Details: StatusCode: #{response.code}, Body: #{response}, Headers: #{response.headers.inspect}" 128 | end 129 | 130 | def validate_cookies_headlessly 131 | return false if @current_cookie.empty? # No cookies to validate 132 | 133 | puts "Validating loaded cookies headlessly..." 134 | options = Selenium::WebDriver::Chrome::Options.new 135 | options.add_argument('--headless') 136 | options.add_argument('--disable-gpu') # Often needed for headless 137 | options.add_argument('--no-sandbox') # May be needed in some environments 138 | options.add_argument('--disable-dev-shm-usage') # Overcome limited resource problems 139 | 140 | driver = nil 141 | begin 142 | driver = Selenium::WebDriver.for :chrome, options: options 143 | # Navigate to the domain first to set cookies for that domain 144 | driver.navigate.to FRONT_PAGE_URL 145 | 146 | # Add cookies one by one 147 | @current_cookie.each do |name, value| 148 | # Selenium needs cookie properties; create a minimal hash 149 | # Note: Domain might need adjustment if cookies are for subdomains 150 | # Note: expiry is not strictly needed for session check but might be for persistence 151 | driver.manage.add_cookie(name: name.to_s, value: value, domain: '.readmoo.com', path: '/') 152 | end 153 | 154 | # Refresh the page to apply cookies 155 | driver.navigate.refresh 156 | sleep(2) # Give page time to load with cookies 157 | 158 | # Check for the logged-in element 159 | driver.find_element(css: LOGGED_IN_SELECTOR) 160 | puts "Cookie validation successful." 161 | return true 162 | rescue Selenium::WebDriver::Error::NoSuchElementError 163 | puts "Cookie validation failed: Logged-in element not found." 164 | return false 165 | rescue StandardError => e 166 | puts "Error during headless cookie validation: #{e.message}" 167 | return false # Treat errors as validation failure 168 | ensure 169 | driver&.quit 170 | end 171 | end 172 | 173 | def load_cookies_from_file 174 | return false unless ::File.exist?(COOKIE_FILE) 175 | 176 | begin 177 | content = ::File.read(COOKIE_FILE) 178 | data = JSON.parse(content, symbolize_names: true) # Use symbolize_names for consistency 179 | if data[:cookies] && data[:headers] 180 | @current_cookie = data[:cookies] 181 | @default_headers = data[:headers] 182 | puts "Loaded cookies from #{COOKIE_FILE}" 183 | return true # Indicate success 184 | else 185 | puts "Cookie file #{COOKIE_FILE} has invalid format." 186 | return false 187 | end 188 | rescue JSON::ParserError => e 189 | puts "Error parsing cookie file #{COOKIE_FILE}: #{e.message}" 190 | return false 191 | rescue StandardError => e 192 | puts "Error loading cookies from #{COOKIE_FILE}: #{e.message}" 193 | return false 194 | end 195 | end 196 | 197 | def save_cookies_to_file 198 | # Only save if we actually have cookies and headers 199 | return if current_cookie.empty? || !@default_headers || !@default_headers.key?(:Cookie) 200 | 201 | begin 202 | data_to_save = { 203 | cookies: @current_cookie, 204 | headers: @default_headers 205 | } 206 | ::File.write(COOKIE_FILE, JSON.pretty_generate(data_to_save)) 207 | puts "Saved cookies to #{COOKIE_FILE}" 208 | rescue StandardError => e 209 | puts "Error saving cookies to #{COOKIE_FILE}: #{e.message}" 210 | end 211 | end 212 | end 213 | end 214 | -------------------------------------------------------------------------------- /lib/readmoo_dl/downloader.rb: -------------------------------------------------------------------------------- 1 | require 'zip' 2 | require 'pathname' 3 | require 'concurrent' 4 | 5 | module ReadmooDL 6 | class Downloader 7 | MAX_RETRIES = 3 8 | RETRY_DELAY = 5 # seconds 9 | MAX_DOWNLOAD_THREADS = 10 # Define constant for concurrent threads 10 | 11 | def initialize(api) 12 | @api = api 13 | @books = {} 14 | end 15 | 16 | def download(book_id) 17 | return puts '該書正在下載中... 請稍候' if @books[book_id] 18 | 19 | # Initialize book ONCE before retrying 20 | @books[book_id] = { 21 | base_path: nil, 22 | root_file_path: nil, 23 | root_file: nil, 24 | files: [ 25 | ] 26 | } 27 | 28 | retries = 0 29 | begin 30 | job("取得檔案路徑#{book_id}") { fetch_base_file(book_id) } 31 | # Add a check here to ensure base_path was set 32 | raise "無法取得書籍 base path: #{book_id}" if @books[book_id][:base_path].nil? 33 | 34 | job('取得 META-INF/container.xml') { fetch_container_file(book_id) } 35 | job('取得 META-INF/encryption.xml') { fetch_encryption_file(book_id) } 36 | job('取得 *.opf 檔案') { fetch_root_file(book_id) } 37 | fetch_book_content(book_id) # 由內部顯示 job 訊息 38 | epub_filename = job('製作 epub 檔案') { build_epub(book_id) } # Capture filename 39 | 40 | puts "#{epub_filename} 下載完成" # Use filename here 41 | rescue StandardError => e # Catching StandardError for simplicity, consider more specific errors 42 | retries += 1 43 | if retries <= MAX_RETRIES 44 | puts "下載失敗 (#{e.message}),#{RETRY_DELAY} 秒後重試 (#{retries}/#{MAX_RETRIES})..." 45 | sleep RETRY_DELAY 46 | retry # Retry the begin block 47 | else 48 | puts "下載失敗,已達最大重試次數 (#{MAX_RETRIES})。" 49 | # Optionally re-raise the error or handle it differently 50 | # raise e 51 | # Or remove the book entry to allow retrying later manually 52 | @books.delete(book_id) 53 | end 54 | end 55 | end 56 | 57 | private 58 | 59 | def job (name, &block) 60 | print "正在#{name}..." 61 | result = block.call # Capture the result 62 | puts '成功' 63 | result # Return the result 64 | end 65 | 66 | def fetch_base_file(book_id) 67 | api_path = "/api/book/#{book_id}/nav" 68 | auth_header = {:'authorization' => 'bearer TWBLXfuP-NbtCrjD2PAiFA'} # Assuming this token is still valid or handled by API class 69 | begin 70 | content_raw = @api.fetch(api_path, auth_header) 71 | content_str = content_raw.to_s 72 | parsed_json = JSON.parse(content_str) 73 | @books[book_id][:base_path] = parsed_json['base'] 74 | rescue JSON::ParserError => e 75 | puts "\n無法解析書籍基本路徑 API 回應 (JSON::ParserError): #{e.message}" 76 | @books[book_id][:base_path] = nil # Ensure it's nil on parse failure 77 | rescue => e # Catch other potential errors during fetch/parse 78 | puts "\n獲取書籍基本路徑時發生錯誤 (#{e.class}): #{e.message}" 79 | @books[book_id][:base_path] = nil 80 | end 81 | end 82 | 83 | def fetch_container_file(book_id) 84 | path = 'META-INF/container.xml' 85 | content = @api.fetch(full_path(book_id, path)) 86 | 87 | container_file = ::ReadmooDL::Files::Container.new(path, content) 88 | 89 | @books[book_id][:root_file_path] = container_file.root_file_path 90 | @books[book_id][:files] << container_file 91 | end 92 | 93 | def fetch_encryption_file(book_id) 94 | path = 'META-INF/encryption.xml' 95 | content = @api.fetch(full_path(book_id, path)) 96 | 97 | encryption_file = ::ReadmooDL::File.new(path, content) 98 | 99 | @books[book_id][:files] << encryption_file 100 | rescue StandardError => e 101 | # Check if the error message string contains 'StatusCode: 403' 102 | if e.message.to_s.include?('StatusCode: 403') 103 | puts "Cannot fetch `encryption.xml` file (HTTP 403 Forbidden). It doesn't matter..." 104 | else 105 | # Print full details for other errors 106 | puts 'Cannot fetch `encryption.xml` file' 107 | puts e.inspect # Keep inspect for unexpected errors 108 | puts "Just a encryption file, it doesn't matter..." 109 | end 110 | end 111 | 112 | def fetch_root_file(book_id) 113 | path = @books[book_id][:root_file_path] 114 | content = @api.fetch(full_path(book_id, path)) 115 | 116 | root_file = ::ReadmooDL::Files::Content.new(path, content) 117 | 118 | @books[book_id][:root_file] = root_file 119 | @books[book_id][:files] << root_file 120 | end 121 | 122 | def fetch_book_content(book_id) 123 | root_file = @books[book_id][:root_file] 124 | file_paths = root_file.file_paths 125 | total = file_paths.size 126 | fetched_count = Concurrent::AtomicFixnum.new(0) # Thread-safe counter 127 | downloaded_files = Concurrent::Array.new # Thread-safe array 128 | 129 | # Adjust pool size based on experimentation/network limits 130 | pool = Concurrent::FixedThreadPool.new(MAX_DOWNLOAD_THREADS) 131 | 132 | puts "準備下載 #{total} 個檔案..." 133 | 134 | futures = file_paths.each_with_index.map do |path, index| 135 | Concurrent::Future.execute(executor: pool) do 136 | begin 137 | puts "#{index + 1}/#{total} => 開始下載 #{path}" 138 | content = @api.fetch(full_path(book_id, path)) 139 | file = ::ReadmooDL::File.new(path, content) 140 | current_count = fetched_count.increment 141 | file # Return the file object 142 | rescue StandardError => e 143 | puts "\n下載檔案失敗: #{path} (#{e.message})" 144 | nil # Indicate failure 145 | end 146 | end 147 | end 148 | 149 | # Wait for all futures to complete and collect results 150 | downloaded_files.concat(futures.map(&:value).compact) # .value blocks until done, .compact removes nils from failures 151 | 152 | pool.shutdown 153 | pool.wait_for_termination 154 | 155 | @books[book_id][:files].concat(downloaded_files) 156 | puts "\n檔案下載完成" 157 | end 158 | 159 | def build_epub(book_id) 160 | book = @books[book_id] 161 | title = book[:root_file].title 162 | files = book[:files] 163 | filename = "#{title}.epub" 164 | 165 | # Replace reserved characters that shouldn't be used in file names 166 | filename = filename.gsub(/[\/\\:\*\?\"\<\>\|]/, '_') 167 | 168 | ::Zip::OutputStream.open(filename) do |stream| 169 | stream.put_next_entry('mimetype', nil, nil, ::Zip::Entry::STORED) 170 | stream.write "application/epub+zip" 171 | 172 | files.each do |file| 173 | stream.put_next_entry Pathname.new(file.path).cleanpath, nil, nil, Zip::Entry::DEFLATED, Zlib::BEST_COMPRESSION 174 | stream.write file.content 175 | end 176 | end 177 | 178 | filename # Return the filename 179 | end 180 | 181 | def full_path(book_id, path) 182 | base = @books[book_id][:base_path] 183 | base + path 184 | end 185 | end 186 | end 187 | -------------------------------------------------------------------------------- /lib/readmoo_dl/file.rb: -------------------------------------------------------------------------------- 1 | module ReadmooDL 2 | class File 3 | attr_reader :path, :content 4 | 5 | def initialize(path, content) 6 | @path = path 7 | @content = content 8 | end 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /lib/readmoo_dl/files/container.rb: -------------------------------------------------------------------------------- 1 | module ReadmooDL 2 | module Files 3 | class Container < ::ReadmooDL::File 4 | def root_file_path 5 | doc.css('rootfile').first.attr('full-path') 6 | end 7 | 8 | private 9 | 10 | def doc 11 | Nokogiri::XML(content) 12 | end 13 | end 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /lib/readmoo_dl/files/content.rb: -------------------------------------------------------------------------------- 1 | module ReadmooDL 2 | module Files 3 | class Content < ::ReadmooDL::File 4 | def file_paths 5 | doc.css('item').map do |item| 6 | ::File.join(base_dir, item.attr('href')).to_s 7 | end 8 | end 9 | 10 | def title 11 | doc.remove_namespaces! 12 | .css('title') 13 | .first 14 | .text 15 | end 16 | 17 | private 18 | 19 | # OEBPS/content.opf => OEBPS 20 | def base_dir 21 | ::File.dirname(path) 22 | end 23 | 24 | def doc 25 | Nokogiri::XML(content) 26 | end 27 | end 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /lib/readmoo_dl/lister.rb: -------------------------------------------------------------------------------- 1 | module ReadmooDL 2 | class Lister 3 | def initialize(api) 4 | @api = api 5 | @books = {} 6 | end 7 | 8 | def list 9 | JSON.parse(@api.list())['included'].map do |item| 10 | item.slice('id', 'title') 11 | end 12 | end 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /main.rb.example: -------------------------------------------------------------------------------- 1 | require_relative 'lib/readmoo_dl' 2 | 3 | username = 'xxxxxxx@gmail.com' # 你的 readmoo 帳號 4 | password = '12345678' # 你的 readmoo 密碼 5 | 6 | api = ReadmooDL::API.new(username: username, password: password) 7 | 8 | downloader = ReadmooDL::Downloader.new(api) 9 | 10 | # 11 | # 書籍編號可以從書籍網址取得,例如書籍網址為 「https://readmoo.com/book/210088579000101」, 12 | # 則編號是 210088579000101,那麼下方的程式碼就改為 「downloader.download('210088579000101')」。 13 | # (注意: 1. 單引號不要刪掉 2. 要有購買過的書籍才可以下載) 14 | # 15 | # 可以複製多行一次下載多本: 16 | # downloader.download('12345678') 17 | # downloader.download('87654321') 18 | # 19 | downloader.download('你的書籍編號') 20 | 21 | # Below code will download every book you have, and save the list to history.txt to avoid re-downloading in future. 22 | # Remove the corresponding line in history.txt if you want to re-download a specific one. 23 | =begin 24 | lister = ReadmooDL::Lister.new(api) 25 | history = {} 26 | File.readlines('history.txt').each do |line| 27 | history_item = JSON.parse(line) 28 | history[history_item['id']] = history_item['title'] 29 | end 30 | 31 | File.open('history.txt', 'a') do |history_file| 32 | lister.list.each do |item| 33 | next if history[item['id']] 34 | downloader.download(item['id']) 35 | history_file.puts(item.to_json) 36 | end 37 | end 38 | =end 39 | --------------------------------------------------------------------------------