Skip to content
This repository has been archived by the owner on Dec 21, 2023. It is now read-only.

Commit

Permalink
Fix structured data parsing from links choking on bad data (mastodon#…
Browse files Browse the repository at this point in the history
…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
  • Loading branch information
Gargron authored Feb 7, 2022
1 parent 73a7823 commit f1f6ddd
Show file tree
Hide file tree
Showing 2 changed files with 166 additions and 9 deletions.
53 changes: 44 additions & 9 deletions app/lib/link_details_extractor.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,19 @@
class LinkDetailsExtractor
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
SUPPORTED_TYPES = %w(
NewsArticle
Expand Down Expand Up @@ -61,6 +74,10 @@ def publisher_logo
publisher.dig('logo', 'url')
end

def valid?
json.present?
end

private

def author
Expand Down Expand Up @@ -134,31 +151,31 @@ def height
end

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

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

def image
valid_url_or_nil(opengraph_tag('og:image'))
end

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

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

def provider_url
valid_url_or_nil(host_to_url(opengraph_tag('og:site')))
end

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

def author_url
Expand Down Expand Up @@ -223,10 +240,24 @@ def meta_tag(name)

def structured_data
@structured_data ||= begin
json_ld = document.xpath('//script[@type="application/ld+json"]').map(&:content).first
json_ld.present? ? StructuredData.new(json_ld) : nil
rescue Oj::ParseError
nil
# 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
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

Expand All @@ -246,4 +277,8 @@ def detector
detector.strip_tags = true
end
end

def html_entities
@html_entities ||= HTMLEntities.new
end
end
122 changes: 122 additions & 0 deletions spec/lib/link_details_extractor_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -26,4 +26,126 @@
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

0 comments on commit f1f6ddd

Please sign in to comment.