html2rss 0.32.4 → 0.34.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 +4 -2
- data/lib/html2rss/article/deduplicator.rb +5 -2
- data/lib/html2rss/article/enclosure.rb +185 -25
- data/lib/html2rss/article/title.rb +203 -0
- data/lib/html2rss/article.rb +79 -13
- data/lib/html2rss/auto_source/README.md +16 -8
- data/lib/html2rss/auto_source/cleanup.rb +127 -53
- data/lib/html2rss/auto_source/corroboration.rb +167 -0
- data/lib/html2rss/auto_source/page_links/anchor_claims.rb +82 -0
- data/lib/html2rss/auto_source/page_links/card_units.rb +77 -0
- data/lib/html2rss/auto_source/page_links/label_binding.rb +79 -0
- data/lib/html2rss/auto_source/page_links/sponsored_cards.rb +147 -0
- data/lib/html2rss/auto_source/page_links.rb +164 -0
- data/lib/html2rss/auto_source/scraper/html.rb +11 -45
- data/lib/html2rss/auto_source/scraper/json_document.rb +3 -6
- data/lib/html2rss/auto_source/scraper/json_state/article_normalizer.rb +202 -125
- data/lib/html2rss/auto_source/scraper/json_state/candidate_detector.rb +27 -38
- data/lib/html2rss/auto_source/scraper/json_state/document_scanner.rb +123 -123
- data/lib/html2rss/auto_source/scraper/json_state/value_finder.rb +27 -35
- data/lib/html2rss/auto_source/scraper/json_state.rb +4 -9
- data/lib/html2rss/auto_source/scraper/meta_oembed.rb +27 -5
- data/lib/html2rss/auto_source/scraper/microdata.rb +233 -248
- data/lib/html2rss/auto_source/scraper/microformats2.rb +10 -4
- data/lib/html2rss/auto_source/scraper/native_feed.rb +28 -18
- data/lib/html2rss/auto_source/scraper/schema/category_extractor.rb +7 -2
- data/lib/html2rss/auto_source/scraper/schema/item_list.rb +5 -3
- data/lib/html2rss/auto_source/scraper/schema/thing.rb +140 -22
- data/lib/html2rss/auto_source/scraper/schema.rb +52 -8
- data/lib/html2rss/auto_source/scraper/semantic_html/entry_deduplicator.rb +3 -9
- data/lib/html2rss/auto_source/scraper/semantic_html.rb +12 -20
- data/lib/html2rss/auto_source/scraper/sitemap/parser.rb +1 -4
- data/lib/html2rss/auto_source/scraper/sitemap.rb +7 -14
- data/lib/html2rss/auto_source/scraper/wordpress_api/page_scope.rb +2 -2
- data/lib/html2rss/auto_source/scraper/wordpress_api/posts_endpoint.rb +1 -3
- data/lib/html2rss/auto_source/scraper/wordpress_api.rb +7 -7
- data/lib/html2rss/auto_source/scraper/xhr_articles.rb +2 -4
- data/lib/html2rss/auto_source/scraper.rb +10 -13
- data/lib/html2rss/auto_source/segment.rb +19 -5
- data/lib/html2rss/auto_source/segmenter/cluster.rb +18 -22
- data/lib/html2rss/auto_source/segmenter/list.rb +35 -59
- data/lib/html2rss/auto_source/segmenter/primary_link.rb +29 -4
- data/lib/html2rss/auto_source/segmenter/semantic.rb +23 -19
- data/lib/html2rss/auto_source/segmenter.rb +4 -8
- data/lib/html2rss/auto_source.rb +81 -26
- data/lib/html2rss/batch.rb +24 -32
- data/lib/html2rss/capture/README.md +6 -3
- data/lib/html2rss/capture.rb +51 -59
- data/lib/html2rss/channel.rb +30 -12
- data/lib/html2rss/cli/render.rb +97 -96
- data/lib/html2rss/cli/validate.rb +2 -4
- data/lib/html2rss/cli.rb +55 -38
- data/lib/html2rss/config/auto_source_contract.rb +52 -45
- data/lib/html2rss/config/issue_mapper.rb +4 -1
- data/lib/html2rss/config/request_controls.rb +16 -5
- data/lib/html2rss/config/schema.rb +23 -15
- data/lib/html2rss/config/selectors_validator.rb +3 -2
- data/lib/html2rss/config/validation_issue.rb +4 -1
- data/lib/html2rss/config/validator.rb +14 -3
- data/lib/html2rss/config.rb +54 -28
- data/lib/html2rss/contract_error.rb +9 -0
- data/lib/html2rss/defaults.rb +25 -38
- data/lib/html2rss/doctor/botasaurus.rb +18 -22
- data/lib/html2rss/error.rb +17 -3
- data/lib/html2rss/error_kind.rb +39 -0
- data/lib/html2rss/feed_builder/item_presentation.rb +22 -9
- data/lib/html2rss/feed_builder/json_feed/item.rb +2 -1
- data/lib/html2rss/feed_builder/json_feed.rb +2 -2
- data/lib/html2rss/feed_builder/rss/stylesheet.rb +3 -3
- data/lib/html2rss/feed_builder/rss.rb +50 -7
- data/lib/html2rss/feed_builder.rb +2 -1
- data/lib/html2rss/feed_pipeline/README.md +1 -1
- data/lib/html2rss/feed_pipeline/auto_fallback.rb +16 -34
- data/lib/html2rss/feed_pipeline/strategy_plan.rb +3 -3
- data/lib/html2rss/feed_pipeline.rb +40 -27
- data/lib/html2rss/feed_resolution/README.md +1 -1
- data/lib/html2rss/feed_resolution/candidate_generator.rb +6 -8
- data/lib/html2rss/feed_resolution/options.rb +3 -7
- data/lib/html2rss/feed_resolution/policy.rb +29 -33
- data/lib/html2rss/feed_resolution/probe.rb +6 -4
- data/lib/html2rss/feed_resolution/scorer.rb +29 -29
- data/lib/html2rss/feed_resolution.rb +16 -21
- data/lib/html2rss/feed_result.rb +11 -6
- data/lib/html2rss/hash_util.rb +91 -90
- data/lib/html2rss/html/article_extractor/category_extractor.rb +7 -10
- data/lib/html2rss/html/article_extractor/date_extractor.rb +1 -1
- data/lib/html2rss/html/article_extractor/enclosure_extractor.rb +12 -33
- data/lib/html2rss/html/article_extractor/heading_extractor.rb +2 -3
- data/lib/html2rss/html/article_extractor/id_generator.rb +7 -10
- data/lib/html2rss/html/article_extractor/image_extractor.rb +9 -1
- data/lib/html2rss/html/article_extractor.rb +19 -47
- data/lib/html2rss/html/article_rules/category.rb +3 -3
- data/lib/html2rss/html/article_rules/description.rb +6 -15
- data/lib/html2rss/html/article_rules/enclosure.rb +70 -42
- data/lib/html2rss/html/article_rules/image.rb +30 -16
- data/lib/html2rss/html/feed_link.rb +7 -9
- data/lib/html2rss/html/navigator/text_extractor.rb +26 -16
- data/lib/html2rss/html/navigator.rb +39 -9
- data/lib/html2rss/html/probe.rb +55 -51
- data/lib/html2rss/html/rendering/description_builder.rb +46 -32
- data/lib/html2rss/html/rendering.rb +2 -9
- data/lib/html2rss/html/sst_article_extractor.rb +55 -145
- data/lib/html2rss/html/title_pieces/kicker.rb +147 -0
- data/lib/html2rss/html/title_pieces/nodes.rb +107 -0
- data/lib/html2rss/html/title_pieces/nokogiri_nodes.rb +70 -0
- data/lib/html2rss/html/title_pieces/sst_nodes.rb +71 -0
- data/lib/html2rss/html/title_pieces.rb +169 -0
- data/lib/html2rss/html/visibility.rb +56 -0
- data/lib/html2rss/html.rb +2 -0
- data/lib/html2rss/link_destination/destination_facts.rb +17 -1
- data/lib/html2rss/link_destination/noise_policy.rb +11 -10
- data/lib/html2rss/link_destination/path_classifier.rb +111 -42
- data/lib/html2rss/link_destination/sponsorship/label_text.rb +82 -0
- data/lib/html2rss/link_destination/sponsorship.rb +184 -0
- data/lib/html2rss/link_destination/text_classifier.rb +104 -8
- data/lib/html2rss/mcp/README.md +13 -1
- data/lib/html2rss/mcp/config_argument.rb +14 -4
- data/lib/html2rss/mcp/contract.rb +20 -9
- data/lib/html2rss/mcp/outcome.rb +18 -17
- data/lib/html2rss/mcp/runtime.rb +53 -23
- data/lib/html2rss/mcp/server/tools.rb +9 -9
- data/lib/html2rss/mcp/server.rb +13 -4
- data/lib/html2rss/option.rb +48 -0
- data/lib/html2rss/page_recon/README.md +1 -1
- data/lib/html2rss/page_recon/diagnostics.rb +9 -9
- data/lib/html2rss/page_recon.rb +0 -4
- data/lib/html2rss/recon.rb +19 -23
- data/lib/html2rss/request_service/blocked_surface.rb +5 -8
- data/lib/html2rss/request_service/botasaurus_contract.rb +25 -3
- data/lib/html2rss/request_service/botasaurus_strategy.rb +44 -12
- data/lib/html2rss/request_service/budget.rb +3 -24
- data/lib/html2rss/request_service/compressed_body.rb +36 -48
- data/lib/html2rss/request_service/context.rb +16 -17
- data/lib/html2rss/request_service/cookie_jar.rb +89 -0
- data/lib/html2rss/request_service/cookie_store.rb +79 -0
- data/lib/html2rss/request_service/feed_advertisement.rb +72 -0
- data/lib/html2rss/request_service/httpx_strategy.rb +7 -3
- data/lib/html2rss/request_service/local_file_strategy.rb +2 -3
- data/lib/html2rss/request_service/network_guard.rb +3 -2
- data/lib/html2rss/request_service/policy.rb +55 -36
- data/lib/html2rss/request_service/replay_breaker.rb +56 -0
- data/lib/html2rss/request_service/response.rb +33 -19
- data/lib/html2rss/request_service/response_cache.rb +109 -0
- data/lib/html2rss/request_service/response_caching.rb +95 -0
- data/lib/html2rss/request_service/strategy.rb +5 -11
- data/lib/html2rss/request_service.rb +71 -12
- data/lib/html2rss/request_session/pager/base.rb +3 -10
- data/lib/html2rss/request_session/pager/custom_selector.rb +4 -4
- data/lib/html2rss/request_session/pager/offset.rb +1 -7
- data/lib/html2rss/request_session/pager/rel_next.rb +1 -1
- data/lib/html2rss/request_session/pager/url_template.rb +1 -7
- data/lib/html2rss/request_session/pager.rb +1 -1
- data/lib/html2rss/request_session.rb +6 -2
- data/lib/html2rss/scoring/cluster_scorer.rb +1 -1
- data/lib/html2rss/scoring/container_assessor.rb +12 -13
- data/lib/html2rss/scoring/engine.rb +10 -38
- data/lib/html2rss/scoring/link_resolver.rb +2 -7
- data/lib/html2rss/scoring/observation.rb +2 -2
- data/lib/html2rss/selector_candidates.rb +19 -8
- data/lib/html2rss/selectors/README.md +1 -1
- data/lib/html2rss/selectors/extractors/href.rb +2 -4
- data/lib/html2rss/selectors/json_xml.rb +33 -34
- data/lib/html2rss/selectors/option_spec.rb +4 -1
- data/lib/html2rss/selectors/post_processors/base.rb +0 -8
- data/lib/html2rss/selectors/post_processors/gsub.rb +9 -10
- data/lib/html2rss/selectors/post_processors/html_transformers/keep_playable_media.rb +50 -0
- data/lib/html2rss/selectors/post_processors/html_transformers/transform_urls_to_absolute_ones.rb +1 -3
- data/lib/html2rss/selectors/post_processors/parse_time.rb +5 -6
- data/lib/html2rss/selectors/post_processors/parse_uri.rb +1 -1
- data/lib/html2rss/selectors/post_processors/sanitize_html.rb +18 -5
- data/lib/html2rss/selectors/post_processors/substring.rb +1 -1
- data/lib/html2rss/selectors/schema_export.rb +60 -60
- data/lib/html2rss/selectors.rb +67 -30
- data/lib/html2rss/sst/attrs.rb +2 -2
- data/lib/html2rss/sst/document.rb +3 -3
- data/lib/html2rss/sst/index.rb +30 -11
- data/lib/html2rss/sst/node.rb +42 -29
- data/lib/html2rss/sst/normalizer.rb +47 -34
- data/lib/html2rss/sst/tags.rb +113 -2
- data/lib/html2rss/sst/text.rb +94 -58
- data/lib/html2rss/sst.rb +2 -0
- data/lib/html2rss/status.rb +27 -31
- data/lib/html2rss/surface_category.rb +1 -1
- data/lib/html2rss/syndication/README.md +12 -6
- data/lib/html2rss/syndication/alternate_links.rb +39 -0
- data/lib/html2rss/syndication/channel_facts.rb +87 -0
- data/lib/html2rss/syndication/discovery.rb +171 -187
- data/lib/html2rss/syndication/feed.rb +20 -0
- data/lib/html2rss/syndication/item_media.rb +103 -0
- data/lib/html2rss/syndication/parser.rb +117 -116
- data/lib/html2rss/test/enhance_audit.rb +15 -17
- data/lib/html2rss/test/item_sample.rb +43 -0
- data/lib/html2rss/test/policy.rb +9 -11
- data/lib/html2rss/test.rb +66 -57
- data/lib/html2rss/url.rb +163 -62
- data/lib/html2rss/version.rb +1 -1
- data/lib/html2rss/word_count.rb +53 -0
- data/lib/html2rss.rb +19 -9
- data/schema/html2rss-config.schema.json +56 -41
- metadata +33 -4
- data/lib/html2rss/html/rendering/media_renderer.rb +0 -39
- data/lib/html2rss/html/rendering/pdf_renderer.rb +0 -30
|
@@ -24,6 +24,7 @@ module Html2rss
|
|
|
24
24
|
|
|
25
25
|
class << self
|
|
26
26
|
# @param parsed_body [Nokogiri::HTML::Document, nil] parsed HTML document
|
|
27
|
+
# @return [Boolean] whether any supported Microdata root exists
|
|
27
28
|
def articles?(parsed_body)
|
|
28
29
|
supported_roots(parsed_body).any?
|
|
29
30
|
end
|
|
@@ -37,6 +38,7 @@ module Html2rss
|
|
|
37
38
|
end
|
|
38
39
|
|
|
39
40
|
# @param node [Nokogiri::XML::Element] itemscope candidate node
|
|
41
|
+
# @return [Boolean] whether the node is a supported top-level root
|
|
40
42
|
def supported_root?(node)
|
|
41
43
|
supported_type_name(node) && top_level_item?(node)
|
|
42
44
|
end
|
|
@@ -59,6 +61,7 @@ module Html2rss
|
|
|
59
61
|
end
|
|
60
62
|
|
|
61
63
|
# @param node [Nokogiri::XML::Element] itemscope candidate node
|
|
64
|
+
# @return [Boolean] whether the node is not nested in another item property
|
|
62
65
|
def top_level_item?(node)
|
|
63
66
|
return false if node.attribute('itemprop')
|
|
64
67
|
|
|
@@ -78,10 +81,8 @@ module Html2rss
|
|
|
78
81
|
# @param parsed_body [Nokogiri::HTML5::Document, Nokogiri::HTML4::Document, Nokogiri::XML::Node, nil]
|
|
79
82
|
# the parsed response body to inspect for top-level Microdata items.
|
|
80
83
|
# @param url [Html2rss::Url] the absolute page URL used to resolve relative links.
|
|
81
|
-
# @param _opts [Hash] unused scraper-specific options.
|
|
82
|
-
# @option _opts [Object] :_reserved reserved for future scraper-specific options
|
|
83
84
|
# @return [void]
|
|
84
|
-
def initialize(parsed_body, url
|
|
85
|
+
def initialize(parsed_body, url:)
|
|
85
86
|
@parsed_body = parsed_body
|
|
86
87
|
@url = url
|
|
87
88
|
end
|
|
@@ -111,8 +112,6 @@ module Html2rss
|
|
|
111
112
|
# @return [Hash{Symbol => Object, nil}] normalized article hash
|
|
112
113
|
def article_from(root)
|
|
113
114
|
schema_object = SchemaObjectBuilder.call(root)
|
|
114
|
-
return unless schema_object
|
|
115
|
-
|
|
116
115
|
article = Schema::Thing.new(schema_object, url:).call.compact
|
|
117
116
|
return unless valid_article?(article)
|
|
118
117
|
|
|
@@ -129,265 +128,246 @@ module Html2rss
|
|
|
129
128
|
|
|
130
129
|
# Extracts direct Microdata itemprop values for a single item root.
|
|
131
130
|
module ItemParser
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
131
|
+
class << self
|
|
132
|
+
# @param root [Nokogiri::XML::Element] microdata root node
|
|
133
|
+
# @return [Hash{Symbol => Object}] extracted direct properties
|
|
134
|
+
def call(root)
|
|
135
|
+
{}.tap do |properties|
|
|
136
|
+
direct_properties(root).each { append_properties!(properties, _1) }
|
|
137
|
+
end
|
|
139
138
|
end
|
|
140
|
-
end
|
|
141
139
|
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
140
|
+
# @param properties [Hash{Symbol => Object}] accumulator hash for parsed properties
|
|
141
|
+
# @param node [Nokogiri::XML::Element] itemprop node
|
|
142
|
+
# @return [void]
|
|
143
|
+
def append_properties!(properties, node)
|
|
144
|
+
value = property_value(node)
|
|
145
|
+
return if blank_value?(value)
|
|
148
146
|
|
|
149
|
-
|
|
150
|
-
|
|
147
|
+
property_names(node).each do |name|
|
|
148
|
+
append(properties, name.to_sym, value)
|
|
149
|
+
end
|
|
151
150
|
end
|
|
152
|
-
end
|
|
153
|
-
|
|
154
|
-
# @param root [Nokogiri::XML::Element] microdata root node
|
|
155
|
-
# @return [Array<Nokogiri::XML::Element>] direct property nodes for the root
|
|
156
|
-
def direct_properties(root)
|
|
157
|
-
root.css('[itemprop]').select { direct_property?(root, _1) }
|
|
158
|
-
end
|
|
159
|
-
|
|
160
|
-
# @param root [Nokogiri::XML::Element] microdata root node
|
|
161
|
-
# @param node [Nokogiri::XML::Element] candidate itemprop node
|
|
162
|
-
# @return [Boolean] whether the node belongs directly to the current root item
|
|
163
|
-
def direct_property?(root, node)
|
|
164
|
-
return false if node == root
|
|
165
151
|
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
152
|
+
# @param root [Nokogiri::XML::Element] microdata root node
|
|
153
|
+
# @return [Array<Nokogiri::XML::Element>] direct property nodes for the root
|
|
154
|
+
def direct_properties(root)
|
|
155
|
+
root.css('[itemprop]').select { direct_property?(root, _1) }
|
|
156
|
+
end
|
|
169
157
|
|
|
170
|
-
|
|
158
|
+
# @param root [Nokogiri::XML::Element] microdata root node
|
|
159
|
+
# @param node [Nokogiri::XML::Element] candidate itemprop node
|
|
160
|
+
# @return [Boolean] whether the node belongs directly to the current root item
|
|
161
|
+
def direct_property?(root, node)
|
|
162
|
+
curr = node.parent
|
|
163
|
+
while curr && curr != root
|
|
164
|
+
return false if curr.attribute('itemscope')
|
|
165
|
+
|
|
166
|
+
curr = curr.parent
|
|
167
|
+
end
|
|
168
|
+
true
|
|
171
169
|
end
|
|
172
|
-
true
|
|
173
|
-
end
|
|
174
170
|
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
171
|
+
# @param node [Nokogiri::XML::Element] itemprop node
|
|
172
|
+
# @return [Array<String>] normalized property names
|
|
173
|
+
def property_names(node)
|
|
174
|
+
node['itemprop'].to_s.split.filter_map do |name|
|
|
175
|
+
stripped = name.strip
|
|
176
|
+
stripped unless stripped.empty?
|
|
177
|
+
end
|
|
181
178
|
end
|
|
182
|
-
end
|
|
183
179
|
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
180
|
+
# @param node [Nokogiri::XML::Element] itemprop node
|
|
181
|
+
# @return [Object, nil] parsed property value
|
|
182
|
+
def property_value(node)
|
|
183
|
+
value = if node.attribute('itemscope')
|
|
184
|
+
nested_item(node)
|
|
185
|
+
else
|
|
186
|
+
attribute_value(node) || text_value(node)
|
|
187
|
+
end
|
|
192
188
|
|
|
193
|
-
|
|
194
|
-
|
|
189
|
+
value unless blank_value?(value)
|
|
190
|
+
end
|
|
195
191
|
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
192
|
+
# @param node [Nokogiri::XML::Element] nested itemscope node
|
|
193
|
+
# @return [Hash{Symbol => Object}] nested parsed microdata item
|
|
194
|
+
def nested_item(node)
|
|
195
|
+
item = call(node)
|
|
196
|
+
itemtype = node['itemtype']
|
|
197
|
+
itemid = node['itemid']
|
|
198
|
+
item[:@type] = Microdata.itemtype_type_names(itemtype).first if itemtype
|
|
199
|
+
item[:@id] = itemid if present?(itemid)
|
|
200
|
+
item
|
|
201
|
+
end
|
|
206
202
|
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
203
|
+
# @param node [Nokogiri::XML::Element] itemprop node
|
|
204
|
+
# @return [String, nil] first present attribute value
|
|
205
|
+
def attribute_value(node)
|
|
206
|
+
VALUE_ATTRIBUTES.each do |attribute|
|
|
207
|
+
value = node[attribute]
|
|
208
|
+
return value if present?(value)
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
nil
|
|
213
212
|
end
|
|
214
213
|
|
|
215
|
-
|
|
216
|
-
|
|
214
|
+
# @param node [Nokogiri::XML::Element] itemprop node
|
|
215
|
+
# @return [String, nil] normalized text content
|
|
216
|
+
def text_value(node)
|
|
217
|
+
value = node.text.to_s.strip
|
|
218
|
+
value unless value.empty?
|
|
219
|
+
end
|
|
217
220
|
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
221
|
+
# @param properties [Hash{Symbol => Object}] accumulator hash for parsed properties
|
|
222
|
+
# @param key [Symbol] target property key
|
|
223
|
+
# @param value [Object] parsed property value to assign for the key
|
|
224
|
+
# @return [void]
|
|
225
|
+
def append(properties, key, value)
|
|
226
|
+
return if blank_value?(value)
|
|
224
227
|
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
def append(properties, key, value)
|
|
230
|
-
return if blank_value?(value)
|
|
228
|
+
unless properties.key?(key)
|
|
229
|
+
properties[key] = value
|
|
230
|
+
return
|
|
231
|
+
end
|
|
231
232
|
|
|
232
|
-
|
|
233
|
-
properties[key] = value
|
|
234
|
-
return
|
|
233
|
+
existing = properties[key]
|
|
234
|
+
properties[key] = existing.is_a?(Array) ? existing + [value] : [existing, value]
|
|
235
235
|
end
|
|
236
236
|
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
when Array, Hash then value.empty?
|
|
247
|
-
else false
|
|
237
|
+
# @param value [Object] candidate value
|
|
238
|
+
# @return [Boolean] whether value is blank for microdata extraction purposes
|
|
239
|
+
def blank_value?(value)
|
|
240
|
+
case value
|
|
241
|
+
when nil then true
|
|
242
|
+
when String then value.strip.empty?
|
|
243
|
+
when Array, Hash then value.empty?
|
|
244
|
+
else false
|
|
245
|
+
end
|
|
248
246
|
end
|
|
249
|
-
end
|
|
250
247
|
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
248
|
+
# @param value [Object] candidate value
|
|
249
|
+
# @return [Boolean] whether value is present for microdata extraction purposes
|
|
250
|
+
def present?(value)
|
|
251
|
+
!blank_value?(value)
|
|
252
|
+
end
|
|
255
253
|
end
|
|
256
254
|
end
|
|
257
255
|
private_constant :ItemParser
|
|
258
256
|
|
|
259
257
|
# Shared value normalization helpers for Microdata property conversion.
|
|
260
258
|
module ValueNormalizer
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
259
|
+
class << self
|
|
260
|
+
# @param values [Array<Object>] value candidates
|
|
261
|
+
# @return [String, nil] first URL-like value converted to string
|
|
262
|
+
def url_value(*values)
|
|
263
|
+
values.each do |value|
|
|
264
|
+
candidate = extract_nested_value(value, :url, :@id)
|
|
265
|
+
return candidate.to_s if present?(candidate)
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
nil
|
|
269
269
|
end
|
|
270
270
|
|
|
271
|
-
|
|
272
|
-
|
|
271
|
+
# @param values [Array<Object>] value candidates
|
|
272
|
+
# @return [String, Hash, nil] first normalized image candidate
|
|
273
|
+
def image_value(*values)
|
|
274
|
+
values.each do |value|
|
|
275
|
+
candidate = normalize_image(value)
|
|
276
|
+
return candidate if present?(candidate)
|
|
277
|
+
end
|
|
273
278
|
|
|
274
|
-
|
|
275
|
-
# @return [String, Hash, nil] first normalized image candidate
|
|
276
|
-
def image_value(*values)
|
|
277
|
-
values.each do |value|
|
|
278
|
-
candidate = normalize_image(value)
|
|
279
|
-
return candidate if present?(candidate)
|
|
279
|
+
nil
|
|
280
280
|
end
|
|
281
281
|
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
def normalize_image(value)
|
|
288
|
-
candidate = unwrap(value)
|
|
289
|
-
return unless present?(candidate)
|
|
290
|
-
|
|
291
|
-
return candidate if candidate.is_a?(String) || candidate.is_a?(Hash)
|
|
292
|
-
|
|
293
|
-
candidate.to_s
|
|
294
|
-
end
|
|
295
|
-
|
|
296
|
-
# @param value [Object] about candidate value
|
|
297
|
-
# @return [Array<String, Hash>, nil] normalized about values
|
|
298
|
-
def normalize_about(value)
|
|
299
|
-
candidate = unwrap(value)
|
|
300
|
-
items = candidate.is_a?(Array) ? candidate : [candidate]
|
|
301
|
-
values = items.filter_map { normalize_about_item(_1) }
|
|
302
|
-
values unless values.empty?
|
|
303
|
-
end
|
|
304
|
-
|
|
305
|
-
# @param item [Object] single about item
|
|
306
|
-
# @return [String, Hash, nil] normalized about item
|
|
307
|
-
def normalize_about_item(item)
|
|
308
|
-
case item
|
|
309
|
-
when Hash
|
|
310
|
-
name = item[:name]
|
|
311
|
-
{ name: name.to_s } if name
|
|
312
|
-
when String then item
|
|
282
|
+
# @param value [Object] image candidate value
|
|
283
|
+
# @return [String, Hash, nil] normalized image-like value
|
|
284
|
+
def normalize_image(value)
|
|
285
|
+
candidate = unwrap(value)
|
|
286
|
+
candidate.is_a?(Hash) ? candidate : candidate.to_s
|
|
313
287
|
end
|
|
314
|
-
end
|
|
315
288
|
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
289
|
+
# @param value [Object] about candidate value
|
|
290
|
+
# @return [Array<String, Hash>, nil] normalized about values
|
|
291
|
+
def normalize_about(value)
|
|
292
|
+
items = value.is_a?(Array) ? value : [value]
|
|
293
|
+
values = items.filter_map { normalize_about_item(_1) }
|
|
294
|
+
values unless values.empty?
|
|
295
|
+
end
|
|
321
296
|
|
|
322
|
-
|
|
297
|
+
# @param item [Object] single about item
|
|
298
|
+
# @return [String, Hash, nil] normalized about item
|
|
299
|
+
def normalize_about_item(item)
|
|
300
|
+
case item
|
|
301
|
+
when Hash
|
|
302
|
+
name = item[:name]
|
|
303
|
+
{ name: name.to_s } if name
|
|
304
|
+
when String then item
|
|
305
|
+
end
|
|
306
|
+
end
|
|
323
307
|
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
308
|
+
# @param values [Array<Object>] value candidates
|
|
309
|
+
# @return [Array<String>, nil] normalized unique string values
|
|
310
|
+
def array_value(*values)
|
|
311
|
+
result = values.flat_map { string_values(Array(_1)) }.uniq
|
|
312
|
+
result unless result.empty?
|
|
313
|
+
end
|
|
327
314
|
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
end
|
|
315
|
+
# @param values [Array<Object>] candidate scalar values collected from microdata arrays
|
|
316
|
+
# @return [Array<String>] normalized string values
|
|
317
|
+
def string_values(values)
|
|
318
|
+
values.filter_map { stringify(_1) }
|
|
319
|
+
end
|
|
334
320
|
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
321
|
+
# @param values [Array<Object>] value candidates
|
|
322
|
+
# @return [String, nil] first present string-like value
|
|
323
|
+
def first_string(*values)
|
|
324
|
+
values.each do |value|
|
|
325
|
+
candidate = stringify(unwrap(value))
|
|
326
|
+
return candidate if present?(candidate)
|
|
327
|
+
end
|
|
340
328
|
|
|
341
|
-
|
|
342
|
-
# @return [String, nil] first present string-like value
|
|
343
|
-
def first_string(*values)
|
|
344
|
-
values.each do |value|
|
|
345
|
-
candidate = stringify(unwrap(value))
|
|
346
|
-
return candidate if present?(candidate)
|
|
329
|
+
nil
|
|
347
330
|
end
|
|
348
331
|
|
|
349
|
-
|
|
350
|
-
|
|
332
|
+
# @param value [Object] nested container or scalar
|
|
333
|
+
# @param keys [Array<Symbol>] nested keys to probe in order
|
|
334
|
+
# @return [Object, nil] first matching nested value
|
|
335
|
+
def extract_nested_value(value, *keys)
|
|
336
|
+
candidate = unwrap(value)
|
|
337
|
+
return candidate unless candidate.is_a?(Hash)
|
|
351
338
|
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
candidate = unwrap(value)
|
|
357
|
-
return candidate unless candidate.is_a?(Hash)
|
|
339
|
+
keys.each do |key|
|
|
340
|
+
nested_value = candidate[key]
|
|
341
|
+
return nested_value if present?(nested_value)
|
|
342
|
+
end
|
|
358
343
|
|
|
359
|
-
|
|
360
|
-
nested_value = candidate[key]
|
|
361
|
-
return nested_value if present?(nested_value)
|
|
344
|
+
nil
|
|
362
345
|
end
|
|
363
346
|
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
def unwrap(value)
|
|
370
|
-
value.is_a?(Array) ? value.first : value
|
|
371
|
-
end
|
|
347
|
+
# @param value [Object] scalar or array candidate
|
|
348
|
+
# @return [Object] first array element or the original value
|
|
349
|
+
def unwrap(value)
|
|
350
|
+
value.is_a?(Array) ? value.first : value
|
|
351
|
+
end
|
|
372
352
|
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
return if value.is_a?(Hash) || value.is_a?(Array)
|
|
353
|
+
# @param value [Object] scalar candidate normalized to string output
|
|
354
|
+
# @return [String, nil] normalized string representation
|
|
355
|
+
def stringify(value)
|
|
356
|
+
return unless present?(value)
|
|
357
|
+
return if value.is_a?(Hash) || value.is_a?(Array)
|
|
379
358
|
|
|
380
|
-
|
|
381
|
-
|
|
359
|
+
value.to_s
|
|
360
|
+
end
|
|
382
361
|
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
362
|
+
# @param value [Object] candidate value
|
|
363
|
+
# @return [Boolean] whether value is present
|
|
364
|
+
def present?(value)
|
|
365
|
+
case value
|
|
366
|
+
when nil then false
|
|
367
|
+
when String then !value.strip.empty?
|
|
368
|
+
when Array, Hash then !value.empty?
|
|
369
|
+
else true
|
|
370
|
+
end
|
|
391
371
|
end
|
|
392
372
|
end
|
|
393
373
|
end
|
|
@@ -395,24 +375,17 @@ module Html2rss
|
|
|
395
375
|
|
|
396
376
|
# Normalizes raw Microdata properties into the schema-like shape used downstream.
|
|
397
377
|
module SchemaObjectBuilder
|
|
398
|
-
module_function
|
|
399
|
-
|
|
400
|
-
extend ValueNormalizer
|
|
401
|
-
|
|
402
378
|
# @param root [Nokogiri::XML::Element] supported microdata root node
|
|
403
379
|
# @return [Hash{Symbol => Object, nil}] compact schema-like object
|
|
404
|
-
def call(root)
|
|
405
|
-
|
|
406
|
-
return unless type
|
|
407
|
-
|
|
408
|
-
compact_object(type, root, ItemParser.call(root))
|
|
380
|
+
def self.call(root)
|
|
381
|
+
compact_object(Microdata.supported_type_name(root), root, ItemParser.call(root))
|
|
409
382
|
end
|
|
410
383
|
|
|
411
384
|
# @param type [String] schema type inferred from itemtype
|
|
412
385
|
# @param root [Nokogiri::XML::Element] supported microdata root node
|
|
413
386
|
# @param properties [Hash{Symbol => Object}] parsed microdata properties
|
|
414
387
|
# @return [Hash{Symbol => Object}] normalized schema-like object
|
|
415
|
-
def compact_object(type, root, properties)
|
|
388
|
+
def self.compact_object(type, root, properties)
|
|
416
389
|
object = base_attributes(type, root, properties)
|
|
417
390
|
merge_categories!(object, properties)
|
|
418
391
|
object.compact
|
|
@@ -422,8 +395,8 @@ module Html2rss
|
|
|
422
395
|
# @param root [Nokogiri::XML::Element] supported microdata root node
|
|
423
396
|
# @param properties [Hash{Symbol => Object}] parsed microdata properties
|
|
424
397
|
# @return [Hash{Symbol => Object}] base schema attributes before category merging
|
|
425
|
-
def base_attributes(type, root, properties)
|
|
426
|
-
identifier = first_string(root['itemid'], properties.delete(:identifier))
|
|
398
|
+
def self.base_attributes(type, root, properties)
|
|
399
|
+
identifier = ValueNormalizer.first_string(root['itemid'], properties.delete(:identifier))
|
|
427
400
|
|
|
428
401
|
{
|
|
429
402
|
'@type': type,
|
|
@@ -431,22 +404,30 @@ module Html2rss
|
|
|
431
404
|
}.merge(text_attributes(properties))
|
|
432
405
|
.merge(link_attributes(properties, identifier))
|
|
433
406
|
.merge(media_attributes(properties))
|
|
407
|
+
.merge(people_attributes(properties))
|
|
408
|
+
end
|
|
409
|
+
|
|
410
|
+
# @param properties [Hash{Symbol => Object}] parsed microdata properties
|
|
411
|
+
# @return [Hash{Symbol => Object}] author and publisher (text or nested Person/Organization `{name:}`)
|
|
412
|
+
def self.people_attributes(properties)
|
|
413
|
+
{ author: properties.delete(:author), publisher: properties.delete(:publisher) }
|
|
434
414
|
end
|
|
435
415
|
|
|
436
416
|
# @param properties [Hash{Symbol => Object}] parsed microdata properties
|
|
437
417
|
# @return [String, nil] normalized title
|
|
438
|
-
def title(properties)
|
|
439
|
-
first_string(properties.delete(:headline), properties.delete(:title),
|
|
418
|
+
def self.title(properties)
|
|
419
|
+
ValueNormalizer.first_string(properties.delete(:headline), properties.delete(:title),
|
|
420
|
+
properties.delete(:name))
|
|
440
421
|
end
|
|
441
422
|
|
|
442
423
|
# @param properties [Hash{Symbol => Object}] parsed microdata properties
|
|
443
424
|
# @return [Hash{Symbol => Object}] normalized text attributes
|
|
444
|
-
def text_attributes(properties)
|
|
425
|
+
def self.text_attributes(properties)
|
|
445
426
|
{
|
|
446
427
|
title: title(properties),
|
|
447
|
-
description: first_string(properties.delete(:description)),
|
|
448
|
-
schema_object_body: first_string(properties.delete(:articleBody)),
|
|
449
|
-
abstract: first_string(properties.delete(:abstract)),
|
|
428
|
+
description: ValueNormalizer.first_string(properties.delete(:description)),
|
|
429
|
+
schema_object_body: ValueNormalizer.first_string(properties.delete(:articleBody)),
|
|
430
|
+
abstract: ValueNormalizer.first_string(properties.delete(:abstract)),
|
|
450
431
|
datePublished: published_at(properties)
|
|
451
432
|
}
|
|
452
433
|
end
|
|
@@ -454,7 +435,7 @@ module Html2rss
|
|
|
454
435
|
# @param properties [Hash{Symbol => Object}] parsed microdata properties
|
|
455
436
|
# @param identifier [String, nil] identifier candidate for fallback URL handling
|
|
456
437
|
# @return [Hash{Symbol => Object}] normalized link attributes
|
|
457
|
-
def link_attributes(properties, identifier)
|
|
438
|
+
def self.link_attributes(properties, identifier)
|
|
458
439
|
{
|
|
459
440
|
url: url(properties, identifier)
|
|
460
441
|
}
|
|
@@ -462,17 +443,21 @@ module Html2rss
|
|
|
462
443
|
|
|
463
444
|
# @param properties [Hash{Symbol => Object}] parsed microdata properties
|
|
464
445
|
# @return [Hash{Symbol => Object}] normalized media attributes
|
|
465
|
-
def media_attributes(properties)
|
|
446
|
+
def self.media_attributes(properties)
|
|
466
447
|
{
|
|
467
|
-
image: image_value(properties.delete(:image), properties.delete(:thumbnailUrl)
|
|
448
|
+
image: ValueNormalizer.image_value(properties.delete(:image), properties.delete(:thumbnailUrl),
|
|
449
|
+
properties.delete(:thumbnail)),
|
|
450
|
+
video: properties.delete(:video),
|
|
451
|
+
audio: properties.delete(:audio),
|
|
452
|
+
associatedMedia: properties.delete(:associatedMedia)
|
|
468
453
|
}
|
|
469
454
|
end
|
|
470
455
|
|
|
471
456
|
# @param properties [Hash{Symbol => Object}] parsed microdata properties
|
|
472
457
|
# @param fallback_id [String, nil] identifier candidate for fallback URL handling
|
|
473
458
|
# @return [String, nil] normalized URL candidate
|
|
474
|
-
def url(properties, fallback_id)
|
|
475
|
-
url_value(
|
|
459
|
+
def self.url(properties, fallback_id)
|
|
460
|
+
ValueNormalizer.url_value(
|
|
476
461
|
properties.delete(:url),
|
|
477
462
|
properties.delete(:mainEntityOfPage),
|
|
478
463
|
url_fallback(fallback_id)
|
|
@@ -481,8 +466,8 @@ module Html2rss
|
|
|
481
466
|
|
|
482
467
|
# @param fallback_id [String, nil] identifier candidate for fallback URL handling
|
|
483
468
|
# @return [String, nil] fallback URL candidate when identifier looks URL-like
|
|
484
|
-
def url_fallback(fallback_id)
|
|
485
|
-
value = first_string(fallback_id)
|
|
469
|
+
def self.url_fallback(fallback_id)
|
|
470
|
+
value = ValueNormalizer.first_string(fallback_id)
|
|
486
471
|
return unless value
|
|
487
472
|
return value if value.start_with?('/')
|
|
488
473
|
return value if value.match?(%r{\Ahttps?://})
|
|
@@ -492,8 +477,8 @@ module Html2rss
|
|
|
492
477
|
|
|
493
478
|
# @param properties [Hash{Symbol => Object}] parsed microdata properties
|
|
494
479
|
# @return [String, nil] normalized published-at value
|
|
495
|
-
def published_at(properties)
|
|
496
|
-
first_string(
|
|
480
|
+
def self.published_at(properties)
|
|
481
|
+
ValueNormalizer.first_string(
|
|
497
482
|
properties.delete(:datePublished),
|
|
498
483
|
properties.delete(:dateCreated),
|
|
499
484
|
properties.delete(:dateModified),
|
|
@@ -504,19 +489,19 @@ module Html2rss
|
|
|
504
489
|
# @param object [Hash{Symbol => Object}] schema-like output object
|
|
505
490
|
# @param properties [Hash{Symbol => Object}] parsed microdata properties
|
|
506
491
|
# @return [void]
|
|
507
|
-
def merge_categories!(object, properties)
|
|
508
|
-
categories = array_value(properties.delete(:categories), properties.delete(:articleSection))
|
|
492
|
+
def self.merge_categories!(object, properties)
|
|
493
|
+
categories = ValueNormalizer.array_value(properties.delete(:categories), properties.delete(:articleSection))
|
|
509
494
|
assign_if_present(object, :categories, categories)
|
|
510
|
-
assign_if_present(object, :keywords,
|
|
511
|
-
assign_if_present(object, :tags,
|
|
512
|
-
assign_if_present(object, :about, normalize_about(properties.delete(:about)))
|
|
495
|
+
assign_if_present(object, :keywords, ValueNormalizer.array_value(properties.delete(:keywords)))
|
|
496
|
+
assign_if_present(object, :tags, ValueNormalizer.array_value(properties.delete(:tags)))
|
|
497
|
+
assign_if_present(object, :about, ValueNormalizer.normalize_about(properties.delete(:about)))
|
|
513
498
|
end
|
|
514
499
|
|
|
515
500
|
# @param object [Hash{Symbol => Object}] schema-like output object
|
|
516
501
|
# @param key [Symbol] target attribute key
|
|
517
502
|
# @param value [Object] value to assign when present
|
|
518
503
|
# @return [void]
|
|
519
|
-
def assign_if_present(object, key, value)
|
|
504
|
+
def self.assign_if_present(object, key, value)
|
|
520
505
|
object[key] = value if value
|
|
521
506
|
end
|
|
522
507
|
end
|