html2rss 0.32.3 → 0.33.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/lib/html2rss/article/enclosure.rb +2 -6
- data/lib/html2rss/auto_source/cleanup.rb +1 -3
- data/lib/html2rss/auto_source/scraper/html.rb +2 -8
- data/lib/html2rss/auto_source/scraper/json_document.rb +2 -3
- data/lib/html2rss/auto_source/scraper/json_state/article_normalizer.rb +128 -128
- data/lib/html2rss/auto_source/scraper/json_state/candidate_detector.rb +37 -37
- data/lib/html2rss/auto_source/scraper/json_state/document_scanner.rb +123 -123
- data/lib/html2rss/auto_source/scraper/json_state/value_finder.rb +34 -34
- data/lib/html2rss/auto_source/scraper/json_state.rb +2 -6
- data/lib/html2rss/auto_source/scraper/meta_oembed.rb +32 -2
- data/lib/html2rss/auto_source/scraper/microdata.rb +228 -245
- data/lib/html2rss/auto_source/scraper/microformats2.rb +9 -1
- data/lib/html2rss/auto_source/scraper/native_feed.rb +1 -1
- data/lib/html2rss/auto_source/scraper/schema/category_extractor.rb +7 -2
- data/lib/html2rss/auto_source/scraper/schema/item_list.rb +2 -2
- data/lib/html2rss/auto_source/scraper/schema/thing.rb +98 -21
- data/lib/html2rss/auto_source/scraper/schema.rb +49 -2
- data/lib/html2rss/auto_source/scraper/semantic_html.rb +12 -16
- data/lib/html2rss/auto_source/scraper/sitemap/parser.rb +1 -4
- data/lib/html2rss/auto_source/scraper/sitemap.rb +3 -11
- 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 +4 -4
- data/lib/html2rss/auto_source/scraper/xhr_articles.rb +1 -1
- data/lib/html2rss/auto_source/scraper.rb +0 -11
- data/lib/html2rss/auto_source/segmenter/cluster.rb +5 -11
- data/lib/html2rss/auto_source/segmenter/list.rb +25 -57
- data/lib/html2rss/auto_source/segmenter/semantic.rb +6 -13
- data/lib/html2rss/auto_source.rb +1 -1
- data/lib/html2rss/batch.rb +22 -32
- data/lib/html2rss/capture/README.md +1 -1
- data/lib/html2rss/capture.rb +7 -27
- data/lib/html2rss/cli/render.rb +97 -96
- data/lib/html2rss/cli/validate.rb +2 -4
- data/lib/html2rss/cli.rb +33 -27
- data/lib/html2rss/config/request_controls.rb +10 -2
- data/lib/html2rss/config/schema.rb +8 -10
- data/lib/html2rss/config/selectors_validator.rb +1 -0
- data/lib/html2rss/config/validator.rb +3 -3
- data/lib/html2rss/config.rb +1 -1
- data/lib/html2rss/defaults.rb +5 -16
- data/lib/html2rss/doctor/botasaurus.rb +11 -19
- data/lib/html2rss/error.rb +54 -1
- data/lib/html2rss/feed_builder/rss.rb +1 -1
- data/lib/html2rss/feed_pipeline/README.md +1 -1
- data/lib/html2rss/feed_pipeline/auto_fallback.rb +58 -55
- data/lib/html2rss/feed_resolution/candidate_generator.rb +3 -4
- 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 +3 -3
- data/lib/html2rss/feed_resolution/scorer.rb +29 -29
- data/lib/html2rss/feed_resolution.rb +5 -9
- data/lib/html2rss/feed_result.rb +4 -3
- data/lib/html2rss/hash_util.rb +91 -90
- data/lib/html2rss/html/article_extractor/category_extractor.rb +6 -9
- data/lib/html2rss/html/article_extractor/date_extractor.rb +1 -1
- data/lib/html2rss/html/article_extractor/enclosure_extractor.rb +1 -3
- data/lib/html2rss/html/article_extractor/id_generator.rb +1 -1
- data/lib/html2rss/html/probe.rb +51 -51
- data/lib/html2rss/link_destination/path_classifier.rb +11 -33
- data/lib/html2rss/mcp/config_argument.rb +9 -2
- data/lib/html2rss/mcp/contract.rb +7 -5
- data/lib/html2rss/mcp/outcome.rb +4 -5
- data/lib/html2rss/mcp/runtime.rb +17 -24
- data/lib/html2rss/mcp/server/tools.rb +9 -9
- data/lib/html2rss/option.rb +48 -0
- data/lib/html2rss/page_recon/diagnostics.rb +7 -9
- data/lib/html2rss/page_recon.rb +0 -4
- data/lib/html2rss/recon.rb +10 -12
- data/lib/html2rss/request_service/blocked_surface.rb +3 -7
- data/lib/html2rss/request_service/botasaurus_strategy.rb +2 -2
- 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 +1 -3
- data/lib/html2rss/request_service/local_file_strategy.rb +3 -1
- data/lib/html2rss/request_service/network_guard.rb +3 -2
- data/lib/html2rss/request_service/policy.rb +8 -26
- data/lib/html2rss/request_service/response.rb +3 -6
- data/lib/html2rss/request_service/strategy.rb +2 -2
- data/lib/html2rss/request_service.rb +26 -2
- data/lib/html2rss/request_session/pager/base.rb +3 -3
- 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/scoring/engine.rb +4 -32
- data/lib/html2rss/scoring/link_resolver.rb +2 -7
- data/lib/html2rss/selectors/extractors/href.rb +2 -4
- data/lib/html2rss/selectors/json_xml.rb +33 -34
- data/lib/html2rss/selectors/post_processors/gsub.rb +2 -3
- data/lib/html2rss/selectors/post_processors/parse_time.rb +4 -5
- data/lib/html2rss/selectors/schema_export.rb +60 -60
- data/lib/html2rss/selectors.rb +14 -18
- data/lib/html2rss/sst/node.rb +9 -3
- data/lib/html2rss/sst/normalizer.rb +25 -24
- data/lib/html2rss/sst/text.rb +53 -57
- data/lib/html2rss/status.rb +23 -30
- data/lib/html2rss/syndication/alternate_links.rb +39 -0
- data/lib/html2rss/syndication/discovery.rb +117 -187
- data/lib/html2rss/syndication/parser.rb +92 -115
- data/lib/html2rss/test/enhance_audit.rb +13 -15
- data/lib/html2rss/test/policy.rb +9 -11
- data/lib/html2rss/test.rb +29 -48
- data/lib/html2rss/url.rb +53 -53
- data/lib/html2rss/version.rb +1 -1
- data/lib/html2rss.rb +2 -1
- metadata +4 -2
|
@@ -111,8 +111,6 @@ module Html2rss
|
|
|
111
111
|
# @return [Hash{Symbol => Object, nil}] normalized article hash
|
|
112
112
|
def article_from(root)
|
|
113
113
|
schema_object = SchemaObjectBuilder.call(root)
|
|
114
|
-
return unless schema_object
|
|
115
|
-
|
|
116
114
|
article = Schema::Thing.new(schema_object, url:).call.compact
|
|
117
115
|
return unless valid_article?(article)
|
|
118
116
|
|
|
@@ -129,265 +127,246 @@ module Html2rss
|
|
|
129
127
|
|
|
130
128
|
# Extracts direct Microdata itemprop values for a single item root.
|
|
131
129
|
module ItemParser
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
130
|
+
class << self
|
|
131
|
+
# @param root [Nokogiri::XML::Element] microdata root node
|
|
132
|
+
# @return [Hash{Symbol => Object}] extracted direct properties
|
|
133
|
+
def call(root)
|
|
134
|
+
{}.tap do |properties|
|
|
135
|
+
direct_properties(root).each { append_properties!(properties, _1) }
|
|
136
|
+
end
|
|
139
137
|
end
|
|
140
|
-
end
|
|
141
138
|
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
139
|
+
# @param properties [Hash{Symbol => Object}] accumulator hash for parsed properties
|
|
140
|
+
# @param node [Nokogiri::XML::Element] itemprop node
|
|
141
|
+
# @return [void]
|
|
142
|
+
def append_properties!(properties, node)
|
|
143
|
+
value = property_value(node)
|
|
144
|
+
return if blank_value?(value)
|
|
148
145
|
|
|
149
|
-
|
|
150
|
-
|
|
146
|
+
property_names(node).each do |name|
|
|
147
|
+
append(properties, name.to_sym, value)
|
|
148
|
+
end
|
|
151
149
|
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
150
|
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
151
|
+
# @param root [Nokogiri::XML::Element] microdata root node
|
|
152
|
+
# @return [Array<Nokogiri::XML::Element>] direct property nodes for the root
|
|
153
|
+
def direct_properties(root)
|
|
154
|
+
root.css('[itemprop]').select { direct_property?(root, _1) }
|
|
155
|
+
end
|
|
169
156
|
|
|
170
|
-
|
|
157
|
+
# @param root [Nokogiri::XML::Element] microdata root node
|
|
158
|
+
# @param node [Nokogiri::XML::Element] candidate itemprop node
|
|
159
|
+
# @return [Boolean] whether the node belongs directly to the current root item
|
|
160
|
+
def direct_property?(root, node)
|
|
161
|
+
curr = node.parent
|
|
162
|
+
while curr && curr != root
|
|
163
|
+
return false if curr.attribute('itemscope')
|
|
164
|
+
|
|
165
|
+
curr = curr.parent
|
|
166
|
+
end
|
|
167
|
+
true
|
|
171
168
|
end
|
|
172
|
-
true
|
|
173
|
-
end
|
|
174
169
|
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
170
|
+
# @param node [Nokogiri::XML::Element] itemprop node
|
|
171
|
+
# @return [Array<String>] normalized property names
|
|
172
|
+
def property_names(node)
|
|
173
|
+
node['itemprop'].to_s.split.filter_map do |name|
|
|
174
|
+
stripped = name.strip
|
|
175
|
+
stripped unless stripped.empty?
|
|
176
|
+
end
|
|
181
177
|
end
|
|
182
|
-
end
|
|
183
178
|
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
179
|
+
# @param node [Nokogiri::XML::Element] itemprop node
|
|
180
|
+
# @return [Object, nil] parsed property value
|
|
181
|
+
def property_value(node)
|
|
182
|
+
value = if node.attribute('itemscope')
|
|
183
|
+
nested_item(node)
|
|
184
|
+
else
|
|
185
|
+
attribute_value(node) || text_value(node)
|
|
186
|
+
end
|
|
192
187
|
|
|
193
|
-
|
|
194
|
-
|
|
188
|
+
value unless blank_value?(value)
|
|
189
|
+
end
|
|
195
190
|
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
191
|
+
# @param node [Nokogiri::XML::Element] nested itemscope node
|
|
192
|
+
# @return [Hash{Symbol => Object}] nested parsed microdata item
|
|
193
|
+
def nested_item(node)
|
|
194
|
+
item = call(node)
|
|
195
|
+
itemtype = node['itemtype']
|
|
196
|
+
itemid = node['itemid']
|
|
197
|
+
item[:@type] = Microdata.itemtype_type_names(itemtype).first if itemtype
|
|
198
|
+
item[:@id] = itemid if present?(itemid)
|
|
199
|
+
item
|
|
200
|
+
end
|
|
206
201
|
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
202
|
+
# @param node [Nokogiri::XML::Element] itemprop node
|
|
203
|
+
# @return [String, nil] first present attribute value
|
|
204
|
+
def attribute_value(node)
|
|
205
|
+
VALUE_ATTRIBUTES.each do |attribute|
|
|
206
|
+
value = node[attribute]
|
|
207
|
+
return value if present?(value)
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
nil
|
|
213
211
|
end
|
|
214
212
|
|
|
215
|
-
|
|
216
|
-
|
|
213
|
+
# @param node [Nokogiri::XML::Element] itemprop node
|
|
214
|
+
# @return [String, nil] normalized text content
|
|
215
|
+
def text_value(node)
|
|
216
|
+
value = node.text.to_s.strip
|
|
217
|
+
value unless value.empty?
|
|
218
|
+
end
|
|
217
219
|
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
220
|
+
# @param properties [Hash{Symbol => Object}] accumulator hash for parsed properties
|
|
221
|
+
# @param key [Symbol] target property key
|
|
222
|
+
# @param value [Object] parsed property value to assign for the key
|
|
223
|
+
# @return [void]
|
|
224
|
+
def append(properties, key, value)
|
|
225
|
+
return if blank_value?(value)
|
|
224
226
|
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
def append(properties, key, value)
|
|
230
|
-
return if blank_value?(value)
|
|
227
|
+
unless properties.key?(key)
|
|
228
|
+
properties[key] = value
|
|
229
|
+
return
|
|
230
|
+
end
|
|
231
231
|
|
|
232
|
-
|
|
233
|
-
properties[key] = value
|
|
234
|
-
return
|
|
232
|
+
existing = properties[key]
|
|
233
|
+
properties[key] = existing.is_a?(Array) ? existing + [value] : [existing, value]
|
|
235
234
|
end
|
|
236
235
|
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
when Array, Hash then value.empty?
|
|
247
|
-
else false
|
|
236
|
+
# @param value [Object] candidate value
|
|
237
|
+
# @return [Boolean] whether value is blank for microdata extraction purposes
|
|
238
|
+
def blank_value?(value)
|
|
239
|
+
case value
|
|
240
|
+
when nil then true
|
|
241
|
+
when String then value.strip.empty?
|
|
242
|
+
when Array, Hash then value.empty?
|
|
243
|
+
else false
|
|
244
|
+
end
|
|
248
245
|
end
|
|
249
|
-
end
|
|
250
246
|
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
247
|
+
# @param value [Object] candidate value
|
|
248
|
+
# @return [Boolean] whether value is present for microdata extraction purposes
|
|
249
|
+
def present?(value)
|
|
250
|
+
!blank_value?(value)
|
|
251
|
+
end
|
|
255
252
|
end
|
|
256
253
|
end
|
|
257
254
|
private_constant :ItemParser
|
|
258
255
|
|
|
259
256
|
# Shared value normalization helpers for Microdata property conversion.
|
|
260
257
|
module ValueNormalizer
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
258
|
+
class << self
|
|
259
|
+
# @param values [Array<Object>] value candidates
|
|
260
|
+
# @return [String, nil] first URL-like value converted to string
|
|
261
|
+
def url_value(*values)
|
|
262
|
+
values.each do |value|
|
|
263
|
+
candidate = extract_nested_value(value, :url, :@id)
|
|
264
|
+
return candidate.to_s if present?(candidate)
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
nil
|
|
269
268
|
end
|
|
270
269
|
|
|
271
|
-
|
|
272
|
-
|
|
270
|
+
# @param values [Array<Object>] value candidates
|
|
271
|
+
# @return [String, Hash, nil] first normalized image candidate
|
|
272
|
+
def image_value(*values)
|
|
273
|
+
values.each do |value|
|
|
274
|
+
candidate = normalize_image(value)
|
|
275
|
+
return candidate if present?(candidate)
|
|
276
|
+
end
|
|
273
277
|
|
|
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)
|
|
278
|
+
nil
|
|
280
279
|
end
|
|
281
280
|
|
|
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
|
|
281
|
+
# @param value [Object] image candidate value
|
|
282
|
+
# @return [String, Hash, nil] normalized image-like value
|
|
283
|
+
def normalize_image(value)
|
|
284
|
+
candidate = unwrap(value)
|
|
285
|
+
candidate.is_a?(Hash) ? candidate : candidate.to_s
|
|
313
286
|
end
|
|
314
|
-
end
|
|
315
287
|
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
288
|
+
# @param value [Object] about candidate value
|
|
289
|
+
# @return [Array<String, Hash>, nil] normalized about values
|
|
290
|
+
def normalize_about(value)
|
|
291
|
+
items = value.is_a?(Array) ? value : [value]
|
|
292
|
+
values = items.filter_map { normalize_about_item(_1) }
|
|
293
|
+
values unless values.empty?
|
|
294
|
+
end
|
|
321
295
|
|
|
322
|
-
|
|
296
|
+
# @param item [Object] single about item
|
|
297
|
+
# @return [String, Hash, nil] normalized about item
|
|
298
|
+
def normalize_about_item(item)
|
|
299
|
+
case item
|
|
300
|
+
when Hash
|
|
301
|
+
name = item[:name]
|
|
302
|
+
{ name: name.to_s } if name
|
|
303
|
+
when String then item
|
|
304
|
+
end
|
|
305
|
+
end
|
|
323
306
|
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
307
|
+
# @param values [Array<Object>] value candidates
|
|
308
|
+
# @return [Array<String>, nil] normalized unique string values
|
|
309
|
+
def array_value(*values)
|
|
310
|
+
result = values.flat_map { string_values(Array(_1)) }.uniq
|
|
311
|
+
result unless result.empty?
|
|
312
|
+
end
|
|
327
313
|
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
end
|
|
314
|
+
# @param values [Array<Object>] candidate scalar values collected from microdata arrays
|
|
315
|
+
# @return [Array<String>] normalized string values
|
|
316
|
+
def string_values(values)
|
|
317
|
+
values.filter_map { stringify(_1) }
|
|
318
|
+
end
|
|
334
319
|
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
320
|
+
# @param values [Array<Object>] value candidates
|
|
321
|
+
# @return [String, nil] first present string-like value
|
|
322
|
+
def first_string(*values)
|
|
323
|
+
values.each do |value|
|
|
324
|
+
candidate = stringify(unwrap(value))
|
|
325
|
+
return candidate if present?(candidate)
|
|
326
|
+
end
|
|
340
327
|
|
|
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)
|
|
328
|
+
nil
|
|
347
329
|
end
|
|
348
330
|
|
|
349
|
-
|
|
350
|
-
|
|
331
|
+
# @param value [Object] nested container or scalar
|
|
332
|
+
# @param keys [Array<Symbol>] nested keys to probe in order
|
|
333
|
+
# @return [Object, nil] first matching nested value
|
|
334
|
+
def extract_nested_value(value, *keys)
|
|
335
|
+
candidate = unwrap(value)
|
|
336
|
+
return candidate unless candidate.is_a?(Hash)
|
|
351
337
|
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
candidate = unwrap(value)
|
|
357
|
-
return candidate unless candidate.is_a?(Hash)
|
|
338
|
+
keys.each do |key|
|
|
339
|
+
nested_value = candidate[key]
|
|
340
|
+
return nested_value if present?(nested_value)
|
|
341
|
+
end
|
|
358
342
|
|
|
359
|
-
|
|
360
|
-
nested_value = candidate[key]
|
|
361
|
-
return nested_value if present?(nested_value)
|
|
343
|
+
nil
|
|
362
344
|
end
|
|
363
345
|
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
def unwrap(value)
|
|
370
|
-
value.is_a?(Array) ? value.first : value
|
|
371
|
-
end
|
|
346
|
+
# @param value [Object] scalar or array candidate
|
|
347
|
+
# @return [Object] first array element or the original value
|
|
348
|
+
def unwrap(value)
|
|
349
|
+
value.is_a?(Array) ? value.first : value
|
|
350
|
+
end
|
|
372
351
|
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
return if value.is_a?(Hash) || value.is_a?(Array)
|
|
352
|
+
# @param value [Object] scalar candidate normalized to string output
|
|
353
|
+
# @return [String, nil] normalized string representation
|
|
354
|
+
def stringify(value)
|
|
355
|
+
return unless present?(value)
|
|
356
|
+
return if value.is_a?(Hash) || value.is_a?(Array)
|
|
379
357
|
|
|
380
|
-
|
|
381
|
-
|
|
358
|
+
value.to_s
|
|
359
|
+
end
|
|
382
360
|
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
361
|
+
# @param value [Object] candidate value
|
|
362
|
+
# @return [Boolean] whether value is present
|
|
363
|
+
def present?(value)
|
|
364
|
+
case value
|
|
365
|
+
when nil then false
|
|
366
|
+
when String then !value.strip.empty?
|
|
367
|
+
when Array, Hash then !value.empty?
|
|
368
|
+
else true
|
|
369
|
+
end
|
|
391
370
|
end
|
|
392
371
|
end
|
|
393
372
|
end
|
|
@@ -395,24 +374,17 @@ module Html2rss
|
|
|
395
374
|
|
|
396
375
|
# Normalizes raw Microdata properties into the schema-like shape used downstream.
|
|
397
376
|
module SchemaObjectBuilder
|
|
398
|
-
module_function
|
|
399
|
-
|
|
400
|
-
extend ValueNormalizer
|
|
401
|
-
|
|
402
377
|
# @param root [Nokogiri::XML::Element] supported microdata root node
|
|
403
378
|
# @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))
|
|
379
|
+
def self.call(root)
|
|
380
|
+
compact_object(Microdata.supported_type_name(root), root, ItemParser.call(root))
|
|
409
381
|
end
|
|
410
382
|
|
|
411
383
|
# @param type [String] schema type inferred from itemtype
|
|
412
384
|
# @param root [Nokogiri::XML::Element] supported microdata root node
|
|
413
385
|
# @param properties [Hash{Symbol => Object}] parsed microdata properties
|
|
414
386
|
# @return [Hash{Symbol => Object}] normalized schema-like object
|
|
415
|
-
def compact_object(type, root, properties)
|
|
387
|
+
def self.compact_object(type, root, properties)
|
|
416
388
|
object = base_attributes(type, root, properties)
|
|
417
389
|
merge_categories!(object, properties)
|
|
418
390
|
object.compact
|
|
@@ -422,8 +394,8 @@ module Html2rss
|
|
|
422
394
|
# @param root [Nokogiri::XML::Element] supported microdata root node
|
|
423
395
|
# @param properties [Hash{Symbol => Object}] parsed microdata properties
|
|
424
396
|
# @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))
|
|
397
|
+
def self.base_attributes(type, root, properties)
|
|
398
|
+
identifier = ValueNormalizer.first_string(root['itemid'], properties.delete(:identifier))
|
|
427
399
|
|
|
428
400
|
{
|
|
429
401
|
'@type': type,
|
|
@@ -431,22 +403,30 @@ module Html2rss
|
|
|
431
403
|
}.merge(text_attributes(properties))
|
|
432
404
|
.merge(link_attributes(properties, identifier))
|
|
433
405
|
.merge(media_attributes(properties))
|
|
406
|
+
.merge(people_attributes(properties))
|
|
407
|
+
end
|
|
408
|
+
|
|
409
|
+
# @param properties [Hash{Symbol => Object}] parsed microdata properties
|
|
410
|
+
# @return [Hash{Symbol => Object}] author and publisher (text or nested Person/Organization `{name:}`)
|
|
411
|
+
def self.people_attributes(properties)
|
|
412
|
+
{ author: properties.delete(:author), publisher: properties.delete(:publisher) }
|
|
434
413
|
end
|
|
435
414
|
|
|
436
415
|
# @param properties [Hash{Symbol => Object}] parsed microdata properties
|
|
437
416
|
# @return [String, nil] normalized title
|
|
438
|
-
def title(properties)
|
|
439
|
-
first_string(properties.delete(:headline), properties.delete(:title),
|
|
417
|
+
def self.title(properties)
|
|
418
|
+
ValueNormalizer.first_string(properties.delete(:headline), properties.delete(:title),
|
|
419
|
+
properties.delete(:name))
|
|
440
420
|
end
|
|
441
421
|
|
|
442
422
|
# @param properties [Hash{Symbol => Object}] parsed microdata properties
|
|
443
423
|
# @return [Hash{Symbol => Object}] normalized text attributes
|
|
444
|
-
def text_attributes(properties)
|
|
424
|
+
def self.text_attributes(properties)
|
|
445
425
|
{
|
|
446
426
|
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)),
|
|
427
|
+
description: ValueNormalizer.first_string(properties.delete(:description)),
|
|
428
|
+
schema_object_body: ValueNormalizer.first_string(properties.delete(:articleBody)),
|
|
429
|
+
abstract: ValueNormalizer.first_string(properties.delete(:abstract)),
|
|
450
430
|
datePublished: published_at(properties)
|
|
451
431
|
}
|
|
452
432
|
end
|
|
@@ -454,7 +434,7 @@ module Html2rss
|
|
|
454
434
|
# @param properties [Hash{Symbol => Object}] parsed microdata properties
|
|
455
435
|
# @param identifier [String, nil] identifier candidate for fallback URL handling
|
|
456
436
|
# @return [Hash{Symbol => Object}] normalized link attributes
|
|
457
|
-
def link_attributes(properties, identifier)
|
|
437
|
+
def self.link_attributes(properties, identifier)
|
|
458
438
|
{
|
|
459
439
|
url: url(properties, identifier)
|
|
460
440
|
}
|
|
@@ -462,17 +442,20 @@ module Html2rss
|
|
|
462
442
|
|
|
463
443
|
# @param properties [Hash{Symbol => Object}] parsed microdata properties
|
|
464
444
|
# @return [Hash{Symbol => Object}] normalized media attributes
|
|
465
|
-
def media_attributes(properties)
|
|
445
|
+
def self.media_attributes(properties)
|
|
466
446
|
{
|
|
467
|
-
image: image_value(properties.delete(:image), properties.delete(:thumbnailUrl)
|
|
447
|
+
image: ValueNormalizer.image_value(properties.delete(:image), properties.delete(:thumbnailUrl),
|
|
448
|
+
properties.delete(:thumbnail)),
|
|
449
|
+
video: properties.delete(:video),
|
|
450
|
+
audio: properties.delete(:audio)
|
|
468
451
|
}
|
|
469
452
|
end
|
|
470
453
|
|
|
471
454
|
# @param properties [Hash{Symbol => Object}] parsed microdata properties
|
|
472
455
|
# @param fallback_id [String, nil] identifier candidate for fallback URL handling
|
|
473
456
|
# @return [String, nil] normalized URL candidate
|
|
474
|
-
def url(properties, fallback_id)
|
|
475
|
-
url_value(
|
|
457
|
+
def self.url(properties, fallback_id)
|
|
458
|
+
ValueNormalizer.url_value(
|
|
476
459
|
properties.delete(:url),
|
|
477
460
|
properties.delete(:mainEntityOfPage),
|
|
478
461
|
url_fallback(fallback_id)
|
|
@@ -481,8 +464,8 @@ module Html2rss
|
|
|
481
464
|
|
|
482
465
|
# @param fallback_id [String, nil] identifier candidate for fallback URL handling
|
|
483
466
|
# @return [String, nil] fallback URL candidate when identifier looks URL-like
|
|
484
|
-
def url_fallback(fallback_id)
|
|
485
|
-
value = first_string(fallback_id)
|
|
467
|
+
def self.url_fallback(fallback_id)
|
|
468
|
+
value = ValueNormalizer.first_string(fallback_id)
|
|
486
469
|
return unless value
|
|
487
470
|
return value if value.start_with?('/')
|
|
488
471
|
return value if value.match?(%r{\Ahttps?://})
|
|
@@ -492,8 +475,8 @@ module Html2rss
|
|
|
492
475
|
|
|
493
476
|
# @param properties [Hash{Symbol => Object}] parsed microdata properties
|
|
494
477
|
# @return [String, nil] normalized published-at value
|
|
495
|
-
def published_at(properties)
|
|
496
|
-
first_string(
|
|
478
|
+
def self.published_at(properties)
|
|
479
|
+
ValueNormalizer.first_string(
|
|
497
480
|
properties.delete(:datePublished),
|
|
498
481
|
properties.delete(:dateCreated),
|
|
499
482
|
properties.delete(:dateModified),
|
|
@@ -504,19 +487,19 @@ module Html2rss
|
|
|
504
487
|
# @param object [Hash{Symbol => Object}] schema-like output object
|
|
505
488
|
# @param properties [Hash{Symbol => Object}] parsed microdata properties
|
|
506
489
|
# @return [void]
|
|
507
|
-
def merge_categories!(object, properties)
|
|
508
|
-
categories = array_value(properties.delete(:categories), properties.delete(:articleSection))
|
|
490
|
+
def self.merge_categories!(object, properties)
|
|
491
|
+
categories = ValueNormalizer.array_value(properties.delete(:categories), properties.delete(:articleSection))
|
|
509
492
|
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)))
|
|
493
|
+
assign_if_present(object, :keywords, ValueNormalizer.array_value(properties.delete(:keywords)))
|
|
494
|
+
assign_if_present(object, :tags, ValueNormalizer.array_value(properties.delete(:tags)))
|
|
495
|
+
assign_if_present(object, :about, ValueNormalizer.normalize_about(properties.delete(:about)))
|
|
513
496
|
end
|
|
514
497
|
|
|
515
498
|
# @param object [Hash{Symbol => Object}] schema-like output object
|
|
516
499
|
# @param key [Symbol] target attribute key
|
|
517
500
|
# @param value [Object] value to assign when present
|
|
518
501
|
# @return [void]
|
|
519
|
-
def assign_if_present(object, key, value)
|
|
502
|
+
def self.assign_if_present(object, key, value)
|
|
520
503
|
object[key] = value if value
|
|
521
504
|
end
|
|
522
505
|
end
|
|
@@ -111,11 +111,19 @@ module Html2rss
|
|
|
111
111
|
# @param entry [Nokogiri::XML::Element] entry DOM node
|
|
112
112
|
# @return [String, nil] image URL
|
|
113
113
|
def extract_image(entry)
|
|
114
|
-
node = entry.
|
|
114
|
+
node = entry.css('.u-featured, .u-photo').find { !author_node?(_1, entry) }
|
|
115
115
|
raw_src = node&.attr('src')&.strip || node&.attr('href')&.strip
|
|
116
116
|
resolve_url(raw_src)&.to_s
|
|
117
117
|
end
|
|
118
118
|
|
|
119
|
+
# @param node [Nokogiri::XML::Element] candidate node
|
|
120
|
+
# @param entry [Nokogiri::XML::Element] entry DOM node
|
|
121
|
+
# @return [Boolean] whether the node is, or sits inside, an author card within the entry
|
|
122
|
+
def author_node?(node, entry)
|
|
123
|
+
chain = [node, *node.ancestors.take_while { _1 != entry }]
|
|
124
|
+
chain.any? { _1['class'].to_s.split.intersect?(%w[p-author h-card]) }
|
|
125
|
+
end
|
|
126
|
+
|
|
119
127
|
# @param entry [Nokogiri::XML::Element] entry DOM node
|
|
120
128
|
# @return [String, nil] author text
|
|
121
129
|
def extract_author(entry)
|
|
@@ -96,7 +96,7 @@ module Html2rss
|
|
|
96
96
|
return [] unless response
|
|
97
97
|
|
|
98
98
|
Syndication::Parser.parse_response(response)
|
|
99
|
-
rescue Html2rss::Error
|
|
99
|
+
rescue Html2rss::Error => error
|
|
100
100
|
Log.warn({
|
|
101
101
|
event_name: 'auto_source.native_feed.failed',
|
|
102
102
|
host: url.host,
|