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
@@ -35,10 +35,8 @@ module Html2rss
35
35
 
36
36
  # @param parsed_body [Nokogiri::HTML::Document] parsed HTML document
37
37
  # @param url [String, Html2rss::Url] base page URL
38
- # @param _opts [Hash] unused scraper-specific options
39
- # @option _opts [Object] :_reserved reserved for future options
40
38
  # @return [void]
41
- def initialize(parsed_body, url:, **_opts)
39
+ def initialize(parsed_body, url:)
42
40
  @parsed_body = parsed_body
43
41
  @url = Html2rss::Url.from_absolute(url)
44
42
  end
@@ -111,11 +109,19 @@ module Html2rss
111
109
  # @param entry [Nokogiri::XML::Element] entry DOM node
112
110
  # @return [String, nil] image URL
113
111
  def extract_image(entry)
114
- node = entry.at_css('.u-featured, .u-photo')
112
+ node = entry.css('.u-featured, .u-photo').find { !author_node?(_1, entry) }
115
113
  raw_src = node&.attr('src')&.strip || node&.attr('href')&.strip
116
114
  resolve_url(raw_src)&.to_s
117
115
  end
118
116
 
117
+ # @param node [Nokogiri::XML::Element] candidate node
118
+ # @param entry [Nokogiri::XML::Element] entry DOM node
119
+ # @return [Boolean] whether the node is, or sits inside, an author card within the entry
120
+ def author_node?(node, entry)
121
+ chain = [node, *node.ancestors.take_while { _1 != entry }]
122
+ chain.any? { _1['class'].to_s.split.intersect?(%w[p-author h-card]) }
123
+ end
124
+
119
125
  # @param entry [Nokogiri::XML::Element] entry DOM node
120
126
  # @return [String, nil] author text
121
127
  def extract_author(entry)
@@ -5,9 +5,15 @@ module Html2rss
5
5
  module Scraper
6
6
  ##
7
7
  # Promotes a page's native RSS/Atom feed via {Syndication::Discovery} + {Syndication::Parser}.
8
+ # After {#each}, {#channel} holds what that feed states about its channel and {#feed_url} where
9
+ # it was read, the provenance {Cleanup} admits the feed's own domain by.
8
10
  class NativeFeed
9
11
  include Enumerable
10
12
 
13
+ # Href shapes that Discovery's path probing can still resolve when the link type is not a feed MIME type.
14
+ FEEDISH_HREF = /rss|atom|feed|\.xml/i
15
+ private_constant :FEEDISH_HREF
16
+
11
17
  # @return [Symbol] scraper config key
12
18
  def self.options_key = :native_feed
13
19
 
@@ -27,25 +33,16 @@ module Html2rss
27
33
  def self.articles?(parsed_body)
28
34
  return false unless parsed_body.is_a?(Nokogiri::HTML::Document)
29
35
 
30
- return true if ::Html2rss::Html::FeedLink.from_document(parsed_body).any?
36
+ return true if Html2rss::Html::FeedLink.from_document(parsed_body).any?
31
37
 
32
- parsed_body.css('head link[rel~="alternate"][href]').any? do |node|
33
- href = node['href'].to_s
34
- ::Html2rss::Html::Probe.mime_match?(
35
- node['type'],
36
- ::Html2rss::Html::Probe::APPLICATION_RSS_XML,
37
- ::Html2rss::Html::Probe::APPLICATION_ATOM_XML
38
- ) || href.match?(/rss|atom|feed|\.xml/i)
39
- end
38
+ parsed_body.css('head link[rel~="alternate"][href]').any? { |node| node['href'].match?(FEEDISH_HREF) }
40
39
  end
41
40
 
42
41
  ##
43
42
  # @param parsed_body [Nokogiri::HTML::Document]
44
43
  # @param url [String, Html2rss::Url]
45
44
  # @param request_session [Html2rss::RequestSession, nil]
46
- # @param _opts [Hash]
47
- # @option _opts [Object] :_reserved reserved for future scraper-specific options
48
- def initialize(parsed_body, url:, request_session: nil, **_opts)
45
+ def initialize(parsed_body, url:, request_session: nil)
49
46
  @parsed_body = parsed_body
