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.
Files changed (108) hide show
  1. checksums.yaml +4 -4
  2. data/lib/html2rss/article/enclosure.rb +2 -6
  3. data/lib/html2rss/auto_source/cleanup.rb +1 -3
  4. data/lib/html2rss/auto_source/scraper/html.rb +2 -8
  5. data/lib/html2rss/auto_source/scraper/json_document.rb +2 -3
  6. data/lib/html2rss/auto_source/scraper/json_state/article_normalizer.rb +128 -128
  7. data/lib/html2rss/auto_source/scraper/json_state/candidate_detector.rb +37 -37
  8. data/lib/html2rss/auto_source/scraper/json_state/document_scanner.rb +123 -123
  9. data/lib/html2rss/auto_source/scraper/json_state/value_finder.rb +34 -34
  10. data/lib/html2rss/auto_source/scraper/json_state.rb +2 -6
  11. data/lib/html2rss/auto_source/scraper/meta_oembed.rb +32 -2
  12. data/lib/html2rss/auto_source/scraper/microdata.rb +228 -245
  13. data/lib/html2rss/auto_source/scraper/microformats2.rb +9 -1
  14. data/lib/html2rss/auto_source/scraper/native_feed.rb +1 -1
  15. data/lib/html2rss/auto_source/scraper/schema/category_extractor.rb +7 -2
  16. data/lib/html2rss/auto_source/scraper/schema/item_list.rb +2 -2
  17. data/lib/html2rss/auto_source/scraper/schema/thing.rb +98 -21
  18. data/lib/html2rss/auto_source/scraper/schema.rb +49 -2
  19. data/lib/html2rss/auto_source/scraper/semantic_html.rb +12 -16
  20. data/lib/html2rss/auto_source/scraper/sitemap/parser.rb +1 -4
  21. data/lib/html2rss/auto_source/scraper/sitemap.rb +3 -11
  22. data/lib/html2rss/auto_source/scraper/wordpress_api/page_scope.rb +2 -2
  23. data/lib/html2rss/auto_source/scraper/wordpress_api/posts_endpoint.rb +1 -3
  24. data/lib/html2rss/auto_source/scraper/wordpress_api.rb +4 -4
  25. data/lib/html2rss/auto_source/scraper/xhr_articles.rb +1 -1
  26. data/lib/html2rss/auto_source/scraper.rb +0 -11
  27. data/lib/html2rss/auto_source/segmenter/cluster.rb +5 -11
  28. data/lib/html2rss/auto_source/segmenter/list.rb +25 -57
  29. data/lib/html2rss/auto_source/segmenter/semantic.rb +6 -13
  30. data/lib/html2rss/auto_source.rb +1 -1
  31. data/lib/html2rss/batch.rb +22 -32
  32. data/lib/html2rss/capture/README.md +1 -1
  33. data/lib/html2rss/capture.rb +7 -27
  34. data/lib/html2rss/cli/render.rb +97 -96
  35. data/lib/html2rss/cli/validate.rb +2 -4
  36. data/lib/html2rss/cli.rb +33 -27
  37. data/lib/html2rss/config/request_controls.rb +10 -2
  38. data/lib/html2rss/config/schema.rb +8 -10
  39. data/lib/html2rss/config/selectors_validator.rb +1 -0
  40. data/lib/html2rss/config/validator.rb +3 -3
  41. data/lib/html2rss/config.rb +1 -1
  42. data/lib/html2rss/defaults.rb +5 -16
  43. data/lib/html2rss/doctor/botasaurus.rb +11 -19
  44. data/lib/html2rss/error.rb +54 -1
  45. data/lib/html2rss/feed_builder/rss.rb +1 -1
  46. data/lib/html2rss/feed_pipeline/README.md +1 -1
  47. data/lib/html2rss/feed_pipeline/auto_fallback.rb +58 -55
  48. data/lib/html2rss/feed_resolution/candidate_generator.rb +3 -4
  49. data/lib/html2rss/feed_resolution/options.rb +3 -7
  50. data/lib/html2rss/feed_resolution/policy.rb +29 -33
  51. data/lib/html2rss/feed_resolution/probe.rb +3 -3
  52. data/lib/html2rss/feed_resolution/scorer.rb +29 -29
  53. data/lib/html2rss/feed_resolution.rb +5 -9
  54. data/lib/html2rss/feed_result.rb +4 -3
  55. data/lib/html2rss/hash_util.rb +91 -90
  56. data/lib/html2rss/html/article_extractor/category_extractor.rb +6 -9
  57. data/lib/html2rss/html/article_extractor/date_extractor.rb +1 -1
  58. data/lib/html2rss/html/article_extractor/enclosure_extractor.rb +1 -3
  59. data/lib/html2rss/html/article_extractor/id_generator.rb +1 -1
  60. data/lib/html2rss/html/probe.rb +51 -51
  61. data/lib/html2rss/link_destination/path_classifier.rb +11 -33
  62. data/lib/html2rss/mcp/config_argument.rb +9 -2
  63. data/lib/html2rss/mcp/contract.rb +7 -5
  64. data/lib/html2rss/mcp/outcome.rb +4 -5
  65. data/lib/html2rss/mcp/runtime.rb +17 -24
  66. data/lib/html2rss/mcp/server/tools.rb +9 -9
  67. data/lib/html2rss/option.rb +48 -0
  68. data/lib/html2rss/page_recon/diagnostics.rb +7 -9
  69. data/lib/html2rss/page_recon.rb +0 -4
  70. data/lib/html2rss/recon.rb +10 -12
  71. data/lib/html2rss/request_service/blocked_surface.rb +3 -7
  72. data/lib/html2rss/request_service/botasaurus_strategy.rb +2 -2
  73. data/lib/html2rss/request_service/budget.rb +3 -24
  74. data/lib/html2rss/request_service/compressed_body.rb +36 -48
  75. data/lib/html2rss/request_service/context.rb +1 -3
  76. data/lib/html2rss/request_service/local_file_strategy.rb +3 -1
  77. data/lib/html2rss/request_service/network_guard.rb +3 -2
  78. data/lib/html2rss/request_service/policy.rb +8 -26
  79. data/lib/html2rss/request_service/response.rb +3 -6
  80. data/lib/html2rss/request_service/strategy.rb +2 -2
  81. data/lib/html2rss/request_service.rb +26 -2
  82. data/lib/html2rss/request_session/pager/base.rb +3 -3
  83. data/lib/html2rss/request_session/pager/custom_selector.rb +4 -4
  84. data/lib/html2rss/request_session/pager/offset.rb +1 -7
  85. data/lib/html2rss/request_session/pager/rel_next.rb +1 -1
  86. data/lib/html2rss/request_session/pager/url_template.rb +1 -7
  87. data/lib/html2rss/scoring/engine.rb +4 -32
  88. data/lib/html2rss/scoring/link_resolver.rb +2 -7
  89. data/lib/html2rss/selectors/extractors/href.rb +2 -4
  90. data/lib/html2rss/selectors/json_xml.rb +33 -34
  91. data/lib/html2rss/selectors/post_processors/gsub.rb +2 -3
  92. data/lib/html2rss/selectors/post_processors/parse_time.rb +4 -5
  93. data/lib/html2rss/selectors/schema_export.rb +60 -60
  94. data/lib/html2rss/selectors.rb +14 -18
  95. data/lib/html2rss/sst/node.rb +9 -3
  96. data/lib/html2rss/sst/normalizer.rb +25 -24
  97. data/lib/html2rss/sst/text.rb +53 -57
  98. data/lib/html2rss/status.rb +23 -30
  99. data/lib/html2rss/syndication/alternate_links.rb +39 -0
  100. data/lib/html2rss/syndication/discovery.rb +117 -187
  101. data/lib/html2rss/syndication/parser.rb +92 -115
  102. data/lib/html2rss/test/enhance_audit.rb +13 -15
  103. data/lib/html2rss/test/policy.rb +9 -11
  104. data/lib/html2rss/test.rb +29 -48
  105. data/lib/html2rss/url.rb +53 -53
  106. data/lib/html2rss/version.rb +1 -1
  107. data/lib/html2rss.rb +2 -1
  108. 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
