Fix structured data parsing from links choking on bad data (#17403)
* Fix structured data parsing from links choking on bad data - Fix og:url meta tag being prioritized over canonical link tag - Fix structured data parsing choking on commented-out CDATA declarations - Fix HTML entities in title, description, provider_name, author_name - Change structured data parsing to attempt every JSON-LD script tag * Remove unnecessary slash escapes from CDATA regex pattern
This commit is contained in:
		
							parent
							
								
									73a782391c
								
							
						
					
					
						commit
						f1f6ddd536
					
				
					 2 changed files with 166 additions and 9 deletions
				
			
		| 
						 | 
					@ -3,6 +3,19 @@
 | 
				
			||||||
class LinkDetailsExtractor
 | 
					class LinkDetailsExtractor
 | 
				
			||||||
  include ActionView::Helpers::TagHelper
 | 
					  include ActionView::Helpers::TagHelper
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  # 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
 | 
					  class StructuredData
 | 
				
			||||||
    SUPPORTED_TYPES = %w(
 | 
					    SUPPORTED_TYPES = %w(
 | 
				
			||||||
      NewsArticle
 | 
					      NewsArticle
 | 
				
			||||||
| 
						 | 
					@ -61,6 +74,10 @@ class LinkDetailsExtractor
 | 
				
			||||||
      publisher.dig('logo', 'url')
 | 
					      publisher.dig('logo', 'url')
 | 
				
			||||||
    end
 | 
					    end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def valid?
 | 
				
			||||||
 | 
					      json.present?
 | 
				
			||||||
 | 
					    end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    private
 | 
					    private
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def author
 | 
					    def author
 | 
				
			||||||
| 
						 | 
					@ -134,11 +151,11 @@ class LinkDetailsExtractor
 | 
				
			||||||
  end
 | 
					  end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  def title
 | 
					  def title
 | 
				
			||||||
    structured_data&.headline || opengraph_tag('og:title') || document.xpath('//title').map(&:content).first
 | 
					    html_entities.decode(structured_data&.headline || opengraph_tag('og:title') || document.xpath('//title').map(&:content).first)
 | 
				
			||||||
  end
 | 
					  end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  def description
 | 
					  def description
 | 
				
			||||||
    structured_data&.description || opengraph_tag('og:description') || meta_tag('description')
 | 
					    html_entities.decode(structured_data&.description || opengraph_tag('og:description') || meta_tag('description'))
 | 
				
			||||||
  end
 | 
					  end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  def image
 | 
					  def image
 | 
				
			||||||
| 
						 | 
					@ -146,11 +163,11 @@ class LinkDetailsExtractor
 | 
				
			||||||
  end
 | 
					  end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  def canonical_url
 | 
					  def canonical_url
 | 
				
			||||||
    valid_url_or_nil(opengraph_tag('og:url') || link_tag('canonical'), same_origin_only: true) || @original_url.to_s
 | 
					    valid_url_or_nil(link_tag('canonical') || opengraph_tag('og:url'), same_origin_only: true) || @original_url.to_s
 | 
				
			||||||
  end
 | 
					  end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  def provider_name
 | 
					  def provider_name
 | 
				
			||||||
    structured_data&.publisher_name || opengraph_tag('og:site_name')
 | 
					    html_entities.decode(structured_data&.publisher_name || opengraph_tag('og:site_name'))
 | 
				
			||||||
  end
 | 
					  end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  def provider_url
 | 
					  def provider_url
 | 
				
			||||||
| 
						 | 
					@ -158,7 +175,7 @@ class LinkDetailsExtractor
 | 
				
			||||||
  end
 | 
					  end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  def author_name
 | 
					  def author_name
 | 
				
			||||||
    structured_data&.author_name || opengraph_tag('og:author') || opengraph_tag('og:author:username')
 | 
					    html_entities.decode(structured_data&.author_name || opengraph_tag('og:author') || opengraph_tag('og:author:username'))
 | 
				
			||||||
  end
 | 
					  end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  def author_url
 | 
					  def author_url
 | 
				
			||||||
| 
						 | 
					@ -223,10 +240,24 @@ class LinkDetailsExtractor
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  def structured_data
 | 
					  def structured_data
 | 
				
			||||||
    @structured_data ||= begin
 | 
					    @structured_data ||= begin
 | 
				
			||||||
      json_ld = document.xpath('//script[@type="application/ld+json"]').map(&:content).first
 | 
					      # Some publications have more than one JSON-LD definition on the page,
 | 
				
			||||||
      json_ld.present? ? StructuredData.new(json_ld) : nil
 | 
					      # and some of those definitions aren't valid JSON either, so we have
 | 
				
			||||||
    rescue Oj::ParseError
 | 
					      # to loop through here until we find something that is the right type
 | 
				
			||||||
      nil
 | 
					      # and doesn't break
 | 
				
			||||||
 | 
					      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
 | 
					    end
 | 
				
			||||||
  end
 | 
					  end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
| 
						 | 
					@ -246,4 +277,8 @@ class LinkDetailsExtractor
 | 
				
			||||||
      detector.strip_tags = true
 | 
					      detector.strip_tags = true
 | 
				
			||||||
    end
 | 
					    end
 | 
				
			||||||
  end
 | 
					  end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  def html_entities
 | 
				
			||||||
 | 
					    @html_entities ||= HTMLEntities.new
 | 
				
			||||||
 | 
					  end
 | 
				
			||||||
end
 | 
					end
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -26,4 +26,126 @@ RSpec.describe LinkDetailsExtractor do
 | 
				
			||||||
      end
 | 
					      end
 | 
				
			||||||
    end
 | 
					    end
 | 
				
			||||||
  end
 | 
					  end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  context 'when structured data is present' do
 | 
				
			||||||
 | 
					    let(:original_url) { 'https://example.com/page.html' }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    context 'and is wrapped in CDATA tags' do
 | 
				
			||||||
 | 
					      let(:html) { <<-HTML }
 | 
				
			||||||
 | 
					<!doctype html>
 | 
				
			||||||
 | 
					<html>
 | 
				
			||||||
 | 
					<head>
 | 
				
			||||||
 | 
					  <script type="application/ld+json">
 | 
				
			||||||
 | 
					  //<![CDATA[
 | 
				
			||||||
 | 
					  {"@context":"http://schema.org","@type":"NewsArticle","mainEntityOfPage":"https://example.com/page.html","headline":"Foo","datePublished":"2022-01-31T19:53:00+00:00","url":"https://example.com/page.html","description":"Bar","author":{"@type":"Person","name":"Hoge"},"publisher":{"@type":"Organization","name":"Baz"}}
 | 
				
			||||||
 | 
					  //]]>
 | 
				
			||||||
 | 
					  </script>
 | 
				
			||||||
 | 
					</head>
 | 
				
			||||||
 | 
					</html>
 | 
				
			||||||
 | 
					      HTML
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      describe '#title' do
 | 
				
			||||||
 | 
					        it 'returns the title from structured data' do
 | 
				
			||||||
 | 
					          expect(subject.title).to eq 'Foo'
 | 
				
			||||||
 | 
					        end
 | 
				
			||||||
 | 
					      end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      describe '#description' do
 | 
				
			||||||
 | 
					        it 'returns the description from structured data' do
 | 
				
			||||||
 | 
					          expect(subject.description).to eq 'Bar'
 | 
				
			||||||
 | 
					        end
 | 
				
			||||||
 | 
					      end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      describe '#provider_name' do
 | 
				
			||||||
 | 
					        it 'returns the provider name from structured data' do
 | 
				
			||||||
 | 
					          expect(subject.provider_name).to eq 'Baz'
 | 
				
			||||||
 | 
					        end
 | 
				
			||||||
 | 
					      end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      describe '#author_name' do
 | 
				
			||||||
 | 
					        it 'returns the author name from structured data' do
 | 
				
			||||||
 | 
					          expect(subject.author_name).to eq 'Hoge'
 | 
				
			||||||
 | 
					        end
 | 
				
			||||||
 | 
					      end
 | 
				
			||||||
 | 
					    end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    context 'but the first tag is invalid JSON' do
 | 
				
			||||||
 | 
					      let(:html) { <<-HTML }
 | 
				
			||||||
 | 
					<!doctype html>
 | 
				
			||||||
 | 
					<html>
 | 
				
			||||||
 | 
					<body>
 | 
				
			||||||
 | 
					  <script type="application/ld+json">
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					      "@context":"https://schema.org",
 | 
				
			||||||
 | 
					      "@type":"ItemList",
 | 
				
			||||||
 | 
					      "url":"https://example.com/page.html",
 | 
				
			||||||
 | 
					      "name":"Foo",
 | 
				
			||||||
 | 
					      "description":"Bar"
 | 
				
			||||||
 | 
					    },
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					      "@context": "https://schema.org",
 | 
				
			||||||
 | 
					      "@type": "BreadcrumbList",
 | 
				
			||||||
 | 
					      "itemListElement":[
 | 
				
			||||||
 | 
					        {
 | 
				
			||||||
 | 
					          "@type":"ListItem",
 | 
				
			||||||
 | 
					          "position":1,
 | 
				
			||||||
 | 
					          "item":{
 | 
				
			||||||
 | 
					            "@id":"https://www.example.com",
 | 
				
			||||||
 | 
					            "name":"Baz"
 | 
				
			||||||
 | 
					          }
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					      ]
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  </script>
 | 
				
			||||||
 | 
					  <script type="application/ld+json">
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					      "@context":"https://schema.org",
 | 
				
			||||||
 | 
					      "@type":"NewsArticle",
 | 
				
			||||||
 | 
					      "mainEntityOfPage": {
 | 
				
			||||||
 | 
					        "@type":"WebPage",
 | 
				
			||||||
 | 
					        "@id": "http://example.com/page.html"
 | 
				
			||||||
 | 
					      },
 | 
				
			||||||
 | 
					      "headline": "Foo",
 | 
				
			||||||
 | 
					      "description": "Bar",
 | 
				
			||||||
 | 
					      "datePublished": "2022-01-31T19:46:00+00:00",
 | 
				
			||||||
 | 
					      "author": {
 | 
				
			||||||
 | 
					        "@type": "Organization",
 | 
				
			||||||
 | 
					        "name": "Hoge"
 | 
				
			||||||
 | 
					      },
 | 
				
			||||||
 | 
					      "publisher": {
 | 
				
			||||||
 | 
					        "@type": "NewsMediaOrganization",
 | 
				
			||||||
 | 
					        "name":"Baz",
 | 
				
			||||||
 | 
					        "url":"https://example.com/"
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  </script>
 | 
				
			||||||
 | 
					</body>
 | 
				
			||||||
 | 
					</html>
 | 
				
			||||||
 | 
					      HTML
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      describe '#title' do
 | 
				
			||||||
 | 
					        it 'returns the title from structured data' do
 | 
				
			||||||
 | 
					          expect(subject.title).to eq 'Foo'
 | 
				
			||||||
 | 
					        end
 | 
				
			||||||
 | 
					      end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      describe '#description' do
 | 
				
			||||||
 | 
					        it 'returns the description from structured data' do
 | 
				
			||||||
 | 
					          expect(subject.description).to eq 'Bar'
 | 
				
			||||||
 | 
					        end
 | 
				
			||||||
 | 
					      end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      describe '#provider_name' do
 | 
				
			||||||
 | 
					        it 'returns the provider name from structured data' do
 | 
				
			||||||
 | 
					          expect(subject.provider_name).to eq 'Baz'
 | 
				
			||||||
 | 
					        end
 | 
				
			||||||
 | 
					      end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      describe '#author_name' do
 | 
				
			||||||
 | 
					        it 'returns the author name from structured data' do
 | 
				
			||||||
 | 
					          expect(subject.author_name).to eq 'Hoge'
 | 
				
			||||||
 | 
					        end
 | 
				
			||||||
 | 
					      end
 | 
				
			||||||
 | 
					    end
 | 
				
			||||||
 | 
					  end
 | 
				
			||||||
end
 | 
					end
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
		Loading…
	
		Reference in a new issue