50
47
  @url = Html2rss::Url.from_absolute(url)
51
48
  @request_session = request_session
@@ -54,6 +51,16 @@ module Html2rss
54
51
  # @return [Boolean]
55
52
  def extractable? = self.class.articles?(parsed_body)
56
53
 
54
+ ##
55
+ # @return [Syndication::ChannelFacts, nil] what the followed feed states about its channel;
56
+ # nil before {#each} or when no feed was read
57
+ def channel = @feed&.channel
58
+
59
+ ##
60
+ # @return [Html2rss::Url, nil] URL of the feed read (after redirects); nil before {#each} or
61
+ # when no feed was read
62
+ attr_reader :feed_url
63
+
57
64
  ##
58
65
  # @yieldparam article [Hash{Symbol => Object}]
59
66
  # @return [Enumerator, void]
@@ -61,7 +68,7 @@ module Html2rss
61
68
  def each(&)
62
69
  return enum_for(:each) unless block_given?
63
70
 
64
- articles = fetch_articles
71
+ articles = (@feed = fetch_feed).articles
65
72
  if articles.empty?
66
73
  Log.info({
67
74
  event_name: 'auto_source.native_feed.empty',
@@ -84,8 +91,8 @@ module Html2rss
84
91
  attr_reader :parsed_body, :url, :request_session
85
92
 
86
93
  # rubocop:disable-next Metrics/AbcSize, Metrics/MethodLength -- discovery + parse + structured warn
87
- def fetch_articles
88
- return [] unless request_session
94
+ def fetch_feed
95
+ return Syndication::Feed::NONE unless request_session
89
96
 
90
97
  response = Syndication::Discovery.best_feed_response(
91
98
  page_url: url,
@@ -93,17 +100,20 @@ module Html2rss
93
100
  parsed_body:,
94
101
  max_probes: self.class.request_slots
95
102
  )
96
- return [] unless response
103
+ return Syndication::Feed::NONE unless response
97
104
 
105
+ @feed_url = response.url
98
106
  Syndication::Parser.parse_response(response)
99
- rescue Html2rss::Error, ArgumentError => error
107
+ rescue Html2rss::ContractError
108
+ raise
109
+ rescue Html2rss::Error => error
100
110
  Log.warn({
101
111
  event_name: 'auto_source.native_feed.failed',
102
112
  host: url.host,
103
113
  error_class: error.class.name,
104
114
  message: "#{self.class}: host=#{url.host} failed (#{error.class}: #{error.message})"
105
115
  }.to_json)
106
- []
116
+ Syndication::Feed::NONE
107
117
  end
108
118
  end
109
119
  end
@@ -8,7 +8,7 @@ module Html2rss
8
8
  # Extracts categories from Schema.org structured data.
9
9
  module CategoryExtractor
10
10
  # Schema.org fields checked for category-like string values.
11
- CATEGORY_FIELDS = %i[articleSection keywords categories tags].freeze
11
+ CATEGORY_FIELDS = %i[articleSection keywords categories tags occupationalCategory industry].freeze
12
12
 
13
13
  ##
14
14
  # @param schema_object [Hash] The schema object
@@ -26,15 +26,20 @@ module Html2rss
26
26
  def add_field(categories, value)
27
27
  case value
28
28
  when Array
29
- value.each { |item| add_string(categories, item.to_s) }
29
+ value.each { |item| add_item(categories, item) }
30
30
  when String
31
31
  split_string(categories, value)
32
32
  end
33
33
  end
34
34
 
35
+ def add_item(categories, item)
36
+ item.is_a?(String) ? split_string(categories, item) : add_string(categories, item.to_s)
37
+ end
38
+
35
39
  def add_about(categories, about)
36
40
  case about
37
41
  when Array then add_about_items(categories, about)
42
+ when Hash then add_about_items(categories, [about])
38
43
  when String then split_string(categories, about)
39
44
  end
40
45
  end
@@ -49,11 +49,13 @@ module Html2rss
49
49
 
50
50
  # @param object [Hash] candidate schema object
51
51
  # @param from_list_item [Boolean] whether object came from a ListItem unwrap
52
- # @return [Boolean] whether to emit this object as an article
52
+ # @return [Boolean] whether to emit this object: a ListItem unwrap admits everything except pure
53
+ # entities ({Thing::ENTITY_TYPES}); a bare element must be an article, media, Event or JobPosting type
53
54
  def emit?(object, from_list_item:)
54
- return true if from_list_item
55
+ types = Schema.normalize_types(object[:@type])
56
+ return types.empty? || !types.subset?(Thing::ENTITY_TYPES) if from_list_item
55
57
 
56
- Schema.normalize_types(object[:@type]).intersect?(Thing::SUPPORTED_TYPES)
58
+ types.intersect?(Thing::ITEM_TYPES | Thing::ITEM_LIST_TYPES)
57
59
  end
58
60
  end
59
61
  end
@@ -10,7 +10,7 @@ module Html2rss
10
10
  # A Thing is kind of the 'base class' for Schema.org schema_objects.
11
11
  #
12
12
  # @see https://schema.org/Thing
13
- class Thing
13
+ class Thing # rubocop:disable Metrics/ClassLength -- one accessor per schema.org field
14
14
  # Supported Schema.org `@type` values mapped to article extraction.
15
15
  SUPPORTED_TYPES = %w[
16
16
  AdvertiserContentArticle AnalysisNewsArticle APIReference Article
@@ -19,6 +19,41 @@ module Html2rss
19
19
  ReviewNewsArticle SatiricalArticle ScholarlyArticle SocialMediaPosting TechArticle
20
20
  ].to_set.freeze
21
21
 
22
+ # Schema.org media types and the file kind each proves (`contentUrl` is the file; `embedUrl`, the player,
23
+ # is never an enclosure nor the item URL), so an untyped `contentUrl` without a media extension is still
24
+ # audio or video.
25
+ MEDIA_KINDS = {
26
+ 'AudioObject' => :audio, 'PodcastEpisode' => :audio, 'VideoObject' => :video, 'Clip' => :video
27
+ }.freeze
28
+
29
+ # Schema.org media types: items wherever they appear (standalone, in `@graph` or an array, as ItemList
30
+ # elements), marked `media: true`; their own `contentUrl` (when present) is a media enclosure.
31
+ MEDIA_TYPES = MEDIA_KINDS.keys.to_set.freeze
32
+
33
+ # Types emitted as items wherever they appear: articles and media.
34
+ ITEM_TYPES = (SUPPORTED_TYPES | MEDIA_TYPES).freeze
35
+
36
+ # Keys holding the media objects (object or array) whose `contentUrl` is an enclosure, in choice order.
37
+ MEDIA_KEYS = %i[video audio associatedMedia].freeze
38
+
39
+ # A MIME type (`type/subtype`) at the start of `encodingFormat`; a bare format name such as `mp3` maps
40
+ # through {Article::Enclosure::MEDIA_TYPES}.
41
+ MIME_TYPE = %r{\A[a-z]+/[a-z0-9][\w.+-]*}i
42
+
43
+ # Schema.org entity types that describe who or what something is, never a piece of content; a
44
+ # ListItem-wrapped object typed only with these is not a feed item. Organization and Place subtypes
45
+ # are listed because the wire form carries the subtype, not the parent.
46
+ ENTITY_TYPES = Set[
47
+ 'Person',
48
+ 'Organization', 'Corporation', 'NGO', 'LocalBusiness', 'GovernmentOrganization',
49
+ 'EducationalOrganization', 'NewsMediaOrganization', 'Brand',
50
+ 'Product', 'ProductGroup', 'Offer',
51
+ 'Place', 'City', 'Country', 'PostalAddress'
52
+ ].freeze
53
+
54
+ # Schema.org types emitted only as elements of an ItemList (never standalone).
55
+ ITEM_LIST_TYPES = Set['Event', 'JobPosting'].freeze
56
+
22
57
  # @param schema_object [Hash{Symbol => Object}] parsed schema.org object
23
58
  # @param url [String, Html2rss::Url, nil] base URL used for relative normalization
24
59
  def initialize(schema_object, url:)
@@ -26,9 +61,10 @@ module Html2rss
26
61
  @base_url = normalized_base_url(url)
27
62
  end
28
63
 
29
- # @return [Hash{Symbol => Object}] scraped article attributes
64
+ # @return [Hash{Symbol => Object}] scraped article attributes; a media object's carry `media: true`
30
65
  def call
31
- { id:, title:, description:, author:, url:, image:, published_at:, categories: }
66
+ item = { id:, title:, description:, author:, url:, image:, published_at:, categories:, enclosures: }
67
+ own_media? ? item.merge(media: true) : item
32
68
  end
33
69
 
34
70
  # @return [String, nil] stable schema object identifier
@@ -43,31 +79,29 @@ module Html2rss
43
79
  def title
44
80
  schema_object.values_at(:headline, :name, :alternativeHeadline, :title)
45
81
  .lazy
46
- .map { _1.to_s.strip }
82
+ .map { first_scalar(_1).to_s.strip }
47
83
  .find { !_1.empty? }
48
84
  end
49
85
 
50
86
  # @return [String, nil] longest available description field
51
87
  def description
52
- schema_object.values_at(:description, :articleBody, :abstract, :schema_object_body).max_by { _1.to_s.size }
88
+ schema_object.values_at(:description, :articleBody, :abstract, :schema_object_body)
89
+ .map { first_scalar(_1) }
90
+ .max_by { _1.to_s.size }
53
91
  end
54
92
 
55
93
  # @return [String, nil] extracted author or publisher name
56
94
  def author
57
- extract_author_name(schema_object[:author]) || extract_author_name(schema_object[:publisher])
95
+ extract_author_name(schema_object[:author]) || extract_author_name(schema_object[:publisher]) ||
96
+ extract_author_name(schema_object[:hiringOrganization]) ||
97
+ extract_author_name(schema_object[:organizer])
58
98
  end
59
99
 
60
100
  # @return [Html2rss::Url, nil] the URL of the schema object
61
101
  def url
62
102
  return @url if defined?(@url)
63
103
 
64
- url = schema_object[:url]
65
- if url.to_s.empty?
66
- Log.debug("Schema#Thing.url: no url in schema_object: #{schema_object.inspect}")
67
- return @url = nil
68
- end
69
-
70
- @url = Url.from_relative(url, base_url || url)
104
+ @url = build_url(first_scalar(schema_object[:url])) || fallback_url
71
105
  end
72
106
 
73
107
  # @return [Html2rss::Url, nil] normalized article image URL
@@ -78,10 +112,11 @@ module Html2rss
78
112
  @image = img_url ? Url.from_relative(img_url, base_url || img_url) : nil
79
113
  end
80
114
 
81
- # @return [String, nil] published-at timestamp (datePublished, else dateModified)
115
+ # @return [String, nil] first non-empty of datePublished, datePosted, dateCreated, dateModified, uploadDate
82
116
  def published_at
83
- value = schema_object[:datePublished] || schema_object[:dateModified]
84
- value.to_s.empty? ? nil : value
117
+ schema_object.values_at(:datePublished, :datePosted, :dateCreated, :dateModified, :uploadDate)
118
+ .map { first_scalar(_1) }
119
+ .find { !_1.to_s.empty? }
85
120
  end
86
121
 
87
122
  # @return [Array<String>, nil] extracted category labels
@@ -89,15 +124,95 @@ module Html2rss
89
124
  @categories ||= CategoryExtractor.call(schema_object)
90
125
  end
91
126
 
127
+ # @return [Array<Hash{Symbol => Object}>, nil] enclosures from the `contentUrl` of nested media objects
128
+ # ({MEDIA_KEYS}) and of a media object itself, with declared size and duration; `embedUrl` is never one
129
+ def enclosures
130
+ nodes = schema_object.values_at(*MEDIA_KEYS).flatten
131
+ nodes << schema_object if own_media?
132
+ found = nodes.filter_map { media_enclosure(_1) }
133
+ found unless found.empty?
134
+ end
135
+
92
136
  attr_reader :schema_object, :base_url
93
137
 
94
138
  # @return [Array<String>] normalized image URL candidates
95
139
  def image_urls
96
- @image_urls ||= schema_object.values_at(:image, :thumbnailUrl).filter_map { image_url_from(_1) }
140
+ @image_urls ||= schema_object.values_at(:image, :thumbnailUrl, :thumbnail).filter_map { image_url_from(_1) }
97
141
  end
98
142
 
99
143
  private
100
144
 
145
+ # @return [Boolean] whether the object itself is a media object (an item marked media)
146
+ def own_media? = Schema.normalize_types(schema_object[:@type]).intersect?(MEDIA_TYPES)
147
+
148
+ # @param node [Object] media object (VideoObject, AudioObject, MediaObject) or the media item itself
149
+ # @return [Hash{Symbol => Object}, nil] enclosure attributes, or nil without an http(s) contentUrl
150
+ # (an ImageObject is never one: images are {#image})
151
+ def media_enclosure(node)
152
+ return unless node.is_a?(Hash) && (src = first_scalar(node[:contentUrl])).is_a?(String)
153
+ return if (types = Schema.normalize_types(node[:@type])).include?('ImageObject')
154
+
155
+ kind = types.filter_map { MEDIA_KINDS[_1] }.first
156
+ type = mime_type(node[:encodingFormat])
157
+ Html2rss::Html::ArticleRules::Enclosure.from_media(src, type, base_url, kind:)&.merge(file_facts(node))
158
+ end
159
+
160
+ # @param node [Hash] media object
161
+ # @return [Hash{Symbol => Integer, nil}] its byte length and playback seconds, the duration else that of
162
+ # the item itself (a PodcastEpisode's duration)
163
+ def file_facts(node)
164
+ duration = Article::Enclosure.parse_duration(node[:duration]) ||
165
+ Article::Enclosure.parse_duration(schema_object[:duration])
166
+ { bytes_length: Article::Enclosure.parse_size(node[:contentSize]), duration: }
167
+ end
168
+
169
+ # @param format [Object] encodingFormat
170
+ # @return [String, nil] its lowercase MIME type, or the playable type of a media format name (+mp3+,
171
+ # +.m4a+); nil for any other name, so the file name decides
172
+ def mime_type(format)
173
+ return unless format.is_a?(String)
174
+
175
+ format[MIME_TYPE]&.downcase || Article::Enclosure::MEDIA_TYPES[format.strip.downcase.delete_prefix('.')]
176
+ end
177
+
178
+ # @param value [Object] url candidate
179
+ # @return [Html2rss::Url, nil] absolute url, or nil when missing or unusable
180
+ def build_url(value)
181
+ return Url.from_relative(value, base_url || value) if value.is_a?(String) && !value.empty?
182
+
183
+ Log.debug("Schema#Thing.url: no usable url in schema_object: #{schema_object.inspect}")
184
+ nil
185
+ rescue Url::InvalidUrlError
186
+ Log.debug("Schema#Thing.url: unusable url #{value.inspect}")
187
+ nil
188
+ end
189
+
190
+ # @return [Html2rss::Url, nil] first absolute http(s) mainEntityOfPage or @id, without fragment; never
191
+ # `contentUrl` (a file) or `embedUrl` (a player page)
192
+ def fallback_url
193
+ main = schema_object[:mainEntityOfPage]
194
+ candidates = main.is_a?(Hash) ? [main[:@id], main[:url]] : [main]
195
+ (candidates << schema_object[:@id]).filter_map { web_url(_1) }.first
196
+ end
197
+
198
+ # @param value [Object] url candidate
199
+ # @return [Html2rss::Url, nil] fragment-free url when value is an absolute http(s) string
200
+ def web_url(value)
201
+ return unless value.is_a?(String) && value.match?(%r{\Ahttps?://}i)
202
+
203
+ Url.from_absolute(value.strip).without_fragment
204
+ rescue Url::InvalidUrlError
205
+ nil
206
+ end
207
+
208
+ # @param value [Object] schema value that may be wrapped in a list (multilingual or multi-valued fields)
209
+ # @return [Object, nil] the value, or the first non-empty String of a list
210
+ def first_scalar(value)
211
+ return value unless value.is_a?(Array)
212
+
213
+ value.find { _1.is_a?(String) && !_1.strip.empty? }
214
+ end
215
+
101
216
  # @param obj [String, Hash, Array, nil] schema author or publisher field
102
217
  # @return [String, nil] extracted author name
103
218
  def extract_author_name(obj)
@@ -110,12 +225,15 @@ module Html2rss
110
225
  end
111
226
  end
112
227
 
113
- # @param obj [String, Hash, nil] image node
114
- # @return [String, nil] image URL string
228
+ # @param obj [String, Hash, Array, nil] image node or list of image nodes
229
+ # @return [String, nil] image URL string (first usable entry for lists)
115
230
  def image_url_from(obj)
116
231
  return obj if obj.is_a?(String)
232
+ return obj.filter_map { image_url_from(_1) }.first if obj.is_a?(Array)
117
233
  return unless obj.is_a?(Hash)
118
- return unless Schema.normalize_types(obj[:@type]).include?('ImageObject')
234
+
235
+ types = Schema.normalize_types(obj[:@type])
236
+ return unless types.empty? || types.include?('ImageObject')
119
237
 
120
238
  obj[:url] || obj[:contentUrl]
121
239
  end
@@ -129,7 +247,7 @@ module Html2rss
129
247
 
130
248
  norm_url = normalized_id_url(text, reference_url:)
131
249
  reference_url && norm_url.host == reference_url.host ? normalized_id_value(norm_url) : text
132
- rescue ArgumentError
250
+ rescue Html2rss::Url::InvalidUrlError
133
251
  text
134
252
  end
135
253
 
@@ -153,7 +271,7 @@ module Html2rss
153
271
  # @return [Html2rss::Url, nil] normalized absolute URL for schema resolution
154
272
  def normalized_base_url(url)
155
273
  Url.from_absolute(url) unless url.to_s.strip.empty?
156
- rescue ArgumentError
274
+ rescue Html2rss::Url::InvalidUrlError
157
275
  nil
158
276
  end
159
277
  end
@@ -25,14 +25,15 @@ module Html2rss
25
25
 
26
26
  # Canonical Schema.org type names keyed by folded wire forms (case-insensitive lookup).
27
27
  CANONICAL_BY_DOWNCASE = begin
28
- canonical_types = Thing::SUPPORTED_TYPES | ItemList::SUPPORTED_TYPES | DENIED_CONTAINER_TYPES | Set['Product']
28
+ canonical_types = Thing::ITEM_TYPES | ItemList::SUPPORTED_TYPES | Thing::ITEM_LIST_TYPES |
29
+ DENIED_CONTAINER_TYPES | Set['Product']
29
30
  canonical_types.to_h { |type| [::Html2rss::Html::Probe.fold(type), type] }.freeze
30
31
  end.freeze
31
32
 
32
33
  # Pre-compiled regex for supported schema types (short name or schema.org URL; string or array @type).
33
34
  # Allows preceding entries in a JSON @type array (e.g. ["WebPage","NewsArticle"]).
34
35
  SUPPORTED_TYPES_RE = begin
35
- types = Thing::SUPPORTED_TYPES | ItemList::SUPPORTED_TYPES
36
+ types = Thing::ITEM_TYPES | ItemList::SUPPORTED_TYPES
36
37
  type_re = Regexp.union(types.to_a)
37
38
  %r{(?i)"@type"\s*:\s*(?:\[\s*(?:"[^"]*"\s*,\s*)*)?"(?:https?://schema\.org/)?(?:#{type_re.source})"}
38
39
  end.freeze
@@ -43,6 +44,9 @@ module Html2rss
43
44
  # Shared empty type set for nil/unsupported wire forms (avoids per-call Set.new).
44
45
  EMPTY_TYPES = Set.new.freeze
45
46
 
47
+ # Keys whose `{"@id": ...}` stubs are resolved against sibling nodes of the same JSON-LD document.
48
+ REFERENCE_KEYS = (%i[author publisher image] + Thing::MEDIA_KEYS).freeze
49
+
46
50
  # @return [Symbol] scraper config key
47
51
  def self.options_key = :schema
48
52
 
@@ -84,6 +88,18 @@ module Html2rss
84
88
  end
85
89
  end
86
90
 
91
+ ##
92
+ # Like {from} for a script tag, but resolves `@id` stubs for {REFERENCE_KEYS} against the same document.
93
+ #
94
+ # @param script [Nokogiri::XML::Element] schema JSON-LD script tag
95
+ # @return [Array<Hash>] the schema_objects with resolved references
96
+ # @api private
97
+ def from_script(script)
98
+ document = parse_script_tag(script)
99
+ index = node_index(document)
100
+ from(document).map { |object| resolve_references(object, index) }
101
+ end
102
+
87
103
  # @param object [Hash{Symbol => Object}] schema candidate object
88
104
  # @return [Boolean] whether an extractor exists for the candidate object
89
105
  def supported_schema_object?(object)
@@ -99,7 +115,7 @@ module Html2rss
99
115
  # Prefer article/list extractors over denied containers for multi-type @type arrays
100
116
  # (e.g. ["NewsArticle","WebPage"]).
101
117
  return ItemList if types.intersect?(ItemList::SUPPORTED_TYPES)
102
- return Thing if types.intersect?(Thing::SUPPORTED_TYPES)
118
+ return Thing if types.intersect?(Thing::ITEM_TYPES)
103
119
  return nil if types.intersect?(DENIED_CONTAINER_TYPES)
104
120
 
105
121
  Log.debug("#{name}: unsupported schema object @type=#{schema_object[:@type].inspect}")
@@ -139,6 +155,37 @@ module Html2rss
139
155
 
140
156
  private
141
157
 
158
+ # @param object [Hash, Array, Object] parsed JSON-LD value
159
+ # @param index [Hash{String => Hash}] accumulator of nodes (more than just `@id`) keyed by `@id`
160
+ # @return [Hash{String => Hash}] nodes keyed by `@id`
161
+ def node_index(object, index = {})
162
+ case object
163
+ when Hash
164
+ index[object[:@id]] ||= object if object[:@id].is_a?(String) && object.size > 1
165
+ object.each_value { node_index(_1, index) }
166
+ when Array then object.each { node_index(_1, index) }
167
+ end
168
+ index
169
+ end
170
+
171
+ # @param object [Hash{Symbol => Object}] schema object
172
+ # @param index [Hash{String => Hash}] nodes keyed by `@id`
173
+ # @return [Hash{Symbol => Object}] object with reference stubs replaced where a sibling node exists
174
+ def resolve_references(object, index)
175
+ object.to_h { |key, value| [key, REFERENCE_KEYS.include?(key) ? resolve_stub(value, index) : value] }
176
+ end
177
+
178
+ # @param value [Object] author, publisher or image value
179
+ # @param index [Hash{String => Hash}] nodes keyed by `@id`
180
+ # @return [Object] the referenced node for an `{"@id": ...}` stub, else the value unchanged
181
+ def resolve_stub(value, index)
182
+ case value
183
+ when Array then value.map { resolve_stub(_1, index) }
184
+ when Hash then value.keys == [:@id] ? index.fetch(value[:@id], value) : value
185
+ else value
186
+ end
187
+ end
188
+
142
189
  # @param hash [Hash] candidate schema object
143
190
  # @return [Array<Hash>] the object itself or nested supported objects
144
191
  def from_hash(hash)
@@ -167,12 +214,9 @@ module Html2rss
167
214
 
168
215
  # @param parsed_body [Nokogiri::HTML::Document] parsed HTML document
169
216
  # @param url [String, Html2rss::Url] base page URL
170
- # @param opts [Hash] scraper-specific options
171
- # @option opts [Object] :_reserved reserved for future scraper-specific options
172
- def initialize(parsed_body, url:, **opts)
217
+ def initialize(parsed_body, url:)
173
218
  @parsed_body = parsed_body
174
219
  @url = url
175
- @opts = opts
176
220
  end
177
221
 
178
222
  # @return [Boolean]
@@ -200,7 +244,7 @@ module Html2rss
200
244
 
201
245
  def schema_objects
202
246
  ::Html2rss::Html::Probe.scripts(@parsed_body, ::Html2rss::Html::Probe::APPLICATION_LD_JSON)
203
- .flat_map { |tag| Schema.from(tag) }
247
+ .flat_map { |tag| Schema.from_script(tag) }
204
248
  end
205
249
 
206
250
  attr_reader :parsed_body, :url
@@ -33,12 +33,8 @@ module Html2rss
33
33
  # @return [Html2rss::Article, nil]
34
34
  def article_for(entry)
35
35
  @article_cache.fetch(entry) do
36
- @article_cache[entry] = ::Html2rss::Html::SstArticleExtractor.call(
37
- entry,
38
- base_url: @url,
39
- scraper: @scraper,
40
- fallback_anchorless: true
41
- )
36
+ @article_cache[entry] =
37
+ ::Html2rss::Html::SstArticleExtractor.call(entry, base_url: @url, scraper: @scraper)
42
38
  end
43
39
  end
44
40
 
@@ -83,9 +79,7 @@ module Html2rss
83
79
  ]
84
80
  end
85
81
 
86
- def word_count(text)
87
- (@word_counts ||= {})[text] ||= text.to_s.scan(/\p{Alnum}+/).size
88
- end
82
+ def word_count(text) = (@word_counts ||= {})[text] ||= WordCount.count(text)
89
83
  end
90
84
  end
91
85
  end
@@ -26,22 +26,17 @@ module Html2rss
26
26
  return false unless parsed_body
27
27
 
28
28
  new(parsed_body, url: 'https://example.com').extractable?
29
- rescue ArgumentError
30
- false
31
29
  end
32
30
 
33
31
  # @param parsed_body [Nokogiri::HTML::Document, nil] parsed HTML (when +document:+ omitted)
34
32
  # @param url [String, Html2rss::Url] base url
35
33
  # @param document [SST::Document, nil] memoized SST document from AutoSource
36
34
  # @param link_resolver [Scoring::LinkResolver, nil] shared page-scoped resolver
37
- # @param opts [Hash] scraper-specific options
38
- # @option opts [Boolean] :fallback_anchorless whether to keep containers without a primary anchor
39
- def initialize(parsed_body = nil, url:, document: nil, link_resolver: nil, **opts)
35
+ def initialize(parsed_body = nil, url:, document: nil, link_resolver: nil)
40
36
  @parsed_body = parsed_body
41
37
  @provided_document = document
42
38
  @provided_link_resolver = link_resolver
43
39
  @url = url
44
- @permit_unanchored = opts.fetch(:fallback_anchorless, false)
45
40
  end
46
41
 
47
42
  ##
@@ -57,8 +52,6 @@ module Html2rss
57
52
  # @return [Boolean]
58
53
  def extractable?
59
54
  ranked_segments.any?
60
- rescue ArgumentError
61
- false
62
55
  end
63
56
 
64
57
  private
@@ -69,21 +62,20 @@ module Html2rss
69
62
  entries = deduplicator.call(ranked_segments)
70
63
  entries.filter_map { |ranked| deduplicator.article_for(ranked) }
71
64
  end
72
- rescue ArgumentError
73
- []
74
65
  end
75
66
 
76
67
  def ranked_segments
77
- @ranked_segments ||= begin
78
- segments = Segmenter.call(
79
- document,
80
- base_url: @url,
81
- strategy: :semantic,
82
- permit_unanchored: @permit_unanchored,
83
- link_resolver:
84
- )
85
- Scoring::Engine.new(link_resolver:).rank_top(segments, limit: TOP_K)
86
- end
68
+ @ranked_segments ||= document ? rank_document : []
69
+ end
70
+
71
+ def rank_document
72
+ segments = Segmenter.call(
73
+ document,
74
+ base_url: @url,
75
+ strategy: :semantic,
76
+ link_resolver:
77
+ )
78
+ Scoring::Engine.new(link_resolver:).rank_top(segments, limit: TOP_K)
87
79
  end
88
80
 
89
81
  def link_resolver
@@ -149,10 +149,7 @@ module Html2rss
149
149
  def stale_date?(published_at)
150
150
  return false unless published_at
151
151
 
152
- time = Time.parse(published_at)
153
- (Time.now.utc - time) > (max_age_days * 86_400)
154
- rescue ArgumentError
155
- false
152
+ (Time.now.utc - Time.parse(published_at)) > (max_age_days * 86_400)
156
153
  end
157
154
  end
158
155
  end