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.
Files changed (202) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +4 -2
  3. data/lib/html2rss/article/deduplicator.rb +5 -2
  4. data/lib/html2rss/article/enclosure.rb +185 -25
  5. data/lib/html2rss/article/title.rb +203 -0
  6. data/lib/html2rss/article.rb +79 -13
  7. data/lib/html2rss/auto_source/README.md +16 -8
  8. data/lib/html2rss/auto_source/cleanup.rb +127 -53
  9. data/lib/html2rss/auto_source/corroboration.rb +167 -0
  10. data/lib/html2rss/auto_source/page_links/anchor_claims.rb +82 -0
  11. data/lib/html2rss/auto_source/page_links/card_units.rb +77 -0
  12. data/lib/html2rss/auto_source/page_links/label_binding.rb +79 -0
  13. data/lib/html2rss/auto_source/page_links/sponsored_cards.rb +147 -0
  14. data/lib/html2rss/auto_source/page_links.rb +164 -0
  15. data/lib/html2rss/auto_source/scraper/html.rb +11 -45
  16. data/lib/html2rss/auto_source/scraper/json_document.rb +3 -6
  17. data/lib/html2rss/auto_source/scraper/json_state/article_normalizer.rb +202 -125
  18. data/lib/html2rss/auto_source/scraper/json_state/candidate_detector.rb +27 -38
  19. data/lib/html2rss/auto_source/scraper/json_state/document_scanner.rb +123 -123
  20. data/lib/html2rss/auto_source/scraper/json_state/value_finder.rb +27 -35
  21. data/lib/html2rss/auto_source/scraper/json_state.rb +4 -9
  22. data/lib/html2rss/auto_source/scraper/meta_oembed.rb +27 -5
  23. data/lib/html2rss/auto_source/scraper/microdata.rb +233 -248
  24. data/lib/html2rss/auto_source/scraper/microformats2.rb +10 -4
  25. data/lib/html2rss/auto_source/scraper/native_feed.rb +28 -18
  26. data/lib/html2rss/auto_source/scraper/schema/category_extractor.rb +7 -2
  27. data/lib/html2rss/auto_source/scraper/schema/item_list.rb +5 -3
  28. data/lib/html2rss/auto_source/scraper/schema/thing.rb +140 -22
  29. data/lib/html2rss/auto_source/scraper/schema.rb +52 -8
  30. data/lib/html2rss/auto_source/scraper/semantic_html/entry_deduplicator.rb +3 -9
  31. data/lib/html2rss/auto_source/scraper/semantic_html.rb +12 -20
  32. data/lib/html2rss/auto_source/scraper/sitemap/parser.rb +1 -4
  33. data/lib/html2rss/auto_source/scraper/sitemap.rb +7 -14
  34. data/lib/html2rss/auto_source/scraper/wordpress_api/page_scope.rb +2 -2
  35. data/lib/html2rss/auto_source/scraper/wordpress_api/posts_endpoint.rb +1 -3
  36. data/lib/html2rss/auto_source/scraper/wordpress_api.rb +7 -7
  37. data/lib/html2rss/auto_source/scraper/xhr_articles.rb +2 -4
  38. data/lib/html2rss/auto_source/scraper.rb +10 -13
  39. data/lib/html2rss/auto_source/segment.rb +19 -5
  40. data/lib/html2rss/auto_source/segmenter/cluster.rb +18 -22
  41. data/lib/html2rss/auto_source/segmenter/list.rb +35 -59
  42. data/lib/html2rss/auto_source/segmenter/primary_link.rb +29 -4
  43. data/lib/html2rss/auto_source/segmenter/semantic.rb +23 -19
  44. data/lib/html2rss/auto_source/segmenter.rb +4 -8
  45. data/lib/html2rss/auto_source.rb +81 -26
  46. data/lib/html2rss/batch.rb +24 -32
  47. data/lib/html2rss/capture/README.md +6 -3
  48. data/lib/html2rss/capture.rb +51 -59
  49. data/lib/html2rss/channel.rb +30 -12
  50. data/lib/html2rss/cli/render.rb +97 -96
  51. data/lib/html2rss/cli/validate.rb +2 -4
  52. data/lib/html2rss/cli.rb +55 -38
  53. data/lib/html2rss/config/auto_source_contract.rb +52 -45
  54. data/lib/html2rss/config/issue_mapper.rb +4 -1
  55. data/lib/html2rss/config/request_controls.rb +16 -5
  56. data/lib/html2rss/config/schema.rb +23 -15
  57. data/lib/html2rss/config/selectors_validator.rb +3 -2
  58. data/lib/html2rss/config/validation_issue.rb +4 -1
  59. data/lib/html2rss/config/validator.rb +14 -3
  60. data/lib/html2rss/config.rb +54 -28
  61. data/lib/html2rss/contract_error.rb +9 -0
  62. data/lib/html2rss/defaults.rb +25 -38
  63. data/lib/html2rss/doctor/botasaurus.rb +18 -22
  64. data/lib/html2rss/error.rb +17 -3
  65. data/lib/html2rss/error_kind.rb +39 -0
  66. data/lib/html2rss/feed_builder/item_presentation.rb +22 -9
  67. data/lib/html2rss/feed_builder/json_feed/item.rb +2 -1
  68. data/lib/html2rss/feed_builder/json_feed.rb +2 -2
  69. data/lib/html2rss/feed_builder/rss/stylesheet.rb +3 -3
  70. data/lib/html2rss/feed_builder/rss.rb +50 -7
  71. data/lib/html2rss/feed_builder.rb +2 -1
  72. data/lib/html2rss/feed_pipeline/README.md +1 -1
  73. data/lib/html2rss/feed_pipeline/auto_fallback.rb +16 -34
  74. data/lib/html2rss/feed_pipeline/strategy_plan.rb +3 -3
  75. data/lib/html2rss/feed_pipeline.rb +40 -27
  76. data/lib/html2rss/feed_resolution/README.md +1 -1
  77. data/lib/html2rss/feed_resolution/candidate_generator.rb +6 -8
  78. data/lib/html2rss/feed_resolution/options.rb +3 -7
  79. data/lib/html2rss/feed_resolution/policy.rb +29 -33
  80. data/lib/html2rss/feed_resolution/probe.rb +6 -4
  81. data/lib/html2rss/feed_resolution/scorer.rb +29 -29
  82. data/lib/html2rss/feed_resolution.rb +16 -21
  83. data/lib/html2rss/feed_result.rb +11 -6
  84. data/lib/html2rss/hash_util.rb +91 -90
  85. data/lib/html2rss/html/article_extractor/category_extractor.rb +7 -10
  86. data/lib/html2rss/html/article_extractor/date_extractor.rb +1 -1
  87. data/lib/html2rss/html/article_extractor/enclosure_extractor.rb +12 -33
  88. data/lib/html2rss/html/article_extractor/heading_extractor.rb +2 -3
  89. data/lib/html2rss/html/article_extractor/id_generator.rb +7 -10
  90. data/lib/html2rss/html/article_extractor/image_extractor.rb +9 -1
  91. data/lib/html2rss/html/article_extractor.rb +19 -47
  92. data/lib/html2rss/html/article_rules/category.rb +3 -3
  93. data/lib/html2rss/html/article_rules/description.rb +6 -15
  94. data/lib/html2rss/html/article_rules/enclosure.rb +70 -42
  95. data/lib/html2rss/html/article_rules/image.rb +30 -16
  96. data/lib/html2rss/html/feed_link.rb +7 -9
  97. data/lib/html2rss/html/navigator/text_extractor.rb +26 -16
  98. data/lib/html2rss/html/navigator.rb +39 -9
  99. data/lib/html2rss/html/probe.rb +55 -51
  100. data/lib/html2rss/html/rendering/description_builder.rb +46 -32
  101. data/lib/html2rss/html/rendering.rb +2 -9
  102. data/lib/html2rss/html/sst_article_extractor.rb +55 -145
  103. data/lib/html2rss/html/title_pieces/kicker.rb +147 -0
  104. data/lib/html2rss/html/title_pieces/nodes.rb +107 -0
  105. data/lib/html2rss/html/title_pieces/nokogiri_nodes.rb +70 -0
  106. data/lib/html2rss/html/title_pieces/sst_nodes.rb +71 -0
  107. data/lib/html2rss/html/title_pieces.rb +169 -0
  108. data/lib/html2rss/html/visibility.rb +56 -0
  109. data/lib/html2rss/html.rb +2 -0
  110. data/lib/html2rss/link_destination/destination_facts.rb +17 -1
  111. data/lib/html2rss/link_destination/noise_policy.rb +11 -10
  112. data/lib/html2rss/link_destination/path_classifier.rb +111 -42
  113. data/lib/html2rss/link_destination/sponsorship/label_text.rb +82 -0
  114. data/lib/html2rss/link_destination/sponsorship.rb +184 -0
  115. data/lib/html2rss/link_destination/text_classifier.rb +104 -8
  116. data/lib/html2rss/mcp/README.md +13 -1
  117. data/lib/html2rss/mcp/config_argument.rb +14 -4
  118. data/lib/html2rss/mcp/contract.rb +20 -9
  119. data/lib/html2rss/mcp/outcome.rb +18 -17
  120. data/lib/html2rss/mcp/runtime.rb +53 -23
  121. data/lib/html2rss/mcp/server/tools.rb +9 -9
  122. data/lib/html2rss/mcp/server.rb +13 -4
  123. data/lib/html2rss/option.rb +48 -0
  124. data/lib/html2rss/page_recon/README.md +1 -1
  125. data/lib/html2rss/page_recon/diagnostics.rb +9 -9
  126. data/lib/html2rss/page_recon.rb +0 -4
  127. data/lib/html2rss/recon.rb +19 -23
  128. data/lib/html2rss/request_service/blocked_surface.rb +5 -8
  129. data/lib/html2rss/request_service/botasaurus_contract.rb +25 -3
  130. data/lib/html2rss/request_service/botasaurus_strategy.rb +44 -12
  131. data/lib/html2rss/request_service/budget.rb +3 -24
  132. data/lib/html2rss/request_service/compressed_body.rb +36 -48
  133. data/lib/html2rss/request_service/context.rb +16 -17
  134. data/lib/html2rss/request_service/cookie_jar.rb +89 -0
  135. data/lib/html2rss/request_service/cookie_store.rb +79 -0
  136. data/lib/html2rss/request_service/feed_advertisement.rb +72 -0
  137. data/lib/html2rss/request_service/httpx_strategy.rb +7 -3
  138. data/lib/html2rss/request_service/local_file_strategy.rb +2 -3
  139. data/lib/html2rss/request_service/network_guard.rb +3 -2
  140. data/lib/html2rss/request_service/policy.rb +55 -36
  141. data/lib/html2rss/request_service/replay_breaker.rb +56 -0
  142. data/lib/html2rss/request_service/response.rb +33 -19
  143. data/lib/html2rss/request_service/response_cache.rb +109 -0
  144. data/lib/html2rss/request_service/response_caching.rb +95 -0
  145. data/lib/html2rss/request_service/strategy.rb +5 -11
  146. data/lib/html2rss/request_service.rb +71 -12
  147. data/lib/html2rss/request_session/pager/base.rb +3 -10
  148. data/lib/html2rss/request_session/pager/custom_selector.rb +4 -4
  149. data/lib/html2rss/request_session/pager/offset.rb +1 -7
  150. data/lib/html2rss/request_session/pager/rel_next.rb +1 -1
  151. data/lib/html2rss/request_session/pager/url_template.rb +1 -7
  152. data/lib/html2rss/request_session/pager.rb +1 -1
  153. data/lib/html2rss/request_session.rb +6 -2
  154. data/lib/html2rss/scoring/cluster_scorer.rb +1 -1
  155. data/lib/html2rss/scoring/container_assessor.rb +12 -13
  156. data/lib/html2rss/scoring/engine.rb +10 -38
  157. data/lib/html2rss/scoring/link_resolver.rb +2 -7
  158. data/lib/html2rss/scoring/observation.rb +2 -2
  159. data/lib/html2rss/selector_candidates.rb +19 -8
  160. data/lib/html2rss/selectors/README.md +1 -1
  161. data/lib/html2rss/selectors/extractors/href.rb +2 -4
  162. data/lib/html2rss/selectors/json_xml.rb +33 -34
  163. data/lib/html2rss/selectors/option_spec.rb +4 -1
  164. data/lib/html2rss/selectors/post_processors/base.rb +0 -8
  165. data/lib/html2rss/selectors/post_processors/gsub.rb +9 -10
  166. data/lib/html2rss/selectors/post_processors/html_transformers/keep_playable_media.rb +50 -0
  167. data/lib/html2rss/selectors/post_processors/html_transformers/transform_urls_to_absolute_ones.rb +1 -3
  168. data/lib/html2rss/selectors/post_processors/parse_time.rb +5 -6
  169. data/lib/html2rss/selectors/post_processors/parse_uri.rb +1 -1
  170. data/lib/html2rss/selectors/post_processors/sanitize_html.rb +18 -5
  171. data/lib/html2rss/selectors/post_processors/substring.rb +1 -1
  172. data/lib/html2rss/selectors/schema_export.rb +60 -60
  173. data/lib/html2rss/selectors.rb +67 -30
  174. data/lib/html2rss/sst/attrs.rb +2 -2
  175. data/lib/html2rss/sst/document.rb +3 -3
  176. data/lib/html2rss/sst/index.rb +30 -11
  177. data/lib/html2rss/sst/node.rb +42 -29
  178. data/lib/html2rss/sst/normalizer.rb +47 -34
  179. data/lib/html2rss/sst/tags.rb +113 -2
  180. data/lib/html2rss/sst/text.rb +94 -58
  181. data/lib/html2rss/sst.rb +2 -0
  182. data/lib/html2rss/status.rb +27 -31
  183. data/lib/html2rss/surface_category.rb +1 -1
  184. data/lib/html2rss/syndication/README.md +12 -6
  185. data/lib/html2rss/syndication/alternate_links.rb +39 -0
  186. data/lib/html2rss/syndication/channel_facts.rb +87 -0
  187. data/lib/html2rss/syndication/discovery.rb +171 -187
  188. data/lib/html2rss/syndication/feed.rb +20 -0
  189. data/lib/html2rss/syndication/item_media.rb +103 -0
  190. data/lib/html2rss/syndication/parser.rb +117 -116
  191. data/lib/html2rss/test/enhance_audit.rb +15 -17
  192. data/lib/html2rss/test/item_sample.rb +43 -0
  193. data/lib/html2rss/test/policy.rb +9 -11
  194. data/lib/html2rss/test.rb +66 -57
  195. data/lib/html2rss/url.rb +163 -62
  196. data/lib/html2rss/version.rb +1 -1
  197. data/lib/html2rss/word_count.rb +53 -0
  198. data/lib/html2rss.rb +19 -9
  199. data/schema/html2rss-config.schema.json +56 -41
  200. metadata +33 -4
  201. data/lib/html2rss/html/rendering/media_renderer.rb +0 -39
  202. 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:, **_opts)
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
- 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) }
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
- # @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)
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
- property_names(node).each do |name|
150
- append(properties, name.to_sym, value)
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
- curr = node.parent
167
- while curr && curr != root
168
- return false if curr.attribute('itemscope')
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
- curr = curr.parent
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
- # @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?
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
- # @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
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
- value unless blank_value?(value)
194
- end
189
+ value unless blank_value?(value)
190
+ end
195
191
 
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
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
- # @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)
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
- nil
216
- end
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
- # @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
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
- # @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)
228
+ unless properties.key?(key)
229
+ properties[key] = value
230
+ return
231
+ end
231
232
 
232
- unless properties.key?(key)
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
- 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
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
- # @param value [Object] candidate value
252
- # @return [Boolean] whether value is present for microdata extraction purposes
253
- def present?(value)
254
- !blank_value?(value)
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
- 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)
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
- nil
272
- end
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
- # @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)
279
+ nil
280
280
  end
281
281
 
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
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
- # @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)
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
- return stringify(candidate) unless candidate.is_a?(Array)
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
- result = string_values(candidate)
325
- result unless result.empty?
326
- end
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
- # @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
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
- # @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
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
- # @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)
329
+ nil
347
330
  end
348
331
 
349
- nil
350
- end
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
- # @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)
339
+ keys.each do |key|
340
+ nested_value = candidate[key]
341
+ return nested_value if present?(nested_value)
342
+ end
358
343
 
359
- keys.each do |key|
360
- nested_value = candidate[key]
361
- return nested_value if present?(nested_value)
344
+ nil
362
345
  end
363
346
 
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
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
- # @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)
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
- value.to_s
381
- end
359
+ value.to_s
360
+ end
382
361
 
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
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
- type = Microdata.supported_type_name(root)
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), properties.delete(:name))
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, 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)))
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