# -*- coding: utf-8 -*- # # Copyright 2013 whiteleaf. All rights reserved. # require "yaml" require "fileutils" require "ostruct" require_relative "narou" require_relative "helper" require_relative "sitesetting" require_relative "template" require_relative "database" require_relative "inventory" require_relative "eventable" require_relative "narou/api" require_relative "html" require_relative "input" # # 小説サイトからのダウンロード # class Downloader include Narou::Eventable NOVEL_SITE_SETTING_DIR = "webnovel/" SECTION_SAVE_DIR_NAME = "本文" # 本文を保存するディレクトリ名 CACHE_SAVE_DIR_NAME = "cache" # 差分用キャッシュ保存用ディレクトリ名 RAW_DATA_DIR_NAME = "raw" # 本文の生データを保存するディレクトリ名 TOC_FILE_NAME = "toc.yaml" STEPS_WAIT_TIME = 5 # 数話ごとにかかるwaitの秒数 WAITING_TIME_FOR_503 = 20 # 503 のときに待機する秒数 RETRY_MAX_FOR_503 = 5 # 503 のときに何回再試行するか NOVEL_TYPE_SERIES = 1 # 連載 NOVEL_TYPE_SS = 2 # 短編 DISPLAY_LIMIT_DIGITS = 4 # indexの表示桁数限界 DEFAULT_INTERVAL_WAIT = 0.7 # download.interval のデフォルト値(秒) attr_reader :id class InvalidTarget < StandardError; end def initialize(target, options = {}) id = Downloader.get_id_by_target(target) options = { force: false, from_download: false, stream: $stdout }.merge(options) setting = Downloader.get_sitesetting_by_target(target) unless setting case type = Downloader.get_target_type(target) when :url, :ncode raise InvalidTarget, "対応外の#{type}です(#{target})" when :id raise InvalidTarget, "指定のID(#{target})は存在しません" when :other raise InvalidTarget, "指定の小説(#{target})は存在しません" end end initialize_variables(id, setting, options) end # # 小説サイト設定を取得する # def self.get_sitesetting_by_target(target) toc_url = get_toc_url(target) setting = nil if toc_url setting = @@settings.find { |s| s.multi_match(toc_url, "url") } end setting end # # 本文格納用ディレクトリを取得 # def self.get_novel_section_save_dir(archive_path) File.join(archive_path, SECTION_SAVE_DIR_NAME) end # # target の種別を判別する # # ncodeの場合、targetを破壊的に変更する # def self.get_target_type(target) case target when URI.regexp :url when /^n\d+[a-z]+$/i target.downcase! :ncode when /^\d+$/, Fixnum :id else :other end end # # 指定されたIDとかから小説の保存ディレクトリを取得 # def self.get_novel_data_dir_by_target(target) target = Narou.alias_to_id(target) type = get_target_type(target) data = nil case type when :url, :ncode toc_url = get_toc_url(target) data = @@database.get_data("toc_url", toc_url) when :other data = @@database.get_data("title", target) when :id data = @@database[target.to_i] end return nil unless data id = data["id"] file_title = data["file_title"] || data["title"] # 互換性維持のための処理 use_subdirectory = data["use_subdirectory"] || false subdirectory = use_subdirectory ? create_subdirecotry_name(file_title) : "" path = File.join(Database.archive_root_path, data["sitename"], subdirectory, file_title) if File.exist?(path) return path else @@database.delete(id) @@database.save_database error "#{path} が見つかりません。\n" \ "保存フォルダが消去されていたため、データベースのインデックスを削除しました。" return nil end end # # target のIDを取得 # def self.get_id_by_target(target) toc_url = get_toc_url(target) or return nil @@database.get_id("toc_url", toc_url) end # # target からデータベースのデータを取得 # def self.get_data_by_target(target) toc_url = get_toc_url(target) or return nil @@database.get_data("toc_url", toc_url) end # # toc 読込 # def self.get_toc_data(archive_path) YAML.load_file(File.join(archive_path, TOC_FILE_NAME)) end # # 指定の小説の目次ページのURLを取得する # # targetがURLかNコードの場合、実際には小説が存在しないURLが返ってくることがあるのを留意する # def self.get_toc_url(target) target = Narou.alias_to_id(target) case get_target_type(target) when :url setting = @@settings.find { |s| s.multi_match(target, "url") } return setting["toc_url"] if setting when :ncode @@database.each do |_, data| if data["toc_url"] =~ %r!#{target}/$! return data["toc_url"] end end return "#{@@narou["top_url"]}/#{target}/" when :id data = @@database[target.to_i] return data["toc_url"] if data when :other data = @@database.get_data("title", target) return data["toc_url"] if data end nil end def self.novel_exists?(target) id = get_id_by_target(target) or return nil @@database.novel_exists?(id) end def self.remove_novel(target, with_file = false) data = get_data_by_target(target) or return nil data_dir = get_novel_data_dir_by_target(target) if with_file FileUtils.remove_entry_secure(data_dir, true) puts "#{data_dir} を完全に削除しました" else # TOCは消しておかないと再DL時に古いデータがあると誤認する File.delete(File.join(data_dir, TOC_FILE_NAME)) end @@database.delete(data["id"]) @@database.save_database data["title"] end def self.get_sitesetting_by_sitename(sitename) setting = @@settings.find { |s| s["name"] == sitename } return setting if setting error "#{sitename} の設定ファイルが見つかりません" exit Narou::EXIT_ERROR_CODE end # # 小説サイトの定義ファイルを全部読み込む # def self.load_settings settings = @@__settings_cache ||= [] return settings unless settings.empty? Dir.glob(File.join(Narou.get_script_dir, NOVEL_SITE_SETTING_DIR, "*.yaml")) do |path| setting = SiteSetting.load_file(path) if setting["name"] == "小説家になろう" @@narou = setting end settings << setting end if settings.empty? error "小説サイトの定義ファイルがひとつもありません" exit Narou::EXIT_ERROR_CODE end unless @@narou error "小説家になろうの定義ファイルが見つかりませんでした" exit Narou::EXIT_ERROR_CODE end settings end # # 差分用キャッシュの保存ディレクトリ取得 # def self.get_cache_root_dir(target) dir = get_novel_data_dir_by_target(target) if dir return File.join(dir, SECTION_SAVE_DIR_NAME, CACHE_SAVE_DIR_NAME) end nil end # # 差分用キャッシュのディレクトリ一覧取得 # def self.get_cache_list(target) dir = get_cache_root_dir(target) if dir return Dir.glob("#{dir}/*") end nil end # # サブディレクトリ名を生成 # def self.create_subdirecotry_name(title) title.start_with?("n") ? title[1..2] : title[0..1] end if Narou.already_init? @@settings = load_settings @@database = Database.instance end # # 変数初期化 # def initialize_variables(id, setting, options) @title = nil @file_title = nil @setting = setting @force = options[:force] @from_download = options[:from_download] @stream = options[:stream] @cache_dir = nil @new_arrivals = false @novel_data_dir = nil @novel_status = nil @id = id || @@database.create_new_id @new_novel = @@database[@id].! @section_download_cache = {} @download_wait_steps = Inventory.load("local_setting")["download.wait-steps"] || 0 @download_use_subdirectory = use_subdirectory? if @setting["is_narou"] && (@download_wait_steps > 10 || @download_wait_steps == 0) @download_wait_steps = 10 end @nosave_diff = Narou.economy?("nosave_diff") @nosave_raw = Narou.economy?("nosave_raw") @gurad_spoiler = Inventory.load("local_setting")["guard-spoiler"] initialize_wait_counter end # # ウェイト関係初期化 # def initialize_wait_counter @@__run_once ||= false unless @@__run_once @@__run_once = true @@__wait_counter = 0 @@__last_download_time = Time.now - 20 end @@interval_sleep_time = Inventory.load("local_setting")["download.interval"] || DEFAULT_INTERVAL_WAIT @@interval_sleep_time = 0 if @@interval_sleep_time < 0 @@max_steps_wait_time = [STEPS_WAIT_TIME, @@interval_sleep_time].max end # # サブディレクトリに保存してあるかどうか # def use_subdirectory? if @new_novel # 新規DLする小説 Inventory.load("local_setting")["download.use-subdirectory"] || false else # すでにDL済みの小説 @@database[@id]["use_subdirectory"] || false end end # # 18歳以上か確認する # def confirm_over18? global_setting = Inventory.load("global_setting", :global) if global_setting.include?("over18") return global_setting["over18"] end if Narou::Input.confirm("年齢認証:あなたは18歳以上ですか") global_setting["over18"] = true global_setting.save return true else return false end end # # ダウンロードを処理本体を起動 # def start_download @status = run_download OpenStruct.new( :id => @id, :new_arrivals => @new_arrivals, :status => @status ).freeze end def load_toc_file load_novel_data(TOC_FILE_NAME) end # # ダウンロード処理本体 # def run_download old_toc = @new_novel ? nil : load_toc_file latest_toc = get_latest_table_of_contents(old_toc) unless latest_toc @stream.error @setting["toc_url"] + " の目次データが取得出来ませんでした" return :failed end if @setting["confirm_over18"] unless confirm_over18? @stream.puts "18歳以上のみ閲覧出来る小説です。ダウンロードを中止しました" return :canceled end end unless old_toc init_novel_dir old_toc = {} @new_arrivals = true end init_raw_dir if old_toc.empty? || @force update_subtitles = latest_toc["subtitles"] else update_subtitles = update_body_check(old_toc["subtitles"], latest_toc["subtitles"]) end if old_toc.empty? && update_subtitles.count == 0 @stream.error "#{@setting['title']} の目次がありません" return :failed end unless @force if process_digest(old_toc, latest_toc) return :canceled end end id_and_title = "ID:#{@id} #{@title}" return_status = case when update_subtitles.size > 0 @cache_dir = create_cache_dir if old_toc.length > 0 begin sections_download_and_save(update_subtitles) if @cache_dir && Dir.glob(File.join(@cache_dir, "*")).count == 0 remove_cache_dir end rescue Interrupt remove_cache_dir raise end update_database :ok when old_toc["subtitles"].size > latest_toc["subtitles"].size # 削除された節がある(かつ更新がない)場合 @stream.puts "#{id_and_title} は一部の話が削除されています" :ok when old_toc["title"] != latest_toc["title"] # タイトルが更新されている場合 @stream.puts "#{id_and_title} のタイトルが更新されています" update_database :ok when old_toc["story"] != latest_toc["story"] # あらすじが更新されている場合 @stream.puts "#{id_and_title} のあらすじが更新されています" :ok else :none end save_novel_data(TOC_FILE_NAME, latest_toc) tags = @new_novel ? [] : @@database[@id]["tags"] || [] if novel_end? unless tags.include?("end") update_database if update_subtitles.count == 0 $stdout.silence do Command::Tag.execute!(%W(#{id} --add end --color white --no-overwrite-color)) end msg = old_toc.empty? ? "完結しているようです" : "完結したようです" @stream.puts "#{id_and_title.escape} は#{msg}".termcolor return_status = :ok end else if tags.include?("end") update_database if update_subtitles.size == 0 $stdout.silence do Command::Tag.execute!([@id, "--delete", "end"]) end @stream.puts "#{id_and_title.escape} は連載を再開したようです".termcolor return_status = :ok end end return_status ensure @setting.clear end CHOICES = { "1" => "このまま更新する", "2" => "更新をキャンセル", "3" => "更新をキャンセルして小説を凍結する", "4" => "バックアップを作成する", "5" => "最新のあらすじを表示する", "6" => "小説ページをブラウザで開く", "7" => "保存フォルダを開く", "8" => "変換する", default: "2" }.freeze def self.choices_to_string(width: 0) CHOICES.dup.tap { |h| h.delete(:default) }.map { |key, summary| "#{key.rjust(width)}: #{summary}" }.join("\n") end # # ダイジェスト化に関する処理 # # @return true = 更新をキャンセル、false = 更新する # def process_digest(old_toc, latest_toc) return false unless old_toc["subtitles"] latest_subtitles_count = latest_toc["subtitles"].size old_subtitles_count = old_toc["subtitles"].size if latest_subtitles_count < old_subtitles_count title = latest_toc["title"] message = <<-EOS 更新後の話数が保存されている話数より減少していることを検知しました。 ダイジェスト化されている可能性があるので、更新に関しての処理を選択して下さい。 保存済み話数: #{old_subtitles_count} 更新後の話数: #{latest_subtitles_count} EOS auto_choices = Inventory.load("local_setting")["download.choices-of-digest-options"] auto_choices &&= auto_choices.split(",") loop do if auto_choices # 自動入力 choice = auto_choices.shift || CHOICES[:default] puts title puts message puts self.class.choices_to_string puts "> #{choice}" else choice = Narou::Input.choose(title, message, CHOICES) end case choice when "1" return false when "2" return true when "3" Command::Freeze.execute!([latest_toc["toc_url"]]) return true when "4" Command::Backup.execute!([latest_toc["toc_url"]]) when "5" if Narou.web? message = "あらすじ\n#{latest_toc["story"]}\n" else puts "あらすじ" puts latest_toc["story"] end when "6" Helper.open_browser(latest_toc["toc_url"]) when "7" Helper.open_directory(Downloader.get_novel_data_dir_by_target(latest_toc["toc_url"])) when "8" Command::Convert.execute!([latest_toc["toc_url"]]) end unless Narou.web? message = "" # 長いので二度は表示しない end end else return false end end # # 差分用キャッシュ保存ディレクトリ作成 # def create_cache_dir return nil if @nosave_diff now = Time.now name = now.strftime("%Y.%m.%d@%H.%M.%S") cache_dir = File.join(get_novel_data_dir, SECTION_SAVE_DIR_NAME, CACHE_SAVE_DIR_NAME, name) FileUtils.mkdir_p(cache_dir) cache_dir end # # 差分用キャッシュ保存ディレクトリを削除 # def remove_cache_dir FileUtils.remove_entry_secure(@cache_dir, true) if @cache_dir end def __search_latest_update_time(key, subtitles) latest = Time.new(0) subtitles.each do |subtitle| time = Helper.date_string_to_time(subtitle[key]) latest = time if latest < time end latest end # # 小説が更新された日をTime型で取得 # def get_novelupdated_at info = @setting["info"] || {} if info["novelupdated_at"] info["novelupdated_at"] else __search_latest_update_time("subupdate", @setting["subtitles"]) end end # # 小説の最新掲載日をTime型で取得 # # 小説家になろう、ハーメルンは小説情報ページの最終話掲載日などから取得した日付 # その他サイトは一番新しい話の投稿日(更新日ではない) # def get_general_lastup info = @setting["info"] || {} if info["general_lastup"] info["general_lastup"] else __search_latest_update_time("subdate", @setting["subtitles"]) end end # # データベース更新 # def update_database info = @setting["info"] || {} data = { "id" => @id, "author" => @setting["author"], "title" => get_title, "file_title" => get_file_title, "toc_url" => @setting["toc_url"], "sitename" => @setting["name"], "novel_type" => get_novel_type, "end" => novel_end?, "last_update" => Time.now, "new_arrivals_date" => (@new_arrivals ? Time.now : @@database[@id]["new_arrivals_date"]), "use_subdirectory" => @download_use_subdirectory, "general_firstup" => info["general_firstup"], "novelupdated_at" => get_novelupdated_at, "general_lastup" => get_general_lastup, } if @@database[@id] @@database[@id].merge!(data) else @@database[@id] = data end @@database.save_database end def get_novel_status novel_status = if false # @setting["narou_api_url"] Narou::API.new(@setting, "nt-e") else NovelInfo.load(@setting) end unless novel_status novel_status = { "novel_type" => NOVEL_TYPE_SERIES, "end" => false } end novel_status end # # 小説の種別を取得(連載か短編) # def get_novel_type @novel_status ||= get_novel_status @novel_status["novel_type"] end # # 小説が完結しているか調べる # def novel_end? @novel_status ||= get_novel_status @novel_status["end"] end # # 連載小説かどうか調べる # def series_novel? get_novel_type == NOVEL_TYPE_SERIES end # # 小説を格納するためのディレクトリ名を取得する # def get_file_title return @file_title if @file_title # すでにデータベースに登録されているならそれを引き続き使うようにする if @@database[@id] && @@database[@id]["file_title"] @file_title = @@database[@id]["file_title"] return @file_title end @file_title = @setting["ncode"] if @setting["append_title_to_folder_name"] @file_title += " " + Helper.replace_filename_special_chars(get_title, true).strip end @file_title end # # 小説のタイトルを取得する # def get_title return @title if @title @title = @setting["title"] if @setting["title_strip_pattern"] @title = @title.gsub(/#{@setting["title_strip_pattern"]}/, "").gsub(/^[ \s]*(.+?)[ \s]*?$/, "\\1") end @title end class DownloaderNotFoundError < OpenURI::HTTPError def initialize super("404 not found", nil) end end class DownloaderForceRedirect < StandardError; end # # HTMLの中から小説が削除されたか非公開なことを示すメッセージを検出する # def self.detect_error_message(setting, source) message = setting["error_message"] return false unless message source.match(message) end def get_toc_source toc_url = @setting["toc_url"] return nil unless toc_url max_retry = 5 toc_source = "" cookie = @setting["cookie"] || "" open_uri_options = make_open_uri_options("Cookie" => cookie, allow_redirections: :safe) begin open(toc_url, open_uri_options) do |toc_fp| if toc_fp.base_uri.to_s != toc_url # リダイレクトされた場合。 # ノクターン・ムーンライトのNコードを ncode.syosetu.com に渡すと、年齢認証のクッションページに飛ばされる # 転送先を取得し再度ページを取得し直す uri = URI.parse(toc_fp.base_uri.to_s) if uri.host == "nl.syosetu.com" decode = Hash[URI.decode_www_form(uri.query)] toc_url = decode["url"] # 年齢認証確認ページからの転送先 raise DownloaderForceRedirect end s = Downloader.get_sitesetting_by_target(toc_fp.base_uri.to_s) raise DownloaderNotFoundError unless s # 非公開や削除等でトップページへリダイレクトされる場合がある @setting.clear # 今まで使っていたのは一旦クリア @setting = s toc_url = @setting["toc_url"] end toc_source = Helper.restor_entity(Helper.pretreatment_source(toc_fp.read, @setting["encoding"])) raise DownloaderNotFoundError if Downloader.detect_error_message(@setting, toc_source) end rescue DownloaderForceRedirect max_retry -= 1 if max_retry >= 0 retry else raise end end toc_source end # # 目次データを取得する # def get_latest_table_of_contents(old_toc, through_error: false) toc_source = get_toc_source return nil unless toc_source @setting.multi_match(toc_source, "tcode") #if @setting["narou_api_url"] if false # なろうAPIの出力がおかしいので直るまで使用中止 info = Narou::API.new(@setting, "t-s-gf-gl-nu-w") else info = NovelInfo.load(@setting, toc_source) end if info raise DownloaderNotFoundError unless info["title"] @setting["title"] = info["title"] @setting["author"] = info["writer"] @setting["story"] = info["story"] else # 小説情報ページがないサイトの場合は目次ページから取得する @setting.multi_match(toc_source, "title", "author", "story") raise DownloaderNotFoundError unless @setting.matched?("title") story_html = HTML.new(@setting["story"]) story_html.strip_decoration_tag = true @setting["story"] = story_html.to_aozora end @setting["info"] = info @setting["title"] = get_title if series_novel? # 連載小説 subtitles = get_subtitles(toc_source, old_toc) else # 短編小説 subtitles = create_short_story_subtitles(info) end @setting["subtitles"] = subtitles toc_objects = { "title" => get_title, "author" => @setting["author"], "toc_url" => @setting["toc_url"], "story" => @setting["story"], "subtitles" => subtitles } toc_objects rescue OpenURI::HTTPError, Errno::ECONNRESET => e raise if through_error # エラー処理はしなくていいからそのまま例外を受け取りたい時用 if e.message.include?("404") @stream.error "小説が削除されているか非公開な可能性があります" if @@database.novel_exists?(@id) $stdout.silence do Command::Tag.execute!(%W(#{@id} --add 404 --color white --no-overwrite-color)) end Command::Freeze.execute!([@id, "--on"]) end else @stream.error "何らかの理由により目次が取得できませんでした(#{e.message})" end false end def __search_index_in_subtitles(subtitles, index) subtitles.index { |item| item["index"] == index } end def __strdate_to_ymd(date) Date.parse(date.to_s.tr("年月日時分秒", "///:::")).strftime("%Y%m%d") end # # 本文更新チェック # # 更新された subtitle だけまとまった配列を返す # def update_body_check(old_subtitles, latest_subtitles) strong_update = Inventory.load("local_setting")["update.strong"] latest_subtitles.select do |latest| index = latest["index"] index_in_old_toc = __search_index_in_subtitles(old_subtitles, index) next true unless index_in_old_toc old = old_subtitles[index_in_old_toc] # タイトルチェック if old["subtitle"] != latest["subtitle"] next true end # 章チェック if old["chapter"] != latest["chapter"] next true end # 前回ダウンロードしたはずの本文ファイルが存在するか section_file_name = "#{index} #{old["file_subtitle"]}.yaml" section_file_relative_path = File.join(SECTION_SAVE_DIR_NAME, section_file_name) unless File.exist?(File.join(get_novel_data_dir, section_file_relative_path)) # あるはずのファイルが存在しなかったので、再ダウンロードが必要 next true end # 更新日チェック # subdate : 初稿投稿日 # subupdate : 改稿日 old_subdate = old["subdate"] latest_subdate = latest["subdate"] old_subupdate = old["subupdate"] latest_subupdate = latest["subupdate"] # oldにsubupdateがなくても、latestのほうにsubupdateがある場合もある old_subupdate = old_subdate if latest_subupdate && !old_subupdate different_check = nil latest["download_time"] = old["download_time"] if strong_update latest_section_timestamp_ymd = __strdate_to_ymd(get_section_file_timestamp(old, latest)) different_check = lambda do latest_info_dummy = latest.dup latest_info_dummy["element"] = a_section_download(latest) deffer = different_section?(section_file_relative_path, latest_info_dummy) unless deffer # 差分がある場合はこのあと保存されて更新されるので、差分がない場合のみ # タイムスタンプを更新しておく now = Time.now File.utime(now, now, File.join(get_novel_data_dir, section_file_relative_path)) end deffer end end if old_subupdate && latest_subupdate if old_subupdate == "" next latest_subupdate != "" end if strong_update if __strdate_to_ymd(old_subupdate) == latest_section_timestamp_ymd next different_check.call end end latest_subupdate > old_subupdate else # 古いバージョンだと old_subdate が nil なので判定出来ないため next true unless old_subdate if strong_update if __strdate_to_ymd(old_subdate) == latest_section_timestamp_ymd next different_check.call end end latest_subdate > old_subdate end end end # # 対象話数のタイムスタンプを取得 # def get_section_file_timestamp(old_subtitles_info, latest_subtitles_info) download_time = old_subtitles_info["download_time"] unless download_time download_time = File.mtime(create_section_file_path(old_subtitles_info)) end latest_subtitles_info["download_time"] = download_time download_time end def title_to_filename(title) Helper.replace_filename_special_chars(Helper.truncate_path(title)) end # # 各話の情報を取得 # def get_subtitles(toc_source, old_toc) subtitles = [] toc_post = toc_source.dup old_subtitles = old_toc ? old_toc["subtitles"] : nil loop do match_data = @setting.multi_match(toc_post, "subtitles") break unless match_data toc_post = match_data.post_match @setting["subtitle"] = @setting["subtitle"].gsub("\t", "") subdate = @setting["subdate"].tap { |sd| # subdate(初回掲載日)がない場合、最初に取得した時のsubupdateで代用する # subdateが取得出来ないのは暁とArcadia unless sd old_index = old_subtitles ? __search_index_in_subtitles(old_subtitles, @setting["index"]) : nil if !old_index || !old_subtitles[old_index]["subdate"] break @setting["subupdate"] end # || 以降は subupdate を取得していない古い(2.4.0以前)toc.yamlがあるためsubdateを使う break old_subtitles[old_index]["subupdate"] || old_subtitles[old_index]["subdate"] end } subtitles << { "index" => @setting["index"], "href" => @setting["href"], "chapter" => @setting["chapter"].to_s, "subchapter" => @setting["subchapter"].to_s, "subtitle" => @setting["subtitle"].gsub("\n", ""), "file_subtitle" => title_to_filename(@setting["subtitle"]), "subdate" => subdate, "subupdate" => @setting["subupdate"] } end subtitles end # # 短編用の情報を生成 # def create_short_story_subtitles(info) subtitle = { "index" => "1", "href" => @setting.replace_group_values("href", "index" => "1"), "chapter" => "", "subtitle" => @setting["title"], "file_subtitle" => title_to_filename(@setting["title"]), "subdate" => info["general_firstup"], "subupdate" => info["novelupdated_at"] } [subtitle] end # # 小説本文をまとめてダウンロードして保存 # # subtitles にダウンロードしたいものをまとめた subtitle info を渡す # def sections_download_and_save(subtitles) max = subtitles.size return if max == 0 @stream.puts "#{"ID:#{@id} #{get_title}".escape} のDL開始".termcolor save_least_one = false subtitles.each_with_index do |subtitle_info, i| index, subtitle, file_subtitle, chapter, subchapter = %w(index subtitle file_subtitle chapter subchapter).map { |k| subtitle_info[k] } info = subtitle_info.dup info["element"] = a_section_download(subtitle_info) @stream.puts "#{chapter}" unless chapter.to_s.empty? @stream.puts "#{subchapter}" unless subchapter.to_s.empty? if get_novel_type == NOVEL_TYPE_SERIES if index.to_s.length <= DISPLAY_LIMIT_DIGITS # indexの数字がでかいと見た目がみっともないので特定の桁以内だけ表示する @stream.print "第#{index}部分 " end else @stream.print "短編 " end printable_subtitle = @gurad_spoiler ? Helper.to_unprintable_words(subtitle) : subtitle @stream.print "#{printable_subtitle} (#{i+1}/#{max})" section_file_name = "#{index} #{file_subtitle}.yaml" section_file_relative_path = File.join(SECTION_SAVE_DIR_NAME, section_file_name) section_file_full_path = File.join(get_novel_data_dir, section_file_relative_path) if File.exist?(section_file_full_path) if @force if different_section?(section_file_relative_path, info) @stream.print " (更新あり)" move_to_cache_dir(section_file_relative_path) end else move_to_cache_dir(section_file_relative_path) end else if !@from_download || (@from_download && @force) @stream.print " (新着)".termcolor trigger(:newarrival, { id: @id, subtitle_info: subtitle_info }) end @new_arrivals = true end save_novel_data(section_file_relative_path, info) save_least_one = true @stream.puts end remove_cache_dir unless save_least_one end # # すでに保存されている内容とDLした内容が違うかどうか # def different_section?(old_relative_path, new_subtitle_info) path = File.join(get_novel_data_dir, old_relative_path) if File.exist?(path) return YAML.load_file(path)["element"] != new_subtitle_info["element"] else return true end end # # 差分用のキャッシュとして保存 # def move_to_cache_dir(relative_path) return if @nosave_diff path = File.join(get_novel_data_dir, relative_path) if File.exist?(path) && @cache_dir FileUtils.mv(path, @cache_dir) end end def sleep_for_download if Time.now - @@__last_download_time > @@max_steps_wait_time @@__wait_counter = 0 end if @download_wait_steps > 0 && @@__wait_counter % @download_wait_steps == 0 \ && @@__wait_counter >= @download_wait_steps # MEMO: # 小説家になろうは連続DL規制があるため、ウェイトを入れる必要がある。 # 10話ごとに規制が入るため、10話ごとにウェイトを挟む。 # 1話ごとに1秒待機を10回繰り返そうと、11回目に規制が入るため、ウェイトは必ず必要。 sleep(@@max_steps_wait_time) else sleep(@@interval_sleep_time) if @@__wait_counter > 0 end @@__wait_counter += 1 @@__last_download_time = Time.now end # # 指定された話数の本文をダウンロード # def a_section_download(subtitle_info) index = subtitle_info["index"] return @section_download_cache[index] if @section_download_cache[index] sleep_for_download href = subtitle_info["href"] if @setting["is_narou"] subtitle_url = @setting.replace_group_values("txtdownload_url", subtitle_info) elsif href[0] == "/" subtitle_url = @setting["top_url"] + href else subtitle_url = @setting["toc_url"] + href end raw = download_raw_data(subtitle_url) if @setting["is_narou"] raw = Helper.restor_entity(raw) save_raw_data(raw, subtitle_info) element = extract_elements_in_section(raw, subtitle_info["subtitle"]) element["data_type"] = "text" else save_raw_data(raw, subtitle_info, ".html") %w(introduction body postscript).each { |type| @setting[type] = nil } @setting.multi_match(raw, "body_pattern", "introduction_pattern", "postscript_pattern") element = { "data_type" => "html" } %w(introduction body postscript).each { |type| element[type] = @setting[type] || "" } end subtitle_info["download_time"] = Time.now @section_download_cache[index] = element element end def display_hint @stream.warn <<-EOS ヒント: 503 がでた場合はしばらくアクセスが規制される場合があります。 設定を変更してサーバーに対する負荷を軽減させましょう。 (メンテナンス等でも503になる場合があります) 下記の設定のどれか、もしくは全てを変更することで調整できます。 # Update時の作品間の待機時間を変更する narou s update.interval=3.0 # 1話ごとに入るウェイトを変更する narou s download.interval=1.0 # 5話ごとに通常より長いウェイトを入れる narou s download.wait-steps=5 EOS end # # 指定したURLからデータをダウンロード # def download_raw_data(url) raw = nil retry_count = RETRY_MAX_FOR_503 cookie = @setting["cookie"] || "" begin open_uri_options = make_open_uri_options("Cookie" => cookie, allow_redirections: :safe) open(url, open_uri_options) do |fp| raw = Helper.pretreatment_source(fp.read, @setting["encoding"]) end rescue OpenURI::HTTPError => e if e.message =~ /^503/ if retry_count == 0 @stream.error "上限までリトライしましたがファイルがダウンロード出来ませんでした" exit Narou::EXIT_ERROR_CODE end retry_count -= 1 @stream.puts @stream.warn "server message: #{e.message}" @stream.warn "リトライ待機中……" @@display_hint_once ||= false unless @@display_hint_once display_hint @@display_hint_once = true end sleep(WAITING_TIME_FOR_503) retry elsif e.message =~ /^404/ @stream.error "#{url} がダウンロード出来ませんでした。時間をおいて再度試してみてください" exit Narou::EXIT_ERROR_CODE else raise end end raw end def get_raw_dir @raw_dir ||= File.join(get_novel_data_dir, RAW_DATA_DIR_NAME) end def init_raw_dir return if @nosave_raw path = get_raw_dir FileUtils.mkdir_p(path) unless File.exist?(path) end # # テキストデータの生データを保存 # def save_raw_data(raw_data, subtitle_info, ext = ".txt") return if @nosave_raw index = subtitle_info["index"] file_subtitle = subtitle_info["file_subtitle"] path = File.join(get_raw_dir, "#{index} #{file_subtitle}#{ext}") File.write(path, raw_data) end # # 本文を解析して前書き・本文・後書きの要素に分解する # # 本文に含まれるタイトルは消す # def extract_elements_in_section(section, subtitle) lines = section.lstrip.lines.map(&:rstrip) introduction = slice_introduction(lines) postscript = slice_postscript(lines) if lines[0] == subtitle.strip if lines[1] == "" lines.slice!(0, 2) else lines.slice!(0, 1) end end { "introduction" => introduction, "body" => lines.join("\n"), "postscript" => postscript } end def slice_introduction(lines) lines.each_with_index do |line, lineno| if line =~ ConverterBase::AUTHOR_INTRODUCTION_SPLITTER lines.slice!(lineno, 1) return lines.slice!(0...lineno).join("\n") end end "" end def slice_postscript(lines) lines.each_with_index do |line, lineno| if line =~ ConverterBase::AUTHOR_POSTSCRIPT_SPLITTER lines.slice!(lineno, 1) return lines.slice!(lineno..-1).join("\n") end end "" end # # 小説データの格納ディレクトリパス # def get_novel_data_dir return @novel_data_dir if @novel_data_dir raise "小説名がまだ設定されていません" unless get_file_title subdirectory = @download_use_subdirectory ? Downloader.create_subdirecotry_name(get_file_title) : "" @novel_data_dir = File.join(Database.archive_root_path, @setting["name"], subdirectory, get_file_title) @novel_data_dir end # # 小説本文の保存パスを生成 # def create_section_file_path(subtitle_info) filename = "#{subtitle_info["index"]} #{subtitle_info["file_subtitle"]}.yaml" File.join(get_novel_data_dir, SECTION_SAVE_DIR_NAME, filename) end # # 小説データの格納ディレクトリに保存 # def save_novel_data(filename, object) path = File.join(get_novel_data_dir, filename) dir_path = File.dirname(path) unless File.exist?(dir_path) FileUtils.mkdir_p(dir_path) end File.write(path, YAML.dump(object)) end # # 小説データの格納ディレクトリから読み込む def load_novel_data(filename) dir_path = get_novel_data_dir YAML.load_file(File.join(dir_path, filename)) rescue Errno::ENOENT nil end # # 小説データの格納ディレクトリを初期設定する # def init_novel_dir novel_dir_path = get_novel_data_dir file_title = File.basename(novel_dir_path) FileUtils.mkdir_p(novel_dir_path) unless File.exist?(novel_dir_path) original_settings = NovelSetting.get_original_settings default_settings = NovelSetting.load_default_settings novel_setting = NovelSetting.new(@id, true, true) special_preset_dir = File.join(Narou.get_preset_dir, @setting["domain"], @setting["ncode"]) exists_special_preset_dir = File.exist?(special_preset_dir) templates = [ [NovelSetting::INI_NAME, NovelSetting::INI_ERB_BINARY_VERSION], ["converter.rb", 1.0], [NovelSetting::REPLACE_NAME, 1.0] ] templates.each do |(filename, binary_version)| if exists_special_preset_dir preset_file_path = File.join(special_preset_dir, filename) if File.exist?(preset_file_path) unless File.exist?(File.join(novel_dir_path, filename)) FileUtils.cp(preset_file_path, novel_dir_path) end next end end Template.write(filename, novel_dir_path, binding, binary_version) end end end