# Parse stories from other websites and uploaded files, looking for metadata to harvest # and put into the archive. # class StoryParser require 'timeout' require 'nokogiri' require 'mechanize' require 'open-uri' include HtmlCleaner OPTIONAL_META = {notes: 'Note', freeform_string: 'Tag', fandom_string: 'Fandom', rating_string: 'Rating', archive_warning_string: 'Warning', relationship_string: 'Relationship|Pairing', character_string: 'Character' }.freeze REQUIRED_META = { title: 'Title', summary: 'Summary', revised_at: 'Date|Posted|Posted on|Posted at', chapter_title: 'Chapter Title' }.freeze # Use this for raising custom error messages # (so that we can distinguish them from unexpected exceptions due to # faulty code) class Error < StandardError end # These attributes need to be moved from the work to the chapter # format: {work_attribute_name: :chapter_attribute_name} (can be the same) CHAPTER_ATTRIBUTES_ONLY = {} # These attributes need to be copied from the work to the chapter CHAPTER_ATTRIBUTES_ALSO = { revised_at: :published_at }.freeze ### NOTE ON KNOWN SOURCES # These lists will stop with the first one it matches, so put more-specific matches # towards the front of the list. # places for which we have a custom parse_story_from_[source] method # for getting information out of the downloaded text KNOWN_STORY_PARSERS = %w[ao3 deviantart dw lj].freeze # places for which we have a custom parse_author_from_[source] method # which returns an external_author object including an email address KNOWN_AUTHOR_PARSERS = %w[lj].freeze # places for which we have a download_story_from_[source] # used to customize the downloading process KNOWN_STORY_LOCATIONS = %w[lj].freeze # places for which we have a download_chaptered_from # to get a set of chapters all together CHAPTERED_STORY_LOCATIONS = %w[ffnet thearchive_net efiction quotev].freeze # regular expressions to match against the URLS SOURCE_AO3 = '(archiveofourown\.org|ao3\.org)'.freeze SOURCE_LJ = '((live|dead|insane)journal\.com)|journalfen(\.net|\.com)|dreamwidth\.org'.freeze SOURCE_DW = 'dreamwidth\.org'.freeze SOURCE_FFNET = '(^|[^A-Za-z0-9-])fanfiction\.net'.freeze SOURCE_DEVIANTART = 'deviantart\.com'.freeze SOURCE_THEARCHIVE_NET = 'the\-archive\.net'.freeze SOURCE_EFICTION = 'viewstory\.php'.freeze SOURCE_QUOTEV = 'quotev\.com'.freeze # time out if we can't download fast enough STORY_DOWNLOAD_TIMEOUT = 60 MAX_CHAPTER_COUNT = 200 # To check for duplicate chapters, take a slice this long out of the story # (in characters) DUPLICATE_CHAPTER_LENGTH = 10_000 # Import many stories def import_many(urls, options = {}) # Try to get the works works = [] failed_urls = [] errors = [] @options = options urls.each do |url| begin response = download_and_parse_work(url, options) work = response[:work] if response[:status] == :created if work && work.save work.chapters.each(&:save) works << work else failed_urls << url errors << work.errors.values.join(", ") work.delete if work end elsif response[:status] == :already_imported raise StoryParser::Error, response[:message] end rescue Timeout::Error failed_urls << url errors << "Import has timed out. This may be due to connectivity problems with the source site. Please try again in a few minutes, or check Known Issues to see if there are import problems with this site." work.delete if work rescue Error => exception failed_urls << url errors << "We couldn't successfully import that work, sorry: #{exception.message}" work.delete if work end end [works, failed_urls, errors] end # Downloads a story and passes it on to the parser. # If the URL of the story is from a site for which we have special rules # (eg, downloading from a livejournal clone, you want to use ?format=light # to get a nice and consistent post format), it will pre-process the url # according to the rules for that site. def download_and_parse_work(location, options = {}) status = :created message = "" work = Work.find_by_url(location) if work.nil? @options = options source = get_source_if_known(CHAPTERED_STORY_LOCATIONS, location) if source.nil? story = download_text(location) work = parse_story(story, location, options) else work = download_and_parse_chaptered_story(source, location, options) end else status = :already_imported message = "A work has already been imported from #{location}." end { status: status, message: message, work: work } end # Given an array of urls for chapters of a single story, # download them all and combine into a single work def import_chapters_into_story(locations, options = {}) status = :created work = Work.find_by_url(locations.first) if work.nil? chapter_contents = [] @options = options locations.each do |location| chapter_contents << download_text(location) end work = parse_chapters_into_story(locations.first, chapter_contents, options) message = "Successfully created work \"" + work.title + "\"." else status = :already_imported message = "A work has already been imported from #{locations.first}." end { status: status, message: message, work: work } end ### OLD PARSING METHODS # Import many stories def import_from_urls(urls, options = {}) # Try to get the works works = [] failed_urls = [] errors = [] @options = options urls.each do |url| begin work = download_and_parse_story(url, options) if work && work.save work.chapters.each(&:save) works << work else failed_urls << url errors << work.errors.values.join(", ") work.delete if work end rescue Timeout::Error failed_urls << url errors << "Import has timed out. This may be due to connectivity problems with the source site. Please try again in a few minutes, or check Known Issues to see if there are import problems with this site." work.delete if work rescue Error => exception failed_urls << url errors << "We couldn't successfully import that work, sorry: #{exception.message}" work.delete if work end end [works, failed_urls, errors] end # Downloads a story and passes it on to the parser. # If the URL of the story is from a site for which we have special rules # (eg, downloading from a livejournal clone, you want to use ?format=light # to get a nice and consistent post format), it will pre-process the url # according to the rules for that site. def download_and_parse_story(location, options = {}) check_for_previous_import(location) @options = options source = get_source_if_known(CHAPTERED_STORY_LOCATIONS, location) if source.nil? story = download_text(location) work = parse_story(story, location, options) else work = download_and_parse_chaptered_story(source, location, options) end work end # Given an array of urls for chapters of a single story, # download them all and combine into a single work def download_and_parse_chapters_into_story(locations, options = {}) check_for_previous_import(locations.first) chapter_contents = [] @options = options locations.each do |location| chapter_contents << download_text(location) end parse_chapters_into_story(locations.first, chapter_contents, options) end ### PARSING METHODS # Parses the text of a story, optionally from a given location. def parse_story(story, location, options = {}) work_params = parse_common(story, location, options[:encoding], options[:detect_tags]) # move any attributes from work to chapter if necessary set_work_attributes(Work.new(work_params), location, options) end # parses and adds a new chapter to the end of the work def parse_chapter_of_work(work, chapter_content, location, options = {}) tmp_work_params = parse_common(chapter_content, location, options[:encoding], options[:detect_tags]) chapter = get_chapter_from_work_params(tmp_work_params) work.chapters << set_chapter_attributes(work, chapter) work end def parse_chapters_into_story(location, chapter_contents, options = {}) work = nil chapter_contents.each do |content| work_params = parse_common(content, location, options[:encoding], options[:detect_tags]) if work.nil? # create the new work work = Work.new(work_params) else new_chapter = get_chapter_from_work_params(work_params) work.chapters << set_chapter_attributes(work, new_chapter) end end set_work_attributes(work, location, options) end # Everything below here is protected and should not be touched by outside # code -- please use the above functions to parse external works. protected # tries to create an external author for a given url def parse_author(location, ext_author_name, ext_author_email) if location.present? && ext_author_name.blank? && ext_author_email.blank? source = get_source_if_known(KNOWN_AUTHOR_PARSERS, location) if source.nil? raise Error, "No external author name or email specified" else send("parse_author_from_#{source.downcase}", location) end else parse_author_common(ext_author_email, ext_author_name) end end # download an entire story from an archive type where we know how to parse multi-chaptered works # this should only be called from download_and_parse_story def download_and_parse_chaptered_story(source, location, options = {}) chapter_contents = send("download_chaptered_from_#{source.downcase}", location) parse_chapters_into_story(location, chapter_contents, options) end # our custom url finder checks for previously imported URL in almost any format it may have been presented def check_for_previous_import(location) if Work.find_by_url(location).present? raise Error, "A work has already been imported from #{location}." end end def set_chapter_attributes(work, chapter) chapter.position = work.chapters.length + 1 chapter.posted = true chapter end def set_work_attributes(work, location = "", options = {}) raise Error, "Work could not be downloaded" if work.nil? @options = options work.imported_from_url = location work.ip_address = options[:ip_address] work.expected_number_of_chapters = work.chapters.length work.revised_at = work.chapters.last.published_at if work.revised_at && work.revised_at.to_date < Date.current work.backdate = true end # set authors for the works pseuds = [] pseuds << User.current_user.default_pseud unless options[:do_not_set_current_author] || User.current_user.nil? pseuds << options[:archivist].default_pseud if options[:archivist] pseuds << options[:pseuds] if options[:pseuds] pseuds = pseuds.flatten.compact.uniq raise Error, "A work must have at least one author specified" if pseuds.empty? pseuds.each do |pseud| work.creatorships.build(pseud: pseud, enable_notifications: true) work.chapters.each do |chapter| chapter.creatorships.build(pseud: pseud) end end # handle importing works for others # build an external creatorship for each author if options[:importing_for_others] external_author_names = options[:external_author_names] || parse_author(location, options[:external_author_name], options[:external_author_email]) # convert to an array if not already one external_author_names = [external_author_names] if external_author_names.is_a?(ExternalAuthorName) if options[:external_coauthor_name].present? external_author_names << parse_author(location, options[:external_coauthor_name], options[:external_coauthor_email]) end external_author_names.each do |external_author_name| next if !external_author_name || external_author_name.external_author.blank? if external_author_name.external_author.do_not_import # we're not allowed to import works from this address raise Error, "Author #{external_author_name.name} at #{external_author_name.external_author.email} does not allow importing their work to this archive." end work.external_creatorships.build(external_author_name: external_author_name, archivist: (options[:archivist] || User.current_user)) end end # lock to registered users if specified or importing for others work.restricted = options[:restricted] || options[:importing_for_others] || false # set comment permissions work.comment_permissions = options[:comment_permissions] || "enable_all" work.moderated_commenting_enabled = options[:moderated_commenting_enabled] || false # set default values for required tags work.fandom_string = meta_or_default(work.fandom_string, options[:fandom], ArchiveConfig.FANDOM_NO_TAG_NAME) work.rating_string = meta_or_default(work.rating_string, options[:rating], ArchiveConfig.RATING_DEFAULT_TAG_NAME) work.archive_warning_strings = meta_or_default(work.archive_warning_strings, options[:archive_warning], ArchiveConfig.WARNING_DEFAULT_TAG_NAME) work.category_string = meta_or_default(work.category_string, options[:category], []) work.character_string = meta_or_default(work.character_string, options[:character], []) work.relationship_string = meta_or_default(work.relationship_string, options[:relationship], []) work.freeform_string = meta_or_default(work.freeform_string, options[:freeform], []) # set default value for title work.title = meta_or_default(work.title, options[:title], "Untitled Imported Work") work.summary = meta_or_default(work.summary, options[:summary], '') work.notes = meta_or_default(work.notes, options[:notes], '') # set collection name if present work.collection_names = get_collection_names(options[:collection_names]) if options[:collection_names].present? # set default language (English) work.language_id = options[:language_id] || Language.default.id work.posted = true if options[:post_without_preview] work.chapters.each do |chapter| if chapter.content.length > ArchiveConfig.CONTENT_MAX # TODO: eventually: insert a new chapter chapter.content.truncate(ArchiveConfig.CONTENT_MAX, omission: "WARNING: import truncated automatically because chapter was too long! Please add a new chapter for remaining content.", separator: "
") elsif chapter.content.empty? raise Error, "Chapter #{chapter.position} of \"#{work.title}\" is blank." end chapter.posted = true # do not save - causes the chapters to exist even if work doesn't get created! end work end def parse_author_from_lj(location) return if location !~ %r{^(?:http:\/\/)?(?Contact:
', "" contact.gsub! /<\/?(span|i)>/, "" contact.delete! "\n" contact.gsub! "