html2rss 0.23.0 → 0.24.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: f53482ae1084593193664ab8301bd451f53f68b4afabc8f0a336d9ae6dcd357c
4
- data.tar.gz: 65ef2c8261a216040484134f71de9b8579378d6f7aa20100d0c79cf684efcd65
3
+ metadata.gz: 8e71e2d9112602603e9cbdf23907d2ad6baabc633c22bd9305094c2ab405f9ad
4
+ data.tar.gz: 1239aecc1f56a8ab4f5f83990671d4655127ad481907552e89fcce30af7d25ef
5
5
  SHA512:
6
- metadata.gz: 18c6b5dfb6f23753b1c4885339b16505800b91235e65f397d7195d2301b7c32974c12be0087c35c2e63a5bd08185518ea49c6bd252cc68f22521814c6f6e74f8
7
- data.tar.gz: 458fff5c3579ea95190e458a9cf5814418467fdfc37cedc1244faf4d7c7eb6582640b9d8465329f68eac3624c33c1691514da25c1ce1a7cb6ddb2c7999c38638
6
+ metadata.gz: fe0e1fe5a2b81886b0bda2f9087e12c42a5b309d105bcd7c1ea2268690740ee36390ad54bafb94757d758204968617569f981f489afe05572f41be28c1e4025a
7
+ data.tar.gz: cfad45944b508e5fafaae484cdb625e33ee43e336a2ab74b336f398c7eaab306432cf06d524ddb81b7ec5a002e77c065d7af491a6b245d0a2f5b2df6098aeda2
data/README.md CHANGED
@@ -13,7 +13,7 @@ Most people looking for a first working feed should start with `html2rss-web`, r
13
13
  Detailed usage guides, reference docs, and the feed directory live on the project website:
14
14
 
