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 +4 -4
- data/README.md +2 -2
- data/lib/html2rss/article.rb +42 -35
- data/lib/html2rss/auto_source/scraper/schema/category_extractor.rb +29 -114
- data/lib/html2rss/auto_source/scraper/schema/item_list.rb +50 -11
- data/lib/html2rss/auto_source/scraper/schema/thing.rb +14 -5
- data/lib/html2rss/auto_source/scraper/schema.rb +61 -18
- data/lib/html2rss/channel.rb +159 -79
- data/lib/html2rss/feed_builder/item_presentation.rb +42 -0
- data/lib/html2rss/feed_builder/json_feed/item.rb +20 -4
- data/lib/html2rss/feed_builder/json_feed.rb +22 -2
- data/lib/html2rss/feed_builder/rss.rb +19 -19
- data/lib/html2rss/feed_builder.rb +9 -17
- data/lib/html2rss/feed_pipeline/auto_fallback.rb +71 -28
- data/lib/html2rss/feed_pipeline.rb +63 -56
- data/lib/html2rss/feed_result.rb +92 -0
- data/lib/html2rss/request_service/botasaurus_contract.rb +33 -5
- data/lib/html2rss/request_service/botasaurus_strategy.rb +13 -4
- data/lib/html2rss/request_service/budget.rb +7 -2
- data/lib/html2rss/request_service/response.rb +9 -1
- data/lib/html2rss/request_service/strategy.rb +22 -0
- data/lib/html2rss/request_session.rb +4 -0
- data/lib/html2rss/selectors.rb +26 -4
- data/lib/html2rss/status.rb +132 -0
- data/lib/html2rss/version.rb +1 -1
- data/lib/html2rss.rb +15 -3
- metadata +5 -3
- data/lib/html2rss/auto_source/scraper/schema/list_item.rb +0 -28
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 8e71e2d9112602603e9cbdf23907d2ad6baabc633c22bd9305094c2ab405f9ad
|
|
4
|
+
data.tar.gz: 1239aecc1f56a8ab4f5f83990671d4655127ad481907552e89fcce30af7d25ef
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
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)
|
data/lib/html2rss/article.rb
CHANGED
|
@@ -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
|
-
#
|
|
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
|
|
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
|
|
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({}) { |(
|
|
39
|
+
@to_h = options.each_with_object({}) { |(key, value), hash| hash[key] = freeze_option(value) }.freeze
|
|
40
40
|
|
|
41
|
-
@
|
|
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
|
-
#
|
|
69
|
-
|
|
70
|
-
|
|
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
|
-
|
|
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
|
-
|
|
18
|
-
|
|
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
|
-
|
|
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
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
9
|
-
#
|
|
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>]
|
|
16
|
+
# @return [Array<Hash>] article hashes from list elements (no parent container)
|
|
18
17
|
def call
|
|
19
|
-
|
|
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
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
78
|
-
def published_at
|
|
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)
|
|
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
|
|
22
|
-
#
|
|
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
|
-
|
|
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
|
-
|
|
56
|
-
when
|
|
57
|
-
|
|
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
|
-
|
|
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
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
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
|
-
|
|
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
|