brennan revised this gist 1 month ago. Go to revision
1 file changed, 1054 insertions
story_parser.rb(file created)
| @@ -0,0 +1,1054 @@ | |||
| 1 | + | # Parse stories from other websites and uploaded files, looking for metadata to harvest | |
| 2 | + | # and put into the archive. | |
| 3 | + | # | |
| 4 | + | class StoryParser | |
| 5 | + | require 'timeout' | |
| 6 | + | require 'nokogiri' | |
| 7 | + | require 'mechanize' | |
| 8 | + | require 'open-uri' | |
| 9 | + | include HtmlCleaner | |
| 10 | + | ||
| 11 | + | OPTIONAL_META = {notes: 'Note', | |
| 12 | + | freeform_string: 'Tag', | |
| 13 | + | fandom_string: 'Fandom', | |
| 14 | + | rating_string: 'Rating', | |
| 15 | + | archive_warning_string: 'Warning', | |
| 16 | + | relationship_string: 'Relationship|Pairing', | |
| 17 | + | character_string: 'Character' }.freeze | |
| 18 | + | REQUIRED_META = { title: 'Title', | |
| 19 | + | summary: 'Summary', | |
| 20 | + | revised_at: 'Date|Posted|Posted on|Posted at', | |
| 21 | + | chapter_title: 'Chapter Title' }.freeze | |
| 22 | + | ||
| 23 | + | # Use this for raising custom error messages | |
| 24 | + | # (so that we can distinguish them from unexpected exceptions due to | |
| 25 | + | # faulty code) | |
| 26 | + | class Error < StandardError | |
| 27 | + | end | |
| 28 | + | ||
| 29 | + | # These attributes need to be moved from the work to the chapter | |
| 30 | + | # format: {work_attribute_name: :chapter_attribute_name} (can be the same) | |
| 31 | + | CHAPTER_ATTRIBUTES_ONLY = {} | |
| 32 | + | ||
| 33 | + | # These attributes need to be copied from the work to the chapter | |
| 34 | + | CHAPTER_ATTRIBUTES_ALSO = { revised_at: :published_at }.freeze | |
| 35 | + | ||
| 36 | + | ### NOTE ON KNOWN SOURCES | |
| 37 | + | # These lists will stop with the first one it matches, so put more-specific matches | |
| 38 | + | # towards the front of the list. | |
| 39 | + | ||
| 40 | + | # places for which we have a custom parse_story_from_[source] method | |
| 41 | + | # for getting information out of the downloaded text | |
| 42 | + | KNOWN_STORY_PARSERS = %w[ao3 deviantart dw lj].freeze | |
| 43 | + | ||
| 44 | + | # places for which we have a custom parse_author_from_[source] method | |
| 45 | + | # which returns an external_author object including an email address | |
| 46 | + | KNOWN_AUTHOR_PARSERS = %w[lj].freeze | |
| 47 | + | ||
| 48 | + | # places for which we have a download_story_from_[source] | |
| 49 | + | # used to customize the downloading process | |
| 50 | + | KNOWN_STORY_LOCATIONS = %w[lj].freeze | |
| 51 | + | ||
| 52 | + | # places for which we have a download_chaptered_from | |
| 53 | + | # to get a set of chapters all together | |
| 54 | + | CHAPTERED_STORY_LOCATIONS = %w[ffnet thearchive_net efiction quotev].freeze | |
| 55 | + | ||
| 56 | + | # regular expressions to match against the URLS | |
| 57 | + | SOURCE_AO3 = '(archiveofourown\.org|ao3\.org|superlove\.sayitditto\.net|sunset\.femslash\.club)'.freeze | |
| 58 | + | SOURCE_LJ = '((live|dead|insane)journal\.com)|journalfen(\.net|\.com)|dreamwidth\.org'.freeze | |
| 59 | + | SOURCE_DW = 'dreamwidth\.org'.freeze | |
| 60 | + | SOURCE_FFNET = '(^|[^A-Za-z0-9-])fanfiction\.net'.freeze | |
| 61 | + | SOURCE_DEVIANTART = 'deviantart\.com'.freeze | |
| 62 | + | SOURCE_THEARCHIVE_NET = 'the\-archive\.net'.freeze | |
| 63 | + | SOURCE_EFICTION = 'viewstory\.php'.freeze | |
| 64 | + | SOURCE_QUOTEV = 'quotev\.com'.freeze | |
| 65 | + | ||
| 66 | + | # time out if we can't download fast enough | |
| 67 | + | STORY_DOWNLOAD_TIMEOUT = 60 | |
| 68 | + | MAX_CHAPTER_COUNT = 200 | |
| 69 | + | ||
| 70 | + | # To check for duplicate chapters, take a slice this long out of the story | |
| 71 | + | # (in characters) | |
| 72 | + | DUPLICATE_CHAPTER_LENGTH = 10_000 | |
| 73 | + | ||
| 74 | + | ||
| 75 | + | # Import many stories | |
| 76 | + | def import_many(urls, options = {}) | |
| 77 | + | # Try to get the works | |
| 78 | + | works = [] | |
| 79 | + | failed_urls = [] | |
| 80 | + | errors = [] | |
| 81 | + | @options = options | |
| 82 | + | urls.each do |url| | |
| 83 | + | begin | |
| 84 | + | response = download_and_parse_work(url, options) | |
| 85 | + | work = response[:work] | |
| 86 | + | if response[:status] == :created | |
| 87 | + | if work && work.save | |
| 88 | + | work.chapters.each(&:save) | |
| 89 | + | works << work | |
| 90 | + | else | |
| 91 | + | failed_urls << url | |
| 92 | + | errors << work.errors.values.join(", ") | |
| 93 | + | work.delete if work | |
| 94 | + | end | |
| 95 | + | elsif response[:status] == :already_imported | |
| 96 | + | raise StoryParser::Error, response[:message] | |
| 97 | + | end | |
| 98 | + | rescue Timeout::Error | |
| 99 | + | failed_urls << url | |
| 100 | + | 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." | |
| 101 | + | work.delete if work | |
| 102 | + | rescue Error => exception | |
| 103 | + | failed_urls << url | |
| 104 | + | errors << "We couldn't successfully import that work, sorry: #{exception.message}" | |
| 105 | + | work.delete if work | |
| 106 | + | end | |
| 107 | + | end | |
| 108 | + | [works, failed_urls, errors] | |
| 109 | + | end | |
| 110 | + | ||
| 111 | + | # Downloads a story and passes it on to the parser. | |
| 112 | + | # If the URL of the story is from a site for which we have special rules | |
| 113 | + | # (eg, downloading from a livejournal clone, you want to use ?format=light | |
| 114 | + | # to get a nice and consistent post format), it will pre-process the url | |
| 115 | + | # according to the rules for that site. | |
| 116 | + | def download_and_parse_work(location, options = {}) | |
| 117 | + | status = :created | |
| 118 | + | message = "" | |
| 119 | + | work = Work.find_by_url(location) | |
| 120 | + | if work.nil? | |
| 121 | + | @options = options | |
| 122 | + | source = get_source_if_known(CHAPTERED_STORY_LOCATIONS, location) | |
| 123 | + | if source.nil? | |
| 124 | + | story = download_text(location) | |
| 125 | + | work = parse_story(story, location, options) | |
| 126 | + | else | |
| 127 | + | work = download_and_parse_chaptered_story(source, location, options) | |
| 128 | + | end | |
| 129 | + | else | |
| 130 | + | status = :already_imported | |
| 131 | + | message = "A work has already been imported from #{location}." | |
| 132 | + | end | |
| 133 | + | { | |
| 134 | + | status: status, | |
| 135 | + | message: message, | |
| 136 | + | work: work | |
| 137 | + | } | |
| 138 | + | end | |
| 139 | + | ||
| 140 | + | # Given an array of urls for chapters of a single story, | |
| 141 | + | # download them all and combine into a single work | |
| 142 | + | def import_chapters_into_story(locations, options = {}) | |
| 143 | + | status = :created | |
| 144 | + | work = Work.find_by_url(locations.first) | |
| 145 | + | if work.nil? | |
| 146 | + | chapter_contents = [] | |
| 147 | + | @options = options | |
| 148 | + | locations.each do |location| | |
| 149 | + | chapter_contents << download_text(location) | |
| 150 | + | end | |
| 151 | + | work = parse_chapters_into_story(locations.first, chapter_contents, options) | |
| 152 | + | message = "Successfully created work \"" + work.title + "\"." | |
| 153 | + | else | |
| 154 | + | status = :already_imported | |
| 155 | + | message = "A work has already been imported from #{locations.first}." | |
| 156 | + | end | |
| 157 | + | { | |
| 158 | + | status: status, | |
| 159 | + | message: message, | |
| 160 | + | work: work | |
| 161 | + | } | |
| 162 | + | end | |
| 163 | + | ||
| 164 | + | ||
| 165 | + | ### OLD PARSING METHODS | |
| 166 | + | ||
| 167 | + | # Import many stories | |
| 168 | + | def import_from_urls(urls, options = {}) | |
| 169 | + | # Try to get the works | |
| 170 | + | works = [] | |
| 171 | + | failed_urls = [] | |
| 172 | + | errors = [] | |
| 173 | + | @options = options | |
| 174 | + | urls.each do |url| | |
| 175 | + | begin | |
| 176 | + | work = download_and_parse_story(url, options) | |
| 177 | + | if work && work.save | |
| 178 | + | work.chapters.each(&:save) | |
| 179 | + | works << work | |
| 180 | + | else | |
| 181 | + | failed_urls << url | |
| 182 | + | errors << work.errors.values.join(", ") | |
| 183 | + | work.delete if work | |
| 184 | + | end | |
| 185 | + | rescue Timeout::Error | |
| 186 | + | failed_urls << url | |
| 187 | + | 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." | |
| 188 | + | work.delete if work | |
| 189 | + | rescue Error => exception | |
| 190 | + | failed_urls << url | |
| 191 | + | errors << "We couldn't successfully import that work, sorry: #{exception.message}" | |
| 192 | + | work.delete if work | |
| 193 | + | end | |
| 194 | + | end | |
| 195 | + | [works, failed_urls, errors] | |
| 196 | + | end | |
| 197 | + | ||
| 198 | + | # Downloads a story and passes it on to the parser. | |
| 199 | + | # If the URL of the story is from a site for which we have special rules | |
| 200 | + | # (eg, downloading from a livejournal clone, you want to use ?format=light | |
| 201 | + | # to get a nice and consistent post format), it will pre-process the url | |
| 202 | + | # according to the rules for that site. | |
| 203 | + | def download_and_parse_story(location, options = {}) | |
| 204 | + | check_for_previous_import(location) | |
| 205 | + | @options = options | |
| 206 | + | source = get_source_if_known(CHAPTERED_STORY_LOCATIONS, location) | |
| 207 | + | if source.nil? | |
| 208 | + | story = download_text(location) | |
| 209 | + | work = parse_story(story, location, options) | |
| 210 | + | else | |
| 211 | + | work = download_and_parse_chaptered_story(source, location, options) | |
| 212 | + | end | |
| 213 | + | work | |
| 214 | + | end | |
| 215 | + | ||
| 216 | + | # Given an array of urls for chapters of a single story, | |
| 217 | + | # download them all and combine into a single work | |
| 218 | + | def download_and_parse_chapters_into_story(locations, options = {}) | |
| 219 | + | check_for_previous_import(locations.first) | |
| 220 | + | chapter_contents = [] | |
| 221 | + | @options = options | |
| 222 | + | locations.each do |location| | |
| 223 | + | chapter_contents << download_text(location) | |
| 224 | + | end | |
| 225 | + | parse_chapters_into_story(locations.first, chapter_contents, options) | |
| 226 | + | end | |
| 227 | + | ||
| 228 | + | ### PARSING METHODS | |
| 229 | + | ||
| 230 | + | # Parses the text of a story, optionally from a given location. | |
| 231 | + | def parse_story(story, location, options = {}) | |
| 232 | + | work_params = parse_common(story, location, options[:encoding], options[:detect_tags]) | |
| 233 | + | ||
| 234 | + | # move any attributes from work to chapter if necessary | |
| 235 | + | set_work_attributes(Work.new(work_params), location, options) | |
| 236 | + | end | |
| 237 | + | ||
| 238 | + | # parses and adds a new chapter to the end of the work | |
| 239 | + | def parse_chapter_of_work(work, chapter_content, location, options = {}) | |
| 240 | + | tmp_work_params = parse_common(chapter_content, location, options[:encoding], options[:detect_tags]) | |
| 241 | + | chapter = get_chapter_from_work_params(tmp_work_params) | |
| 242 | + | work.chapters << set_chapter_attributes(work, chapter) | |
| 243 | + | work | |
| 244 | + | end | |
| 245 | + | ||
| 246 | + | def parse_chapters_into_story(location, chapter_contents, options = {}) | |
| 247 | + | work = nil | |
| 248 | + | chapter_contents.each do |content| | |
| 249 | + | work_params = parse_common(content, location, options[:encoding], options[:detect_tags]) | |
| 250 | + | if work.nil? | |
| 251 | + | # create the new work | |
| 252 | + | work = Work.new(work_params) | |
| 253 | + | else | |
| 254 | + | new_chapter = get_chapter_from_work_params(work_params) | |
| 255 | + | work.chapters << set_chapter_attributes(work, new_chapter) | |
| 256 | + | end | |
| 257 | + | end | |
| 258 | + | set_work_attributes(work, location, options) | |
| 259 | + | end | |
| 260 | + | ||
| 261 | + | # Everything below here is protected and should not be touched by outside | |
| 262 | + | # code -- please use the above functions to parse external works. | |
| 263 | + | ||
| 264 | + | protected | |
| 265 | + | ||
| 266 | + | # tries to create an external author for a given url | |
| 267 | + | def parse_author(location, ext_author_name, ext_author_email) | |
| 268 | + | if location.present? && ext_author_name.blank? && ext_author_email.blank? | |
| 269 | + | source = get_source_if_known(KNOWN_AUTHOR_PARSERS, location) | |
| 270 | + | if source.nil? | |
| 271 | + | raise Error, "No external author name or email specified" | |
| 272 | + | else | |
| 273 | + | send("parse_author_from_#{source.downcase}", location) | |
| 274 | + | end | |
| 275 | + | else | |
| 276 | + | parse_author_common(ext_author_email, ext_author_name) | |
| 277 | + | end | |
| 278 | + | end | |
| 279 | + | ||
| 280 | + | # download an entire story from an archive type where we know how to parse multi-chaptered works | |
| 281 | + | # this should only be called from download_and_parse_story | |
| 282 | + | def download_and_parse_chaptered_story(source, location, options = {}) | |
| 283 | + | chapter_contents = send("download_chaptered_from_#{source.downcase}", location) | |
| 284 | + | parse_chapters_into_story(location, chapter_contents, options) | |
| 285 | + | end | |
| 286 | + | ||
| 287 | + | # our custom url finder checks for previously imported URL in almost any format it may have been presented | |
| 288 | + | def check_for_previous_import(location) | |
| 289 | + | if Work.find_by_url(location).present? | |
| 290 | + | raise Error, "A work has already been imported from #{location}." | |
| 291 | + | end | |
| 292 | + | end | |
| 293 | + | ||
| 294 | + | def set_chapter_attributes(work, chapter) | |
| 295 | + | chapter.position = work.chapters.length + 1 | |
| 296 | + | chapter.posted = true | |
| 297 | + | chapter | |
| 298 | + | end | |
| 299 | + | ||
| 300 | + | def set_work_attributes(work, location = "", options = {}) | |
| 301 | + | raise Error, "Work could not be downloaded" if work.nil? | |
| 302 | + | ||
| 303 | + | @options = options | |
| 304 | + | work.imported_from_url = location # @todo remove this as part of AO3-6979 | |
| 305 | + | work.imported_url = ImportedUrl.new(original: work.imported_from_url) | |
| 306 | + | ||
| 307 | + | work.ip_address = options[:ip_address] | |
| 308 | + | work.expected_number_of_chapters = work.chapters.length | |
| 309 | + | work.revised_at = work.chapters.last.published_at | |
| 310 | + | if work.revised_at && work.revised_at.to_date < Date.current | |
| 311 | + | work.backdate = true | |
| 312 | + | end | |
| 313 | + | ||
| 314 | + | # set authors for the works | |
| 315 | + | pseuds = [] | |
| 316 | + | pseuds << User.current_user.default_pseud unless options[:do_not_set_current_author] || User.current_user.nil? | |
| 317 | + | pseuds << options[:archivist].default_pseud if options[:archivist] | |
| 318 | + | pseuds << options[:pseuds] if options[:pseuds] | |
| 319 | + | pseuds = pseuds.flatten.compact.uniq | |
| 320 | + | raise Error, "A work must have at least one author specified" if pseuds.empty? | |
| 321 | + | pseuds.each do |pseud| | |
| 322 | + | work.creatorships.build(pseud: pseud, enable_notifications: true) | |
| 323 | + | work.chapters.each do |chapter| | |
| 324 | + | chapter.creatorships.build(pseud: pseud) | |
| 325 | + | end | |
| 326 | + | end | |
| 327 | + | ||
| 328 | + | # handle importing works for others | |
| 329 | + | # build an external creatorship for each author | |
| 330 | + | if options[:importing_for_others] | |
| 331 | + | external_author_names = options[:external_author_names] || parse_author(location, options[:external_author_name], options[:external_author_email]) | |
| 332 | + | # convert to an array if not already one | |
| 333 | + | external_author_names = [external_author_names] if external_author_names.is_a?(ExternalAuthorName) | |
| 334 | + | if options[:external_coauthor_name].present? | |
| 335 | + | external_author_names << parse_author(location, options[:external_coauthor_name], options[:external_coauthor_email]) | |
| 336 | + | end | |
| 337 | + | external_author_names.each do |external_author_name| | |
| 338 | + | next if !external_author_name || external_author_name.external_author.blank? | |
| 339 | + | if external_author_name.external_author.do_not_import | |
| 340 | + | # we're not allowed to import works from this address | |
| 341 | + | raise Error, "Author #{external_author_name.name} at #{external_author_name.external_author.email} does not allow importing their work to this archive." | |
| 342 | + | end | |
| 343 | + | work.external_creatorships.build(external_author_name: external_author_name, archivist: (options[:archivist] || User.current_user)) | |
| 344 | + | end | |
| 345 | + | end | |
| 346 | + | ||
| 347 | + | # lock to registered users if specified or importing for others | |
| 348 | + | work.restricted = options[:restricted] || options[:importing_for_others] || false | |
| 349 | + | ||
| 350 | + | # set comment permissions | |
| 351 | + | work.comment_permissions = options[:comment_permissions] || "enable_all" | |
| 352 | + | work.moderated_commenting_enabled = options[:moderated_commenting_enabled] || false | |
| 353 | + | ||
| 354 | + | # set default values for required tags | |
| 355 | + | work.fandom_string = meta_or_default(work.fandom_string, options[:fandom], ArchiveConfig.FANDOM_NO_TAG_NAME) | |
| 356 | + | work.rating_string = meta_or_default(work.rating_string, options[:rating], ArchiveConfig.RATING_DEFAULT_TAG_NAME) | |
| 357 | + | work.archive_warning_strings = meta_or_default(work.archive_warning_strings, options[:archive_warning], ArchiveConfig.WARNING_DEFAULT_TAG_NAME) | |
| 358 | + | work.category_string = meta_or_default(work.category_string, options[:category], []) | |
| 359 | + | work.character_string = meta_or_default(work.character_string, options[:character], []) | |
| 360 | + | work.relationship_string = meta_or_default(work.relationship_string, options[:relationship], []) | |
| 361 | + | work.freeform_string = meta_or_default(work.freeform_string, options[:freeform], []) | |
| 362 | + | ||
| 363 | + | # set default value for title | |
| 364 | + | work.title = meta_or_default(work.title, options[:title], "Untitled Imported Work") | |
| 365 | + | work.summary = meta_or_default(work.summary, options[:summary], '') | |
| 366 | + | work.notes = meta_or_default(work.notes, options[:notes], '') | |
| 367 | + | ||
| 368 | + | # set collection name if present | |
| 369 | + | work.collection_names = get_collection_names(options[:collection_names]) if options[:collection_names].present? | |
| 370 | + | ||
| 371 | + | # set default language (English) | |
| 372 | + | work.language_id = options[:language_id] || Language.default.id | |
| 373 | + | ||
| 374 | + | work.posted = true if options[:post_without_preview] | |
| 375 | + | work.chapters.each do |chapter| | |
| 376 | + | if chapter.content.length > ArchiveConfig.CONTENT_MAX | |
| 377 | + | # TODO: eventually: insert a new chapter | |
| 378 | + | chapter.content.truncate(ArchiveConfig.CONTENT_MAX, omission: "<strong>WARNING: import truncated automatically because chapter was too long! Please add a new chapter for remaining content.</strong>", separator: "</p>") | |
| 379 | + | elsif chapter.content.empty? | |
| 380 | + | raise Error, "Chapter #{chapter.position} of \"#{work.title}\" is blank." | |
| 381 | + | end | |
| 382 | + | ||
| 383 | + | chapter.posted = true # do not save - causes the chapters to exist even if work doesn't get created! | |
| 384 | + | end | |
| 385 | + | work | |
| 386 | + | end | |
| 387 | + | ||
| 388 | + | def parse_author_from_lj(location) | |
| 389 | + | return if location !~ %r{^(?:http:\/\/)?(?<lj_name>[^.]*).(?<site_name>livejournal\.com|dreamwidth\.org|insanejournal\.com|journalfen.net)} | |
| 390 | + | email = "" | |
| 391 | + | lj_name = Regexp.last_match[:lj_name] | |
| 392 | + | site_name = Regexp.last_match[:site_name] | |
| 393 | + | if lj_name == "community" | |
| 394 | + | # whups | |
| 395 | + | post_text = download_text(location) | |
| 396 | + | doc = Nokogiri.parse(post_text) | |
| 397 | + | lj_name = doc.xpath("/html/body/div[2]/div/div/div/table/tbody/tr/td[2]/span/a[2]/b").content | |
| 398 | + | end | |
| 399 | + | profile_url = "http://#{lj_name}.#{site_name}/profile" | |
| 400 | + | lj_profile = download_text(profile_url) | |
| 401 | + | doc = Nokogiri.parse(lj_profile) | |
| 402 | + | contact = doc.css('div.contact').inner_html | |
| 403 | + | if contact.present? | |
| 404 | + | contact.gsub! '<p class="section_body_title">Contact:</p>', "" | |
| 405 | + | contact.gsub! /<\/?(span|i)>/, "" | |
| 406 | + | contact.delete! "\n" | |
| 407 | + | contact.gsub! "<br/>", "" | |
| 408 | + | if contact =~ /(.*@.*\..*)/ | |
| 409 | + | email = Regexp.last_match[1] | |
| 410 | + | end | |
| 411 | + | end | |
| 412 | + | email = "#{lj_name}@#{site_name}" if email.blank? | |
| 413 | + | parse_author_common(email, lj_name) | |
| 414 | + | end | |
| 415 | + | ||
| 416 | + | def parse_author_from_unknown(_location) | |
| 417 | + | # for now, nothing | |
| 418 | + | nil | |
| 419 | + | end | |
| 420 | + | ||
| 421 | + | def parse_author_common(email, name) | |
| 422 | + | errors = [] | |
| 423 | + | ||
| 424 | + | errors << "No author name specified" if name.blank? | |
| 425 | + | ||
| 426 | + | if email.present? | |
| 427 | + | external_author = ExternalAuthor.find_or_create_by(email: email) | |
| 428 | + | errors += external_author.errors.full_messages | |
| 429 | + | else | |
| 430 | + | errors << "No author email specified" | |
| 431 | + | end | |
| 432 | + | ||
| 433 | + | raise Error, errors.join("\n") if errors.present? | |
| 434 | + | ||
| 435 | + | # convert to ASCII and strip out invalid characters (everything except alphanumeric characters, _, @ and -) | |
| 436 | + | redacted_name = name.to_ascii.gsub(/[^\w[ \-@.]]/u, "") | |
| 437 | + | if redacted_name.present? | |
| 438 | + | external_author.names.find_or_create_by(name: redacted_name) | |
| 439 | + | else | |
| 440 | + | external_author.default_name | |
| 441 | + | end | |
| 442 | + | end | |
| 443 | + | ||
| 444 | + | def get_chapter_from_work_params(work_params) | |
| 445 | + | @chapter = Chapter.new(work_params[:chapter_attributes]) | |
| 446 | + | # don't override specific chapter params (eg title) with work params | |
| 447 | + | chapter_params = work_params.delete_if do |name, _param| | |
| 448 | + | !@chapter.attribute_names.include?(name.to_s) || !@chapter.send(name.to_s).blank? | |
| 449 | + | end | |
| 450 | + | @chapter.update(chapter_params) | |
| 451 | + | @chapter | |
| 452 | + | end | |
| 453 | + | ||
| 454 | + | def download_text(location) | |
| 455 | + | source = get_source_if_known(KNOWN_STORY_LOCATIONS, location) | |
| 456 | + | if source.nil? | |
| 457 | + | download_with_timeout(location) | |
| 458 | + | else | |
| 459 | + | send("download_from_#{source.downcase}", location) | |
| 460 | + | end | |
| 461 | + | end | |
| 462 | + | ||
| 463 | + | # canonicalize the url for downloading from lj or clones | |
| 464 | + | def download_from_lj(location) | |
| 465 | + | url = location | |
| 466 | + | url.gsub!(/\#(.*)$/, "") # strip off any anchor information | |
| 467 | + | url.gsub!(/\?(.*)$/, "") # strip off any existing params at the end | |
| 468 | + | url.gsub!('_', '-') # convert underscores in usernames to hyphens | |
| 469 | + | url += "?format=light" # go to light format | |
| 470 | + | text = download_with_timeout(url) | |
| 471 | + | ||
| 472 | + | if text.match(/adult_check/) | |
| 473 | + | Timeout::timeout(STORY_DOWNLOAD_TIMEOUT) { | |
| 474 | + | begin | |
| 475 | + | agent = Mechanize.new | |
| 476 | + | url.include?("dreamwidth") ? form = agent.get(url).forms.first : form = agent.get(url).forms.third | |
| 477 | + | page = agent.submit(form, form.buttons.first) # submits the adult concepts form | |
| 478 | + | text = page.body.force_encoding(agent.page.encoding) | |
| 479 | + | rescue | |
| 480 | + | text = "" | |
| 481 | + | end | |
| 482 | + | } | |
| 483 | + | end | |
| 484 | + | text | |
| 485 | + | end | |
| 486 | + | ||
| 487 | + | # grab all the chapters of the story from ff.net | |
| 488 | + | def download_chaptered_from_ffnet(_location) | |
| 489 | + | raise Error, "Sorry, Fanfiction.net does not allow imports from their site." | |
| 490 | + | end | |
| 491 | + | ||
| 492 | + | def download_chaptered_from_quotev(_location) | |
| 493 | + | raise Error, "Sorry, Quotev.com does not allow imports from their site." | |
| 494 | + | end | |
| 495 | + | ||
| 496 | + | # this is an efiction archive but it doesn't handle chapters normally | |
| 497 | + | # best way to handle is to get the full story printable version | |
| 498 | + | # We have to make it a download-chaptered because otherwise it gets sent to the | |
| 499 | + | # generic efiction version since chaptered sources are checked first | |
| 500 | + | def download_chaptered_from_thearchive_net(location) | |
| 501 | + | if location.match(/^(.*)\/.*viewstory\.php.*[^p]sid=(\d+)($|&)/i) | |
| 502 | + | location = "#{$1}/viewstory.php?action=printable&psid=#{$2}" | |
| 503 | + | end | |
| 504 | + | text = download_with_timeout(location) | |
| 505 | + | text.sub!('</style>', '</style></head>') unless text.match('</head>') | |
| 506 | + | [text] | |
| 507 | + | end | |
| 508 | + | ||
| 509 | + | # grab all the chapters of a story from an efiction-based site | |
| 510 | + | def download_chaptered_from_efiction(location) | |
| 511 | + | chapter_contents = [] | |
| 512 | + | if location.match(/^(?<site>.*)\/.*viewstory\.php.*sid=(?<storyid>\d+)($|&)/i) | |
| 513 | + | site = Regexp.last_match[:site] | |
| 514 | + | storyid = Regexp.last_match[:storyid] | |
| 515 | + | chapnum = 1 | |
| 516 | + | last_body = "" | |
| 517 | + | Timeout::timeout(STORY_DOWNLOAD_TIMEOUT) do | |
| 518 | + | loop do | |
| 519 | + | url = "#{site}/viewstory.php?action=printable&sid=#{storyid}&chapter=#{chapnum}" | |
| 520 | + | body = download_with_timeout(url) | |
| 521 | + | # get a section to check that this isn't a duplicate of previous chapter | |
| 522 | + | body_to_check = body.slice(10, DUPLICATE_CHAPTER_LENGTH) | |
| 523 | + | if body.nil? || body_to_check == last_body || chapnum > MAX_CHAPTER_COUNT || body.match(/<div class='chaptertitle'> by <\/div>/) || body.match(/Access denied./) || body.match(/Chapter : /) | |
| 524 | + | break | |
| 525 | + | end | |
| 526 | + | # save the value to check for duplicate chapter | |
| 527 | + | last_body = body_to_check | |
| 528 | + | ||
| 529 | + | # clean up the broken head in many efiction printable sites | |
| 530 | + | body.sub!('</style>', '</style></head>') unless body.match('</head>') | |
| 531 | + | chapter_contents << body | |
| 532 | + | chapnum += 1 | |
| 533 | + | end | |
| 534 | + | end | |
| 535 | + | end | |
| 536 | + | chapter_contents | |
| 537 | + | end | |
| 538 | + | ||
| 539 | + | ||
| 540 | + | # This is the heavy lifter, invoked by all the story and chapter parsers. | |
| 541 | + | # It takes a single string containing the raw contents of a story, parses it with | |
| 542 | + | # Nokogiri into the @doc object, and then and calls a subparser. | |
| 543 | + | # | |
| 544 | + | # If the story source can be identified as one of the sources we know how to parse in some custom/ | |
| 545 | + | # special way, parse_common calls the customized parse_story_from_[source] method. | |
| 546 | + | # Otherwise, it falls back to parse_story_from_unknown. | |
| 547 | + | # | |
| 548 | + | # This produces a hash equivalent to the params hash that is normally created by the standard work | |
| 549 | + | # upload form. | |
| 550 | + | # | |
| 551 | + | # parse_common then calls sanitize_params (which would also be called on the standard work upload | |
| 552 | + | # form results) and returns the final sanitized hash. | |
| 553 | + | # | |
| 554 | + | def parse_common(story, location = nil, encoding = nil, detect_tags = true) | |
| 555 | + | work_params = { title: "Untitled Imported Work", chapter_attributes: { content: "" } } | |
| 556 | + | ||
| 557 | + | # Encode as HTML - the dummy "foo" tag will be stripped out by the sanitizer but forces Nokogiri to | |
| 558 | + | # preserve line breaks in plain text documents | |
| 559 | + | # Rescue all errors as Nokogiri complains about things the sanitizer will fix later | |
| 560 | + | story.prepend("<foo></foo>") | |
| 561 | + | @doc = | |
| 562 | + | begin | |
| 563 | + | Nokogiri::HTML5.parse(story, encoding: encoding) | |
| 564 | + | rescue StandardError | |
| 565 | + | Nokogiri::HTML5.parse("") | |
| 566 | + | end | |
| 567 | + | ||
| 568 | + | # Try to convert all relative links to absolute | |
| 569 | + | base = @doc.at_css("base") ? @doc.css("base")[0]["href"] : location.split("?").first | |
| 570 | + | if base.present? | |
| 571 | + | @doc.css("a").each do |link| | |
| 572 | + | next if link["href"].blank? || link["href"].start_with?("#") | |
| 573 | + | begin | |
| 574 | + | query = link["href"].match(/(\?.*)$/) ? $1 : "" | |
| 575 | + | link["href"] = URI.join(base, link["href"].gsub(/(\?.*)$/, "")).to_s + query | |
| 576 | + | rescue | |
| 577 | + | # ignored | |
| 578 | + | end | |
| 579 | + | end | |
| 580 | + | end | |
| 581 | + | ||
| 582 | + | # Extract metadata (unless detect_tags is false) | |
| 583 | + | if location && (source = get_source_if_known(KNOWN_STORY_PARSERS, location)) | |
| 584 | + | params = send("parse_story_from_#{source.downcase}", story, detect_tags) | |
| 585 | + | work_params.merge!(params) | |
| 586 | + | else | |
| 587 | + | work_params.merge!(parse_story_from_unknown(story, detect_tags)) | |
| 588 | + | end | |
| 589 | + | ||
| 590 | + | shift_chapter_attributes(sanitize_params(work_params)) | |
| 591 | + | end | |
| 592 | + | ||
| 593 | + | # our fallback: parse a story from an unknown source, so we have no special | |
| 594 | + | # rules. | |
| 595 | + | def parse_story_from_unknown(story, detect_tags = true) | |
| 596 | + | work_params = { chapter_attributes: {} } | |
| 597 | + | story_head = "" | |
| 598 | + | story_head = @doc.css("head").inner_html if @doc.css("head") | |
| 599 | + | ||
| 600 | + | # Story content - Look for progressively less specific containers or grab everything | |
| 601 | + | element = @doc.at_css('.chapter-content') || @doc.at_css('body') || @doc.at_css('html') || @doc | |
| 602 | + | storytext = element ? element.inner_html : story | |
| 603 | + | ||
| 604 | + | meta = {} | |
| 605 | + | meta.merge!(scan_text_for_meta(story_head, detect_tags)) unless story_head.blank? | |
| 606 | + | meta.merge!(scan_text_for_meta(story, detect_tags)) | |
| 607 | + | meta[:title] ||= @doc.css('title').inner_html | |
| 608 | + | work_params[:chapter_attributes][:title] = meta.delete(:chapter_title) | |
| 609 | + | work_params[:chapter_attributes][:content] = clean_storytext(storytext) | |
| 610 | + | work_params.merge!(meta) | |
| 611 | + | end | |
| 612 | + | ||
| 613 | + | # Parses a story from livejournal or a livejournal equivalent (eg, dreamwidth, insanejournal) | |
| 614 | + | # Assumes that we have downloaded the story from one of those equivalents (ie, we've downloaded | |
| 615 | + | # it in format=light which is a stripped-down plaintext version.) | |
| 616 | + | # | |
| 617 | + | def parse_story_from_lj(_story, detect_tags = true) | |
| 618 | + | work_params = { chapter_attributes: {} } | |
| 619 | + | ||
| 620 | + | # in LJ "light" format, the story contents are in the second div | |
| 621 | + | # inside the body. | |
| 622 | + | body = @doc.css("body") | |
| 623 | + | storytext = body.css("article.b-singlepost-body").inner_html | |
| 624 | + | storytext = body.css("div.aentry-post__text").inner_html if storytext.empty? | |
| 625 | + | storytext = body.inner_html if storytext.empty? | |
| 626 | + | ||
| 627 | + | # cleanup the text | |
| 628 | + | # storytext.gsub!(/<br\s*\/?>/i, "\n") # replace the breaks with newlines | |
| 629 | + | storytext = clean_storytext(storytext) | |
| 630 | + | ||
| 631 | + | work_params[:chapter_attributes][:content] = storytext | |
| 632 | + | work_params[:title] = @doc.css("title").inner_html | |
| 633 | + | work_params[:title].gsub! /^[^:]+: /, "" | |
| 634 | + | work_params.merge!(scan_text_for_meta(storytext, detect_tags)) | |
| 635 | + | ||
| 636 | + | date = @doc.css("time.b-singlepost-author-date") | |
| 637 | + | date = @doc.css("p.aentry-head__date/time") if date.empty? | |
| 638 | + | work_params[:revised_at] = convert_revised_at(date.first.inner_text) unless date.empty? | |
| 639 | + | ||
| 640 | + | work_params | |
| 641 | + | end | |
| 642 | + | ||
| 643 | + | def parse_story_from_dw(_story, detect_tags = true) | |
| 644 | + | work_params = { chapter_attributes: {} } | |
| 645 | + | ||
| 646 | + | body = @doc.css("body") | |
| 647 | + | content_divs = body.css("div.contents") | |
| 648 | + | ||
| 649 | + | if content_divs[0].present? | |
| 650 | + | # Get rid of the DW metadata table | |
| 651 | + | content_divs[0].css("div.currents, ul.entry-management-links, div.header.inner, span.restrictions, h3.entry-title").each(&:remove) | |
| 652 | + | storytext = content_divs[0].inner_html | |
| 653 | + | else | |
| 654 | + | storytext = body.inner_html | |
| 655 | + | end | |
| 656 | + | ||
| 657 | + | # cleanup the text | |
| 658 | + | storytext = clean_storytext(storytext) | |
| 659 | + | ||
| 660 | + | work_params[:chapter_attributes][:content] = storytext | |
| 661 | + | work_params[:title] = @doc.css("title").inner_html | |
| 662 | + | work_params[:title].gsub! /^[^:]+: /, "" | |
| 663 | + | work_params.merge!(scan_text_for_meta(storytext, detect_tags)) | |
| 664 | + | ||
| 665 | + | font_blocks = @doc.xpath('//font') | |
| 666 | + | unless font_blocks.empty? | |
| 667 | + | date = font_blocks.first.inner_text | |
| 668 | + | work_params[:revised_at] = convert_revised_at(date) | |
| 669 | + | end | |
| 670 | + | ||
| 671 | + | # get the date | |
| 672 | + | date = @doc.css("span.date").inner_text | |
| 673 | + | work_params[:revised_at] = convert_revised_at(date) | |
| 674 | + | ||
| 675 | + | work_params | |
| 676 | + | end | |
| 677 | + | ||
| 678 | + | def parse_story_from_deviantart(_story, detect_tags = true) | |
| 679 | + | work_params = { chapter_attributes: {} } | |
| 680 | + | storytext = "" | |
| 681 | + | notes = "" | |
| 682 | + | ||
| 683 | + | body = @doc.css("body") | |
| 684 | + | title = @doc.css("title").inner_html.gsub /\s*on deviantart$/i, "" | |
| 685 | + | ||
| 686 | + | # Find the image (original size) if it's art | |
| 687 | + | image_full = body.css("div.dev-view-deviation img.dev-content-full") | |
| 688 | + | unless image_full[0].nil? | |
| 689 | + | storytext = "<center><img src=\"#{image_full[0]["src"]}\"></center>" | |
| 690 | + | end | |
| 691 | + | ||
| 692 | + | # Find the fic text if it's fic (needs the id for disambiguation, the "deviantART loves you" bit in the footer has the same class path) | |
| 693 | + | text_table = body.css(".grf-indent > div:nth-child(1)")[0] | |
| 694 | + | unless text_table.nil? | |
| 695 | + | # Try to remove some metadata (title and author) from the work's text, if possible | |
| 696 | + | # Try to remove the title: if it exists, and if it's the same as the browser title | |
| 697 | + | if text_table.css("h1")[0].present? && title && title.match(text_table.css("h1")[0].text) | |
| 698 | + | text_table.css("h1")[0].remove | |
| 699 | + | end | |
| 700 | + | ||
| 701 | + | # Try to remove the author: if it exists, and if it follows a certain pattern | |
| 702 | + | if text_table.css("small")[0].present? && text_table.css("small")[0].inner_html.match(/by ~.*?<a class="u" href=/m) | |
| 703 | + | text_table.css("small")[0].remove | |
| 704 | + | end | |
| 705 | + | storytext = text_table.inner_html | |
| 706 | + | end | |
| 707 | + | ||
| 708 | + | # cleanup the text | |
| 709 | + | storytext.gsub!(%r{<br\s*\/?>}i, "\n") # replace the breaks with newlines | |
| 710 | + | storytext = clean_storytext(storytext) | |
| 711 | + | work_params[:chapter_attributes][:content] = storytext | |
| 712 | + | ||
| 713 | + | # Find the notes | |
| 714 | + | content_divs = body.css("div.text-ctrl div.text") | |
| 715 | + | notes = content_divs[0].inner_html unless content_divs[0].nil? | |
| 716 | + | ||
| 717 | + | # cleanup the notes | |
| 718 | + | notes.gsub!(%r{<br\s*\/?>}i, "\n") # replace the breaks with newlines | |
| 719 | + | notes = clean_storytext(notes, "notes") | |
| 720 | + | work_params[:notes] = notes | |
| 721 | + | ||
| 722 | + | work_params.merge!(scan_text_for_meta(notes, detect_tags)) | |
| 723 | + | work_params[:title] = title | |
| 724 | + | ||
| 725 | + | body.css("div.dev-title-container h1 a").each do |node| | |
| 726 | + | if node["class"] != "u" | |
| 727 | + | work_params[:title] = node.inner_html | |
| 728 | + | end | |
| 729 | + | end | |
| 730 | + | ||
| 731 | + | tags = [] | |
| 732 | + | @doc.css("div.dev-about-cat-cc a.h").each { |node| tags << node.inner_html } | |
| 733 | + | work_params[:freeform_string] = clean_tags(tags.join(ArchiveConfig.DELIMITER_FOR_OUTPUT)) | |
| 734 | + | ||
| 735 | + | details = @doc.css("div.dev-right-bar-content span[title]") | |
| 736 | + | unless details[0].nil? | |
| 737 | + | work_params[:revised_at] = convert_revised_at(details[0].inner_text) | |
| 738 | + | end | |
| 739 | + | ||
| 740 | + | work_params | |
| 741 | + | end | |
| 742 | + | ||
| 743 | + | def parse_story_from_ao3(_story, detect_tags = true) | |
| 744 | + | work_params = { chapter_attributes: {} } | |
| 745 | + | ||
| 746 | + | # Title: use the work heading, not the browser tab title | |
| 747 | + | title_node = @doc.at_css('h2.title.heading') | |
| 748 | + | work_params[:title] = if title_node | |
| 749 | + | title_node.inner_text.strip | |
| 750 | + | else | |
| 751 | + | @doc.at_css('title')&.inner_text&.sub(/\s*\[Archive of Our Own\]\s*$/i, '')&.strip.to_s | |
| 752 | + | end | |
| 753 | + | ||
| 754 | + | # Summary | |
| 755 | + | summary_node = @doc.at_css('.summary.module blockquote.userstuff') | |
| 756 | + | work_params[:summary] = clean_storytext(summary_node.inner_html) if summary_node | |
| 757 | + | ||
| 758 | + | # Author beginning notes (inside .preface.group, before chapter content) | |
| 759 | + | preface = @doc.at_css('.preface.group') | |
| 760 | + | if preface | |
| 761 | + | notes_node = preface.at_css('.notes.module blockquote.userstuff') | |
| 762 | + | work_params[:notes] = clean_storytext(notes_node.inner_html) if notes_node | |
| 763 | + | end | |
| 764 | + | ||
| 765 | + | # Story text: extract only from #chapters .userstuff, not the whole page body | |
| 766 | + | chapters_div = @doc.at_css('#chapters') | |
| 767 | + | if chapters_div | |
| 768 | + | userstuff = chapters_div.at_css('.userstuff') | |
| 769 | + | storytext = userstuff ? userstuff.inner_html : chapters_div.inner_html | |
| 770 | + | else | |
| 771 | + | storytext = @doc.at_css('body')&.inner_html || _story | |
| 772 | + | end | |
| 773 | + | work_params[:chapter_attributes][:content] = clean_storytext(storytext) | |
| 774 | + | ||
| 775 | + | if detect_tags | |
| 776 | + | meta_group = @doc.at_css('dl.work.meta.group') | |
| 777 | + | if meta_group | |
| 778 | + | rating = meta_group.css('dd.rating.tags li a.tag').map { |a| a.inner_text.strip } | |
| 779 | + | work_params[:rating_string] = convert_rating_string(rating.first) if rating.any? | |
| 780 | + | ||
| 781 | + | warnings = meta_group.css('dd.warning.tags li a.tag').map { |a| a.inner_text.strip } | |
| 782 | + | work_params[:archive_warning_string] = warnings.join(', ') if warnings.any? | |
| 783 | + | ||
| 784 | + | fandoms = meta_group.css('dd.fandom.tags li a.tag').map { |a| a.inner_text.strip } | |
| 785 | + | work_params[:fandom_string] = clean_tags(fandoms.join(ArchiveConfig.DELIMITER_FOR_OUTPUT)) if fandoms.any? | |
| 786 | + | ||
| 787 | + | relationships = meta_group.css('dd.relationship.tags li a.tag').map { |a| a.inner_text.strip } | |
| 788 | + | work_params[:relationship_string] = clean_tags(relationships.join(ArchiveConfig.DELIMITER_FOR_OUTPUT)) if relationships.any? | |
| 789 | + | ||
| 790 | + | characters = meta_group.css('dd.character.tags li a.tag').map { |a| a.inner_text.strip } | |
| 791 | + | work_params[:character_string] = clean_tags(characters.join(ArchiveConfig.DELIMITER_FOR_OUTPUT)) if characters.any? | |
| 792 | + | ||
| 793 | + | freeforms = meta_group.css('dd.freeform.tags li a.tag').map { |a| a.inner_text.strip } | |
| 794 | + | work_params[:freeform_string] = clean_tags(freeforms.join(ArchiveConfig.DELIMITER_FOR_OUTPUT)) if freeforms.any? | |
| 795 | + | ||
| 796 | + | published = meta_group.at_css('dd.published') | |
| 797 | + | work_params[:revised_at] = convert_revised_at(published.inner_text.strip) if published | |
| 798 | + | end | |
| 799 | + | end | |
| 800 | + | ||
| 801 | + | post_process_meta(work_params) | |
| 802 | + | end | |
| 803 | + | ||
| 804 | + | # Move and/or copy any meta attributes that need to be on the chapter rather | |
| 805 | + | # than on the work itself | |
| 806 | + | def shift_chapter_attributes(work_params) | |
| 807 | + | CHAPTER_ATTRIBUTES_ONLY.each_pair do |work_attrib, chapter_attrib| | |
| 808 | + | if work_params[work_attrib] && !work_params[:chapter_attributes][chapter_attrib] | |
| 809 | + | work_params[:chapter_attributes][chapter_attrib] = work_params[work_attrib] | |
| 810 | + | work_params.delete(work_attrib) | |
| 811 | + | end | |
| 812 | + | end | |
| 813 | + | ||
| 814 | + | # copy any attributes from work to chapter as necessary | |
| 815 | + | CHAPTER_ATTRIBUTES_ALSO.each_pair do |work_attrib, chapter_attrib| | |
| 816 | + | if work_params[work_attrib] && !work_params[:chapter_attributes][chapter_attrib] | |
| 817 | + | work_params[:chapter_attributes][chapter_attrib] = work_params[work_attrib] | |
| 818 | + | end | |
| 819 | + | end | |
| 820 | + | ||
| 821 | + | work_params | |
| 822 | + | end | |
| 823 | + | ||
| 824 | + | # Find any cases of the given pieces of meta in the given text | |
| 825 | + | # and return a hash of meta values | |
| 826 | + | def scan_text_for_meta(text, detect_tags = true) | |
| 827 | + | # break up the text with some extra newlines to make matching more likely | |
| 828 | + | # and strip out some tags | |
| 829 | + | text = text.gsub(/<br/, "\n<br") | |
| 830 | + | text.gsub!(/<p/, "\n<p") | |
| 831 | + | text.gsub!(/<\/?(label|span|div|b)(.*?)?>/, '') | |
| 832 | + | ||
| 833 | + | meta = {} | |
| 834 | + | metapatterns = detect_tags ? REQUIRED_META.merge(OPTIONAL_META) : REQUIRED_META | |
| 835 | + | is_tag = {}.tap do |h| | |
| 836 | + | %w[fandom_string relationship_string freeform_string rating_string archive_warning_string].each do |c| | |
| 837 | + | h[c.to_sym] = true | |
| 838 | + | end | |
| 839 | + | end | |
| 840 | + | handler = {}.tap do |h| | |
| 841 | + | %w[rating_string revised_at].each do |c| | |
| 842 | + | h[c.to_sym] = "convert_#{c.to_s.downcase}" | |
| 843 | + | end | |
| 844 | + | end | |
| 845 | + | ||
| 846 | + | # 1. Look for Pattern: (whatever), optionally followed by a closing p or div tag | |
| 847 | + | # 2. Set meta[:metaname] = whatever | |
| 848 | + | # eg, if it finds Fandom: Stargate SG-1 it will set meta[:fandom] = Stargate SG-1 | |
| 849 | + | # 3. convert_<metaname> for cleanup if such a function is defined (eg convert_rating_string) | |
| 850 | + | metapatterns.each do |metaname, pattern| | |
| 851 | + | metapattern = Regexp.new("(?:#{pattern}|#{pattern.pluralize})\s*:\s*(.*?)(?:</(?:p|div)>)?$", Regexp::IGNORECASE) | |
| 852 | + | if text.match(metapattern) | |
| 853 | + | value = Regexp.last_match[1] | |
| 854 | + | value = clean_tags(value) if is_tag[metaname] | |
| 855 | + | value = clean_close_html_tags(value) | |
| 856 | + | value.strip! # lose leading/trailing whitespace | |
| 857 | + | value = send(handler[metaname], value) if handler[metaname] | |
| 858 | + | ||
| 859 | + | meta[metaname] = value | |
| 860 | + | end | |
| 861 | + | end | |
| 862 | + | post_process_meta meta | |
| 863 | + | end | |
| 864 | + | ||
| 865 | + | def download_with_timeout(location, limit = 10) | |
| 866 | + | story = +"" | |
| 867 | + | Timeout.timeout(STORY_DOWNLOAD_TIMEOUT) do | |
| 868 | + | begin | |
| 869 | + | # we do a little cleanup here in case the user hasn't included the 'http://' | |
| 870 | + | # or if they've used capital letters or an underscore in the hostname | |
| 871 | + | uri = UrlFormatter.new(location).standardized | |
| 872 | + | raise Error, I18n.t("story_parser.on_archive") if ArchiveConfig.PERMITTED_HOSTS.include?(uri.host) | |
| 873 | + | ||
| 874 | + | env_proxy = ENV["http_proxy"] | |
| 875 | + | http = if env_proxy | |
| 876 | + | proxy = URI(env_proxy) | |
| 877 | + | Net::HTTP.new(uri.hostname, uri.port, proxy.hostname, proxy.port) | |
| 878 | + | else | |
| 879 | + | Net::HTTP.new(uri.hostname, uri.port) | |
| 880 | + | end | |
| 881 | + | http.use_ssl = true if uri.scheme == "https" | |
| 882 | + | response = http.start { |h| h.request_get(uri.path.presence || "/") } | |
| 883 | + | ||
| 884 | + | case response | |
| 885 | + | when Net::HTTPSuccess | |
| 886 | + | story = response.body | |
| 887 | + | when Net::HTTPRedirection | |
| 888 | + | if limit.positive? | |
| 889 | + | new_uri = URI.parse(response["location"]) | |
| 890 | + | new_uri = URI.join(uri, new_uri) if new_uri.relative? | |
| 891 | + | story = download_with_timeout(new_uri.to_s, limit - 1) | |
| 892 | + | end | |
| 893 | + | else | |
| 894 | + | Rails.logger.error("------- STORY PARSER: download_with_timeout: response is not success or redirection ------") | |
| 895 | + | nil | |
| 896 | + | end | |
| 897 | + | rescue Errno::ECONNREFUSED, SocketError, EOFError => e | |
| 898 | + | Rails.logger.error("------- STORY PARSER: download_with_timeout: error rescue: \n#{e.inspect} ------") | |
| 899 | + | nil | |
| 900 | + | end | |
| 901 | + | end | |
| 902 | + | if story.blank? | |
| 903 | + | raise Error, "We couldn't download anything from #{location}. Please make sure that the URL is correct and complete, and try again." | |
| 904 | + | end | |
| 905 | + | ||
| 906 | + | # clean up any erroneously included string terminator (AO3-2251) | |
| 907 | + | story.delete("\000") | |
| 908 | + | end | |
| 909 | + | ||
| 910 | + | def get_last_modified(location) | |
| 911 | + | Timeout.timeout(STORY_DOWNLOAD_TIMEOUT) do | |
| 912 | + | resp = open(location) | |
| 913 | + | resp.last_modified | |
| 914 | + | end | |
| 915 | + | end | |
| 916 | + | ||
| 917 | + | def get_source_if_known(known_sources, location) | |
| 918 | + | known_sources.each do |source| | |
| 919 | + | pattern = Regexp.new(eval("SOURCE_#{source.upcase}"), Regexp::IGNORECASE) | |
| 920 | + | return source if location.match(pattern) | |
| 921 | + | end | |
| 922 | + | nil | |
| 923 | + | end | |
| 924 | + | ||
| 925 | + | def clean_close_html_tags(value) | |
| 926 | + | # if there are any closing html tags at the start of the value let's ditch them | |
| 927 | + | value.gsub(/^(\s*<\/[^>]+>)+/, '') | |
| 928 | + | end | |
| 929 | + | ||
| 930 | + | # We clean the text as if it had been submitted as the content of a chapter | |
| 931 | + | def clean_storytext(storytext, field = "content") | |
| 932 | + | storytext = storytext.encode("UTF-8", invalid: :replace, undef: :replace, replace: "") unless storytext.encoding.name == "UTF-8" | |
| 933 | + | sanitize_value(field, storytext) | |
| 934 | + | end | |
| 935 | + | ||
| 936 | + | # works conservatively -- doesn't split on | |
| 937 | + | # spaces and truncates instead. | |
| 938 | + | def clean_tags(tags) | |
| 939 | + | tags = Sanitize.clean(tags.force_encoding("UTF-8")) # no html allowed in tags | |
| 940 | + | tags_list = tags =~ /,/ ? tags.split(/,/) : [tags] | |
| 941 | + | new_list = [] | |
| 942 | + | tags_list.each do |tag| | |
| 943 | + | tag.gsub!(/[*<>]/, '') | |
| 944 | + | tag = truncate_on_word_boundary(tag, ArchiveConfig.TAG_MAX) | |
| 945 | + | new_list << tag unless tag.blank? | |
| 946 | + | end | |
| 947 | + | new_list.join(ArchiveConfig.DELIMITER_FOR_OUTPUT) | |
| 948 | + | end | |
| 949 | + | ||
| 950 | + | def truncate_on_word_boundary(text, max_length) | |
| 951 | + | return if text.blank? | |
| 952 | + | words = text.split | |
| 953 | + | truncated = words.first | |
| 954 | + | if words.length > 1 | |
| 955 | + | words[1..words.length].each do |word| | |
| 956 | + | truncated += " " + word if truncated.length + word.length + 1 <= max_length | |
| 957 | + | end | |
| 958 | + | end | |
| 959 | + | truncated[0..max_length - 1] | |
| 960 | + | end | |
| 961 | + | ||
| 962 | + | # convert space-separated tags to comma-separated | |
| 963 | + | def clean_and_split_tags(tags) | |
| 964 | + | tags = tags.split(/\s+/).join(',') if !tags.match(/,/) && tags.match(/\s/) | |
| 965 | + | clean_tags(tags) | |
| 966 | + | end | |
| 967 | + | ||
| 968 | + | # Convert the common ratings into whatever ratings we're | |
| 969 | + | # using on this archive. | |
| 970 | + | def convert_rating_string(rating) | |
| 971 | + | rating = rating.downcase | |
| 972 | + | if rating =~ /^(nc-?1[78]|x|ma|explicit)/ | |
| 973 | + | ArchiveConfig.RATING_EXPLICIT_TAG_NAME | |
| 974 | + | elsif rating =~ /^(r|m|mature)/ | |
| 975 | + | ArchiveConfig.RATING_MATURE_TAG_NAME | |
| 976 | + | elsif rating =~ /^(pg-?1[35]|t|teen)/ | |
| 977 | + | ArchiveConfig.RATING_TEEN_TAG_NAME | |
| 978 | + | elsif rating =~ /^(pg|g|k+|k|general audiences)/ | |
| 979 | + | ArchiveConfig.RATING_GENERAL_TAG_NAME | |
| 980 | + | else | |
| 981 | + | ArchiveConfig.RATING_DEFAULT_TAG_NAME | |
| 982 | + | end | |
| 983 | + | end | |
| 984 | + | ||
| 985 | + | def convert_revised_at(date_string) | |
| 986 | + | begin | |
| 987 | + | date = nil | |
| 988 | + | if date_string =~ /^(\d+)$/ | |
| 989 | + | # probably seconds since the epoch | |
| 990 | + | date = Time.at(Regex.last_match[1].to_i) | |
| 991 | + | end | |
| 992 | + | date ||= Date.parse(date_string) | |
| 993 | + | return '' if date > Date.current | |
| 994 | + | return date | |
| 995 | + | rescue ArgumentError, TypeError | |
| 996 | + | return '' | |
| 997 | + | end | |
| 998 | + | end | |
| 999 | + | ||
| 1000 | + | # Additional processing for meta - currently to make sure warnings | |
| 1001 | + | # that aren't Archive warnings become additional tags instead | |
| 1002 | + | def post_process_meta(meta) | |
| 1003 | + | if meta[:archive_warning_string] | |
| 1004 | + | result = process_warnings(meta[:archive_warning_string], meta[:freeform_string]) | |
| 1005 | + | meta[:archive_warning_string] = result[:archive_warning_string] | |
| 1006 | + | meta[:freeform_string] = result[:freeform_string] | |
| 1007 | + | end | |
| 1008 | + | meta | |
| 1009 | + | end | |
| 1010 | + | ||
| 1011 | + | def process_warnings(warning_string, freeform_string) | |
| 1012 | + | result = { | |
| 1013 | + | archive_warning_string: warning_string, | |
| 1014 | + | freeform_string: freeform_string | |
| 1015 | + | } | |
| 1016 | + | new_warning = '' | |
| 1017 | + | result[:archive_warning_string].split(/\s?,\s?/).each do |warning| | |
| 1018 | + | if ArchiveWarning.warning? warning | |
| 1019 | + | new_warning += ', ' unless new_warning.blank? | |
| 1020 | + | new_warning += warning | |
| 1021 | + | else | |
| 1022 | + | result[:freeform_string] = (result[:freeform_string] || '') + ", #{warning}" | |
| 1023 | + | end | |
| 1024 | + | end | |
| 1025 | + | result[:archive_warning_string] = new_warning | |
| 1026 | + | result | |
| 1027 | + | end | |
| 1028 | + | ||
| 1029 | + | # tries to find appropriate existing collections and converts them to comma-separated collection names only | |
| 1030 | + | def get_collection_names(collection_string) | |
| 1031 | + | collections = "" | |
| 1032 | + | collection_string.split(',').map(&:squish).each do |collection_name| | |
| 1033 | + | collection = Collection.find_by(name: collection_name) || Collection.find_by(title: collection_name) | |
| 1034 | + | if collection | |
| 1035 | + | collections += ", " unless collections.blank? | |
| 1036 | + | collections += collection.name | |
| 1037 | + | end | |
| 1038 | + | end | |
| 1039 | + | collections | |
| 1040 | + | end | |
| 1041 | + | ||
| 1042 | + | # determine which value to use for a metadata field | |
| 1043 | + | def meta_or_default(detected_field, provided_field, default = nil) | |
| 1044 | + | if @options[:override_tags] || detected_field.blank? | |
| 1045 | + | if provided_field.blank? | |
| 1046 | + | detected_field.blank? ? default : detected_field | |
| 1047 | + | else | |
| 1048 | + | provided_field | |
| 1049 | + | end | |
| 1050 | + | else | |
| 1051 | + | detected_field | |
| 1052 | + | end | |
| 1053 | + | end | |
| 1054 | + | end | |
Newer
Older