15
15
  - [Ruby gem documentation](https://html2rss.github.io/ruby-gem)
16
- - [Request strategies](https://html2rss.github.io/ruby-gem/reference/strategy) (`auto`, `faraday`, `botasaurus`, `browserless`)
16
+ - [Request strategies](https://html2rss.github.io/ruby-gem/reference/strategy) (`auto` = `faraday` `botasaurus`; pin `browserless` explicitly)
17
17
  - [Selectors & pagination](https://html2rss.github.io/ruby-gem/reference/selectors#paginated-feeds)
18
18
  - [Web application](https://html2rss.github.io/web-application)
19
19
  - [Feed directory](https://html2rss.github.io/feed-directory)
@@ -26,7 +26,7 @@ Cloud development: [Open in GitHub Codespaces](https://github.com/codespaces/new
26
26
  ## Architecture
27
27
 
28
28
  1. **Config** — loads and validates configuration (YAML/hash); schema via `html2rss schema` / `schema/html2rss-config.schema.json`
29
- 2. **RequestService** — fetches pages (`faraday`, `botasaurus`, or `browserless`)
29
+ 2. **RequestService** — fetches pages (`faraday`, `botasaurus`, or explicit `browserless`)
30
30
  3. **Selectors** — extracts content via CSS selectors with extractors/post-processors
31
31
  4. **AutoSource** — auto-detects content (Schema.org, JSON state, semantic HTML, structural patterns)
32
32
  5. **FeedBuilder** — assembles Article objects and renders feeds (RSS 2.0 / JSON Feed 1.1)
@@ -1,14 +1,14 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'zlib'
4
- require 'sanitize'
5
- require 'nokogiri'
6
4
 
7
5
  module Html2rss
8
6
  ##
9
7
  # Article is a simple data object representing an article extracted from a page.
10
8
  # It is enumerable and responds to all keys specified in PROVIDED_KEYS.
11
- # rubocop:disable Metrics/ClassLength
9
+ #
10
+ # Description and enclosure wire presentation live in {FeedBuilder::ItemPresentation}.
11
+ # rubocop:disable Metrics/ClassLength -- value object retains Marshal + defensive freeze helpers
12
12
  class Article
13
13
  include Enumerable
14
14
  include Comparable
@@ -26,9 +26,9 @@ module Html2rss
26
26
  # @param options [Hash{Symbol => String}]
27
27
  # @option options [String] :id stable article identifier
28
28
  # @option options [String] :title article title
29
- # @option options [String] :description article description/content
29
+ # @option options [String] :description raw extracted description/content (not feed-rendered HTML)
30
30
  # @option options [String, Html2rss::Url] :url canonical article URL
31
- # @option options [String, Html2rss::Url] :image image URL for fallback enclosure rendering
31
+ # @option options [String, Html2rss::Url] :image image URL for description / JSON Feed +image+
32
32
  # @option options [String] :author author name
33
33
  # @option options [String] :guid explicit GUID override
34
34
  # @option options [String, Time, DateTime] :published_at publication timestamp
@@ -36,9 +36,9 @@ module Html2rss
36
36
  # @option options [Array<String>] :categories category labels
37
37
  # @option options [Class] :scraper scraper class that produced the article
38
38
  def initialize(**options)
39
- @to_h = options.each_with_object({}) { |(k, v), h| h[k] = v.freeze if v }.freeze
39
+ @to_h = options.each_with_object({}) { |(key, value), hash| hash[key] = freeze_option(value) }.freeze
40
40
 
41
- @description = @url = @image = @guid = @enclosures = @enclosure = @categories = @published_at = NOT_SET
41
+ @url = @image = @guid = @enclosures = @categories = @published_at = NOT_SET
42
42
 
43
43
  return unless (unknown_keys = options.keys - PROVIDED_KEYS).any?
44
44
 
@@ -65,18 +65,10 @@ module Html2rss
65
65
  # @return [String, nil] article title
66
66
  def title = blank_string_to_nil(@to_h[:title])
67
67
 
68
- # @return [String] rendered article description
69
- def description
70
- return @description unless @description == NOT_SET
71
-
72
- @description = Html::Rendering::DescriptionBuilder.new(
73
- base: @to_h[:description],
74
- title:,
75
- url:,
76
- enclosures:,
77
- image:
78
- ).call
79
- end
68
+ # Raw extracted description feed HTML enrichment is {FeedBuilder::ItemPresentation.description_for}.
69
+ #
70
+ # @return [String, nil]
71
+ def description = blank_string_to_nil(@to_h[:description])
80
72
 
81
73
  # @return [Url, nil]
82
74
  def url
@@ -117,21 +109,7 @@ module Html2rss
117
109
 
118
110
  @enclosures = Array(@to_h[:enclosures])
119
111
  .map { |enclosure| Enclosure.new(**enclosure) }
120
- end
121
-
122
- # @return [Html2rss::Article::Enclosure, nil]
123
- def enclosure
124
- return @enclosure unless @enclosure == NOT_SET
125
-
126
- @enclosure = case (object = @to_h[:enclosures]&.first)
127
- when Hash
128
- Enclosure.new(**object)
129
- when nil
130
- Enclosure.new(url: image) if image
131
- else
132
- Log.warn "Article: unknown enclosure type: #{object.class}"
133
- nil
134
- end
112
+ .freeze
135
113
  end
136
114
 
137
115
  # @return [Array<String>] normalized, unique category names
@@ -142,7 +120,7 @@ module Html2rss
142
120
  categories.map! { |category| category.to_s.strip }
143
121
  categories.reject!(&:empty?)
144
122
  categories.uniq!
145
- end
123
+ end.freeze
146
124
  end
147
125
 
148
126
  # Parses and returns the published_at time.
@@ -171,6 +149,35 @@ module Html2rss
171
149
 
172
150
  private
173
151
 
152
+ ##
153
+ # Marshal only the constructor payload so lazy +NOT_SET+ sentinels do not break
154
+ # {FeedResult} cache round-trips.
155
+ #
156
+ # @return [Hash{Symbol => Object}]
157
+ def marshal_dump = @to_h
158
+
159
+ ##
160
+ # @param payload [Hash{Symbol => Object}] constructor options from {#marshal_dump}
161
+ # @return [void]
162
+ def marshal_load(payload)
163
+ initialize(**payload)
164
+ end
165
+
166
+ def freeze_option(value)
167
+ case value
168
+ when String then value.dup.freeze
169
+ when Array then freeze_array_option(value)
170
+ when Hash then value.transform_values { freeze_option(_1) }.freeze
171
+ else value
172
+ end
173
+ end
174
+
175
+ def freeze_array_option(value)
176
+ value.map do |entry|
177
+ entry.is_a?(Hash) ? entry.transform_values { freeze_option(_1) }.freeze : freeze_option(entry)
178
+ end.freeze
179
+ end
180
+
174
181
  def dedup_from_url
175
182
  return unless (value = url)
176
183
 
@@ -7,138 +7,53 @@ module Html2rss
7
7
  ##
8
8
  # Extracts categories from Schema.org structured data.
9
9
  module CategoryExtractor
10
+ # Schema.org fields checked for category-like string values.
11
+ CATEGORY_FIELDS = %i[articleSection keywords categories tags].freeze
12
+
10
13
  ##
11
- # Extracts categories from a schema object.
12
- #
13
14
  # @param schema_object [Hash] The schema object
14
15
  # @return [Array<String>] Array of category strings
15
16
  def self.call(schema_object)
16
17
  Set.new.tap do |categories|
17
- extract_field_categories!(categories, schema_object)
18
- extract_about_categories!(categories, schema_object)
18
+ CATEGORY_FIELDS.each { |field| add_field(categories, schema_object[field]) }
19
+ add_about(categories, schema_object[:about])
19
20
  end.to_a
20
21
  end
21
22
 
22
- ##
23
- # Extracts categories from keywords, categories, and tags fields.
24
- #
25
- # @param schema_object [Hash] The schema object
26
- # @return [Set<String>] Set of category strings
27
- def self.extract_field_categories(schema_object)
28
- Set.new.tap { |categories| extract_field_categories!(categories, schema_object) }
29
- end
30
-
31
- ##
32
- # Extracts categories from keywords, categories, and tags fields.
33
- #
34
- # @param categories [Set<String>] Accumulator set
35
- # @param schema_object [Hash] The schema object
36
- # @return [void]
37
- def self.extract_field_categories!(categories, schema_object)
38
- %i[keywords categories tags].each do |field|
39
- extract_field_value!(categories, schema_object[field])
40
- end
41
- end
23
+ class << self
24
+ private
42
25
 
43
- ##
44
- # Extracts categories from the about field.
45
- #
46
- # @param schema_object [Hash] The schema object
47
- # @return [Set<String>] Set of category strings
48
- def self.extract_about_categories(schema_object)
49
- Set.new.tap { |categories| extract_about_categories!(categories, schema_object) }
50
- end
51
-
52
- ##
53
- # Extracts categories from the about field.
54
- #
55
- # @param categories [Set<String>] Accumulator set
56
- # @param schema_object [Hash] The schema object
57
- # @return [void]
58
- def self.extract_about_categories!(categories, schema_object)
59
- about = schema_object[:about]
60
- return unless about
61
-
62
- if about.is_a?(Array)
63
- extract_about_array!(categories, about)
64
- elsif about.is_a?(String)
65
- extract_string_categories!(categories, about)
26
+ def add_field(categories, value)
27
+ case value
28
+ when Array
29
+ value.each { |item| add_string(categories, item.to_s) }
30
+ when String
31
+ split_string(categories, value)
32
+ end
66
33
  end
67
- end
68
-
69
- ##
70
- # Extracts categories from a single field value.
71
- #
72
- # @param schema_object [Hash] The schema object
73
- # @param field [String] The field name
74
- # @return [Set<String>] Set of category strings
75
- def self.extract_field_value(schema_object, field)
76
- Set.new.tap { |categories| extract_field_value!(categories, schema_object[field.to_sym]) }
77
- end
78
34
 
79
- ##
80
- # Extracts categories from a single field value.
81
- #
82
- # @param categories [Set<String>] Accumulator set
83
- # @param value [Object] The field value
84
- # @return [void]
85
- def self.extract_field_value!(categories, value)
86
- return unless value
87
-
88
- if value.is_a?(Array)
89
- value.each do |item|
90
- s = item.to_s
91
- categories.add(s) unless s.empty?
35
+ def add_about(categories, about)
36
+ case about
37
+ when Array then add_about_items(categories, about)
38
+ when String then split_string(categories, about)
92
39
  end
93
- elsif value.is_a?(String)
94
- extract_string_categories!(categories, value)
95
40
  end
96
- end
97
41
 
98
- ##
99
- # Extracts categories from an about array.
100
- #
101
- # @param about [Array] The about array
102
- # @return [Set<String>] Set of category strings
103
- def self.extract_about_array(about)
104
- Set.new.tap { |categories| extract_about_array!(categories, about) }
105
- end
106
-
107
- ##
108
- # Extracts categories from an about array.
109
- #
110
- # @param categories [Set<String>] Accumulator set
111
- # @param about [Array] The about array
112
- # @return [void]
113
- def self.extract_about_array!(categories, about)
114
- about.each do |item|
115
- if item.is_a?(Hash) && item[:name]
116
- categories.add(item[:name].to_s)
117
- elsif item.is_a?(String)
118
- categories.add(item)
42
+ def add_about_items(categories, about)
43
+ about.each do |item|
44
+ case item
45
+ when Hash then add_string(categories, item[:name].to_s)
46
+ when String then add_string(categories, item)
47
+ end
119
48
  end
120
49
  end
121
- end
122
50
 
123
- ##
124
- # Extracts categories from a string by splitting on separators.
125
- #
126
- # @param string [String] source string that may contain category delimiters
127
- # @return [Set<String>] Set of category strings
128
- def self.extract_string_categories(string)
129
- Set.new.tap { |categories| extract_string_categories!(categories, string) }
130
- end
51
+ def split_string(categories, string)
52
+ string.split(/[,;|]/).each { |part| add_string(categories, part.strip) }
53
+ end
131
54
 
132
- ##
133
- # Extracts categories from a string by splitting on separators.
134
- #
135
- # @param categories [Set<String>] Accumulator set
136
- # @param string [String] source string that may contain category delimiters
137
- # @return [void]
138
- def self.extract_string_categories!(categories, string)
139
- string.split(/[,;|]/).each do |part|
140
- s = part.strip
141
- categories.add(s) unless s.empty?
55
+ def add_string(categories, string)
56
+ categories.add(string) unless string.empty?
142
57
  end
143
58
  end
144
59
  end
@@ -5,28 +5,67 @@ module Html2rss
5
5
  module Scraper
6
6
  class Schema
7
7
  ##
8
- # Handles schema.org ItemList objects, which contain
9
- # 1. itemListElements, and/or
10
- # 2. interesting attributes, i.e. description, url, image, itself.
8
+ # Walks schema.org ItemList objects and yields article-shaped elements only.
9
+ # The ItemList container itself is never emitted.
11
10
  #
12
11
  # @see https://schema.org/ItemList
13
12
  class ItemList < Thing
14
13
  # Schema.org type names handled by the ItemList extractor.
15
14
  SUPPORTED_TYPES = Set['ItemList']
16
15
 
17
- # @return [Array<Hash>] the scraped article hashes with DEFAULT_ATTRIBUTES
16
+ # @return [Array<Hash>] article hashes from list elements (no parent container)
18
17
  def call
19
- hashes = [super]
20
-
21
- return hashes unless (elements = @schema_object[:itemListElement])
18
+ elements = @schema_object[:itemListElement]
19
+ return [] unless elements
22
20
 
23
21
  elements = [elements] unless elements.is_a?(Array)
22
+ elements.filter_map { |element| materialize_element(element) }
23
+ end
24
+
25
+ private
26
+
27
+ # @param element [Object] raw itemListElement entry
28
+ # @return [Hash, nil] scraped article hash when emit-worthy
29
+ def materialize_element(element)
30
+ object, from_list_item = unwrap_list_item(element)
31
+ return unless object.is_a?(Hash)
32
+ return unless emit?(object, from_list_item:)
33
+
34
+ article = Thing.new(object, url: base_url || '').call
35
+ titleize_list_item_stub!(article) if from_list_item
36
+ article
37
+ end
38
+
39
+ # @param element [Object] raw list entry
40
+ # @return [Array(Hash, Boolean)] schema object and whether it came from ListItem
41
+ def unwrap_list_item(element)
42
+ return [element, false] unless element.is_a?(Hash)
43
+
44
+ types = Schema.normalize_types(element[:@type])
45
+ return [element, false] unless types.include?('ListItem')
46
+
47
+ item = element[:item]
48
+ [item.is_a?(Hash) ? item : element, true]
49
+ end
50
+
51
+ # @param object [Hash] candidate schema object
52
+ # @param from_list_item [Boolean] whether object came from a ListItem unwrap
53
+ # @return [Boolean] whether to emit this object as an article
54
+ def emit?(object, from_list_item:)
55
+ return true if from_list_item
56
+
57
+ Schema.normalize_types(object[:@type]).intersect?(Thing::SUPPORTED_TYPES)
58
+ end
24
59
 
25
- elements.each do |schema_object|
26
- hashes << ListItem.new(schema_object, url: base_url || '').call
27
- end
60
+ # URL-only ListItem stubs historically used a titleized path as title.
61
+ #
62
+ # @param article [Hash] scraped article hash
63
+ # @return [void]
64
+ def titleize_list_item_stub!(article)
65
+ return unless article[:title].to_s.empty?
66
+ return unless (article_url = article[:url])
28
67
 
29
- hashes
68
+ article[:title] = article_url.titleized
30
69
  end
31
70
  end
32
71
  end
@@ -40,8 +40,13 @@ module Html2rss
40
40
  @id = id.to_s.empty? ? nil : id
41
41
  end
42
42
 
43
- # @return [String, nil] article title
44
- def title = schema_object[:title]
43
+ # @return [String, nil] article title (schema.org: headline → name → alternativeHeadline; legacy title)
44
+ def title
45
+ schema_object.values_at(:headline, :name, :alternativeHeadline, :title)
46
+ .lazy
47
+ .map { _1.to_s.strip }
48
+ .find { !_1.empty? }
49
+ end
45
50
 
46
51
  # @return [String, nil] longest available description field
47
52
  def description
@@ -74,8 +79,11 @@ module Html2rss
74
79
  @image = img_url ? Url.from_relative(img_url, base_url || img_url) : nil
75
80
  end
76
81
 
77
- # @return [String, nil] published-at timestamp string
78
- def published_at = schema_object[:datePublished]
82
+ # @return [String, nil] published-at timestamp (datePublished, else dateModified)
83
+ def published_at
84
+ value = schema_object[:datePublished] || schema_object[:dateModified]
85
+ value.to_s.empty? ? nil : value
86
+ end
79
87
 
80
88
  # @return [Array<String>, nil] extracted category labels
81
89
  def categories
@@ -107,7 +115,8 @@ module Html2rss
107
115
  # @return [String, nil] image URL string
108
116
  def image_url_from(obj)
109
117
  return obj if obj.is_a?(String)
110
- return unless obj.is_a?(Hash) && obj[:@type] == 'ImageObject'
118
+ return unless obj.is_a?(Hash)
119
+ return unless Schema.normalize_types(obj[:@type]).include?('ImageObject')
111
120
 
112
121
  obj[:url] || obj[:contentUrl]
113
122
  end
@@ -18,13 +18,25 @@ module Html2rss
18
18
  # Selector for JSON-LD script tags containing Schema.org objects.
19
19
  TAG_SELECTOR = 'script[type="application/ld+json"]'
20
20
 
21
- # Pre-compiled regex union for supported schema types.
22
- # Performs a single pass over script tag text instead of multiple regex matches.
21
+ # Pre-compiled regex for supported schema types (short name or schema.org URL; string or array @type).
22
+ # Allows preceding entries in a JSON @type array (e.g. ["WebPage","NewsArticle"]).
23
23
  SUPPORTED_TYPES_RE = begin
24
24
  types = Thing::SUPPORTED_TYPES | ItemList::SUPPORTED_TYPES
25
- /"@type"\s*:\s*"(?:#{Regexp.union(types.to_a).source})"/
25
+ type_re = Regexp.union(types.to_a)
26
+ %r{"@type"\s*:\s*(?:\[\s*(?:"[^"]*"\s*,\s*)*)?"(?:https?://schema\.org/)?(?:#{type_re.source})"}
26
27
  end.freeze
27
28
 
29
+ # Matches a leading schema.org URL prefix on @type values (http or https).
30
+ SCHEMA_ORG_PREFIX_RE = %r{\Ahttps?://schema\.org/}i
31
+
32
+ # Prefer these keys when recursively walking unsupported container objects.
33
+ COLLECTION_KEYS = %i[itemListElement blogPost mainEntity hasPart].freeze
34
+
35
+ # Container types that must never be emitted as feed items (walk children only).
36
+ DENIED_CONTAINER_TYPES = Set[
37
+ 'ItemList', 'Blog', 'BreadcrumbList', 'WebPage', 'CollectionPage'
38
+ ].freeze
39
+
28
40
  # @return [Symbol] scraper config key
29
41
  def self.options_key = :schema
30
42
 
@@ -46,19 +58,17 @@ module Html2rss
46
58
  # of all supported schema objects
47
59
  # by recursively traversing the given `object`.
48
60
  #
61
+ # Prefers collection keys ({COLLECTION_KEYS}) when walking containers.
62
+ #
49
63
  # @param object [Hash, Array, Nokogiri::XML::Element]
50
64
  # @return [Array<Hash>] the schema_objects, or an empty array
51
65
  # :reek:DuplicateMethodCall
52
66
  def from(object)
53
67
  case object
54
- when Nokogiri::XML::Element
55
- from(parse_script_tag(object))
56
- when Hash
57
- supported_schema_object?(object) ? [object] : object.values.flat_map { |item| from(item) }
58
- when Array
59
- object.flat_map { |item| from(item) }
60
- else
61
- []
68
+ when Nokogiri::XML::Element then from(parse_script_tag(object))
69
+ when Hash then from_hash(object)
70
+ when Array then object.flat_map { |item| from(item) }
71
+ else []
62
72
  end
63
73
  end
64
74
 
@@ -72,20 +82,53 @@ module Html2rss
72
82
  # @param schema_object [Hash{Symbol => Object}] schema object with an @type key
73
83
  # @return [Scraper::Schema::Thing, Scraper::Schema::ItemList, nil] a class responding to `#call`
74
84
  def scraper_for_schema_object(schema_object)
75
- type = schema_object[:@type]
85
+ types = normalize_types(schema_object[:@type])
86
+
87
+ # Prefer article/list extractors over denied containers for multi-type @type arrays
88
+ # (e.g. ["NewsArticle","WebPage"]).
89
+ return ItemList if types.intersect?(ItemList::SUPPORTED_TYPES)
90
+ return Thing if types.intersect?(Thing::SUPPORTED_TYPES)
91
+ return nil if types.intersect?(DENIED_CONTAINER_TYPES)
92
+
93
+ Log.debug("#{name}: unsupported schema object @type=#{schema_object[:@type].inspect}")
94
+ nil
95
+ end
76
96
 
77
- if Thing::SUPPORTED_TYPES.member?(type)
78
- Thing
79
- elsif ItemList::SUPPORTED_TYPES.member?(type)
80
- ItemList
97
+ # Normalizes Schema.org `@type` wire forms to short type names.
98
+ #
99
+ # Handles String, Symbol, Array, and strips `https://schema.org/` / `http://schema.org/` prefixes.
100
+ #
101
+ # @param object [String, Symbol, Array, nil] raw `@type` value
102
+ # @return [Set<String>] short type names (e.g. "NewsArticle")
103
+ # @api private
104
+ def normalize_types(object)
105
+ case object
106
+ when Array
107
+ object.each_with_object(Set.new) { |item, set| set.merge(normalize_types(item)) }
108
+ when String, Symbol
109
+ short = object.to_s.sub(SCHEMA_ORG_PREFIX_RE, '')
110
+ short.empty? ? Set.new : Set[short]
81
111
  else
82
- Log.debug("#{name}: unsupported schema object @type=#{type.inspect}")
83
- nil
112
+ Set.new
84
113
  end
85
114
  end
86
115
 
87
116
  private
88
117
 
118
+ # @param hash [Hash] candidate schema object
119
+ # @return [Array<Hash>] the object itself or nested supported objects
120
+ def from_hash(hash)
121
+ supported_schema_object?(hash) ? [hash] : walk_hash_values(hash)
122
+ end
123
+
124
+ # @param hash [Hash] schema object that is not itself extractable
125
+ # @return [Array<Hash>] nested supported objects
126
+ def walk_hash_values(hash)
127
+ preferred = COLLECTION_KEYS.filter_map { |key| hash[key] }
128
+ rest = hash.except(*COLLECTION_KEYS).values
129
+ (preferred + rest).flat_map { |item| from(item) }
130
+ end
131
+
89
132
  def parse_script_tag(script_tag)
90
133
  JSON.parse(script_tag.text, symbolize_names: true)
91
134
  rescue JSON::ParserError => error