- module_function
133
-
134
- # @param root [Nokogiri::XML::Element] microdata root node
135
- # @return [Hash{Symbol => Object}] extracted direct properties
136
- def call(root)
137
- {}.tap do |properties|
138
- direct_properties(root).each { append_properties!(properties, _1) }
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
- # @param properties [Hash{Symbol => Object}] accumulator hash for parsed properties
143
- # @param node [Nokogiri::XML::Element] itemprop node
144
- # @return [void]
145
- def append_properties!(properties, node)
146
- value = property_value(node)
147
- return if blank_value?(value)
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
- property_names(node).each do |name|
150
- append(properties, name.to_sym, value)
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
- curr = node.parent
167
- while curr && curr != root
168
- return false if curr.attribute('itemscope')
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
- curr = curr.parent
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
- # @param node [Nokogiri::XML::Element] itemprop node
176
- # @return [Array<String>] normalized property names
177
- def property_names(node)
178
- node['itemprop'].to_s.split.filter_map do |name|
179
- stripped = name.strip
180
- stripped unless stripped.empty?
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
- # @param node [Nokogiri::XML::Element] itemprop node
185
- # @return [Object, nil] parsed property value
186
- def property_value(node)
187
- value = if node.attribute('itemscope')
188
- nested_item(node)
189
- else
190
- attribute_value(node) || text_value(node)
191
- end
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
- value unless blank_value?(value)
194
- end
188
+ value unless blank_value?(value)
189
+ end
195
190
 
