288 lines
		
	
	
		
			6.6 KiB
		
	
	
	
		
			Ruby
		
	
	
	
			
		
		
	
	
			288 lines
		
	
	
		
			6.6 KiB
		
	
	
	
		
			Ruby
		
	
	
	
| # frozen_string_literal: true
 | |
| 
 | |
| class LinkDetailsExtractor
 | |
|   include ActionView::Helpers::TagHelper
 | |
|   include LanguagesHelper
 | |
| 
 | |
|   # Some publications wrap their JSON-LD data in their <script> tags
 | |
|   # in commented-out CDATA blocks, they need to be removed before
 | |
|   # attempting to parse JSON
 | |
|   CDATA_JUNK_PATTERN = %r{^\s*(
 | |
|     (/\*\s*<!\[CDATA\[\s*\*/) # Block comment style opening
 | |
|     |
 | |
|     (//\s*<!\[CDATA\[) # Single-line comment style opening
 | |
|     |
 | |
|     (/\*\s*\]\]>\s*\*/) # Block comment style closing
 | |
|     |
 | |
|     (//\s*\]\]>) # Single-line comment style closing
 | |
|   )\s*$}x
 | |
| 
 | |
|   class StructuredData
 | |
|     SUPPORTED_TYPES = %w(
 | |
|       NewsArticle
 | |
|       WebPage
 | |
|     ).freeze
 | |
| 
 | |
|     def initialize(data)
 | |
|       @data = data
 | |
|     end
 | |
| 
 | |
|     def headline
 | |
|       json['headline']
 | |
|     end
 | |
| 
 | |
|     def description
 | |
|       json['description']
 | |
|     end
 | |
| 
 | |
|     def language
 | |
|       lang = json['inLanguage']
 | |
|       lang = lang.first if lang.is_a?(Array)
 | |
|       lang.is_a?(Hash) ? (lang['alternateName'] || lang['name']) : lang
 | |
|     end
 | |
| 
 | |
|     def type
 | |
|       json['@type']
 | |
|     end
 | |
| 
 | |
|     def image
 | |
|       obj = first_of_value(json['image'])
 | |
| 
 | |
|       return obj['url'] if obj.is_a?(Hash)
 | |
| 
 | |
|       obj
 | |
|     end
 | |
| 
 | |
|     def date_published
 | |
|       json['datePublished']
 | |
|     end
 | |
| 
 | |
|     def date_modified
 | |
|       json['dateModified']
 | |
|     end
 | |
| 
 | |
|     def author_name
 | |
|       author['name']
 | |
|     end
 | |
| 
 | |
|     def author_url
 | |
|       author['url']
 | |
|     end
 | |
| 
 | |
|     def publisher_name
 | |
|       publisher['name']
 | |
|     end
 | |
| 
 | |
|     def publisher_logo
 | |
|       publisher.dig('logo', 'url')
 | |
|     end
 | |
| 
 | |
|     def valid?
 | |
|       json.present?
 | |
|     end
 | |
| 
 | |
|     private
 | |
| 
 | |
|     def author
 | |
|       first_of_value(json['author']) || {}
 | |
|     end
 | |
| 
 | |
|     def publisher
 | |
|       first_of_value(json['publisher']) || {}
 | |
|     end
 | |
| 
 | |
|     def first_of_value(arr)
 | |
|       arr.is_a?(Array) ? arr.first : arr
 | |
|     end
 | |
| 
 | |
|     def root_array(root)
 | |
|       root.is_a?(Array) ? root : [root]
 | |
|     end
 | |
| 
 | |
|     def json
 | |
|       @json ||= root_array(Oj.load(@data)).find { |obj| SUPPORTED_TYPES.include?(obj['@type']) } || {}
 | |
|     end
 | |
|   end
 | |
| 
 | |
|   def initialize(original_url, html, html_charset)
 | |
|     @original_url = Addressable::URI.parse(original_url)
 | |
|     @html         = html
 | |
|     @html_charset = html_charset
 | |
|   end
 | |
| 
 | |
|   def to_preview_card_attributes
 | |
|     {
 | |
|       title: title || '',
 | |
|       description: description || '',
 | |
|       image_remote_url: image,
 | |
|       image_description: image_alt || '',
 | |
|       type: type,
 | |
|       link_type: link_type,
 | |
|       width: width || 0,
 | |
|       height: height || 0,
 | |
|       html: html || '',
 | |
|       provider_name: provider_name || '',
 | |
|       provider_url: provider_url || '',
 | |
|       author_name: author_name || '',
 | |
|       author_url: author_url || '',
 | |
|       embed_url: embed_url || '',
 | |
|       language: language,
 | |
|       published_at: published_at.presence,
 | |
|     }
 | |
|   end
 | |
| 
 | |
|   def type
 | |
|     player_url.present? ? :video : :link
 | |
|   end
 | |
| 
 | |
|   def link_type
 | |
|     if structured_data&.type == 'NewsArticle' || opengraph_tag('og:type') == 'article'
 | |
|       :article
 | |
|     else
 | |
|       :unknown
 | |
|     end
 | |
|   end
 | |
| 
 | |
|   def html
 | |
|     player_url.present? ? content_tag(:iframe, nil, src: player_url, width: width, height: height, allowfullscreen: 'true', allowtransparency: 'true', scrolling: 'no', frameborder: '0') : nil
 | |
|   end
 | |
| 
 | |
|   def width
 | |
|     opengraph_tag('twitter:player:width')
 | |
|   end
 | |
| 
 | |
|   def height
 | |
|     opengraph_tag('twitter:player:height')
 | |
|   end
 | |
| 
 | |
|   def title
 | |
|     html_entities.decode(structured_data&.headline || opengraph_tag('og:title') || document.xpath('//title').map(&:content).first)
 | |
|   end
 | |
| 
 | |
|   def description
 | |
|     html_entities.decode(structured_data&.description || opengraph_tag('og:description') || meta_tag('description'))
 | |
|   end
 | |
| 
 | |
|   def published_at
 | |
|     structured_data&.date_published || opengraph_tag('article:published_time')
 | |
|   end
 | |
| 
 | |
|   def image
 | |
|     valid_url_or_nil(opengraph_tag('og:image'))
 | |
|   end
 | |
| 
 | |
|   def image_alt
 | |
|     opengraph_tag('og:image:alt')
 | |
|   end
 | |
| 
 | |
|   def canonical_url
 | |
|     valid_url_or_nil(link_tag('canonical') || opengraph_tag('og:url'), same_origin_only: true) || @original_url.to_s
 | |
|   end
 | |
| 
 | |
|   def provider_name
 | |
|     html_entities.decode(structured_data&.publisher_name || opengraph_tag('og:site_name'))
 | |
|   end
 | |
| 
 | |
|   def provider_url
 | |
|     valid_url_or_nil(host_to_url(opengraph_tag('og:site')))
 | |
|   end
 | |
| 
 | |
|   def author_name
 | |
|     html_entities.decode(structured_data&.author_name || opengraph_tag('og:author') || opengraph_tag('og:author:username'))
 | |
|   end
 | |
| 
 | |
|   def author_url
 | |
|     structured_data&.author_url
 | |
|   end
 | |
| 
 | |
|   def embed_url
 | |
|     valid_url_or_nil(opengraph_tag('twitter:player:stream'))
 | |
|   end
 | |
| 
 | |
|   def language
 | |
|     valid_locale_or_nil(structured_data&.language || opengraph_tag('og:locale') || document.xpath('//html').pick('lang'))
 | |
|   end
 | |
| 
 | |
|   def icon
 | |
|     valid_url_or_nil(structured_data&.publisher_icon || link_tag('apple-touch-icon') || link_tag('shortcut icon'))
 | |
|   end
 | |
| 
 | |
|   private
 | |
| 
 | |
|   def player_url
 | |
|     valid_url_or_nil(opengraph_tag('twitter:player'))
 | |
|   end
 | |
| 
 | |
|   def host_to_url(str)
 | |
|     return if str.blank?
 | |
| 
 | |
|     str.start_with?(%r{https?://}) ? str : "http://#{str}"
 | |
|   end
 | |
| 
 | |
|   def valid_url_or_nil(str, same_origin_only: false)
 | |
|     return if str.blank? || str == 'null'
 | |
| 
 | |
|     url = @original_url + Addressable::URI.parse(str)
 | |
| 
 | |
|     return if url.host.blank? || !%w(http https).include?(url.scheme) || (same_origin_only && url.host != @original_url.host)
 | |
| 
 | |
|     url.to_s
 | |
|   rescue Addressable::URI::InvalidURIError
 | |
|     nil
 | |
|   end
 | |
| 
 | |
|   def link_tag(name)
 | |
|     document.xpath("//link[@rel=\"#{name}\"]").pick('href')
 | |
|   end
 | |
| 
 | |
|   def opengraph_tag(name)
 | |
|     document.xpath("//meta[@property=\"#{name}\" or @name=\"#{name}\"]").pick('content')
 | |
|   end
 | |
| 
 | |
|   def meta_tag(name)
 | |
|     document.xpath("//meta[@name=\"#{name}\"]").pick('content')
 | |
|   end
 | |
| 
 | |
|   def structured_data
 | |
|     # Some publications have more than one JSON-LD definition on the page,
 | |
|     # and some of those definitions aren't valid JSON either, so we have
 | |
|     # to loop through here until we find something that is the right type
 | |
|     # and doesn't break
 | |
|     @structured_data ||= document.xpath('//script[@type="application/ld+json"]').filter_map do |element|
 | |
|       json_ld = element.content&.gsub(CDATA_JUNK_PATTERN, '')
 | |
| 
 | |
|       next if json_ld.blank?
 | |
| 
 | |
|       structured_data = StructuredData.new(html_entities.decode(json_ld))
 | |
| 
 | |
|       next unless structured_data.valid?
 | |
| 
 | |
|       structured_data
 | |
|     rescue Oj::ParseError, EncodingError
 | |
|       Rails.logger.debug { "Invalid JSON-LD in #{@original_url}" }
 | |
|       next
 | |
|     end.first
 | |
|   end
 | |
| 
 | |
|   def document
 | |
|     @document ||= Nokogiri::HTML(@html, nil, encoding)
 | |
|   end
 | |
| 
 | |
|   def encoding
 | |
|     @encoding ||= begin
 | |
|       guess = detector.detect(@html, @html_charset)
 | |
|       guess&.fetch(:confidence, 0).to_i > 60 ? guess&.fetch(:encoding, nil) : nil
 | |
|     end
 | |
|   end
 | |
| 
 | |
|   def detector
 | |
|     @detector ||= CharlockHolmes::EncodingDetector.new.tap do |detector|
 | |
|       detector.strip_tags = true
 | |
|     end
 | |
|   end
 | |
| 
 | |
|   def html_entities
 | |
|     @html_entities ||= HTMLEntities.new
 | |
|   end
 | |
| end
 |