require 'time' require 'date' class PostMetadataFetcher TIMESTAMP_PATTERN = /\A(\d{4})-(\d{2})-(\d{2})T(\d{2}) (?::(\d{2})(?::(\d{2})(?:\.(\d+))?)?)? (Z|[+-]\d{2}:?\d{2})?\z/x def self.fetch raw_url uri, = Preview::UrlSafety.validate(raw_url) response = Preview::HttpFetcher.fetch( uri.to_s, max_bytes: Preview::ThumbnailFetcher::HTML_MAX_BYTES) metadata = Preview::HtmlMetadataExtractor.extract(response) document = Nokogiri::HTML.parse(response.body) content = lambda { |name| document .at_css("meta[property='#{ name }'], meta[name='#{ name }']") &.[]('content') &.strip &.presence } duration = content.call('og:video:duration') || content.call('video:duration') published = content.call('article:published_time') || content.call('date') created_range = original_created_range(published) platform_tags = platform_tags(uri) { title: metadata[:title], thumbnail_base: Preview::KnownSiteExtractor.thumbnail_url(uri) || metadata[:image_url], original_created_from: serialise_time(created_range&.first), original_created_before: serialise_time(created_range&.last), duration: serialise_duration(duration), display_tags: display_tags(platform_tags), tags: platform_tags.join(' ') } end def self.platform_tags uri return ['動画', 'YouTube'] if Preview::KnownSiteExtractor.youtube_video_id(uri) return ['動画', 'ニコニコ'] if Preview::KnownSiteExtractor.niconico_video_id(uri) [] end def self.display_tags names return [] if names.blank? existing_tags = Tag .joins(:tag_name) .where(tag_names: { name: names }) .index_by(&:name) names.map { |name| tag = existing_tags[name] { name: name, category: (tag&.category || 'meta'), section_literals: [] } } end def self.original_created_range value return nil if value.blank? raw = value.to_s.strip from, before = case raw when /\A(\d{4})\z/ year = Regexp.last_match(1).to_i from = Time.zone.local(year, 1, 1) [from, from + 1.year] when /\A(\d{4})-(\d{2})\z/ year = Regexp.last_match(1).to_i month = Regexp.last_match(2).to_i return nil unless month.between?(1, 12) from = Time.zone.local(year, month, 1) [from, from + 1.month] when /\A(\d{4})-(\d{2})-(\d{2})\z/ year = Regexp.last_match(1).to_i month = Regexp.last_match(2).to_i day = Regexp.last_match(3).to_i return nil unless Date.valid_date?(year, month, day) from = Time.zone.local(year, month, day) [from, from + 1.day] else parse_timestamp_range(raw) end return nil if from.nil? || before.nil? [from, before] rescue ArgumentError, TypeError nil end def self.parse_timestamp_range raw match = raw.match(TIMESTAMP_PATTERN) return nil unless match year = match[1].to_i month = match[2].to_i day = match[3].to_i hour = match[4].to_i minute = match[5]&.to_i || 0 second = match[6]&.to_i || 0 fraction = match[7] offset = match[8] return nil unless valid_timestamp_components?(year, month, day, hour, minute, second) return nil unless valid_offset?(offset) nanoseconds = parse_nanoseconds(fraction) timestamp = if offset.present? Time.new( year, month, day, hour, minute, second + Rational(nanoseconds, 1_000_000_000), parse_offset(offset)).in_time_zone else Time.zone.local(year, month, day, hour, minute, second).change(nsec: nanoseconds) end from = timestamp.change(sec: 0, nsec: 0) before = if match[5].nil? from + 1.hour else from + 1.minute end [from, before] end def self.parse_nanoseconds value return 0 if value.blank? digits = value[0, 9].ljust(9, '0') Integer(digits, 10) end def self.parse_offset value return '+00:00' if value == 'Z' value.match?(/\A[+-]\d{2}:\d{2}\z/) ? value : "#{ value[0, 3] }:#{ value[3, 2] }" end def self.serialise_duration value seconds = Float(value) return nil unless seconds.positive? milliseconds = (seconds * 1_000).round seconds_string = (milliseconds / 1_000.0).to_s seconds_string.end_with?('.0') ? seconds_string.delete_suffix('.0') : seconds_string rescue ArgumentError, TypeError nil end def self.valid_timestamp_components? year, month, day, hour, minute, second return false unless Date.valid_date?(year, month, day) return false unless hour.between?(0, 23) return false unless minute.between?(0, 59) return false unless second.between?(0, 59) true end def self.valid_offset? value return true if value.nil? || value == 'Z' match = value.match(/\A([+-])(\d{2}):?(\d{2})\z/) return false if match.nil? hours = match[2].to_i minutes = match[3].to_i hours.between?(0, 23) && minutes.between?(0, 59) end def self.serialise_time value return nil if value.nil? value.nsec.zero? ? value.iso8601 : value.iso8601(9) end private_class_method :platform_tags, :display_tags, :original_created_range, :parse_timestamp_range, :parse_nanoseconds, :parse_offset, :serialise_duration, :valid_timestamp_components?, :valid_offset?, :serialise_time end