196
- # @param node [Nokogiri::XML::Element] nested itemscope node
197
- # @return [Hash{Symbol => Object}] nested parsed microdata item
198
- def nested_item(node)
199
- item = call(node)
200
- itemtype = node['itemtype']
201
- itemid = node['itemid']
202
- item[:@type] = Microdata.itemtype_type_names(itemtype).first if itemtype
203
- item[:@id] = itemid if present?(itemid)
204
- item
205
- end
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
- # @param node [Nokogiri::XML::Element] itemprop node
208
- # @return [String, nil] first present attribute value
209
- def attribute_value(node)
210
- VALUE_ATTRIBUTES.each do |attribute|
211
- value = node[attribute]
212
- return value if present?(value)
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
- nil
216
- end
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
- # @param node [Nokogiri::XML::Element] itemprop node
219
- # @return [String, nil] normalized text content
220
- def text_value(node)
221
- value = node.text.to_s.strip
222
- value unless value.empty?
223
- end
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
- # @param properties [Hash{Symbol => Object}] accumulator hash for parsed properties
226
- # @param key [Symbol] target property key
227
- # @param value [Object] parsed property value to assign for the key
228
- # @return [void]
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
- unless properties.key?(key)
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
- properties[key] = Array(properties[key]) << value
238
- end
239
-
240
- # @param value [Object] candidate value
241
- # @return [Boolean] whether value is blank for microdata extraction purposes
242
- def blank_value?(value)
243
- case value
244
- when nil then true
245
- when String then value.strip.empty?
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
- # @param value [Object] candidate value
252
- # @return [Boolean] whether value is present for microdata extraction purposes
253
- def present?(value)
254
- !blank_value?(value)
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
- module_function
262
-
263
- # @param values [Array<Object>] value candidates
264
- # @return [String, nil] first URL-like value converted to string
265
- def url_value(*values)
266
- values.each do |value|
267
- candidate = extract_nested_value(value, :url, :@id)
268
- return candidate.to_s if present?(candidate)
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
- nil
272
- end
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
- # @param values [Array<Object>] value candidates
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
- nil
283
- end
284
-
285
- # @param value [Object] image candidate value
286
- # @return [String, Hash, nil] normalized image-like value
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
- # @param value [Object] scalar or array candidate
317
- # @return [String, Array<String>, nil] normalized scalar or string array
318
- def string_or_array(value)
319
- candidate = unwrap(value)
320
- return unless present?(candidate)
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
- return stringify(candidate) unless candidate.is_a?(Array)
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
- result = string_values(candidate)
325
- result unless result.empty?
326
- end
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
- # @param values [Array<Object>] value candidates
329
- # @return [Array<String>, nil] normalized unique string values
330
- def array_value(*values)
331
- result = values.flat_map { string_values(Array(unwrap(_1))) }.uniq
332
- result unless result.empty?
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
- # @param values [Array<Object>] candidate scalar values collected from microdata arrays
336
- # @return [Array<String>] normalized string values
337
- def string_values(values)
338
- values.filter_map { stringify(_1) }
339
- end
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
- # @param values [Array<Object>] value candidates
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
- nil
350
- end
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
- # @param value [Object] nested container or scalar
353
- # @param keys [Array<Symbol>] nested keys to probe in order
354
- # @return [Object, nil] first matching nested value
355
- def extract_nested_value(value, *keys)
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
- keys.each do |key|
360
- nested_value = candidate[key]
361
- return nested_value if present?(nested_value)
343
+ nil
362
344
  end
363
345
 
364
- nil
365
- end
366
-
367
- # @param value [Object] scalar or array candidate
368
- # @return [Object] first array element or the original value
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
- # @param value [Object] scalar candidate normalized to string output
374
- # @return [String, nil] normalized string representation
375
- def stringify(value)
376
- return unless present?(value)
377
- return value if value.is_a?(String)
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
- value.to_s
381
- end
358
+ value.to_s
359
+ end
382
360
 
383
- # @param value [Object] candidate value
384
- # @return [Boolean] whether value is present
385
- def present?(value)
386
- case value
387
- when nil then false
388
- when String then !value.strip.empty?
389
- when Array, Hash then !value.empty?
390
- else true
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
- type = Microdata.supported_type_name(root)
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), properties.delete(:name))
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, string_or_array(properties.delete(:keywords)))
511
- assign_if_present(object, :tags, string_or_array(properties.delete(: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.at_css('.u-featured, .u-photo')
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, ArgumentError => error
99
+ rescue Html2rss::Error => error
100
100
  Log.warn({
101
101
  event_name: 'auto_source.native_feed.failed',
102
102
  host: url.host,