180 行
5.3 KiB
Ruby
180 行
5.3 KiB
Ruby
require 'time'
|
|
require 'date'
|
|
|
|
class PostMetadataFetcher
|
|
TIMESTAMP_PATTERN =
|
|
Regexp.new(
|
|
'\A(\d{4})-(\d{2})-(\d{2})T(\d{2})' \
|
|
'(?::(\d{2})(?::(\d{2})(?:\.(\d+))?)?)?' \
|
|
'(Z|[+-]\d{2}:?\d{2})?\z')
|
|
|
|
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),
|
|
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.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,
|
|
:original_created_range,
|
|
:parse_timestamp_range,
|
|
:parse_nanoseconds,
|
|
:parse_offset,
|
|
:serialise_duration,
|
|
:valid_timestamp_components?,
|
|
:valid_offset?,
|
|
:serialise_time
|
|
end
|